diff --git a/apps/webapp/app/components/code/TSQLEditor.tsx b/apps/webapp/app/components/code/TSQLEditor.tsx new file mode 100644 index 000000000..a796275d7 --- /dev/null +++ b/apps/webapp/app/components/code/TSQLEditor.tsx @@ -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 { + /** 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; + +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(null); + + const settings: Omit = { + ...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 ( +
+ {showButtons && ( +
+ {additionalActions && additionalActions} + {showClearButton && ( + + )} + {showCopyButton && ( + + )} +
+ )} +
{ + if (!onBlur) return; + onBlur(editor.current?.textContent ?? ""); + }} + /> +
+ ); +} + diff --git a/apps/webapp/app/components/code/tsql/index.ts b/apps/webapp/app/components/code/tsql/index.ts new file mode 100644 index 000000000..bda244e30 --- /dev/null +++ b/apps/webapp/app/components/code/tsql/index.ts @@ -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"; + diff --git a/apps/webapp/app/components/code/tsql/tsqlCompletion.test.ts b/apps/webapp/app/components/code/tsql/tsqlCompletion.test.ts new file mode 100644 index 000000000..5fee69463 --- /dev/null +++ b/apps/webapp/app/components/code/tsql/tsqlCompletion.test.ts @@ -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"); + }); +}); + diff --git a/apps/webapp/app/components/code/tsql/tsqlCompletion.ts b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts new file mode 100644 index 000000000..caf0b4260 --- /dev/null +++ b/apps/webapp/app/components/code/tsql/tsqlCompletion.ts @@ -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 { + const tableMap = new Map(); + 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.]*$/, + }; + }; +} + diff --git a/apps/webapp/app/components/code/tsql/tsqlLinter.test.ts b/apps/webapp/app/components/code/tsql/tsqlLinter.test.ts new file mode 100644 index 000000000..8acc81d66 --- /dev/null +++ b/apps/webapp/app/components/code/tsql/tsqlLinter.test.ts @@ -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(); + }); + }); +}); + diff --git a/apps/webapp/app/components/code/tsql/tsqlLinter.ts b/apps/webapp/app/components/code/tsql/tsqlLinter.ts new file mode 100644 index 000000000..2d0bc1561 --- /dev/null +++ b/apps/webapp/app/components/code/tsql/tsqlLinter.ts @@ -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"; + } +} + diff --git a/apps/webapp/app/routes/storybook.tsql-editor/route.tsx b/apps/webapp/app/routes/storybook.tsql-editor/route.tsx new file mode 100644 index 000000000..d7a012ad7 --- /dev/null +++ b/apps/webapp/app/routes/storybook.tsql-editor/route.tsx @@ -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 ( +
+
+

TSQL Editor

+

+ A CodeMirror-based SQL editor with syntax highlighting, schema-aware autocomplete, and + real-time error detection. +

+
+ + {/* Example queries */} +
+

Example Queries

+
+ {exampleQueries.map((example) => ( + + ))} +
+
+ + {/* Main editor */} +
+

Editor with Schema

+

+ Try typing to see autocomplete suggestions. Available tables: runs,{" "} + logs +

+
+ +
+
+ + {/* Read-only example */} +
+

Read-only Mode

+
+ +
+
+ + {/* Editor without schema (no autocomplete) */} +
+

Without Schema (Basic Mode)

+

+ Editor without schema - still has SQL syntax highlighting and keyword completion. +

+
+ +
+
+ + {/* Error example */} +
+

With Syntax Error

+

+ The linter detects syntax errors and underlines them in red. +

+
+ +
+
+ + {/* Available tables reference */} +
+

Available Schema

+
+ {exampleSchema.map((table) => ( +
+

{table.name}

+

{table.description}

+
+ {Object.entries(table.columns).map(([name, col]) => ( +
+ {name} + {col.type} + {col.description && ( + - {col.description} + )} +
+ ))} +
+
+ ))} +
+
+
+ ); +} + diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index d18986621..2da850cc9 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -120,6 +120,10 @@ const stories: Story[] = [ name: "Tree view", slug: "tree-view", }, + { + name: "TSQL Editor", + slug: "tsql-editor", + }, { name: "Timeline", slug: "timeline", diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 45de003c8..95f05a4e5 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -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:*", diff --git a/apps/webapp/test/components/code/tsql/tsqlCompletion.test.ts b/apps/webapp/test/components/code/tsql/tsqlCompletion.test.ts new file mode 100644 index 000000000..9fcfe7cd4 --- /dev/null +++ b/apps/webapp/test/components/code/tsql/tsqlCompletion.test.ts @@ -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"); + }); +}); + diff --git a/apps/webapp/test/components/code/tsql/tsqlLinter.test.ts b/apps/webapp/test/components/code/tsql/tsqlLinter.test.ts new file mode 100644 index 000000000..a669c1209 --- /dev/null +++ b/apps/webapp/test/components/code/tsql/tsqlLinter.test.ts @@ -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(); + }); + }); +}); + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4147e17a..6f44137ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: