diff --git a/src/app/(outerbase)/new-resource-list.tsx b/src/app/(outerbase)/new-resource-list.tsx index 86709980..6118bb8d 100644 --- a/src/app/(outerbase)/new-resource-list.tsx +++ b/src/app/(outerbase)/new-resource-list.tsx @@ -4,6 +4,7 @@ import { SQLiteIcon, } from "@/components/icons/outerbase-icon"; import { + ClickHouseIcon, CloudflareIcon, RQLiteIcon, StarbaseIcon, @@ -63,6 +64,13 @@ export function getCreateResourceTypeList( ? `/w/${workspaceId}/new-base/mysql` : "/local/new-base/mysql", }, + { + name: "ClickHouse", + icon: ClickHouseIcon, + href: workspaceId + ? `/w/${workspaceId}/new-base/clickhouse` + : "/local/new-base/clickhouse", + }, { name: "SQLite", icon: SQLiteIcon, diff --git a/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx b/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx index 25f46284..3056c503 100644 --- a/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx +++ b/src/app/(outerbase)/w/[workspaceId]/[baseId]/page.tsx @@ -4,10 +4,12 @@ import { Studio } from "@/components/gui/studio"; import PageLoading from "@/components/page-loading"; import { StudioExtensionManager } from "@/core/extension-manager"; import { + createClickHouseExtensions, createMySQLExtensions, createPostgreSQLExtensions, createSQLiteExtensions, } from "@/core/standard-extension"; +import ClickHouseLikeDriver from "@/drivers/clickhouse/clickhouse-driver"; import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; @@ -94,6 +96,17 @@ export default function OuterbaseSourcePage() { ...outerbaseSpecifiedDrivers, ]), ]; + } else if (dialect === "clickhouse") { + return [ + new ClickHouseLikeDriver( + new OuterbaseQueryable(outerbaseConfig), + credential.database + ), + new StudioExtensionManager([ + ...createClickHouseExtensions(), + ...outerbaseSpecifiedDrivers, + ]), + ]; } return [ diff --git a/src/app/(theme)/client/s/[[...driver]]/page-client.tsx b/src/app/(theme)/client/s/[[...driver]]/page-client.tsx index 56dec655..66a5f7ef 100644 --- a/src/app/(theme)/client/s/[[...driver]]/page-client.tsx +++ b/src/app/(theme)/client/s/[[...driver]]/page-client.tsx @@ -6,6 +6,7 @@ import { import { Studio } from "@/components/gui/studio"; import { StudioExtensionManager } from "@/core/extension-manager"; import { + createClickHouseExtensions, createMySQLExtensions, createPostgreSQLExtensions, createSQLiteExtensions, @@ -52,6 +53,8 @@ export default function ClientPageBody() { return new StudioExtensionManager(createSQLiteExtensions()); } else if (dialet === "postgres") { return new StudioExtensionManager(createPostgreSQLExtensions()); + } else if (dialet === "clickhouse") { + return new StudioExtensionManager(createClickHouseExtensions()); } return new StudioExtensionManager(createStandardExtensions()); diff --git a/src/app/(theme)/connect/saved-connection-storage.ts b/src/app/(theme)/connect/saved-connection-storage.ts index fee5ad86..ff443094 100644 --- a/src/app/(theme)/connect/saved-connection-storage.ts +++ b/src/app/(theme)/connect/saved-connection-storage.ts @@ -5,7 +5,8 @@ export type SupportedDriver = | "starbase" | "cloudflare-d1" | "cloudflare-wae" - | "sqlite-filehandler"; + | "sqlite-filehandler" + | "clickhouse"; export type SavedConnectionStorage = "remote" | "local"; export type SavedConnectionLabel = "gray" | "red" | "yellow" | "green" | "blue"; diff --git a/src/components/connection-config-editor/template/clickhouse.tsx b/src/components/connection-config-editor/template/clickhouse.tsx new file mode 100644 index 00000000..d529ebcf --- /dev/null +++ b/src/components/connection-config-editor/template/clickhouse.tsx @@ -0,0 +1,82 @@ +import { ConnectionTemplateList } from "@/app/(outerbase)/base-template"; +import { GENERIC_CONNECTION_TEMPLATE } from "./generic"; + +function buildClickHouseUrl( + host?: string, + port?: string, + ssl?: boolean | string +): string { + const useSsl = ssl === true || ssl === "true"; + const protocol = useSsl ? "https" : "http"; + const defaultPort = useSsl ? "8443" : "8123"; + const actualHost = (host ?? "localhost").replace(/^https?:\/\//, ""); + const actualPort = port && port.length > 0 ? port : defaultPort; + return `${protocol}://${actualHost}:${actualPort}`; +} + +function parseClickHouseUrl(url: string | undefined): { + host: string; + port: string; + ssl: boolean; +} { + if (!url) return { host: "", port: "8123", ssl: false }; + try { + const parsed = new URL(url); + return { + host: parsed.hostname, + port: parsed.port || (parsed.protocol === "https:" ? "8443" : "8123"), + ssl: parsed.protocol === "https:", + }; + } catch { + return { host: url, port: "8123", ssl: false }; + } +} + +export const ClickHouseConnectionTemplate: ConnectionTemplateList = { + template: GENERIC_CONNECTION_TEMPLATE, + remoteFrom: (value) => { + return { + name: value.name, + host: value.source.host, + username: value.source.user, + database: value.source.database, + port: value.source.port, + }; + }, + remoteTo: (value) => { + return { + name: value.name, + source: { + host: value.host, + user: value.username, + password: value.password, + database: value.database, + port: value.port, + type: "clickhouse", + base_id: "", + }, + }; + }, + localFrom: (value) => { + const parsed = parseClickHouseUrl(value.url); + return { + name: value.name, + host: parsed.host, + port: parsed.port, + username: value.username, + password: value.password, + database: value.database, + ssl: parsed.ssl, + }; + }, + localTo: (value) => { + return { + name: value.name, + driver: "clickhouse", + url: buildClickHouseUrl(value.host, value.port, value.ssl), + username: value.username, + password: value.password, + database: value.database, + }; + }, +}; diff --git a/src/components/connection-config-editor/template/index.ts b/src/components/connection-config-editor/template/index.ts index 5ea3acf2..70186c20 100644 --- a/src/components/connection-config-editor/template/index.ts +++ b/src/components/connection-config-editor/template/index.ts @@ -1,4 +1,5 @@ import { ConnectionTemplateList } from "@/app/(outerbase)/base-template"; +import { ClickHouseConnectionTemplate } from "./clickhouse"; import { CloudflareConnectionTemplate } from "./cloudflare"; import { CloudflareWAEConnectionTemplate } from "./cloudflare-wae"; import { MySQLConnectionTemplate } from "./mysql"; @@ -25,4 +26,5 @@ export const ConnectionTemplateDictionary: Record< mysql: MySQLConnectionTemplate, postgres: PostgresConnectionTemplate, + clickhouse: ClickHouseConnectionTemplate, }; diff --git a/src/components/gui/tabs/query-tab.tsx b/src/components/gui/tabs/query-tab.tsx index 7650f353..4e1a1094 100644 --- a/src/components/gui/tabs/query-tab.tsx +++ b/src/components/gui/tabs/query-tab.tsx @@ -34,7 +34,17 @@ import { } from "@/lib/sql/multiple-query"; import { sendAnalyticEvents } from "@/lib/tracking"; import { cn } from "@/lib/utils"; +import type { SupportedDialect as SdkSupportedDialect } from "@outerbase/sdk-transform"; import { tokenizeSql } from "@outerbase/sdk-transform"; +import type { SupportedDialect } from "@/drivers/base-driver"; + +// The SDK's tokenizer/parser does not ship a ClickHouse grammar yet. +// ClickHouse's SQL surface — backtick-quoted identifiers, `--` comments, +// standard string literals — is closest to MySQL, so fall back to MySQL +// tokenization rules for dialects the SDK doesn't know about. +function toSdkDialect(d: SupportedDialect): SdkSupportedDialect { + return d === "clickhouse" ? "mysql" : d; +} import { CaretDown } from "@phosphor-icons/react"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { @@ -96,7 +106,10 @@ export default function QueryWindow({ const timer = setTimeout(() => { setPlaceholders((prev) => { const newPlaceholders: Record = {}; - const token = tokenizeSql(code, databaseDriver.getFlags().dialect); + const token = tokenizeSql( + code, + toSdkDialect(databaseDriver.getFlags().dialect) + ); const foundPlaceholders = token .filter((t) => t.type === "PLACEHOLDER") @@ -168,7 +181,7 @@ export default function QueryWindow({ for (let i = 0; i < finalStatements.length; i++) { const token = tokenizeSql( finalStatements[i], - databaseDriver.getFlags().dialect + toSdkDialect(databaseDriver.getFlags().dialect) ); // Defensive measurement diff --git a/src/components/resource-card/icon.tsx b/src/components/resource-card/icon.tsx index 5e1d3987..1c8218ae 100644 --- a/src/components/resource-card/icon.tsx +++ b/src/components/resource-card/icon.tsx @@ -167,6 +167,26 @@ export const SupabaseIcon = ({ className }: IconProps) => ( ); +export const ClickHouseIcon = ({ className }: IconProps) => ( + + {/* Simplified placeholder glyph: 5 vertical bars evoking the + ClickHouse brand mark. Replace with the official SVG when + brand assets are added. */} + + + + + + +); + export const ValTownIcon = ({ className }: IconProps) => ( ; diff --git a/src/drivers/clickhouse/clickhouse-data-type.ts b/src/drivers/clickhouse/clickhouse-data-type.ts new file mode 100644 index 00000000..1141f883 --- /dev/null +++ b/src/drivers/clickhouse/clickhouse-data-type.ts @@ -0,0 +1,147 @@ +import { ColumnTypeSelector } from "../base-driver"; + +// https://clickhouse.com/docs/en/sql-reference/data-types +export const CLICKHOUSE_DATA_TYPE_SUGGESTION: ColumnTypeSelector = { + type: "text", + idTypeName: "UInt64", + textTypeName: "String", + typeSuggestions: [ + { + name: "Integer", + suggestions: [ + { name: "Int8", description: "Signed 1-byte integer" }, + { name: "Int16", description: "Signed 2-byte integer" }, + { name: "Int32", description: "Signed 4-byte integer" }, + { name: "Int64", description: "Signed 8-byte integer" }, + { name: "Int128", description: "Signed 16-byte integer" }, + { name: "Int256", description: "Signed 32-byte integer" }, + { name: "UInt8", description: "Unsigned 1-byte integer" }, + { name: "UInt16", description: "Unsigned 2-byte integer" }, + { name: "UInt32", description: "Unsigned 4-byte integer" }, + { name: "UInt64", description: "Unsigned 8-byte integer" }, + { name: "UInt128", description: "Unsigned 16-byte integer" }, + { name: "UInt256", description: "Unsigned 32-byte integer" }, + { name: "Bool", description: "Boolean (stored as UInt8)" }, + ], + }, + { + name: "Real", + suggestions: [ + { name: "Float32", description: "Single-precision floating-point" }, + { name: "Float64", description: "Double-precision floating-point" }, + { + name: "Decimal", + parameters: [ + { + name: "precision", + description: "Total number of digits (1-76)", + default: "10", + }, + { + name: "scale", + description: "Number of digits after the decimal point", + default: "2", + }, + ], + description: "Fixed-point number", + }, + ], + }, + { + name: "String", + suggestions: [ + { + name: "String", + description: "Variable-length string (UTF-8)", + }, + { + name: "FixedString", + parameters: [{ name: "length", default: "16" }], + description: "Fixed-length string of N bytes", + }, + { + name: "UUID", + description: "128-bit universally unique identifier", + }, + { + name: "JSON", + description: "Semi-structured JSON data (experimental)", + }, + ], + }, + { + name: "Date/Time", + suggestions: [ + { + name: "Date", + description: "Date (2-byte, range 1970-01-01 to 2149-06-06)", + }, + { + name: "Date32", + description: "Date (4-byte, wider range)", + }, + { + name: "DateTime", + parameters: [ + { + name: "timezone", + description: "Optional IANA timezone", + default: "'UTC'", + }, + ], + description: "Second-precision timestamp", + }, + { + name: "DateTime64", + parameters: [ + { name: "precision", description: "0-9 fractional digits", default: "3" }, + { name: "timezone", description: "Optional IANA timezone", default: "'UTC'" }, + ], + description: "Sub-second precision timestamp", + }, + ], + }, + { + name: "Composite", + suggestions: [ + { + name: "Array", + parameters: [{ name: "type", default: "String" }], + description: "Array of a single element type", + }, + { + name: "Tuple", + parameters: [{ name: "types", default: "String, UInt64" }], + description: "Heterogeneous, ordered collection of elements", + }, + { + name: "Map", + parameters: [ + { name: "key", default: "String" }, + { name: "value", default: "String" }, + ], + description: "Key-value pairs", + }, + { + name: "Nullable", + parameters: [{ name: "type", default: "String" }], + description: "Wraps a base type to allow NULL values", + }, + { + name: "LowCardinality", + parameters: [{ name: "type", default: "String" }], + description: + "Dictionary-encoded wrapper for low-cardinality columns", + }, + { + name: "IPv4", + description: "32-bit IPv4 address", + }, + { + name: "IPv6", + description: "128-bit IPv6 address", + }, + ], + }, + ], +}; diff --git a/src/drivers/clickhouse/clickhouse-driver.ts b/src/drivers/clickhouse/clickhouse-driver.ts new file mode 100644 index 00000000..c7bdf586 --- /dev/null +++ b/src/drivers/clickhouse/clickhouse-driver.ts @@ -0,0 +1,449 @@ +import { ColumnType } from "@outerbase/sdk-transform"; +import { + ColumnTypeSelector, + DatabaseResultSet, + DatabaseSchemaItem, + DatabaseSchemas, + DatabaseTableColumn, + DatabaseTableOperation, + DatabaseTableOperationReslt, + DatabaseTableSchema, + DatabaseTableSchemaChange, + DatabaseTriggerSchema, + DatabaseViewSchema, + DatabaseValue, + DriverFlags, + QueryableBaseDriver, +} from "../base-driver"; +import CommonSQLImplement from "../common-sql-imp"; +import { escapeSqlValue } from "../sqlite/sql-helper"; +import { clickhouseTypeToColumnType } from "./clickhouse-http"; +import { CLICKHOUSE_DATA_TYPE_SUGGESTION } from "./clickhouse-data-type"; +import { generateClickHouseSchemaChange } from "./generate-schema"; + +interface ClickHouseDatabaseRow { + name: string; +} + +interface ClickHouseTableRow { + database: string; + name: string; + engine: string; + total_rows: string | number | null; + total_bytes: string | number | null; + is_temporary: number; +} + +interface ClickHouseColumnRow { + database: string; + table: string; + name: string; + type: string; + default_kind: string; + default_expression: string; + is_in_primary_key: number; + comment: string; + position: number; +} + +const SYSTEM_DATABASES = ["system", "INFORMATION_SCHEMA", "information_schema"]; + +/** + * Parse a `USE ` statement and return the target database name, or + * `null` if the input isn't a USE statement. Handles backtick / double-quote + * quoted identifiers and a trailing semicolon. + */ +function parseUseStatement(sql: string): string | null { + const match = + /^\s*USE\s+(?:`([^`]+)`|"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*))\s*;?\s*$/i.exec( + sql + ); + if (!match) return null; + return match[1] ?? match[2] ?? match[3] ?? null; +} + +function coerceNumeric(value: string | number | null): number | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === "number") return value; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +/** + * ClickHouse driver for Outerbase Studio. + * + * ClickHouse is a multi-database OLAP store with an SQL-compatible dialect + * but some notable departures from OLTP engines: no general transactions, + * asynchronous mutations for UPDATE/DELETE, no trigger support, and no + * foreign-key constraints on MergeTree tables. Capability flags are set + * accordingly so the Studio UI hides affordances that would never succeed. + */ +export default class ClickHouseLikeDriver extends CommonSQLImplement { + columnTypeSelector: ColumnTypeSelector = CLICKHOUSE_DATA_TYPE_SUGGESTION; + + // When connecting through the Outerbase Cloud proxy we are often scoped + // to a single database; match the MySQL driver's shape for this. + selectedDatabase: string = ""; + + constructor( + protected _db: QueryableBaseDriver, + selectedDatabase = "" + ) { + super(); + this.selectedDatabase = selectedDatabase; + } + + /** + * Intercept `USE ` statements. ClickHouse's HTTP interface is + * stateless — it runs each query in its own session and forgets the + * `USE`. We persist the selection by flipping the `?database=X` URL + * param on the underlying HTTP queryable (when available), so that + * subsequent unqualified queries like `SELECT * FROM events` resolve + * against the selected database. + */ + query(stmt: string): Promise { + const useTarget = parseUseStatement(stmt); + if (useTarget !== null) { + const queryable = this._db as { + setDatabase?: (db: string) => void; + }; + if (typeof queryable.setDatabase === "function") { + queryable.setDatabase(useTarget); + return Promise.resolve({ + headers: [], + rows: [], + stat: { + rowsAffected: 0, + rowsRead: null, + rowsWritten: null, + queryDurationMs: 0, + }, + }); + } + } + return this._db.query(stmt); + } + + transaction(stmts: string[]): Promise { + return this._db.transaction(stmts); + } + + batch(stmts: string[]): Promise { + return this._db.batch ? this._db.batch(stmts) : super.batch(stmts); + } + + close(): void { + // Nothing to tear down — transport is HTTP/fetch-based. + } + + escapeId(id: string): string { + // ClickHouse accepts either backticks or double quotes. Use backticks so + // reserved keywords like `date` / `user` keep working in raw SQL. + return `\`${id.replace(/`/g, "``")}\``; + } + + escapeValue(value: unknown): string { + return escapeSqlValue(value); + } + + getFlags(): DriverFlags { + return { + defaultSchema: this.selectedDatabase || "default", + optionalSchema: this.selectedDatabase ? true : false, + dialect: "clickhouse", + supportBigInt: true, + supportModifyColumn: true, + supportCreateUpdateTable: true, + supportCreateUpdateDatabase: this.selectedDatabase ? false : true, + supportUseStatement: true, + supportRowId: false, + // ClickHouse INSERT / UPDATE statements do not return affected rows + // inline; rely on a follow-up SELECT (the CommonSQLImplement default). + supportInsertReturning: false, + supportUpdateReturning: false, + // No trigger system in ClickHouse. + supportCreateUpdateTrigger: false, + }; + } + + getCollationList(): string[] { + return []; + } + + async getCurrentSchema(): Promise { + const result = (await this.query( + "SELECT currentDatabase() AS db" + )) as unknown as { rows: { db?: string | null }[] }; + return result.rows[0]?.db ?? null; + } + + async schemas(): Promise { + const dbFilter = this.selectedDatabase + ? `name = ${this.escapeValue(this.selectedDatabase)}` + : `name NOT IN (${SYSTEM_DATABASES.map((d) => this.escapeValue(d)).join(", ")})`; + + const tableDbFilter = this.selectedDatabase + ? `database = ${this.escapeValue(this.selectedDatabase)}` + : `database NOT IN (${SYSTEM_DATABASES.map((d) => this.escapeValue(d)).join(", ")})`; + + const databaseSql = `SELECT name FROM system.databases WHERE ${dbFilter}`; + const tableSql = `SELECT database, name, engine, total_rows, total_bytes, is_temporary FROM system.tables WHERE ${tableDbFilter} AND is_temporary = 0`; + const columnSql = `SELECT database, table, name, type, default_kind, default_expression, is_in_primary_key, comment, position FROM system.columns WHERE ${tableDbFilter} ORDER BY database, table, position`; + + const [databaseResult, tableResult, columnResult] = await this.batch([ + databaseSql, + tableSql, + columnSql, + ]); + + const databases = databaseResult.rows as unknown as ClickHouseDatabaseRow[]; + const tables = tableResult.rows as unknown as ClickHouseTableRow[]; + const columns = columnResult.rows as unknown as ClickHouseColumnRow[]; + + const schemas: DatabaseSchemas = {}; + for (const d of databases) { + schemas[d.name] = []; + } + + const tableIndex: Record = {}; + for (const t of tables) { + // Engines ending in "View" are ClickHouse views (View, MaterializedView, + // LiveView, WindowView). Everything else is treated as a table. + const isView = /View$/.test(t.engine); + const item: DatabaseSchemaItem = { + name: t.name, + type: isView ? "view" : "table", + tableName: t.name, + schemaName: t.database, + tableSchema: { + stats: { + sizeInByte: coerceNumeric(t.total_bytes), + estimateRowCount: coerceNumeric(t.total_rows), + }, + columns: [], + autoIncrement: false, + pk: [], + schemaName: t.database, + tableName: t.name, + type: isView ? "view" : "table", + }, + }; + + tableIndex[`${t.database}.${t.name}`] = item; + if (schemas[t.database]) { + schemas[t.database].push(item); + } else { + schemas[t.database] = [item]; + } + } + + for (const c of columns) { + const parent = tableIndex[`${c.database}.${c.table}`]; + if (!parent?.tableSchema) continue; + const column: DatabaseTableColumn = { + name: c.name, + type: c.type, + pk: c.is_in_primary_key === 1, + constraint: c.is_in_primary_key === 1 ? { primaryKey: true } : undefined, + }; + if (c.default_kind && c.default_expression) { + column.constraint = { + ...column.constraint, + defaultExpression: c.default_expression, + }; + } + parent.tableSchema.columns.push(column); + if (c.is_in_primary_key === 1) { + parent.tableSchema.pk.push(c.name); + } + } + + return schemas; + } + + async tableSchema( + schemaName: string, + tableName: string + ): Promise { + const columnResult = ( + await this.query( + `SELECT name, type, default_kind, default_expression, is_in_primary_key, comment, position FROM system.columns WHERE database = ${this.escapeValue(schemaName)} AND table = ${this.escapeValue(tableName)} ORDER BY position` + ) + ).rows as unknown as ClickHouseColumnRow[]; + + const columns: DatabaseTableColumn[] = columnResult.map((c) => { + const col: DatabaseTableColumn = { + name: c.name, + type: c.type, + pk: c.is_in_primary_key === 1, + constraint: c.is_in_primary_key === 1 ? { primaryKey: true } : undefined, + }; + if (c.default_kind && c.default_expression) { + col.constraint = { + ...col.constraint, + defaultExpression: c.default_expression, + }; + } + return col; + }); + + const pk = columns.filter((c) => c.pk).map((c) => c.name); + + return { + autoIncrement: false, + pk, + schemaName, + tableName, + columns, + constraints: pk.length + ? [{ name: `${tableName}_pkey`, primaryKey: true, primaryColumns: pk }] + : [], + }; + } + + /** + * Override the default implementation because ClickHouse uses + * `ALTER TABLE ... UPDATE/DELETE` for mutations (not plain UPDATE/DELETE), + * has no per-row `RETURNING`, and has no transactional semantics. We build + * per-op SQL here and fall back to SELECTs after to fetch the resulting rows. + */ + async updateTableData( + schemaName: string, + tableName: string, + ops: DatabaseTableOperation[], + validateSchema?: DatabaseTableSchema + ): Promise { + if (validateSchema) { + this.validateUpdateOperation(ops, validateSchema); + } + + const fqTable = `${this.escapeId(schemaName)}.${this.escapeId(tableName)}`; + const results: DatabaseTableOperationReslt[] = []; + + for (const op of ops) { + if (op.operation === "INSERT") { + const cols = Object.keys(op.values); + const colList = cols.map((c) => this.escapeId(c)).join(", "); + const valList = cols + .map((c) => this.escapeValue(op.values[c])) + .join(", "); + await this.query( + `INSERT INTO ${fqTable} (${colList}) VALUES (${valList})` + ); + if (op.pk && op.pk.length > 0) { + const pkFilter = op.pk.reduce>((acc, k) => { + acc[k] = op.values[k]; + return acc; + }, {}); + const found = await this.findFirst(schemaName, tableName, pkFilter); + results.push({ record: found.rows[0] }); + } else { + results.push({}); + } + } else if (op.operation === "DELETE") { + const where = this.buildWhere(op.where); + await this.query(`ALTER TABLE ${fqTable} DELETE WHERE ${where}`); + results.push({}); + } else { + const setClause = Object.keys(op.values) + .map( + (c) => `${this.escapeId(c)} = ${this.escapeValue(op.values[c])}` + ) + .join(", "); + const where = this.buildWhere(op.where); + await this.query( + `ALTER TABLE ${fqTable} UPDATE ${setClause} WHERE ${where}` + ); + // Mutations are asynchronous — the read-back may race. Best-effort + // fetch using the original where clause so the UI still reflects + // the caller's intent. + const merged: Record = { ...op.where }; + for (const k of Object.keys(op.values)) { + merged[k] = op.values[k]; + } + const found = await this.findFirst(schemaName, tableName, op.where); + results.push({ record: found.rows[0] ?? merged }); + } + } + + return results; + } + + private buildWhere(where: Record): string { + const parts = Object.entries(where).map(([k, v]) => { + if (v === null || v === undefined) { + return `${this.escapeId(k)} IS NULL`; + } + return `${this.escapeId(k)} = ${this.escapeValue(v)}`; + }); + return parts.length ? parts.join(" AND ") : "1 = 1"; + } + + async dropTable(schemaName: string, tableName: string): Promise { + await this.query( + `DROP TABLE ${this.escapeId(schemaName)}.${this.escapeId(tableName)}` + ); + } + + async emptyTable(schemaName: string, tableName: string): Promise { + await this.query( + `TRUNCATE TABLE ${this.escapeId(schemaName)}.${this.escapeId(tableName)}` + ); + } + + createUpdateTableSchema(change: DatabaseTableSchemaChange): string[] { + return generateClickHouseSchemaChange(this, change); + } + + createUpdateDatabaseSchema(): string[] { + // Scoped connections are managed at the Outerbase layer; for direct + // connections, creating a database is a single statement. + throw new Error( + "Database create/update is not supported yet for ClickHouse" + ); + } + + // -- Triggers / views -------------------------------------------------- + // ClickHouse has no trigger concept, so these throw. Materialized views + // exist but don't map cleanly onto the generic DatabaseViewSchema shape; + // `supportCreateUpdateTrigger` is false in `getFlags()` and the Studio + // UI won't surface these affordances. + + trigger(): Promise { + throw new Error("Triggers are not supported in ClickHouse"); + } + + createTrigger(): string { + throw new Error("Triggers are not supported in ClickHouse"); + } + + dropTrigger(): string { + throw new Error("Triggers are not supported in ClickHouse"); + } + + async view(schemaName: string, name: string): Promise { + const result = await this.query( + `SELECT as_select FROM system.tables WHERE database = ${this.escapeValue(schemaName)} AND name = ${this.escapeValue(name)}` + ); + const row = result.rows[0] as { as_select?: string } | undefined; + if (!row) throw new Error("View does not exist"); + return { + schemaName, + name, + statement: (row.as_select ?? "").trim(), + }; + } + + createView(view: DatabaseViewSchema): string { + return `CREATE VIEW ${this.escapeId(view.schemaName)}.${this.escapeId(view.name)} AS ${view.statement}`; + } + + dropView(schemaName: string, name: string): string { + return `DROP VIEW IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; + } + + inferTypeFromHeader(header?: DatabaseTableColumn): ColumnType | undefined { + if (!header) return undefined; + return clickhouseTypeToColumnType(header.type); + } +} diff --git a/src/drivers/clickhouse/clickhouse-http.ts b/src/drivers/clickhouse/clickhouse-http.ts new file mode 100644 index 00000000..50186d4e --- /dev/null +++ b/src/drivers/clickhouse/clickhouse-http.ts @@ -0,0 +1,236 @@ +import { ColumnType } from "@outerbase/sdk-transform"; +import { + DatabaseHeader, + DatabaseResultSet, + DatabaseRow, + QueryableBaseDriver, +} from "../base-driver"; + +export interface ClickHouseHttpConfig { + /** + * Base URL of the ClickHouse HTTP interface, e.g. `http://localhost:8123` + * or `https://.clickhouse.cloud:8443`. No trailing slash required. + */ + url: string; + username?: string; + password?: string; + /** Optional default database (mapped to the `database` query param). */ + database?: string; +} + +interface ClickHouseJSONResponse { + meta?: { name: string; type: string }[]; + data?: unknown[][]; + rows?: number; + rows_before_limit_at_least?: number; + statistics?: { + elapsed?: number; + rows_read?: number; + bytes_read?: number; + }; +} + +/** + * Map a ClickHouse type string (e.g. `Nullable(UInt64)`, `LowCardinality(String)`, + * `Array(String)`) to the column-type enum used by the Studio UI. + * + * ClickHouse types may be nested through wrappers — we repeatedly peel them + * off to find the underlying base type. + */ +export function clickhouseTypeToColumnType(raw: string): ColumnType { + if (!raw) return ColumnType.TEXT; + + let t = raw.trim(); + // Peel Nullable(...) and LowCardinality(...) wrappers + const wrappers = ["Nullable", "LowCardinality"]; + let changed = true; + while (changed) { + changed = false; + for (const w of wrappers) { + if (t.startsWith(w + "(") && t.endsWith(")")) { + t = t.slice(w.length + 1, -1).trim(); + changed = true; + } + } + } + + // Array / Map / Tuple / JSON → treat as text for display purposes + if ( + t.startsWith("Array(") || + t.startsWith("Map(") || + t.startsWith("Tuple(") || + t === "JSON" || + t.startsWith("Nested(") + ) { + return ColumnType.TEXT; + } + + // Numeric families + if (/^U?Int(8|16|32|64|128|256)$/.test(t)) return ColumnType.INTEGER; + if (/^Float(32|64)$/.test(t)) return ColumnType.REAL; + if (t.startsWith("Decimal")) return ColumnType.REAL; + if (t === "Bool" || t === "Boolean") return ColumnType.INTEGER; + + // Strings + if (t === "String" || t.startsWith("FixedString") || t === "UUID") { + return ColumnType.TEXT; + } + + // Dates + if (t === "Date" || t === "Date32") return ColumnType.TEXT; + if (t === "DateTime" || t.startsWith("DateTime(") || t.startsWith("DateTime64")) + return ColumnType.TEXT; + + // IP addresses / Enums / everything else → text + return ColumnType.TEXT; +} + +function btoaUnicode(str: string): string { + // Edge/browser-safe base64 encoding of UTF-8 input. + if (typeof btoa === "function") { + return btoa(unescape(encodeURIComponent(str))); + } + // Fallback for any Node-only execution path during tests. + return Buffer.from(str, "utf-8").toString("base64"); +} + +/** + * `QueryableBaseDriver` backed by ClickHouse's HTTP interface. + * Uses native `fetch` so it works in both the browser and Cloudflare + * Workers / Edge runtime without pulling in a Node-only client. + */ +export class ClickHouseHttpQueryable implements QueryableBaseDriver { + constructor(protected config: ClickHouseHttpConfig) {} + + /** + * Update the default database for subsequent requests. Called by the + * driver when the user switches DB in the sidebar (the stateless HTTP + * interface forgets `USE ` across requests, so we carry it ourselves). + */ + setDatabase(db: string): void { + this.config = { ...this.config, database: db || undefined }; + } + + getDatabase(): string | undefined { + return this.config.database; + } + + private buildUrl(): string { + const base = this.config.url.replace(/\/$/, ""); + const params = new URLSearchParams(); + params.set("default_format", "JSONCompact"); + if (this.config.database) { + params.set("database", this.config.database); + } + return `${base}/?${params.toString()}`; + } + + private buildHeaders(): Record { + const headers: Record = { + "Content-Type": "text/plain; charset=UTF-8", + }; + if (this.config.username !== undefined) { + const token = btoaUnicode( + `${this.config.username}:${this.config.password ?? ""}` + ); + headers["Authorization"] = `Basic ${token}`; + } + return headers; + } + + async query(stmt: string): Promise { + const start = + typeof performance !== "undefined" ? performance.now() : Date.now(); + + const res = await fetch(this.buildUrl(), { + method: "POST", + headers: this.buildHeaders(), + body: stmt, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error( + `ClickHouse HTTP ${res.status}: ${text.trim() || res.statusText}` + ); + } + + const end = + typeof performance !== "undefined" ? performance.now() : Date.now(); + const queryDurationMs = Math.round(end - start); + + const text = await res.text(); + + // Non-SELECT statements (INSERT, DDL, etc.) return an empty body. + if (!text.trim()) { + return { + headers: [], + rows: [], + stat: { + rowsAffected: 0, + rowsRead: null, + rowsWritten: null, + queryDurationMs, + }, + }; + } + + let json: ClickHouseJSONResponse; + try { + json = JSON.parse(text); + } catch { + // Unexpected non-JSON body — surface as an error rather than silently + // dropping the result. + throw new Error( + `ClickHouse returned non-JSON response: ${text.slice(0, 200)}` + ); + } + + const meta = json.meta ?? []; + const headers: DatabaseHeader[] = meta.map((m) => ({ + name: m.name, + displayName: m.name, + originalType: m.type, + type: clickhouseTypeToColumnType(m.type), + })); + + const rows: DatabaseRow[] = (json.data ?? []).map((row) => + headers.reduce((acc, h, idx) => { + acc[h.name] = row[idx] as unknown; + return acc; + }, {} as DatabaseRow) + ); + + return { + headers, + rows, + stat: { + rowsAffected: rows.length, + rowsRead: json.statistics?.rows_read ?? null, + rowsWritten: null, + queryDurationMs: + json.statistics?.elapsed !== undefined + ? Math.round(json.statistics.elapsed * 1000) + : queryDurationMs, + }, + }; + } + + async transaction(stmts: string[]): Promise { + // ClickHouse does not have general-purpose ACID transactions; run + // statements sequentially and fail fast on the first error. + const out: DatabaseResultSet[] = []; + for (const s of stmts) { + out.push(await this.query(s)); + } + return out; + } + + async batch(stmts: string[]): Promise { + const out: DatabaseResultSet[] = []; + for (const s of stmts) { + out.push(await this.query(s)); + } + return out; + } +} diff --git a/src/drivers/clickhouse/generate-schema.ts b/src/drivers/clickhouse/generate-schema.ts new file mode 100644 index 00000000..b4a1ab8f --- /dev/null +++ b/src/drivers/clickhouse/generate-schema.ts @@ -0,0 +1,118 @@ +import { isEqual, omit } from "lodash"; +import { + BaseDriver, + DatabaseTableColumn, + DatabaseTableSchemaChange, +} from "../base-driver"; + +function generateCreateColumn( + driver: BaseDriver, + col: DatabaseTableColumn +): string { + const tokens: string[] = [driver.escapeId(col.name), col.type]; + + // ClickHouse expresses nullability via the Nullable(T) wrapper in the + // type itself rather than with a NOT NULL clause, so we only handle + // DEFAULT / MATERIALIZED here. + if ( + col.constraint?.defaultValue !== undefined && + col.constraint?.defaultValue !== null + ) { + tokens.push("DEFAULT", driver.escapeValue(col.constraint.defaultValue)); + } else if (col.constraint?.defaultExpression) { + tokens.push("DEFAULT", col.constraint.defaultExpression); + } + + if (col.constraint?.generatedExpression) { + // ClickHouse has MATERIALIZED / ALIAS / EPHEMERAL instead of + // GENERATED ALWAYS — MATERIALIZED is the closest analog. + tokens.push("MATERIALIZED", col.constraint.generatedExpression); + } + + return tokens.join(" "); +} + +// https://clickhouse.com/docs/en/sql-reference/statements/create/table +// https://clickhouse.com/docs/en/sql-reference/statements/alter +export function generateClickHouseSchemaChange( + driver: BaseDriver, + change: DatabaseTableSchemaChange +): string[] { + const isCreateScript = !change.name.old; + const schemaName = change.schemaName ?? "default"; + + if (isCreateScript) { + const columnLines: string[] = []; + const primaryColumns: string[] = []; + + for (const col of change.columns) { + if (!col.new) continue; + columnLines.push(generateCreateColumn(driver, col.new)); + if (col.new.constraint?.primaryKey || col.new.pk) { + primaryColumns.push(col.new.name); + } + } + + // Pick up table-level PK constraints too + for (const con of change.constraints) { + if (con.new?.primaryKey && con.new.primaryColumns) { + for (const c of con.new.primaryColumns) { + if (!primaryColumns.includes(c)) primaryColumns.push(c); + } + } + } + + const orderBy = + primaryColumns.length > 0 + ? `(${primaryColumns.map((c) => driver.escapeId(c)).join(", ")})` + : "tuple()"; + + const hasFk = change.constraints.some((c) => c.new?.foreignKey); + const warnings = hasFk + ? "-- NOTE: ClickHouse MergeTree does not support FOREIGN KEY constraints; they were skipped.\n" + : ""; + + return [ + `${warnings}CREATE TABLE ${driver.escapeId(schemaName)}.${driver.escapeId( + change.name.new || "no_table_name" + )} (\n${columnLines.map((l) => " " + l).join(",\n")}\n) ENGINE = MergeTree()\nORDER BY ${orderBy}`, + ]; + } + + // ALTER path + const prefix = `ALTER TABLE ${driver.escapeId(schemaName)}.${driver.escapeId( + change.name.old ?? "" + )} `; + const lines: string[] = []; + + for (const col of change.columns) { + if (col.new === null && col.old) { + lines.push(`DROP COLUMN ${driver.escapeId(col.old.name)}`); + } else if (col.old === null && col.new) { + lines.push(`ADD COLUMN ${generateCreateColumn(driver, col.new)}`); + } else if (col.old && col.new) { + if (col.old.name !== col.new.name) { + lines.push( + `RENAME COLUMN ${driver.escapeId(col.old.name)} TO ${driver.escapeId(col.new.name)}` + ); + } + if (!isEqual(omit(col.old, ["name"]), omit(col.new, ["name"]))) { + lines.push( + `MODIFY COLUMN ${driver.escapeId(col.new.name)} ${col.new.type}` + ); + } + } + } + + const statements = lines.map((l) => prefix + l); + + if (change.name.new && change.name.new !== change.name.old) { + statements.push( + `RENAME TABLE ${driver.escapeId(schemaName)}.${driver.escapeId( + change.name.old ?? "" + )} TO ${driver.escapeId(schemaName)}.${driver.escapeId(change.name.new)}` + ); + } + + return statements; +} diff --git a/src/drivers/helpers.ts b/src/drivers/helpers.ts index be5ec0f3..09b76c1d 100644 --- a/src/drivers/helpers.ts +++ b/src/drivers/helpers.ts @@ -1,4 +1,6 @@ import { SavedConnectionRawLocalStorage } from "@/app/(theme)/connect/saved-connection-storage"; +import ClickHouseLikeDriver from "./clickhouse/clickhouse-driver"; +import { ClickHouseHttpQueryable } from "./clickhouse/clickhouse-http"; import { CloudflareD1Queryable } from "./database/cloudflare-d1"; import CloudflareWAEDriver from "./database/cloudflare-wae"; import { RqliteQueryable } from "./database/rqlite"; @@ -26,6 +28,15 @@ export function createLocalDriver(conn: SavedConnectionRawLocalStorage) { return new SqliteLikeBaseDriver(new StarbaseQuery(conn.url!, conn.token!)); } else if (conn.driver === "cloudflare-wae") { return new CloudflareWAEDriver(conn.username!, conn.token!); + } else if (conn.driver === "clickhouse") { + return new ClickHouseLikeDriver( + new ClickHouseHttpQueryable({ + url: conn.url!, + username: conn.username, + password: conn.password, + database: conn.database, + }) + ); } return new TursoDriver(conn.url!, conn.token!, true); diff --git a/src/outerbase-cloud/database/utils.ts b/src/outerbase-cloud/database/utils.ts index 6d584162..87112d1a 100644 --- a/src/outerbase-cloud/database/utils.ts +++ b/src/outerbase-cloud/database/utils.ts @@ -1,4 +1,5 @@ import { DatabaseResultSet } from "@/drivers/base-driver"; +import ClickHouseLikeDriver from "@/drivers/clickhouse/clickhouse-driver"; import MySQLLikeDriver from "@/drivers/mysql/mysql-driver"; import PostgresLikeDriver from "@/drivers/postgres/postgres-driver"; import { SqliteLikeBaseDriver } from "@/drivers/sqlite-base-driver"; @@ -31,6 +32,8 @@ export function createOuterbaseDatabaseDriver( return new PostgresLikeDriver(queryable); } else if (type === "mysql") { return new MySQLLikeDriver(queryable); + } else if (type === "clickhouse") { + return new ClickHouseLikeDriver(queryable); } return new SqliteLikeBaseDriver(queryable);