Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
14 changes: 13 additions & 1 deletion api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
ARR,
type Asserted,
BOOL,
LIST,
NUM,
OBJ,
optional,
Expand Down Expand Up @@ -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(),
Expand Down
107 changes: 91 additions & 16 deletions api/sql.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
DatabaseSchema,
DatabaseSchemasCollection,
Deployment,
DeploymentsCollection,
Expand Down Expand Up @@ -75,25 +76,48 @@ async function detectDialect(endpoint: string, token: string): Promise<string> {
// Introspection queries per dialect returning columns list
// Standardized output fields: table_schema (nullable), table_name, column_name, data_type, ordinal_position
const INTROSPECTION: Record<string, string> = {
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<string, ColumnInfo>
columnsMap: Record<string, ColumnInfo>
}

export async function refreshOneSchema(
Expand All @@ -103,29 +127,65 @@ 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<string, TableInfo>()
for (const r of rows) {
const schema = (r.table_schema as string) || undefined
const table = r.table_name as string
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<string, ColumnInfo>),
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<string, ColumnInfo>,
}))
const payload = {
deploymentUrl: dep.url,
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
42 changes: 15 additions & 27 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading