umbral_core/inspect.rs
1//! `inspectdb` — introspect an existing database into umbral models.
2//!
3//! The porting payoff. A team with an existing
4//! SQLite database points `inspectdb` at it and gets a `models.rs`
5//! with `#[derive(Model)]` structs plus a `0001_initial.json`
6//! migration carrying one `CreateTable` op per table. The migration
7//! is recorded as applied in `umbral_migrations` so the next `migrate`
8//! is a no-op until the user actually changes a model.
9//!
10//! After that, the introspected schema enters the M5 declare →
11//! migrate → change → migrate loop with no separate code path.
12//!
13//! ## Backend coverage
14//!
15//! - **SQLite (M6 v1).** [`introspect_pool`] reads `sqlite_master` for
16//! table names and `PRAGMA table_info` for column descriptors.
17//! - **Postgres (Phase 3 of the rollout).** [`introspect_pool_pg`]
18//! reads `information_schema.tables` / `information_schema.columns`
19//! and joins `information_schema.table_constraints` + `key_column_usage`
20//! for primary keys. Same `IntrospectedSchema` output; the
21//! downstream pipeline (`render_models` / `render_initial_migration`
22//! / `write_outputs`) is backend-agnostic.
23//!
24//! ## M6 v1 scope
25//!
26//! - **Output.** A flat `models.rs` plus `migrations/0001_initial.json`
27//! in the user-chosen output directory. No `Cargo.toml`, no `lib.rs`
28//! with a `Plugin` impl: the plugin trait isn't shipped until M7,
29//! so M6 v1 leaves the wiring (one `mod models;` plus one
30//! `.model::<T>()` per generated struct) to the user. M7 turns the
31//! output into a self-contained plugin crate.
32//! - **Type mapping.** Covers the M5 [`SqlType`] catalogue
33//! (integers, floats, bool, text, date / time / timestamptz, uuid)
34//! plus their nullable variants. Anything else (NUMERIC, JSON,
35//! BYTEA, arrays, custom types) returns
36//! [`InspectError::UnsupportedColumnType`] with the table / column
37//! names; the user fixes by-hand or waits for the field-type
38//! catalogue to grow.
39//! - **FKs and indexes.** Not yet read out. The CreateTable op carries
40//! columns only; FK / index detection lands with the field-level
41//! support in [`crate::orm`].
42//!
43//! See [`docs/specs/07-inspectdb.md`] for the eventual target shape
44//! and the deferred items.
45//!
46//! [`DatabaseBackend`]: crate::backend::DatabaseBackend
47//! [`SqlType`]: crate::orm::SqlType
48
49use std::path::{Path, PathBuf};
50
51use sqlx::{PgPool, Row, SqlitePool};
52use umbral_casing::{pascal_case_from_table, to_snake_case};
53
54use crate::migrate::{self, Column, MigrationFile, ModelMeta, Operation, Snapshot};
55use crate::orm::SqlType;
56
57/// Default plugin name the generated migration is filed under. Matches
58/// [`crate::migrate::APP_PLUGIN_NAME`] so the produced
59/// `0001_initial.json` lands inside the same `migrations/app/`
60/// directory the M5 engine reads from. M7 lifts this once the user can
61/// choose a real plugin name via `--plugin`.
62pub const INSPECTED_PLUGIN_NAME: &str = migrate::APP_PLUGIN_NAME;
63
64/// Default filename for the introspected initial migration.
65pub const INITIAL_MIGRATION_ID: &str = "0001_initial";
66
67/// The introspection result. A flat list of tables, each with its
68/// columns in declaration order. Indexes and foreign keys are omitted
69/// at M6 v1 (the field types they target don't exist yet).
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct IntrospectedSchema {
72 pub tables: Vec<IntrospectedTable>,
73}
74
75/// One introspected table.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct IntrospectedTable {
78 /// The SQL table name as it appears in the database.
79 pub table: String,
80 /// The struct name the renderer will use. Defaults to the table
81 /// name in UpperCamelCase; the M6 v1 importer does not strip
82 /// prefixes (deferred to M7's `--strip-prefix` flag).
83 pub name: String,
84 /// One descriptor per column, in declaration order.
85 pub columns: Vec<IntrospectedColumn>,
86}
87
88/// One introspected column.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct IntrospectedColumn {
91 pub name: String,
92 pub ty: SqlType,
93 pub primary_key: bool,
94 pub nullable: bool,
95}
96
97/// Errors `inspectdb` can produce. Carries enough detail for the CLI
98/// to print a single-line diagnostic with the offending table and
99/// column.
100#[derive(Debug)]
101pub enum InspectError {
102 /// IO error reading or writing a generated file.
103 Io(std::io::Error),
104 /// JSON serialisation error pretty-printing the generated migration.
105 Json(serde_json::Error),
106 /// sqlx error executing the introspection queries.
107 Sqlx(sqlx::Error),
108 /// The introspection ran but found no tables. Surfaced so the CLI
109 /// can print "nothing to import" instead of writing empty files.
110 NoTables,
111 /// A column's SQL type isn't in the M6 v1 mapping table. Holds the
112 /// table / column / raw SQL type so the user can decide whether to
113 /// add a field type, edit the generated code, or wait for the
114 /// catalogue to grow.
115 UnsupportedColumnType {
116 table: String,
117 column: String,
118 sql_type: String,
119 },
120 /// Pass-through for migration-engine failures (e.g. recording the
121 /// initial migration as applied).
122 Migrate(migrate::MigrateError),
123}
124
125impl std::fmt::Display for InspectError {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
128 InspectError::Io(e) => write!(f, "umbral inspectdb: io: {e}"),
129 InspectError::Json(e) => write!(f, "umbral inspectdb: json: {e}"),
130 InspectError::Sqlx(e) => write!(f, "umbral inspectdb: sqlx: {e}"),
131 InspectError::NoTables => write!(
132 f,
133 "umbral inspectdb: no tables found in the database (nothing to import)"
134 ),
135 InspectError::UnsupportedColumnType {
136 table,
137 column,
138 sql_type,
139 } => write!(
140 f,
141 "umbral inspectdb: column `{table}.{column}` has unsupported SQL type `{sql_type}`; \
142 add a matching SqlType variant or edit the generated model by hand"
143 ),
144 InspectError::Migrate(e) => write!(f, "umbral inspectdb: migrate: {e}"),
145 }
146 }
147}
148
149impl std::error::Error for InspectError {}
150
151impl From<std::io::Error> for InspectError {
152 fn from(e: std::io::Error) -> Self {
153 Self::Io(e)
154 }
155}
156
157impl From<sqlx::Error> for InspectError {
158 fn from(e: sqlx::Error) -> Self {
159 Self::Sqlx(e)
160 }
161}
162
163impl From<serde_json::Error> for InspectError {
164 fn from(e: serde_json::Error) -> Self {
165 Self::Json(e)
166 }
167}
168
169impl From<migrate::MigrateError> for InspectError {
170 fn from(e: migrate::MigrateError) -> Self {
171 Self::Migrate(e)
172 }
173}
174
175/// CLI-driven options. The CLI subcommand wires its flags into this
176/// struct and hands it to [`inspectdb`].
177#[derive(Debug, Clone)]
178pub struct InspectOptions {
179 /// Directory the generated files are written under. `models.rs`
180 /// lands at the root; the migration lands at
181 /// `<output>/migrations/<INSPECTED_PLUGIN_NAME>/0001_initial.json`.
182 pub output: PathBuf,
183 /// Mark `0001_initial` as applied in `umbral_migrations` after
184 /// writing it. The right default when the target database already
185 /// has tables (running the migration would fail). Off for empty
186 /// databases.
187 pub mark_applied: bool,
188}
189
190/// Summary returned to the CLI. Counts that the caller can render as a
191/// one-line "imported N tables / M columns" message.
192#[derive(Debug, Clone, Default)]
193pub struct InspectReport {
194 pub tables: usize,
195 pub columns: usize,
196 pub models_path: PathBuf,
197 pub migration_path: PathBuf,
198}
199
200// =========================================================================
201// Top-level entry points. Bodies filled in by the M6 fan-out subagents.
202// =========================================================================
203
204/// Run the full `inspectdb` pipeline against the ambient pool:
205/// introspect (dispatching on the active backend), render `models.rs`,
206/// render `0001_initial.json`, write both to `opts.output`, and
207/// optionally mark applied.
208///
209/// Phase 3 of the Postgres rollout taught this entry point to dispatch
210/// on `DbPool` — the SQLite path uses `PRAGMA table_info`; the
211/// Postgres path uses `information_schema`. The downstream pipeline
212/// (rendering + writing) is backend-agnostic and runs the same way.
213pub async fn inspectdb(opts: InspectOptions) -> Result<InspectReport, InspectError> {
214 let schema = match crate::db::pool_dispatched() {
215 crate::db::DbPool::Sqlite(pool) => introspect_pool(pool).await?,
216 crate::db::DbPool::Postgres(pool) => introspect_pool_pg(pool).await?,
217 };
218 if schema.tables.is_empty() {
219 return Err(InspectError::NoTables);
220 }
221
222 let models_src = render_models(&schema);
223 let migration = render_initial_migration(&schema);
224 let report = write_outputs(&opts.output, &models_src, &migration).await?;
225
226 if opts.mark_applied {
227 let hash = migration.snapshot_after.hash();
228 migrate::record_applied(&migration.plugin, &migration.id, &hash).await?;
229 }
230
231 Ok(report)
232}
233
234/// Introspect the schema reachable through the given SQLite pool.
235/// Reads `sqlite_master` for table names and `PRAGMA table_info(...)`
236/// for column descriptors. Skips internal tables (`sqlite_*`,
237/// `umbral_migrations`).
238pub async fn introspect_pool(pool: &SqlitePool) -> Result<IntrospectedSchema, InspectError> {
239 // List user tables in lexical name order. `sqlite_master` carries
240 // both tables and indexes; the `type = 'table'` predicate scopes the
241 // result to tables. The skip-list takes out SQLite's internal
242 // bookkeeping (`sqlite_%`) and umbral's own tracking table, which
243 // would otherwise loop back through the migration engine.
244 let table_rows = sqlx::query(
245 "SELECT name FROM sqlite_master \
246 WHERE type = 'table' \
247 AND name NOT LIKE 'sqlite_%' \
248 AND name <> 'umbral_migrations' \
249 ORDER BY name",
250 )
251 .fetch_all(pool)
252 .await?;
253
254 let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
255 for row in table_rows {
256 let table: String = row.try_get("name")?;
257 let columns = introspect_columns(pool, &table).await?;
258 tables.push(IntrospectedTable {
259 name: pascal_case_from_table(&table),
260 table,
261 columns,
262 });
263 }
264
265 Ok(IntrospectedSchema { tables })
266}
267
268/// Introspect the schema reachable through the given Postgres pool.
269/// Reads `information_schema.tables` for table names,
270/// `information_schema.columns` for column descriptors, and joins
271/// `information_schema.table_constraints` + `key_column_usage` for
272/// the primary-key flag. Scoped to the `public` schema by default;
273/// internal Postgres schemas and umbral's own `umbral_migrations`
274/// tracking table are skipped.
275///
276/// The output is the same `IntrospectedSchema` the SQLite path
277/// produces — downstream rendering doesn't know which backend the
278/// data came from.
279pub async fn introspect_pool_pg(pool: &PgPool) -> Result<IntrospectedSchema, InspectError> {
280 // List user tables in the `public` schema, lexically. Postgres
281 // information_schema is standard SQL; pg_catalog is the lower-
282 // level surface but information_schema is portable across
283 // Postgres-compatible servers and carries everything the
284 // SqlType catalogue needs.
285 let table_rows: Vec<(String,)> = sqlx::query_as(
286 "SELECT table_name FROM information_schema.tables \
287 WHERE table_schema = 'public' \
288 AND table_type = 'BASE TABLE' \
289 AND table_name <> 'umbral_migrations' \
290 ORDER BY table_name",
291 )
292 .fetch_all(pool)
293 .await?;
294
295 let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
296 for (table,) in table_rows {
297 let columns = introspect_columns_pg(pool, &table).await?;
298 tables.push(IntrospectedTable {
299 name: pascal_case_from_table(&table),
300 table,
301 columns,
302 });
303 }
304
305 Ok(IntrospectedSchema { tables })
306}
307
308/// Read one Postgres table's columns via `information_schema.columns`,
309/// plus a primary-key join over `information_schema.table_constraints`
310/// and `key_column_usage`. Columns come back in declaration order
311/// (`ordinal_position`).
312///
313/// `data_type` is the normalised type string Postgres exposes through
314/// information_schema (e.g. `"integer"`, `"character varying"`,
315/// `"timestamp with time zone"`); [`map_postgres_type`] maps it to the
316/// umbral `SqlType` catalogue. Anything unmapped surfaces as
317/// [`InspectError::UnsupportedColumnType`] with the table / column
318/// names and the raw type string.
319async fn introspect_columns_pg(
320 pool: &PgPool,
321 table: &str,
322) -> Result<Vec<IntrospectedColumn>, InspectError> {
323 // The primary-key lookup runs once per table. The set is typically
324 // tiny (one column for most tables, a handful for composite keys)
325 // so collecting it up-front into a Vec keeps the inner column loop
326 // O(columns × pk_columns) without an extra round trip per column.
327 let pk_rows: Vec<(String,)> = sqlx::query_as(
328 "SELECT kcu.column_name \
329 FROM information_schema.table_constraints tc \
330 JOIN information_schema.key_column_usage kcu \
331 ON tc.constraint_name = kcu.constraint_name \
332 AND tc.table_schema = kcu.table_schema \
333 WHERE tc.constraint_type = 'PRIMARY KEY' \
334 AND tc.table_schema = 'public' \
335 AND tc.table_name = $1",
336 )
337 .bind(table)
338 .fetch_all(pool)
339 .await?;
340 let pk_columns: std::collections::HashSet<String> = pk_rows.into_iter().map(|(c,)| c).collect();
341
342 // `udt_name` carries the underlying type name even when `data_type`
343 // is the abstract `"ARRAY"` placeholder. For `bigint[]` the
344 // information_schema reports data_type = "ARRAY" and udt_name =
345 // "_int8" (underscore prefix marks the array variant in pg_type).
346 // For non-array columns udt_name carries the same physical name
347 // (`int8`, `text`, etc.) but `data_type` is the canonical match
348 // key we already lookup against.
349 let column_rows: Vec<(String, String, String, String)> = sqlx::query_as(
350 "SELECT column_name, data_type, is_nullable, udt_name \
351 FROM information_schema.columns \
352 WHERE table_schema = 'public' AND table_name = $1 \
353 ORDER BY ordinal_position",
354 )
355 .bind(table)
356 .fetch_all(pool)
357 .await?;
358
359 let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(column_rows.len());
360 for (name, data_type, is_nullable, udt_name) in column_rows {
361 let ty = if data_type.eq_ignore_ascii_case("ARRAY") {
362 // Element type comes from udt_name with the leading
363 // underscore stripped. `_int8` -> int8 -> ArrayElement::BigInt.
364 let elem_name = udt_name.strip_prefix('_').unwrap_or(udt_name.as_str());
365 map_postgres_array_element(elem_name).ok_or_else(|| {
366 InspectError::UnsupportedColumnType {
367 table: table.to_string(),
368 column: name.clone(),
369 sql_type: format!("ARRAY of {elem_name}"),
370 }
371 })?
372 } else {
373 map_postgres_type(&data_type).ok_or_else(|| InspectError::UnsupportedColumnType {
374 table: table.to_string(),
375 column: name.clone(),
376 sql_type: data_type.clone(),
377 })?
378 };
379 let primary_key = pk_columns.contains(&name);
380 // Postgres `is_nullable` is the string "YES" or "NO". A primary
381 // key is non-nullable by definition (the server enforces it);
382 // we force `nullable = false` so a SERIAL/BIGSERIAL PK round-
383 // trips through the M3 derive (which rejects `Option<T>` PKs)
384 // matching the behavioural fix already in place on the SQLite
385 // path.
386 let nullable = if primary_key {
387 false
388 } else {
389 is_nullable.eq_ignore_ascii_case("YES")
390 };
391 columns.push(IntrospectedColumn {
392 name,
393 ty,
394 primary_key,
395 nullable,
396 });
397 }
398
399 Ok(columns)
400}
401
402/// Map a Postgres array's element-type name (from `udt_name` with the
403/// leading underscore stripped) to a [`SqlType::Array`] variant.
404///
405/// The `udt_name` column on `information_schema.columns` carries the
406/// physical type name from `pg_catalog.pg_type`; array variants are
407/// prefixed with `_` (`_int8` for `bigint[]`, `_text` for `text[]`).
408/// The caller strips the prefix; this function maps the remaining
409/// stem to the umbral `ArrayElement` catalogue.
410///
411/// Returns `None` if the element type isn't in
412/// `umbral::orm::ArrayElement` — chrono types, JSON, network types,
413/// and Postgres-specific types like NUMERIC fall outside Phase 4.1's
414/// array catalogue.
415fn map_postgres_array_element(elem: &str) -> Option<SqlType> {
416 use crate::orm::ArrayElement;
417 let kind = match elem.trim().to_ascii_lowercase().as_str() {
418 // Postgres physical type names (per pg_type.typname). The
419 // information_schema strips spaces from the data_type alias
420 // form, so we match the canonical lowercase names here.
421 "int2" => ArrayElement::SmallInt,
422 "int4" => ArrayElement::Integer,
423 "int8" => ArrayElement::BigInt,
424 "float4" => ArrayElement::Real,
425 "float8" => ArrayElement::Double,
426 "bool" => ArrayElement::Boolean,
427 "text" | "varchar" | "bpchar" => ArrayElement::Text,
428 "uuid" => ArrayElement::Uuid,
429 _ => return None,
430 };
431 Some(SqlType::Array(kind))
432}
433
434/// Map a Postgres `information_schema.columns.data_type` value to the
435/// umbral `SqlType` catalogue. Postgres normalises the strings, so the
436/// match table is the canonical names rather than the optional aliases
437/// `pg_type.typname` would expose. The inverse of
438/// [`crate::backend::PostgresBackend::map_type`] — both stay in sync
439/// as new `SqlType` variants land.
440///
441/// Returns `None` on anything not in the catalogue (Postgres-specific
442/// types like `numeric`, `jsonb`, `bytea`, arrays, custom domains).
443/// The caller turns that into `UnsupportedColumnType` with enough
444/// context for the operator to fix by hand or wait for the field-
445/// type catalogue to grow.
446fn map_postgres_type(raw: &str) -> Option<SqlType> {
447 let normalised = raw.trim().to_ascii_lowercase();
448 match normalised.as_str() {
449 "smallint" => Some(SqlType::SmallInt),
450 "integer" => Some(SqlType::Integer),
451 "bigint" => Some(SqlType::BigInt),
452 "real" => Some(SqlType::Real),
453 "double precision" => Some(SqlType::Double),
454 "boolean" => Some(SqlType::Boolean),
455 // information_schema reports `text`, `character varying`, and
456 // `character` for VARCHAR / CHAR / TEXT. All round-trip through
457 // umbral's Text variant.
458 "text" | "character varying" | "character" => Some(SqlType::Text),
459 "date" => Some(SqlType::Date),
460 // Both timezone variants of TIME land on umbral's Time. The
461 // distinction is preserved in the database; the client-side
462 // type system doesn't model it yet.
463 "time without time zone" | "time with time zone" => Some(SqlType::Time),
464 // Likewise both timezone variants of TIMESTAMP land on
465 // Timestamptz. The umbral catalogue picks the with-tz variant
466 // as the default so chrono::DateTime<Utc> is the natural Rust
467 // type for either.
468 "timestamp without time zone" | "timestamp with time zone" => Some(SqlType::Timestamptz),
469 "uuid" => Some(SqlType::Uuid),
470 // Both `json` and `jsonb` round-trip to umbral's portable Json
471 // variant. The DDL renderer chose `jsonb` on the way out; if a
472 // pre-existing database stores values as `json` (the unindexed
473 // text variant), inspectdb still recognises it on the way in.
474 // A re-migrate would normalize to `jsonb` if the user re-creates
475 // the column, which matches the M5 declare-and-migrate loop.
476 "json" | "jsonb" => Some(SqlType::Json),
477 // Phase 4.4: Postgres network address types.
478 "inet" => Some(SqlType::Inet),
479 "cidr" => Some(SqlType::Cidr),
480 "macaddr" => Some(SqlType::MacAddr),
481 // gaps2 #70: text-backed Postgres types. `bit varying` and bare
482 // `bit` (the information_schema sometimes reports `bit` for a
483 // BIT(n)) both round-trip to the `Bit` variant.
484 "xml" => Some(SqlType::Xml),
485 "ltree" => Some(SqlType::Ltree),
486 "bit" | "bit varying" | "varbit" => Some(SqlType::Bit),
487 "tsvector" => Some(SqlType::FullText),
488 "bytea" => Some(SqlType::Bytes),
489 _ => None,
490 }
491}
492
493/// Read one table's columns via `PRAGMA table_info`. The PRAGMA returns
494/// `(cid, name, type, notnull, dflt_value, pk)` rows in declaration
495/// order, sorted defensively by `cid` so a downstream change to the
496/// PRAGMA's behaviour doesn't silently scramble field order.
497async fn introspect_columns(
498 pool: &SqlitePool,
499 table: &str,
500) -> Result<Vec<IntrospectedColumn>, InspectError> {
501 // The PRAGMA name can't be bound as a parameter, but it also can't
502 // contain user-supplied input here: `table` comes from `sqlite_master`
503 // and matches an existing table identifier by construction.
504 let sql = format!("PRAGMA table_info(\"{}\")", table.replace('"', "\"\""));
505 let mut rows = sqlx::query(&sql).fetch_all(pool).await?;
506 rows.sort_by_key(|r| r.try_get::<i64, _>("cid").unwrap_or(0));
507
508 let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(rows.len());
509 for row in rows {
510 let name: String = row.try_get("name")?;
511 let raw_type: String = row.try_get("type")?;
512 let notnull: i64 = row.try_get("notnull")?;
513 let pk: i64 = row.try_get("pk")?;
514 let ty = map_sqlite_type(&raw_type).ok_or_else(|| InspectError::UnsupportedColumnType {
515 table: table.to_string(),
516 column: name.clone(),
517 sql_type: raw_type.clone(),
518 })?;
519 let primary_key = pk != 0;
520 // SQLite's `PRAGMA table_info` reports `notnull = 0` for
521 // `INTEGER PRIMARY KEY` columns because they're aliases for
522 // ROWID (which SQLite manages internally). The columns are
523 // nonetheless guaranteed non-null: SQLite refuses to insert
524 // NULL into a primary key. Forcing `nullable = false` here
525 // makes the generated `#[derive(Model)]` compile (the M3
526 // derive's PK detection requires a non-`Option` PK field)
527 // and matches what the database actually enforces.
528 let nullable = if primary_key { false } else { notnull == 0 };
529 columns.push(IntrospectedColumn {
530 name,
531 ty,
532 primary_key,
533 nullable,
534 });
535 }
536 Ok(columns)
537}
538
539/// Map a raw SQLite type string to the M6 v1 [`SqlType`] catalogue.
540/// Case-insensitive; trailing `(n)` or `(p,s)` width parameters are
541/// stripped before matching so `VARCHAR(255)` and `NUMERIC(10,2)` come
542/// through as `varchar` and `numeric`. Returns `None` on anything not
543/// in the table; the caller turns that into
544/// [`InspectError::UnsupportedColumnType`] with the table and column
545/// names attached.
546fn map_sqlite_type(raw: &str) -> Option<SqlType> {
547 let head = match raw.split_once('(') {
548 Some((before, _)) => before,
549 None => raw,
550 };
551 let normalised = head.trim().to_ascii_lowercase();
552 match normalised.as_str() {
553 "smallint" | "int2" => Some(SqlType::SmallInt),
554 "int" | "integer" | "int4" => Some(SqlType::Integer),
555 "bigint" | "int8" => Some(SqlType::BigInt),
556 "real" | "float" | "float4" => Some(SqlType::Real),
557 "double" | "double precision" | "float8" => Some(SqlType::Double),
558 "boolean" | "bool" => Some(SqlType::Boolean),
559 "text" | "varchar" | "char" | "clob" | "character" | "varying character" | "nchar"
560 | "nvarchar" => Some(SqlType::Text),
561 "date" => Some(SqlType::Date),
562 "time" => Some(SqlType::Time),
563 "timestamp" | "timestamptz" | "datetime" => Some(SqlType::Timestamptz),
564 "uuid" => Some(SqlType::Uuid),
565 // SQLite doesn't have a native JSON column type, but a user
566 // declaring `CREATE TABLE t (data JSON)` parses the type-name
567 // verbatim into `sqlite_master` and `PRAGMA table_info`. Treat
568 // that as a hint that the column holds JSON content and route
569 // it through `SqlType::Json` (which lowers to TEXT on SQLite
570 // anyway).
571 "json" | "jsonb" => Some(SqlType::Json),
572 "blob" | "bytea" => Some(SqlType::Bytes),
573 _ => None,
574 }
575}
576
577// `derive_table_name` (was `to_snake_case`) and `pascal_case` (now
578// `pascal_case_from_table`) are imported from `umbral_casing` at the top
579// of this file. The local copies were removed in the gaps2 #77 refactor.
580
581/// Render the introspected schema as the contents of a `models.rs`
582/// file. The output is one `#[derive(Model)]` struct per table, with
583/// fields in declaration order and the `#[umbral(table = "…")]`
584/// attribute set when the struct name differs from the SQL table.
585///
586/// Structs are emitted in alphabetical order by struct name so a
587/// re-run against an unchanged schema produces a byte-identical file.
588/// Field-type rendering uses fully-qualified `chrono::*` / `uuid::*`
589/// paths so no extra `use` lines are needed at the top of the file.
590pub fn render_models(schema: &IntrospectedSchema) -> String {
591 let mut out = String::new();
592 out.push_str(HEADER);
593
594 let mut tables: Vec<&IntrospectedTable> = schema.tables.iter().collect();
595 tables.sort_by(|a, b| a.name.cmp(&b.name));
596
597 for table in tables {
598 out.push('\n');
599 out.push_str(&render_one_struct(table));
600 }
601 out
602}
603
604/// Two-line module doc plus the single facade import every generated
605/// file needs. Kept as a constant so the empty-schema path emits
606/// exactly the header and nothing else.
607const HEADER: &str = "\
608//! Generated by `umbral inspectdb`. Wire each struct into your App
609//! builder with `.model::<StructName>()`. Re-run `inspectdb` to
610//! regenerate; edits made by hand will be lost.
611
612use umbral::prelude::*;
613";
614
615/// Render a single `#[derive(Model)]` struct for one introspected table.
616/// The `#[umbral(table = "...")]` attribute is emitted only when the
617/// derive's auto-derived table name (snake_case of the struct name)
618/// doesn't equal the SQL table name. For the typical snake_case shape
619/// (`blog_post` -> `BlogPost` -> derive computes `"blog_post"`), the
620/// attribute is redundant and is left off. For unusual SQL casings
621/// (`POSTS` -> `Posts` -> derive computes `"posts"` not `"POSTS"`),
622/// the attribute is emitted and the M3.1 derive picks it up to
623/// override the default. See `umbral-macros/src/lib.rs` for the
624/// attribute parser.
625fn render_one_struct(table: &IntrospectedTable) -> String {
626 let mut out = String::new();
627 // `sqlx::FromRow` is required because the `Model` trait bounds it
628 // as a supertrait (see `crates/umbral-core/src/orm/model.rs`).
629 // Without it, `#[derive(Model)]` emits an `impl Model` whose
630 // sqlx::FromRow supertrait isn't satisfied and the generated file
631 // fails to compile.
632 out.push_str("#[derive(Debug, Clone, sqlx::FromRow, Model)]\n");
633 if to_snake_case(&table.name) != table.table {
634 out.push_str(&format!("#[umbral(table = \"{}\")]\n", table.table));
635 }
636 out.push_str(&format!("pub struct {} {{\n", table.name));
637 for column in &table.columns {
638 out.push_str(&format!(
639 " pub {}: {},\n",
640 column.name,
641 render_field_type(column.ty, column.nullable),
642 ));
643 }
644 out.push_str("}\n");
645 out
646}
647
648/// Map `(SqlType, nullable)` to the Rust type string the derive macro's
649/// `classify_field_type` accepts. Mirrors the table in
650/// `umbral-macros/src/lib.rs` (see `FieldKind` for the full catalogue).
651fn render_field_type(ty: SqlType, nullable: bool) -> String {
652 let base = match ty {
653 SqlType::SmallInt => "i16".to_string(),
654 SqlType::Integer => "i32".to_string(),
655 SqlType::BigInt => "i64".to_string(),
656 SqlType::Real => "f32".to_string(),
657 SqlType::Double => "f64".to_string(),
658 SqlType::Boolean => "bool".to_string(),
659 SqlType::Text => "String".to_string(),
660 SqlType::Date => "chrono::NaiveDate".to_string(),
661 SqlType::Time => "chrono::NaiveTime".to_string(),
662 SqlType::Timestamptz => "chrono::DateTime<chrono::Utc>".to_string(),
663 SqlType::Uuid => "uuid::Uuid".to_string(),
664 SqlType::Json => "serde_json::Value".to_string(),
665 // Recurse through the element's SqlType. Wrapping in `Vec<...>`
666 // matches the derive's catalogue: a `Vec<i64>` declares an
667 // `Array(ArrayElement::BigInt)` field.
668 SqlType::Array(elem) => format!("Vec<{}>", render_field_type(elem.to_sql_type(), false)),
669 // Phase 4.4: Postgres network address types. Both `Inet` and
670 // `Cidr` round-trip through `ipnetwork::IpNetwork`; `MacAddr`
671 // uses the `mac_address` crate.
672 SqlType::Inet => "ipnetwork::IpNetwork".to_string(),
673 SqlType::Cidr => "ipnetwork::IpNetwork".to_string(),
674 SqlType::MacAddr => "mac_address::MacAddress".to_string(),
675 // gaps2 #70: text-backed Postgres types surface as `String`.
676 // inspectdb can't recover which `#[umbral(...)]` attr produced
677 // the column (the attr lives only in the source model, not the
678 // DB), so the generated model is a plain `String`; the user
679 // re-adds `#[umbral(xml)]` / `#[umbral(ltree)]` / `#[umbral(bit)]`
680 // if they want the native type back on a re-migrate.
681 SqlType::Xml => "String".to_string(),
682 SqlType::Ltree => "String".to_string(),
683 SqlType::Bit => "String".to_string(),
684 SqlType::FullText => "umbral::orm::TsVector".to_string(),
685 // ForeignKey inspectdb renders as i64 for now; the FK relationship
686 // introspection that would emit ForeignKey<T> is deferred.
687 SqlType::ForeignKey => "i64".to_string(),
688 // BLOB / BYTEA columns surface as Vec<u8> in user code.
689 SqlType::Bytes => "Vec<u8>".to_string(),
690 // BUG-10: NUMERIC introspection renders as
691 // `rust_decimal::Decimal`. inspectdb reads the column type
692 // from Postgres' `information_schema`; the resulting
693 // model imports use this exact path.
694 SqlType::Decimal => "rust_decimal::Decimal".to_string(),
695 };
696 let base = base.as_str();
697 if nullable {
698 format!("Option<{base}>")
699 } else {
700 base.to_string()
701 }
702}
703
704/// Render the introspected schema as a [`MigrationFile`] suitable for
705/// writing to `migrations/<INSPECTED_PLUGIN_NAME>/0001_initial.json`.
706/// One `CreateTable` per introspected table; `snapshot_after` captures
707/// the imported state so subsequent `make_in` runs diff against it.
708///
709/// Filled in by subagent B.
710pub fn render_initial_migration(schema: &IntrospectedSchema) -> MigrationFile {
711 let mut models: Vec<ModelMeta> = schema
712 .tables
713 .iter()
714 .map(|t| ModelMeta {
715 name: t.name.clone(),
716 table: t.table.clone(),
717 fields: t.columns.iter().map(Column::from).collect(),
718 display: t.name.clone(),
719 icon: "database".to_string(),
720 database: None,
721 singleton: false,
722 unique_together: Vec::new(),
723 indexes: Vec::new(),
724 ordering: Vec::new(),
725 m2m_relations: Vec::new(),
726 soft_delete: false,
727 // inspectdb has no plugin attribute to read; default to "app".
728 app_label: "app".to_string(),
729 })
730 .collect();
731 models.sort_by(|a, b| a.name.cmp(&b.name));
732
733 let operations = schema
734 .tables
735 .iter()
736 .map(|t| Operation::CreateTable {
737 table: t.table.clone(),
738 columns: t.columns.iter().map(Column::from).collect(),
739 unique_together: Vec::new(),
740 indexes: Vec::new(),
741 })
742 .collect();
743
744 MigrationFile {
745 id: INITIAL_MIGRATION_ID.to_string(),
746 plugin: INSPECTED_PLUGIN_NAME.to_string(),
747 depends_on: Vec::new(),
748 operations,
749 snapshot_after: Snapshot { models },
750 }
751}
752
753/// Write `models.rs` and the initial migration to `output`. Creates
754/// `output/` and `output/migrations/<INSPECTED_PLUGIN_NAME>/` as
755/// needed. Returns the report carrying the table / column counts and
756/// the paths.
757///
758/// The migration is pretty-printed so the file diffs cleanly when a
759/// later `makemigrations` writes the next migration alongside.
760pub async fn write_outputs(
761 output: &Path,
762 models_src: &str,
763 migration: &MigrationFile,
764) -> Result<InspectReport, InspectError> {
765 std::fs::create_dir_all(output)?;
766
767 let models_path = output.join("models.rs");
768 std::fs::write(&models_path, models_src)?;
769
770 let plugin_dir = output.join("migrations").join(INSPECTED_PLUGIN_NAME);
771 std::fs::create_dir_all(&plugin_dir)?;
772
773 let migration_path = plugin_dir.join(format!("{}.json", migration.id));
774 let json = serde_json::to_string_pretty(migration)?;
775 std::fs::write(&migration_path, json)?;
776
777 let (tables, columns) =
778 migration
779 .operations
780 .iter()
781 .fold((0usize, 0usize), |(t, c), op| match op {
782 Operation::CreateTable { columns, .. } => (t + 1, c + columns.len()),
783 Operation::CreateM2MTable { .. } => (t + 1, c + 2),
784 Operation::DropTable { .. }
785 | Operation::DropM2MTable { .. }
786 | Operation::AddColumn { .. }
787 | Operation::DropColumn { .. }
788 | Operation::AlterColumn { .. }
789 | Operation::RenameTable { .. }
790 | Operation::RenameColumn { .. }
791 | Operation::AddIndex { .. }
792 | Operation::DropIndex { .. }
793 | Operation::RunSql { .. } => (t, c),
794 });
795
796 Ok(InspectReport {
797 tables,
798 columns,
799 models_path,
800 migration_path,
801 })
802}
803
804// =========================================================================
805// Internal helpers.
806// =========================================================================
807
808impl From<&IntrospectedColumn> for Column {
809 fn from(c: &IntrospectedColumn) -> Self {
810 Self {
811 name: c.name.clone(),
812 ty: c.ty,
813 primary_key: c.primary_key,
814 nullable: c.nullable,
815 fk_target: None,
816 noform: false,
817 privileged: false,
818 // inspectdb introspects no FK yet (`fk_target: None`), so a
819 // real DB constraint maps to the default `true`.
820 db_constraint: true,
821 noedit: false,
822 is_string_repr: false,
823 max_length: 0,
824 choices: Vec::new(),
825 choice_labels: Vec::new(),
826 default: String::new(),
827 is_multichoice: false,
828 // inspectdb does not introspect UNIQUE constraints yet
829 // (gap #65 ships the declare-side first; inspect-side
830 // lands when there's a real porting case that needs it).
831 unique: false,
832 on_delete: crate::orm::FkAction::NoAction,
833 on_update: crate::orm::FkAction::NoAction,
834 index: false,
835 auto_now_add: false,
836 auto_now: false,
837 help: String::new(),
838 example: String::new(),
839 widget: None,
840 supported_backends: Vec::new(),
841 min: None,
842 max: None,
843 text_format: ::core::option::Option::None,
844 slug_from: ::core::option::Option::None,
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852
853 fn col(name: &str, ty: SqlType, primary_key: bool, nullable: bool) -> IntrospectedColumn {
854 IntrospectedColumn {
855 name: name.to_string(),
856 ty,
857 primary_key,
858 nullable,
859 }
860 }
861
862 #[test]
863 fn empty_schema_renders_header_only() {
864 let out = render_models(&IntrospectedSchema { tables: Vec::new() });
865 assert_eq!(out, HEADER);
866 }
867
868 #[test]
869 fn snake_case_table_skips_attribute_when_derive_round_trips() {
870 let schema = IntrospectedSchema {
871 tables: vec![IntrospectedTable {
872 table: "blog_post".to_string(),
873 name: "BlogPost".to_string(),
874 columns: vec![
875 col("id", SqlType::BigInt, true, false),
876 col("title", SqlType::Text, false, false),
877 ],
878 }],
879 };
880 let out = render_models(&schema);
881 // `BlogPost` snake_cases to `blog_post` via the derive, so the
882 // attribute is redundant and is left off. This keeps the
883 // generated file compatible with the M3 derive, which doesn't
884 // yet recognise `#[umbral(...)]` attributes.
885 assert!(!out.contains("#[umbral(table"));
886 assert!(out.contains("pub struct BlogPost {"));
887 assert!(out.contains("pub id: i64,"));
888 assert!(out.contains("pub title: String,"));
889 }
890
891 #[test]
892 fn lowercase_single_word_table_skips_attribute() {
893 // `post` -> `Post` -> derive snake_cases to `"post"`, matches
894 // the source table verbatim, so the attribute is left off.
895 let schema = IntrospectedSchema {
896 tables: vec![IntrospectedTable {
897 table: "post".to_string(),
898 name: "Post".to_string(),
899 columns: vec![col("id", SqlType::BigInt, true, false)],
900 }],
901 };
902 let out = render_models(&schema);
903 assert!(!out.contains("#[umbral(table"));
904 assert!(out.contains("pub struct Post {"));
905 }
906
907 #[test]
908 fn non_round_tripping_table_name_keeps_attribute() {
909 // SQL tables with names the derive's snake_case won't reach
910 // (e.g. uppercase, runs of capitals, leading digits) need the
911 // explicit attribute. This case is rare in real ports but
912 // the renderer should still cover it for the derive's eventual
913 // attribute-support landing.
914 let schema = IntrospectedSchema {
915 tables: vec![IntrospectedTable {
916 table: "POSTS".to_string(),
917 name: "Posts".to_string(),
918 columns: vec![col("id", SqlType::BigInt, true, false)],
919 }],
920 };
921 let out = render_models(&schema);
922 assert!(out.contains("#[umbral(table = \"POSTS\")]"));
923 }
924
925 #[test]
926 fn nullable_column_wraps_in_option() {
927 let schema = IntrospectedSchema {
928 tables: vec![IntrospectedTable {
929 table: "post".to_string(),
930 name: "Post".to_string(),
931 columns: vec![
932 col("id", SqlType::BigInt, true, false),
933 col("published_at", SqlType::Timestamptz, false, true),
934 ],
935 }],
936 };
937 let out = render_models(&schema);
938 assert!(out.contains("pub published_at: Option<chrono::DateTime<chrono::Utc>>,"));
939 }
940
941 #[test]
942 fn type_catalogue_renders_each_sql_type() {
943 let schema = IntrospectedSchema {
944 tables: vec![IntrospectedTable {
945 table: "kitchen_sink".to_string(),
946 name: "KitchenSink".to_string(),
947 columns: vec![
948 col("id", SqlType::BigInt, true, false),
949 col("small", SqlType::SmallInt, false, false),
950 col("medium", SqlType::Integer, false, false),
951 col("real_v", SqlType::Real, false, false),
952 col("double_v", SqlType::Double, false, false),
953 col("flag", SqlType::Boolean, false, false),
954 col("note", SqlType::Text, false, false),
955 col("day", SqlType::Date, false, false),
956 col("clock", SqlType::Time, false, false),
957 col("at", SqlType::Timestamptz, false, false),
958 col("uid", SqlType::Uuid, false, false),
959 ],
960 }],
961 };
962 let out = render_models(&schema);
963 for expected in [
964 "pub id: i64,",
965 "pub small: i16,",
966 "pub medium: i32,",
967 "pub real_v: f32,",
968 "pub double_v: f64,",
969 "pub flag: bool,",
970 "pub note: String,",
971 "pub day: chrono::NaiveDate,",
972 "pub clock: chrono::NaiveTime,",
973 "pub at: chrono::DateTime<chrono::Utc>,",
974 "pub uid: uuid::Uuid,",
975 ] {
976 assert!(out.contains(expected), "missing field render: {expected}");
977 }
978 }
979
980 #[test]
981 fn structs_are_sorted_by_name() {
982 let schema = IntrospectedSchema {
983 tables: vec![
984 IntrospectedTable {
985 table: "zebra".to_string(),
986 name: "Zebra".to_string(),
987 columns: vec![col("id", SqlType::BigInt, true, false)],
988 },
989 IntrospectedTable {
990 table: "antelope".to_string(),
991 name: "Antelope".to_string(),
992 columns: vec![col("id", SqlType::BigInt, true, false)],
993 },
994 ],
995 };
996 let out = render_models(&schema);
997 let antelope_at = out.find("struct Antelope").expect("Antelope rendered");
998 let zebra_at = out.find("struct Zebra").expect("Zebra rendered");
999 assert!(antelope_at < zebra_at);
1000 }
1001
1002 #[test]
1003 fn header_carries_the_regen_warning_and_facade_import() {
1004 let out = render_models(&IntrospectedSchema { tables: Vec::new() });
1005 assert!(out.contains("Generated by `umbral inspectdb`"));
1006 assert!(out.contains("edits made by hand will be lost"));
1007 assert!(out.contains("use umbral::prelude::*;"));
1008 }
1009
1010 // --------------------------------------------------------------- //
1011 // Postgres type-mapping coverage (Phase 3). //
1012 // --------------------------------------------------------------- //
1013
1014 /// Every variant of the M5 SqlType catalogue has a mapping from
1015 /// the canonical Postgres `information_schema.columns.data_type`
1016 /// value back to the variant. Lockstep with
1017 /// `crate::backend::PostgresBackend::map_type` — if a SqlType
1018 /// variant lands, both `map_type` (outbound) and `map_postgres_type`
1019 /// (inbound) need an arm.
1020 #[test]
1021 fn map_postgres_type_covers_the_full_catalogue() {
1022 assert_eq!(map_postgres_type("smallint"), Some(SqlType::SmallInt));
1023 assert_eq!(map_postgres_type("integer"), Some(SqlType::Integer));
1024 assert_eq!(map_postgres_type("bigint"), Some(SqlType::BigInt));
1025 assert_eq!(map_postgres_type("real"), Some(SqlType::Real));
1026 assert_eq!(map_postgres_type("double precision"), Some(SqlType::Double));
1027 assert_eq!(map_postgres_type("boolean"), Some(SqlType::Boolean));
1028 assert_eq!(map_postgres_type("text"), Some(SqlType::Text));
1029 assert_eq!(
1030 map_postgres_type("character varying"),
1031 Some(SqlType::Text),
1032 "VARCHAR maps to Text",
1033 );
1034 assert_eq!(
1035 map_postgres_type("character"),
1036 Some(SqlType::Text),
1037 "CHAR maps to Text",
1038 );
1039 assert_eq!(map_postgres_type("date"), Some(SqlType::Date));
1040 assert_eq!(
1041 map_postgres_type("time without time zone"),
1042 Some(SqlType::Time),
1043 );
1044 assert_eq!(
1045 map_postgres_type("time with time zone"),
1046 Some(SqlType::Time)
1047 );
1048 assert_eq!(
1049 map_postgres_type("timestamp without time zone"),
1050 Some(SqlType::Timestamptz),
1051 );
1052 assert_eq!(
1053 map_postgres_type("timestamp with time zone"),
1054 Some(SqlType::Timestamptz),
1055 );
1056 assert_eq!(map_postgres_type("uuid"), Some(SqlType::Uuid));
1057 // Phase 4: both `json` and `jsonb` round-trip to the portable
1058 // `SqlType::Json` (DDL renders as `jsonb` on Postgres, TEXT on
1059 // SQLite).
1060 assert_eq!(map_postgres_type("json"), Some(SqlType::Json));
1061 assert_eq!(map_postgres_type("jsonb"), Some(SqlType::Json));
1062 // Phase 4.4: Postgres network address types.
1063 assert_eq!(map_postgres_type("inet"), Some(SqlType::Inet));
1064 assert_eq!(map_postgres_type("cidr"), Some(SqlType::Cidr));
1065 assert_eq!(map_postgres_type("macaddr"), Some(SqlType::MacAddr));
1066 // BLOB / BYTEA — Vec<u8> in Rust.
1067 assert_eq!(map_postgres_type("bytea"), Some(SqlType::Bytes));
1068 }
1069
1070 /// Postgres-specific types umbral doesn't model yet surface as
1071 /// `None` so the caller produces `UnsupportedColumnType` with the
1072 /// raw type string preserved. The catalogue lookups most likely to
1073 /// bite a port: numeric, bytea, arrays, network types. The
1074 /// user fixes by hand or waits for the catalogue to grow.
1075 ///
1076 /// Note `json`/`jsonb` are NOT on this list — Phase 4's `Json`
1077 /// SqlType variant maps both back to `SqlType::Json`. Likewise
1078 /// `inet`/`cidr`/`macaddr` left this list when Phase 4.4 added
1079 /// the matching SqlType variants. The companion arms in
1080 /// `map_postgres_type` are covered by
1081 /// `map_postgres_type_covers_the_full_catalogue` above.
1082 #[test]
1083 fn map_postgres_type_returns_none_for_postgres_only_types() {
1084 assert_eq!(map_postgres_type("numeric"), None);
1085 // `bytea` USED to be off-catalogue and returned None; once
1086 // SqlType::Bytes shipped, `bytea` started routing to it.
1087 // Asserted in the positive `map_postgres_type_covers_the_full_catalogue`
1088 // test instead.
1089 assert_eq!(map_postgres_type("ARRAY"), None);
1090 }
1091
1092 /// The mapping is case-insensitive on the input but matches against
1093 /// the canonical lowercase form information_schema reports. Whether
1094 /// the operator's DB returns `INTEGER` (uppercase, from a quoted
1095 /// type) or `integer` shouldn't matter.
1096 #[test]
1097 fn map_postgres_type_is_case_insensitive_on_input() {
1098 assert_eq!(map_postgres_type("INTEGER"), Some(SqlType::Integer));
1099 assert_eq!(map_postgres_type("Bigint"), Some(SqlType::BigInt));
1100 assert_eq!(map_postgres_type("UUID"), Some(SqlType::Uuid));
1101 }
1102
1103 /// Surrounding whitespace doesn't break the lookup. Trimming
1104 /// matches `map_sqlite_type`'s `trim()`; both functions parse
1105 /// values straight from a sqlx row and the trim is a cheap
1106 /// safety net.
1107 #[test]
1108 fn map_postgres_type_trims_whitespace() {
1109 assert_eq!(map_postgres_type(" bigint "), Some(SqlType::BigInt));
1110 }
1111}