TRQL editor WIP
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+23
-5
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user