From 5e7f428bcd2ea846d9edb9fc0a0b7b7e2d9160d1 Mon Sep 17 00:00:00 2001 From: Adam J Esslinger Date: Fri, 9 Oct 2026 10:44:55 -0400 Subject: [PATCH] feat: add numeric_precision/scale and seq_in_fk for DDL type fidelity (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new optional fields to the plugin's get_columns and get_foreign_keys responses so Tabularis generates DDL with correct type modifiers and composite FK column order (Tabularis #840, PR #939): get_columns / get_view_columns / get_all_columns_batch: - Added c.numeric_precision and c.numeric_scale to all three information_schema.columns queries. - row_to_table_column extracts them as Option and includes them as optional JSON fields (only when present, matching the character_maximum_length pattern). Without these, numeric(10,2) columns generate as bare numeric in CREATE TABLE DDL. get_foreign_keys / get_all_foreign_keys_batch: - Changed unnest(con.conkey, con.confkey) to WITH ORDINALITY to get key_seq — the true composite-key column order, independent of raw attnum. Changed ORDER BY from cols.src_attnum to cols.key_seq. - row_to_foreign_key extracts key_seq as Option, narrows to Option, and includes seq_in_fk as an optional JSON field. Without this, composite FK columns may be in the wrong order in generated DDL. All fields are additive (optional, omitted when absent) — the current host ignores unknown fields and the PR #939 host uses null-safe checks, so this is safe to ship before or after #939 lands. Verified: 400 unit tests green, 3 live tests green, 30 live_db tests green, 83/83 cross-repo parity suite unchanged, clippy + fmt clean. Manual verification: numeric(10,2) → prec=10 scale=2, composite FK (y,x) → seq_in_fk=1,2 ordered by constraint definition. --- src/handlers/metadata.rs | 57 ++++++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/src/handlers/metadata.rs b/src/handlers/metadata.rs index 1b038b1..acef326 100644 --- a/src/handlers/metadata.rs +++ b/src/handlers/metadata.rs @@ -123,6 +123,8 @@ async fn fetch_table_columns( c.column_default::text, c.is_identity::text, c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, d.description::text AS comment, (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) FROM pg_enum e @@ -269,6 +271,11 @@ fn row_to_table_column(r: &tokio_postgres::Row) -> Value { .try_get::<_, Option>("character_maximum_length") .ok() .flatten(); + let numeric_precision: Option = r + .try_get::<_, Option>("numeric_precision") + .ok() + .flatten(); + let numeric_scale: Option = r.try_get::<_, Option>("numeric_scale").ok().flatten(); let is_pk: bool = r.try_get("is_pk").unwrap_or(false); let comment: Option = r.try_get("comment").ok().flatten(); @@ -304,6 +311,16 @@ fn row_to_table_column(r: &tokio_postgres::Row) -> Value { .unwrap() .insert("character_maximum_length".to_string(), json!(len)); } + if let Some(p) = numeric_precision { + col.as_object_mut() + .unwrap() + .insert("numeric_precision".to_string(), json!(p)); + } + if let Some(s) = numeric_scale { + col.as_object_mut() + .unwrap() + .insert("numeric_scale".to_string(), json!(s)); + } if let Some(c) = comment { col.as_object_mut() .unwrap() @@ -370,13 +387,14 @@ pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' - END::text AS delete_rule + END::text AS delete_rule, + cols.key_seq FROM pg_constraint con JOIN pg_class src_cl ON src_cl.oid = con.conrelid JOIN pg_namespace src_nsp ON src_nsp.oid = src_cl.relnamespace JOIN pg_class ref_cl ON ref_cl.oid = con.confrelid JOIN pg_namespace ref_nsp ON ref_nsp.oid = ref_cl.relnamespace - JOIN unnest(con.conkey, con.confkey) AS cols(src_attnum, ref_attnum) ON true + JOIN unnest(con.conkey, con.confkey) WITH ORDINALITY AS cols(src_attnum, ref_attnum, key_seq) ON true JOIN pg_attribute src_att ON src_att.attrelid = src_cl.oid AND src_att.attnum = cols.src_attnum @@ -389,7 +407,7 @@ pub async fn get_foreign_keys(id: Value, params: &Value) -> Value { AND con.conparentid = 0 AND src_nsp.nspname = $1 AND src_cl.relname = $2 - ORDER BY con.conname, cols.src_attnum + ORDER BY con.conname, cols.key_seq "#; match client::query_rows(&conn_params, query, &[&schema, &table]).await { @@ -411,15 +429,33 @@ fn row_to_foreign_key(r: &tokio_postgres::Row) -> Value { let ref_column: String = r.try_get("foreign_column_name").unwrap_or_default(); let on_update: Option = r.try_get("update_rule").ok().flatten(); let on_delete: Option = r.try_get("delete_rule").ok().flatten(); + // WITH ORDINALITY yields the position within conkey/confkey — the true + // composite-key column order, independent of raw attnum (#840/#142). + // The ordinal column is a Postgres bigint (i64); narrow to i32 for the + // wire field. A composite key's position is always small, so the cast is + // lossless. + let seq_in_fk: Option = r + .try_get::<_, Option>("key_seq") + .ok() + .flatten() + .and_then(|v| i32::try_from(v).ok()); - json!({ + let mut fk = json!({ "name": name, "column_name": column_name, "ref_table": ref_table, "ref_column": ref_column, "on_delete": on_delete, "on_update": on_update, - }) + }); + + if let Some(seq) = seq_in_fk { + fk.as_object_mut() + .unwrap() + .insert("seq_in_fk".to_string(), json!(seq)); + } + + fk } pub async fn get_indexes(id: Value, params: &Value) -> Value { @@ -569,6 +605,8 @@ pub async fn get_view_columns(id: Value, params: &Value) -> Value { c.column_default::text, c.is_identity::text, c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) FROM pg_enum e JOIN pg_type t ON t.oid = e.enumtypid @@ -1174,6 +1212,8 @@ async fn fetch_all_columns( c.column_default::text, c.is_identity::text, c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, d.description::text AS comment, (SELECT string_agg('''' || replace(e.enumlabel, '''', '''''') || '''', ',' ORDER BY e.enumsortorder) FROM pg_enum e @@ -1238,13 +1278,14 @@ async fn fetch_all_foreign_keys( WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' - END::text AS delete_rule + END::text AS delete_rule, + cols.key_seq FROM pg_constraint con JOIN pg_class src_cl ON src_cl.oid = con.conrelid JOIN pg_namespace src_nsp ON src_nsp.oid = src_cl.relnamespace JOIN pg_class ref_cl ON ref_cl.oid = con.confrelid JOIN pg_namespace ref_nsp ON ref_nsp.oid = ref_cl.relnamespace - JOIN unnest(con.conkey, con.confkey) AS cols(src_attnum, ref_attnum) ON true + JOIN unnest(con.conkey, con.confkey) WITH ORDINALITY AS cols(src_attnum, ref_attnum, key_seq) ON true JOIN pg_attribute src_att ON src_att.attrelid = src_cl.oid AND src_att.attnum = cols.src_attnum @@ -1256,7 +1297,7 @@ async fn fetch_all_foreign_keys( WHERE con.contype = 'f' AND con.conparentid = 0 AND src_nsp.nspname = $1 - ORDER BY src_cl.relname, con.conname, cols.src_attnum + ORDER BY src_cl.relname, con.conname, cols.key_seq "#; let rows = client::query_rows(conn_params, query, &[&schema]).await?;