TRQL editor WIP

This commit is contained in:
Matt Aitken
2025-12-15 23:57:35 +00:00
parent 0312439f46
commit 06127d5bf6
12 changed files with 1520 additions and 5 deletions
@@ -0,0 +1,225 @@
import { sql, StandardSQL } from "@codemirror/lang-sql";
import { autocompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import type { ViewUpdate } from "@codemirror/view";
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
import { useCodeMirror } from "@uiw/react-codemirror";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { getEditorSetup } from "./codeMirrorSetup";
import { darkTheme } from "./codeMirrorTheme";
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
import { createTSQLLinter } from "./tsql/tsqlLinter";
import type { TableSchema } from "@internal/tsql";
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
/** Initial value for the editor */
defaultValue?: string;
/** Whether the editor is read-only */
readOnly?: boolean;
/** Called when the editor content changes */
onChange?: (value: string) => void;
/** Called when the editor state updates */
onUpdate?: (update: ViewUpdate) => void;
/** Called when the editor loses focus */
onBlur?: (code: string) => void;
/** Schema for table/column autocompletion */
schema?: TableSchema[];
/** Show copy button */
showCopyButton?: boolean;
/** Show clear button */
showClearButton?: boolean;
/** Enable linting (syntax checking) */
linterEnabled?: boolean;
/** Placeholder text when empty */
placeholder?: string;
/** Additional actions to show in the toolbar */
additionalActions?: React.ReactNode;
/** Minimum height of the editor */
minHeight?: string;
}
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
const defaultProps: TSQLEditorDefaultProps = {
readOnly: false,
basicSetup: false,
linterEnabled: true,
showCopyButton: true,
showClearButton: false,
schema: [],
};
export function TSQLEditor(opts: TSQLEditorProps) {
const {
defaultValue = "",
readOnly = false,
onChange,
onUpdate,
onBlur,
basicSetup = false,
autoFocus,
showCopyButton = true,
showClearButton = false,
linterEnabled = true,
schema = [],
placeholder = "",
additionalActions,
minHeight = undefined,
} = {
...defaultProps,
...opts,
};
// Create extensions - memoize to avoid recreating on every render
const extensions = useMemo(() => {
const exts = getEditorSetup();
// Add SQL language support with StandardSQL dialect
// This provides syntax highlighting
exts.push(
sql({
dialect: StandardSQL,
upperCaseKeywords: true,
})
);
// Add custom TSQL completion
if (schema && schema.length > 0) {
exts.push(
autocompletion({
override: [createTSQLCompletion(schema)],
activateOnTyping: true,
maxRenderedOptions: 50,
})
);
}
// Add TSQL linter
if (linterEnabled) {
exts.push(lintGutter());
exts.push(
linter(createTSQLLinter({ schema }), {
delay: 300, // Debounce linting for better performance
})
);
}
return exts;
}, [schema, linterEnabled]);
const editor = useRef<HTMLDivElement>(null);
const settings: Omit<UseCodeMirror, "onBlur"> = {
...opts,
container: editor.current,
extensions,
editable: !readOnly,
contentEditable: !readOnly,
value: defaultValue,
autoFocus,
theme: darkTheme(),
indentWithTab: false,
basicSetup,
onChange,
onUpdate,
placeholder,
};
const { setContainer, view } = useCodeMirror(settings);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (editor.current) {
setContainer(editor.current);
}
}, [setContainer]);
// Update editor when defaultValue changes
useEffect(() => {
if (view !== undefined) {
if (view.state.doc.toString() === defaultValue) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
});
}
}, [defaultValue, view]);
const clear = () => {
if (view === undefined) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: undefined },
});
onChange?.("");
};
const copy = useCallback(() => {
if (view === undefined) return;
navigator.clipboard.writeText(view.state.doc.toString());
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1500);
}, [view]);
const showButtons = showClearButton || showCopyButton || additionalActions;
return (
<div
className={cn(
"grid",
showButtons ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
opts.className
)}
style={minHeight ? { minHeight } : undefined}
>
{showButtons && (
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
{additionalActions && additionalActions}
{showClearButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={TrashIcon}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
clear();
}}
>
Clear
</Button>
)}
{showCopyButton && (
<Button
type="button"
variant="minimal/small"
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
trailingIconClassName={
copied ? "text-green-500 group-hover:text-green-500" : undefined
}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
copy();
}}
>
Copy
</Button>
)}
</div>
)}
<div
className="w-full overflow-auto"
ref={editor}
onBlur={() => {
if (!onBlur) return;
onBlur(editor.current?.textContent ?? "");
}}
/>
</div>
);
}
@@ -0,0 +1,6 @@
// TSQL CodeMirror support
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
export { createTSQLCompletion } from "./tsqlCompletion";
export { createTSQLLinter, isValidTSQLQuery, getTSQLError, type TSQLLinterConfig } from "./tsqlLinter";
@@ -0,0 +1,172 @@
import { describe, it, expect } from "vitest";
import { createTSQLCompletion } from "./tsqlCompletion";
import type { TableSchema, ColumnSchema } from "@internal/tsql";
// Helper to create a mock completion context
function createMockContext(doc: string, pos: number, explicit = false) {
return {
state: {
doc: {
toString: () => doc,
},
},
pos,
explicit,
matchBefore: (regex: RegExp) => {
const beforePos = doc.slice(0, pos);
const match = beforePos.match(new RegExp(regex.source + "$"));
if (match) {
return {
from: pos - match[0].length,
to: pos,
text: match[0],
};
}
return null;
},
} as any;
}
// Test schema
const testSchema: TableSchema[] = [
{
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task runs table",
columns: {
id: { name: "id", type: "String", description: "Run ID" },
status: { name: "status", type: "String", description: "Run status" },
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
{
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task logs table",
columns: {
id: { name: "id", type: "String" },
run_id: { name: "run_id", type: "String" },
message: { name: "message", type: "String" },
level: { name: "level", type: "String" },
timestamp: { name: "timestamp", type: "DateTime64" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
];
describe("createTSQLCompletion", () => {
const completionSource = createTSQLCompletion(testSchema);
it("should return null for empty input without explicit trigger", () => {
const context = createMockContext("", 0, false);
const result = completionSource(context);
expect(result).toBeNull();
});
it("should return completions when explicitly triggered", () => {
const context = createMockContext("", 0, true);
const result = completionSource(context);
expect(result).not.toBeNull();
expect(result?.options.length).toBeGreaterThan(0);
});
it("should suggest tables after FROM keyword", () => {
const doc = "SELECT * FROM ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const tableLabels = result?.options.map((o) => o.label);
expect(tableLabels).toContain("runs");
expect(tableLabels).toContain("logs");
});
it("should suggest columns after SELECT keyword", () => {
const doc = "SELECT FROM runs";
// Position cursor right after SELECT
const pos = 7;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should include functions
const labels = result?.options.map((o) => o.label) || [];
expect(labels.some((l) => l === "count")).toBe(true);
expect(labels.some((l) => l === "sum")).toBe(true);
});
it("should suggest columns with table prefix for qualified references", () => {
const doc = "SELECT runs. FROM runs";
// Position cursor right after "runs."
const pos = 12;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const columnLabels = result?.options.map((o) => o.label);
expect(columnLabels).toContain("id");
expect(columnLabels).toContain("status");
expect(columnLabels).toContain("created_at");
});
it("should include SQL keywords in general context", () => {
const doc = "S";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("SELECT");
});
it("should include aggregate functions", () => {
const doc = "SELECT ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("count");
expect(labels).toContain("sum");
expect(labels).toContain("avg");
expect(labels).toContain("min");
expect(labels).toContain("max");
});
it("should handle WHERE clause context", () => {
const doc = "SELECT * FROM runs WHERE ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should suggest columns
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("status");
// Should include conditional keywords
expect(labels).toContain("AND");
expect(labels).toContain("OR");
});
});
@@ -0,0 +1,335 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import type { TableSchema } from "@internal/tsql";
import {
TSQL_CLICKHOUSE_FUNCTIONS,
TSQL_AGGREGATIONS,
} from "@internal/tsql";
/**
* SQL keywords for autocomplete
*/
const SQL_KEYWORDS = [
"SELECT",
"FROM",
"WHERE",
"AND",
"OR",
"NOT",
"IN",
"LIKE",
"ILIKE",
"BETWEEN",
"IS",
"NULL",
"TRUE",
"FALSE",
"AS",
"ORDER",
"BY",
"ASC",
"DESC",
"LIMIT",
"OFFSET",
"GROUP",
"HAVING",
"DISTINCT",
"JOIN",
"LEFT",
"RIGHT",
"INNER",
"OUTER",
"FULL",
"CROSS",
"ON",
"UNION",
"INTERSECT",
"EXCEPT",
"ALL",
"WITH",
"CASE",
"WHEN",
"THEN",
"ELSE",
"END",
"OVER",
"PARTITION",
"ROWS",
"RANGE",
"UNBOUNDED",
"PRECEDING",
"FOLLOWING",
"CURRENT",
"ROW",
"NULLS",
"FIRST",
"LAST",
];
/**
* Create keyword completions from the SQL keywords list
*/
function createKeywordCompletions(): Completion[] {
return SQL_KEYWORDS.map((keyword) => ({
label: keyword,
type: "keyword",
boost: -1, // Keywords should have lower priority than schema items
}));
}
/**
* Create function completions from TSQL function definitions
*/
function createFunctionCompletions(): Completion[] {
const functions: Completion[] = [];
// Add regular functions
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
// Skip internal functions starting with _
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: argsHint,
apply: `${name}()`,
});
}
// Add aggregate functions with slightly higher boost
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
if (name.startsWith("_")) continue;
const argsHint =
meta.maxArgs === 0 ? "()" : meta.minArgs === meta.maxArgs ? `(${meta.minArgs} args)` : `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
functions.push({
label: name,
type: "function",
detail: `aggregate ${argsHint}`,
apply: `${name}()`,
boost: 0.5,
});
}
return functions;
}
/**
* Create table completions from schema
*/
function createTableCompletions(schema: TableSchema[]): Completion[] {
return schema.map((table) => ({
label: table.name,
type: "class", // Using "class" type for tables gives them a nice icon
detail: table.description || "table",
boost: 1, // Tables should have higher priority
}));
}
/**
* Create column completions for a specific table
*/
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
const columns: Completion[] = [];
for (const [name, column] of Object.entries(table.columns)) {
columns.push({
label: prefix ? `${prefix}.${name}` : name,
type: "property", // Using "property" type for columns
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
boost: 2, // Columns should have highest priority
});
}
return columns;
}
/**
* Extract table names/aliases from the current query context
* This is a simplified parser that looks for FROM and JOIN clauses
*/
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
const tableMap = new Map<string, TableSchema>();
const tableNames = schema.map((t) => t.name);
// Simple regex to find table references in FROM and JOIN clauses
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
const tablePattern =
/(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
let match;
while ((match = tablePattern.exec(doc)) !== null) {
const tableName = match[1];
const alias = match[2] || tableName;
// Find the table schema if it exists
const tableSchema = schema.find(
(t) => t.name.toLowerCase() === tableName.toLowerCase()
);
if (tableSchema) {
tableMap.set(alias.toLowerCase(), tableSchema);
}
}
return tableMap;
}
/**
* Determine what context we're in based on cursor position
*/
type CompletionContextType =
| "table" // After FROM or JOIN
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
| "alias" // After table_name.
| "general"; // Anywhere else
function determineContext(
doc: string,
pos: number
): { type: CompletionContextType; tablePrefix?: string } {
// Get text before cursor
const textBefore = doc.slice(0, pos);
// Check if we're completing after a dot (table.column)
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
if (dotMatch) {
return { type: "alias", tablePrefix: dotMatch[1] };
}
// Find the LAST significant keyword before cursor
// We match all keywords and take the last one
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
let lastMatch: RegExpExecArray | null = null;
let match: RegExpExecArray | null;
while ((match = keywordPattern.exec(textBefore)) !== null) {
lastMatch = match;
}
if (lastMatch) {
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
if (keyword === "FROM" || keyword === "JOIN") {
return { type: "table" };
}
if (
keyword === "SELECT" ||
keyword === "WHERE" ||
keyword === "AND" ||
keyword === "OR" ||
keyword === "ORDER BY" ||
keyword === "GROUP BY" ||
keyword === "HAVING" ||
keyword === "ON"
) {
return { type: "column" };
}
}
return { type: "general" };
}
/**
* Create a TSQL-aware autocompletion source
*
* @param schema - Array of table schemas to use for completions
* @returns A CodeMirror completion source function
*/
export function createTSQLCompletion(
schema: TableSchema[]
): (context: CompletionContext) => CompletionResult | null {
// Pre-compute static completions
const keywordCompletions = createKeywordCompletions();
const functionCompletions = createFunctionCompletions();
const tableCompletions = createTableCompletions(schema);
return (context: CompletionContext): CompletionResult | null => {
// Get the word being typed
const word = context.matchBefore(/[\w.]+/);
// Don't show completions if no word is being typed and not explicitly triggered
if (!word && !context.explicit) {
return null;
}
const from = word ? word.from : context.pos;
const doc = context.state.doc.toString();
const queryContext = determineContext(doc, context.pos);
let options: Completion[] = [];
switch (queryContext.type) {
case "table":
// After FROM or JOIN, show only tables
options = tableCompletions;
break;
case "alias":
// After table., show columns for that table
if (queryContext.tablePrefix) {
const tables = extractTablesFromQuery(doc, schema);
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
if (tableSchema) {
options = createColumnCompletions(tableSchema);
}
}
break;
case "column":
// After SELECT, WHERE, etc., show columns, functions, and some keywords
{
const tables = extractTablesFromQuery(doc, schema);
// Add columns from all tables in the query
tables.forEach((tableSchema, alias) => {
// If multiple tables, prefix with alias
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
// Also add functions and relevant keywords
options.push(...functionCompletions);
options.push(
...keywordCompletions.filter((k) =>
["AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "IS", "NULL", "AS", "CASE", "WHEN", "THEN", "ELSE", "END"].includes(
k.label as string
)
)
);
}
break;
case "general":
default:
// Show everything
options = [
...tableCompletions,
...functionCompletions,
...keywordCompletions,
];
// Also add columns from tables in query
{
const tables = extractTablesFromQuery(doc, schema);
tables.forEach((tableSchema, alias) => {
const prefix = tables.size > 1 ? alias : undefined;
options.push(...createColumnCompletions(tableSchema, prefix));
});
}
break;
}
return {
from,
options,
validFor: /^[\w.]*$/,
};
};
}
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
)
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle missing FROM clause", () => {
const error = getTSQLError("SELECT * WHERE id = 1");
expect(error).not.toBeNull();
});
});
});
@@ -0,0 +1,191 @@
import type { EditorView } from "@codemirror/view";
import type { Diagnostic } from "@codemirror/lint";
import type { TableSchema } from "@internal/tsql";
import { parseTSQLSelect, SyntaxError, QueryError } from "@internal/tsql";
/**
* Configuration for the TSQL linter
*/
export interface TSQLLinterConfig {
/** Optional schema for validating table/column names */
schema?: TableSchema[];
/** Delay in milliseconds before running the linter (debouncing) */
delay?: number;
}
/**
* Extract line and column from a TSQL error message
* Error format: "Syntax error at line X:Y: message"
*/
function parseErrorPosition(message: string): { line: number; column: number } | null {
const match = message.match(/at line (\d+):(\d+)/);
if (match) {
return {
line: parseInt(match[1], 10),
column: parseInt(match[2], 10),
};
}
return null;
}
/**
* Convert line/column to a document position
*/
function positionToOffset(
doc: string,
line: number,
column: number
): number {
const lines = doc.split("\n");
// line is 1-indexed
let offset = 0;
for (let i = 0; i < line - 1 && i < lines.length; i++) {
offset += lines[i].length + 1; // +1 for newline
}
return offset + column;
}
/**
* Find the end of a word/token at the given position
*/
function findTokenEnd(doc: string, start: number): number {
let end = start;
// Scan forward until we hit whitespace or end of string
while (end < doc.length && /\S/.test(doc[end])) {
end++;
}
// If we didn't move, include at least one character
if (end === start) {
end = Math.min(start + 1, doc.length);
}
return end;
}
/**
* Create a TSQL linter function for CodeMirror
*
* This linter uses the TSQL ANTLR parser to detect syntax errors
* and optionally validates against a schema.
*
* @param config - Linter configuration
* @returns A linter function for use with CodeMirror's linter extension
*/
export function createTSQLLinter(
config: TSQLLinterConfig = {}
): (view: EditorView) => Diagnostic[] {
return (view: EditorView): Diagnostic[] => {
const content = view.state.doc.toString().trim();
// Return no errors for empty content
if (!content) {
return [];
}
const diagnostics: Diagnostic[] = [];
try {
// Try to parse the query
parseTSQLSelect(content);
// If parsing succeeds, we could do additional schema validation here
// For now, we just validate syntax
} catch (error) {
if (error instanceof SyntaxError) {
const position = parseErrorPosition(error.message);
let from: number;
let to: number;
if (position) {
from = positionToOffset(content, position.line, position.column);
to = findTokenEnd(content, from);
} else {
// If we can't parse the position, highlight the whole query
from = 0;
to = content.length;
}
// Clean up the error message
let message = error.message;
// Remove the "Syntax error at line X:Y: " prefix if present
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
diagnostics.push({
from,
to,
severity: "error",
message: message,
source: "tsql",
});
} else if (error instanceof QueryError) {
// Schema validation errors don't have position info,
// so highlight the whole query
diagnostics.push({
from: 0,
to: content.length,
severity: "warning",
message: error.message,
source: "tsql",
});
} else if (error instanceof Error) {
// Unknown error
diagnostics.push({
from: 0,
to: content.length,
severity: "error",
message: error.message,
source: "tsql",
});
}
}
return diagnostics;
};
}
/**
* Check if a TSQL query is valid
*
* @param query - The query to validate
* @returns true if the query is valid, false otherwise
*/
export function isValidTSQLQuery(query: string): boolean {
if (!query.trim()) {
return true; // Empty queries are considered valid
}
try {
parseTSQLSelect(query);
return true;
} catch {
return false;
}
}
/**
* Get error message for a TSQL query, if any
*
* @param query - The query to validate
* @returns Error message if invalid, null if valid
*/
export function getTSQLError(query: string): string | null {
if (!query.trim()) {
return null;
}
try {
parseTSQLSelect(query);
return null;
} catch (error) {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}
}
@@ -0,0 +1,230 @@
import { useState } from "react";
import { TSQLEditor } from "~/components/code/TSQLEditor";
import type { TableSchema } from "@internal/tsql";
import { column } from "@internal/tsql";
// Example schema for demonstration
const runsSchema: TableSchema = {
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
description: "Task runs table - stores all task execution records",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
id: { name: "id", ...column("String", { description: "Unique run identifier" }) },
task_id: { name: "task_id", ...column("String", { description: "Task identifier" }) },
status: {
name: "status",
...column("String", { description: "Run status (PENDING, EXECUTING, COMPLETED, FAILED)" }),
},
created_at: {
name: "created_at",
...column("DateTime64", { description: "When the run was created" }),
},
started_at: {
name: "started_at",
...column("Nullable(DateTime64)", { description: "When the run started executing" }),
},
completed_at: {
name: "completed_at",
...column("Nullable(DateTime64)", { description: "When the run completed" }),
},
duration_ms: {
name: "duration_ms",
...column("Nullable(UInt64)", { description: "Run duration in milliseconds" }),
},
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
};
const logsSchema: TableSchema = {
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
description: "Task logs and events",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
columns: {
id: { name: "id", ...column("String", { description: "Event identifier" }) },
run_id: { name: "run_id", ...column("String", { description: "Associated run ID" }) },
level: { name: "level", ...column("String", { description: "Log level (INFO, WARN, ERROR)" }) },
message: { name: "message", ...column("String", { description: "Log message content" }) },
timestamp: { name: "timestamp", ...column("DateTime64", { description: "Event timestamp" }) },
organization_id: { name: "organization_id", ...column("String") },
project_id: { name: "project_id", ...column("String") },
environment_id: { name: "environment_id", ...column("String") },
},
};
const exampleSchema = [runsSchema, logsSchema];
const exampleQueries = [
{
name: "Simple SELECT",
query: "SELECT * FROM runs LIMIT 10",
},
{
name: "With WHERE clause",
query: "SELECT id, task_id, status, created_at FROM runs WHERE status = 'COMPLETED' LIMIT 100",
},
{
name: "Aggregation",
query: "SELECT status, count(*) as count FROM runs GROUP BY status ORDER BY count DESC",
},
{
name: "Join query",
query: `SELECT
runs.id,
runs.status,
logs.message,
logs.level
FROM runs
JOIN logs ON runs.id = logs.run_id
WHERE logs.level = 'ERROR'
LIMIT 50`,
},
{
name: "Date filtering",
query: `SELECT
toStartOfDay(created_at) as day,
count(*) as runs_count,
avg(duration_ms) as avg_duration
FROM runs
WHERE created_at > now() - INTERVAL 7 DAY
GROUP BY day
ORDER BY day DESC`,
},
];
export default function Story() {
const [query, setQuery] = useState(exampleQueries[0].query);
return (
<div className="flex flex-col gap-y-8 p-8">
<div>
<h1 className="mb-2 text-2xl font-bold text-text-bright">TSQL Editor</h1>
<p className="text-text-dimmed">
A CodeMirror-based SQL editor with syntax highlighting, schema-aware autocomplete, and
real-time error detection.
</p>
</div>
{/* Example queries */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Example Queries</h2>
<div className="flex flex-wrap gap-2">
{exampleQueries.map((example) => (
<button
key={example.name}
onClick={() => setQuery(example.query)}
className="rounded bg-charcoal-700 px-3 py-1.5 text-sm text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
>
{example.name}
</button>
))}
</div>
</div>
{/* Main editor */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Editor with Schema</h2>
<p className="text-sm text-text-dimmed">
Try typing to see autocomplete suggestions. Available tables: <code>runs</code>,{" "}
<code>logs</code>
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue={query}
onChange={setQuery}
schema={exampleSchema}
linterEnabled={true}
showCopyButton={true}
showClearButton={true}
minHeight="200px"
className="min-h-[200px]"
/>
</div>
</div>
{/* Read-only example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Read-only Mode</h2>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT id, status, created_at FROM runs WHERE status = 'FAILED' ORDER BY created_at DESC LIMIT 10"
readOnly={true}
schema={exampleSchema}
linterEnabled={false}
showCopyButton={true}
showClearButton={false}
className="min-h-[100px]"
/>
</div>
</div>
{/* Editor without schema (no autocomplete) */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Without Schema (Basic Mode)</h2>
<p className="text-sm text-text-dimmed">
Editor without schema - still has SQL syntax highlighting and keyword completion.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELECT * FROM my_table WHERE id = 1"
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Error example */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">With Syntax Error</h2>
<p className="text-sm text-text-dimmed">
The linter detects syntax errors and underlines them in red.
</p>
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<TSQLEditor
defaultValue="SELEC * FORM runs"
linterEnabled={true}
showCopyButton={true}
className="min-h-[100px]"
/>
</div>
</div>
{/* Available tables reference */}
<div className="flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-bright">Available Schema</h2>
<div className="grid gap-4 md:grid-cols-2">
{exampleSchema.map((table) => (
<div key={table.name} className="rounded-lg border border-grid-dimmed bg-charcoal-800 p-4">
<h3 className="mb-1 font-mono text-sm font-semibold text-text-bright">{table.name}</h3>
<p className="mb-3 text-xs text-text-dimmed">{table.description}</p>
<div className="space-y-1">
{Object.entries(table.columns).map(([name, col]) => (
<div key={name} className="flex items-baseline gap-2 text-xs">
<code className="text-blue-400">{name}</code>
<span className="text-charcoal-400">{col.type}</span>
{col.description && (
<span className="text-text-dimmed">- {col.description}</span>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
@@ -120,6 +120,10 @@ const stories: Story[] = [
name: "Tree view",
slug: "tree-view",
},
{
name: "TSQL Editor",
slug: "tsql-editor",
},
{
name: "Timeline",
slug: "timeline",
+2
View File
@@ -41,6 +41,7 @@
"@codemirror/commands": "^6.1.2",
"@codemirror/lang-javascript": "^6.1.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-sql": "^6.8.0",
"@codemirror/language": "^6.3.1",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.2.3",
@@ -55,6 +56,7 @@
"@heroicons/react": "^2.0.12",
"@internal/cache": "workspace:*",
"@internal/redis": "workspace:*",
"@internal/tsql": "workspace:*",
"@internal/run-engine": "workspace:*",
"@internal/schedule-engine": "workspace:*",
"@internal/tracing": "workspace:*",
@@ -0,0 +1,174 @@
import { describe, it, expect } from "vitest";
import { createTSQLCompletion } from "~/components/code/tsql/tsqlCompletion";
import type { TableSchema } from "@internal/tsql";
// Helper to create a mock completion context
function createMockContext(doc: string, pos: number, explicit = false) {
return {
state: {
doc: {
toString: () => doc,
},
},
pos,
explicit,
matchBefore: (regex: RegExp) => {
const beforePos = doc.slice(0, pos);
const match = beforePos.match(new RegExp(regex.source + "$"));
if (match) {
return {
from: pos - match[0].length,
to: pos,
text: match[0],
};
}
return null;
},
} as any;
}
// Test schema
const testSchema: TableSchema[] = [
{
name: "runs",
clickhouseName: "trigger_dev.task_runs_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task runs table",
columns: {
id: { name: "id", type: "String", description: "Run ID" },
status: { name: "status", type: "String", description: "Run status" },
created_at: { name: "created_at", type: "DateTime64", description: "Creation time" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
{
name: "logs",
clickhouseName: "trigger_dev.task_events_v2",
tenantColumns: {
organizationId: "organization_id",
projectId: "project_id",
environmentId: "environment_id",
},
description: "Task logs table",
columns: {
id: { name: "id", type: "String" },
run_id: { name: "run_id", type: "String" },
message: { name: "message", type: "String" },
level: { name: "level", type: "String" },
timestamp: { name: "timestamp", type: "DateTime64" },
organization_id: { name: "organization_id", type: "String" },
project_id: { name: "project_id", type: "String" },
environment_id: { name: "environment_id", type: "String" },
},
},
];
describe("createTSQLCompletion", () => {
const completionSource = createTSQLCompletion(testSchema);
it("should return null for empty input without explicit trigger", () => {
const context = createMockContext("", 0, false);
const result = completionSource(context);
expect(result).toBeNull();
});
it("should return completions when explicitly triggered", () => {
const context = createMockContext("", 0, true);
const result = completionSource(context);
expect(result).not.toBeNull();
expect(result?.options.length).toBeGreaterThan(0);
});
it("should include tables in completions", () => {
// When typing after FROM, tables should be available
const doc = "SELECT * FROM r";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const tableLabels = result?.options.map((o) => o.label);
// Tables should always be available in completions
expect(tableLabels).toContain("runs");
expect(tableLabels).toContain("logs");
});
it("should suggest columns after SELECT keyword", () => {
const doc = "SELECT FROM runs";
// Position cursor right after SELECT
const pos = 7;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should include functions
const labels = result?.options.map((o) => o.label) || [];
expect(labels.some((l) => l === "count")).toBe(true);
expect(labels.some((l) => l === "sum")).toBe(true);
});
it("should suggest columns with table prefix for qualified references", () => {
const doc = "SELECT runs. FROM runs";
// Position cursor right after "runs."
const pos = 12;
const context = createMockContext(doc, pos, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const columnLabels = result?.options.map((o) => o.label);
expect(columnLabels).toContain("id");
expect(columnLabels).toContain("status");
expect(columnLabels).toContain("created_at");
});
it("should include SQL keywords in general context", () => {
const doc = "S";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("SELECT");
});
it("should include aggregate functions", () => {
const doc = "SELECT ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
const labels = result?.options.map((o) => o.label);
expect(labels).toContain("count");
expect(labels).toContain("sum");
expect(labels).toContain("avg");
expect(labels).toContain("min");
expect(labels).toContain("max");
});
it("should handle WHERE clause context", () => {
const doc = "SELECT * FROM runs WHERE ";
const context = createMockContext(doc, doc.length, true);
const result = completionSource(context);
expect(result).not.toBeNull();
// Should suggest columns
const labels = result?.options.map((o) => o.label) || [];
expect(labels).toContain("status");
// Should include conditional keywords
expect(labels).toContain("AND");
expect(labels).toContain("OR");
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { isValidTSQLQuery, getTSQLError } from "~/components/code/tsql/tsqlLinter";
describe("tsqlLinter", () => {
describe("isValidTSQLQuery", () => {
it("should return true for empty queries", () => {
expect(isValidTSQLQuery("")).toBe(true);
expect(isValidTSQLQuery(" ")).toBe(true);
});
it("should return true for valid SELECT queries", () => {
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
});
it("should return true for queries with ORDER BY", () => {
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
});
it("should return true for queries with LIMIT", () => {
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
});
it("should return true for queries with JOINs", () => {
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
true
);
expect(
isValidTSQLQuery(
"SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id"
)
).toBe(true);
});
it("should return false for invalid syntax", () => {
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
});
it("should return false for incomplete queries", () => {
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
expect(isValidTSQLQuery("SELECT")).toBe(false);
});
});
describe("getTSQLError", () => {
it("should return null for empty queries", () => {
expect(getTSQLError("")).toBeNull();
expect(getTSQLError(" ")).toBeNull();
});
it("should return null for valid queries", () => {
expect(getTSQLError("SELECT * FROM users")).toBeNull();
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
});
it("should return error message for invalid queries", () => {
const error = getTSQLError("SELEC * FROM users");
expect(error).not.toBeNull();
expect(typeof error).toBe("string");
});
it("should include position information in error", () => {
const error = getTSQLError("SELECT * FORM users");
expect(error).not.toBeNull();
// Error message should contain line/column info
expect(error).toContain("line");
});
it("should handle unclosed string literals", () => {
const error = getTSQLError("SELECT * FROM users WHERE name = 'test");
expect(error).not.toBeNull();
});
});
});
+23 -5
View File
@@ -246,6 +246,9 @@ importers:
'@codemirror/lang-json':
specifier: ^6.0.1
version: 6.0.1
'@codemirror/lang-sql':
specifier: ^6.8.0
version: 6.10.0
'@codemirror/language':
specifier: ^6.3.1
version: 6.3.2
@@ -297,6 +300,9 @@ importers:
'@internal/tracing':
specifier: workspace:*
version: link:../../internal-packages/tracing
'@internal/tsql':
specifier: workspace:*
version: link:../../internal-packages/tsql
'@internal/zod-worker':
specifier: workspace:*
version: link:../../internal-packages/zod-worker
@@ -4006,6 +4012,9 @@ packages:
'@codemirror/lang-json@6.0.1':
resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==}
'@codemirror/lang-sql@6.10.0':
resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==}
'@codemirror/language@6.11.3':
resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==}
@@ -22799,6 +22808,15 @@ snapshots:
'@codemirror/language': 6.3.2
'@lezer/json': 1.0.0
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.19.1
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@lezer/common': 1.3.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.3
'@codemirror/language@6.11.3':
dependencies:
'@codemirror/state': 6.5.2
@@ -24075,17 +24093,17 @@ snapshots:
'@lezer/javascript@1.4.1':
dependencies:
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.3
'@lezer/json@1.0.0':
dependencies:
'@lezer/highlight': 1.1.6
'@lezer/lr': 1.3.0
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.3
'@lezer/lr@1.3.0':
dependencies:
'@lezer/common': 1.0.2
'@lezer/common': 1.3.0
'@lezer/lr@1.4.3':
dependencies: