diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 88fef51f4..a921446b3 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -70,7 +70,7 @@ import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLa import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches"; import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints"; import { focusEditorView } from "@/lib/editor/queryEditorFocus"; -import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; +import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; import { sqlSemanticTableNameSpansForSyntaxTree } from "@/lib/editor/codemirrorSqlSemanticHighlight"; import { startsQueryEditorRectangularSelection } from "@/lib/editor/queryEditorPointerSelection"; import { LARGE_PASTE_HISTORY_USER_EVENT, normalizeQueryEditorPasteText, recoverableNativePasteSuffix, shouldRecoverLargeTauriPaste } from "@/lib/editor/queryEditorLargePaste"; @@ -109,7 +109,7 @@ const props = defineProps<{ completionContextVersion?: number; databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver"; - syntaxDialect?: "mysql" | "postgres" | "sqlserver"; + syntaxDialect?: CodeMirrorSqlDialectName; formatDialect?: SqlFormatDialect; formatRequestId?: number; compressRequestId?: number; @@ -124,6 +124,10 @@ const props = defineProps<{ statementExecutionMarkers?: StatementExecutionMarker[]; }>(); +function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined { + return props.syntaxDialect === "clickhouse" ? props.dialect : (props.syntaxDialect ?? props.dialect); +} + const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120; const COMPLETION_DEBOUNCE_DELAY_MS = 150; const COMPLETION_TAB_RETRY_DELAY_MS = 16; @@ -422,7 +426,7 @@ let hoverSqlHighlighter: SqlHighlighter | null = null; function sqlCompletionDialectOptions() { return { databaseType: props.databaseType, - dialect: props.syntaxDialect ?? props.dialect, + dialect: sqlBehaviorDialect(), }; } @@ -2248,7 +2252,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo const semanticModel = SEMANTIC_SQL_COMPLETION_ENABLED ? buildSqlSemanticModel(range.sql, semanticCursor, { databaseType: props.databaseType, - dialect: props.syntaxDialect ?? props.dialect, + dialect: sqlBehaviorDialect(), }) : null; const semanticAnalysis = semanticModel ? mergeSqlSemanticReferenceAnalysis(analysis, semanticModel) : analysis; @@ -3679,7 +3683,7 @@ onMounted(async () => { const ranges = windows.flatMap((window) => sqlSemanticTableNameSpansForSyntaxTree(sql, window, tree, { databaseType: props.databaseType, - dialect: props.syntaxDialect ?? props.dialect, + dialect: sqlBehaviorDialect(), }), ); return Decoration.set( diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 9af95dcb1..040a81c28 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -77,6 +77,7 @@ import { isTableDataEditable } from "@/lib/table/tableEditing"; import { tableMetaForDataTab } from "@/lib/table/tableDataTabMeta"; import { dataTabExecutionDatabase } from "@/lib/table/dataTabExecutionDatabase"; import { formatShortcut } from "@/lib/editor/shortcutRegistry"; +import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { chartableColumnIndexes } from "@/lib/dataGrid/chartData"; import { elasticsearchJsonResponseForResult } from "@/lib/elasticsearch/elasticsearchJsonResponse"; @@ -293,7 +294,7 @@ const activeTabDimension = computed(() => { const activeSqlFormatDialect = computed(() => sqlFormatDialectForDbType(activeEffectiveDatabaseType.value)); const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialect(activeEffectiveDatabaseType.value)); -const editorSyntaxDialect = computed<"mysql" | "postgres" | "sqlserver">(() => codeMirrorSqlDialectForConnection(props.activeConnection)); +const editorSyntaxDialect = computed(() => codeMirrorSqlDialectForConnection(props.activeConnection)); const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl")); diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts new file mode 100644 index 000000000..491bb3cd0 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; + +describe("ClickHouse semantic dialect", () => { + it("takes precedence over the legacy MySQL behavior dialect", () => { + const dialect = sqlSemanticDialectFor({ + databaseType: "clickhouse", + dialect: "mysql", + }); + + expect(dialect.id).toBe("clickhouse"); + expect(dialect.identifierQuotes).toEqual([ + { open: "`", close: "`" }, + { open: '"', close: '"' }, + ]); + expect(dialect.normalizeIdentifier("EventName")).toBe("EventName"); + expect(dialect.quoteIdentifier("event`name")).toBe("`event``name`"); + }); +}); diff --git a/apps/desktop/src/lib/database/jdbcDialect.ts b/apps/desktop/src/lib/database/jdbcDialect.ts index 27a3af612..6f142e7ad 100644 --- a/apps/desktop/src/lib/database/jdbcDialect.ts +++ b/apps/desktop/src/lib/database/jdbcDialect.ts @@ -1,5 +1,6 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database"; import { isSchemaAware, usesDatabaseObjectTreeMode, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; +import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; type JdbcDialectConnection = Pick & Partial>; @@ -125,9 +126,11 @@ export function codeMirrorSqlDialect(dbType: DatabaseType | undefined): "mysql" return "mysql"; } -export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): "mysql" | "postgres" | "sqlserver" { +export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): CodeMirrorSqlDialectName { if (isJdbcAseProfile(connection)) return "sqlserver"; - return codeMirrorSqlDialect(effectiveDatabaseTypeForConnection(connection)); + const databaseType = effectiveDatabaseTypeForConnection(connection); + if (databaseType === "clickhouse") return "clickhouse"; + return codeMirrorSqlDialect(databaseType); } function isJdbcAseProfile(connection?: JdbcDialectConnection): boolean { diff --git a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts index cd52fbfef..9c3790ee3 100644 --- a/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts +++ b/apps/desktop/src/lib/editor/codemirrorSqlDialect.ts @@ -1,7 +1,7 @@ import type { SQLDialect } from "@codemirror/lang-sql"; import type { DatabaseType } from "@/types/database"; -export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver"; +export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse"; type CodeMirrorSqlLanguageModule = Pick; @@ -10,6 +10,9 @@ const POSTGRES_CODEMIRROR_DATABASE_TYPES = new Set(["postgres", "r const ORACLE_CODEMIRROR_DATABASE_TYPES = new Set(["oracle", "dameng", "yashandb", "oscar", "oceanbase-oracle"]); const SQLITE_CODEMIRROR_DATABASE_TYPES = new Set(["sqlite", "rqlite", "turso", "cloudflare-d1"]); +const CODEMIRROR_SQLITE_EXTENSION_KEYWORDS = new Set("abort analyze attach autoincrement conflict database detach exclusive fail glob ignore index indexed instead isnull notnull offset plan pragma query raise regexp reindex rename replace temp vacuum virtual".split(" ")); +const STANDARD_SQL_TYPES = "array binary bit boolean char character clob date decimal double float int integer interval large national nchar nclob numeric object precision real smallint time timestamp varchar varying"; + const DBX_COMMON_SQL_KEYWORDS = [ "PIVOT", "UNPIVOT", @@ -51,6 +54,94 @@ const POSTGRES_IDENTIFIER_LIKE_KEYWORDS = new Set("COMMENT COUNT DATA DAY HOUR I // SQL Server table-valued parameters require READONLY in procedure/function declarations. const SQLSERVER_KEYWORDS = "readonly"; +const CLICKHOUSE_KEYWORDS = [ + "ATTACH", + "DETACH", + "OPTIMIZE", + "SYSTEM", + "KILL", + "ENGINE", + "PARTITION", + "PRIMARY", + "SAMPLE", + "PREWHERE", + "ARRAY", + "GLOBAL", + "FINAL", + "TOTALS", + "ROLLUP", + "CUBE", + "LIMIT", + "BY", + "INTO", + "OUTFILE", + "COMPRESSION", + "FORMAT", + "SETTINGS", + "TTL", + "CODEC", + "MATERIALIZED", + "ALIAS", + "PROJECTION", + "INDEX", + "GRANULARITY", +] + .join(" ") + .toLowerCase(); + +const CLICKHOUSE_TYPES = [ + "Bool", + "Int8", + "Int16", + "Int32", + "Int64", + "Int128", + "Int256", + "UInt8", + "UInt16", + "UInt32", + "UInt64", + "UInt128", + "UInt256", + "Float32", + "Float64", + "Decimal", + "Decimal32", + "Decimal64", + "Decimal128", + "Decimal256", + "String", + "FixedString", + "Date", + "Date32", + "DateTime", + "DateTime64", + "Time", + "Time64", + "Enum8", + "Enum16", + "UUID", + "IPv4", + "IPv6", + "Array", + "Tuple", + "Map", + "Nested", + "Nullable", + "LowCardinality", + "AggregateFunction", + "SimpleAggregateFunction", + "JSON", + "Object", + "Variant", + "Dynamic", + "Nothing", +] + .join(" ") + .toLowerCase(); + +const CLICKHOUSE_BUILTINS = ["now", "today", "toDate", "toDateTime", "toDateTime64", "toYYYYMM", "count", "sum", "avg", "min", "max", "uniq", "uniqExact", "argMin", "argMax", "groupArray", "arrayJoin", "mapKeys", "mapValues", "JSONExtract", "JSONExtractString"].join(" ").toLowerCase(); + export function postgresKeywordSyntaxTerms(keywords: string): string { return keywords .split(/\s+/) @@ -58,8 +149,19 @@ export function postgresKeywordSyntaxTerms(keywords: string): string { .join(" "); } +function standardSqlKeywordSyntaxTerms(langSql: CodeMirrorSqlLanguageModule): string { + // CodeMirror keeps StandardSQL's default vocabulary internal and exposes an + // empty StandardSQL.spec. SQLite is its smallest public standard-SQL + // superset, so remove SQLite-only terms to retain the standard vocabulary. + return (langSql.SQLite.spec.keywords || "") + .split(/\s+/) + .filter((keyword) => keyword && !CODEMIRROR_SQLITE_EXTENSION_KEYWORDS.has(keyword)) + .join(" "); +} + function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName: CodeMirrorSqlDialectName, databaseType?: DatabaseType): SQLDialect { if (databaseType) { + if (databaseType === "clickhouse") return langSql.StandardSQL; if (MYSQL_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.MySQL; if (POSTGRES_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.PostgreSQL; if (ORACLE_CODEMIRROR_DATABASE_TYPES.has(databaseType)) return langSql.PLSQL; @@ -69,6 +171,7 @@ function codeMirrorBaseDialect(langSql: CodeMirrorSqlLanguageModule, dialectName if (databaseType === "jdbc" && dialectName === "sqlserver") return langSql.MSSQL; return langSql.StandardSQL; } + if (dialectName === "clickhouse") return langSql.StandardSQL; return dialectName === "postgres" ? langSql.PostgreSQL : dialectName === "sqlserver" ? langSql.MSSQL : langSql.MySQL; } @@ -76,13 +179,23 @@ export function createDbxCodeMirrorSqlDialect(langSql: CodeMirrorSqlLanguageModu const baseDialect = codeMirrorBaseDialect(langSql, dialectName, databaseType); const isPostgres = baseDialect === langSql.PostgreSQL; const isSqlServer = baseDialect === langSql.MSSQL; - const baseKeywords = isPostgres ? postgresKeywordSyntaxTerms(baseDialect.spec.keywords || "") : baseDialect.spec.keywords || ""; + const isClickHouse = databaseType === "clickhouse" || dialectName === "clickhouse"; + const baseKeywords = isClickHouse ? standardSqlKeywordSyntaxTerms(langSql) : isPostgres ? postgresKeywordSyntaxTerms(baseDialect.spec.keywords || "") : baseDialect.spec.keywords || ""; + const baseTypes = isClickHouse ? STANDARD_SQL_TYPES : baseDialect.spec.types || ""; + const commonKeywords = isClickHouse ? DBX_COMMON_SQL_KEYWORDS.toLowerCase() : DBX_COMMON_SQL_KEYWORDS; return langSql.SQLDialect.define({ ...baseDialect.spec, - keywords: [baseKeywords, DBX_COMMON_SQL_KEYWORDS, isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", isSqlServer ? SQLSERVER_KEYWORDS : ""].filter(Boolean).join(" "), - types: [baseDialect.spec.types || "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""].filter(Boolean).join(" ") || undefined, - builtin: [baseDialect.spec.builtin || "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""].filter(Boolean).join(" ") || undefined, + keywords: [baseKeywords, commonKeywords, isClickHouse ? CLICKHOUSE_KEYWORDS : "", isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", isSqlServer ? SQLSERVER_KEYWORDS : ""].filter(Boolean).join(" "), + types: [baseTypes, isClickHouse ? CLICKHOUSE_TYPES : "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""].filter(Boolean).join(" ") || undefined, + builtin: [baseDialect.spec.builtin || "", isClickHouse ? CLICKHOUSE_BUILTINS : "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""].filter(Boolean).join(" ") || undefined, + ...(isClickHouse + ? { + identifierQuotes: '"`', + backslashEscapes: true, + spaceAfterDashes: false, + } + : {}), doubleDollarQuotedStrings: false, }); } diff --git a/apps/desktop/src/lib/sql/semantic/dialect.ts b/apps/desktop/src/lib/sql/semantic/dialect.ts index 2c0f329e6..658bd9a2f 100644 --- a/apps/desktop/src/lib/sql/semantic/dialect.ts +++ b/apps/desktop/src/lib/sql/semantic/dialect.ts @@ -83,6 +83,22 @@ export const SQL_SEMANTIC_DIALECTS: Record = return parts.length >= 1 ? "schema" : "unknown"; }, }, + clickhouse: { + id: "clickhouse", + identifierQuotes: [ + { open: "`", close: "`" }, + { open: '"', close: '"' }, + ], + supportsAsForTableAlias: true, + projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true }, + normalizeIdentifier: defaultNormalize, + quoteIdentifier: (identifier) => quoteWith(identifier, "`"), + qualifierRole(parts, context) { + if (context === "column") return parts.length >= 2 ? "table" : "table"; + if (context === "routine") return parts.length >= 2 ? "package" : "schema"; + return parts.length >= 1 ? "schema" : "unknown"; + }, + }, sqlserver: { id: "sqlserver", identifierQuotes: [ @@ -143,6 +159,7 @@ export function sqlReferenceAnalysisDialectFor(options: { databaseType?: Databas } export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" }): SqlSemanticDialectAdapter { + if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse; if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect]; switch (options.databaseType) { case "postgres": diff --git a/docs/superpowers/plans/2026-07-29-clickhouse-syntax-highlighting.md b/docs/superpowers/plans/2026-07-29-clickhouse-syntax-highlighting.md new file mode 100644 index 000000000..fc1a0439c --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-clickhouse-syntax-highlighting.md @@ -0,0 +1,542 @@ +# ClickHouse SQL Syntax Highlighting Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a dedicated ClickHouse CodeMirror dialect so ClickHouse keywords, types, built-ins, comments, and table names receive correct editor highlighting. + +**Architecture:** Keep DBX's existing CodeMirror/Lezer pipeline and define a ClickHouse dialect by extending `StandardSQL` with a curated ClickHouse vocabulary and lexical options. Route ClickHouse connections to that syntax dialect without widening the existing completion/formatting dialect, and add a ClickHouse semantic adapter selected before the legacy MySQL behavior dialect. + +**Tech Stack:** TypeScript, Vue 3, CodeMirror 6 `@codemirror/lang-sql@6.10.0`, Lezer SQL parser, Vitest. + +## Global Constraints + +- Cover the main CodeMirror query editor and CodeMirror-based DDL/SQL viewers. +- Do not change Shiki-rendered static SQL blocks in AI messages. +- Do not change SQL formatting, execution, completion, snippets, or editor theme colors. +- Use `StandardSQL`, not `MySQL`, as the ClickHouse parser base. +- Preserve ClickHouse's compact `--SELECT 1` line-comment behavior. +- Add no runtime dependency and no persisted-setting migration. + +--- + +## File Map + +- Modify `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts`: own the ClickHouse CodeMirror dialect name, vocabulary, lexical configuration, and parser factory selection. +- Modify `apps/desktop/src/lib/database/jdbcDialect.ts`: return the dedicated syntax dialect for native and inferred JDBC ClickHouse connections while retaining the existing three-value behavior dialect. +- Modify `apps/desktop/src/components/editor/QueryEditor.vue`: allow `clickhouse` only for the syntax-dialect prop. +- Modify `apps/desktop/src/components/layout/ContentArea.vue`: type the syntax dialect with the shared CodeMirror dialect type. +- Modify `packages/app-tests/codemirrorSqlDialect.test.ts`: verify vocabulary parser nodes, comments, and database-to-syntax propagation. +- Modify `packages/app-tests/jdbcDialect.test.ts`: verify inferred JDBC ClickHouse syntax selection. +- Modify `apps/desktop/src/lib/sql/semantic/dialect.ts`: own the ClickHouse semantic adapter and prioritize it for ClickHouse database types. +- Create `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts`: verify adapter selection, quoting, and normalization. + +### Task 1: Dedicated ClickHouse CodeMirror dialect + +**Files:** + +- Modify: `packages/app-tests/codemirrorSqlDialect.test.ts` +- Modify: `packages/app-tests/jdbcDialect.test.ts` +- Modify: `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts` +- Modify: `apps/desktop/src/lib/database/jdbcDialect.ts` +- Modify: `apps/desktop/src/components/editor/QueryEditor.vue` +- Modify: `apps/desktop/src/components/layout/ContentArea.vue` + +**Interfaces:** + +- Produces: `CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse"`. +- Produces: `codeMirrorSqlDialectForConnection(connection): CodeMirrorSqlDialectName`. +- Preserves: `codeMirrorSqlDialect(dbType): "mysql" | "postgres" | "sqlserver"` for completion and formatting behavior. +- Consumes: `createDbxCodeMirrorSqlDialect(langSql, dialectName, databaseType)`. + +- [ ] **Step 1: Write failing parser and mapping tests** + +Extend `packages/app-tests/codemirrorSqlDialect.test.ts` with: + +```ts +import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; + +test("maps ClickHouse connections to the dedicated editor syntax dialect", () => { + assert.equal(codeMirrorSqlDialectForConnection({ db_type: "clickhouse" }), "clickhouse"); + assert.equal( + codeMirrorSqlDialectForConnection({ + db_type: "jdbc", + connection_string: "jdbc:clickhouse://127.0.0.1:8123/default", + }), + "clickhouse", + ); +}); + +test("classifies ClickHouse-specific syntax", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "clickhouse", "clickhouse"); + const sql = ` + CREATE TABLE events + ( + id UInt64, + created_at DateTime64(3), + category LowCardinality(String), + attributes Map(String, String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY id + TTL created_at + INTERVAL 30 DAY + SETTINGS index_granularity = 8192; + + SELECT uniqExact(id), argMax(category, created_at) + FROM events + PREWHERE created_at >= now() - INTERVAL 1 DAY + ARRAY JOIN mapKeys(attributes) AS attribute_key + LIMIT 10 BY category + FORMAT JSONEachRow; + `; + + for (const keyword of ["ENGINE", "PARTITION", "TTL", "SETTINGS", "PREWHERE", "ARRAY", "FORMAT"]) { + assert.ok(countParsedNodes(dialect, sql, "Keyword", keyword) >= 1, keyword); + } + for (const type of ["UInt64", "DateTime64", "LowCardinality", "Map"]) { + assert.equal(countParsedNodes(dialect, sql, "Type", type), 1, type); + } + for (const builtin of ["toYYYYMM", "uniqExact", "argMax", "mapKeys"]) { + assert.equal(countParsedNodes(dialect, sql, "Builtin", builtin), 1, builtin); + } + assert.equal(countParsedNodes(dialect, "--SELECT 1", "LineComment", "--SELECT 1"), 1); +}); +``` + +Extend `packages/app-tests/jdbcDialect.test.ts` with: + +```ts +test("uses dedicated ClickHouse editor syntax for inferred JDBC connections", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:clickhouse://127.0.0.1:8123/default", + }; + + assert.equal(inferJdbcDialect(connection), "clickhouse"); + assert.equal(codeMirrorSqlDialectForConnection(connection), "clickhouse"); +}); +``` + +- [ ] **Step 2: Run the tests and verify the red state** + +Run: + +```bash +pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts +``` + +Expected: FAIL because `"clickhouse"` is not accepted as a CodeMirror dialect name and ClickHouse connections currently return `"mysql"`. + +- [ ] **Step 3: Add the ClickHouse dialect definition** + +In `apps/desktop/src/lib/editor/codemirrorSqlDialect.ts`, extend the shared name and add focused vocabulary constants: + +```ts +export type CodeMirrorSqlDialectName = "mysql" | "postgres" | "sqlserver" | "clickhouse"; + +const CLICKHOUSE_KEYWORDS = [ + "ATTACH", + "DETACH", + "OPTIMIZE", + "SYSTEM", + "KILL", + "ENGINE", + "PARTITION", + "PRIMARY", + "SAMPLE", + "PREWHERE", + "ARRAY", + "GLOBAL", + "FINAL", + "WITH", + "TOTALS", + "ROLLUP", + "CUBE", + "LIMIT", + "BY", + "INTO", + "OUTFILE", + "COMPRESSION", + "FORMAT", + "SETTINGS", + "TTL", + "CODEC", + "MATERIALIZED", + "ALIAS", + "PROJECTION", + "INDEX", + "GRANULARITY", +].join(" "); + +const CLICKHOUSE_TYPES = [ + "Bool", + "Int8", + "Int16", + "Int32", + "Int64", + "Int128", + "Int256", + "UInt8", + "UInt16", + "UInt32", + "UInt64", + "UInt128", + "UInt256", + "Float32", + "Float64", + "Decimal", + "Decimal32", + "Decimal64", + "Decimal128", + "Decimal256", + "String", + "FixedString", + "Date", + "Date32", + "DateTime", + "DateTime64", + "Time", + "Time64", + "Enum8", + "Enum16", + "UUID", + "IPv4", + "IPv6", + "Array", + "Tuple", + "Map", + "Nested", + "Nullable", + "LowCardinality", + "AggregateFunction", + "SimpleAggregateFunction", + "JSON", + "Object", + "Variant", + "Dynamic", + "Nothing", +].join(" "); + +const CLICKHOUSE_BUILTINS = [ + "now", + "today", + "toDate", + "toDateTime", + "toDateTime64", + "toYYYYMM", + "count", + "sum", + "avg", + "min", + "max", + "uniq", + "uniqExact", + "argMin", + "argMax", + "groupArray", + "arrayJoin", + "mapKeys", + "mapValues", + "JSONExtract", + "JSONExtractString", +].join(" "); +``` + +Make `codeMirrorBaseDialect()` select `StandardSQL` for either an explicit +ClickHouse syntax name or `databaseType === "clickhouse"`: + +```ts +if (databaseType) { + if (databaseType === "clickhouse") return langSql.StandardSQL; + // Existing database family checks remain in their current order. +} +if (dialectName === "clickhouse") return langSql.StandardSQL; +``` + +In `createDbxCodeMirrorSqlDialect()`, build ClickHouse-specific spec fields +without changing other dialects: + +```ts +const isClickHouse = databaseType === "clickhouse" || dialectName === "clickhouse"; + +return langSql.SQLDialect.define({ + ...baseDialect.spec, + keywords: [ + baseKeywords, + DBX_COMMON_SQL_KEYWORDS, + isClickHouse ? CLICKHOUSE_KEYWORDS : "", + isPostgres ? POSTGRES_PLPGSQL_KEYWORDS : "", + isSqlServer ? SQLSERVER_KEYWORDS : "", + ] + .filter(Boolean) + .join(" "), + types: [baseDialect.spec.types || "", isClickHouse ? CLICKHOUSE_TYPES : "", isPostgres ? POSTGRES_PLPGSQL_TYPES : ""] + .filter(Boolean) + .join(" ") || undefined, + builtin: [baseDialect.spec.builtin || "", isClickHouse ? CLICKHOUSE_BUILTINS : "", isPostgres ? POSTGRES_PLPGSQL_BUILTIN : ""] + .filter(Boolean) + .join(" ") || undefined, + identifierQuotes: isClickHouse ? '"`' : baseDialect.spec.identifierQuotes, + backslashEscapes: isClickHouse ? true : baseDialect.spec.backslashEscapes, + spaceAfterDashes: isClickHouse ? false : baseDialect.spec.spaceAfterDashes, + doubleDollarQuotedStrings: false, +}); +``` + +- [ ] **Step 4: Route ClickHouse only through the syntax-dialect channel** + +In `apps/desktop/src/lib/database/jdbcDialect.ts`, import the type and keep the +existing behavior-dialect function narrow: + +```ts +import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; + +export function codeMirrorSqlDialectForConnection(connection?: JdbcDialectConnection): CodeMirrorSqlDialectName { + if (isJdbcAseProfile(connection)) return "sqlserver"; + const databaseType = effectiveDatabaseTypeForConnection(connection); + if (databaseType === "clickhouse") return "clickhouse"; + return codeMirrorSqlDialect(databaseType); +} +``` + +In `apps/desktop/src/components/editor/QueryEditor.vue`, import the type and +change only the syntax prop: + +```ts +import { createDbxCodeMirrorSqlDialect, type CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; + +syntaxDialect?: CodeMirrorSqlDialectName; +``` + +In `apps/desktop/src/components/layout/ContentArea.vue`, import the same type and +change only the syntax computed value: + +```ts +import type { CodeMirrorSqlDialectName } from "@/lib/editor/codemirrorSqlDialect"; + +const editorSyntaxDialect = computed(() => codeMirrorSqlDialectForConnection(props.activeConnection)); +``` + +The existing `editorDialect` remains `"mysql" | "postgres" | "sqlserver"` so +completion, identifier quoting, and formatting behavior stay out of scope. + +- [ ] **Step 5: Run focused tests and typecheck** + +Run: + +```bash +pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts +pnpm typecheck +``` + +Expected: both test files PASS and `vue-tsc` exits with code 0. + +- [ ] **Step 6: Commit the dedicated parser** + +```bash +git add apps/desktop/src/lib/editor/codemirrorSqlDialect.ts \ + apps/desktop/src/lib/database/jdbcDialect.ts \ + apps/desktop/src/components/editor/QueryEditor.vue \ + apps/desktop/src/components/layout/ContentArea.vue \ + packages/app-tests/codemirrorSqlDialect.test.ts \ + packages/app-tests/jdbcDialect.test.ts +git commit -m "feat(editor): add ClickHouse SQL highlighting" +``` + +### Task 2: ClickHouse semantic table-name adapter + +**Files:** + +- Create: `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts` +- Modify: `apps/desktop/src/lib/sql/semantic/dialect.ts` + +**Interfaces:** + +- Produces: `SQL_SEMANTIC_DIALECTS.clickhouse: SqlSemanticDialectAdapter`. +- Preserves: `sqlSemanticDialectFor(options)` input type; ClickHouse is selected by `databaseType`, not by widening completion's three-value dialect. +- Consumes: `SqlSemanticDialectAdapter` and `defaultNormalize`. + +- [ ] **Step 1: Write the failing semantic adapter test** + +Create `apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts`: + +```ts +import { describe, expect, it } from "vitest"; +import { sqlSemanticDialectFor } from "@/lib/sql/semantic/dialect"; + +describe("ClickHouse semantic dialect", () => { + it("takes precedence over the legacy MySQL behavior dialect", () => { + const dialect = sqlSemanticDialectFor({ + databaseType: "clickhouse", + dialect: "mysql", + }); + + expect(dialect.id).toBe("clickhouse"); + expect(dialect.identifierQuotes).toEqual([ + { open: "`", close: "`" }, + { open: '"', close: '"' }, + ]); + expect(dialect.normalizeIdentifier("EventName")).toBe("EventName"); + expect(dialect.quoteIdentifier("event`name")).toBe("`event``name`"); + }); +}); +``` + +- [ ] **Step 2: Run the test and verify the red state** + +Run: + +```bash +pnpm vitest run apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts +``` + +Expected: FAIL because the returned adapter id is currently `"mysql"`. + +- [ ] **Step 3: Implement the minimal ClickHouse semantic adapter** + +Add this entry to `SQL_SEMANTIC_DIALECTS` in +`apps/desktop/src/lib/sql/semantic/dialect.ts`: + +```ts +clickhouse: { + id: "clickhouse", + identifierQuotes: [ + { open: "`", close: "`" }, + { open: '"', close: '"' }, + ], + supportsAsForTableAlias: true, + projectionAliasVisibility: { where: false, groupBy: true, having: true, orderBy: true }, + normalizeIdentifier: defaultNormalize, + quoteIdentifier: (identifier) => quoteWith(identifier, "`"), + qualifierRole(parts, context) { + if (context === "column") return parts.length >= 2 ? "table" : "table"; + if (context === "routine") return parts.length >= 2 ? "package" : "schema"; + return parts.length >= 1 ? "schema" : "unknown"; + }, +}, +``` + +Select it before the explicit three-value behavior dialect: + +```ts +export function sqlSemanticDialectFor(options: { databaseType?: DatabaseType; dialect?: "mysql" | "postgres" | "sqlserver" }): SqlSemanticDialectAdapter { + if (options.databaseType === "clickhouse") return SQL_SEMANTIC_DIALECTS.clickhouse; + if (options.dialect && SQL_SEMANTIC_DIALECTS[options.dialect]) return SQL_SEMANTIC_DIALECTS[options.dialect]; + // Existing switch remains unchanged. +} +``` + +- [ ] **Step 4: Run semantic and parser regression tests** + +Run: + +```bash +pnpm vitest run \ + apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts \ + apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts \ + packages/app-tests/codemirrorSqlDialect.test.ts +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit the semantic adapter** + +```bash +git add apps/desktop/src/lib/sql/semantic/dialect.ts \ + apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts +git commit -m "feat(editor): add ClickHouse semantic SQL dialect" +``` + +### Task 3: Final verification and PR preparation + +**Files:** + +- Verify: all files changed by Tasks 1 and 2 +- Verify: `docs/superpowers/specs/2026-07-29-clickhouse-syntax-highlighting-design.md` +- Verify: `docs/superpowers/plans/2026-07-29-clickhouse-syntax-highlighting.md` + +**Interfaces:** + +- Consumes: the dedicated ClickHouse parser and semantic adapter. +- Produces: a reviewed, tested branch ready for a GitHub pull request. + +- [ ] **Step 1: Run the full relevant automated checks** + +Run: + +```bash +pnpm vitest run \ + packages/app-tests/codemirrorSqlDialect.test.ts \ + packages/app-tests/jdbcDialect.test.ts \ + apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts \ + apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts \ + apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +pnpm typecheck +``` + +Expected: every Vitest file passes and typecheck exits with code 0. + +- [ ] **Step 2: Review parser output and scope boundaries** + +Run: + +```bash +git diff main...HEAD --check +git diff main...HEAD --stat +git diff main...HEAD -- \ + apps/desktop/src/lib/editor/codemirrorSqlDialect.ts \ + apps/desktop/src/lib/database/jdbcDialect.ts \ + apps/desktop/src/lib/sql/semantic/dialect.ts +rg -n "clickhouse" apps/desktop/src/lib/sql/sqlHighlighter.ts +``` + +Expected: no whitespace errors; changes stay in the planned CodeMirror and +semantic paths; `sqlHighlighter.ts` has no new ClickHouse/Shiki modification. + +- [ ] **Step 3: Perform a local correctness review** + +Check each diff hunk against this exact checklist: + +```text +[ ] ClickHouse uses StandardSQL, never MySQL, as its parser base. +[ ] Compact -- comments remain LineComment nodes. +[ ] Keywords, types, and built-ins are stored in separate spec fields. +[ ] Only syntaxDialect accepts the new clickhouse value. +[ ] Existing completion/formatting dialect types remain unchanged. +[ ] databaseType=clickhouse wins over dialect=mysql in semantic selection. +[ ] No dependency, persistence, theme, Shiki, execution, or formatter changes. +``` + +Expected: every item is satisfied; correct any violation and rerun Step 1. + +- [ ] **Step 4: Push and create the pull request** + +Run: + +```bash +git status --short +git push -u origin codex/clickhouse-syntax-highlighting +gh pr create \ + --base main \ + --head codex/clickhouse-syntax-highlighting \ + --title "feat(editor): add dedicated ClickHouse SQL highlighting" \ + --body-file /tmp/dbx-clickhouse-highlighting-pr.md +``` + +Before the last command, create `/tmp/dbx-clickhouse-highlighting-pr.md` with: + +```markdown +## Summary + +- add a dedicated ClickHouse CodeMirror SQL dialect +- classify ClickHouse keywords, data types, and common built-in functions +- select a ClickHouse semantic adapter for table-name highlighting +- preserve existing completion, formatting, Shiki, and theme behavior + +## Test plan + +- `pnpm vitest run packages/app-tests/codemirrorSqlDialect.test.ts packages/app-tests/jdbcDialect.test.ts apps/desktop/src/lib/__tests__/editor/codemirrorSqlDialect.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/dialect.spec.ts apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts` +- `pnpm typecheck` +``` + +Expected: the branch is pushed and GitHub returns a new PR URL. diff --git a/docs/superpowers/specs/2026-07-29-clickhouse-syntax-highlighting-design.md b/docs/superpowers/specs/2026-07-29-clickhouse-syntax-highlighting-design.md new file mode 100644 index 000000000..eeb24e43c --- /dev/null +++ b/docs/superpowers/specs/2026-07-29-clickhouse-syntax-highlighting-design.md @@ -0,0 +1,141 @@ +# ClickHouse SQL Syntax Highlighting Design + +**Date:** 2026-07-29 + +## Goal + +Give ClickHouse connections a dedicated SQL highlighting dialect in DBX's +CodeMirror query editors and DDL viewers. ClickHouse keywords, data types, and +common built-in functions should receive the same semantic colors that the +existing editor themes already assign to those token classes. + +## Background + +DBX currently has separate dialect-selection paths for SQL formatting and SQL +highlighting: + +- `sqlFormatter.ts` maps ClickHouse to the formatter's native `clickhouse` + language. +- `jdbcDialect.ts` maps ClickHouse to the generic `mysql` CodeMirror dialect + name. +- `codemirrorSqlDialect.ts` does not recognize the ClickHouse database type and + therefore selects `StandardSQL` as its parser base. +- the semantic table-name overlay receives the explicit `mysql` dialect and + consequently applies MySQL rules to ClickHouse SQL. + +This explains why formatting can look correct while highlighting does not. +Editor themes already distinguish keywords, types, functions, strings, and +identifiers; the parser is producing incomplete or incorrect token classes. + +## Scope + +Included: + +- the main CodeMirror query editor +- CodeMirror-based DDL and SQL viewers that use the shared DBX dialect factory +- ClickHouse keyword, data-type, built-in-function, comment, and table-name + classification +- focused parser and propagation tests + +Not included: + +- Shiki-rendered static SQL blocks in AI messages +- SQL formatting, execution, completion, or snippet behavior +- a complete ClickHouse grammar or query validator +- theme color changes + +## Chosen Approach + +Add `clickhouse` as a first-class DBX CodeMirror dialect name. Define its +CodeMirror dialect from `StandardSQL`, then extend the dialect specification +with ClickHouse-specific keywords, data types, and common built-in functions. + +`StandardSQL` is the safer parser base than `MySQL` because ClickHouse and MySQL +do not share all lexical behavior. In particular, DBX already tests that +ClickHouse accepts `--` comments without the whitespace requirement used by the +CodeMirror MySQL dialect. + +The vocabulary will be curated from the official ClickHouse SQL reference and +kept as named constants next to the existing PostgreSQL and SQL Server +extensions. Representative coverage includes: + +- clauses and DDL terms such as `PREWHERE`, `ARRAY JOIN`, `ENGINE`, + `PARTITION BY`, `SAMPLE BY`, `TTL`, `SETTINGS`, `CODEC`, and `FORMAT` +- types such as `UInt64`, `DateTime64`, `LowCardinality`, `Nullable`, `Map`, + `Tuple`, `Nested`, `AggregateFunction`, and `SimpleAggregateFunction` +- common functions such as `toYYYYMM`, `uniqExact`, `argMax`, `groupArray`, + `arrayJoin`, and `JSONExtractString` + +This is vocabulary-driven syntax highlighting, not validation. Unknown future +functions will still parse as callable identifiers instead of breaking editor +input. + +## Data Flow + +1. A ClickHouse connection resolves to the new `clickhouse` CodeMirror dialect + name in `jdbcDialect.ts`. +2. QueryEditor and DDL viewers pass that name and the ClickHouse database type + into `createDbxCodeMirrorSqlDialect()`. +3. The dialect factory selects the dedicated ClickHouse specification. +4. CodeMirror's Lezer SQL parser assigns keyword, type, built-in, comment, and + identifier tags. +5. Existing editor themes render those tags with their current colors. +6. The semantic overlay selects a ClickHouse adapter for table-name + decorations instead of inheriting MySQL behavior accidentally. + +## Semantic Adapter + +Add a ClickHouse semantic dialect adapter rather than silently treating +ClickHouse as MySQL. It will support ClickHouse's backtick and double-quote +identifier quoting and use the project's existing default identifier +normalization unless a verified ClickHouse-specific rule is required. + +This adapter is limited to tokenization and table-reference decoration. It does +not change query execution or attempt to model every ClickHouse alias-resolution +extension. + +## Compatibility And Failure Behavior + +- Existing MySQL, PostgreSQL, SQL Server, Oracle, SQLite, and generic database + mappings remain unchanged. +- ClickHouse keeps its current `--SELECT 1` line-comment behavior. +- An unlisted ClickHouse keyword or function remains readable as an identifier; + highlighting degrades locally without blocking editing. +- All changes remain frontend-only and require no persisted-setting migration. + +## Testing + +Use test-driven development: + +1. Add parser tests that initially fail under the current StandardSQL fallback. +2. Assert representative ClickHouse terms produce keyword, type, and built-in + parser nodes. +3. Retain and strengthen the line-comment regression test. +4. Assert the JDBC database-type mapping returns `clickhouse`. +5. Assert QueryEditor and DDL viewers pass the dedicated dialect through the + shared factory. +6. Add semantic-dialect coverage for ClickHouse identifier quoting and + selection. +7. Run focused tests, the relevant desktop test suite, and frontend typecheck. + +## Alternatives Considered + +### Extend the MySQL dialect + +This is smaller but preserves known lexical mismatches and continues to make +ClickHouse behavior depend on unrelated MySQL changes. + +### Integrate the ClickHouse lexer or WebAssembly + +ClickHouse exposes native query-highlighting capabilities, including recent +lexer/WASM work. That route could eventually provide higher fidelity, but it +adds runtime, bundle-size, and CodeMirror syntax-tree integration costs that are +disproportionate to this focused highlighting fix. + +## Success Criteria + +- ClickHouse-specific clauses, types, and common functions are visually + differentiated in query and DDL editors. +- ClickHouse no longer resolves to MySQL or generic StandardSQL by accident. +- existing dialect behavior and editor themes do not regress. +- the implementation is covered by focused parser, mapping, and semantic tests. diff --git a/packages/app-tests/codemirrorSqlDialect.test.ts b/packages/app-tests/codemirrorSqlDialect.test.ts index 2e138cdae..20069552b 100644 --- a/packages/app-tests/codemirrorSqlDialect.test.ts +++ b/packages/app-tests/codemirrorSqlDialect.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { test } from "vitest"; import * as langSql from "@codemirror/lang-sql"; import { createDbxCodeMirrorSqlDialect } from "../../apps/desktop/src/lib/editor/codemirrorSqlDialect.ts"; -import { codeMirrorSqlDialect } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; +import { codeMirrorSqlDialect, codeMirrorSqlDialectForConnection } from "../../apps/desktop/src/lib/database/jdbcDialect.ts"; import type { DatabaseType } from "../../apps/desktop/src/types/database.ts"; function hasKeyword(keywords: string | undefined, keyword: string): boolean { @@ -47,6 +47,53 @@ test("keeps DBX PostgreSQL procedural dialect extensions", () => { assert.equal(hasKeyword(dialect.spec.builtin, "TG_NAME"), true); }); +test("maps ClickHouse connections to the dedicated editor syntax dialect", () => { + assert.equal(codeMirrorSqlDialectForConnection({ db_type: "clickhouse" }), "clickhouse"); + assert.equal( + codeMirrorSqlDialectForConnection({ + db_type: "jdbc", + connection_string: "jdbc:clickhouse://127.0.0.1:8123/default", + }), + "clickhouse", + ); +}); + +test("classifies ClickHouse-specific syntax", () => { + const dialect = createDbxCodeMirrorSqlDialect(langSql, "clickhouse", "clickhouse"); + const sql = ` + CREATE TABLE events + ( + id UInt64, + created_at DateTime64(3), + category LowCardinality(String), + attributes Map(String, String) + ) + ENGINE = MergeTree + PARTITION BY toYYYYMM(created_at) + ORDER BY id + TTL created_at + INTERVAL 30 DAY + SETTINGS index_granularity = 8192; + + SELECT uniqExact(id), argMax(category, created_at) + FROM events + PREWHERE created_at >= now() - INTERVAL 1 DAY + ARRAY JOIN mapKeys(attributes) AS attribute_key + LIMIT 10 BY category + FORMAT JSONEachRow; + `; + + for (const keyword of ["SELECT", "FROM", "ENGINE", "PARTITION", "TTL", "SETTINGS", "PREWHERE", "FORMAT"]) { + assert.ok(countParsedNodes(dialect, sql, "Keyword", keyword) >= 1, keyword); + } + for (const type of ["UInt64", "DateTime64", "LowCardinality", "Map"]) { + assert.equal(countParsedNodes(dialect, sql, "Type", type), 1, type); + } + for (const builtin of ["toYYYYMM", "uniqExact", "argMax", "mapKeys"]) { + assert.equal(countParsedNodes(dialect, sql, "Builtin", builtin), 1, builtin); + } + assert.equal(countParsedNodes(dialect, "--SELECT 1", "LineComment", "--SELECT 1"), 1); +}); + test("treats compact double-dash comments as comments in non-MySQL SQL dialects", () => { const databaseTypes: DatabaseType[] = [ "oracle", diff --git a/packages/app-tests/jdbcDialect.test.ts b/packages/app-tests/jdbcDialect.test.ts index 672a830fd..a114f0c38 100644 --- a/packages/app-tests/jdbcDialect.test.ts +++ b/packages/app-tests/jdbcDialect.test.ts @@ -29,6 +29,16 @@ test("infers JDBC dialect from driver profile", () => { ); }); +test("uses dedicated ClickHouse editor syntax for inferred JDBC connections", () => { + const connection = { + db_type: "jdbc" as const, + connection_string: "jdbc:clickhouse://127.0.0.1:8123/default", + }; + + assert.equal(inferJdbcDialect(connection), "clickhouse"); + assert.equal(codeMirrorSqlDialectForConnection(connection), "clickhouse"); +}); + test("infers GaussDB-compatible JDBC connections as schema-aware", () => { const gaussdbConnection = { db_type: "jdbc" as const,