chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,10 @@
// 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,490 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import {
type TableSchema,
type ColumnSchema,
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,
});
}
// Add special TSQL functions not in the ClickHouse function registry
functions.push({
label: "timeBucket",
type: "function",
detail: "auto time bucket (0 args)",
apply: "timeBucket()",
boost: 1.5,
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
});
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.
| "value" // After comparison operator (=, !=, IN, etc.)
| "general"; // Anywhere else
/**
* Result of context detection
*/
interface ContextResult {
type: CompletionContextType;
tablePrefix?: string;
/** Column being compared (for value context) */
columnName?: string;
/** Table alias for the column (for value context) */
columnTableAlias?: string;
}
/**
* Extract column name from text before a comparison operator
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
*/
function extractColumnBeforeOperator(
textBefore: string
): { columnName: string; tableAlias?: string } | null {
// Match patterns like: column =, column !=, column IN, table.column =, etc.
// We need to capture the column (and optional table prefix) before the operator
// Also match when user is typing a partial string value like: column = 'val
const patterns = [
// column = or column != or column <> (with optional whitespace and optional partial string value)
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
/(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
// column IN ( or column NOT IN ( (with optional partial string value)
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
// After a comma in IN clause (with optional partial string value)
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
];
for (const pattern of patterns) {
const match = textBefore.match(pattern);
if (match) {
if (match.length === 3) {
// table.column pattern
return { tableAlias: match[1], columnName: match[2] };
} else {
// just column pattern
return { columnName: match[1] };
}
}
}
return null;
}
function determineContext(doc: string, pos: number): ContextResult {
// Get text before cursor
const textBefore = doc.slice(0, pos);
// Check if we're in a value context (after comparison operator)
// This should be checked before other contexts
const columnInfo = extractColumnBeforeOperator(textBefore);
if (columnInfo) {
return {
type: "value",
columnName: columnInfo.columnName,
columnTableAlias: columnInfo.tableAlias,
};
}
// 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" };
}
/**
* Find a column schema by name in the tables map
*/
function findColumnSchema(
columnName: string,
tableAlias: string | undefined,
tables: Map<string, TableSchema>
): ColumnSchema | null {
if (tableAlias) {
// Look in specific table
const tableSchema = tables.get(tableAlias.toLowerCase());
if (tableSchema) {
return tableSchema.columns[columnName] || null;
}
} else {
// Look in all tables
for (const tableSchema of tables.values()) {
const col = tableSchema.columns[columnName];
if (col) {
return col;
}
}
}
return null;
}
/**
* Create completions for enum values from allowedValues
*/
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
return [];
}
return columnSchema.allowedValues.map((value) => ({
label: `'${value}'`,
type: "enum",
detail: columnSchema.description || "allowed value",
boost: 3, // Highest priority for enum values in value context
}));
}
/**
* 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 - include single quotes for value completion
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[] = [];
// Track if we need to extend replacement range (e.g., to consume auto-paired closing quote)
let to: number | undefined = undefined;
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 "value":
// After comparison operator, show enum values if available
if (queryContext.columnName) {
const tables = extractTablesFromQuery(doc, schema);
const columnSchema = findColumnSchema(
queryContext.columnName,
queryContext.columnTableAlias,
tables
);
if (columnSchema) {
options = createEnumValueCompletions(columnSchema);
// Check if there's a closing quote right after cursor (from auto-pairing)
// If so, extend replacement range to include it to avoid 'Completed''
const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1);
if (charAfterCursor === "'") {
to = context.pos + 1;
}
}
}
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;
}
const result: CompletionResult = {
from,
options,
validFor: /^[\w.']*$/,
};
// Only set 'to' if we need to extend the replacement range
if (to !== undefined) {
result.to = to;
}
return result;
};
}
@@ -0,0 +1,76 @@
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,208 @@
import type { EditorView } from "@codemirror/view";
import type { Diagnostic } from "@codemirror/lint";
import type { TableSchema } from "@internal/tsql";
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } 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[] {
const { schema = [] } = config;
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
const ast = parseTSQLSelect(content);
// If parsing succeeds and we have a schema, run schema validation
if (schema.length > 0) {
const validationResult = validateQuery(ast, schema);
for (const issue of validationResult.issues) {
// Map validation severity to CodeMirror diagnostic severity
const severity: "error" | "warning" | "info" =
issue.severity === "error"
? "error"
: issue.severity === "warning"
? "warning"
: "info";
diagnostics.push({
from: 0,
to: content.length,
severity,
message: issue.message,
source: "tsql",
});
}
}
} 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";
}
}