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 audited: false,
728 // inspectdb introspects TABLES; a view it finds becomes a plain model
729 // with `view: None`, i.e. the framework will not try to manage it.
730 view: None,
731 materialized: false,
732 // inspectdb has no plugin attribute to read; default to "app".
733 app_label: "app".to_string(),
734 })
735 .collect();
736 models.sort_by(|a, b| a.name.cmp(&b.name));
737
738 let operations = schema
739 .tables
740 .iter()
741 .map(|t| Operation::CreateTable {
742 table: t.table.clone(),
743 columns: t.columns.iter().map(Column::from).collect(),
744 unique_together: Vec::new(),
745 indexes: Vec::new(),
746 })
747 .collect();
748
749 MigrationFile {
750 id: INITIAL_MIGRATION_ID.to_string(),
751 plugin: INSPECTED_PLUGIN_NAME.to_string(),
752 depends_on: Vec::new(),
753 operations,
754 snapshot_after: Snapshot { models },
755 replaces: Vec::new(),
756 }
757}
758
759/// Write `models.rs` and the initial migration to `output`. Creates
760/// `output/` and `output/migrations/<INSPECTED_PLUGIN_NAME>/` as
761/// needed. Returns the report carrying the table / column counts and
762/// the paths.
763///
764/// The migration is pretty-printed so the file diffs cleanly when a
765/// later `makemigrations` writes the next migration alongside.
766pub async fn write_outputs(
767 output: &Path,
768 models_src: &str,
769 migration: &MigrationFile,
770) -> Result<InspectReport, InspectError> {
771 std::fs::create_dir_all(output)?;
772
773 let models_path = output.join("models.rs");
774 std::fs::write(&models_path, models_src)?;
775
776 let plugin_dir = output.join("migrations").join(INSPECTED_PLUGIN_NAME);
777 std::fs::create_dir_all(&plugin_dir)?;
778
779 let migration_path = plugin_dir.join(format!("{}.json", migration.id));
780 let json = serde_json::to_string_pretty(migration)?;
781 std::fs::write(&migration_path, json)?;
782
783 let (tables, columns) =
784 migration
785 .operations
786 .iter()
787 .fold((0usize, 0usize), |(t, c), op| match op {
788 Operation::CreateTable { columns, .. } => (t + 1, c + columns.len()),
789 Operation::CreateM2MTable { .. } => (t + 1, c + 2),
790 Operation::CreateView { .. }
791 | Operation::DropView { .. }
792 | Operation::DropTable { .. }
793 | Operation::DropM2MTable { .. }
794 | Operation::AddColumn { .. }
795 | Operation::DropColumn { .. }
796 | Operation::AlterColumn { .. }
797 | Operation::RenameTable { .. }
798 | Operation::RenameColumn { .. }
799 | Operation::SetColumnComment { .. }
800 | Operation::AddIndex { .. }
801 | Operation::DropIndex { .. }
802 | Operation::RunSql { .. } => (t, c),
803 });
804
805 Ok(InspectReport {
806 tables,
807 columns,
808 models_path,
809 migration_path,
810 })
811}
812
813// =========================================================================
814// Internal helpers.
815// =========================================================================
816
817impl From<&IntrospectedColumn> for Column {
818 fn from(c: &IntrospectedColumn) -> Self {
819 Self {
820 name: c.name.clone(),
821 ty: c.ty,
822 primary_key: c.primary_key,
823 nullable: c.nullable,
824 fk_target: None,
825 noform: false,
826 privileged: false,
827 // inspectdb introspects no FK yet (`fk_target: None`), so a
828 // real DB constraint maps to the default `true`.
829 db_constraint: true,
830 noedit: false,
831 is_string_repr: false,
832 max_length: 0,
833 choices: Vec::new(),
834 choice_labels: Vec::new(),
835 default: String::new(),
836 is_multichoice: false,
837 // inspectdb does not introspect UNIQUE constraints yet
838 // (gap #65 ships the declare-side first; inspect-side
839 // lands when there's a real porting case that needs it).
840 unique: false,
841 on_delete: crate::orm::FkAction::NoAction,
842 on_update: crate::orm::FkAction::NoAction,
843 index: false,
844 auto_now_add: false,
845 auto_now: false,
846 auto_user_add: false,
847 auto_user: false,
848 trim: false,
849 lowercase: false,
850 case_insensitive: false,
851 help: String::new(),
852 example: String::new(),
853 widget: None,
854 supported_backends: Vec::new(),
855 min: None,
856 max: None,
857 text_format: ::core::option::Option::None,
858 slug_from: ::core::option::Option::None,
859 }
860 }
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866
867 fn col(name: &str, ty: SqlType, primary_key: bool, nullable: bool) -> IntrospectedColumn {
868 IntrospectedColumn {
869 name: name.to_string(),
870 ty,
871 primary_key,
872 nullable,
873 }
874 }
875
876 #[test]
877 fn empty_schema_renders_header_only() {
878 let out = render_models(&IntrospectedSchema { tables: Vec::new() });
879 assert_eq!(out, HEADER);
880 }
881
882 #[test]
883 fn snake_case_table_skips_attribute_when_derive_round_trips() {
884 let schema = IntrospectedSchema {
885 tables: vec![IntrospectedTable {
886 table: "blog_post".to_string(),
887 name: "BlogPost".to_string(),
888 columns: vec![
889 col("id", SqlType::BigInt, true, false),
890 col("title", SqlType::Text, false, false),
891 ],
892 }],
893 };
894 let out = render_models(&schema);
895 // `BlogPost` snake_cases to `blog_post` via the derive, so the
896 // attribute is redundant and is left off. This keeps the
897 // generated file compatible with the M3 derive, which doesn't
898 // yet recognise `#[umbral(...)]` attributes.
899 assert!(!out.contains("#[umbral(table"));
900 assert!(out.contains("pub struct BlogPost {"));
901 assert!(out.contains("pub id: i64,"));
902 assert!(out.contains("pub title: String,"));
903 }
904
905 #[test]
906 fn lowercase_single_word_table_skips_attribute() {
907 // `post` -> `Post` -> derive snake_cases to `"post"`, matches
908 // the source table verbatim, so the attribute is left off.
909 let schema = IntrospectedSchema {
910 tables: vec![IntrospectedTable {
911 table: "post".to_string(),
912 name: "Post".to_string(),
913 columns: vec![col("id", SqlType::BigInt, true, false)],
914 }],
915 };
916 let out = render_models(&schema);
917 assert!(!out.contains("#[umbral(table"));
918 assert!(out.contains("pub struct Post {"));
919 }
920
921 #[test]
922 fn non_round_tripping_table_name_keeps_attribute() {
923 // SQL tables with names the derive's snake_case won't reach
924 // (e.g. uppercase, runs of capitals, leading digits) need the
925 // explicit attribute. This case is rare in real ports but
926 // the renderer should still cover it for the derive's eventual
927 // attribute-support landing.
928 let schema = IntrospectedSchema {
929 tables: vec![IntrospectedTable {
930 table: "POSTS".to_string(),
931 name: "Posts".to_string(),
932 columns: vec![col("id", SqlType::BigInt, true, false)],
933 }],
934 };
935 let out = render_models(&schema);
936 assert!(out.contains("#[umbral(table = \"POSTS\")]"));
937 }
938
939 #[test]
940 fn nullable_column_wraps_in_option() {
941 let schema = IntrospectedSchema {
942 tables: vec![IntrospectedTable {
943 table: "post".to_string(),
944 name: "Post".to_string(),
945 columns: vec![
946 col("id", SqlType::BigInt, true, false),
947 col("published_at", SqlType::Timestamptz, false, true),
948 ],
949 }],
950 };
951 let out = render_models(&schema);
952 assert!(out.contains("pub published_at: Option<chrono::DateTime<chrono::Utc>>,"));
953 }
954
955 #[test]
956 fn type_catalogue_renders_each_sql_type() {
957 let schema = IntrospectedSchema {
958 tables: vec![IntrospectedTable {
959 table: "kitchen_sink".to_string(),
960 name: "KitchenSink".to_string(),
961 columns: vec![
962 col("id", SqlType::BigInt, true, false),
963 col("small", SqlType::SmallInt, false, false),
964 col("medium", SqlType::Integer, false, false),
965 col("real_v", SqlType::Real, false, false),
966 col("double_v", SqlType::Double, false, false),
967 col("flag", SqlType::Boolean, false, false),
968 col("note", SqlType::Text, false, false),
969 col("day", SqlType::Date, false, false),
970 col("clock", SqlType::Time, false, false),
971 col("at", SqlType::Timestamptz, false, false),
972 col("uid", SqlType::Uuid, false, false),
973 ],
974 }],
975 };
976 let out = render_models(&schema);
977 for expected in [
978 "pub id: i64,",
979 "pub small: i16,",
980 "pub medium: i32,",
981 "pub real_v: f32,",
982 "pub double_v: f64,",
983 "pub flag: bool,",
984 "pub note: String,",
985 "pub day: chrono::NaiveDate,",
986 "pub clock: chrono::NaiveTime,",
987 "pub at: chrono::DateTime<chrono::Utc>,",
988 "pub uid: uuid::Uuid,",
989 ] {
990 assert!(out.contains(expected), "missing field render: {expected}");
991 }
992 }
993
994 #[test]
995 fn structs_are_sorted_by_name() {
996 let schema = IntrospectedSchema {
997 tables: vec![
998 IntrospectedTable {
999 table: "zebra".to_string(),
1000 name: "Zebra".to_string(),
1001 columns: vec![col("id", SqlType::BigInt, true, false)],
1002 },
1003 IntrospectedTable {
1004 table: "antelope".to_string(),
1005 name: "Antelope".to_string(),
1006 columns: vec![col("id", SqlType::BigInt, true, false)],
1007 },
1008 ],
1009 };
1010 let out = render_models(&schema);
1011 let antelope_at = out.find("struct Antelope").expect("Antelope rendered");
1012 let zebra_at = out.find("struct Zebra").expect("Zebra rendered");
1013 assert!(antelope_at < zebra_at);
1014 }
1015
1016 #[test]
1017 fn header_carries_the_regen_warning_and_facade_import() {
1018 let out = render_models(&IntrospectedSchema { tables: Vec::new() });
1019 assert!(out.contains("Generated by `umbral inspectdb`"));
1020 assert!(out.contains("edits made by hand will be lost"));
1021 assert!(out.contains("use umbral::prelude::*;"));
1022 }
1023
1024 // --------------------------------------------------------------- //
1025 // Postgres type-mapping coverage (Phase 3). //
1026 // --------------------------------------------------------------- //
1027
1028 /// Every variant of the M5 SqlType catalogue has a mapping from
1029 /// the canonical Postgres `information_schema.columns.data_type`
1030 /// value back to the variant. Lockstep with
1031 /// `crate::backend::PostgresBackend::map_type` — if a SqlType
1032 /// variant lands, both `map_type` (outbound) and `map_postgres_type`
1033 /// (inbound) need an arm.
1034 #[test]
1035 fn map_postgres_type_covers_the_full_catalogue() {
1036 assert_eq!(map_postgres_type("smallint"), Some(SqlType::SmallInt));
1037 assert_eq!(map_postgres_type("integer"), Some(SqlType::Integer));
1038 assert_eq!(map_postgres_type("bigint"), Some(SqlType::BigInt));
1039 assert_eq!(map_postgres_type("real"), Some(SqlType::Real));
1040 assert_eq!(map_postgres_type("double precision"), Some(SqlType::Double));
1041 assert_eq!(map_postgres_type("boolean"), Some(SqlType::Boolean));
1042 assert_eq!(map_postgres_type("text"), Some(SqlType::Text));
1043 assert_eq!(
1044 map_postgres_type("character varying"),
1045 Some(SqlType::Text),
1046 "VARCHAR maps to Text",
1047 );
1048 assert_eq!(
1049 map_postgres_type("character"),
1050 Some(SqlType::Text),
1051 "CHAR maps to Text",
1052 );
1053 assert_eq!(map_postgres_type("date"), Some(SqlType::Date));
1054 assert_eq!(
1055 map_postgres_type("time without time zone"),
1056 Some(SqlType::Time),
1057 );
1058 assert_eq!(
1059 map_postgres_type("time with time zone"),
1060 Some(SqlType::Time)
1061 );
1062 assert_eq!(
1063 map_postgres_type("timestamp without time zone"),
1064 Some(SqlType::Timestamptz),
1065 );
1066 assert_eq!(
1067 map_postgres_type("timestamp with time zone"),
1068 Some(SqlType::Timestamptz),
1069 );
1070 assert_eq!(map_postgres_type("uuid"), Some(SqlType::Uuid));
1071 // Phase 4: both `json` and `jsonb` round-trip to the portable
1072 // `SqlType::Json` (DDL renders as `jsonb` on Postgres, TEXT on
1073 // SQLite).
1074 assert_eq!(map_postgres_type("json"), Some(SqlType::Json));
1075 assert_eq!(map_postgres_type("jsonb"), Some(SqlType::Json));
1076 // Phase 4.4: Postgres network address types.
1077 assert_eq!(map_postgres_type("inet"), Some(SqlType::Inet));
1078 assert_eq!(map_postgres_type("cidr"), Some(SqlType::Cidr));
1079 assert_eq!(map_postgres_type("macaddr"), Some(SqlType::MacAddr));
1080 // BLOB / BYTEA — Vec<u8> in Rust.
1081 assert_eq!(map_postgres_type("bytea"), Some(SqlType::Bytes));
1082 }
1083
1084 /// Postgres-specific types umbral doesn't model yet surface as
1085 /// `None` so the caller produces `UnsupportedColumnType` with the
1086 /// raw type string preserved. The catalogue lookups most likely to
1087 /// bite a port: numeric, bytea, arrays, network types. The
1088 /// user fixes by hand or waits for the catalogue to grow.
1089 ///
1090 /// Note `json`/`jsonb` are NOT on this list — Phase 4's `Json`
1091 /// SqlType variant maps both back to `SqlType::Json`. Likewise
1092 /// `inet`/`cidr`/`macaddr` left this list when Phase 4.4 added
1093 /// the matching SqlType variants. The companion arms in
1094 /// `map_postgres_type` are covered by
1095 /// `map_postgres_type_covers_the_full_catalogue` above.
1096 #[test]
1097 fn map_postgres_type_returns_none_for_postgres_only_types() {
1098 assert_eq!(map_postgres_type("numeric"), None);
1099 // `bytea` USED to be off-catalogue and returned None; once
1100 // SqlType::Bytes shipped, `bytea` started routing to it.
1101 // Asserted in the positive `map_postgres_type_covers_the_full_catalogue`
1102 // test instead.
1103 assert_eq!(map_postgres_type("ARRAY"), None);
1104 }
1105
1106 /// The mapping is case-insensitive on the input but matches against
1107 /// the canonical lowercase form information_schema reports. Whether
1108 /// the operator's DB returns `INTEGER` (uppercase, from a quoted
1109 /// type) or `integer` shouldn't matter.
1110 #[test]
1111 fn map_postgres_type_is_case_insensitive_on_input() {
1112 assert_eq!(map_postgres_type("INTEGER"), Some(SqlType::Integer));
1113 assert_eq!(map_postgres_type("Bigint"), Some(SqlType::BigInt));
1114 assert_eq!(map_postgres_type("UUID"), Some(SqlType::Uuid));
1115 }
1116
1117 /// Surrounding whitespace doesn't break the lookup. Trimming
1118 /// matches `map_sqlite_type`'s `trim()`; both functions parse
1119 /// values straight from a sqlx row and the trim is a cheap
1120 /// safety net.
1121 #[test]
1122 fn map_postgres_type_trims_whitespace() {
1123 assert_eq!(map_postgres_type(" bigint "), Some(SqlType::BigInt));
1124 }
1125}