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, "ed).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, "ed).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}