Skip to content

Commit 9f2d747

Browse files
committed
drivers: fetch_indexes / fetch_foreign_keys (DDL UI #2)
Implement the new Connection trait methods on each driver so the Structure tab can load existing indexes and FK constraints when opening a table for editing. - Postgres: pg_index + pg_constraint joined with pg_class / pg_attribute. array_agg(... ORDER BY ordinality) preserves the column order on composite indexes / FKs. confdeltype / confupdtype single-char codes map through pg_action_char_to_keyword to canonical SQL action keywords ("RESTRICT", "CASCADE", etc.). - MySQL: information_schema.statistics for indexes (grouped by index_name in Rust since sqlx doesn't aggregate ordered column arrays natively); key_column_usage JOIN referential_constraints for FKs. "PRIMARY" maps to is_primary; non_unique inverts to unique. - SQLite: PRAGMA index_list + per-index PRAGMA index_info. Implicit PK indexes that use the rowid alias don't appear in index_list so they're synthesised from PRAGMA table_info when no origin='pk' row exists. PRAGMA foreign_key_list grouped by synthetic id since SQLite doesn't store user-visible FK names — we render "fk_{table}_{id}" stably.
1 parent ba0bf98 commit 9f2d747

3 files changed

Lines changed: 292 additions & 6 deletions

File tree

linux/crates/drivers/mysql/src/lib.rs

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use sqlx::{Column, Pool, Row, TypeInfo};
88
use futures::stream::StreamExt;
99

1010
use tablepro_core::{
11-
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, MAX_QUERY_ROWS, QueryResult,
12-
TableInfo, Value,
11+
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo,
12+
MAX_QUERY_ROWS, QueryResult, TableInfo, Value,
1313
};
1414

1515
pub struct MysqlDriver;
@@ -182,6 +182,94 @@ impl Connection for MysqlConnection {
182182
Ok(affected)
183183
}
184184

185+
async fn fetch_indexes(&self, schema: Option<&str>, table: &str) -> Result<Vec<IndexInfo>, DriverError> {
186+
// information_schema.statistics returns one row per (index,
187+
// column). Group rows by index_name in Rust because sqlx can't
188+
// GROUP_CONCAT-then-split natively for ordered column lists.
189+
// PRIMARY is the literal index name MySQL uses for the PK.
190+
let rows = sqlx::query(
191+
"SELECT
192+
CAST(index_name AS CHAR) AS index_name,
193+
non_unique,
194+
CAST(column_name AS CHAR) AS column_name
195+
FROM information_schema.statistics
196+
WHERE table_schema = COALESCE(?, DATABASE())
197+
AND table_name = ?
198+
ORDER BY index_name, seq_in_index",
199+
)
200+
.bind(schema)
201+
.bind(table)
202+
.fetch_all(&self.pool)
203+
.await
204+
.map_err(map_sqlx_error)?;
205+
let mut by_name: std::collections::BTreeMap<String, IndexInfo> = std::collections::BTreeMap::new();
206+
for r in rows {
207+
let name: String = r.get(0);
208+
let non_unique: i64 = r.try_get(1).unwrap_or(0);
209+
let column: String = r.get(2);
210+
let entry = by_name.entry(name.clone()).or_insert_with(|| IndexInfo {
211+
name: name.clone(),
212+
columns: Vec::new(),
213+
unique: non_unique == 0,
214+
primary: name == "PRIMARY",
215+
});
216+
entry.columns.push(column);
217+
}
218+
Ok(by_name.into_values().collect())
219+
}
220+
221+
async fn fetch_foreign_keys(&self, schema: Option<&str>, table: &str) -> Result<Vec<ForeignKeyInfo>, DriverError> {
222+
// key_column_usage gives us the FK column ↔ referenced column
223+
// pairs (one row per (constraint, ordinal)); referential_constraints
224+
// adds the ON DELETE / ON UPDATE rules. Group by constraint_name
225+
// in Rust to assemble the column lists.
226+
let rows = sqlx::query(
227+
"SELECT
228+
CAST(kcu.constraint_name AS CHAR),
229+
CAST(kcu.column_name AS CHAR),
230+
CAST(kcu.referenced_table_name AS CHAR),
231+
CAST(kcu.referenced_table_schema AS CHAR),
232+
CAST(kcu.referenced_column_name AS CHAR),
233+
CAST(rc.delete_rule AS CHAR),
234+
CAST(rc.update_rule AS CHAR)
235+
FROM information_schema.key_column_usage kcu
236+
JOIN information_schema.referential_constraints rc
237+
ON rc.constraint_name = kcu.constraint_name
238+
AND rc.constraint_schema = kcu.constraint_schema
239+
WHERE kcu.table_schema = COALESCE(?, DATABASE())
240+
AND kcu.table_name = ?
241+
AND kcu.referenced_table_name IS NOT NULL
242+
ORDER BY kcu.constraint_name, kcu.ordinal_position",
243+
)
244+
.bind(schema)
245+
.bind(table)
246+
.fetch_all(&self.pool)
247+
.await
248+
.map_err(map_sqlx_error)?;
249+
let mut by_name: std::collections::BTreeMap<String, ForeignKeyInfo> = std::collections::BTreeMap::new();
250+
for r in rows {
251+
let name: String = r.get(0);
252+
let column: String = r.get(1);
253+
let ref_table: String = r.get(2);
254+
let ref_schema: Option<String> = r.try_get(3).unwrap_or(None);
255+
let ref_column: String = r.get(4);
256+
let delete_rule: Option<String> = r.try_get(5).ok();
257+
let update_rule: Option<String> = r.try_get(6).ok();
258+
let entry = by_name.entry(name.clone()).or_insert_with(|| ForeignKeyInfo {
259+
name: name.clone(),
260+
columns: Vec::new(),
261+
ref_schema,
262+
ref_table,
263+
ref_columns: Vec::new(),
264+
on_delete: delete_rule.filter(|s| !s.is_empty() && s != "NO ACTION"),
265+
on_update: update_rule.filter(|s| !s.is_empty() && s != "NO ACTION"),
266+
});
267+
entry.columns.push(column);
268+
entry.ref_columns.push(ref_column);
269+
}
270+
Ok(by_name.into_values().collect())
271+
}
272+
185273
async fn ping(&self) -> Result<(), DriverError> {
186274
sqlx::query("SELECT 1")
187275
.execute(&self.pool)

linux/crates/drivers/postgres/src/lib.rs

Lines changed: 106 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use sqlx::{Column, Pool, Postgres, Row, TypeInfo};
88
use futures::stream::StreamExt;
99

1010
use tablepro_core::{
11-
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, MAX_QUERY_ROWS, QueryResult,
12-
TableInfo, Value,
11+
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo,
12+
MAX_QUERY_ROWS, QueryResult, TableInfo, Value,
1313
};
1414

1515
pub struct PgDriver;
@@ -207,6 +207,95 @@ impl Connection for PgConnection {
207207
Ok(affected)
208208
}
209209

210+
async fn fetch_indexes(&self, schema: Option<&str>, table: &str) -> Result<Vec<IndexInfo>, DriverError> {
211+
// pg_index + pg_class + pg_attribute join. `array_agg ORDER BY
212+
// ordinality` keeps the column order deterministic; pg_index
213+
// stores `indkey` as an int2vector positional reference so we
214+
// unnest with `WITH ORDINALITY` to capture position.
215+
let rows = sqlx::query(
216+
"SELECT
217+
i.relname AS index_name,
218+
ix.indisunique,
219+
ix.indisprimary,
220+
array_agg(a.attname ORDER BY k.ordinality) AS columns
221+
FROM pg_catalog.pg_class t
222+
JOIN pg_catalog.pg_namespace n ON t.relnamespace = n.oid
223+
JOIN pg_catalog.pg_index ix ON ix.indrelid = t.oid
224+
JOIN pg_catalog.pg_class i ON i.oid = ix.indexrelid
225+
JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ordinality) ON true
226+
JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum
227+
WHERE n.nspname = COALESCE($2, current_schema())
228+
AND t.relname = $1
229+
AND a.attnum > 0
230+
GROUP BY i.relname, ix.indisunique, ix.indisprimary
231+
ORDER BY i.relname",
232+
)
233+
.bind(table)
234+
.bind(schema)
235+
.fetch_all(&self.pool)
236+
.await
237+
.map_err(map_sqlx_error)?;
238+
Ok(rows
239+
.into_iter()
240+
.map(|r| IndexInfo {
241+
name: r.get::<String, _>(0),
242+
unique: r.get::<bool, _>(1),
243+
primary: r.get::<bool, _>(2),
244+
columns: r.get::<Vec<String>, _>(3),
245+
})
246+
.collect())
247+
}
248+
249+
async fn fetch_foreign_keys(&self, schema: Option<&str>, table: &str) -> Result<Vec<ForeignKeyInfo>, DriverError> {
250+
// pg_constraint with contype = 'f'. confkey arrays are parallel
251+
// to conkey via ordinality; the LATERAL join pairs them so the
252+
// FK column ↔ referenced column mapping survives composite
253+
// FKs. confdeltype / confupdtype are single chars normalised
254+
// to canonical SQL keyword strings.
255+
let rows = sqlx::query(
256+
"SELECT
257+
c.conname AS fk_name,
258+
array_agg(a.attname ORDER BY kf.ordinality) AS columns,
259+
fn_class.relname AS ref_table,
260+
fn_ns.nspname AS ref_schema,
261+
array_agg(fa.attname ORDER BY kf.ordinality) AS ref_columns,
262+
c.confdeltype,
263+
c.confupdtype
264+
FROM pg_catalog.pg_constraint c
265+
JOIN pg_catalog.pg_class t ON t.oid = c.conrelid
266+
JOIN pg_catalog.pg_namespace n ON n.oid = t.relnamespace
267+
JOIN pg_catalog.pg_class fn_class ON fn_class.oid = c.confrelid
268+
JOIN pg_catalog.pg_namespace fn_ns ON fn_ns.oid = fn_class.relnamespace
269+
JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS kf(attnum, ordinality) ON true
270+
JOIN pg_catalog.pg_attribute a ON a.attrelid = t.oid AND a.attnum = kf.attnum
271+
JOIN LATERAL unnest(c.confkey) WITH ORDINALITY AS kfr(attnum, ordinality)
272+
ON kfr.ordinality = kf.ordinality
273+
JOIN pg_catalog.pg_attribute fa ON fa.attrelid = c.confrelid AND fa.attnum = kfr.attnum
274+
WHERE c.contype = 'f'
275+
AND n.nspname = COALESCE($2, current_schema())
276+
AND t.relname = $1
277+
GROUP BY c.conname, fn_class.relname, fn_ns.nspname, c.confdeltype, c.confupdtype
278+
ORDER BY c.conname",
279+
)
280+
.bind(table)
281+
.bind(schema)
282+
.fetch_all(&self.pool)
283+
.await
284+
.map_err(map_sqlx_error)?;
285+
Ok(rows
286+
.into_iter()
287+
.map(|r| ForeignKeyInfo {
288+
name: r.get::<String, _>(0),
289+
columns: r.get::<Vec<String>, _>(1),
290+
ref_table: r.get::<String, _>(2),
291+
ref_schema: r.try_get::<Option<String>, _>(3).unwrap_or(None),
292+
ref_columns: r.get::<Vec<String>, _>(4),
293+
on_delete: pg_action_char_to_keyword(r.try_get::<String, _>(5).ok().as_deref().unwrap_or("a")),
294+
on_update: pg_action_char_to_keyword(r.try_get::<String, _>(6).ok().as_deref().unwrap_or("a")),
295+
})
296+
.collect())
297+
}
298+
210299
async fn ping(&self) -> Result<(), DriverError> {
211300
sqlx::query("SELECT 1")
212301
.execute(&self.pool)
@@ -389,6 +478,21 @@ fn qualified(schema: Option<&str>, table: &str) -> String {
389478
}
390479
}
391480

481+
/// Map `pg_constraint.confdeltype` / `confupdtype` single-char codes
482+
/// to canonical SQL action keywords. Returns `None` for the default
483+
/// "no action" so the FK builder can omit the redundant ON clause.
484+
fn pg_action_char_to_keyword(code: &str) -> Option<String> {
485+
match code {
486+
"r" => Some("RESTRICT".into()),
487+
"c" => Some("CASCADE".into()),
488+
"n" => Some("SET NULL".into()),
489+
"d" => Some("SET DEFAULT".into()),
490+
// 'a' = NO ACTION is the default; surface as None so the
491+
// generated DDL stays clean.
492+
_ => None,
493+
}
494+
}
495+
392496
fn map_sqlx_error(err: sqlx::Error) -> DriverError {
393497
use sqlx::Error::*;
394498
match err {

linux/crates/drivers/sqlite/src/lib.rs

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use sqlx::{Column, Pool, Row, Sqlite, TypeInfo};
88
use futures::stream::StreamExt;
99

1010
use tablepro_core::{
11-
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, MAX_QUERY_ROWS, QueryResult,
12-
TableInfo, Value,
11+
ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo,
12+
MAX_QUERY_ROWS, QueryResult, TableInfo, Value,
1313
};
1414

1515
pub struct SqliteDriver;
@@ -196,6 +196,100 @@ impl Connection for SqliteConnection {
196196
Ok(affected)
197197
}
198198

199+
async fn fetch_indexes(&self, _schema: Option<&str>, table: &str) -> Result<Vec<IndexInfo>, DriverError> {
200+
// SQLite catalog access is via PRAGMAs — they're scoped to the
201+
// current database file (no schema parameter needed). For each
202+
// entry from index_list we issue an index_info to get column
203+
// ordering. PK index doesn't always show up in index_list (a
204+
// bare INTEGER PRIMARY KEY uses the rowid alias, no real
205+
// index), so we synthesise one from table_info if missing.
206+
let list = sqlx::query(&format!("PRAGMA index_list({})", quote_ident(table)))
207+
.fetch_all(&self.pool)
208+
.await
209+
.map_err(map_sqlx_error)?;
210+
let mut out: Vec<IndexInfo> = Vec::with_capacity(list.len());
211+
let mut saw_primary = false;
212+
for r in list {
213+
let name: String = r.try_get(1).map_err(map_sqlx_error)?;
214+
let unique: i64 = r.try_get(2).unwrap_or(0);
215+
let origin: String = r.try_get(3).unwrap_or_default();
216+
let primary = origin == "pk";
217+
if primary {
218+
saw_primary = true;
219+
}
220+
let info_rows = sqlx::query(&format!("PRAGMA index_info({})", quote_ident(&name)))
221+
.fetch_all(&self.pool)
222+
.await
223+
.map_err(map_sqlx_error)?;
224+
let columns: Vec<String> = info_rows
225+
.into_iter()
226+
.map(|c| c.try_get::<String, _>(2).unwrap_or_default())
227+
.collect();
228+
out.push(IndexInfo {
229+
name,
230+
columns,
231+
unique: unique == 1,
232+
primary,
233+
});
234+
}
235+
if !saw_primary {
236+
// Synthesise the implicit PK index from PRAGMA table_info
237+
// so the UI can render PK columns even when SQLite chose
238+
// the rowid-alias path.
239+
let table_info = sqlx::query(&format!("PRAGMA table_info({})", quote_ident(table)))
240+
.fetch_all(&self.pool)
241+
.await
242+
.map_err(map_sqlx_error)?;
243+
let pk_cols: Vec<String> = table_info
244+
.into_iter()
245+
.filter(|r| r.try_get::<i64, _>(5).unwrap_or(0) > 0)
246+
.map(|r| r.try_get::<String, _>(1).unwrap_or_default())
247+
.collect();
248+
if !pk_cols.is_empty() {
249+
out.push(IndexInfo {
250+
name: "PRIMARY".into(),
251+
columns: pk_cols,
252+
unique: true,
253+
primary: true,
254+
});
255+
}
256+
}
257+
Ok(out)
258+
}
259+
260+
async fn fetch_foreign_keys(&self, _schema: Option<&str>, table: &str) -> Result<Vec<ForeignKeyInfo>, DriverError> {
261+
// PRAGMA foreign_key_list returns one row per (constraint, ordinal)
262+
// grouped by the synthetic `id` field. Constraint names aren't
263+
// stored by SQLite, so we synthesise "fk_{table}_{id}" — stable
264+
// across re-runs of the same schema. Group by id and build
265+
// ForeignKeyInfo.
266+
let rows = sqlx::query(&format!("PRAGMA foreign_key_list({})", quote_ident(table)))
267+
.fetch_all(&self.pool)
268+
.await
269+
.map_err(map_sqlx_error)?;
270+
let mut by_id: std::collections::BTreeMap<i64, ForeignKeyInfo> = std::collections::BTreeMap::new();
271+
for r in rows {
272+
let id: i64 = r.try_get(0).unwrap_or(0);
273+
let ref_table: String = r.try_get(2).unwrap_or_default();
274+
let from_col: String = r.try_get(3).unwrap_or_default();
275+
let to_col: String = r.try_get(4).unwrap_or_default();
276+
let on_update: String = r.try_get(5).unwrap_or_default();
277+
let on_delete: String = r.try_get(6).unwrap_or_default();
278+
let entry = by_id.entry(id).or_insert_with(|| ForeignKeyInfo {
279+
name: format!("fk_{table}_{id}"),
280+
columns: Vec::new(),
281+
ref_schema: None,
282+
ref_table,
283+
ref_columns: Vec::new(),
284+
on_delete: Some(on_delete.clone()).filter(|s| !s.is_empty() && s != "NO ACTION"),
285+
on_update: Some(on_update.clone()).filter(|s| !s.is_empty() && s != "NO ACTION"),
286+
});
287+
entry.columns.push(from_col);
288+
entry.ref_columns.push(to_col);
289+
}
290+
Ok(by_id.into_values().collect())
291+
}
292+
199293
async fn ping(&self) -> Result<(), DriverError> {
200294
sqlx::query("SELECT 1")
201295
.execute(&self.pool)

0 commit comments

Comments
 (0)