diff --git a/api/routes.ts b/api/routes.ts index 79b370d..b49bb5d 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -459,6 +459,12 @@ const defs = { name: STR('Column name'), type: STR('Column data type'), ordinal: NUM('Column ordinal position'), + isPrimaryKey: optional(BOOL('Is primary key')), + relation: optional(OBJ({ + table: STR('Target table'), + column: STR('Target column'), + type: LIST(['enum', 'table'], 'Relation type'), + })), })), schema: optional(STR('Schema name')), table: STR('Table name'), diff --git a/api/schema.ts b/api/schema.ts index cf533ab..be34902 100644 --- a/api/schema.ts +++ b/api/schema.ts @@ -2,6 +2,7 @@ import { ARR, type Asserted, BOOL, + LIST, NUM, OBJ, optional, @@ -70,7 +71,18 @@ export const DatabaseSchemaDef = OBJ({ dialect: STR('Detected SQL dialect'), refreshedAt: STR('ISO datetime of last refresh'), tables: ARR(OBJ({ - columns: ARR(OBJ({ name: STR(), type: STR(), ordinal: NUM() })), + columns: ARR(OBJ({ + name: STR(), + type: STR(), + ordinal: NUM(), + relation: optional(OBJ({ + table: STR('Target table'), + column: STR('Target column'), + labelColumn: optional(STR('Column to use for inlining')), + type: LIST(['enum', 'table'], 'Relation type'), + })), + isPrimaryKey: optional(BOOL()), + })), columnsMap: optional(OBJ({})), schema: optional(STR()), table: STR(), diff --git a/api/sql.ts b/api/sql.ts index 4f7e31f..0b58626 100644 --- a/api/sql.ts +++ b/api/sql.ts @@ -1,4 +1,5 @@ import { + DatabaseSchema, DatabaseSchemasCollection, Deployment, DeploymentsCollection, @@ -75,25 +76,48 @@ async function detectDialect(endpoint: string, token: string): Promise { // Introspection queries per dialect returning columns list // Standardized output fields: table_schema (nullable), table_name, column_name, data_type, ordinal_position const INTROSPECTION: Record = { - sqlite: - `SELECT NULL AS table_schema, m.name AS table_name, p.name AS column_name, p.type AS data_type, p.cid + 1 AS ordinal_position FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' ORDER BY m.name, p.cid`, + sqlite: `SELECT + NULL AS table_schema, + m.name AS table_name, + p.name AS column_name, + p.type AS data_type, + p.cid + 1 AS ordinal_position, + p.pk AS is_primary_key, + f."table" AS ref_table, + f."to" AS ref_column + FROM sqlite_master m + JOIN pragma_table_info(m.name) p + LEFT JOIN pragma_foreign_key_list(m.name) f ON f."from" = p.name + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + ORDER BY m.name, p.cid`, unknown: `SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns ORDER BY table_schema, table_name, ordinal_position`, } +type STANDARD_COLUMNS = { + table_schema: string | null + table_name: string + column_name: string + data_type: string + ordinal_position: number + is_primary_key?: number + ref_table?: string + ref_column?: string +}[] + async function fetchSchema(endpoint: string, token: string, dialect: string) { const sql = INTROSPECTION[dialect] ?? INTROSPECTION.unknown try { - return await runSQL(endpoint, token, sql) + return await runSQL(endpoint, token, sql) as STANDARD_COLUMNS } catch { /* ignore */ } } -export type ColumnInfo = { name: string; type: string; ordinal: number } +export type ColumnInfo = DatabaseSchema['tables'][number]['columns'][number] type TableInfo = { schema: string | undefined table: string columns: ColumnInfo[] - columnsMap: Map + columnsMap: Record } export async function refreshOneSchema( @@ -103,7 +127,7 @@ export async function refreshOneSchema( try { const dialect = await detectDialect(dep.sqlEndpoint, dep.sqlToken) const rows = await fetchSchema(dep.sqlEndpoint, dep.sqlToken, dialect) - // group rows + if (!rows || !rows.length) return const tableMap = new Map() for (const r of rows) { const schema = (r.table_schema as string) || undefined @@ -111,21 +135,57 @@ export async function refreshOneSchema( if (!table) continue const key = (schema ? schema + '.' : '') + table if (!tableMap.has(key)) { - tableMap.set(key, { schema, table, columns: [], columnsMap: new Map() }) + tableMap.set(key, { schema, table, columns: [], columnsMap: {} }) } - tableMap.get(key)!.columns.push({ + + const column: ColumnInfo = { name: String(r.column_name), type: String(r.data_type || ''), ordinal: Number(r.ordinal_position || 0), - }) + relation: undefined, + isPrimaryKey: r.is_primary_key ? true : false, + } + + if (r.ref_table && r.ref_column) { + column.relation = { + table: r.ref_table, + column: r.ref_column, + labelColumn: undefined, + type: 'table', // Default + } + } + + tableMap.get(key)!.columns.push(column) + } + + // Classify relations + for (const t of tableMap.values()) { + for (const col of t.columns) { + if (col.relation) { + const ref = tableMap.get(col.relation.table) + if (ref?.columns.length === 2) { + col.relation.type = 'enum' + // Find the column that is NOT the ID column + const labelCol = ref.columns.find((c) => + c.name !== col.relation?.column + ) + if (labelCol) { + col.relation.labelColumn = labelCol.name + } + } + } + } } const tables = [...tableMap.values()].map((t) => ({ ...t, - columns: t.columns.sort((a, b) => a.ordinal - b.ordinal), - columnsMap: t.columns.reduce((obj, col) => { - obj[col.name] = col - return obj - }, {} as Record), + columns: t.columns.sort((a, b) => { + if (a.isPrimaryKey && !b.isPrimaryKey) return -1 + if (!a.isPrimaryKey && b.isPrimaryKey) return 1 + return a.ordinal - b.ordinal + }), + columnsMap: Object.fromEntries( + t.columns.map((col) => [col.name, col]), + ) as Record, })) const payload = { deploymentUrl: dep.url, @@ -245,8 +305,23 @@ export const fetchTablesData = async ( } } - const query = - `SELECT * FROM ${params.table} ${whereClause} ${orderByClause} ${limitOffsetClause}` + const selectColumns = ['t.rowid AS _rowid_', 't.*'] + const joins: string[] = [] + + for (const col of columnsMap.values()) { + if (col.relation?.type === 'enum' && col.relation.labelColumn) { + const { table, column, labelColumn } = col.relation + const alias = `inline_${col.name}` + selectColumns.push(`${table}.${labelColumn} AS ${alias}`) + joins.push( + `LEFT JOIN ${table} ON t.${col.name} = ${table}.${column}`, + ) + } + } + + const query = `SELECT ${selectColumns.join(', ')} FROM ${params.table} t ${ + joins.join(' ') + } ${whereClause} ${orderByClause} ${limitOffsetClause}` const countQuery = `SELECT COUNT(*) as count FROM ${params.table} ${whereClause}` const rows = await runSQL(sqlEndpoint, sqlToken, query) diff --git a/deno.json b/deno.json index 4c34eb5..16054c6 100644 --- a/deno.json +++ b/deno.json @@ -32,10 +32,10 @@ "imports": { "./": "./", "/": "./", - "@01edu/api": "jsr:@01edu/api@^0.2.6", + "@01edu/api": "jsr:@01edu/api@^0.2.7", "@01edu/api-client": "jsr:@01edu/api-client@^0.2.6", "@01edu/api-proxy": "jsr:@01edu/api-proxy@^0.2.1", - "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.2", + "@01edu/signal-router": "npm:@01edu/signal-router@^0.2.3", "@01edu/time": "jsr:@01edu/time@^0.1.0", "@deno/vite-plugin": "npm:@deno/vite-plugin@^2.0.2", "@std/assert": "jsr:@std/assert@^1.0.19", @@ -50,7 +50,7 @@ "preact": "npm:preact@^10.29.1", "@preact/preset-vite": "npm:@preact/preset-vite@^2.10.5", "@preact/signals": "npm:@preact/signals@^2.9.0", - "@clickhouse/client": "npm:@clickhouse/client@^1.18.3", + "@clickhouse/client": "npm:@clickhouse/client@^1.18.4", "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.2.4", "tailwindcss": "npm:tailwindcss@^4.2.4", "daisyui": "npm:daisyui@^5.5.19", diff --git a/deno.lock b/deno.lock index f3167a4..ab5d7da 100644 --- a/deno.lock +++ b/deno.lock @@ -3,7 +3,7 @@ "specifiers": { "jsr:@01edu/api-client@~0.2.6": "0.2.6", "jsr:@01edu/api-proxy@~0.2.1": "0.2.1", - "jsr:@01edu/api@~0.2.6": "0.2.6", + "jsr:@01edu/api@~0.2.7": "0.2.7", "jsr:@01edu/time@0.1": "0.1.0", "jsr:@01edu/types@~0.2.6": "0.2.6", "jsr:@cd/sqlite@~0.13.1": "0.13.1", @@ -11,10 +11,8 @@ "jsr:@denosaurs/emoji@~0.3.1": "0.3.1", "jsr:@denosaurs/plug@1": "1.1.0", "jsr:@std/assert@^1.0.19": "1.0.19", - "jsr:@std/async@^1.3.0": "1.3.0", "jsr:@std/cli@^1.0.29": "1.0.29", "jsr:@std/crypto@^1.1.0": "1.1.0", - "jsr:@std/data-structures@^1.0.11": "1.0.11", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/fmt@1": "1.0.10", @@ -34,8 +32,8 @@ "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/streams@^1.1.0": "1.1.0", "jsr:@std/testing@^1.0.18": "1.0.18", - "npm:@01edu/signal-router@~0.2.2": "0.2.2_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1", - "npm:@clickhouse/client@^1.18.3": "1.18.3", + "npm:@01edu/signal-router@~0.2.3": "0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1", + "npm:@clickhouse/client@^1.18.4": "1.18.4", "npm:@deno/vite-plugin@^2.0.2": "2.0.2_vite@8.0.10", "npm:@preact/preset-vite@^2.10.5": "2.10.5_@babel+core@7.29.0_vite@8.0.10_preact@10.29.1", "npm:@preact/signals@^2.9.0": "2.9.0_preact@10.29.1", @@ -57,8 +55,8 @@ "npm:vite@^8.0.3": "8.0.10" }, "jsr": { - "@01edu/api@0.2.6": { - "integrity": "f23ec5c8fa5c78e84d67ea571d77143ff4b4e588e93212dda6953c47613279f9", + "@01edu/api@0.2.7": { + "integrity": "17198ab087829f38dafc17e08cd7fd72ce04118a0058019d6af055f5ab3ea244", "dependencies": [ "jsr:@01edu/time", "jsr:@01edu/types", @@ -128,18 +126,12 @@ "jsr:@std/internal@^1.0.12" ] }, - "@std/async@1.3.0": { - "integrity": "80485538a4f7baaa46bfe2246168069e02ed142b9f9079cd164f43bb060ad9e9" - }, "@std/cli@1.0.29": { "integrity": "fa4ef29130baa834d8a13b7d138240c3a2fcfba740bfb7afa646a360a15ec84f" }, "@std/crypto@1.1.0": { "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" }, - "@std/data-structures@1.0.11": { - "integrity": "53b98ed7efa61f107dfc14244bd2ec5557f7f7ee0bbaef6d449d7937facacb89" - }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -195,17 +187,13 @@ "integrity": "d3152f57b11666bf6358d0e127c7e3488e91178b0c2d8fbf0793e1c53cd13cb1", "dependencies": [ "jsr:@std/assert", - "jsr:@std/async", - "jsr:@std/data-structures", - "jsr:@std/fs@^1.0.23", - "jsr:@std/internal@^1.0.13", - "jsr:@std/path@^1.1.4" + "jsr:@std/internal@^1.0.13" ] } }, "npm": { - "@01edu/signal-router@0.2.2_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1": { - "integrity": "sha512-synRhAav0HJnf4hJTsWyK0Jrx2j+Nm9MRHLgeEIUgqEFrY+E0+oLsWxYvjeMnRRlWFOL1Cs8f/aPRWiO+27M7w==", + "@01edu/signal-router@0.2.3_@preact+signals@2.9.0__preact@10.29.1_preact@10.29.1": { + "integrity": "sha512-Eg2ORuigaA8i3vM+Lr4k0P7+A53vZ3mKb2wsVnbBslkItaebEFwPUDxXiz2zDCJYHd06wLdvd64r/VRQQw6XFw==", "dependencies": [ "@preact/signals", "preact" @@ -365,11 +353,11 @@ "@babel/helper-validator-identifier" ] }, - "@clickhouse/client-common@1.18.3": { - "integrity": "sha512-3axzO3zvrsGT5PzDenxgWscltYCNRDbhaHWUgdsmcM9OnW/VnZn9EarOcZogr9P82Z0mQh+Jd2x+p2K4TFD2fA==" + "@clickhouse/client-common@1.18.4": { + "integrity": "sha512-kPPtv8yQmplNAxfrAJvwBJq5dd+IWRewEbXSpUvtyEJXlrB8lt/ZH63jUS81Nmd+lK5MRvpOFXPoN3iogkvg+A==" }, - "@clickhouse/client@1.18.3": { - "integrity": "sha512-340ngdYktL8PLUBK2QKSwe0o02tYfZSz1mSn1uXCEU8TxHvwh9pnQxElf9YHumDGj5gX/IdgxPsJTGMs82Hgug==", + "@clickhouse/client@1.18.4": { + "integrity": "sha512-jjCrddI+e2OVXGh/MQY92K9r8Z/iwqaZtUXNI/MfZ/y9VGYwfbQsXRzp4Jv6w4Hgxvr4sLcz9YwIvkCBQ6X/mw==", "dependencies": [ "@clickhouse/client-common" ] @@ -1181,7 +1169,7 @@ "dependencies": [ "jsr:@01edu/api-client@~0.2.6", "jsr:@01edu/api-proxy@~0.2.1", - "jsr:@01edu/api@~0.2.6", + "jsr:@01edu/api@~0.2.7", "jsr:@01edu/time@0.1", "jsr:@deno/gfm@0.12.0", "jsr:@std/assert@^1.0.19", @@ -1192,8 +1180,8 @@ "jsr:@std/http@^1.1.0", "jsr:@std/path@^1.1.4", "jsr:@std/testing@^1.0.18", - "npm:@01edu/signal-router@~0.2.2", - "npm:@clickhouse/client@^1.18.3", + "npm:@01edu/signal-router@~0.2.3", + "npm:@clickhouse/client@^1.18.4", "npm:@deno/vite-plugin@^2.0.2", "npm:@preact/preset-vite@^2.10.5", "npm:@preact/signals@^2.9.0", diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 8321819..7156107 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -74,6 +74,121 @@ const Toast = () => { ) } +type ColumnDef = NonNullable< + ApiOutput['GET/api/deployment/schema']['tables'] +>[number]['columns'][number] + +const COLUMN_STYLES = { + pk: { color: 'text-warning', icon: Hash }, + enum: { color: 'text-success', icon: Hash }, + table: { color: 'text-info', icon: Link2 }, + default: { color: 'text-base-content/80', icon: Columns }, +} as const + +const getColumnConfig = (col?: ColumnDef) => { + const key = col?.isPrimaryKey ? 'pk' : col?.relation?.type || 'default' + return COLUMN_STYLES[key as keyof typeof COLUMN_STYLES] || + COLUMN_STYLES.default +} + +const isDateColumn = (col?: ColumnDef, value?: unknown) => { + const type = col?.type || '' + const name = col?.name || '' + return type.includes('Date') || type.includes('Time') || + (name.endsWith('At') && + (typeof value === 'number' || + (typeof value === 'string' && value.length > 0 && + !isNaN(Number(value))))) +} + +const toSafeDate = (value: unknown) => { + if (value instanceof Date) return value + const num = typeof value === 'number' ? value : Number(value) + if (!isNaN(num) && value !== '' && value !== null) { + const correctTime = num < 1e12 ? num * 1000 : num + return new Date(correctTime) + } + return new Date(String(value)) +} + +const formatCellValue = (value: unknown, col?: ColumnDef) => { + const isObj = typeof value === 'object' && value !== null + if (!value || !isDateColumn(col, value)) { + return isObj ? JSON.stringify(value) : String(value ?? '') + } + + const date = toSafeDate(value) + if (isNaN(date.getTime())) return String(value) + + return date.toLocaleString([], { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +const ColumnLabel = ({ col, row }: { col: ColumnDef; row?: AnyRecord }) => { + const config = getColumnConfig(col) + return ( + + ) +} + +const DataCell = ( + { col, row, name }: { col?: ColumnDef; row: AnyRecord; name: string }, +) => { + const config = getColumnConfig(col) + const isEnum = col?.relation?.type === 'enum' + const isTableRel = col?.relation?.type === 'table' + const isPK = col?.isPrimaryKey + const rawValue = row[name] + + const value = isEnum ? (row[`inline_${name}`] ?? rawValue) : rawValue + + return ( + + {(isTableRel || isPK) && !!rawValue + ? ( + e.stopPropagation()} + > + + {isTableRel && ( + + )} + + ) + : } + + ) +} + // Effect to fetch schema when deployment URL changes effect(() => { const dep = url.params.dep @@ -354,37 +469,38 @@ const RowNumberCell = ({ index }: { index: number }) => ( ) -const TableCell = ({ value }: { value: unknown }) => { +const TableCell = ({ value, col }: { value: unknown; col?: ColumnDef }) => { + const config = getColumnConfig(col) const isObj = typeof value === 'object' && value !== null - const stringValue = isObj ? JSON.stringify(value) : String(value ?? '') - const isTooLong = stringValue.length > 100 - - if (value === null || value === undefined || value === '') { - return ( - - null - - ) - } - - if (isObj) { - return ( - - {stringValue} - - ) - } + const displayValue = formatCellValue(value, col) + const isTooLong = displayValue.length > 100 return ( - - {stringValue} - +
+ {value === null || value === undefined || value === '' + ? ( + + null + + ) + : isObj + ? ( + + {displayValue} + + ) + : ( + + {displayValue} + + )} +
) } @@ -400,29 +516,23 @@ const EmptyRow = ({ colSpan }: { colSpan: number }) => ( ) const DataRow = ( - { row, columns, index }: { row: AnyRecord; columns: string[]; index: number }, -) => { - const tableName = url.params.table || schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) - const pk = tableDef?.columns?.[0]?.name - const rowId = pk ? String(row[pk]) : undefined - - return ( - - - - {columns.map((key) => ( - - - - ))} - - - ) -} + { row, columns, columnsDef }: { + row: AnyRecord + columns: string[] + columnsDef?: ColumnDef[] + }, +) => ( + + {columns.map((key) => ( + c.name === key)} + /> + ))} + +) const TableHeader = ( { columns }: { columns: (string | { key: string; label: string })[] }, @@ -433,11 +543,6 @@ const TableHeader = ( return ( - - - # - - {columns.length > 0 ? ( columns.map((col) => { @@ -533,15 +638,16 @@ const TableFooter = ({ rows }: { rows: AnyRecord[] }) => { } const TableContent = ({ rows }: { rows: AnyRecord[] }) => { - let columns = Object.keys(rows[0] || {}) + let columns = Object.keys(rows[0] || {}).filter((c) => c !== '_rowid_') + + const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const columnsDef = tableDef?.columns // If in tables view, use schema columns first if (url.params.tab === 'tables') { - const tableName = url.params.table || schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) - - if (tableDef?.columns) { - const schemaColumns = tableDef.columns.map((c) => c.name) + if (columnsDef) { + const schemaColumns = columnsDef.map((c) => c.name) // Add any extra columns found in rows that aren't in schema (e.g. virtual columns) const extraColumns = columns.filter((c) => !schemaColumns.includes(c)) columns = [...schemaColumns, ...extraColumns] @@ -556,10 +662,10 @@ const TableContent = ({ rows }: { rows: AnyRecord[] }) => { : ( rows.map((row, index) => ( )) )} @@ -743,23 +849,26 @@ function SchemaPanel() { Columns
- {table.columns.map((col, idx) => ( -
- - { + const config = getColumnConfig(col) + return ( +
- {col.name} - - - {col.type} - -
- ))} + + + {col.name} + + + {col.type} + +
+ ) + })}
)} @@ -991,11 +1100,10 @@ const dateFmtConfig = { fractionalSecondDigits: 3, } as const -const safeFormatTimestamp = (timestamp: Date) => { - // If timestamp is close to epoch (1970), assume it's in seconds and convert to ms - const time = timestamp.getTime() - const correctTime = time < 1000000000000 ? time * 1000 : time // 1e12 ms is roughly year 2001, 1e10 sec is year 2286 - return new Date(correctTime).toLocaleString('en-UK', dateFmtConfig) +const safeFormatTimestamp = (timestamp: Date | number | string) => { + const date = toSafeDate(timestamp) + if (isNaN(date.getTime())) return String(timestamp) + return date.toLocaleString('en-UK', dateFmtConfig) .split(', ').reverse().join(' ') } @@ -1882,9 +1990,11 @@ function MetricsViewer() { effect(() => { const rowId = url.params['row-id'] const dep = url.params.dep + const relTable = url.params.rt if (dep && rowId) { - const tableName = url.params.table || schema.data?.tables?.[0]?.table + const tableName = relTable || url.params.table || + schema.data?.tables?.[0]?.table const tableDef = schema.data?.tables?.find((t) => t.table === tableName) const pk = tableDef?.columns?.[0]?.name @@ -1933,12 +2043,14 @@ const RowDetails = () => { ) } + const tableName = url.params.rt || url.params.table || + schema.data?.tables?.[0]?.table + const tableDef = schema.data?.tables?.find((t) => t.table === tableName) + const onUpdateRow = async (e: Event) => { e.preventDefault() const row = rowDetailsData.data?.rows?.[0] as AnyRecord | undefined if (!row) return - const tableName = url.params.table || schema.data?.tables?.[0]?.table - const tableDef = schema.data?.tables?.find((t) => t.table === tableName) const pk = tableDef?.columns?.[0]?.name if (!tableName || !pk) { toast('Could not identify table or primary key', 'error') @@ -1983,7 +2095,7 @@ const RowDetails = () => { }) toast('Row updated successfully') tableData.fetch() - navigate({ params: { drawer: null, 'row-id': null } }) + navigate({ params: { drawer: null, 'row-id': null, rt: null } }) } catch (err) { toast(err instanceof Error ? err.message : String(err), 'error') } @@ -1992,9 +2104,11 @@ const RowDetails = () => { return (