Skip to main content

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 [`SqlType`] catalogue: integers
33//!   (including `unsigned` variants from Django's PositiveIntegerField
34//!   family), floats, bool, text, date / time / timestamptz, uuid, json,
35//!   bytea, and numeric / decimal — plus their nullable variants.
36//!   Decimal maps faithfully to `rust_decimal::Decimal` even from a
37//!   SQLite source (it is Postgres-only at runtime, so the boot system
38//!   check surfaces that when the model targets SQLite). Anything still
39//!   off-catalogue (arrays, custom types) returns
40//!   [`InspectError::UnsupportedColumnType`] with the table / column
41//!   names; the user fixes by-hand or waits for the field-type
42//!   catalogue to grow.
43//! - **FKs and indexes.** Not yet read out. The CreateTable op carries
44//!   columns only; FK / index detection lands with the field-level
45//!   support in [`crate::orm`].
46//!
47//! See [`docs/specs/07-inspectdb.md`] for the eventual target shape
48//! and the deferred items.
49//!
50//! [`DatabaseBackend`]: crate::backend::DatabaseBackend
51//! [`SqlType`]: crate::orm::SqlType
52
53use std::path::{Path, PathBuf};
54
55use sqlx::{PgPool, Row, SqlitePool};
56use umbral_casing::{pascal_case_from_table, to_snake_case};
57
58use crate::migrate::{self, Column, MigrationFile, ModelMeta, Operation, Snapshot};
59use crate::orm::SqlType;
60
61/// Default plugin name the generated migration is filed under. Matches
62/// [`crate::migrate::APP_PLUGIN_NAME`] so the produced
63/// `0001_initial.json` lands inside the same `migrations/app/`
64/// directory the M5 engine reads from. M7 lifts this once the user can
65/// choose a real plugin name via `--plugin`.
66pub const INSPECTED_PLUGIN_NAME: &str = migrate::APP_PLUGIN_NAME;
67
68/// Default filename for the introspected initial migration.
69pub const INITIAL_MIGRATION_ID: &str = "0001_initial";
70
71/// The introspection result. A flat list of tables, each with its
72/// columns in declaration order. Indexes and foreign keys are omitted
73/// at M6 v1 (the field types they target don't exist yet).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct IntrospectedSchema {
76    pub tables: Vec<IntrospectedTable>,
77}
78
79/// One introspected table.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct IntrospectedTable {
82    /// The SQL table name as it appears in the database.
83    pub table: String,
84    /// The struct name the renderer will use. Defaults to the table
85    /// name in UpperCamelCase; the M6 v1 importer does not strip
86    /// prefixes (deferred to M7's `--strip-prefix` flag).
87    pub name: String,
88    /// One descriptor per column, in declaration order.
89    pub columns: Vec<IntrospectedColumn>,
90    /// Multi-column UNIQUE constraints / unique indexes, each a column-name
91    /// group. Rendered as `#[umbral(unique_together = [[...]])]`. Single-column
92    /// uniques live on the column's `unique` flag instead.
93    pub unique_together: Vec<Vec<String>>,
94    /// Multi-column (non-unique) indexes, each a column-name group. Rendered as
95    /// `#[umbral(indexes = [[...]])]`. Single-column indexes use the column's
96    /// `index` flag.
97    pub indexes: Vec<Vec<String>>,
98    /// Many-to-many relations this table OWNS — recovered by folding a Django
99    /// join table (`communities_community_software`) into an `M2M<T>` field on
100    /// the owner (`Community.software`). The join table itself is removed from
101    /// the schema; umbral auto-generates its own junction. See
102    /// [`detect_m2m_relations`].
103    pub m2m: Vec<IntrospectedM2M>,
104}
105
106/// One recovered many-to-many relation, folded from a Django join table onto
107/// the owning model as an `M2M<Target>` field.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct IntrospectedM2M {
110    /// The Rust field name (`software`), from the join table's suffix after the
111    /// owner table name.
112    pub field_name: String,
113    /// The target model's SQL table (`software`).
114    pub target_table: String,
115    /// The target model's resolved struct name (`Software`).
116    pub target_name: String,
117}
118
119/// One introspected column.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct IntrospectedColumn {
122    pub name: String,
123    pub ty: SqlType,
124    pub primary_key: bool,
125    pub nullable: bool,
126    /// The referenced table when this column is a foreign key, else `None`.
127    /// Drives rendering the field as `ForeignKey<Target>` rather than a bare
128    /// integer, and populates `Column::fk_target` in the initial migration.
129    pub fk_target: Option<String>,
130    /// A single-column UNIQUE constraint / unique index covers this column.
131    pub unique: bool,
132    /// A single-column (non-unique) index covers this column — rendered as
133    /// `#[umbral(index)]`.
134    pub index: bool,
135    /// The recovered constant DB default (`'active'`, `0`, `true`), cleaned of
136    /// Postgres `::type` casts and surrounding quotes. `None` when the column
137    /// has no default, or one umbral can't represent as a `#[umbral(default)]`
138    /// literal — a sequence (`nextval(...)`) or function call. A
139    /// `CURRENT_TIMESTAMP` / `now()` default on a temporal column is lifted to
140    /// `auto_now_add` instead of landing here.
141    pub default: Option<String>,
142    /// The column is populated with the current time on INSERT — recovered from
143    /// a `CURRENT_TIMESTAMP` / `now()` default on a temporal column, or (under
144    /// `--framework django`) a `created*`-named timestamp. Renders
145    /// `#[umbral(auto_now_add)]`.
146    pub auto_now_add: bool,
147    /// The column is refreshed to the current time on every write. Not
148    /// expressible as DB metadata on Postgres/SQLite (Django sets it in Python),
149    /// so recovered only by the `--framework django` name heuristic
150    /// (`updated*` / `modified*`). Renders `#[umbral(auto_now)]`.
151    pub auto_now: bool,
152    /// The closed set of values a native DB enum column accepts, in
153    /// `enumsortorder`. Non-empty only for a Postgres `USER-DEFINED` enum
154    /// column: the type stays [`SqlType::Text`] and the field renders as the
155    /// generated [`Choices`] enum named by [`Self::enum_type`], carrying a
156    /// `#[umbral(choices)]` attribute (which the migration lowers to a
157    /// `CHECK (col IN (...))` constraint). Empty for every non-enum column —
158    /// SQLite has no native enum type, so its path never populates this.
159    pub choices: Vec<String>,
160    /// The Postgres enum type name backing a `choices` column (`PaymentMethod`),
161    /// or `None` when the column isn't a native enum. Drives the generated Rust
162    /// enum's name and lets columns sharing one DB enum type reuse a single
163    /// generated `enum` rather than each emitting its own.
164    pub enum_type: Option<String>,
165}
166
167/// Errors `inspectdb` can produce. Carries enough detail for the CLI
168/// to print a single-line diagnostic with the offending table and
169/// column.
170#[derive(Debug)]
171pub enum InspectError {
172    /// IO error reading or writing a generated file.
173    Io(std::io::Error),
174    /// JSON serialisation error pretty-printing the generated migration.
175    Json(serde_json::Error),
176    /// sqlx error executing the introspection queries.
177    Sqlx(sqlx::Error),
178    /// The introspection ran but found no tables. Surfaced so the CLI
179    /// can print "nothing to import" instead of writing empty files.
180    NoTables,
181    /// A column's SQL type isn't in the M6 v1 mapping table. Holds the
182    /// table / column / raw SQL type so the user can decide whether to
183    /// add a field type, edit the generated code, or wait for the
184    /// catalogue to grow.
185    UnsupportedColumnType {
186        table: String,
187        column: String,
188        sql_type: String,
189    },
190    /// Pass-through for migration-engine failures (e.g. recording the
191    /// initial migration as applied).
192    Migrate(migrate::MigrateError),
193}
194
195impl std::fmt::Display for InspectError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            InspectError::Io(e) => write!(f, "umbral inspectdb: io: {e}"),
199            InspectError::Json(e) => write!(f, "umbral inspectdb: json: {e}"),
200            InspectError::Sqlx(e) => write!(f, "umbral inspectdb: sqlx: {e}"),
201            InspectError::NoTables => write!(
202                f,
203                "umbral inspectdb: no tables found in the database (nothing to import)"
204            ),
205            InspectError::UnsupportedColumnType {
206                table,
207                column,
208                sql_type,
209            } => write!(
210                f,
211                "umbral inspectdb: column `{table}.{column}` has unsupported SQL type `{sql_type}`; \
212                 add a matching SqlType variant or edit the generated model by hand"
213            ),
214            InspectError::Migrate(e) => write!(f, "umbral inspectdb: migrate: {e}"),
215        }
216    }
217}
218
219impl std::error::Error for InspectError {}
220
221impl From<std::io::Error> for InspectError {
222    fn from(e: std::io::Error) -> Self {
223        Self::Io(e)
224    }
225}
226
227impl From<sqlx::Error> for InspectError {
228    fn from(e: sqlx::Error) -> Self {
229        Self::Sqlx(e)
230    }
231}
232
233impl From<serde_json::Error> for InspectError {
234    fn from(e: serde_json::Error) -> Self {
235        Self::Json(e)
236    }
237}
238
239impl From<migrate::MigrateError> for InspectError {
240    fn from(e: migrate::MigrateError) -> Self {
241        Self::Migrate(e)
242    }
243}
244
245/// CLI-driven options. The CLI subcommand wires its flags into this
246/// struct and hands it to [`inspectdb`].
247/// A source ORM/framework whose naming conventions `inspectdb` can undo to
248/// produce idiomatic umbral models. Currently only Django, the porting test
249/// ground. `None` keeps the raw database names verbatim.
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251pub enum Framework {
252    /// Django: FK column `<field>_id`. Strip the `_id` (and a leading `<app>_`
253    /// prefix when the leading segment is a detected app label) so the field is
254    /// the clean `<field>`; also externalizes `auth_user` and folds M2M tables.
255    Django,
256    /// Rails / ActiveRecord: FK column `<field>_id` (snake, like Django) — strip
257    /// the `_id`. No app prefix, no `auth_user`.
258    Rails,
259    /// Laravel / Eloquent: FK column `<field>_id` (snake, like Django) — strip
260    /// the `_id`. No app prefix, no `auth_user`.
261    Laravel,
262    /// Prisma / TypeORM (and camelCase ORMs generally): columns are camelCase.
263    /// Snake-case every column to umbral's convention (`firstName` ->
264    /// `first_name`); a FK column additionally sheds a trailing `Id`
265    /// (`authorId` -> `author`).
266    Prisma,
267}
268
269impl Framework {
270    /// Parse a `--framework` value (case-insensitive). Returns `None` for an
271    /// unknown name so the caller can report it.
272    pub fn parse(s: &str) -> Option<Framework> {
273        match s.trim().to_ascii_lowercase().as_str() {
274            "django" => Some(Framework::Django),
275            "rails" | "activerecord" => Some(Framework::Rails),
276            "laravel" | "eloquent" => Some(Framework::Laravel),
277            "prisma" | "typeorm" => Some(Framework::Prisma),
278            _ => None,
279        }
280    }
281}
282
283#[derive(Debug, Clone)]
284pub struct InspectOptions {
285    /// The source database connection URL to introspect. `None` means "use the
286    /// app's ambient pool" (the historical behaviour); `Some(url)` opens a
287    /// dedicated connection to that database instead, so `umbral inspectdb
288    /// <db>` can onboard a foreign schema without repointing the whole app.
289    pub source: Option<String>,
290    /// The source framework whose conventions to undo (`--framework django`).
291    /// `None` keeps raw column names.
292    pub framework: Option<Framework>,
293    /// Strip the framework app-prefix off **struct names** (`blog_post` ->
294    /// `Post`) and preserve the real table with a `#[umbral(table = "...")]`
295    /// macro (`--with-table-names`). Off by default: struct names stay full
296    /// (`BlogPost`) and round-trip to their table, so no table macro is emitted.
297    /// A struct name that still wouldn't round-trip (odd casing) gets its table
298    /// macro regardless, so generated models always map to the right table.
299    pub with_table_names: bool,
300    /// Directory the generated files are written under. `models.rs`
301    /// lands at the root; the migration lands at
302    /// `<output>/migrations/<INSPECTED_PLUGIN_NAME>/0001_initial.json`.
303    pub output: PathBuf,
304    /// Mark `0001_initial` as applied in `umbral_migrations` after
305    /// writing it. The right default when the target database already
306    /// has tables (running the migration would fail). Off for empty
307    /// databases.
308    pub mark_applied: bool,
309}
310
311/// Summary returned to the CLI. Counts that the caller can render as a
312/// one-line "imported N tables / M columns" message.
313#[derive(Debug, Clone, Default)]
314pub struct InspectReport {
315    pub tables: usize,
316    pub columns: usize,
317    pub models_path: PathBuf,
318    pub migration_path: PathBuf,
319}
320
321// =========================================================================
322// Top-level entry points. Bodies filled in by the M6 fan-out subagents.
323// =========================================================================
324
325/// Run the full `inspectdb` pipeline against the ambient pool:
326/// introspect (dispatching on the active backend), render `models.rs`,
327/// render `0001_initial.json`, write both to `opts.output`, and
328/// optionally mark applied.
329///
330/// Phase 3 of the Postgres rollout taught this entry point to dispatch
331/// on `DbPool` — the SQLite path uses `PRAGMA table_info`; the
332/// Postgres path uses `information_schema`. The downstream pipeline
333/// (rendering + writing) is backend-agnostic and runs the same way.
334pub async fn inspectdb(opts: InspectOptions) -> Result<InspectReport, InspectError> {
335    // A `--source` URL opens its own connection so a foreign database can be
336    // onboarded without repointing the whole app; otherwise introspect the
337    // ambient pool the app already booted with.
338    let schema = match &opts.source {
339        Some(url) => match crate::db::connect(url).await? {
340            crate::db::DbPool::Sqlite(pool) => introspect_pool(&pool).await?,
341            crate::db::DbPool::Postgres(pool) => introspect_pool_pg(&pool).await?,
342        },
343        None => match crate::db::pool_dispatched() {
344            crate::db::DbPool::Sqlite(pool) => introspect_pool(pool).await?,
345            crate::db::DbPool::Postgres(pool) => introspect_pool_pg(pool).await?,
346        },
347    };
348    if schema.tables.is_empty() {
349        return Err(InspectError::NoTables);
350    }
351    // Lift recovered DB defaults + framework naming into umbral's semantic field
352    // attributes (auto_now_add / auto_now / default) so BOTH the model and the
353    // initial migration render them consistently.
354    let mut schema = schema;
355    apply_recovered_conventions(&mut schema, opts.framework);
356    // Rename source columns to umbral field names per the framework's
357    // convention (Django/Rails/Laravel shed a FK's `_id`; Prisma snake-cases
358    // every column). inspectdb writes a fresh schema, so the field IS the
359    // column — no `#[sqlx(rename)]` needed.
360    if let Some(fw) = opts.framework {
361        apply_framework_column_names(&mut schema, fw);
362    }
363    // Fold Django M2M join tables into `M2M<T>` fields on their owner (and drop
364    // the join table — umbral auto-generates its own junction).
365    detect_m2m_relations(&mut schema, opts.framework, opts.with_table_names);
366
367    let models_src = render_models_with(&schema, opts.framework, opts.with_table_names);
368    let migration = render_initial_migration(&schema);
369    let report = write_outputs(&opts.output, &models_src, &migration).await?;
370
371    if opts.mark_applied {
372        let hash = migration.snapshot_after.hash();
373        migrate::record_applied(&migration.plugin, &migration.id, &hash).await?;
374    }
375
376    Ok(report)
377}
378
379/// Introspect the schema reachable through the given SQLite pool.
380/// Reads `sqlite_master` for table names and `PRAGMA table_info(...)`
381/// for column descriptors. Skips internal tables (`sqlite_*`,
382/// `umbral_migrations`).
383pub async fn introspect_pool(pool: &SqlitePool) -> Result<IntrospectedSchema, InspectError> {
384    // List user tables in lexical name order. `sqlite_master` carries
385    // both tables and indexes; the `type = 'table'` predicate scopes the
386    // result to tables. The skip-list takes out SQLite's internal
387    // bookkeeping (`sqlite_%`) and umbral's own tracking table, which
388    // would otherwise loop back through the migration engine.
389    let table_rows = sqlx::query(
390        "SELECT name FROM sqlite_master \
391         WHERE type = 'table' \
392           AND name NOT LIKE 'sqlite_%' \
393           AND name <> 'umbral_migrations' \
394         ORDER BY name",
395    )
396    .fetch_all(pool)
397    .await?;
398
399    let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
400    for row in table_rows {
401        let table: String = row.try_get("name")?;
402        let columns = introspect_columns(pool, &table).await?;
403        let (unique_together, indexes) = sqlite_composite_indexes(pool, &table).await?;
404        tables.push(IntrospectedTable {
405            name: pascal_case_from_table(&table),
406            table,
407            columns,
408            unique_together,
409            indexes,
410            m2m: Vec::new(),
411        });
412    }
413
414    Ok(IntrospectedSchema { tables })
415}
416
417/// Introspect the schema reachable through the given Postgres pool.
418/// Reads `information_schema.tables` for table names,
419/// `information_schema.columns` for column descriptors, and joins
420/// `information_schema.table_constraints` + `key_column_usage` for
421/// the primary-key flag. Scoped to the `public` schema by default;
422/// internal Postgres schemas and umbral's own `umbral_migrations`
423/// tracking table are skipped.
424///
425/// The output is the same `IntrospectedSchema` the SQLite path
426/// produces — downstream rendering doesn't know which backend the
427/// data came from.
428pub async fn introspect_pool_pg(pool: &PgPool) -> Result<IntrospectedSchema, InspectError> {
429    // List user tables in the `public` schema, lexically. Postgres
430    // information_schema is standard SQL; pg_catalog is the lower-
431    // level surface but information_schema is portable across
432    // Postgres-compatible servers and carries everything the
433    // SqlType catalogue needs.
434    let table_rows: Vec<(String,)> = sqlx::query_as(
435        "SELECT table_name FROM information_schema.tables \
436         WHERE table_schema = 'public' \
437           AND table_type = 'BASE TABLE' \
438           AND table_name <> 'umbral_migrations' \
439         ORDER BY table_name",
440    )
441    .fetch_all(pool)
442    .await?;
443
444    let mut tables: Vec<IntrospectedTable> = Vec::with_capacity(table_rows.len());
445    for (table,) in table_rows {
446        let columns = introspect_columns_pg(pool, &table).await?;
447        let (unique_together, indexes) = pg_composite_indexes(pool, &table).await;
448        tables.push(IntrospectedTable {
449            name: pascal_case_from_table(&table),
450            table,
451            columns,
452            unique_together,
453            indexes,
454            m2m: Vec::new(),
455        });
456    }
457
458    Ok(IntrospectedSchema { tables })
459}
460
461/// Read one Postgres table's columns via `information_schema.columns`,
462/// plus a primary-key join over `information_schema.table_constraints`
463/// and `key_column_usage`. Columns come back in declaration order
464/// (`ordinal_position`).
465///
466/// `data_type` is the normalised type string Postgres exposes through
467/// information_schema (e.g. `"integer"`, `"character varying"`,
468/// `"timestamp with time zone"`); [`map_postgres_type`] maps it to the
469/// umbral `SqlType` catalogue. Anything unmapped surfaces as
470/// [`InspectError::UnsupportedColumnType`] with the table / column
471/// names and the raw type string.
472async fn introspect_columns_pg(
473    pool: &PgPool,
474    table: &str,
475) -> Result<Vec<IntrospectedColumn>, InspectError> {
476    // The primary-key lookup runs once per table. The set is typically
477    // tiny (one column for most tables, a handful for composite keys)
478    // so collecting it up-front into a Vec keeps the inner column loop
479    // O(columns × pk_columns) without an extra round trip per column.
480    let pk_rows: Vec<(String,)> = sqlx::query_as(
481        "SELECT kcu.column_name \
482         FROM information_schema.table_constraints tc \
483         JOIN information_schema.key_column_usage kcu \
484           ON tc.constraint_name = kcu.constraint_name \
485          AND tc.table_schema = kcu.table_schema \
486         WHERE tc.constraint_type = 'PRIMARY KEY' \
487           AND tc.table_schema = 'public' \
488           AND tc.table_name = $1",
489    )
490    .bind(table)
491    .fetch_all(pool)
492    .await?;
493    let pk_columns: std::collections::HashSet<String> = pk_rows.into_iter().map(|(c,)| c).collect();
494
495    // `udt_name` carries the underlying type name even when `data_type`
496    // is the abstract `"ARRAY"` placeholder. For `bigint[]` the
497    // information_schema reports data_type = "ARRAY" and udt_name =
498    // "_int8" (underscore prefix marks the array variant in pg_type).
499    // For non-array columns udt_name carries the same physical name
500    // (`int8`, `text`, etc.) but `data_type` is the canonical match
501    // key we already lookup against.
502    let column_rows: Vec<(String, String, String, String, Option<String>)> = sqlx::query_as(
503        "SELECT column_name, data_type, is_nullable, udt_name, column_default \
504         FROM information_schema.columns \
505         WHERE table_schema = 'public' AND table_name = $1 \
506         ORDER BY ordinal_position",
507    )
508    .bind(table)
509    .fetch_all(pool)
510    .await?;
511
512    // Foreign keys: information_schema referential integrity views map a FK
513    // column to its referenced table. One row per FK column.
514    let fk_rows: Vec<(String, String)> = sqlx::query_as(
515        "SELECT kcu.column_name, ccu.table_name AS foreign_table \
516         FROM information_schema.table_constraints tc \
517         JOIN information_schema.key_column_usage kcu \
518           ON tc.constraint_name = kcu.constraint_name \
519          AND tc.table_schema = kcu.table_schema \
520         JOIN information_schema.constraint_column_usage ccu \
521           ON ccu.constraint_name = tc.constraint_name \
522          AND ccu.table_schema = tc.table_schema \
523         WHERE tc.constraint_type = 'FOREIGN KEY' \
524           AND tc.table_schema = 'public' \
525           AND tc.table_name = $1",
526    )
527    .bind(table)
528    .fetch_all(pool)
529    .await?;
530    let fk_map: std::collections::HashMap<String, String> = fk_rows.into_iter().collect();
531
532    // Single-column unique / index coverage from pg_index. `indisunique` marks
533    // a unique index; `indisprimary` PK indexes are excluded (the column is
534    // already the PK). Only single-column (`array_length(indkey) = 1`) indexes
535    // map to a per-column flag.
536    let idx_rows: Vec<(String, bool)> = sqlx::query_as(
537        "SELECT a.attname, ix.indisunique \
538         FROM pg_index ix \
539         JOIN pg_class t ON t.oid = ix.indrelid \
540         JOIN pg_namespace n ON n.oid = t.relnamespace \
541         JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ix.indkey[0] \
542         WHERE n.nspname = 'public' AND t.relname = $1 \
543           AND ix.indnatts = 1 AND NOT ix.indisprimary",
544    )
545    .bind(table)
546    .fetch_all(pool)
547    .await
548    .unwrap_or_default();
549    let mut unique_cols = std::collections::HashSet::new();
550    let mut index_cols = std::collections::HashSet::new();
551    for (col, is_unique) in idx_rows {
552        if is_unique {
553            unique_cols.insert(col);
554        } else {
555            index_cols.insert(col);
556        }
557    }
558
559    // PostGIS spatial columns report `data_type = "USER-DEFINED"` with
560    // `udt_name = "geometry"` / `"geography"`; the real subtype + SRID live in
561    // the `geometry_columns` / `geography_columns` catalog views. Look them up
562    // once per table so a geometry column recovers `geometry(Point, 4326)`
563    // rather than crashing as an unsupported type.
564    let spatial = pg_spatial_columns(pool, table).await;
565
566    // Native Postgres enum types (`CREATE TYPE ... AS ENUM (...)`) report
567    // `data_type = "USER-DEFINED"` with `udt_name` naming the enum type. The
568    // accepted labels live in `pg_enum`; recover them (in `enumsortorder`) so an
569    // enum column folds into a generated `Choices` enum rather than crashing as
570    // an unsupported type. Composite / range / domain USER-DEFINED types aren't
571    // enums, so they're absent from this map and still surface as unsupported.
572    let enums = pg_enum_columns(pool, table).await;
573
574    let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(column_rows.len());
575    for (name, data_type, is_nullable, udt_name, raw_default) in column_rows {
576        // A native enum column: TEXT-backed choices, carrying the enum type name
577        // so the renderer names (and de-duplicates) the generated `Choices` enum.
578        let (choices, enum_type) = match enums.get(&name) {
579            Some((type_name, labels)) => (labels.clone(), Some(type_name.clone())),
580            None => (Vec::new(), None),
581        };
582        let ty = if !choices.is_empty() {
583            // The enum's storage type is TEXT + a CHECK; the migration engine
584            // emits the CHECK from `choices`.
585            SqlType::Text
586        } else if udt_name.eq_ignore_ascii_case("geometry")
587            || udt_name.eq_ignore_ascii_case("geography")
588        {
589            // Prefer the catalog's exact subtype+SRID; fall back to the
590            // unconstrained base type when the column isn't registered there.
591            spatial.get(&name).copied().unwrap_or_else(|| {
592                let spec = crate::orm::GeometrySpec::DEFAULT;
593                if udt_name.eq_ignore_ascii_case("geography") {
594                    SqlType::Geography(spec)
595                } else {
596                    SqlType::Geometry(spec)
597                }
598            })
599        } else if data_type.eq_ignore_ascii_case("ARRAY") {
600            // Element type comes from udt_name with the leading
601            // underscore stripped. `_int8` -> int8 -> ArrayElement::BigInt.
602            let elem_name = udt_name.strip_prefix('_').unwrap_or(udt_name.as_str());
603            map_postgres_array_element(elem_name).ok_or_else(|| {
604                InspectError::UnsupportedColumnType {
605                    table: table.to_string(),
606                    column: name.clone(),
607                    sql_type: format!("ARRAY of {elem_name}"),
608                }
609            })?
610        } else {
611            map_postgres_type(&data_type).ok_or_else(|| InspectError::UnsupportedColumnType {
612                table: table.to_string(),
613                column: name.clone(),
614                sql_type: data_type.clone(),
615            })?
616        };
617        // A FK column renders as `ForeignKey<Target>`; the referenced table is
618        // what makes it one, regardless of the stored integer type.
619        let fk_target = fk_map.get(&name).cloned();
620        let ty = if fk_target.is_some() {
621            SqlType::ForeignKey
622        } else {
623            ty
624        };
625        let primary_key = pk_columns.contains(&name);
626        // Postgres `is_nullable` is the string "YES" or "NO". A primary
627        // key is non-nullable by definition (the server enforces it);
628        // we force `nullable = false` so a SERIAL/BIGSERIAL PK round-
629        // trips through the M3 derive (which rejects `Option<T>` PKs)
630        // matching the behavioural fix already in place on the SQLite
631        // path.
632        let nullable = if primary_key {
633            false
634        } else {
635            is_nullable.eq_ignore_ascii_case("YES")
636        };
637        let unique = !primary_key && unique_cols.contains(&name);
638        let index = !primary_key && !unique && index_cols.contains(&name);
639        columns.push(IntrospectedColumn {
640            name,
641            ty,
642            primary_key,
643            nullable,
644            fk_target,
645            unique,
646            index,
647            // Raw recovered default; semantic lift happens in
648            // `apply_recovered_conventions`, shared with the SQLite path.
649            default: raw_default,
650            auto_now_add: false,
651            auto_now: false,
652            // Recovered native-enum labels (empty for a non-enum column).
653            choices,
654            enum_type,
655        });
656    }
657
658    Ok(columns)
659}
660
661/// Map a Postgres array's element-type name (from `udt_name` with the
662/// leading underscore stripped) to a [`SqlType::Array`] variant.
663///
664/// The `udt_name` column on `information_schema.columns` carries the
665/// physical type name from `pg_catalog.pg_type`; array variants are
666/// prefixed with `_` (`_int8` for `bigint[]`, `_text` for `text[]`).
667/// The caller strips the prefix; this function maps the remaining
668/// stem to the umbral `ArrayElement` catalogue.
669///
670/// Returns `None` if the element type isn't in
671/// `umbral::orm::ArrayElement` — chrono types, JSON, network types,
672/// and Postgres-specific types like NUMERIC fall outside Phase 4.1's
673/// array catalogue.
674/// Return `(unique_together, indexes)` — the MULTI-column index groups for a
675/// Postgres table, columns in index order. A composite unique index →
676/// `unique_together`; a composite plain index → `indexes`. Primary-key and
677/// single-column indexes are excluded (the latter ride the per-column flags).
678/// Expression indexes (a column position isn't a plain attribute) are skipped.
679async fn pg_composite_indexes(pool: &PgPool, table: &str) -> (Vec<Vec<String>>, Vec<Vec<String>>) {
680    // `indkey` is an int2vector of attribute numbers in index order; unnest it
681    // WITH ORDINALITY to preserve that order, then resolve each to its column
682    // name. `a.attnum > 0` drops system/expression positions.
683    let rows: Vec<(bool, Vec<String>)> = sqlx::query_as(
684        "SELECT ix.indisunique, array_agg(a.attname ORDER BY k.ord) AS cols \
685         FROM pg_index ix \
686         JOIN pg_class t ON t.oid = ix.indrelid \
687         JOIN pg_namespace n ON n.oid = t.relnamespace \
688         JOIN unnest(string_to_array(ix.indkey::text, ' ')::smallint[]) \
689              WITH ORDINALITY AS k(attnum, ord) ON true \
690         JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum AND a.attnum > 0 \
691         WHERE n.nspname = 'public' AND t.relname = $1 \
692           AND ix.indnatts > 1 AND NOT ix.indisprimary \
693         GROUP BY ix.indexrelid, ix.indisunique",
694    )
695    .bind(table)
696    .fetch_all(pool)
697    .await
698    .unwrap_or_default();
699
700    let mut uniques = Vec::new();
701    let mut plains = Vec::new();
702    for (is_unique, cols) in rows {
703        if cols.len() < 2 {
704            continue; // a partial/expression index — can't model it
705        }
706        if is_unique {
707            uniques.push(cols);
708        } else {
709            plains.push(cols);
710        }
711    }
712    (uniques, plains)
713}
714
715/// Read PostGIS's `geometry_columns` / `geography_columns` catalog views for
716/// one table, mapping each spatial column to its exact `SqlType::Geometry` /
717/// `Geography` with recovered subtype + SRID. Returns an empty map when the
718/// views are absent (a non-PostGIS database) or a column isn't registered, so
719/// the caller falls back to the unconstrained base type.
720async fn pg_spatial_columns(
721    pool: &PgPool,
722    table: &str,
723) -> std::collections::HashMap<String, SqlType> {
724    use crate::orm::{GeometryKind, GeometrySpec};
725    let mut map = std::collections::HashMap::new();
726
727    // `geometry_columns.type` is the PostGIS subtype name ('POINT',
728    // 'MULTIPOLYGON', 'GEOMETRY'); `GeometryKind::from_attr` folds case.
729    let geom: Vec<(String, String, i32)> = sqlx::query_as(
730        "SELECT f_geometry_column, type, srid FROM geometry_columns \
731         WHERE f_table_schema = 'public' AND f_table_name = $1",
732    )
733    .bind(table)
734    .fetch_all(pool)
735    .await
736    .unwrap_or_default();
737    for (col, kind, srid) in geom {
738        let spec = GeometrySpec {
739            kind: GeometryKind::from_attr(&kind).unwrap_or(GeometryKind::Geometry),
740            srid,
741        };
742        map.insert(col, SqlType::Geometry(spec));
743    }
744
745    let geog: Vec<(String, String, i32)> = sqlx::query_as(
746        "SELECT f_geography_column, type, srid FROM geography_columns \
747         WHERE f_table_schema = 'public' AND f_table_name = $1",
748    )
749    .bind(table)
750    .fetch_all(pool)
751    .await
752    .unwrap_or_default();
753    for (col, kind, srid) in geog {
754        let spec = GeometrySpec {
755            kind: GeometryKind::from_attr(&kind).unwrap_or(GeometryKind::Geometry),
756            srid,
757        };
758        map.insert(col, SqlType::Geography(spec));
759    }
760
761    map
762}
763
764/// Recover the native Postgres enum columns of one table: a map from column
765/// name to `(enum_type_name, labels)`, labels in `enumsortorder`.
766///
767/// A `CREATE TYPE ... AS ENUM (...)` column reports `data_type =
768/// "USER-DEFINED"` through information_schema; only `pg_enum` carries the
769/// accepted labels. Joining `pg_attribute` → `pg_type` → `pg_enum` for the
770/// table yields one row per (column, label); the labels for a column are
771/// collected in declaration order. Columns whose USER-DEFINED type is a
772/// composite / range / domain (not an enum) simply produce no rows and stay
773/// out of the map, so they still surface as [`InspectError::UnsupportedColumnType`].
774async fn pg_enum_columns(
775    pool: &PgPool,
776    table: &str,
777) -> std::collections::HashMap<String, (String, Vec<String>)> {
778    // One row per (column, enum label), ordered so `enumsortorder` preserves the
779    // enum's declared value order and `attnum` groups a column's labels together.
780    let rows: Vec<(String, String, String)> = sqlx::query_as(
781        "SELECT a.attname, t.typname, e.enumlabel \
782         FROM pg_attribute a \
783         JOIN pg_type t ON t.oid = a.atttypid \
784         JOIN pg_enum e ON e.enumtypid = t.oid \
785         JOIN pg_class c ON c.oid = a.attrelid \
786         JOIN pg_namespace n ON n.oid = c.relnamespace \
787         WHERE n.nspname = 'public' AND c.relname = $1 \
788           AND a.attnum > 0 AND NOT a.attisdropped \
789         ORDER BY a.attnum, e.enumsortorder",
790    )
791    .bind(table)
792    .fetch_all(pool)
793    .await
794    .unwrap_or_default();
795
796    let mut map: std::collections::HashMap<String, (String, Vec<String>)> =
797        std::collections::HashMap::new();
798    for (col, type_name, label) in rows {
799        map.entry(col)
800            .or_insert_with(|| (type_name, Vec::new()))
801            .1
802            .push(label);
803    }
804    map
805}
806
807fn map_postgres_array_element(elem: &str) -> Option<SqlType> {
808    use crate::orm::ArrayElement;
809    let kind = match elem.trim().to_ascii_lowercase().as_str() {
810        // Postgres physical type names (per pg_type.typname). The
811        // information_schema strips spaces from the data_type alias
812        // form, so we match the canonical lowercase names here.
813        "int2" => ArrayElement::SmallInt,
814        "int4" => ArrayElement::Integer,
815        "int8" => ArrayElement::BigInt,
816        "float4" => ArrayElement::Real,
817        "float8" => ArrayElement::Double,
818        "bool" => ArrayElement::Boolean,
819        "text" | "varchar" | "bpchar" => ArrayElement::Text,
820        "uuid" => ArrayElement::Uuid,
821        _ => return None,
822    };
823    Some(SqlType::Array(kind))
824}
825
826/// Map a Postgres `information_schema.columns.data_type` value to the
827/// umbral `SqlType` catalogue. Postgres normalises the strings, so the
828/// match table is the canonical names rather than the optional aliases
829/// `pg_type.typname` would expose. The inverse of
830/// [`crate::backend::PostgresBackend::map_type`] — both stay in sync
831/// as new `SqlType` variants land.
832///
833/// Returns `None` on anything not in the catalogue (Postgres-specific
834/// types like `numeric`, `jsonb`, `bytea`, arrays, custom domains).
835/// The caller turns that into `UnsupportedColumnType` with enough
836/// context for the operator to fix by hand or wait for the field-
837/// type catalogue to grow.
838fn map_postgres_type(raw: &str) -> Option<SqlType> {
839    let normalised = raw.trim().to_ascii_lowercase();
840    match normalised.as_str() {
841        "smallint" => Some(SqlType::SmallInt),
842        "integer" => Some(SqlType::Integer),
843        "bigint" => Some(SqlType::BigInt),
844        "real" => Some(SqlType::Real),
845        "double precision" => Some(SqlType::Double),
846        "boolean" => Some(SqlType::Boolean),
847        // information_schema reports `text`, `character varying`, and
848        // `character` for VARCHAR / CHAR / TEXT. All round-trip through
849        // umbral's Text variant.
850        "text" | "character varying" | "character" => Some(SqlType::Text),
851        "date" => Some(SqlType::Date),
852        // Both timezone variants of TIME land on umbral's Time. The
853        // distinction is preserved in the database; the client-side
854        // type system doesn't model it yet.
855        "time without time zone" | "time with time zone" => Some(SqlType::Time),
856        // Likewise both timezone variants of TIMESTAMP land on
857        // Timestamptz. The umbral catalogue picks the with-tz variant
858        // as the default so chrono::DateTime<Utc> is the natural Rust
859        // type for either.
860        // A tz-aware column round-trips through `DateTime<Utc>`; a naive one
861        // (Prisma's `DateTime`, some Django configs) needs `NaiveDateTime` or
862        // sqlx refuses to decode it. Recover the distinction so the generated
863        // model reads the source as-is.
864        "timestamp with time zone" => Some(SqlType::Timestamptz),
865        "timestamp without time zone" => Some(SqlType::Timestamp),
866        "uuid" => Some(SqlType::Uuid),
867        // Both `json` and `jsonb` round-trip to umbral's portable Json
868        // variant. The DDL renderer chose `jsonb` on the way out; if a
869        // pre-existing database stores values as `json` (the unindexed
870        // text variant), inspectdb still recognises it on the way in.
871        // A re-migrate would normalize to `jsonb` if the user re-creates
872        // the column, which matches the M5 declare-and-migrate loop.
873        "json" | "jsonb" => Some(SqlType::Json),
874        // Phase 4.4: Postgres network address types.
875        "inet" => Some(SqlType::Inet),
876        "cidr" => Some(SqlType::Cidr),
877        "macaddr" => Some(SqlType::MacAddr),
878        // gaps2 #70: text-backed Postgres types. `bit varying` and bare
879        // `bit` (the information_schema sometimes reports `bit` for a
880        // BIT(n)) both round-trip to the `Bit` variant.
881        "xml" => Some(SqlType::Xml),
882        "ltree" => Some(SqlType::Ltree),
883        "bit" | "bit varying" | "varbit" => Some(SqlType::Bit),
884        "tsvector" => Some(SqlType::FullText),
885        // Postgres reports both NUMERIC and DECIMAL as `numeric` in
886        // information_schema.columns.data_type (precision/scale live in
887        // separate columns, so no width string to strip). Maps to
888        // umbral's Decimal, whose PG DDL renders back as `numeric(19,4)`.
889        "numeric" | "decimal" => Some(SqlType::Decimal),
890        "bytea" => Some(SqlType::Bytes),
891        _ => None,
892    }
893}
894
895/// Read one table's columns via `PRAGMA table_info`. The PRAGMA returns
896/// `(cid, name, type, notnull, dflt_value, pk)` rows in declaration
897/// order, sorted defensively by `cid` so a downstream change to the
898/// PRAGMA's behaviour doesn't silently scramble field order.
899async fn introspect_columns(
900    pool: &SqlitePool,
901    table: &str,
902) -> Result<Vec<IntrospectedColumn>, InspectError> {
903    // The PRAGMA name can't be bound as a parameter, but it also can't
904    // contain user-supplied input here: `table` comes from `sqlite_master`
905    // and matches an existing table identifier by construction.
906    let quoted = table.replace('"', "\"\"");
907    let sql = format!("PRAGMA table_info(\"{quoted}\")");
908    let mut rows = sqlx::query(&sql).fetch_all(pool).await?;
909    rows.sort_by_key(|r| r.try_get::<i64, _>("cid").unwrap_or(0));
910
911    // Foreign keys: `PRAGMA foreign_key_list` gives one row per FK column with
912    // its referenced `table`. Map from-column -> target table so a column that
913    // is a FK renders as `ForeignKey<Target>` instead of a bare integer.
914    let fk_map = sqlite_foreign_keys(pool, &quoted).await?;
915    // Single-column unique / index coverage from `PRAGMA index_list` +
916    // `index_info`. A one-column unique index -> the column is UNIQUE; a
917    // one-column plain index -> `#[umbral(index)]`.
918    let (unique_cols, index_cols) = sqlite_indexed_columns(pool, &quoted).await?;
919
920    let mut columns: Vec<IntrospectedColumn> = Vec::with_capacity(rows.len());
921    for row in rows {
922        let name: String = row.try_get("name")?;
923        let raw_type: String = row.try_get("type")?;
924        let notnull: i64 = row.try_get("notnull")?;
925        let pk: i64 = row.try_get("pk")?;
926        // `dflt_value` is NULL when the column has no default; sqlx surfaces
927        // that as `None`. Otherwise it's the raw default token verbatim
928        // (`CURRENT_TIMESTAMP`, `0`, `'active'`).
929        let raw_default: Option<String> = row.try_get("dflt_value").ok().flatten();
930        // A FK column's declared type is whatever SQLite stored (usually
931        // `integer`); the target table is what makes it a foreign key.
932        let fk_target = fk_map.get(&name).cloned();
933        let ty = if fk_target.is_some() {
934            SqlType::ForeignKey
935        } else {
936            map_sqlite_type(&raw_type).ok_or_else(|| InspectError::UnsupportedColumnType {
937                table: table.to_string(),
938                column: name.clone(),
939                sql_type: raw_type.clone(),
940            })?
941        };
942        let primary_key = pk != 0;
943        // SQLite's `PRAGMA table_info` reports `notnull = 0` for
944        // `INTEGER PRIMARY KEY` columns because they're aliases for
945        // ROWID (which SQLite manages internally). The columns are
946        // nonetheless guaranteed non-null: SQLite refuses to insert
947        // NULL into a primary key. Forcing `nullable = false` here
948        // makes the generated `#[derive(Model)]` compile (the M3
949        // derive's PK detection requires a non-`Option` PK field)
950        // and matches what the database actually enforces.
951        let nullable = if primary_key { false } else { notnull == 0 };
952        // A PK column is already indexed/unique implicitly; don't re-emit.
953        let unique = !primary_key && unique_cols.contains(&name);
954        let index = !primary_key && !unique && index_cols.contains(&name);
955        columns.push(IntrospectedColumn {
956            name,
957            ty,
958            primary_key,
959            nullable,
960            fk_target,
961            unique,
962            index,
963            // Raw recovered default; the semantic lift (CURRENT_TIMESTAMP ->
964            // auto_now_add, unquote strings, drop expressions) happens in
965            // `apply_recovered_conventions` so it's shared with the PG path.
966            default: raw_default,
967            auto_now_add: false,
968            auto_now: false,
969            // SQLite has no native enum type — a closed-set column is modelled
970            // as plain TEXT with a CHECK the introspector doesn't parse back,
971            // so the recovered column carries no choices.
972            choices: Vec::new(),
973            enum_type: None,
974        });
975    }
976    Ok(columns)
977}
978
979/// Map each foreign-key column of `table` to its referenced table, via
980/// `PRAGMA foreign_key_list`. Composite FKs (rare in ORM-generated schemas)
981/// contribute each of their `from` columns pointing at the same target; umbral
982/// models a FK as a single column, so the first mapping per column wins.
983async fn sqlite_foreign_keys(
984    pool: &SqlitePool,
985    quoted_table: &str,
986) -> Result<std::collections::HashMap<String, String>, InspectError> {
987    let rows = sqlx::query(&format!("PRAGMA foreign_key_list(\"{quoted_table}\")"))
988        .fetch_all(pool)
989        .await?;
990    let mut map = std::collections::HashMap::new();
991    for row in rows {
992        let from: String = row.try_get("from")?;
993        let target: String = row.try_get("table")?;
994        map.entry(from).or_insert(target);
995    }
996    Ok(map)
997}
998
999/// Return `(unique_columns, indexed_columns)` for `table`: the columns covered
1000/// by a **single-column** unique index and a single-column plain index
1001/// respectively. Multi-column indexes are skipped (umbral models per-column
1002/// `unique`/`index`; composite `unique_together` recovery is deferred).
1003/// SQLite's auto-index for a UNIQUE constraint (`origin = 'u'`) and an explicit
1004/// `CREATE UNIQUE INDEX` both surface here as `unique = 1`.
1005async fn sqlite_indexed_columns(
1006    pool: &SqlitePool,
1007    quoted_table: &str,
1008) -> Result<
1009    (
1010        std::collections::HashSet<String>,
1011        std::collections::HashSet<String>,
1012    ),
1013    InspectError,
1014> {
1015    let mut unique = std::collections::HashSet::new();
1016    let mut plain = std::collections::HashSet::new();
1017    let index_rows = sqlx::query(&format!("PRAGMA index_list(\"{quoted_table}\")"))
1018        .fetch_all(pool)
1019        .await?;
1020    for idx in index_rows {
1021        let index_name: String = idx.try_get("name")?;
1022        let is_unique: i64 = idx.try_get("unique")?;
1023        let cols = sqlx::query(&format!(
1024            "PRAGMA index_info(\"{}\")",
1025            index_name.replace('"', "\"\"")
1026        ))
1027        .fetch_all(pool)
1028        .await?;
1029        // Only single-column indexes map to a per-column flag.
1030        if cols.len() != 1 {
1031            continue;
1032        }
1033        let col: String = cols[0].try_get("name")?;
1034        if is_unique != 0 {
1035            unique.insert(col);
1036        } else {
1037            plain.insert(col);
1038        }
1039    }
1040    Ok((unique, plain))
1041}
1042
1043/// Return `(unique_together, indexes)` — the MULTI-column index groups for
1044/// `table`. A composite unique index / UNIQUE constraint → `unique_together`; a
1045/// composite plain index → `indexes`. The PK's auto-index (`origin = 'pk'`) is
1046/// skipped (umbral has no composite PK), and single-column indexes are left to
1047/// [`sqlite_indexed_columns`]'s per-column flags.
1048async fn sqlite_composite_indexes(
1049    pool: &SqlitePool,
1050    table: &str,
1051) -> Result<(Vec<Vec<String>>, Vec<Vec<String>>), InspectError> {
1052    let quoted = table.replace('"', "\"\"");
1053    let mut uniques: Vec<Vec<String>> = Vec::new();
1054    let mut plains: Vec<Vec<String>> = Vec::new();
1055    let index_rows = sqlx::query(&format!("PRAGMA index_list(\"{quoted}\")"))
1056        .fetch_all(pool)
1057        .await?;
1058    for idx in index_rows {
1059        let index_name: String = idx.try_get("name")?;
1060        let is_unique: i64 = idx.try_get("unique")?;
1061        // `origin`: 'c' explicit CREATE INDEX, 'u' UNIQUE constraint, 'pk' the
1062        // primary-key auto-index (skip — not a user index).
1063        let origin: String = idx.try_get("origin").unwrap_or_default();
1064        if origin == "pk" {
1065            continue;
1066        }
1067        let cols_rows = sqlx::query(&format!(
1068            "PRAGMA index_info(\"{}\")",
1069            index_name.replace('"', "\"\"")
1070        ))
1071        .fetch_all(pool)
1072        .await?;
1073        if cols_rows.len() < 2 {
1074            continue; // single-column → handled per-column
1075        }
1076        let mut cols: Vec<(i64, String)> = Vec::new();
1077        for c in &cols_rows {
1078            cols.push((c.try_get("seqno")?, c.try_get("name")?));
1079        }
1080        cols.sort_by_key(|(seq, _)| *seq);
1081        let group: Vec<String> = cols.into_iter().map(|(_, n)| n).collect();
1082        if is_unique != 0 {
1083            uniques.push(group);
1084        } else {
1085            plains.push(group);
1086        }
1087    }
1088    Ok((uniques, plains))
1089}
1090
1091/// Map a raw SQLite type string to the M6 v1 [`SqlType`] catalogue.
1092/// Case-insensitive; trailing `(n)` or `(p,s)` width parameters are
1093/// stripped before matching so `VARCHAR(255)` and `NUMERIC(10,2)` come
1094/// through as `varchar` and `numeric`. A trailing `unsigned` / `signed`
1095/// qualifier is also stripped, so Django's `integer unsigned`
1096/// (`PositiveIntegerField`) maps to the base signed type. Returns `None` on anything not
1097/// in the table; the caller turns that into
1098/// [`InspectError::UnsupportedColumnType`] with the table and column
1099/// names attached.
1100fn map_sqlite_type(raw: &str) -> Option<SqlType> {
1101    let head = match raw.split_once('(') {
1102        Some((before, _)) => before,
1103        None => raw,
1104    };
1105    let normalised = head.trim().to_ascii_lowercase();
1106    // Strip a trailing signedness qualifier: Django's PositiveIntegerField
1107    // family emits `smallint unsigned` / `integer unsigned` / `bigint
1108    // unsigned`, and MySQL-origin dumps can carry `int signed`. SQLite
1109    // ignores these for column affinity, and Django range-caps the value
1110    // to the signed max, so the base signed type is the faithful mapping.
1111    let base = normalised
1112        .strip_suffix(" unsigned")
1113        .or_else(|| normalised.strip_suffix(" signed"))
1114        .map(str::trim_end)
1115        .unwrap_or(normalised.as_str());
1116    match base {
1117        "smallint" | "int2" => Some(SqlType::SmallInt),
1118        "int" | "integer" | "int4" => Some(SqlType::Integer),
1119        "bigint" | "int8" => Some(SqlType::BigInt),
1120        "real" | "float" | "float4" => Some(SqlType::Real),
1121        "double" | "double precision" | "float8" => Some(SqlType::Double),
1122        "boolean" | "bool" => Some(SqlType::Boolean),
1123        "text" | "varchar" | "char" | "clob" | "character" | "varying character" | "nchar"
1124        | "nvarchar" => Some(SqlType::Text),
1125        "date" => Some(SqlType::Date),
1126        "time" => Some(SqlType::Time),
1127        "timestamp" | "timestamptz" | "datetime" => Some(SqlType::Timestamptz),
1128        "uuid" => Some(SqlType::Uuid),
1129        // SQLite doesn't have a native JSON column type, but a user
1130        // declaring `CREATE TABLE t (data JSON)` parses the type-name
1131        // verbatim into `sqlite_master` and `PRAGMA table_info`. Treat
1132        // that as a hint that the column holds JSON content and route
1133        // it through `SqlType::Json` (which lowers to TEXT on SQLite
1134        // anyway).
1135        "json" | "jsonb" => Some(SqlType::Json),
1136        // Django's DecimalField declares its SQLite columns as `decimal`;
1137        // `numeric` is the SQL-standard spelling. Both map to umbral's
1138        // Decimal (rendered as `rust_decimal::Decimal`). NOTE: Decimal is
1139        // Postgres-only at v1 (sqlx has no SQLite Encode/Decode for it),
1140        // so a model carrying this field passes the boot check only
1141        // against Postgres. That's deliberate: inspectdb emits the
1142        // faithful type and lets the backend system check surface the
1143        // SQLite limitation, rather than silently downgrading to a lossy
1144        // f64. Width parameters (`decimal(9,6)`) are already stripped by
1145        // the `split_once('(')` above.
1146        "decimal" | "numeric" => Some(SqlType::Decimal),
1147        "blob" | "bytea" => Some(SqlType::Bytes),
1148        _ => None,
1149    }
1150}
1151
1152// `derive_table_name` (was `to_snake_case`) and `pascal_case` (now
1153// `pascal_case_from_table`) are imported from `umbral_casing` at the top
1154// of this file. The local copies were removed in the gaps2 #77 refactor.
1155
1156/// Render the introspected schema as the contents of a `models.rs`
1157/// file. The output is one `#[derive(Model)]` struct per table, with
1158/// fields in declaration order and the `#[umbral(table = "…")]`
1159/// attribute set when the struct name differs from the SQL table.
1160///
1161/// Structs are emitted in alphabetical order by struct name so a
1162/// re-run against an unchanged schema produces a byte-identical file.
1163/// Field-type rendering uses fully-qualified `chrono::*` / `uuid::*`
1164/// paths so no extra `use` lines are needed at the top of the file.
1165pub fn render_models(schema: &IntrospectedSchema) -> String {
1166    render_models_with(schema, None, false)
1167}
1168
1169/// Django's canonical user table, and the umbral-auth type it maps onto (same
1170/// `auth_user` table, `id: i64` PK). Under `--framework django` the generated
1171/// file does NOT re-declare this table; FKs to it point at umbral's `AuthUser`
1172/// and an import at the top lets the operator swap in a custom user model.
1173const DJANGO_USER_TABLE: &str = "auth_user";
1174const DJANGO_USER_STRUCT: &str = "AuthUser";
1175
1176/// [`render_models`] with a source [`Framework`] whose *reference* conventions
1177/// are undone (e.g. `--framework django` strips the `<app>_` prefix off FK
1178/// target structs and maps `auth_user` to `AuthUser`). FK field names keep
1179/// their real `_id` column — umbral's own idiom. `with_table_names` additionally
1180/// strips the `<app>_` prefix off **struct names**, preserving the real table
1181/// with a `#[umbral(table)]` macro (emitted in [`render_one_struct`]).
1182/// Resolve every table to its final Rust struct name, applying the same rules
1183/// the model renderer uses: under Django `auth_user` maps to the external
1184/// `AuthUser`; with `--with-table-names` the `<app>_` prefix is stripped (with a
1185/// collision fallback to the full pascal name). Shared so the model file, the
1186/// migration snapshot, and M2M target resolution all agree on names.
1187pub(crate) fn resolve_struct_names(
1188    schema: &IntrospectedSchema,
1189    framework: Option<Framework>,
1190    with_table_names: bool,
1191) -> std::collections::HashMap<String, String> {
1192    let django = framework == Some(Framework::Django);
1193    // Django "app labels" — the leading `<app>_` segment shared by table names.
1194    let app_labels: std::collections::HashSet<String> = schema
1195        .tables
1196        .iter()
1197        .filter_map(|t| t.table.split_once('_').map(|(app, _)| app.to_string()))
1198        .collect();
1199
1200    let mut struct_names: std::collections::HashMap<String, String> =
1201        std::collections::HashMap::new();
1202    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
1203    for t in &schema.tables {
1204        let name = if django && t.table == DJANGO_USER_TABLE {
1205            DJANGO_USER_STRUCT.to_string()
1206        } else if django && with_table_names {
1207            // App-prefix stripping is a Django convention; other frameworks
1208            // have no app prefix, so keep the full pascal-cased table name.
1209            django_struct_name(&t.table, &app_labels)
1210        } else {
1211            t.name.clone()
1212        };
1213        *counts.entry(name.clone()).or_default() += 1;
1214        struct_names.insert(t.table.clone(), name);
1215    }
1216    // Collision fallback: revert every table whose stripped name is shared to
1217    // its full pascal name (the external AuthUser is exempt — it's canonical).
1218    for t in &schema.tables {
1219        let name = &struct_names[&t.table];
1220        if counts[name] > 1 && !(django && t.table == DJANGO_USER_TABLE) {
1221            struct_names.insert(t.table.clone(), pascal_case_from_table(&t.table));
1222        }
1223    }
1224    struct_names
1225}
1226
1227pub fn render_models_with(
1228    schema: &IntrospectedSchema,
1229    framework: Option<Framework>,
1230    with_table_names: bool,
1231) -> String {
1232    let django = framework == Some(Framework::Django);
1233    let struct_names = resolve_struct_names(schema, framework, with_table_names);
1234
1235    // Does the schema reference Django's auth_user (own it, or FK at it)? If so,
1236    // emit the swap-your-user import.
1237    let uses_auth_user = django
1238        && schema.tables.iter().any(|t| {
1239            t.table == DJANGO_USER_TABLE
1240                || t.columns
1241                    .iter()
1242                    .any(|c| c.fk_target.as_deref() == Some(DJANGO_USER_TABLE))
1243        });
1244
1245    let mut out = String::new();
1246    out.push_str(HEADER);
1247    if uses_auth_user {
1248        out.push_str(AUTH_USER_IMPORT);
1249    }
1250
1251    // Recovered native enum types (Postgres `CREATE TYPE ... AS ENUM`) render as
1252    // `Choices` enums once, ahead of the structs that reference them. Columns
1253    // sharing one DB enum type collapse onto a single generated enum, keyed by
1254    // its Rust name; emitted in name order for a stable diff.
1255    let mut enums: std::collections::BTreeMap<String, Vec<String>> =
1256        std::collections::BTreeMap::new();
1257    for table in &schema.tables {
1258        for column in &table.columns {
1259            if let Some(enum_type) = &column.enum_type {
1260                if !column.choices.is_empty() {
1261                    enums
1262                        .entry(choices_enum_name(enum_type))
1263                        .or_insert_with(|| column.choices.clone());
1264                }
1265            }
1266        }
1267    }
1268    for (name, labels) in &enums {
1269        out.push('\n');
1270        out.push_str(&render_choices_enum(name, labels));
1271    }
1272
1273    let mut tables: Vec<&IntrospectedTable> = schema.tables.iter().collect();
1274    tables.sort_by(|a, b| struct_names[&a.table].cmp(&struct_names[&b.table]));
1275
1276    for table in tables {
1277        // Django's auth_user is provided by umbral-auth; don't re-declare it.
1278        if django && table.table == DJANGO_USER_TABLE {
1279            continue;
1280        }
1281        out.push('\n');
1282        out.push_str(&render_one_struct(table, &struct_names));
1283    }
1284    out
1285}
1286
1287/// The Rust type name for a recovered DB enum: PascalCase of the DB type name
1288/// (`payment_method` -> `PaymentMethod`; an already-PascalCase Prisma type name
1289/// like `PaymentMethod` passes through). Kept in one place so the enum
1290/// definition and every field that references it agree on the identifier.
1291fn choices_enum_name(enum_type: &str) -> String {
1292    umbral_casing::pascal_case_from_ident(enum_type)
1293}
1294
1295/// Render one `#[derive(Choices)]` enum from a recovered DB enum's labels.
1296///
1297/// Each variant identifier is the PascalCase of its DB label
1298/// (`PARTIALLY_PAID` -> `PartiallyPaid`). When every label is reproduced by the
1299/// `SCREAMING_SNAKE_CASE` rename rule — the Choices derive computes a variant's
1300/// DB value as `to_snake_case(variant).to_uppercase()` — a single enum-level
1301/// `rename_all` keeps the variants clean. When a label wouldn't round-trip that
1302/// way (a lowercase or mixed-case enum), each variant pins its exact DB string
1303/// with `#[choices(value = "...")]` (and the matching `#[serde(rename)]`) so the
1304/// generated column always round-trips, whatever the label casing.
1305fn render_choices_enum(rust_name: &str, labels: &[String]) -> String {
1306    let variants: Vec<String> = labels
1307        .iter()
1308        .enumerate()
1309        .map(|(i, l)| {
1310            let ident = pascal_case_from_table(l);
1311            if ident.is_empty() {
1312                format!("Variant{i}")
1313            } else {
1314                ident
1315            }
1316        })
1317        .collect();
1318    // The Choices derive's SCREAMING_SNAKE_CASE value == to_snake_case(variant)
1319    // uppercased; check every label is reproduced before trusting the tidy form.
1320    let screaming_round_trips = variants
1321        .iter()
1322        .zip(labels)
1323        .all(|(v, l)| to_snake_case(v).to_ascii_uppercase() == *l);
1324
1325    let mut out = String::new();
1326    out.push_str(
1327        "#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Choices)]\n",
1328    );
1329    if screaming_round_trips {
1330        out.push_str("#[choices(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
1331        out.push_str("#[serde(rename_all = \"SCREAMING_SNAKE_CASE\")]\n");
1332    }
1333    out.push_str(&format!("pub enum {rust_name} {{\n"));
1334    for (variant, label) in variants.iter().zip(labels) {
1335        if !screaming_round_trips {
1336            let escaped = label.replace('\\', "\\\\").replace('"', "\\\"");
1337            out.push_str(&format!("    #[choices(value = \"{escaped}\")]\n"));
1338            out.push_str(&format!("    #[serde(rename = \"{escaped}\")]\n"));
1339        }
1340        out.push_str(&format!("    {variant},\n"));
1341    }
1342    out.push_str("}\n");
1343    out
1344}
1345
1346/// The struct name for a table under Django: strip a leading `<app>_` app-label
1347/// prefix, then pascal-case (`communities_community` -> `Community`,
1348/// `communities_community_categories` -> `CommunityCategories`). A table whose
1349/// leading segment isn't a detected app label keeps its full name.
1350fn django_struct_name(table: &str, app_labels: &std::collections::HashSet<String>) -> String {
1351    let model_part = table
1352        .split_once('_')
1353        .filter(|(app, rest)| app_labels.contains(*app) && !rest.is_empty())
1354        .map(|(_, rest)| rest)
1355        .unwrap_or(table);
1356    pascal_case_from_table(model_part)
1357}
1358
1359/// The import block emitted at the top of a Django-imported file. Maps
1360/// `auth_user` onto umbral-auth's built-in user and tells the operator how to
1361/// swap in a custom one.
1362const AUTH_USER_IMPORT: &str = "\
1363// This schema references Django's `auth_user`, mapped to umbral-auth's built-in
1364// `AuthUser` (same `auth_user` table). If you use a CUSTOM user model, replace
1365// the line below with your own, e.g. `use crate::models::MyUser as AuthUser;`.
1366use umbral_auth::AuthUser;
1367";
1368
1369/// Two-line module doc plus the single facade import every generated
1370/// file needs. Kept as a constant so the empty-schema path emits
1371/// exactly the header and nothing else.
1372const HEADER: &str = "\
1373//! Generated by `umbral inspectdb`. Wire each struct into your App
1374//! builder with `.model::<StructName>()`. Re-run `inspectdb` to
1375//! regenerate; edits made by hand will be lost.
1376
1377use umbral::prelude::*;
1378";
1379
1380/// Render a single `#[derive(Model)]` struct for one introspected table.
1381/// The `#[umbral(table = "...")]` attribute is emitted only when the
1382/// derive's auto-derived table name (snake_case of the struct name)
1383/// doesn't equal the SQL table name. For the typical snake_case shape
1384/// The temporal SQL types whose current-timestamp default is an `auto_now_add`
1385/// and whose `created*`/`updated*` name (under Django) implies a timestamp.
1386fn is_temporal(ty: SqlType) -> bool {
1387    matches!(
1388        ty,
1389        SqlType::Timestamptz | SqlType::Timestamp | SqlType::Date | SqlType::Time
1390    )
1391}
1392
1393/// True when a raw DB default expresses "the current time" — SQLite's
1394/// `CURRENT_TIMESTAMP` and Postgres's `now()` / `CURRENT_TIMESTAMP` /
1395/// `LOCALTIMESTAMP`, tolerating a trailing `()` and a `::type` cast.
1396fn is_current_timestamp_default(raw: &str) -> bool {
1397    let s = raw.trim();
1398    let s = s.split("::").next().unwrap_or(s).trim();
1399    let s = s.trim_end_matches("()").trim();
1400    s.eq_ignore_ascii_case("CURRENT_TIMESTAMP")
1401        || s.eq_ignore_ascii_case("now")
1402        || s.eq_ignore_ascii_case("LOCALTIMESTAMP")
1403}
1404
1405/// Reduce a raw DB default to a constant umbral can re-emit as
1406/// `#[umbral(default = "...")]`, or `None` when it can't — a sequence
1407/// (`nextval(...)`), a function call (`gen_random_uuid()`), or NULL. Strips a
1408/// Postgres `::type` cast and unwraps a single-quoted string literal so
1409/// `'active'::character varying` -> `active` (umbral re-quotes on emit).
1410fn clean_constant_default(raw: &str) -> Option<String> {
1411    let s = raw.trim();
1412    if s.is_empty() || s.eq_ignore_ascii_case("null") {
1413        return None;
1414    }
1415    let s = s.split("::").next().unwrap_or(s).trim();
1416    if s.len() >= 2 && s.starts_with('\'') && s.ends_with('\'') {
1417        // A quoted string literal — accept its inner text, un-doubling the
1418        // SQL `''` escape. Can't be an expression, so it's always safe.
1419        return Some(s[1..s.len() - 1].replace("''", "'"));
1420    }
1421    // A bare token: a number (`0`, `-1`, `3.14`) or boolean (`true`/`false`).
1422    // Anything carrying `(` (a function / sequence) or other punctuation umbral
1423    // can't represent as a literal is dropped.
1424    if !s.is_empty()
1425        && s.chars()
1426            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
1427    {
1428        return Some(s.to_string());
1429    }
1430    None
1431}
1432
1433/// Turn raw recovered defaults + framework naming into umbral's semantic field
1434/// attributes (`auto_now_add` / `auto_now` / `default`). Run once over the
1435/// introspected schema so the model renderer and the initial migration agree.
1436///
1437/// - A `CURRENT_TIMESTAMP` / `now()` default on a temporal column becomes
1438///   `auto_now_add`: umbral emits the correct per-backend default itself
1439///   (`CURRENT_TIMESTAMP` on SQLite, `now()` on Postgres), so carrying the raw
1440///   expression as a `#[umbral(default)]` literal (which umbral would quote)
1441///   would be wrong.
1442/// - Under `--framework django`, a `created*` timestamp with no recoverable
1443///   default becomes `auto_now_add` and an `updated*` / `modified*` one becomes
1444///   `auto_now` — Django keeps these in Python, leaving no DB default behind.
1445/// - Every other default is reduced to a constant literal, or dropped when
1446///   umbral can't represent it (see [`clean_constant_default`]).
1447pub fn apply_recovered_conventions(schema: &mut IntrospectedSchema, framework: Option<Framework>) {
1448    // The `created*` / `updated*` timestamp name heuristic applies to any
1449    // framework (Rails/Laravel/Prisma also keep these in code, and the lowercase
1450    // check matches camelCase `createdAt` too).
1451    let has_framework = framework.is_some();
1452    for table in &mut schema.tables {
1453        for col in &mut table.columns {
1454            if let Some(raw) = col.default.take() {
1455                if is_temporal(col.ty) && is_current_timestamp_default(&raw) {
1456                    col.auto_now_add = true;
1457                } else {
1458                    col.default = clean_constant_default(&raw);
1459                }
1460            }
1461            // Code-managed timestamps leave no DB default; recover them by name,
1462            // but never override a default we actually found.
1463            if has_framework && is_temporal(col.ty) && col.default.is_none() && !col.auto_now_add {
1464                let lower = col.name.to_ascii_lowercase();
1465                if lower.starts_with("created")
1466                    || lower.starts_with("added")
1467                    || lower == "date_joined"
1468                {
1469                    col.auto_now_add = true;
1470                } else if !col.auto_now
1471                    && (lower.starts_with("updated")
1472                        || lower.starts_with("modified")
1473                        || lower.starts_with("changed"))
1474                {
1475                    col.auto_now = true;
1476                }
1477            }
1478        }
1479    }
1480}
1481
1482/// The umbral field name for a source column under a framework's convention, or
1483/// `None` when the column already matches umbral's shape (no rename). Because
1484/// inspectdb targets a *fresh* database, the new column simply IS the new name —
1485/// no `#[sqlx(rename)]` needed.
1486///
1487/// - Django / Rails / Laravel: strip a FK column's `_id` (`author_id` ->
1488///   `author`); leave non-FK columns (already snake_case) alone.
1489/// - Prisma: snake-case every column (`firstName` -> `first_name`), and a FK
1490///   additionally sheds a trailing `Id` (`authorId` -> `author`).
1491fn framework_field_name(col: &IntrospectedColumn, framework: Framework) -> Option<String> {
1492    let is_fk = col.fk_target.is_some();
1493    match framework {
1494        Framework::Django | Framework::Rails | Framework::Laravel => {
1495            if is_fk {
1496                col.name
1497                    .strip_suffix("_id")
1498                    .filter(|b| !b.is_empty())
1499                    .map(str::to_string)
1500            } else {
1501                None
1502            }
1503        }
1504        Framework::Prisma => {
1505            let base = if is_fk {
1506                col.name.strip_suffix("Id").unwrap_or(&col.name)
1507            } else {
1508                &col.name
1509            };
1510            let snake = to_snake_case(base);
1511            (snake != col.name && !snake.is_empty()).then_some(snake)
1512        }
1513    }
1514}
1515
1516/// Rename source columns to umbral field names per the framework's convention
1517/// (see [`framework_field_name`]), matching how umbral models are written
1518/// (`pub author: ForeignKey<Author>`, accessed `post.author`). Renames are
1519/// collision-guarded (a target name already owned by another column, or claimed
1520/// by two columns, is left as-is), and `unique_together` / index groups that
1521/// referenced the old name are rewritten in lockstep so a composite index never
1522/// names a column that no longer exists.
1523fn apply_framework_column_names(schema: &mut IntrospectedSchema, framework: Framework) {
1524    for table in &mut schema.tables {
1525        let existing: std::collections::HashSet<&str> =
1526            table.columns.iter().map(|c| c.name.as_str()).collect();
1527        let mut renames: std::collections::HashMap<String, String> =
1528            std::collections::HashMap::new();
1529        let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1530        for col in &table.columns {
1531            if let Some(new) = framework_field_name(col, framework) {
1532                if !existing.contains(new.as_str()) && claimed.insert(new.clone()) {
1533                    renames.insert(col.name.clone(), new);
1534                }
1535            }
1536        }
1537        if renames.is_empty() {
1538            continue;
1539        }
1540        for col in &mut table.columns {
1541            if let Some(new) = renames.get(&col.name) {
1542                col.name = new.clone();
1543            }
1544        }
1545        for group in table
1546            .unique_together
1547            .iter_mut()
1548            .chain(table.indexes.iter_mut())
1549        {
1550            for c in group.iter_mut() {
1551                if let Some(new) = renames.get(c) {
1552                    *c = new.clone();
1553                }
1554            }
1555        }
1556    }
1557}
1558
1559/// Pick the owner side of a Django M2M join table. Django names the table
1560/// `<owner_table>_<field>`, so the owner is the FK target that prefixes the
1561/// join-table name and the remainder is the field. Returns
1562/// `(owner_table, field_name, target_table)`, or `None` when neither target
1563/// prefixes the name (a non-standard through table we can't safely fold).
1564fn pick_m2m_owner(join_table: &str, ta: &str, tb: &str) -> Option<(String, String, String)> {
1565    let try_owner = |owner: &str, target: &str| -> Option<(String, String, String)> {
1566        join_table
1567            .strip_prefix(&format!("{owner}_"))
1568            .filter(|field| !field.is_empty())
1569            .map(|field| (owner.to_string(), field.to_string(), target.to_string()))
1570    };
1571    match (try_owner(ta, tb), try_owner(tb, ta)) {
1572        // Both targets prefix the name (e.g. one is a prefix of the other, or a
1573        // self-M2M) — prefer the longer, more specific owner table.
1574        (Some(ra), Some(rb)) => Some(if ta.len() >= tb.len() { ra } else { rb }),
1575        (Some(r), None) | (None, Some(r)) => Some(r),
1576        (None, None) => None,
1577    }
1578}
1579
1580/// Derive `(owner_table, field_name, target_table)` for a junction, per
1581/// framework. Django/Rails/Laravel name the table `<owner_table>_<field>`
1582/// (see [`pick_m2m_owner`]). Prisma's implicit M2M is `_<ModelA>To<ModelB>` with
1583/// FK columns `A` (-> alphabetically-first model) and `B` — symmetric, so `A`'s
1584/// target is taken as the owner and the field is named from the child table.
1585fn pick_m2m(
1586    framework: Framework,
1587    table: &str,
1588    fk_cols: &[&IntrospectedColumn],
1589) -> Option<(String, String, String)> {
1590    let ta = fk_cols[0].fk_target.as_deref().unwrap();
1591    let tb = fk_cols[1].fk_target.as_deref().unwrap();
1592    match framework {
1593        Framework::Prisma => {
1594            if !(table.starts_with('_') && table.contains("To")) {
1595                return None;
1596            }
1597            let by_name = |name: &str| {
1598                fk_cols
1599                    .iter()
1600                    .find(|c| c.name == name)
1601                    .and_then(|c| c.fk_target.as_deref())
1602            };
1603            let owner = by_name("A").unwrap_or(ta);
1604            let child = by_name("B").unwrap_or(tb);
1605            Some((owner.to_string(), to_snake_case(child), child.to_string()))
1606        }
1607        _ => pick_m2m_owner(table, ta, tb),
1608    }
1609}
1610
1611/// Fold M2M join tables into `M2M<T>` fields on their owner model. A pure
1612/// junction — exactly two FK columns, every other column just the surrogate PK
1613/// — named `<owner_table>_<field>` becomes `owner.field: M2M<Target>`, and the
1614/// join table is dropped (umbral auto-generates its own junction from the
1615/// field). Works for Django, Rails and Laravel, which all name their through /
1616/// join / pivot table `<owner_table>_<field>` — the owner is the FK target that
1617/// prefixes the name. Prisma's implicit M2M (`_ModelAToModelB` with `A`/`B`
1618/// columns) is a different shape and isn't folded. Skips a table whose owner is
1619/// `auth_user` (external) or whose field would collide with an existing column.
1620fn detect_m2m_relations(
1621    schema: &mut IntrospectedSchema,
1622    framework: Option<Framework>,
1623    with_table_names: bool,
1624) {
1625    // Django/Rails/Laravel share the `<owner_table>_<field>` join naming; Prisma
1626    // uses `_<A>To<B>` with `A`/`B` columns. Both are foldable (see `pick_m2m`).
1627    let Some(fw) = framework else {
1628        return;
1629    };
1630    let struct_names = resolve_struct_names(schema, framework, with_table_names);
1631    let owner_cols: std::collections::HashMap<String, std::collections::HashSet<String>> = schema
1632        .tables
1633        .iter()
1634        .map(|t| {
1635            (
1636                t.table.clone(),
1637                t.columns.iter().map(|c| c.name.clone()).collect(),
1638            )
1639        })
1640        .collect();
1641
1642    let mut to_remove: std::collections::HashSet<String> = std::collections::HashSet::new();
1643    let mut additions: Vec<(String, IntrospectedM2M)> = Vec::new();
1644    for t in &schema.tables {
1645        let fk_cols: Vec<&IntrospectedColumn> =
1646            t.columns.iter().filter(|c| c.fk_target.is_some()).collect();
1647        // A pure junction: exactly two FKs, everything else just the PK.
1648        let is_junction = fk_cols.len() == 2
1649            && t.columns
1650                .iter()
1651                .all(|c| c.fk_target.is_some() || c.primary_key);
1652        if !is_junction {
1653            continue;
1654        }
1655        let Some((owner_table, field, target_table)) = pick_m2m(fw, &t.table, &fk_cols) else {
1656            continue;
1657        };
1658        // Owner `auth_user` isn't re-declared, so we can't hang a field on it.
1659        if owner_table == DJANGO_USER_TABLE {
1660            continue;
1661        }
1662        // Don't shadow a real column on the owner.
1663        if owner_cols
1664            .get(&owner_table)
1665            .is_some_and(|cols| cols.contains(&field))
1666        {
1667            continue;
1668        }
1669        let target_name = struct_names
1670            .get(&target_table)
1671            .cloned()
1672            .unwrap_or_else(|| pascal_case_from_table(&target_table));
1673        additions.push((
1674            owner_table,
1675            IntrospectedM2M {
1676                field_name: field,
1677                target_table,
1678                target_name,
1679            },
1680        ));
1681        to_remove.insert(t.table.clone());
1682    }
1683
1684    for (owner_table, m2m) in additions {
1685        if let Some(owner) = schema.tables.iter_mut().find(|t| t.table == owner_table) {
1686            owner.m2m.push(m2m);
1687        }
1688    }
1689    schema.tables.retain(|t| !to_remove.contains(&t.table));
1690}
1691
1692/// (`blog_post` -> `BlogPost` -> derive computes `"blog_post"`), the
1693/// attribute is redundant and is left off. For unusual SQL casings
1694/// (`POSTS` -> `Posts` -> derive computes `"posts"` not `"POSTS"`),
1695/// the attribute is emitted and the M3.1 derive picks it up to
1696/// override the default. See `umbral-macros/src/lib.rs` for the
1697/// attribute parser.
1698fn render_one_struct(
1699    table: &IntrospectedTable,
1700    struct_names: &std::collections::HashMap<String, String>,
1701) -> String {
1702    // The resolved struct name for this table (app-prefix-stripped under Django,
1703    // full pascal otherwise), and the same resolution for FK targets.
1704    let this_struct = struct_names
1705        .get(&table.table)
1706        .cloned()
1707        .unwrap_or_else(|| table.name.clone());
1708    let resolve_target = |target: &str| -> String {
1709        struct_names
1710            .get(target)
1711            .cloned()
1712            .unwrap_or_else(|| pascal_case_from_table(target))
1713    };
1714
1715    let mut out = String::new();
1716    // `sqlx::FromRow` is required (the `Model` trait bounds it as a supertrait),
1717    // and `Model` also requires `serde::Serialize` + `DeserializeOwned` (a
1718    // `ForeignKey<T>` needs `T: DeserializeOwned`), so both serde derives are
1719    // mandatory for the generated file to compile.
1720    out.push_str(
1721        "#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]\n",
1722    );
1723    // Emit `#[umbral(table)]` whenever the struct name doesn't snake_case back
1724    // to the SQL table — always true once the app prefix is stripped.
1725    if to_snake_case(&this_struct) != table.table {
1726        out.push_str(&format!("#[umbral(table = \"{}\")]\n", table.table));
1727    }
1728    // Struct-level composite index attributes: multi-column UNIQUE constraints
1729    // and multi-column indexes recovered from the schema.
1730    if let Some(attr) = composite_groups_attr("unique_together", &table.unique_together) {
1731        out.push_str(&attr);
1732    }
1733    if let Some(attr) = composite_groups_attr("indexes", &table.indexes) {
1734        out.push_str(&attr);
1735    }
1736    out.push_str(&format!("pub struct {this_struct} {{\n"));
1737    for column in &table.columns {
1738        // Per-column attributes recovered from the schema: single-column
1739        // UNIQUE / index constraints become `#[umbral(unique)]` /
1740        // `#[umbral(index)]` so a re-migrate rebuilds them.
1741        if column.unique {
1742            out.push_str("    #[umbral(unique)]\n");
1743        }
1744        if column.index {
1745            out.push_str("    #[umbral(index)]\n");
1746        }
1747        // Recovered temporal semantics / constant default. `auto_now_add` and
1748        // `auto_now` are mutually exclusive with a literal default (a
1749        // current-timestamp default is lifted to `auto_now_add` upstream).
1750        if column.auto_now_add {
1751            out.push_str("    #[umbral(auto_now_add)]\n");
1752        } else if column.auto_now {
1753            out.push_str("    #[umbral(auto_now)]\n");
1754        } else if let Some(def) = &column.default {
1755            out.push_str(&format!(
1756                "    #[umbral(default = \"{}\")]\n",
1757                def.replace('\\', "\\\\").replace('"', "\\\"")
1758            ));
1759        }
1760        // A primary key not named `id` must be marked so the derive can find
1761        // it (Django's `authtoken_token.key`, `django_session.session_key`, …).
1762        if column.primary_key && column.name != "id" {
1763            out.push_str("    #[umbral(primary_key)]\n");
1764        }
1765        // PostGIS: emit the recovered subtype + SRID so the geometry column
1766        // round-trips as `geometry(Point, 4326)` rather than the unconstrained
1767        // base type.
1768        if let Some(attr) = geometry_attr(column.ty) {
1769            out.push_str(&format!("    {attr}\n"));
1770        }
1771        // A recovered native enum column renders as its generated `Choices`
1772        // enum type, marked `#[umbral(choices)]` so the derive treats it as a
1773        // closed set and the migration re-emits the CHECK. Emitted before the
1774        // field line, like the other per-column attributes.
1775        let is_enum_column = column.enum_type.is_some() && !column.choices.is_empty();
1776        if is_enum_column {
1777            out.push_str("    #[umbral(choices)]\n");
1778        }
1779        // A FK field keeps its REAL column name (`author_id`), not a prettified
1780        // `author`: umbral uses the field name as the column name, so this
1781        // avoids a `#[sqlx(rename)]` on every foreign key and keeps the index /
1782        // field reading clearly against the actual column. Only the target
1783        // STRUCT name is app-prefix-stripped (`ForeignKey<Author>`).
1784        let (desired, ty) = match &column.fk_target {
1785            Some(target) => {
1786                let target_struct = resolve_target(target);
1787                let ty = if column.nullable {
1788                    format!("Option<ForeignKey<{target_struct}>>")
1789                } else {
1790                    format!("ForeignKey<{target_struct}>")
1791                };
1792                (column.name.clone(), ty)
1793            }
1794            None => match &column.enum_type {
1795                // The enum column's type is the generated `Choices` enum.
1796                Some(enum_type) if !column.choices.is_empty() => {
1797                    let enum_name = choices_enum_name(enum_type);
1798                    let ty = if column.nullable {
1799                        format!("Option<{enum_name}>")
1800                    } else {
1801                        enum_name
1802                    };
1803                    (column.name.clone(), ty)
1804                }
1805                _ => (
1806                    column.name.clone(),
1807                    render_field_type(column.ty, column.nullable),
1808                ),
1809            },
1810        };
1811        // Escape a Rust keyword / otherwise-invalid identifier (a column named
1812        // `type`, `match`, …) by suffixing `_`. Whenever the Rust field name
1813        // ends up different from the DB column, bind them with `#[sqlx(rename)]`
1814        // so `FromRow` and umbral's column name both resolve to the real column.
1815        let field_name = safe_field_ident(&desired);
1816        if field_name != column.name {
1817            out.push_str(&format!("    #[sqlx(rename = \"{}\")]\n", column.name));
1818        }
1819        out.push_str(&format!("    pub {field_name}: {ty},\n"));
1820    }
1821    // Many-to-many fields recovered from Django join tables. `M2M<T>` has no
1822    // column on this table — umbral auto-generates the junction — so they're
1823    // emitted after the real columns. `M2M<T, P>`'s parent-PK generic `P`
1824    // defaults to `i64`; a non-i64 owner PK (Django's `i32` AutoField, a UUID /
1825    // slug PK) must spell it out or the derive's `set_parent_id(id: P)` won't
1826    // typecheck.
1827    let parent_pk_ty = table
1828        .columns
1829        .iter()
1830        .find(|c| c.primary_key)
1831        .map(|c| render_field_type(c.ty, false));
1832    for m2m in &table.m2m {
1833        let field = safe_field_ident(&m2m.field_name);
1834        let target = resolve_target(&m2m.target_table);
1835        let ty = match parent_pk_ty.as_deref() {
1836            Some(pk) if pk != "i64" => format!("M2M<{target}, {pk}>"),
1837            _ => format!("M2M<{target}>"),
1838        };
1839        out.push_str(&format!("    pub {field}: {ty},\n"));
1840    }
1841    out.push_str("}\n");
1842    out
1843}
1844
1845/// The Rust reserved words that can't be a bare field identifier. A column with
1846/// one of these names is suffixed with `_` (`type` -> `type_`) and bound to the
1847/// real column via `#[sqlx(rename)]`.
1848fn is_rust_keyword(s: &str) -> bool {
1849    matches!(
1850        s,
1851        "as" | "break"
1852            | "const"
1853            | "continue"
1854            | "crate"
1855            | "dyn"
1856            | "else"
1857            | "enum"
1858            | "extern"
1859            | "false"
1860            | "fn"
1861            | "for"
1862            | "if"
1863            | "impl"
1864            | "in"
1865            | "let"
1866            | "loop"
1867            | "match"
1868            | "mod"
1869            | "move"
1870            | "mut"
1871            | "pub"
1872            | "ref"
1873            | "return"
1874            | "self"
1875            | "Self"
1876            | "static"
1877            | "struct"
1878            | "super"
1879            | "trait"
1880            | "true"
1881            | "type"
1882            | "unsafe"
1883            | "use"
1884            | "where"
1885            | "while"
1886            | "async"
1887            | "await"
1888            | "box"
1889            | "final"
1890            | "macro"
1891            | "override"
1892            | "priv"
1893            | "typeof"
1894            | "unsized"
1895            | "virtual"
1896            | "yield"
1897    )
1898}
1899
1900/// Render a `#[umbral(<name> = [["a","b"], ["c"]])]` struct-level attribute from
1901/// a list of column-name groups, or `None` when there are no groups. Used for
1902/// `unique_together` and `indexes`.
1903fn composite_groups_attr(name: &str, groups: &[Vec<String>]) -> Option<String> {
1904    if groups.is_empty() {
1905        return None;
1906    }
1907    let rendered = groups
1908        .iter()
1909        .map(|g| {
1910            let cols = g
1911                .iter()
1912                .map(|c| format!("\"{c}\""))
1913                .collect::<Vec<_>>()
1914                .join(", ");
1915            format!("[{cols}]")
1916        })
1917        .collect::<Vec<_>>()
1918        .join(", ");
1919    Some(format!("#[umbral({name} = [{rendered}])]\n"))
1920}
1921
1922/// The `#[umbral(geometry|geography = "<kind>", srid = N)]` attribute for a
1923/// PostGIS column, or `None` for a non-spatial column. Renders the subtype
1924/// recovered from the catalog so the column round-trips with its real shape.
1925fn geometry_attr(ty: SqlType) -> Option<String> {
1926    use crate::orm::GeometryKind;
1927    let (base, spec) = match ty {
1928        SqlType::Geometry(s) => ("geometry", s),
1929        SqlType::Geography(s) => ("geography", s),
1930        _ => return None,
1931    };
1932    let kind = match spec.kind {
1933        GeometryKind::Geometry => "geometry",
1934        GeometryKind::Point => "point",
1935        GeometryKind::LineString => "linestring",
1936        GeometryKind::Polygon => "polygon",
1937        GeometryKind::MultiPoint => "multipoint",
1938        GeometryKind::MultiLineString => "multilinestring",
1939        GeometryKind::MultiPolygon => "multipolygon",
1940        GeometryKind::GeometryCollection => "geometrycollection",
1941    };
1942    Some(format!(
1943        "#[umbral({base} = \"{kind}\", srid = {})]",
1944        spec.srid
1945    ))
1946}
1947
1948/// Turn a column name into a valid, non-keyword Rust field identifier.
1949fn safe_field_ident(name: &str) -> String {
1950    if is_rust_keyword(name) {
1951        format!("{name}_")
1952    } else {
1953        name.to_string()
1954    }
1955}
1956
1957/// Map `(SqlType, nullable)` to the Rust type string the derive macro's
1958/// `classify_field_type` accepts. Mirrors the table in
1959/// `umbral-macros/src/lib.rs` (see `FieldKind` for the full catalogue).
1960fn render_field_type(ty: SqlType, nullable: bool) -> String {
1961    let base = match ty {
1962        SqlType::SmallInt => "i16".to_string(),
1963        SqlType::Integer => "i32".to_string(),
1964        SqlType::BigInt => "i64".to_string(),
1965        SqlType::Real => "f32".to_string(),
1966        SqlType::Double => "f64".to_string(),
1967        SqlType::Boolean => "bool".to_string(),
1968        SqlType::Text => "String".to_string(),
1969        SqlType::Date => "chrono::NaiveDate".to_string(),
1970        SqlType::Time => "chrono::NaiveTime".to_string(),
1971        SqlType::Timestamptz => "chrono::DateTime<chrono::Utc>".to_string(),
1972        SqlType::Timestamp => "chrono::NaiveDateTime".to_string(),
1973        SqlType::Uuid => "uuid::Uuid".to_string(),
1974        SqlType::Json => "serde_json::Value".to_string(),
1975        // Recurse through the element's SqlType. Wrapping in `Vec<...>`
1976        // matches the derive's catalogue: a `Vec<i64>` declares an
1977        // `Array(ArrayElement::BigInt)` field.
1978        SqlType::Array(elem) => format!("Vec<{}>", render_field_type(elem.to_sql_type(), false)),
1979        // Phase 4.4: Postgres network address types. Both `Inet` and
1980        // `Cidr` round-trip through `ipnetwork::IpNetwork`; `MacAddr`
1981        // uses the `mac_address` crate.
1982        SqlType::Inet => "ipnetwork::IpNetwork".to_string(),
1983        SqlType::Cidr => "ipnetwork::IpNetwork".to_string(),
1984        SqlType::MacAddr => "mac_address::MacAddress".to_string(),
1985        // gaps2 #70: text-backed Postgres types surface as `String`.
1986        // inspectdb can't recover which `#[umbral(...)]` attr produced
1987        // the column (the attr lives only in the source model, not the
1988        // DB), so the generated model is a plain `String`; the user
1989        // re-adds `#[umbral(xml)]` / `#[umbral(ltree)]` / `#[umbral(bit)]`
1990        // if they want the native type back on a re-migrate.
1991        SqlType::Xml => "String".to_string(),
1992        SqlType::Ltree => "String".to_string(),
1993        SqlType::Bit => "String".to_string(),
1994        SqlType::FullText => "umbral::orm::TsVector".to_string(),
1995        // ForeignKey inspectdb renders as i64 for now; the FK relationship
1996        // introspection that would emit ForeignKey<T> is deferred.
1997        SqlType::ForeignKey => "i64".to_string(),
1998        // BLOB / BYTEA columns surface as Vec<u8> in user code.
1999        SqlType::Bytes => "Vec<u8>".to_string(),
2000        // BUG-10: NUMERIC introspection renders as
2001        // `rust_decimal::Decimal`. inspectdb reads the column type
2002        // from Postgres' `information_schema`; the resulting
2003        // model imports use this exact path.
2004        SqlType::Decimal => "rust_decimal::Decimal".to_string(),
2005        SqlType::DecimalN(_) => "rust_decimal::Decimal".to_string(),
2006        // Arbitrary-precision decimal renders as `bigdecimal::BigDecimal`.
2007        // inspectdb never *emits* BigDecimal on its own — it maps every DB
2008        // `numeric`/`decimal` column to the friendlier `rust_decimal::Decimal`
2009        // (see the type classifier) — so this arm only fires if a snapshot
2010        // already carries BigDecimal from a hand-written model. Kept here so
2011        // the render stays total over `SqlType`.
2012        SqlType::BigDecimal => "bigdecimal::BigDecimal".to_string(),
2013        // PostGIS geometry/geography both surface as the `postgis`-feature
2014        // `Geometry` newtype; the subtype + SRID ride the `#[umbral(...)]`
2015        // attribute the model renderer emits alongside this type.
2016        SqlType::Geometry(_) | SqlType::Geography(_) => "umbral::orm::gis::Geometry".to_string(),
2017    };
2018    let base = base.as_str();
2019    if nullable {
2020        format!("Option<{base}>")
2021    } else {
2022        base.to_string()
2023    }
2024}
2025
2026/// Order the introspected tables so a table referenced by a foreign key is
2027/// created before the table that references it. Kahn's algorithm over the
2028/// FK-target graph restricted to this schema's tables; self-references and FK
2029/// targets outside the schema are ignored. A cycle (rare — mutual FKs) can't be
2030/// created with plain inline `REFERENCES`, so the leftover cyclic tables are
2031/// appended in their original order and the failure surfaces at apply time,
2032/// matching the autodetector's Pass-2 behaviour in `migrate.rs`.
2033fn fk_topo_order_tables(tables: &[IntrospectedTable]) -> Vec<&IntrospectedTable> {
2034    use std::collections::{BTreeMap, HashSet};
2035    let in_schema: HashSet<&str> = tables.iter().map(|t| t.table.as_str()).collect();
2036    let mut deps: BTreeMap<&str, HashSet<&str>> = BTreeMap::new();
2037    for t in tables {
2038        let mut targets = HashSet::new();
2039        for col in &t.columns {
2040            if let Some(target) = col.fk_target.as_deref() {
2041                if target != t.table.as_str() && in_schema.contains(target) {
2042                    targets.insert(target);
2043                }
2044            }
2045        }
2046        deps.insert(t.table.as_str(), targets);
2047    }
2048    let by_name = |name: &str| tables.iter().find(|t| t.table.as_str() == name);
2049    let mut ordered: Vec<&IntrospectedTable> = Vec::with_capacity(tables.len());
2050    while !deps.is_empty() {
2051        let ready: Vec<&str> = deps
2052            .iter()
2053            .filter(|(_, d)| d.is_empty())
2054            .map(|(t, _)| *t)
2055            .collect();
2056        if ready.is_empty() {
2057            // Cyclic FK: append the leftover tables in original order. The
2058            // apply-time error is clearer than silently looping here.
2059            for t in tables {
2060                if deps.contains_key(t.table.as_str()) {
2061                    ordered.push(t);
2062                }
2063            }
2064            break;
2065        }
2066        for t in &ready {
2067            if let Some(table) = by_name(t) {
2068                ordered.push(table);
2069            }
2070            deps.remove(t);
2071        }
2072        for set in deps.values_mut() {
2073            for t in &ready {
2074                set.remove(t);
2075            }
2076        }
2077    }
2078    ordered
2079}
2080
2081/// Render the introspected schema as a [`MigrationFile`] suitable for
2082/// writing to `migrations/<INSPECTED_PLUGIN_NAME>/0001_initial.json`.
2083/// One `CreateTable` per introspected table; `snapshot_after` captures
2084/// the imported state so subsequent `make_in` runs diff against it.
2085///
2086/// Filled in by subagent B.
2087pub fn render_initial_migration(schema: &IntrospectedSchema) -> MigrationFile {
2088    let mut models: Vec<ModelMeta> = schema
2089        .tables
2090        .iter()
2091        .map(|t| ModelMeta {
2092            name: t.name.clone(),
2093            table: t.table.clone(),
2094            fields: t.columns.iter().map(Column::from).collect(),
2095            display: t.name.clone(),
2096            icon: "database".to_string(),
2097            database: None,
2098            singleton: false,
2099            unique_together: Vec::new(),
2100            indexes: Vec::new(),
2101            ordering: Vec::new(),
2102            // Recovered many-to-many relations; drives the junction snapshot.
2103            m2m_relations: t
2104                .m2m
2105                .iter()
2106                .map(|r| crate::migrate::M2MRelation {
2107                    field_name: r.field_name.clone(),
2108                    target_table: r.target_table.clone(),
2109                    target_name: r.target_name.clone(),
2110                })
2111                .collect(),
2112            soft_delete: false,
2113            audited: false,
2114            // inspectdb introspects TABLES; a view it finds becomes a plain model
2115            // with `view: None`, i.e. the framework will not try to manage it.
2116            view: None,
2117            materialized: false,
2118            // inspectdb has no plugin attribute to read; default to "app".
2119            app_label: "app".to_string(),
2120        })
2121        .collect();
2122    models.sort_by(|a, b| a.name.cmp(&b.name));
2123
2124    // Emit CreateTable in FK-dependency order: a table referenced by another
2125    // must be created first, or Postgres rejects the inline `REFERENCES` with
2126    // `relation "<target>" does not exist` (SQLite tolerates the wrong order,
2127    // which is why the SQLite-only tests never caught it). Mirrors the
2128    // autodetector's Pass-2 Kahn sort in `migrate.rs`.
2129    let mut operations: Vec<Operation> = fk_topo_order_tables(&schema.tables)
2130        .into_iter()
2131        .map(|t| Operation::CreateTable {
2132            table: t.table.clone(),
2133            columns: t.columns.iter().map(Column::from).collect(),
2134            unique_together: t.unique_together.clone(),
2135            indexes: t.indexes.clone(),
2136        })
2137        .collect();
2138
2139    // Emit a junction table per recovered M2M relation (the join table it came
2140    // from was dropped from the schema). `<parent_table>_<field>` is umbral's
2141    // junction naming, matching what `M2M<T>` autogenerates on a re-migrate.
2142    let pk_of = |table_name: &str| -> (String, SqlType) {
2143        schema
2144            .tables
2145            .iter()
2146            .find(|t| t.table == table_name)
2147            .and_then(|t| t.columns.iter().find(|c| c.primary_key))
2148            .map(|c| (c.name.clone(), c.ty))
2149            .unwrap_or_else(|| ("id".to_string(), SqlType::BigInt))
2150    };
2151    for t in &schema.tables {
2152        for m2m in &t.m2m {
2153            let (parent_col, parent_ty) = pk_of(&t.table);
2154            let (child_col, child_ty) = pk_of(&m2m.target_table);
2155            operations.push(Operation::CreateM2MTable {
2156                junction_table: format!("{}_{}", t.table, m2m.field_name),
2157                parent_table: t.table.clone(),
2158                parent_col,
2159                child_table: m2m.target_table.clone(),
2160                child_col,
2161                parent_ty,
2162                child_ty,
2163            });
2164        }
2165    }
2166
2167    MigrationFile {
2168        id: INITIAL_MIGRATION_ID.to_string(),
2169        plugin: INSPECTED_PLUGIN_NAME.to_string(),
2170        depends_on: Vec::new(),
2171        operations,
2172        snapshot_after: Snapshot { models },
2173        replaces: Vec::new(),
2174    }
2175}
2176
2177/// Write `models.rs` and the initial migration to `output`. Creates
2178/// `output/` and `output/migrations/<INSPECTED_PLUGIN_NAME>/` as
2179/// needed. Returns the report carrying the table / column counts and
2180/// the paths.
2181///
2182/// The migration is pretty-printed so the file diffs cleanly when a
2183/// later `makemigrations` writes the next migration alongside.
2184pub async fn write_outputs(
2185    output: &Path,
2186    models_src: &str,
2187    migration: &MigrationFile,
2188) -> Result<InspectReport, InspectError> {
2189    std::fs::create_dir_all(output)?;
2190
2191    let models_path = output.join("models.rs");
2192    std::fs::write(&models_path, models_src)?;
2193
2194    let plugin_dir = output.join("migrations").join(INSPECTED_PLUGIN_NAME);
2195    std::fs::create_dir_all(&plugin_dir)?;
2196
2197    let migration_path = plugin_dir.join(format!("{}.json", migration.id));
2198    let json = serde_json::to_string_pretty(migration)?;
2199    std::fs::write(&migration_path, json)?;
2200
2201    let (tables, columns) =
2202        migration
2203            .operations
2204            .iter()
2205            .fold((0usize, 0usize), |(t, c), op| match op {
2206                Operation::CreateTable { columns, .. } => (t + 1, c + columns.len()),
2207                Operation::CreateM2MTable { .. } => (t + 1, c + 2),
2208                Operation::CreateView { .. }
2209                | Operation::DropView { .. }
2210                | Operation::DropTable { .. }
2211                | Operation::DropM2MTable { .. }
2212                | Operation::AddColumn { .. }
2213                | Operation::DropColumn { .. }
2214                | Operation::AlterColumn { .. }
2215                | Operation::RenameTable { .. }
2216                | Operation::RenameColumn { .. }
2217                | Operation::SetColumnComment { .. }
2218                | Operation::AddIndex { .. }
2219                | Operation::DropIndex { .. }
2220                | Operation::RunSql { .. } => (t, c),
2221            });
2222
2223    Ok(InspectReport {
2224        tables,
2225        columns,
2226        models_path,
2227        migration_path,
2228    })
2229}
2230
2231// =========================================================================
2232// Internal helpers.
2233// =========================================================================
2234
2235impl From<&IntrospectedColumn> for Column {
2236    fn from(c: &IntrospectedColumn) -> Self {
2237        Self {
2238            name: c.name.clone(),
2239            ty: c.ty,
2240            primary_key: c.primary_key,
2241            nullable: c.nullable,
2242            // Recovered foreign key: the referenced table, so the migration
2243            // re-emits `REFERENCES "<target>"("id")`.
2244            fk_target: c.fk_target.clone(),
2245            noform: false,
2246            privileged: false,
2247            private: false,
2248            secret: false,
2249            db_constraint: true,
2250            noedit: false,
2251            is_string_repr: false,
2252            max_length: 0,
2253            // Recovered native-enum labels: the closed set drives the migration's
2254            // `CHECK (col IN (...))`. Labels double as their own human labels —
2255            // inspectdb has no display-name source beyond the DB value.
2256            choices: c.choices.clone(),
2257            choice_labels: c.choices.clone(),
2258            // Recovered constant default (`''` when none / unrepresentable), so
2259            // the initial migration re-emits the DDL `DEFAULT` clause.
2260            default: c.default.clone().unwrap_or_default(),
2261            is_multichoice: false,
2262            // Recovered single-column UNIQUE / index constraints.
2263            unique: c.unique,
2264            on_delete: crate::orm::FkAction::NoAction,
2265            on_update: crate::orm::FkAction::NoAction,
2266            index: c.index,
2267            // Recovered temporal semantics — a re-migrate rebuilds the correct
2268            // per-backend default (CURRENT_TIMESTAMP / now()).
2269            auto_now_add: c.auto_now_add,
2270            auto_uuid: false,
2271            auto_now: c.auto_now,
2272            auto_user_add: false,
2273            auto_user: false,
2274            trim: false,
2275            lowercase: false,
2276            case_insensitive: false,
2277            help: String::new(),
2278            example: String::new(),
2279            widget: None,
2280            supported_backends: Vec::new(),
2281            min: None,
2282            max: None,
2283            text_format: ::core::option::Option::None,
2284            slug_from: ::core::option::Option::None,
2285        }
2286    }
2287}
2288
2289#[cfg(test)]
2290mod tests {
2291    use super::*;
2292
2293    fn col(name: &str, ty: SqlType, primary_key: bool, nullable: bool) -> IntrospectedColumn {
2294        IntrospectedColumn {
2295            name: name.to_string(),
2296            ty,
2297            primary_key,
2298            nullable,
2299            fk_target: None,
2300            unique: false,
2301            index: false,
2302            default: None,
2303            auto_now_add: false,
2304            auto_now: false,
2305            choices: Vec::new(),
2306            enum_type: None,
2307        }
2308    }
2309
2310    #[test]
2311    fn empty_schema_renders_header_only() {
2312        let out = render_models(&IntrospectedSchema { tables: Vec::new() });
2313        assert_eq!(out, HEADER);
2314    }
2315
2316    #[test]
2317    fn snake_case_table_skips_attribute_when_derive_round_trips() {
2318        let schema = IntrospectedSchema {
2319            tables: vec![IntrospectedTable {
2320                table: "blog_post".to_string(),
2321                name: "BlogPost".to_string(),
2322                columns: vec![
2323                    col("id", SqlType::BigInt, true, false),
2324                    col("title", SqlType::Text, false, false),
2325                ],
2326
2327                unique_together: Vec::new(),
2328                indexes: Vec::new(),
2329                m2m: Vec::new(),
2330            }],
2331        };
2332        let out = render_models(&schema);
2333        // `BlogPost` snake_cases to `blog_post` via the derive, so the
2334        // attribute is redundant and is left off. This keeps the
2335        // generated file compatible with the M3 derive, which doesn't
2336        // yet recognise `#[umbral(...)]` attributes.
2337        assert!(!out.contains("#[umbral(table"));
2338        assert!(out.contains("pub struct BlogPost {"));
2339        assert!(out.contains("pub id: i64,"));
2340        assert!(out.contains("pub title: String,"));
2341    }
2342
2343    #[test]
2344    fn lowercase_single_word_table_skips_attribute() {
2345        // `post` -> `Post` -> derive snake_cases to `"post"`, matches
2346        // the source table verbatim, so the attribute is left off.
2347        let schema = IntrospectedSchema {
2348            tables: vec![IntrospectedTable {
2349                table: "post".to_string(),
2350                name: "Post".to_string(),
2351                columns: vec![col("id", SqlType::BigInt, true, false)],
2352                unique_together: Vec::new(),
2353                indexes: Vec::new(),
2354                m2m: Vec::new(),
2355            }],
2356        };
2357        let out = render_models(&schema);
2358        assert!(!out.contains("#[umbral(table"));
2359        assert!(out.contains("pub struct Post {"));
2360    }
2361
2362    #[test]
2363    fn non_round_tripping_table_name_keeps_attribute() {
2364        // SQL tables with names the derive's snake_case won't reach
2365        // (e.g. uppercase, runs of capitals, leading digits) need the
2366        // explicit attribute. This case is rare in real ports but
2367        // the renderer should still cover it for the derive's eventual
2368        // attribute-support landing.
2369        let schema = IntrospectedSchema {
2370            tables: vec![IntrospectedTable {
2371                table: "POSTS".to_string(),
2372                name: "Posts".to_string(),
2373                columns: vec![col("id", SqlType::BigInt, true, false)],
2374                unique_together: Vec::new(),
2375                indexes: Vec::new(),
2376                m2m: Vec::new(),
2377            }],
2378        };
2379        let out = render_models(&schema);
2380        assert!(out.contains("#[umbral(table = \"POSTS\")]"));
2381    }
2382
2383    #[test]
2384    fn nullable_column_wraps_in_option() {
2385        let schema = IntrospectedSchema {
2386            tables: vec![IntrospectedTable {
2387                table: "post".to_string(),
2388                name: "Post".to_string(),
2389                columns: vec![
2390                    col("id", SqlType::BigInt, true, false),
2391                    col("published_at", SqlType::Timestamptz, false, true),
2392                ],
2393
2394                unique_together: Vec::new(),
2395                indexes: Vec::new(),
2396                m2m: Vec::new(),
2397            }],
2398        };
2399        let out = render_models(&schema);
2400        assert!(out.contains("pub published_at: Option<chrono::DateTime<chrono::Utc>>,"));
2401    }
2402
2403    #[test]
2404    fn type_catalogue_renders_each_sql_type() {
2405        let schema = IntrospectedSchema {
2406            tables: vec![IntrospectedTable {
2407                table: "kitchen_sink".to_string(),
2408                name: "KitchenSink".to_string(),
2409                columns: vec![
2410                    col("id", SqlType::BigInt, true, false),
2411                    col("small", SqlType::SmallInt, false, false),
2412                    col("medium", SqlType::Integer, false, false),
2413                    col("real_v", SqlType::Real, false, false),
2414                    col("double_v", SqlType::Double, false, false),
2415                    col("flag", SqlType::Boolean, false, false),
2416                    col("note", SqlType::Text, false, false),
2417                    col("day", SqlType::Date, false, false),
2418                    col("clock", SqlType::Time, false, false),
2419                    col("at", SqlType::Timestamptz, false, false),
2420                    col("uid", SqlType::Uuid, false, false),
2421                ],
2422
2423                unique_together: Vec::new(),
2424                indexes: Vec::new(),
2425                m2m: Vec::new(),
2426            }],
2427        };
2428        let out = render_models(&schema);
2429        for expected in [
2430            "pub id: i64,",
2431            "pub small: i16,",
2432            "pub medium: i32,",
2433            "pub real_v: f32,",
2434            "pub double_v: f64,",
2435            "pub flag: bool,",
2436            "pub note: String,",
2437            "pub day: chrono::NaiveDate,",
2438            "pub clock: chrono::NaiveTime,",
2439            "pub at: chrono::DateTime<chrono::Utc>,",
2440            "pub uid: uuid::Uuid,",
2441        ] {
2442            assert!(out.contains(expected), "missing field render: {expected}");
2443        }
2444    }
2445
2446    #[test]
2447    fn structs_are_sorted_by_name() {
2448        let schema = IntrospectedSchema {
2449            tables: vec![
2450                IntrospectedTable {
2451                    table: "zebra".to_string(),
2452                    name: "Zebra".to_string(),
2453                    columns: vec![col("id", SqlType::BigInt, true, false)],
2454                    unique_together: Vec::new(),
2455                    indexes: Vec::new(),
2456                    m2m: Vec::new(),
2457                },
2458                IntrospectedTable {
2459                    table: "antelope".to_string(),
2460                    name: "Antelope".to_string(),
2461                    columns: vec![col("id", SqlType::BigInt, true, false)],
2462                    unique_together: Vec::new(),
2463                    indexes: Vec::new(),
2464                    m2m: Vec::new(),
2465                },
2466            ],
2467        };
2468        let out = render_models(&schema);
2469        let antelope_at = out.find("struct Antelope").expect("Antelope rendered");
2470        let zebra_at = out.find("struct Zebra").expect("Zebra rendered");
2471        assert!(antelope_at < zebra_at);
2472    }
2473
2474    #[test]
2475    fn header_carries_the_regen_warning_and_facade_import() {
2476        let out = render_models(&IntrospectedSchema { tables: Vec::new() });
2477        assert!(out.contains("Generated by `umbral inspectdb`"));
2478        assert!(out.contains("edits made by hand will be lost"));
2479        assert!(out.contains("use umbral::prelude::*;"));
2480    }
2481
2482    // --------------------------------------------------------------- //
2483    // SQLite type-mapping coverage.                                    //
2484    // --------------------------------------------------------------- //
2485
2486    /// Django's `PositiveIntegerField` family declares its SQLite columns
2487    /// with an `unsigned` qualifier (`smallint unsigned`, `integer
2488    /// unsigned`, `bigint unsigned`). SQLite ignores the qualifier for
2489    /// affinity and Django range-caps the value to the signed max, so the
2490    /// mapper strips the qualifier and routes to the base signed type
2491    /// instead of raising `UnsupportedColumnType`. Regression test for a
2492    /// port from a Django-managed schema failing on the first such column.
2493    #[test]
2494    fn map_sqlite_type_strips_signedness_qualifier() {
2495        assert_eq!(
2496            map_sqlite_type("smallint unsigned"),
2497            Some(SqlType::SmallInt)
2498        );
2499        assert_eq!(map_sqlite_type("integer unsigned"), Some(SqlType::Integer));
2500        assert_eq!(map_sqlite_type("bigint unsigned"), Some(SqlType::BigInt));
2501        // Case-insensitive and MySQL-style `signed` qualifier too.
2502        assert_eq!(map_sqlite_type("INTEGER UNSIGNED"), Some(SqlType::Integer));
2503        assert_eq!(map_sqlite_type("int signed"), Some(SqlType::Integer));
2504        // Plain types are unaffected.
2505        assert_eq!(map_sqlite_type("integer"), Some(SqlType::Integer));
2506    }
2507
2508    /// Django's DecimalField declares SQLite columns as `decimal`;
2509    /// `numeric` is the standard spelling. Both map to `SqlType::Decimal`
2510    /// (Postgres-only at v1, but inspectdb emits the faithful type rather
2511    /// than a lossy f64). Width parameters are stripped like any other.
2512    #[test]
2513    fn map_sqlite_type_maps_decimal_and_numeric() {
2514        assert_eq!(map_sqlite_type("decimal"), Some(SqlType::Decimal));
2515        assert_eq!(map_sqlite_type("numeric"), Some(SqlType::Decimal));
2516        assert_eq!(map_sqlite_type("DECIMAL(9,6)"), Some(SqlType::Decimal));
2517        assert_eq!(map_sqlite_type("numeric(10, 2)"), Some(SqlType::Decimal));
2518    }
2519
2520    // --------------------------------------------------------------- //
2521    // Postgres type-mapping coverage (Phase 3).                        //
2522    // --------------------------------------------------------------- //
2523
2524    /// Every variant of the M5 SqlType catalogue has a mapping from
2525    /// the canonical Postgres `information_schema.columns.data_type`
2526    /// value back to the variant. Lockstep with
2527    /// `crate::backend::PostgresBackend::map_type` — if a SqlType
2528    /// variant lands, both `map_type` (outbound) and `map_postgres_type`
2529    /// (inbound) need an arm.
2530    #[test]
2531    fn map_postgres_type_covers_the_full_catalogue() {
2532        assert_eq!(map_postgres_type("smallint"), Some(SqlType::SmallInt));
2533        assert_eq!(map_postgres_type("integer"), Some(SqlType::Integer));
2534        assert_eq!(map_postgres_type("bigint"), Some(SqlType::BigInt));
2535        assert_eq!(map_postgres_type("real"), Some(SqlType::Real));
2536        assert_eq!(map_postgres_type("double precision"), Some(SqlType::Double));
2537        assert_eq!(map_postgres_type("boolean"), Some(SqlType::Boolean));
2538        assert_eq!(map_postgres_type("text"), Some(SqlType::Text));
2539        assert_eq!(
2540            map_postgres_type("character varying"),
2541            Some(SqlType::Text),
2542            "VARCHAR maps to Text",
2543        );
2544        assert_eq!(
2545            map_postgres_type("character"),
2546            Some(SqlType::Text),
2547            "CHAR maps to Text",
2548        );
2549        assert_eq!(map_postgres_type("date"), Some(SqlType::Date));
2550        assert_eq!(
2551            map_postgres_type("time without time zone"),
2552            Some(SqlType::Time),
2553        );
2554        assert_eq!(
2555            map_postgres_type("time with time zone"),
2556            Some(SqlType::Time)
2557        );
2558        // A naive timestamp recovers as the tz-less `Timestamp` (NaiveDateTime);
2559        // a tz-aware one stays `Timestamptz` (DateTime<Utc>).
2560        assert_eq!(
2561            map_postgres_type("timestamp without time zone"),
2562            Some(SqlType::Timestamp),
2563        );
2564        assert_eq!(
2565            map_postgres_type("timestamp with time zone"),
2566            Some(SqlType::Timestamptz),
2567        );
2568        assert_eq!(map_postgres_type("uuid"), Some(SqlType::Uuid));
2569        // Phase 4: both `json` and `jsonb` round-trip to the portable
2570        // `SqlType::Json` (DDL renders as `jsonb` on Postgres, TEXT on
2571        // SQLite).
2572        assert_eq!(map_postgres_type("json"), Some(SqlType::Json));
2573        assert_eq!(map_postgres_type("jsonb"), Some(SqlType::Json));
2574        // Phase 4.4: Postgres network address types.
2575        assert_eq!(map_postgres_type("inet"), Some(SqlType::Inet));
2576        assert_eq!(map_postgres_type("cidr"), Some(SqlType::Cidr));
2577        assert_eq!(map_postgres_type("macaddr"), Some(SqlType::MacAddr));
2578        // BLOB / BYTEA — Vec<u8> in Rust.
2579        assert_eq!(map_postgres_type("bytea"), Some(SqlType::Bytes));
2580        // NUMERIC / DECIMAL — information_schema reports both as `numeric`.
2581        assert_eq!(map_postgres_type("numeric"), Some(SqlType::Decimal));
2582        assert_eq!(map_postgres_type("decimal"), Some(SqlType::Decimal));
2583    }
2584
2585    /// Postgres-specific types umbral doesn't model yet surface as
2586    /// `None` so the caller produces `UnsupportedColumnType` with the
2587    /// raw type string preserved. The lookup most likely to bite a port
2588    /// now is `ARRAY`; the user fixes by hand or waits for the catalogue
2589    /// to grow.
2590    ///
2591    /// Note `json`/`jsonb` are NOT on this list — Phase 4's `Json`
2592    /// SqlType variant maps both back to `SqlType::Json`. Likewise
2593    /// `inet`/`cidr`/`macaddr` left this list when Phase 4.4 added
2594    /// the matching SqlType variants, and `numeric`/`bytea` left once
2595    /// `SqlType::Decimal` / `SqlType::Bytes` shipped. The companion arms
2596    /// in `map_postgres_type` are covered by
2597    /// `map_postgres_type_covers_the_full_catalogue` above.
2598    #[test]
2599    fn map_postgres_type_returns_none_for_postgres_only_types() {
2600        // `numeric` and `bytea` USED to be off-catalogue and returned
2601        // None; once SqlType::Decimal / SqlType::Bytes shipped they
2602        // started routing to those variants. Asserted in the positive
2603        // `map_postgres_type_covers_the_full_catalogue` test instead.
2604        assert_eq!(map_postgres_type("ARRAY"), None);
2605    }
2606
2607    /// The mapping is case-insensitive on the input but matches against
2608    /// the canonical lowercase form information_schema reports. Whether
2609    /// the operator's DB returns `INTEGER` (uppercase, from a quoted
2610    /// type) or `integer` shouldn't matter.
2611    #[test]
2612    fn map_postgres_type_is_case_insensitive_on_input() {
2613        assert_eq!(map_postgres_type("INTEGER"), Some(SqlType::Integer));
2614        assert_eq!(map_postgres_type("Bigint"), Some(SqlType::BigInt));
2615        assert_eq!(map_postgres_type("UUID"), Some(SqlType::Uuid));
2616    }
2617
2618    /// Surrounding whitespace doesn't break the lookup. Trimming
2619    /// matches `map_sqlite_type`'s `trim()`; both functions parse
2620    /// values straight from a sqlx row and the trim is a cheap
2621    /// safety net.
2622    #[test]
2623    fn map_postgres_type_trims_whitespace() {
2624        assert_eq!(map_postgres_type("  bigint  "), Some(SqlType::BigInt));
2625    }
2626}