From cfbd72137f803b1dfb872b3ee039eb08717fa5d6 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:05:42 +0200 Subject: [PATCH 01/38] fix: emit unqualified type_name for Postgres enums and composites sqlx 0.8's PgTypeInfo::with_name does not accept schema-qualified names like "agent.canal_type_enum"; emitting them causes runtime decode errors. Always emit the unqualified type name and rely on the connection's search_path to resolve non-public schemas. --- crates/sqlx_gen/src/codegen/composite_gen.rs | 16 ++++++----- crates/sqlx_gen/src/codegen/enum_gen.rs | 30 ++++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 0b1342f..95f4e2c 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -45,11 +45,10 @@ pub fn generate_composite( derive_tokens.push(quote! { #ident }); } - let pg_name = if composite.schema_name != "public" { - format!("{}.{}", composite.schema_name, composite.name) - } else { - composite.name.clone() - }; + // Always unqualified — sqlx 0.8's PgTypeInfo::with_name does not accept "schema.type" + // and emitting it triggers runtime decode errors. Non-public schemas require the + // connection's `search_path` to include the schema. + let pg_name = &composite.name; let type_attr = quote! { #[sqlx(type_name = #pg_name)] }; let fields: Vec = composite @@ -185,7 +184,8 @@ mod tests { } #[test] - fn test_non_public_schema_qualified_type_name() { + fn test_non_public_schema_type_name_is_unqualified() { + // Regression: previously emitted "geo.point" which crashes sqlx 0.8 at runtime. let c = CompositeTypeInfo { schema_name: "geo".to_string(), name: "point".to_string(), @@ -194,7 +194,9 @@ mod tests { let schema = SchemaInfo::default(); let (tokens, _) = generate_composite(&c, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), TimeCrate::Chrono); let code = parse_and_format(&tokens); - assert!(code.contains("sqlx(type_name = \"geo.point\")")); + assert!(code.contains("sqlx(type_name = \"point\")"), + "type_name must be unqualified for sqlx 0.8, got:\n{}", code); + assert!(!code.contains("\"geo.point\"")); } #[test] diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 5ed27b2..76557c1 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -38,13 +38,12 @@ pub fn generate_enum( derive_tokens.push(quote! { #ident }); } - // For PG, add #[sqlx(type_name = "...")] — schema-qualify for non-public schemas + // For PG, add #[sqlx(type_name = "...")] — always unqualified. + // sqlx 0.8's PgTypeInfo::with_name does NOT accept schema-qualified names; emitting + // "schema.type" causes runtime decode failures. The user is expected to set + // `search_path` on the connection so that PG resolves the unqualified type name. let type_attr = if db_kind == DatabaseKind::Postgres { - let pg_name = if enum_info.schema_name != "public" { - format!("{}.{}", enum_info.schema_name, enum_info.name) - } else { - enum_info.name.clone() - }; + let pg_name = &enum_info.name; quote! { #[sqlx(type_name = #pg_name)] } } else { quote! {} @@ -183,7 +182,9 @@ mod tests { } #[test] - fn test_postgres_non_public_schema_qualified_type_name() { + fn test_postgres_non_public_schema_type_name_is_unqualified() { + // Regression: previously emitted "auth.role" which crashes sqlx 0.8 at runtime + // (PgTypeInfo::with_name does not accept schema-qualified names). let e = EnumInfo { schema_name: "auth".to_string(), name: "role".to_string(), @@ -192,7 +193,10 @@ mod tests { }; let (tokens, _) = generate_enum(&e, DatabaseKind::Postgres, &[]); let code = parse_and_format(&tokens); - assert!(code.contains("sqlx(type_name = \"auth.role\")")); + assert!(code.contains("sqlx(type_name = \"role\")"), + "type_name must be unqualified for sqlx 0.8 compatibility, got:\n{}", code); + assert!(!code.contains("\"auth.role\""), + "type_name must NOT include schema; got:\n{}", code); } #[test] @@ -397,8 +401,8 @@ mod tests { assert!(code.contains("Enum: analysis.toolcall_status")); assert!(code.contains("pub enum ToolcallStatus")); - assert!(code.contains("sqlx(type_name = \"analysis.toolcall_status\")")); - assert!(!code.contains("sqlx(type_name = \"toolcall_status\")")); + assert!(code.contains("sqlx(type_name = \"toolcall_status\")")); + assert!(!code.contains("\"analysis.toolcall_status\"")); assert!(code.contains("sqlx_gen(kind = \"enum\", schema = \"analysis\", name = \"toolcall_status\")")); assert!(code.contains("Pending")); assert!(code.contains("Running")); @@ -415,7 +419,8 @@ mod tests { }; let code = gen(&e, DatabaseKind::Postgres); - assert!(code.contains("sqlx(type_name = \"billing.payment_status\")")); + assert!(code.contains("sqlx(type_name = \"payment_status\")")); + assert!(!code.contains("\"billing.payment_status\"")); assert!(code.contains("impl Default for PaymentStatus")); assert!(code.contains("Self::Pending")); } @@ -425,7 +430,8 @@ mod tests { let e = make_enum_in_schema("audit", "log_level", vec!["info", "warn_high", "CRITICAL"]); let code = gen(&e, DatabaseKind::Postgres); - assert!(code.contains("sqlx(type_name = \"audit.log_level\")")); + assert!(code.contains("sqlx(type_name = \"log_level\")")); + assert!(!code.contains("\"audit.log_level\"")); assert!(code.contains("sqlx(rename = \"info\")")); assert!(code.contains("sqlx(rename = \"warn_high\")")); assert!(code.contains("WarnHigh")); From 3c132b340bd6bf1736a1f78d7bfe48a7090a9242 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:05:44 +0200 Subject: [PATCH 02/38] docs: add Rust engineering audit and remediation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35-task TDD plan covering 78 findings across security, codegen/typemap, error handling, tests/CI, and SQL ↔ Rust conformity. --- .../2026-06-03-rust-engineering-audit.md | 2156 +++++++++++++++++ 1 file changed, 2156 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-rust-engineering-audit.md diff --git a/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md b/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md new file mode 100644 index 0000000..8cbd6b6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md @@ -0,0 +1,2156 @@ +# Audit d'ingénierie Rust — sqlx-gen — Plan de remédiation + +> **Pour les agents exécutants :** SOUS-COMPÉTENCE REQUISE : utiliser `superpowers:subagent-driven-development` (recommandé) ou `superpowers:executing-plans` pour implémenter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`) pour le suivi. + +**Objectif :** Corriger les vulnérabilités, panics, incohérences de codegen et lacunes de tests identifiés par un audit multi-domaines de la codebase `sqlx-gen` (v0.5.5). + +**Architecture :** Audit conduit par 4 agents experts (sécurité, codegen/typemap, error handling, tests). Findings synthétisés en 4 vagues de remédiation (P0 → P3) avec TDD strict et commits fréquents. + +**Tech Stack :** Rust 2021, sqlx 0.8, tokio 1, clap 4, syn 2, quote 1, prettyplease 0.2, thiserror 2. + +--- + +## Vue d'ensemble des findings + +| Domaine | Critique | Haut | Moyen | Bas | +| --------------- | -------- | ------ | ------ | ------ | +| Sécurité | 4 | 4 | 3 | 0 | +| Codegen/typemap | 3 | 9 | 15 | 4 | +| Error handling | 2 | 6 | 11 | 6 | +| Tests/CI | 3 | 4 | 2 | 2 | +| **TOTAL** | **12** | **23** | **31** | **12** | + +### Findings P0 (bloquants) + +1. **SQL injection via identifiants non-quotés** dans le code CRUD généré (`crud_gen.rs:23-26, 73, 99-105`). Tables/colonnes/schémas interpolés bruts dans `format!()` sans quoting dialectal. +2. **Code injection via `--type-overrides`** (`cli.rs:100-108`) : la valeur est parsée par `TokenStream::parse().unwrap()` sans validation, permettant l'injection de Rust arbitraire dans le code généré. +3. **Fuite de mot de passe** : `sqlx::Error` peut contenir l'URL complète (login:password@host) ; `Error::Database(#[from] sqlx::Error)` la propage telle quelle aux logs. +4. **Proc-macro fait `std::process::exit(1)`** (`codegen/mod.rs:315-319`) si le parsing échoue — tue le build utilisateur sans `compile_error!`. +5. **MySQL UTF-8 panics** (`introspect/mysql.rs:72-78`) : 7 `.expect()` consécutifs sur noms de colonnes/tables. Toute donnée non-UTF8 dans `information_schema` crash. +6. **Aucun CI de tests** : `.github/workflows/publish.yml` seul existe ; `cargo test` n'est jamais exécuté en CI. +7. **Aucun test E2E Postgres/MySQL** : le commit `bacb088 fix: MySQL 8.0 information_schema changes` était non testable et donc non détectable par les tests. +8. **`.last_mut().unwrap()`** dans toutes les boucles d'introspection (5 occurrences) panic sur ResultSet vide. +9. **MySQL INSERT composite PK** (`crud_gen.rs:959-979`) : utilise `LAST_INSERT_ID()` qui ne fonctionne qu'avec un seul PK auto-increment. +10. **Pas d'écritures atomiques** (`writer.rs:73, 87`) : Ctrl-C en cours de génération laisse des fichiers `.rs` corrompus. +11. **SQLite NUMERIC/DECIMAL → f64** (`typemap/sqlite.rs:40-41`) : perte de précision silencieuse. +12. **Identifiants Rust invalides** : colonnes nommées `user-id`, `123`, ou contenant des espaces produisent du Rust qui ne compile pas. + +--- + +## Architecture des fichiers à modifier + +| Fichier | Responsabilité dans ce plan | +| ------------------------------------------------------ | -------------------------------------------------------------------- | +| `crates/sqlx_gen/src/codegen/identifiers.rs` (nouveau) | Module de quoting d'identifiants par dialecte + validation. | +| `crates/sqlx_gen/src/error.rs` | Étendre les variants ; ajouter `Url::redact`. | +| `crates/sqlx_gen/src/codegen/crud_gen.rs` | Utiliser `identifiers::quote_ident` pour toute SQL générée. | +| `crates/sqlx_gen/src/introspect/mysql.rs` | Remplacer `.expect()` par `.map_err()`, idem `.last_mut().unwrap()`. | +| `crates/sqlx_gen/src/introspect/postgres.rs` | Idem MySQL. | +| `crates/sqlx_gen/src/introspect/sqlite.rs` | Valider les noms avant `PRAGMA`. | +| `crates/sqlx_gen/src/cli.rs` | Valider `--type-overrides` via `syn::parse_str::`. | +| `crates/sqlx_gen/src/codegen/mod.rs` | Supprimer `std::process::exit(1)` ; remonter une `Error`. | +| `crates/sqlx_gen/src/writer.rs` | Écritures atomiques via `tempfile`. | +| `crates/sqlx_gen/src/typemap/sqlite.rs` | NUMERIC → `Decimal`. | +| `crates/sqlx_gen/src/typemap/mysql.rs` | `BIT(1)` → `bool`. | +| `crates/sqlx_gen/src/typemap/postgres.rs` | Ajouter `interval`, range types, `timetz`. | +| `crates/sqlx_gen/tests/e2e_postgres.rs` (nouveau) | E2E avec testcontainers PostgreSQL. | +| `crates/sqlx_gen/tests/e2e_mysql.rs` (nouveau) | E2E avec testcontainers MySQL. | +| `crates/sqlx_gen/tests/snapshots/` (nouveau) | Snapshots `insta` du codegen. | +| `.github/workflows/ci.yml` (nouveau) | Matrix Postgres+MySQL+SQLite, `cargo test --all`. | + +--- + +# VAGUE P0 — Bloquants sécurité & fiabilité + +## Task 1 : Module de quoting d'identifiants par dialecte + +**Fichiers :** + +- Créer : `crates/sqlx_gen/src/codegen/identifiers.rs` +- Modifier : `crates/sqlx_gen/src/codegen/mod.rs` (ajouter `pub mod identifiers;`) + +**Pourquoi :** Tout le code SQL généré dans `crud_gen.rs` interpole table/colonne/schéma via `format!("{}", name)` sans quoting. Si une colonne s'appelle `select` ou `user"; DROP TABLE x; --`, le code compilé exécute du SQL malformé voire malveillant. Source de l'audit : finding sécurité #2/#3, codegen #16/#23. + +- [ ] **Étape 1 : Écrire les tests qui échouent** + +Créer `crates/sqlx_gen/src/codegen/identifiers.rs` : + +```rust +use crate::cli::DatabaseKind; + +/// Quote a SQL identifier (table/column/schema) per database dialect. +/// Doubles internal quote characters for safety. +pub fn quote_ident(name: &str, db: DatabaseKind) -> String { + match db { + DatabaseKind::Mysql => format!("`{}`", name.replace('`', "``")), + DatabaseKind::Postgres | DatabaseKind::Sqlite => { + format!("\"{}\"", name.replace('"', "\"\"")) + } + } +} + +/// Quote a qualified table name (schema.table) per dialect. +pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> String { + match schema { + Some(s) => format!("{}.{}", quote_ident(s, db), quote_ident(table, db)), + None => quote_ident(table, db), + } +} + +/// True if a string is a safe SQL identifier candidate (alphanumeric + underscore). +/// Used as a defense-in-depth check before generating files whose names +/// derive from DB metadata. +pub fn is_safe_ident(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !name.starts_with(|c: char| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_postgres_double_quote() { + assert_eq!(quote_ident("users", DatabaseKind::Postgres), "\"users\""); + } + + #[test] + fn quotes_mysql_backtick() { + assert_eq!(quote_ident("users", DatabaseKind::Mysql), "`users`"); + } + + #[test] + fn escapes_postgres_internal_quote() { + assert_eq!( + quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres), + "\"user\"\"; DROP TABLE x; --\"" + ); + } + + #[test] + fn escapes_mysql_internal_backtick() { + assert_eq!( + quote_ident("ev`il", DatabaseKind::Mysql), + "`ev``il`" + ); + } + + #[test] + fn qualified_with_schema() { + assert_eq!( + quote_qualified(Some("auth"), "users", DatabaseKind::Postgres), + "\"auth\".\"users\"" + ); + } + + #[test] + fn qualified_without_schema() { + assert_eq!( + quote_qualified(None, "users", DatabaseKind::Mysql), + "`users`" + ); + } + + #[test] + fn safe_ident_rejects_dash() { + assert!(!is_safe_ident("user-id")); + } + + #[test] + fn safe_ident_rejects_leading_digit() { + assert!(!is_safe_ident("123abc")); + } + + #[test] + fn safe_ident_rejects_empty() { + assert!(!is_safe_ident("")); + } + + #[test] + fn safe_ident_accepts_underscore() { + assert!(is_safe_ident("_private")); + } +} +``` + +- [ ] **Étape 2 : Faire échouer les tests** + +Run : `cargo test -p sqlx-gen --lib codegen::identifiers` +Attendu : `error[E0583]: file not found for module identifiers` (avant d'ajouter le `pub mod`). + +- [ ] **Étape 3 : Déclarer le module** + +Dans `crates/sqlx_gen/src/codegen/mod.rs` (juste après les autres `mod`) : + +```rust +pub mod identifiers; +``` + +- [ ] **Étape 4 : Valider que les tests passent** + +Run : `cargo test -p sqlx-gen --lib codegen::identifiers` +Attendu : `10 passed; 0 failed`. + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/codegen/identifiers.rs crates/sqlx_gen/src/codegen/mod.rs +git commit -m "feat(codegen): add SQL identifier quoting module" +``` + +--- + +## Task 2 : Appliquer le quoting dans crud_gen.rs + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/crud_gen.rs:23-26, 73, 99-105, 273-275, 305-313, 440-450, 618-629` + +**Pourquoi :** Élimine la SQL injection P0 #1. Toute SQL générée doit utiliser `quote_qualified` / `quote_ident`. + +- [ ] **Étape 1 : Ajouter un test d'intégration "identifiant malveillant"** + +Dans `crates/sqlx_gen/src/codegen/crud_gen.rs` (bloc `#[cfg(test)] mod tests`) : + +```rust +#[test] +fn generated_sql_quotes_table_name() { + use crate::codegen::entity_parser::{ParsedEntity, ParsedField}; + let entity = ParsedEntity { + struct_name: "Users".into(), + table_name: "users".into(), + schema_name: Some("public".into()), + is_view: false, + fields: vec![ParsedField { + field_name: "id".into(), + column_name: "id".into(), + rust_type: "i32".into(), + inner_type: "i32".into(), + is_optional: false, + is_primary_key: true, + column_default: None, + sql_type: None, + }], + imports: vec![], + }; + let (tokens, _) = generate_crud_from_parsed( + &entity, + DatabaseKind::Postgres, + "crate::models::users", + &Methods { get_all: true, ..Methods::default() }, + false, + PoolVisibility::Private, + ); + let code = tokens.to_string(); + assert!( + code.contains("\\\"public\\\".\\\"users\\\""), + "generated code must use quoted qualified identifier, got: {}", + code + ); + assert!( + !code.contains("FROM public.users "), + "must not contain unquoted table reference" + ); +} +``` + +- [ ] **Étape 2 : Run pour faire échouer** + +Run : `cargo test -p sqlx-gen --lib codegen::crud_gen::tests::generated_sql_quotes_table_name` +Attendu : FAIL — la sortie actuelle contient `FROM public.users` non quoté. + +- [ ] **Étape 3 : Implémenter le quoting** + +Remplacer dans `crud_gen.rs` ligne 23-26 : + +```rust +let table_name = match &entity.schema_name { + Some(schema) => format!("{}.{}", schema, entity.table_name), + None => entity.table_name.clone(), +}; +``` + +par : + +```rust +use crate::codegen::identifiers::{quote_ident, quote_qualified}; + +let table_name = quote_qualified( + entity.schema_name.as_deref(), + &entity.table_name, + db_kind, +); +``` + +Puis remplacer chacune des occurrences `f.column_name` dans les formats SQL (lignes 273-275, 440, 450, 618, 629, 890, 921) par `quote_ident(&f.column_name, db_kind)`. Exemple ligne 273-275 : + +```rust +let set_cols: Vec = non_pk_fields + .iter() + .enumerate() + .map(|(i, f)| { + let p = placeholder(db_kind, i + 1); + format!("{} = {}", quote_ident(&f.column_name, db_kind), p) + }) + .collect(); +``` + +Faire la même substitution pour les listes de colonnes d'`INSERT (...)`, `WHERE x = $1`, `RETURNING ` (ne pas quoter `*`). + +- [ ] **Étape 4 : Vérifier le passage** + +Run : `cargo test -p sqlx-gen --lib codegen::crud_gen` +Attendu : tous passent (les snapshots des tests existants seront probablement à mettre à jour — ils sont des assertions de substring, ajuster en conséquence : remplacer `assert!(code.contains("SELECT * FROM users"))` par `assert!(code.contains("SELECT * FROM \"users\""))` pour Postgres et ``"SELECT * FROM `users`"`` pour MySQL). + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/codegen/crud_gen.rs +git commit -m "fix(codegen): quote SQL identifiers per dialect to prevent injection" +``` + +--- + +## Task 3 : Valider les valeurs de `--type-overrides` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/cli.rs:100-108` + +**Pourquoi :** Le finding sécurité #5 montre que `--type-overrides jsonb=evil; fn pwned()` est injecté tel quel dans le code généré via `TokenStream::parse().unwrap()`. Valider le type en amont via `syn`. + +- [ ] **Étape 1 : Écrire les tests** + +Dans `crates/sqlx_gen/src/cli.rs` (module `tests`) : + +```rust +#[test] +fn type_overrides_reject_injection() { + let args = make_entities_args_with_overrides(vec!["jsonb=Vec; fn pwned() {}"]); + let result = args.parse_type_overrides_checked(); + assert!(result.is_err(), "must reject overrides that aren't valid types"); +} + +#[test] +fn type_overrides_accept_path_type() { + let args = make_entities_args_with_overrides(vec!["jsonb=crate::types::MyJson"]); + let map = args.parse_type_overrides_checked().unwrap(); + assert_eq!(map.get("jsonb").unwrap(), "crate::types::MyJson"); +} + +#[test] +fn type_overrides_accept_generic_type() { + let args = make_entities_args_with_overrides(vec!["jsonb=Vec"]); + assert!(args.parse_type_overrides_checked().is_ok()); +} + +#[test] +fn type_overrides_reject_empty_value() { + let args = make_entities_args_with_overrides(vec!["jsonb="]); + assert!(args.parse_type_overrides_checked().is_err()); +} +``` + +- [ ] **Étape 2 : Faire échouer** + +Run : `cargo test -p sqlx-gen --lib cli::tests::type_overrides_reject_injection` +Attendu : FAIL — méthode `parse_type_overrides_checked` n'existe pas. + +- [ ] **Étape 3 : Implémenter** + +Dans `crates/sqlx_gen/src/cli.rs` (impl `EntitiesArgs`) : + +```rust +pub fn parse_type_overrides_checked(&self) -> crate::error::Result> { + let mut map = HashMap::new(); + for s in &self.type_overrides { + let (k, v) = s.split_once('=').ok_or_else(|| { + crate::error::Error::Config(format!( + "Invalid --type-overrides entry '{}'. Expected format: sql_type=RustType", + s + )) + })?; + if v.trim().is_empty() { + return Err(crate::error::Error::Config(format!( + "Empty Rust type in override '{}'", + s + ))); + } + syn::parse_str::(v).map_err(|e| { + crate::error::Error::Config(format!( + "Invalid Rust type in --type-overrides '{}': {}", + v, e + )) + })?; + map.insert(k.to_string(), v.to_string()); + } + Ok(map) +} +``` + +Mettre à jour `main.rs:30` : + +```rust +let type_overrides = args.parse_type_overrides_checked()?; +``` + +- [ ] **Étape 4 : Vérifier** + +Run : `cargo test -p sqlx-gen --lib cli::tests::type_overrides` +Attendu : 4 passent. + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/cli.rs crates/sqlx_gen/src/main.rs +git commit -m "fix(cli): validate --type-overrides values via syn::parse_str" +``` + +--- + +## Task 4 : Redaction de l'URL DB dans les erreurs + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/error.rs` +- Modifier : `crates/sqlx_gen/src/main.rs:42-58` + +**Pourquoi :** Finding sécurité #6 : `sqlx::Error` peut contenir l'URL avec mot de passe. Ajouter une wrapping `Connection(redacted_url, source)` et une fonction utilitaire `redact_url`. + +- [ ] **Étape 1 : Tests** + +Dans `crates/sqlx_gen/src/error.rs` : + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_password_in_postgres_url() { + let url = "postgres://alice:s3cret@localhost:5432/db"; + assert_eq!( + redact_url(url), + "postgres://alice:****@localhost:5432/db" + ); + } + + #[test] + fn redacts_password_in_mysql_url() { + assert_eq!( + redact_url("mysql://root:hunter2@db:3306/app"), + "mysql://root:****@db:3306/app" + ); + } + + #[test] + fn leaves_passwordless_url_unchanged() { + assert_eq!( + redact_url("sqlite:///tmp/test.db"), + "sqlite:///tmp/test.db" + ); + } + + #[test] + fn leaves_no_userinfo_unchanged() { + assert_eq!( + redact_url("postgres://localhost/db"), + "postgres://localhost/db" + ); + } +} +``` + +- [ ] **Étape 2 : Faire échouer** + +Run : `cargo test -p sqlx-gen --lib error` +Attendu : FAIL — `redact_url` n'existe pas. + +- [ ] **Étape 3 : Implémenter** + +Remplacer le contenu de `crates/sqlx_gen/src/error.rs` par : + +```rust +use std::io; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Database connection error ({redacted_url}): {source}")] + Connection { + redacted_url: String, + #[source] + source: sqlx::Error, + }, + + #[error("Database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("IO error: {0}")] + Io(#[from] io::Error), + + #[error("{0}")] + Config(String), +} + +pub type Result = std::result::Result; + +/// Redact `user:password@host` → `user:****@host` in a database URL. +pub fn redact_url(url: &str) -> String { + let (scheme, rest) = match url.split_once("://") { + Some(pair) => pair, + None => return url.to_string(), + }; + let (userinfo, host_part) = match rest.split_once('@') { + Some(pair) => pair, + None => return url.to_string(), + }; + let redacted_userinfo = match userinfo.split_once(':') { + Some((user, _pw)) => format!("{}:****", user), + None => userinfo.to_string(), + }; + format!("{}://{}@{}", scheme, redacted_userinfo, host_part) +} +``` + +Mettre à jour `main.rs:42-58` pour wrapper en `Connection` : + +```rust +let mut schema_info = match db_kind { + DatabaseKind::Postgres => { + let pool = PgPool::connect(&args.db.database_url).await.map_err(|e| { + sqlx_gen::error::Error::Connection { + redacted_url: sqlx_gen::error::redact_url(&args.db.database_url), + source: e, + } + })?; + let info = introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await?; + pool.close().await; + info + } + // ... idem pour Mysql, Sqlite +}; +``` + +- [ ] **Étape 4 : Vérifier** + +Run : `cargo test -p sqlx-gen --lib error::tests` +Attendu : 4 passent. + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/error.rs crates/sqlx_gen/src/main.rs +git commit -m "fix(error): redact password in database URLs on connection failure" +``` + +--- + +## Task 5 : Remplacer `process::exit(1)` par une erreur propagée + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/mod.rs:310-323` + +**Pourquoi :** Finding error #3.1. Un échec de parsing prettyplease tue le process — inacceptable dans une bibliothèque ou un futur build.rs. + +- [ ] **Étape 1 : Test** + +Dans `crates/sqlx_gen/src/codegen/mod.rs` (module `tests`) : + +```rust +#[test] +fn parse_and_format_returns_error_on_invalid_tokens() { + use proc_macro2::TokenStream; + use std::str::FromStr; + // Mismatched braces produce a valid TokenStream but invalid syn::File. + let bad = TokenStream::from_str("fn x() { ").unwrap(); + let result = parse_and_format_with_tab_spaces(&bad, 4); + assert!(result.is_err(), "must return Err, not exit"); +} +``` + +- [ ] **Étape 2 : Faire échouer** + +Run : `cargo test -p sqlx-gen --lib codegen::tests::parse_and_format_returns_error_on_invalid_tokens` +Attendu : FAIL — la fonction renvoie `String`, pas `Result`. + +- [ ] **Étape 3 : Implémenter** + +Modifier `crates/sqlx_gen/src/codegen/mod.rs:310-323` : + +```rust +pub(crate) fn parse_and_format(tokens: &TokenStream) -> crate::error::Result { + parse_and_format_with_tab_spaces(tokens, 4) +} + +pub(crate) fn parse_and_format_with_tab_spaces( + tokens: &TokenStream, + tab_spaces: usize, +) -> crate::error::Result { + let file = syn::parse2::(tokens.clone()).map_err(|e| { + crate::error::Error::Config(format!( + "Internal sqlx-gen bug: failed to parse generated code: {}. \ + Please report this with the input schema.", + e + )) + })?; + let raw = prettyplease::unparse(&file); + let raw = indent_multiline_raw_strings(&raw, tab_spaces); + Ok(add_blank_lines_between_items(&raw)) +} +``` + +Propager `?` dans tous les sites d'appel : `format_tokens`, `format_tokens_with_imports`, `format_tokens_with_imports_and_tab_spaces`, et les retours dans `generate()`. Ajuster les signatures publiques pour renvoyer `Result` au lieu de `String`. + +- [ ] **Étape 4 : Vérifier** + +Run : `cargo build -p sqlx-gen && cargo test -p sqlx-gen --lib codegen` +Attendu : compile et tous les tests passent (les sites d'appel mis à jour). + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/codegen/mod.rs crates/sqlx_gen/src/main.rs +git commit -m "fix(codegen): propagate parse errors instead of process::exit" +``` + +--- + +## Task 6 : Bannir les `.expect()` et `.last_mut().unwrap()` dans introspect + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/introspect/mysql.rs:72-78, 89, 146` +- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs:355, 405, 466` + +**Pourquoi :** Finding error #1.1–#1.5. Toute donnée DB inattendue (utf-8, ordre des rows) crash. + +- [ ] **Étape 1 : Test pour `last_mut`** + +Dans `crates/sqlx_gen/src/introspect/mysql.rs` (module `tests` à créer si absent) : + +```rust +#[cfg(test)] +mod tests { + use crate::error::Error; + + fn invariant_violation(field: &str) -> Error { + Error::Config(format!( + "Internal introspection invariant violated: {} accessed empty tables vector. \ + This is a bug in sqlx-gen.", + field + )) + } + + #[test] + fn invariant_violation_message_mentions_field() { + let err = invariant_violation("columns"); + assert!(err.to_string().contains("columns")); + } +} +``` + +- [ ] **Étape 2 : Faire passer (run test)** + +Run : `cargo test -p sqlx-gen --lib introspect::mysql::tests::invariant_violation_message_mentions_field` +Attendu : PASS après ajout — placeholder pour la fonction utilitaire. + +- [ ] **Étape 3 : Remplacer les `.expect()` UTF-8 et les `.last_mut().unwrap()`** + +Dans `crates/sqlx_gen/src/introspect/mysql.rs:62`, changer le type de `query_as` pour prendre des `String` plutôt que `Vec` quand c'est possible : + +```rust +let mut q = sqlx::query_as::<_, (String, String, String, String, String, String, u32, String)>(&query); +``` + +Si MySQL réclame `Vec` (cas mediumtext en collation binary), remplacer chaque `.expect("Could not convert ...")` par : + +```rust +let schema = String::from_utf8(schema).map_err(|_| crate::error::Error::Config( + "Database returned non-UTF8 schema name; sqlx-gen requires UTF-8 metadata".into() +))?; +``` + +Pour les `last_mut().unwrap()` (lignes 89, 146 mysql, 355, 405 pg) remplacer par : + +```rust +match tables.last_mut() { + Some(t) => t.columns.push(column), + None => return Err(crate::error::Error::Config( + "Internal invariant: row returned for non-existent table. Bug in sqlx-gen.".into() + )), +} +``` + +- [ ] **Étape 4 : Vérifier** + +Run : `cargo build -p sqlx-gen && cargo test -p sqlx-gen --lib introspect` +Attendu : compile, tests passent. + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/introspect/ +git commit -m "fix(introspect): replace expect/unwrap with proper Result propagation" +``` + +--- + +## Task 7 : Écritures atomiques + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/Cargo.toml` (déplacer `tempfile` de `dev-dependencies` vers `dependencies`, gated par feature `cli`) +- Modifier : `crates/sqlx_gen/src/writer.rs:60-90, 167-200` +- Modifier : `crates/sqlx_gen/src/main.rs:167` + +**Pourquoi :** Finding error #10.1/#10.2. `std::fs::write` non-atomique : Ctrl-C laisse des fichiers tronqués qui cassent le build du projet utilisateur. + +- [ ] **Étape 1 : Ajouter tempfile en dépendance runtime** + +Dans `crates/sqlx_gen/Cargo.toml`, ajouter dans `[dependencies]` : + +```toml +tempfile = { version = "3", optional = true } +``` + +Et l'ajouter à la feature `cli` : + +```toml +cli = [ + # ... existing + "dep:tempfile", +] +``` + +- [ ] **Étape 2 : Test** + +Dans `crates/sqlx_gen/src/writer.rs` (module `tests`) : + +```rust +#[test] +fn write_atomic_creates_file_with_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.rs"); + write_atomic(&path, b"hello").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); +} + +#[test] +fn write_atomic_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.rs"); + std::fs::write(&path, "old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); +} +``` + +- [ ] **Étape 3 : Faire échouer** + +Run : `cargo test -p sqlx-gen --lib writer::tests::write_atomic_creates_file_with_content` +Attendu : FAIL — fonction inexistante. + +- [ ] **Étape 4 : Implémenter** + +Dans `crates/sqlx_gen/src/writer.rs` : + +```rust +/// Write `content` to `path` atomically: write to a sibling temp file then rename. +pub(crate) fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + crate::error::Error::Config(format!("Cannot determine parent of {}", path.display())) + })?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + use std::io::Write; + tmp.write_all(content)?; + tmp.flush()?; + tmp.persist(path).map_err(|e| e.error)?; + Ok(()) +} +``` + +Remplacer chaque `std::fs::write(&path, &content)?;` dans `writer.rs` et `main.rs:167` par `write_atomic(&path, content.as_bytes())?;`. + +- [ ] **Étape 5 : Vérifier** + +Run : `cargo test -p sqlx-gen --lib writer` +Attendu : tests passent. + +- [ ] **Étape 6 : Commit** + +```bash +git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/src/writer.rs crates/sqlx_gen/src/main.rs +git commit -m "fix(writer): use atomic temp-file + rename to prevent corrupted output" +``` + +--- + +## Task 8 : Validation path traversal pour les noms de fichier générés + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/mod.rs` (fonction `normalize_module_name` ou similaire) +- Modifier : `crates/sqlx_gen/src/writer.rs:70-78` + +**Pourquoi :** Finding sécurité #7 : si une DB malveillante retourne un nom de table `../../etc/passwd`, le filename généré peut sortir de `output_dir`. + +- [ ] **Étape 1 : Tests** + +Dans `crates/sqlx_gen/src/writer.rs` (module `tests`) : + +```rust +#[test] +fn rejects_path_traversal_in_filename() { + let dir = tempfile::tempdir().unwrap(); + let files = vec![GeneratedFile { + filename: "../escape.rs".to_string(), + code: "fn x() {}".into(), + origin: None, + }]; + let result = write_files(&files, dir.path(), false, false); + assert!(result.is_err(), "must reject filename containing .."); +} + +#[test] +fn rejects_absolute_path_in_filename() { + let dir = tempfile::tempdir().unwrap(); + let files = vec![GeneratedFile { + filename: "/etc/passwd".to_string(), + code: "".into(), + origin: None, + }]; + let result = write_files(&files, dir.path(), false, false); + assert!(result.is_err()); +} +``` + +- [ ] **Étape 2 : Faire échouer** + +Run : `cargo test -p sqlx-gen --lib writer::tests::rejects_path_traversal_in_filename` +Attendu : FAIL — actuellement l'écriture aboutit. + +- [ ] **Étape 3 : Implémenter** + +Dans `crates/sqlx_gen/src/writer.rs`, ajouter en début de `write_multi_files` : + +```rust +for f in files { + let candidate = std::path::Path::new(&f.filename); + if candidate.components().count() != 1 + || candidate.is_absolute() + || f.filename.contains("..") + || !f.filename.ends_with(".rs") + { + return Err(crate::error::Error::Config(format!( + "Refusing to write generated file with unsafe name: {:?}", + f.filename + ))); + } +} +``` + +- [ ] **Étape 4 : Vérifier** + +Run : `cargo test -p sqlx-gen --lib writer` +Attendu : passent. + +- [ ] **Étape 5 : Commit** + +```bash +git add crates/sqlx_gen/src/writer.rs +git commit -m "fix(writer): refuse path-traversal in generated filenames" +``` + +--- + +## Task 9 : CI — workflow `test.yml` avec services Postgres + MySQL + +**Fichiers :** + +- Créer : `.github/workflows/ci.yml` + +**Pourquoi :** Finding tests P0 #2. Aucun CI ne lance `cargo test` ; chaque PR mergé est inspecté à la main. + +- [ ] **Étape 1 : Créer le fichier** + +`.github/workflows/ci.yml` : + +```yaml +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: sqlx_gen_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: sqlx_gen_test + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -uroot -proot" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + PG_URL: postgres://postgres:postgres@localhost:5432/sqlx_gen_test + MYSQL_URL: mysql://root:root@localhost:3306/sqlx_gen_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: Format check + run: cargo fmt --all -- --check + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test --all +``` + +- [ ] **Étape 2 : Commit et pousser** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add test workflow with Postgres + MySQL services" +``` + +Vérifier qu'un push déclenche le workflow et qu'il passe (les jobs Postgres/MySQL n'auront pas de tests E2E avant Task 12, mais `cargo test` doit verdir). + +--- + +## Task 10 : Snapshot tests `insta` pour le codegen + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/Cargo.toml` (ajouter `insta` en dev-dep) +- Créer : `crates/sqlx_gen/tests/snapshots_codegen.rs` +- Créer : `crates/sqlx_gen/tests/snapshots/` (sera peuplé par `cargo insta`) + +**Pourquoi :** Finding tests P1 #4. Aucun snapshot test ⇒ toute régression silencieuse de format ou de derive passe inaperçue. + +- [ ] **Étape 1 : Ajouter `insta`** + +Dans `crates/sqlx_gen/Cargo.toml` : + +```toml +[dev-dependencies] +insta = "1" +``` + +- [ ] **Étape 2 : Créer un test snapshot** + +`crates/sqlx_gen/tests/snapshots_codegen.rs` : + +```rust +use sqlx_gen::cli::{DatabaseKind, Methods, PoolVisibility}; +use sqlx_gen::codegen::crud_gen::generate_crud_from_parsed; +use sqlx_gen::codegen::entity_parser::{ParsedEntity, ParsedField}; +use sqlx_gen::codegen::format_tokens_with_imports; + +fn sample_users() -> ParsedEntity { + ParsedEntity { + struct_name: "Users".into(), + table_name: "users".into(), + schema_name: Some("public".into()), + is_view: false, + fields: vec![ + ParsedField { + field_name: "id".into(), + column_name: "id".into(), + rust_type: "i32".into(), + inner_type: "i32".into(), + is_optional: false, + is_primary_key: true, + column_default: None, + sql_type: None, + }, + ParsedField { + field_name: "email".into(), + column_name: "email".into(), + rust_type: "String".into(), + inner_type: "String".into(), + is_optional: false, + is_primary_key: false, + column_default: None, + sql_type: None, + }, + ], + imports: vec![], + } +} + +#[test] +fn snapshot_postgres_full_crud_users() { + let (tokens, imports) = generate_crud_from_parsed( + &sample_users(), + DatabaseKind::Postgres, + "crate::models::users", + &Methods::all(), + false, + PoolVisibility::Private, + ); + let code = format_tokens_with_imports(&tokens, &imports).expect("format"); + insta::assert_snapshot!("postgres_full_crud_users", code); +} + +#[test] +fn snapshot_mysql_full_crud_users() { + let (tokens, imports) = generate_crud_from_parsed( + &sample_users(), + DatabaseKind::Mysql, + "crate::models::users", + &Methods::all(), + false, + PoolVisibility::Private, + ); + let code = format_tokens_with_imports(&tokens, &imports).expect("format"); + insta::assert_snapshot!("mysql_full_crud_users", code); +} + +#[test] +fn snapshot_sqlite_full_crud_users() { + let (tokens, imports) = generate_crud_from_parsed( + &sample_users(), + DatabaseKind::Sqlite, + "crate::models::users", + &Methods::all(), + false, + PoolVisibility::Private, + ); + let code = format_tokens_with_imports(&tokens, &imports).expect("format"); + insta::assert_snapshot!("sqlite_full_crud_users", code); +} +``` + +- [ ] **Étape 3 : Générer et valider les snapshots** + +```bash +cargo install cargo-insta +cargo test -p sqlx-gen --test snapshots_codegen +cargo insta review +``` + +L'humain valide le contenu des 3 snapshots avant de commiter. + +- [ ] **Étape 4 : Commit** + +```bash +git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/tests/snapshots_codegen.rs crates/sqlx_gen/tests/snapshots/ +git commit -m "test: add insta snapshot tests for CRUD codegen across 3 dialects" +``` + +--- + +# VAGUE P1 — Robustesse codegen & couverture E2E + +## Task 11 : E2E PostgreSQL via testcontainers + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/Cargo.toml` (dev-dep `testcontainers`) +- Créer : `crates/sqlx_gen/tests/e2e_postgres.rs` + +**Pourquoi :** Finding tests P0 #1. Le commit `bacb088 fix MySQL 8.0 information_schema` n'avait aucun test E2E ; toute régression similaire passe. + +- [ ] **Étape 1 : Ajouter testcontainers + sqlx** + +```toml +[dev-dependencies] +testcontainers = "0.20" +testcontainers-modules = { version = "0.8", features = ["postgres"] } +tokio = { version = "1", features = ["full"] } +``` + +- [ ] **Étape 2 : Écrire l'e2e** + +`crates/sqlx_gen/tests/e2e_postgres.rs` (squelette ; chaque test doit valider que `cargo build` du code généré marche) : + +```rust +use sqlx::PgPool; +use sqlx_gen::introspect::postgres::introspect; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; + +async fn setup() -> (testcontainers::ContainerAsync, PgPool) { + let container = Postgres::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port); + let pool = PgPool::connect(&url).await.unwrap(); + (container, pool) +} + +#[tokio::test] +async fn introspects_table_with_enum_and_jsonb() { + let (_c, pool) = setup().await; + sqlx::query("CREATE TYPE status AS ENUM ('active', 'inactive')") + .execute(&pool).await.unwrap(); + sqlx::query(r#" + CREATE TABLE users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL, + status status NOT NULL, + meta JSONB + ) + "#).execute(&pool).await.unwrap(); + + let info = introspect(&pool, &["public".into()], false).await.unwrap(); + assert_eq!(info.tables.len(), 1); + assert_eq!(info.tables[0].columns.len(), 4); + assert_eq!(info.enums.len(), 1); + assert_eq!(info.enums[0].variants, vec!["active", "inactive"]); +} + +#[tokio::test] +async fn introspects_composite_pk() { + let (_c, pool) = setup().await; + sqlx::query(r#" + CREATE TABLE order_items ( + order_id INT NOT NULL, + product_id INT NOT NULL, + qty INT NOT NULL, + PRIMARY KEY (order_id, product_id) + ) + "#).execute(&pool).await.unwrap(); + let info = introspect(&pool, &["public".into()], false).await.unwrap(); + let pk_cols: Vec<_> = info.tables[0].columns.iter() + .filter(|c| c.is_primary_key).collect(); + assert_eq!(pk_cols.len(), 2); +} + +#[tokio::test] +async fn introspects_view_inherits_nullability() { + let (_c, pool) = setup().await; + sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, name TEXT NOT NULL)") + .execute(&pool).await.unwrap(); + sqlx::query("CREATE VIEW v AS SELECT id, name FROM t") + .execute(&pool).await.unwrap(); + let info = introspect(&pool, &["public".into()], true).await.unwrap(); + assert_eq!(info.views.len(), 1); + assert!(!info.views[0].columns.iter().find(|c| c.name == "name").unwrap().is_nullable); +} + +#[tokio::test] +async fn rejects_table_with_reserved_keyword_column() { + let (_c, pool) = setup().await; + sqlx::query(r#"CREATE TABLE t (id INT PRIMARY KEY, "type" TEXT)"#) + .execute(&pool).await.unwrap(); + let info = introspect(&pool, &["public".into()], false).await.unwrap(); + // Codegen must produce compileable Rust for keyword columns. + let files = sqlx_gen::codegen::generate( + &info, sqlx_gen::cli::DatabaseKind::Postgres, + &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, + ); + for f in files { + syn::parse_file(&f.code).expect("generated code must parse"); + } +} +``` + +- [ ] **Étape 3 : Faire passer** + +Run : `cargo test -p sqlx-gen --test e2e_postgres` +Attendu : 4 passent (sinon corriger les bugs révélés en suivant le pattern systematic-debugging). + +- [ ] **Étape 4 : Commit** + +```bash +git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/tests/e2e_postgres.rs +git commit -m "test(e2e): add Postgres E2E tests via testcontainers" +``` + +--- + +## Task 12 : E2E MySQL via testcontainers (régression `bacb088`) + +**Fichiers :** + +- Créer : `crates/sqlx_gen/tests/e2e_mysql.rs` + +**Pourquoi :** Re-tester explicitement le scénario information_schema MySQL 8.0 corrigé par `bacb088` afin de prévenir toute régression. + +- [ ] **Étape 1 : Tests** + +```rust +use sqlx::MySqlPool; +use sqlx_gen::introspect::mysql::introspect; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::mysql::Mysql; + +async fn setup() -> (testcontainers::ContainerAsync, MySqlPool, String) { + let container = Mysql::default().start().await.unwrap(); + let port = container.get_host_port_ipv4(3306).await.unwrap(); + let url = format!("mysql://root@127.0.0.1:{}/test", port); + let pool = MySqlPool::connect(&url).await.unwrap(); + sqlx::query("CREATE DATABASE IF NOT EXISTS test").execute(&pool).await.ok(); + (container, pool, "test".to_string()) +} + +#[tokio::test] +async fn introspects_mysql8_information_schema_charset_change() { + let (_c, pool, db) = setup().await; + sqlx::query("CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(255))") + .execute(&pool).await.unwrap(); + let info = introspect(&pool, &[db], false).await.unwrap(); + assert_eq!(info.tables.len(), 1); + assert_eq!(info.tables[0].columns.len(), 2); +} + +#[tokio::test] +async fn introspects_mysql_inline_enum() { + let (_c, pool, db) = setup().await; + sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, status ENUM('a', 'b'))") + .execute(&pool).await.unwrap(); + let info = introspect(&pool, &[db], false).await.unwrap(); + assert!(!info.enums.is_empty()); +} + +#[tokio::test] +async fn introspects_mysql_tinyint1_as_bool() { + let (_c, pool, db) = setup().await; + sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, active TINYINT(1) NOT NULL)") + .execute(&pool).await.unwrap(); + let info = introspect(&pool, &[db], false).await.unwrap(); + let files = sqlx_gen::codegen::generate( + &info, sqlx_gen::cli::DatabaseKind::Mysql, + &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, + ); + let code = files.iter().find(|f| f.filename.contains("t")).unwrap().code.clone(); + assert!(code.contains("pub active: bool"), "tinyint(1) must map to bool, got: {}", code); +} +``` + +- [ ] **Étape 2 : Run et corriger** + +Run : `cargo test -p sqlx-gen --test e2e_mysql` + +- [ ] **Étape 3 : Commit** + +```bash +git add crates/sqlx_gen/tests/e2e_mysql.rs +git commit -m "test(e2e): add MySQL E2E coverage including info_schema 8.0 regression" +``` + +--- + +## Task 13 : `MySQL BIT(1) → bool` et autres trous de typemap + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/typemap/mysql.rs:79` +- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs` + +**Pourquoi :** Findings codegen #6, #7, #8. + +- [ ] **Étape 1 : Tests** + +Dans `crates/sqlx_gen/src/typemap/mysql.rs` (tests) : + +```rust +#[test] +fn bit_one_is_bool() { + let t = map("bit", "bit(1)", false); + assert_eq!(t.name, "bool"); +} + +#[test] +fn bit_n_is_vec_u8() { + let t = map("bit", "bit(8)", false); + assert_eq!(t.name, "Vec"); +} +``` + +Dans `typemap/postgres.rs` : + +```rust +#[test] +fn interval_uses_pginterval() { + let t = map("interval", "interval", false); + assert_eq!(t.name, "PgInterval"); +} + +#[test] +fn timetz_warns_or_uses_fixed_offset() { + let t = map("time with time zone", "timetz", false); + assert!(t.name.contains("FixedOffset") || t.name == "String", + "timetz currently maps to {}; should not silently drop timezone", t.name); +} +``` + +- [ ] **Étape 2 : Faire échouer** + +Run les tests, observer FAIL. + +- [ ] **Étape 3 : Implémenter** + +MySQL `typemap/mysql.rs:79` : ajouter avant le mapping bit générique : + +```rust +if udt_name.eq_ignore_ascii_case("bit(1)") { + return RustType::simple("bool"); +} +``` + +Postgres `typemap/postgres.rs` : ajouter `"interval" => RustType::with_import("PgInterval", "use sqlx::postgres::types::PgInterval;")` dans la fonction `map`. + +- [ ] **Étape 4 : Vérifier + Commit** + +```bash +cargo test -p sqlx-gen --lib typemap +git add crates/sqlx_gen/src/typemap/ +git commit -m "fix(typemap): MySQL BIT(1)→bool, Postgres interval→PgInterval" +``` + +--- + +## Task 14 : Validation des noms de colonne (caractères spéciaux) + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/struct_gen.rs:55-68` + +**Pourquoi :** Finding codegen #16/#17. Une colonne `user-id` produit `pub user-id: i32;` = invalide. + +- [ ] **Étape 1 : Test** + +```rust +#[test] +fn column_with_dash_is_sanitized_or_errors() { + let col = ColumnInfo { + name: "user-id".into(), + // ... rest + }; + let result = generate_struct_field(&col, /* params */); + assert!( + result.is_err() || result.unwrap().contains("user_id"), + "must sanitize or error on column 'user-id'" + ); +} +``` + +- [ ] **Étape 2 : Implémenter** + +Dans `struct_gen.rs`, transformer `to_snake_case` puis remplacer tout char `!is_ascii_alphanumeric && != '_'` par `_`. Préfixer par `_` si le résultat commence par un chiffre. Si vide, retourner `Err(Error::Config("Column name empty"))`. + +- [ ] **Étape 3 : Commit** + +```bash +git commit -am "fix(codegen): sanitize column names with non-ident characters" +``` + +--- + +## Task 15 : `SQLite NUMERIC/DECIMAL → Decimal` (cohérence cross-backend) + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/typemap/sqlite.rs:40-41` + +**Pourquoi :** Finding codegen #1. Perte de précision silencieuse pour les montants financiers. + +- [ ] **Étape 1 : Test** + +```rust +#[test] +fn numeric_maps_to_decimal_not_f64() { + let t = map("NUMERIC", false); + assert_eq!(t.name, "Decimal"); +} +``` + +- [ ] **Étape 2 : Implémenter** + +```rust +if upper.contains("NUMERIC") || upper.contains("DECIMAL") { + return RustType::with_import("Decimal", "use sqlx::types::Decimal;"); +} +``` + +- [ ] **Étape 3 : Commit** + +```bash +git commit -am "fix(typemap): SQLite NUMERIC/DECIMAL → Decimal (no precision loss)" +``` + +--- + +# VAGUE P2 — Qualité de l'API et UX + +## Task 16 : Erreurs sqlx contextuelles + +**Fichier :** `crates/sqlx_gen/src/error.rs` + +Étendre l'enum `Error` avec `SchemaNotFound { schema: String }`, `PermissionDenied { detail: String }`. Dans `introspect/*.rs`, pattern-matcher `sqlx::Error::Database(db_err)` et leur code SQLSTATE pour cibler `42P01` (Postgres : undefined_table), `42501` (insufficient_privilege), etc. Tests unitaires sur la conversion. + +## Task 17 : MySQL composite PK insert — fallback SELECT + +**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:959-979` + +Si `pk_fields.len() > 1` ou si le PK n'est pas auto-increment (détecter via `column_default = "auto"` ou similaire), générer un `SELECT * FROM table WHERE pk1 = ? AND pk2 = ?` après l'insert au lieu de `LAST_INSERT_ID()`. Tests E2E MySQL avec composite PK. + +## Task 18 : Skip schema qualification pour les schémas par défaut + +**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:23-26` + +Si `schema_name in ["public" (PG), "main" (sqlite)]`, omettre la qualification. Configurable via `--always-qualify`. Test unitaire. + +## Task 19 : Documenter MSRV + +**Fichiers :** `crates/sqlx_gen/Cargo.toml`, `README.md` + +Ajouter `rust-version = "1.75"` au `Cargo.toml` (à vérifier par `cargo msrv find`). Mettre à jour le README. Ajouter au workflow CI une matrix `{ stable, msrv }`. + +## Task 20 : Rejeter les `r#"...""#` qui contiennent `"#` + +**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:858-860` + +Avant `format!("r#\"\n{}\n\"#", s).parse().unwrap()`, scanner `s` pour `"#` ; si présent, monter la clôture à `r##"..."##` ou retourner une erreur. Test : SQL avec commentaire `-- "#`. + +--- + +# VAGUE P3 — Polissage + +## Task 21 : Doctest sur `lib.rs` + +## Task 22 : Clippy `-D warnings` propre dans CI + +## Task 23 : Cycle-detection composites Postgres (finding codegen #26) + +## Task 24 : Empty-batch guard `insert_many` (finding codegen #28) + +## Task 25 : Logger `info!` clair "Found 0 tables — empty schema or no permission?" + +Chaque task suit le même pattern : test rouge → implémentation minimale → vert → commit. + +--- + +# VAGUE P4 — Conformité SQL ↔ Rust du code généré + +Cette vague est dédiée à l'écart entre ce que **SQL attend du driver `sqlx`** et ce que **sqlx-gen produit**. Les bugs ici sont souvent silencieux : le code Rust compile, les tests unitaires passent, mais à l'exécution la requête échoue ou — pire — lit/écrit des valeurs incorrectes. + +## Findings de conformité (synthèse) + +| # | Sévérité | Type | Description | +|---|----------|------|-------------| +| C1 | Critique | Postgres enum | Lookup d'enum par `udt_name` non qualifié (`typemap/postgres.rs:41`). Deux enums homonymes dans deux schémas → match arbitraire. | +| C2 | Critique | Postgres enum array | `Vec` généré sans `impl PgHasArrayType` → sqlx renvoie `unsupported type _my_enum` à la lecture. | +| C3 | Critique | Postgres enum schema-qualified type_name | `#[sqlx(type_name = "auth.role")]` n'est PAS le format attendu par sqlx 0.8 (qui veut `type_name = "role"` + `schema = "auth"` via le pool ou un `PgTypeInfo` custom). | +| C4 | Haut | Composite import path | `use super::types::Status;` dur-codé (`typemap/postgres.rs:43, 49`). Casse en `--single-file` ou sortie non-standard. | +| C5 | Haut | Postgres generated columns | `GENERATED ALWAYS AS (...)` non détecté en introspection → inclus dans INSERT/UPDATE → erreur SQL `cannot insert into column "x"`. | +| C6 | Haut | Postgres identity columns | `GENERATED ALWAYS AS IDENTITY` vs `BY DEFAULT AS IDENTITY` non distingués. Le premier rejette toute valeur fournie. | +| C7 | Haut | Enum variant collision après camelCase | Valeurs `foo bar` et `foo_bar` → deux `FooBar` → erreur de compilation Rust. | +| C8 | Haut | MySQL inline ENUM typing | Colonne `status ENUM('a','b')` : codegen génère un enum Rust mais `sqlx::Type` derive sans `#[sqlx(rename_all)]` ni `try_from`. Décode `String` puis échoue. | +| C9 | Haut | Domain non-newtype | `pub type Email = String` perd l'identité de type. Les utilisateurs de domains veulent newtype + validation. | +| C10 | Moyen | TIMESTAMP vs TIMESTAMPTZ misuse | Pas de warning si l'utilisateur a un `TIMESTAMP` (sans tz) mais s'attend à `DateTime`. | +| C11 | Moyen | SQLite enum via CHECK | `TEXT CHECK (col IN ('a','b'))` non détecté → généré comme `String`. | +| C12 | Moyen | Default value `Option` ambiguïté | Pour colonne `nullable + default`, l'utilisateur ne peut pas distinguer "écrire NULL" vs "utiliser default". | +| C13 | Moyen | Postgres array index OID | `udt_name` retourné par `information_schema` est parfois `_int4`, parfois `integer[]` selon la version. Le strip `_` ne couvre que le premier. | +| C14 | Moyen | MySQL `BOOLEAN` alias | `BOOLEAN` est alias de `TINYINT(1)` en MySQL. L'introspection voit `tinyint(1)` mais l'utilisateur a écrit `BOOLEAN`. Cohérent par hasard. | + +--- + +## Task 26 : Postgres enum lookup qualifié par schéma + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs:40-50` +- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs` (s'assurer que `ColumnInfo` porte `udt_schema`) + +**Pourquoi :** Finding C1. Aujourd'hui `schema_info.enums.iter().any(|e| e.name == udt_name)` ne filtre pas par schéma. Si `public.status` et `auth.status` coexistent, le mauvais enum est référencé. Vérifié à `typemap/postgres.rs:41`. + +- [ ] **Étape 1 : Test rouge** + +Dans `crates/sqlx_gen/src/typemap/postgres.rs` (module `tests`) : + +```rust +#[test] +fn enum_lookup_respects_schema() { + use crate::introspect::EnumInfo; + let schema = SchemaInfo { + enums: vec![ + EnumInfo { + schema_name: "public".to_string(), + name: "status".to_string(), + variants: vec!["a".into()], + default_variant: None, + }, + EnumInfo { + schema_name: "auth".to_string(), + name: "status".to_string(), + variants: vec!["x".into()], + default_variant: None, + }, + ], + ..Default::default() + }; + let col = crate::introspect::ColumnInfo { + name: "role".into(), + data_type: "USER-DEFINED".into(), + udt_name: "status".into(), + udt_schema: Some("auth".into()), + is_nullable: false, + is_primary_key: false, + ordinal_position: 0, + schema_name: "public".into(), + column_default: None, + is_generated: false, + is_identity_always: false, + }; + let rt = map_column_pg(&col, &schema, TimeCrate::Chrono); + assert!( + rt.needs_import.as_ref().unwrap().contains("auth"), + "must import from auth schema, got {:?}", rt.needs_import + ); +} +``` + +- [ ] **Étape 2 : Run** + +```bash +cargo test -p sqlx-gen --lib typemap::postgres::tests::enum_lookup_respects_schema +``` +Attendu : FAIL (le champ `udt_schema` n'existe pas encore). + +- [ ] **Étape 3 : Étendre `ColumnInfo`** + +Dans `crates/sqlx_gen/src/introspect/mod.rs` (struct `ColumnInfo`) ajouter : + +```rust +pub udt_schema: Option, +pub is_generated: bool, +pub is_identity_always: bool, +``` + +Dans `crates/sqlx_gen/src/introspect/postgres.rs`, la query `fetch_columns` ajoute : + +```sql +SELECT + c.column_name, + c.data_type, + c.udt_name, + c.udt_schema, -- nouveau + c.is_nullable, + c.column_default, + c.is_generated, -- nouveau (ALWAYS/NEVER) + c.is_identity, -- nouveau (YES/NO) + c.identity_generation -- nouveau (ALWAYS/BY DEFAULT) +FROM information_schema.columns c +WHERE c.table_schema = ANY($1) +ORDER BY c.table_schema, c.table_name, c.ordinal_position +``` + +Mapping : `is_generated = (is_generated == "ALWAYS")`, `is_identity_always = (is_identity == "YES" AND identity_generation == "ALWAYS")`. + +Pour MySQL/SQLite : laisser `udt_schema = None`, `is_generated = false`, `is_identity_always = false`. + +- [ ] **Étape 4 : Implémenter `map_column_pg`** + +Remplacer `map_type` par `map_column_pg(col, schema, time_crate)`. Dans la fonction : + +```rust +if let Some(ref udt_schema) = col.udt_schema { + if let Some(e) = schema_info.enums.iter() + .find(|e| e.name == col.udt_name && &e.schema_name == udt_schema) + { + let name = e.name.to_upper_camel_case(); + let import_path = if e.schema_name == "public" { + format!("use super::types::{};", name) + } else { + format!("use super::{}_types::{};", e.schema_name, name) + }; + return RustType::with_import(&name, &import_path); + } +} +// Fallback : ancien comportement (lookup non qualifié) pour MySQL inline-enum +``` + +Idem pour composite types. Garder `map_type(udt_name, ...)` en wrapper rétrocompatible. + +- [ ] **Étape 5 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --lib typemap +git add crates/sqlx_gen/src/introspect/ crates/sqlx_gen/src/typemap/postgres.rs +git commit -m "fix(typemap): qualify Postgres enum/composite lookup by schema" +``` + +--- + +## Task 27 : Postgres `_my_enum` (arrays of custom types) — émettre `PgHasArrayType` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:90-100` +- Modifier : `crates/sqlx_gen/src/codegen/composite_gen.rs:97-105` + +**Pourquoi :** Finding C2. `typemap/postgres.rs:35-38` produit `Vec` pour un type `_status`. Mais sqlx 0.8 exige `impl PgHasArrayType for Status` pour pouvoir décoder `_status`. Sans ça, runtime panic à la lecture : `unsupported type _status of column #N`. + +- [ ] **Étape 1 : Test rouge** + +Dans `crates/sqlx_gen/src/codegen/enum_gen.rs` (module `tests`) : + +```rust +#[test] +fn pg_enum_emits_pg_has_array_type_impl() { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, DatabaseKind::Postgres); + assert!( + code.contains("impl sqlx::postgres::PgHasArrayType for Status"), + "must impl PgHasArrayType so Vec works, got:\n{}", code + ); + assert!(code.contains("\"_status\"")); +} + +#[test] +fn mysql_enum_does_not_emit_pg_has_array_type_impl() { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, DatabaseKind::Mysql); + assert!(!code.contains("PgHasArrayType")); +} +``` + +- [ ] **Étape 2 : Run** + +```bash +cargo test -p sqlx-gen --lib codegen::enum_gen::tests::pg_enum_emits_pg_has_array_type_impl +``` +Attendu : FAIL. + +- [ ] **Étape 3 : Implémenter** + +Dans `enum_gen.rs` après le bloc `default_impl` : + +```rust +let array_type_impl = if db_kind == DatabaseKind::Postgres { + let array_type_name = if enum_info.schema_name != "public" { + format!("_{}.{}", enum_info.schema_name, enum_info.name) + } else { + format!("_{}", enum_info.name) + }; + quote! { + impl sqlx::postgres::PgHasArrayType for #enum_name { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + sqlx::postgres::PgTypeInfo::with_name(#array_type_name) + } + } + } +} else { + quote! {} +}; +``` + +Puis ajouter `#array_type_impl` dans le `quote! { ... }` final, après `#default_impl`. + +Reproduire la même logique dans `composite_gen.rs` (l'array d'un composite suit la même règle PG). + +- [ ] **Étape 4 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --lib codegen +git add crates/sqlx_gen/src/codegen/enum_gen.rs crates/sqlx_gen/src/codegen/composite_gen.rs +git commit -m "feat(codegen): emit PgHasArrayType impl for Postgres enums and composites" +``` + +--- + +## Task 28 : Postgres enum/composite `#[sqlx(type_name)]` — non qualifié ✅ APPLIQUÉ + +**Statut : appliqué le 2026-06-03 après bug confirmé en production.** + +**Reproducer confirmé (rapporté par @Cyrik le 30/04/2026) :** une enum en schéma non-`public` générait `#[sqlx(type_name = "agent.canal_type_enum")]` ; sqlx 0.8 plante à l'exécution car `PgTypeInfo::with_name` ne parse pas la qualification `schema.type`. La forme correcte est unqualified : `#[sqlx(type_name = "canal_type_enum")]`, avec `search_path` configuré côté pool pour résoudre le bon enum. + +**Fichiers modifiés :** + +- `crates/sqlx_gen/src/codegen/enum_gen.rs:42-50` — suppression de la branche `if schema != "public"` +- `crates/sqlx_gen/src/codegen/composite_gen.rs:48-52` — idem +- Tests `test_postgres_non_public_schema_qualified_type_name`, `test_named_schema_full_output`, `test_named_schema_with_default_variant`, `test_named_schema_variant_rename`, `test_non_public_schema_qualified_type_name` mis à jour pour asservir la **non-qualification** + assertion explicite `!contains("schema.type")`. + +**Suivi à prévoir :** + +- README : documenter clairement que le pool doit avoir `search_path` configuré avec tous les schémas portant des enums/composites utilisés. Snippet à ajouter : + +```rust +let pool = PgPoolOptions::new() + .after_connect(|conn, _meta| Box::pin(async move { + sqlx::query("SET search_path TO public, agent, auth") + .execute(conn).await?; + Ok(()) + })) + .connect(&url).await?; +``` + +- Émettre un doc-comment sur les enums en schéma non-public pour rappeler la contrainte (optionnel, futur). + +--- + +## Task 29 : Collision de variants enum après camelCase + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:53-71` + +**Pourquoi :** Finding C7. `["foo bar", "foo_bar"]` → deux `FooBar` → erreur de compilation. La fonction `generate_enum` ne détecte pas les collisions. + +- [ ] **Étape 1 : Test** + +```rust +#[test] +fn detects_variant_camelcase_collision() { + let e = EnumInfo { + schema_name: "public".into(), + name: "weird".into(), + variants: vec!["foo bar".into(), "foo_bar".into()], + default_variant: None, + }; + let result = generate_enum_checked(&e, DatabaseKind::Postgres, &[]); + assert!(result.is_err(), "must detect collision"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("FooBar"), "error must mention conflicting Rust ident"); +} +``` + +- [ ] **Étape 2 : Implémenter** + +Ajouter `generate_enum_checked` qui renvoie `Result`, et lever une erreur si : + +```rust +use std::collections::BTreeMap; +let mut seen: BTreeMap = BTreeMap::new(); +for v in &enum_info.variants { + let pascal = v.to_upper_camel_case(); + if let Some(prev) = seen.get(&pascal) { + return Err(crate::error::Error::Config(format!( + "Enum '{}': SQL variants '{}' and '{}' both map to Rust ident '{}'. \ + Rename in the database or use a custom mapping.", + enum_info.name, prev, v, pascal + ))); + } + seen.insert(pascal, v); +} +``` + +- [ ] **Étape 3 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --lib codegen::enum_gen +git commit -am "fix(codegen): detect enum variant collisions after camelCase" +``` + +--- + +## Task 30 : Postgres `GENERATED` & `IDENTITY ALWAYS` exclus des INSERT/UPDATE + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs` (query déjà étendue Task 26) +- Modifier : `crates/sqlx_gen/src/codegen/crud_gen.rs:246-269` + +**Pourquoi :** Finding C5/C6. Une colonne `total INTEGER GENERATED ALWAYS AS (qty * price) STORED` rejette tout INSERT/UPDATE qui la mentionne — erreur `42601`. De même `id INT GENERATED ALWAYS AS IDENTITY` : ne tolère pas `OVERRIDING SYSTEM VALUE`. Il faut les exclure des params. + +- [ ] **Étape 1 : Test E2E** + +Dans `crates/sqlx_gen/tests/e2e_postgres.rs` : + +```rust +#[tokio::test] +async fn generated_column_excluded_from_insert() { + let (_c, pool) = setup().await; + sqlx::query(r#" + CREATE TABLE invoices ( + id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + qty INT NOT NULL, + price INT NOT NULL, + total INT GENERATED ALWAYS AS (qty * price) STORED + ) + "#).execute(&pool).await.unwrap(); + let info = introspect(&pool, &["public".into()], false).await.unwrap(); + let cols = &info.tables[0].columns; + let total = cols.iter().find(|c| c.name == "total").unwrap(); + assert!(total.is_generated, "must detect GENERATED column"); + let id = cols.iter().find(|c| c.name == "id").unwrap(); + assert!(id.is_identity_always, "must detect IDENTITY ALWAYS"); + + let files = sqlx_gen::codegen::generate( + &info, sqlx_gen::cli::DatabaseKind::Postgres, + &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, + ).unwrap(); + // Find the InsertInvoicesParams in the generated code + let inv = files.iter().find(|f| f.filename.contains("invoice")).unwrap(); + assert!(!inv.code.contains("total:"), "InsertParams must not include 'total'"); + assert!(!inv.code.contains("pub id:") || inv.code.contains("Option"), + "IDENTITY ALWAYS must be excluded or optional"); +} +``` + +- [ ] **Étape 2 : Implémenter** + +Dans `crud_gen.rs` (constructeur des `non_pk_fields` autour de la ligne 62-63), filtrer aussi : + +```rust +let non_pk_fields: Vec<&ParsedField> = entity.fields.iter() + .filter(|f| !f.is_primary_key) + .filter(|f| !f.is_generated) // exclu des INSERT/UPDATE + .filter(|f| !f.is_identity_always) // exclu des INSERT + .collect(); +``` + +Il faut donc ajouter `is_generated: bool` et `is_identity_always: bool` à `ParsedField` (`entity_parser.rs`), et les sérialiser dans l'annotation `#[sqlx_gen(...)]` lors de la génération des structs entité (Task 1 / `struct_gen.rs`). + +- [ ] **Étape 3 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --test e2e_postgres +git add crates/sqlx_gen/src/ +git commit -m "fix(codegen): exclude generated/identity-always columns from INSERT/UPDATE" +``` + +--- + +## Task 31 : MySQL inline ENUM — `#[sqlx(rename_all = ...)]` ou utilisation `String` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:11-103` + +**Pourquoi :** Finding C8. Pour MySQL, sqlx 0.8 lit/écrit les `ENUM` comme `String` par défaut. Pour qu'un `derive(sqlx::Type)` fonctionne, il faut soit (a) `#[repr(u32)]` + `#[sqlx(repr = ...)]` (basé sur index), soit (b) implémenter manuellement `Encode`/`Decode` qui lit la valeur en `&str`. La voie la plus simple : annoter le champ entité avec `#[sqlx(try_from = "String")]` ou utiliser `#[derive(sqlx::Type)] #[sqlx(rename_all = "lowercase")]`. + +- [ ] **Étape 1 : Test E2E MySQL** + +```rust +#[tokio::test] +async fn mysql_inline_enum_round_trips() { + let (_c, pool, db) = setup().await; + sqlx::query(r#" + CREATE TABLE t ( + id INT PRIMARY KEY AUTO_INCREMENT, + status ENUM('active', 'inactive') NOT NULL + ) + "#).execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO t (status) VALUES (?)") + .bind("active").execute(&pool).await.unwrap(); + + let info = introspect(&pool, &[db], false).await.unwrap(); + let files = sqlx_gen::codegen::generate( + &info, sqlx_gen::cli::DatabaseKind::Mysql, + &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, + ).unwrap(); + let code = files.iter().map(|f| f.code.as_str()).collect::>().join("\n"); + // Must use rename_all so 'active' / 'inactive' encode/decode correctly + assert!( + code.contains("rename_all") || code.contains("rename = \"active\""), + "MySQL inline enum codegen must wire up SQL ↔ Rust variant mapping" + ); +} +``` + +- [ ] **Étape 2 : Implémenter** + +Pour `DatabaseKind::Mysql` dans `enum_gen.rs`, après les variants, vérifier si tous les variants sont lowercase ASCII — auquel cas émettre : + +```rust +quote! { #[sqlx(rename_all = "lowercase")] } +``` + +et omettre les `#[sqlx(rename = "...")]` individuels. Sinon, garder les renames explicites par variant. + +- [ ] **Étape 3 : Vérifier + commit** + +```bash +git commit -am "fix(codegen): wire MySQL inline ENUM variants via rename_all/rename" +``` + +--- + +## Task 32 : Domain en newtype optionnel via `--domains-as-newtype` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/cli.rs` (ajouter flag) +- Modifier : `crates/sqlx_gen/src/codegen/domain_gen.rs:43-53` + +**Pourquoi :** Finding C9. `pub type Email = String;` est un alias transparent. L'utilisateur perd la sécurité de type. Offrir un mode newtype optionnel : `pub struct Email(pub String);` + `#[sqlx(transparent)]`. + +- [ ] **Étape 1 : Ajouter le flag CLI** + +Dans `EntitiesArgs` : + +```rust +/// Generate domains as newtype structs (`pub struct Email(String)`) instead of type aliases. +#[arg(long)] +pub domains_as_newtype: bool, +``` + +- [ ] **Étape 2 : Test** + +```rust +#[test] +fn domain_as_newtype_uses_transparent_derive() { + let d = make_domain("email", "text"); + let schema = SchemaInfo::default(); + let (tokens, _) = generate_domain_with_style( + &d, DatabaseKind::Postgres, &schema, + &HashMap::new(), TimeCrate::Chrono, + DomainStyle::Newtype, + ); + let code = parse_and_format(&tokens).unwrap(); + assert!(code.contains("pub struct Email")); + assert!(code.contains("#[sqlx(transparent)]")); + assert!(code.contains("pub String")); +} +``` + +- [ ] **Étape 3 : Implémenter** + +```rust +pub enum DomainStyle { Alias, Newtype } + +pub fn generate_domain_with_style( + domain: &DomainInfo, + db_kind: DatabaseKind, + schema_info: &SchemaInfo, + type_overrides: &HashMap, + time_crate: TimeCrate, + style: DomainStyle, +) -> (TokenStream, BTreeSet) { + // ... existing setup + let tokens = match style { + DomainStyle::Alias => quote! { + #[doc = #doc] + pub type #alias_name = #type_tokens; + }, + DomainStyle::Newtype => quote! { + #[doc = #doc] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] + #[sqlx(transparent)] + pub struct #alias_name(pub #type_tokens); + }, + }; + (tokens, imports) +} +``` + +- [ ] **Étape 4 : Vérifier + commit** + +```bash +git commit -am "feat(codegen): add --domains-as-newtype for type-safe domain wrappers" +``` + +--- + +## Task 33 : SQLite enum via `CHECK (col IN (...))` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/introspect/sqlite.rs` +- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs` (le SQLite branch) + +**Pourquoi :** Finding C11. SQLite n'a pas d'`ENUM` natif. Convention courante : `TEXT CHECK (col IN ('a','b','c'))`. Détecter ce pattern via `sqlite_master.sql` (DDL stocké) et émettre un enum Rust. + +- [ ] **Étape 1 : Test E2E SQLite** + +Dans `crates/sqlx_gen/tests/introspect_sqlite.rs` : + +```rust +#[tokio::test] +async fn detects_check_enum_pattern() { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::query(r#" + CREATE TABLE t ( + id INTEGER PRIMARY KEY, + status TEXT CHECK (status IN ('active', 'inactive')) NOT NULL + ) + "#).execute(&pool).await.unwrap(); + let info = introspect(&pool, false).await.unwrap(); + assert!(!info.enums.is_empty(), "should detect implicit enum"); + assert_eq!(info.enums[0].variants, vec!["active", "inactive"]); +} +``` + +- [ ] **Étape 2 : Implémenter le parser** + +Dans `introspect/sqlite.rs`, ajouter `extract_check_enums(&sql_ddl)` qui regex-trouve `CHECK\s*\(\s*(\w+)\s+IN\s*\(\s*(.+?)\s*\)\s*\)` et extrait les variants entre apostrophes. Le résultat alimente `SchemaInfo.enums` avec `schema_name="main"`. + +Pour la `column.udt_name`, remplacer `"TEXT"` par le nom de l'enum déduit (e.g. `status_enum`). + +- [ ] **Étape 3 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --test introspect_sqlite +git commit -am "feat(introspect-sqlite): detect TEXT CHECK IN (...) enum pattern" +``` + +--- + +## Task 34 : Cohérence array : `_x` + `x[]` + `ARRAY[x]` + +**Fichiers :** + +- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs:33-38` + +**Pourquoi :** Finding C13. `information_schema.columns.udt_name` retourne `_int4` ; `data_type` retourne `ARRAY` ; `pg_catalog.format_type` retourne `integer[]`. Le code actuel ne gère que `_int4`. Robustifier. + +- [ ] **Étape 1 : Tests** + +```rust +#[test] +fn array_underscore_prefix() { + assert_eq!(map_type("_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec"); +} +#[test] +fn array_bracket_suffix() { + assert_eq!(map_type("integer[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); +} +#[test] +fn array_double() { + assert_eq!(map_type("_int4_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec>"); +} +``` + +- [ ] **Étape 2 : Implémenter** + +```rust +pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) -> RustType { + if let Some(inner) = udt_name.strip_prefix('_') { + return map_type(inner, schema_info, time_crate).wrap_vec(); + } + if let Some(inner) = udt_name.strip_suffix("[]") { + return map_type(inner.trim(), schema_info, time_crate).wrap_vec(); + } + // ... reste inchangé +} +``` + +- [ ] **Étape 3 : Vérifier + commit** + +```bash +cargo test -p sqlx-gen --lib typemap::postgres +git commit -am "fix(typemap): normalize array notations (_x and x[])" +``` + +--- + +## Task 35 : Snapshot E2E "build du code généré" + +**Fichiers :** + +- Créer : `crates/sqlx_gen/tests/compile_check.rs` + +**Pourquoi :** Aucun test ne vérifie que le code généré **compile dans un projet utilisateur**. C'est la conformité ultime : si le code passe `cargo build`, alors les attributs sqlx sont bien formés. + +- [ ] **Étape 1 : Squelette** + +```rust +use std::process::Command; + +fn write_minimal_consumer(out_dir: &std::path::Path, generated_code: &str) { + std::fs::write(out_dir.join("Cargo.toml"), r#" +[package] +name = "sqlx_gen_compile_test" +version = "0.0.0" +edition = "2021" + +[dependencies] +sqlx = { version = "0.8", features = ["postgres", "uuid", "chrono", "json"] } +sqlx_gen = { path = "../../../../crates/sqlx_gen" } +serde = { version = "1", features = ["derive"] } +chrono = "0.4" +uuid = "1" +serde_json = "1" +"#).unwrap(); + std::fs::create_dir_all(out_dir.join("src")).unwrap(); + std::fs::write(out_dir.join("src/lib.rs"), generated_code).unwrap(); +} + +#[test] +fn generated_postgres_table_compiles() { + use sqlx_gen::codegen::{generate, GeneratedFile}; + use sqlx_gen::introspect::*; + // build a small SchemaInfo by hand with enum + composite + view + array column + // ... (~50 lines) + let files = generate(&info, sqlx_gen::cli::DatabaseKind::Postgres, + &[], &Default::default(), true /* single file */, sqlx_gen::cli::TimeCrate::Chrono).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let code = files[0].code.clone(); + write_minimal_consumer(dir.path(), &code); + let status = Command::new("cargo").arg("build").current_dir(dir.path()).status().unwrap(); + assert!(status.success(), "generated code must compile"); +} +``` + +- [ ] **Étape 2 : Commit** + +```bash +git add crates/sqlx_gen/tests/compile_check.rs +git commit -m "test(compile): verify generated code compiles in a downstream crate" +``` + +--- + +## Synthèse de la vague conformité + +Tasks 26–35 ferment les écarts SQL ↔ Rust les plus dangereux : + +| Couvre finding | Task | +|----------------|------| +| C1 (lookup enum non qualifié) | 26 | +| C2 (PgHasArrayType manquant) | 27 | +| C3 (type_name schema-qualifié) | 28 | +| C4 (import path super::types) | 26 (indirect, via path schema-aware) | +| C5 (generated columns) | 30 | +| C6 (identity always) | 30 | +| C7 (variant collisions) | 29 | +| C8 (MySQL inline ENUM) | 31 | +| C9 (domain newtype) | 32 | +| C10 (TS vs TSTZ) | À documenter dans le README (pas de task séparée — trivial) | +| C11 (SQLite CHECK enum) | 33 | +| C12 (default vs nullable ambiguïté) | Couvert par doc en Task 18 | +| C13 (array notations) | 34 | +| C14 (MySQL BOOLEAN alias) | Couvert par Task 13 (BIT(1)→bool) | + +Le **compile-check Task 35** est le filet de sécurité final : toute régression d'attribut ou d'import est détectée par `cargo build` sur un crate consommateur. + +--- + +## Self-review (effectuée par le rédacteur) + +**Couverture spec :** Les 12 findings P0 sont couverts par les Tasks 1–10 ; les 9 findings High P1 par Tasks 11–15 ; le P2 par Tasks 16–20 ; le P3 par Tasks 21–25 ; les 14 findings de conformité SQL ↔ Rust (C1–C14) par Tasks 26–35. + +**Placeholders :** Tasks 16–25 sont volontairement plus haut-niveau (1 paragraphe chacune) car le pattern TDD est répétitif et chaque task isolément ne nécessite pas 5 étapes détaillées. Si l'engineer demande, dérouler à la demande. + +**Cohérence des types :** `quote_ident`/`quote_qualified` utilisés systématiquement après Task 1. `write_atomic` ajouté Task 7, réutilisé Task 8. `parse_and_format_with_tab_spaces` renvoie `Result` après Task 5 — tous les call-sites mis à jour dans le même commit. + +**Gaps connus :** Le finding codegen #19 (forced `query_scalar` sur `LAST_INSERT_ID`) est traité indirectement par Task 17. Le finding error #11 (cancellation tokio) n'est pas traité — coût élevé, gain faible ; documenter dans CONTRIBUTING. + +--- + +## Handoff d'exécution + +**Plan complet sauvegardé dans `docs/superpowers/plans/2026-06-03-rust-engineering-audit.md`. Deux options d'exécution :** + +**1. Subagent-Driven (recommandé)** — un sous-agent frais par task, review entre chaque, itération rapide. Idéal pour P0 où chaque correction doit être validée avant la suivante. + +**2. Inline Execution** — exécution des tasks dans cette session via `superpowers:executing-plans`, batch avec checkpoints. + +**Quelle approche ?** From d67eb39e21861c7bdb22d5eaff8d5dd45d0d1f51 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:05:45 +0200 Subject: [PATCH 03/38] feat: add SQL identifier quoting module Introduces codegen::identifiers with quote_ident, quote_qualified, and is_safe_ident helpers. Foundation for preventing SQL injection in generated CRUD code by quoting table/column/schema names per dialect (backticks for MySQL, double quotes for Postgres/SQLite). --- crates/sqlx_gen/src/codegen/identifiers.rs | 116 +++++++++++++++++++++ crates/sqlx_gen/src/codegen/mod.rs | 1 + 2 files changed, 117 insertions(+) create mode 100644 crates/sqlx_gen/src/codegen/identifiers.rs diff --git a/crates/sqlx_gen/src/codegen/identifiers.rs b/crates/sqlx_gen/src/codegen/identifiers.rs new file mode 100644 index 0000000..67a4834 --- /dev/null +++ b/crates/sqlx_gen/src/codegen/identifiers.rs @@ -0,0 +1,116 @@ +use crate::cli::DatabaseKind; + +/// Quote a SQL identifier (table/column/schema) per database dialect. +/// Doubles any internal quote characters for safety. +pub fn quote_ident(name: &str, db: DatabaseKind) -> String { + match db { + DatabaseKind::Mysql => format!("`{}`", name.replace('`', "``")), + DatabaseKind::Postgres | DatabaseKind::Sqlite => { + format!("\"{}\"", name.replace('"', "\"\"")) + } + } +} + +/// Quote a qualified table reference (`schema.table`) per dialect, or the +/// bare table when no schema is provided. +pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> String { + match schema { + Some(s) => format!("{}.{}", quote_ident(s, db), quote_ident(table, db)), + None => quote_ident(table, db), + } +} + +/// True if `name` is a safe SQL identifier candidate (alphanumeric + underscore, +/// non-empty, does not start with a digit). +pub fn is_safe_ident(name: &str) -> bool { + !name.is_empty() + && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + && !name.starts_with(|c: char| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quotes_postgres_double_quote() { + assert_eq!(quote_ident("users", DatabaseKind::Postgres), "\"users\""); + } + + #[test] + fn quotes_sqlite_double_quote() { + assert_eq!(quote_ident("users", DatabaseKind::Sqlite), "\"users\""); + } + + #[test] + fn quotes_mysql_backtick() { + assert_eq!(quote_ident("users", DatabaseKind::Mysql), "`users`"); + } + + #[test] + fn escapes_postgres_internal_quote() { + assert_eq!( + quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres), + "\"user\"\"; DROP TABLE x; --\"" + ); + } + + #[test] + fn escapes_mysql_internal_backtick() { + assert_eq!(quote_ident("ev`il", DatabaseKind::Mysql), "`ev``il`"); + } + + #[test] + fn qualified_with_schema_postgres() { + assert_eq!( + quote_qualified(Some("auth"), "users", DatabaseKind::Postgres), + "\"auth\".\"users\"" + ); + } + + #[test] + fn qualified_with_schema_mysql() { + assert_eq!( + quote_qualified(Some("app"), "users", DatabaseKind::Mysql), + "`app`.`users`" + ); + } + + #[test] + fn qualified_without_schema() { + assert_eq!( + quote_qualified(None, "users", DatabaseKind::Mysql), + "`users`" + ); + } + + #[test] + fn safe_ident_rejects_dash() { + assert!(!is_safe_ident("user-id")); + } + + #[test] + fn safe_ident_rejects_leading_digit() { + assert!(!is_safe_ident("123abc")); + } + + #[test] + fn safe_ident_rejects_empty() { + assert!(!is_safe_ident("")); + } + + #[test] + fn safe_ident_rejects_space() { + assert!(!is_safe_ident("user id")); + } + + #[test] + fn safe_ident_accepts_underscore_prefix() { + assert!(is_safe_ident("_private")); + } + + #[test] + fn safe_ident_accepts_mixed_case() { + assert!(is_safe_ident("UserAccount2")); + } +} diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index 193a58e..02a9c12 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -3,6 +3,7 @@ pub mod crud_gen; pub mod domain_gen; pub mod entity_parser; pub mod enum_gen; +pub mod identifiers; pub mod struct_gen; use std::collections::{BTreeSet, HashMap}; From 47426007a74b43b29cc67850b3c54624821d73a9 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:17:26 +0200 Subject: [PATCH 04/38] fix: quote SQL identifiers per dialect in generated CRUD code Every table, schema, and column name interpolated into generated SQL strings now goes through identifiers::quote_ident / quote_qualified. Prevents SQL injection when DB metadata contains quote characters or reserved words, and produces correct SQL for identifiers that would otherwise be ambiguous (e.g. columns named "select"). Updated 13 existing tests whose substring assertions encoded the prior unquoted output. Fixed the junction_entity test fixture to split schema/table properly instead of relying on a dotted table_name. --- .DS_Store | Bin 0 -> 6148 bytes crates/.DS_Store | Bin 0 -> 6148 bytes crates/sqlx_gen/src/codegen/crud_gen.rs | 78 +++++++++++++----------- 3 files changed, 42 insertions(+), 36 deletions(-) create mode 100644 .DS_Store create mode 100644 crates/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..a12536aa64c3838495cf9f59a301ee3054da3b15 GIT binary patch literal 6148 zcmeHKy-EW?5S}%s1_eoBA!2b)Af&SN3@0{r!Cv!25=gk9Nnv-rjc;J7f}MqkrJxTX zK7@@{g5S)p$*q@wl}MO@-EVJxZsxw@c5aEtRECW*QHh9(7>vOZrU}A!9yytF8V)vc zk0CXv*=?1p^(5!53S~eU_-_pGx4TH2RHrT-QRe&Y^=+xE&|7L*_o&N~U{x^~ioq3wSHXcq;>H(ur5i{KY-0 z@$A`JuJ7t^>6O&-!)MING#{^yg>&XbWi}Z=&t@quThv(@PzIEN5d*wGgcyvG!_=Z| z9hl?@01TiO!CHDaFk}TVa+q3#24ZX|(1sd!#4t7-`q24B4pWOZoQ!0~eOzYaPAEpQ zLm!GenaHBf%78N9Gf=RXCGP*b)6f5YkX|VR%D_x9V1l?BSMW%#x8@#>du@nuh{3{s msYMCF2`b zfHANw46tU4B>Rds8Ux0FF;Fug-v>_>Oe0o`;nTq(v;f2w%|SSqUP5Ag#57{1$Q6py zP@;yq*kU*hr`<=tG-9Qw;c&6}a51xs9f}Ltd4C_=;Zj8#jR9j|o`I%bwx#}`{oeo2 z2icV|U<~{#23#x4vmTEWYwObCq}F=sB~?WHD#bd4lTeE3E2a358iaN)6Ji>%Qly1q OKLUXU8;pTJW#9{?M_y?F literal 0 HcmV?d00001 diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 1623e53..f01eb38 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -5,6 +5,7 @@ use quote::{format_ident, quote}; use crate::cli::{DatabaseKind, Methods, PoolVisibility}; use crate::codegen::entity_parser::{ParsedEntity, ParsedField}; +use crate::codegen::identifiers::{quote_ident, quote_qualified}; pub fn generate_crud_from_parsed( entity: &ParsedEntity, @@ -20,10 +21,11 @@ pub fn generate_crud_from_parsed( let repo_name = format!("{}Repository", entity.struct_name); let repo_ident = format_ident!("{}", repo_name); - let table_name = match &entity.schema_name { - Some(schema) => format!("{}.{}", schema, entity.table_name), - None => entity.table_name.clone(), - }; + let table_name = quote_qualified( + entity.schema_name.as_deref(), + &entity.table_name, + db_kind, + ); // Pool type (used via full path sqlx::PgPool etc., no import needed) let pool_type = pool_type_tokens(db_kind); @@ -268,9 +270,9 @@ pub fn generate_crud_from_parsed( }) .collect(); - let col_names: Vec<&str> = insert_source_fields + let col_names: Vec = insert_source_fields .iter() - .map(|f| f.column_name.as_str()) + .map(|f| quote_ident(&f.column_name, db_kind)) .collect(); let col_list = col_names.join(", "); @@ -355,9 +357,9 @@ pub fn generate_crud_from_parsed( non_pk_fields.clone() }; - let col_names: Vec<&str> = insert_source_fields + let col_names: Vec = insert_source_fields .iter() - .map(|f| f.column_name.as_str()) + .map(|f| quote_ident(&f.column_name, db_kind)) .collect(); let col_list = col_names.join(", "); let num_cols = insert_source_fields.len(); @@ -437,7 +439,7 @@ pub fn generate_crud_from_parsed( .enumerate() .map(|(i, f)| { let p = placeholder(db_kind, i + 1); - format!("{} = {}", f.column_name, p) + format!("{} = {}", quote_ident(&f.column_name, db_kind), p) }) .collect(); let set_clause = set_cols.join(",\n "); @@ -447,7 +449,7 @@ pub fn generate_crud_from_parsed( .enumerate() .map(|(i, f)| { let p = placeholder_with_cast(db_kind, i + 1, f); - format!("{} = {}", f.column_name, p) + format!("{} = {}", quote_ident(&f.column_name, db_kind), p) }) .collect(); let set_clause_cast = set_cols_cast.join(",\n "); @@ -467,6 +469,8 @@ pub fn generate_crud_from_parsed( format!("UPDATE {}\nSET\n {}\nWHERE {}", table_name, sc, wc) } }; + + let sql = raw_sql_lit(&build_overwrite_sql(&set_clause, &where_clause)); let sql_macro = raw_sql_lit(&build_overwrite_sql(&set_clause_cast, &where_clause_cast)); @@ -615,7 +619,8 @@ pub fn generate_crud_from_parsed( .enumerate() .map(|(i, f)| { let p = placeholder(db_kind, i + 1); - format!("{col} = COALESCE({p}, {col})", col = f.column_name, p = p) + let col = quote_ident(&f.column_name, db_kind); + format!("{col} = COALESCE({p}, {col})", col = col, p = p) }) .collect(); let set_clause = set_cols.join(",\n "); @@ -626,7 +631,8 @@ pub fn generate_crud_from_parsed( .enumerate() .map(|(i, f)| { let p = placeholder_with_cast(db_kind, i + 1, f); - format!("{col} = COALESCE({p}, {col})", col = f.column_name, p = p) + let col = quote_ident(&f.column_name, db_kind); + format!("{col} = COALESCE({p}, {col})", col = col, p = p) }) .collect(); let set_clause_cast = set_cols_cast.join(",\n "); @@ -887,7 +893,7 @@ fn build_where_clause_parsed( .enumerate() .map(|(i, f)| { let p = placeholder(db_kind, start_index + i); - format!("{} = {}", f.column_name, p) + format!("{} = {}", quote_ident(&f.column_name, db_kind), p) }) .collect::>() .join(" AND ") @@ -918,7 +924,7 @@ fn build_where_clause_cast( .enumerate() .map(|(i, f)| { let p = placeholder_with_cast(db_kind, start_index + i, f); - format!("{} = {}", f.column_name, p) + format!("{} = {}", quote_ident(&f.column_name, db_kind), p) }) .collect::>() .join(" AND ") @@ -1406,7 +1412,7 @@ mod tests { #[test] fn test_get_all_sql() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("SELECT * FROM users")); + assert!(code.contains("SELECT * FROM \"users\"")); } // --- paginate --- @@ -1458,7 +1464,7 @@ mod tests { #[test] fn test_paginate_count_sql() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("SELECT COUNT(*) FROM users")); + assert!(code.contains("SELECT COUNT(*) FROM \"users\"")); } #[test] @@ -1490,13 +1496,13 @@ mod tests { #[test] fn test_get_where_pk_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("WHERE id = $1")); + assert!(code.contains("WHERE \"id\" = $1")); } #[test] fn test_get_where_pk_mysql() { let code = gen(&standard_entity(), DatabaseKind::Mysql); - assert!(code.contains("WHERE id = ?")); + assert!(code.contains("WHERE `id` = ?")); } // --- insert --- @@ -1716,12 +1722,12 @@ mod tests { fn test_update_set_clause_uses_coalesce_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); assert!( - code.contains("COALESCE($1, name)"), + code.contains("COALESCE($1, \"name\")"), "Expected COALESCE for name:\n{}", code ); assert!( - code.contains("COALESCE($2, email)"), + code.contains("COALESCE($2, \"email\")"), "Expected COALESCE for email:\n{}", code ); @@ -1730,7 +1736,7 @@ mod tests { #[test] fn test_update_where_clause_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("WHERE id = $3")); + assert!(code.contains("WHERE \"id\" = $3")); } #[test] @@ -1744,12 +1750,12 @@ mod tests { fn test_update_set_clause_mysql() { let code = gen(&standard_entity(), DatabaseKind::Mysql); assert!( - code.contains("COALESCE(?, name)"), + code.contains("COALESCE(?, `name`)"), "Expected COALESCE for MySQL:\n{}", code ); assert!( - code.contains("COALESCE(?, email)"), + code.contains("COALESCE(?, `email`)"), "Expected COALESCE for email in MySQL:\n{}", code ); @@ -1759,7 +1765,7 @@ mod tests { fn test_update_set_clause_sqlite() { let code = gen(&standard_entity(), DatabaseKind::Sqlite); assert!( - code.contains("COALESCE(?, name)"), + code.contains("COALESCE(?, \"name\")"), "Expected COALESCE for SQLite:\n{}", code ); @@ -1825,9 +1831,9 @@ mod tests { #[test] fn test_overwrite_set_clause_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("name = $1,")); - assert!(code.contains("email = $2")); - assert!(code.contains("WHERE id = $3")); + assert!(code.contains("\"name\" = $1,")); + assert!(code.contains("\"email\" = $2")); + assert!(code.contains("WHERE \"id\" = $3")); } #[test] @@ -1892,8 +1898,8 @@ mod tests { #[test] fn test_delete_where_pk() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("DELETE FROM users")); - assert!(code.contains("WHERE id = $1")); + assert!(code.contains("DELETE FROM \"users\"")); + assert!(code.contains("WHERE \"id\" = $1")); } #[test] @@ -2625,8 +2631,8 @@ mod tests { fn junction_entity() -> ParsedEntity { ParsedEntity { struct_name: "AnalysisRecord".to_string(), - table_name: "analysis.analysis__record".to_string(), - schema_name: None, + table_name: "analysis__record".to_string(), + schema_name: Some("analysis".to_string()), is_view: false, fields: vec![ make_field("record_id", "record_id", "uuid::Uuid", false, true), @@ -2655,8 +2661,8 @@ mod tests { code ); assert!( - code.contains("INSERT INTO analysis.analysis__record (record_id, analysis_id)"), - "Expected INSERT INTO clause:\n{}", + code.contains("INSERT INTO \"analysis\".\"analysis__record\" (\"record_id\", \"analysis_id\")"), + "Expected quoted INSERT INTO clause:\n{}", code ); assert!( @@ -2700,12 +2706,12 @@ mod tests { code ); assert!( - code.contains("DELETE FROM analysis.analysis__record"), + code.contains("DELETE FROM \"analysis\".\"analysis__record\""), "Expected DELETE clause:\n{}", code ); assert!( - code.contains("WHERE record_id = $1 AND analysis_id = $2"), + code.contains("WHERE \"record_id\" = $1 AND \"analysis_id\" = $2"), "Expected WHERE clause:\n{}", code ); @@ -2720,7 +2726,7 @@ mod tests { code ); assert!( - code.contains("WHERE record_id = $1 AND analysis_id = $2"), + code.contains("WHERE \"record_id\" = $1 AND \"analysis_id\" = $2"), "Expected WHERE clause with both PK columns:\n{}", code ); From 5ca1a0728b03c9f4977453117b5d42ce2f1ab174 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:17:35 +0200 Subject: [PATCH 05/38] chore: ignore macOS .DS_Store files --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 4 +++- crates/.DS_Store | Bin 6148 -> 0 bytes 3 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 .DS_Store delete mode 100644 crates/.DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index a12536aa64c3838495cf9f59a301ee3054da3b15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKy-EW?5S}%s1_eoBA!2b)Af&SN3@0{r!Cv!25=gk9Nnv-rjc;J7f}MqkrJxTX zK7@@{g5S)p$*q@wl}MO@-EVJxZsxw@c5aEtRECW*QHh9(7>vOZrU}A!9yytF8V)vc zk0CXv*=?1p^(5!53S~eU_-_pGx4TH2RHrT-QRe&Y^=+xE&|7L*_o&N~U{x^~ioq3wSHXcq;>H(ur5i{KY-0 z@$A`JuJ7t^>6O&-!)MING#{^yg>&XbWi}Z=&t@quThv(@PzIEN5d*wGgcyvG!_=Z| z9hl?@01TiO!CHDaFk}TVa+q3#24ZX|(1sd!#4t7-`q24B4pWOZoQ!0~eOzYaPAEpQ zLm!GenaHBf%78N9Gf=RXCGP*b)6f5YkX|VR%D_x9V1l?BSMW%#x8@#>du@nuh{3{s msYMCF2`b zfHANw46tU4B>Rds8Ux0FF;Fug-v>_>Oe0o`;nTq(v;f2w%|SSqUP5Ag#57{1$Q6py zP@;yq*kU*hr`<=tG-9Qw;c&6}a51xs9f}Ltd4C_=;Zj8#jR9j|o`I%bwx#}`{oeo2 z2icV|U<~{#23#x4vmTEWYwObCq}F=sB~?WHD#bd4lTeE3E2a358iaN)6Ji>%Qly1q OKLUXU8;pTJW#9{?M_y?F From d390391cd5132385e30056f2c0b6e1f99433edd2 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:18:46 +0200 Subject: [PATCH 06/38] fix: validate --type-overrides values via syn::parse_str Each Rust type passed via --type-overrides is now parsed by syn::parse_str:: before being injected into generated code. Rejects empty keys/values, missing '=', and strings that aren't a single valid Rust type. Closes the code-injection path where "--type-overrides jsonb=Vec; fn pwned() {}" would have been emitted verbatim into the output. --- crates/sqlx_gen/src/cli.rs | 88 +++++++++++++++++++++++++++++++++++++ crates/sqlx_gen/src/main.rs | 2 +- 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/crates/sqlx_gen/src/cli.rs b/crates/sqlx_gen/src/cli.rs index 18e322f..ddedefa 100644 --- a/crates/sqlx_gen/src/cli.rs +++ b/crates/sqlx_gen/src/cli.rs @@ -106,6 +106,41 @@ impl EntitiesArgs { }) .collect() } + + /// Parse and validate `--type-overrides`. Each value must be a syntactically + /// valid Rust type (parseable by `syn::parse_str::`). Prevents + /// injection of arbitrary Rust into generated code. + pub fn parse_type_overrides_checked(&self) -> crate::error::Result> { + let mut map = HashMap::new(); + for s in &self.type_overrides { + let (k, v) = s.split_once('=').ok_or_else(|| { + crate::error::Error::Config(format!( + "Invalid --type-overrides entry '{}'. Expected format: sql_type=RustType", + s + )) + })?; + if k.is_empty() { + return Err(crate::error::Error::Config(format!( + "Empty SQL type key in --type-overrides entry '{}'", + s + ))); + } + if v.trim().is_empty() { + return Err(crate::error::Error::Config(format!( + "Empty Rust type value in --type-overrides entry '{}'", + s + ))); + } + syn::parse_str::(v).map_err(|e| { + crate::error::Error::Config(format!( + "Invalid Rust type in --type-overrides value '{}': {}", + v, e + )) + })?; + map.insert(k.to_string(), v.to_string()); + } + Ok(map) + } } #[derive(Parser, Debug)] @@ -419,6 +454,59 @@ mod tests { assert!(args.parse_type_overrides().is_empty()); } + // ========== parse_type_overrides_checked ========== + + #[test] + fn test_overrides_checked_empty_ok() { + let args = make_entities_args_with_overrides(vec![]); + assert!(args.parse_type_overrides_checked().unwrap().is_empty()); + } + + #[test] + fn test_overrides_checked_simple_type() { + let args = make_entities_args_with_overrides(vec!["jsonb=MyJson"]); + let map = args.parse_type_overrides_checked().unwrap(); + assert_eq!(map.get("jsonb").unwrap(), "MyJson"); + } + + #[test] + fn test_overrides_checked_path_type() { + let args = make_entities_args_with_overrides(vec!["jsonb=crate::types::MyJson"]); + let map = args.parse_type_overrides_checked().unwrap(); + assert_eq!(map.get("jsonb").unwrap(), "crate::types::MyJson"); + } + + #[test] + fn test_overrides_checked_generic_type() { + let args = make_entities_args_with_overrides(vec!["bytea=Vec"]); + assert!(args.parse_type_overrides_checked().is_ok()); + } + + #[test] + fn test_overrides_checked_rejects_injection() { + let args = make_entities_args_with_overrides(vec!["jsonb=Vec; fn pwned() {}"]); + let result = args.parse_type_overrides_checked(); + assert!(result.is_err(), "must reject value that isn't a single Rust type"); + } + + #[test] + fn test_overrides_checked_rejects_no_equals() { + let args = make_entities_args_with_overrides(vec!["noequals"]); + assert!(args.parse_type_overrides_checked().is_err()); + } + + #[test] + fn test_overrides_checked_rejects_empty_value() { + let args = make_entities_args_with_overrides(vec!["jsonb="]); + assert!(args.parse_type_overrides_checked().is_err()); + } + + #[test] + fn test_overrides_checked_rejects_empty_key() { + let args = make_entities_args_with_overrides(vec!["=Foo"]); + assert!(args.parse_type_overrides_checked().is_err()); + } + #[test] fn test_overrides_single() { let args = make_entities_args_with_overrides(vec!["jsonb=MyJson"]); diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 9ccc74f..7275fdf 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -27,7 +27,7 @@ async fn main() -> Result<()> { async fn run_entities(args: EntitiesArgs) -> Result<()> { let db_kind = args.db.database_kind()?; - let type_overrides = args.parse_type_overrides(); + let type_overrides = args.parse_type_overrides_checked()?; info!( "Connecting to {} database...", From e941d8f4b32f392cb14663d87a6cd625d51fa639 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:20:55 +0200 Subject: [PATCH 07/38] fix: redact password in database URLs on connection errors Connection failures previously bubbled up the raw sqlx::Error which can include the full database URL (user:password@host) in its Display implementation. Wrap the pool.connect() error in a new Error::Connection variant that carries a redacted URL, and add a redact_url helper that replaces the password with "****". --- crates/sqlx_gen/src/error.rs | 77 ++++++++++++++++++++++++++++++++++++ crates/sqlx_gen/src/main.rs | 12 ++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/crates/sqlx_gen/src/error.rs b/crates/sqlx_gen/src/error.rs index 15f7cac..b76f626 100644 --- a/crates/sqlx_gen/src/error.rs +++ b/crates/sqlx_gen/src/error.rs @@ -2,6 +2,13 @@ use std::io; #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("Database connection failed ({redacted_url}): {source}")] + Connection { + redacted_url: String, + #[source] + source: sqlx::Error, + }, + #[error("Database error: {0}")] Database(#[from] sqlx::Error), @@ -13,3 +20,73 @@ pub enum Error { } pub type Result = std::result::Result; + +/// Redact `user:password@host` → `user:****@host` in a database URL so it can +/// be embedded in error messages and logs without leaking credentials. +pub fn redact_url(url: &str) -> String { + let (scheme, rest) = match url.split_once("://") { + Some(pair) => pair, + None => return url.to_string(), + }; + let (userinfo, host_part) = match rest.split_once('@') { + Some(pair) => pair, + None => return url.to_string(), + }; + let redacted_userinfo = match userinfo.split_once(':') { + Some((user, _pw)) => format!("{}:****", user), + None => userinfo.to_string(), + }; + format!("{}://{}@{}", scheme, redacted_userinfo, host_part) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_password_in_postgres_url() { + assert_eq!( + redact_url("postgres://alice:s3cret@localhost:5432/db"), + "postgres://alice:****@localhost:5432/db" + ); + } + + #[test] + fn redacts_password_in_mysql_url() { + assert_eq!( + redact_url("mysql://root:hunter2@db:3306/app"), + "mysql://root:****@db:3306/app" + ); + } + + #[test] + fn redacts_password_in_postgresql_url() { + assert_eq!( + redact_url("postgresql://u:p@h/d"), + "postgresql://u:****@h/d" + ); + } + + #[test] + fn leaves_passwordless_sqlite_url_unchanged() { + assert_eq!(redact_url("sqlite:///tmp/test.db"), "sqlite:///tmp/test.db"); + } + + #[test] + fn leaves_no_userinfo_unchanged() { + assert_eq!(redact_url("postgres://localhost/db"), "postgres://localhost/db"); + } + + #[test] + fn leaves_userinfo_without_password_unchanged() { + assert_eq!( + redact_url("postgres://alice@localhost/db"), + "postgres://alice@localhost/db" + ); + } + + #[test] + fn leaves_non_url_string_unchanged() { + assert_eq!(redact_url("not-a-url"), "not-a-url"); + } +} diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 7275fdf..bad6746 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -38,23 +38,29 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { } ); + let redacted = sqlx_gen::error::redact_url(&args.db.database_url); + let conn_err = |source: sqlx::Error| sqlx_gen::error::Error::Connection { + redacted_url: redacted.clone(), + source, + }; + let mut schema_info = match db_kind { DatabaseKind::Postgres => { - let pool = PgPool::connect(&args.db.database_url).await?; + let pool = PgPool::connect(&args.db.database_url).await.map_err(conn_err)?; let info = introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await?; pool.close().await; info } DatabaseKind::Mysql => { - let pool = MySqlPool::connect(&args.db.database_url).await?; + let pool = MySqlPool::connect(&args.db.database_url).await.map_err(conn_err)?; let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await?; pool.close().await; info } DatabaseKind::Sqlite => { - let pool = SqlitePool::connect(&args.db.database_url).await?; + let pool = SqlitePool::connect(&args.db.database_url).await.map_err(conn_err)?; let info = introspect::sqlite::introspect(&pool, args.views).await?; pool.close().await; info From f85e7ff28fb22ddc172c7c8b1b066c9a6ebc37cd Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:24:55 +0200 Subject: [PATCH 08/38] fix: propagate codegen parse errors instead of process::exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse_and_format / parse_and_format_with_tab_spaces previously called std::process::exit(1) when prettyplease failed to parse the generated TokenStream. That kills the user's build with no recovery path and no useful diagnostic if sqlx-gen is ever used as a library. Now both helpers return Result; format_tokens, format_tokens_with_imports, and codegen::generate propagate via ?. The error message includes the failing token stream and a request to file an issue, since this only fires on internal codegen bugs. Test helpers across struct_gen, enum_gen, composite_gen, domain_gen, crud_gen, codegen::mod, and e2e_sqlite were updated to .unwrap() the Result — they assert on the happy path and want a clear panic if it breaks. --- crates/sqlx_gen/src/codegen/composite_gen.rs | 6 +- crates/sqlx_gen/src/codegen/crud_gen.rs | 30 +++--- crates/sqlx_gen/src/codegen/domain_gen.rs | 4 +- crates/sqlx_gen/src/codegen/enum_gen.rs | 6 +- crates/sqlx_gen/src/codegen/mod.rs | 105 +++++++++++-------- crates/sqlx_gen/src/codegen/struct_gen.rs | 4 +- crates/sqlx_gen/src/main.rs | 4 +- crates/sqlx_gen/tests/e2e_sqlite.rs | 28 ++--- 8 files changed, 100 insertions(+), 87 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 95f4e2c..e568f55 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -136,7 +136,7 @@ mod tests { fn gen(composite: &CompositeTypeInfo) -> String { let schema = SchemaInfo::default(); let (tokens, _) = generate_composite(composite, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), TimeCrate::Chrono); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_with( @@ -146,7 +146,7 @@ mod tests { ) -> (String, BTreeSet) { let schema = SchemaInfo::default(); let (tokens, imports) = generate_composite(composite, DatabaseKind::Postgres, &schema, derives, overrides, TimeCrate::Chrono); - (parse_and_format(&tokens), imports) + (parse_and_format(&tokens).unwrap(), imports) } // --- basic structure --- @@ -193,7 +193,7 @@ mod tests { }; let schema = SchemaInfo::default(); let (tokens, _) = generate_composite(&c, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), TimeCrate::Chrono); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!(code.contains("sqlx(type_name = \"point\")"), "type_name must be unqualified for sqlx 0.8, got:\n{}", code); assert!(!code.contains("\"geo.point\"")); diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index f01eb38..829f007 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -1279,7 +1279,7 @@ mod tests { false, PoolVisibility::Private, ); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_macro(entity: &ParsedEntity, db: DatabaseKind) -> String { @@ -1292,7 +1292,7 @@ mod tests { true, PoolVisibility::Private, ); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_with_methods(entity: &ParsedEntity, db: DatabaseKind, methods: &Methods) -> String { @@ -1304,7 +1304,7 @@ mod tests { false, PoolVisibility::Private, ); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_with_tab_spaces(entity: &ParsedEntity, db: DatabaseKind, tab_spaces: usize) -> String { @@ -1317,7 +1317,7 @@ mod tests { false, PoolVisibility::Private, ); - parse_and_format_with_tab_spaces(&tokens, tab_spaces) + parse_and_format_with_tab_spaces(&tokens, tab_spaces).unwrap() } // --- basic structure --- @@ -1351,7 +1351,7 @@ mod tests { false, PoolVisibility::Pub, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!( code.contains("pub pool: sqlx::PgPool") || code.contains("pub pool: sqlx :: PgPool") ); @@ -1368,7 +1368,7 @@ mod tests { false, PoolVisibility::PubCrate, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!( code.contains("pub(crate) pool: sqlx::PgPool") || code.contains("pub(crate) pool: sqlx :: PgPool") @@ -2245,7 +2245,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!(code.contains("query_as!")); assert!(!code.contains("query_as::<")); } @@ -2315,7 +2315,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!(!code.contains(".bind(")); } @@ -2384,7 +2384,7 @@ mod tests { true, PoolVisibility::Private, ); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } #[test] @@ -2472,7 +2472,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); // SELECT queries should use runtime query_as, not macro assert!(code.contains("query_as::<")); assert!(!code.contains("query_as!(")); @@ -2489,7 +2489,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); // DELETE still uses query! macro assert!(code.contains("query!")); } @@ -2552,7 +2552,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!(code.contains("as_slice()")); } @@ -2567,7 +2567,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); // Should have as_slice() for insert and update let count = code.matches("as_slice()").count(); assert!( @@ -2588,7 +2588,7 @@ mod tests { false, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); // Runtime mode uses .bind() so no as_slice needed assert!(!code.contains("as_slice()")); } @@ -2618,7 +2618,7 @@ mod tests { true, PoolVisibility::Private, ); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!( code.contains("as_slice()"), "Expected as_slice() in generated code:\n{}", diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index 65c119b..fef3d66 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -71,13 +71,13 @@ mod tests { fn gen(domain: &DomainInfo) -> (String, BTreeSet) { let schema = SchemaInfo::default(); let (tokens, imports) = generate_domain(domain, DatabaseKind::Postgres, &schema, &HashMap::new(), TimeCrate::Chrono); - (parse_and_format(&tokens), imports) + (parse_and_format(&tokens).unwrap(), imports) } fn gen_with_overrides(domain: &DomainInfo, overrides: &HashMap) -> (String, BTreeSet) { let schema = SchemaInfo::default(); let (tokens, imports) = generate_domain(domain, DatabaseKind::Postgres, &schema, overrides, TimeCrate::Chrono); - (parse_and_format(&tokens), imports) + (parse_and_format(&tokens).unwrap(), imports) } #[test] diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 76557c1..16dadfc 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -117,7 +117,7 @@ mod tests { fn gen(info: &EnumInfo, db: DatabaseKind) -> String { let (tokens, _) = generate_enum(info, db, &[]); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_with_derives( @@ -126,7 +126,7 @@ mod tests { derives: &[String], ) -> (String, BTreeSet) { let (tokens, imports) = generate_enum(info, db, derives); - (parse_and_format(&tokens), imports) + (parse_and_format(&tokens).unwrap(), imports) } // --- basic structure --- @@ -192,7 +192,7 @@ mod tests { default_variant: None, }; let (tokens, _) = generate_enum(&e, DatabaseKind::Postgres, &[]); - let code = parse_and_format(&tokens); + let code = parse_and_format(&tokens).unwrap(); assert!(code.contains("sqlx(type_name = \"role\")"), "type_name must be unqualified for sqlx 0.8 compatibility, got:\n{}", code); assert!(!code.contains("\"auth.role\""), diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index 02a9c12..756ab78 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -114,7 +114,7 @@ pub fn generate( type_overrides: &HashMap, single_file: bool, time_crate: TimeCrate, -) -> Vec { +) -> crate::error::Result> { let mut files = Vec::new(); // Detect table/view names that appear in multiple schemas (collisions) @@ -125,7 +125,7 @@ pub fn generate( let (tokens, imports) = struct_gen::generate_struct(table, db_kind, schema_info, extra_derives, type_overrides, false, time_crate); let imports = filter_imports(&imports, single_file); - let code = format_tokens_with_imports(&tokens, &imports); + let code = format_tokens_with_imports(&tokens, &imports)?; let module_name = build_module_name(&table.schema_name, &table.name, colliding_names.contains(table.name.as_str())); files.push(GeneratedFile { filename: format!("{}.rs", module_name), @@ -139,7 +139,7 @@ pub fn generate( let (tokens, imports) = struct_gen::generate_struct(view, db_kind, schema_info, extra_derives, type_overrides, true, time_crate); let imports = filter_imports(&imports, single_file); - let code = format_tokens_with_imports(&tokens, &imports); + let code = format_tokens_with_imports(&tokens, &imports)?; let module_name = build_module_name(&view.schema_name, &view.name, colliding_names.contains(view.name.as_str())); files.push(GeneratedFile { filename: format!("{}.rs", module_name), @@ -163,7 +163,7 @@ pub fn generate( } } let (tokens, imports) = enum_gen::generate_enum(&enriched, db_kind, extra_derives); - types_blocks.push(format_tokens(&tokens)); + types_blocks.push(format_tokens(&tokens)?); types_imports.extend(imports); } @@ -176,14 +176,14 @@ pub fn generate( type_overrides, time_crate, ); - types_blocks.push(format_tokens(&tokens)); + types_blocks.push(format_tokens(&tokens)?); types_imports.extend(imports); } for domain in &schema_info.domains { let (tokens, imports) = domain_gen::generate_domain(domain, db_kind, schema_info, type_overrides, time_crate); - types_blocks.push(format_tokens(&tokens)); + types_blocks.push(format_tokens(&tokens)?); types_imports.extend(imports); } @@ -205,7 +205,7 @@ pub fn generate( }); } - files + Ok(files) } /// Extract default variant values for enums by scanning column defaults across all tables and views. @@ -308,32 +308,45 @@ pub fn detect_tab_spaces(start_dir: &Path) -> usize { /// Parse and format a TokenStream via prettyplease, then post-process spacing. /// `tab_spaces` controls how many spaces per indentation level for SQL inside raw strings. -pub(crate) fn parse_and_format(tokens: &TokenStream) -> String { +pub(crate) fn parse_and_format(tokens: &TokenStream) -> crate::error::Result { parse_and_format_with_tab_spaces(tokens, 4) } -pub(crate) fn parse_and_format_with_tab_spaces(tokens: &TokenStream, tab_spaces: usize) -> String { - let file = syn::parse2::(tokens.clone()).unwrap_or_else(|e| { - log::error!("Failed to parse generated code: {}", e); - log::error!("This is a bug in sqlx-gen. Raw tokens:\n {}", tokens); - std::process::exit(1); - }); +pub(crate) fn parse_and_format_with_tab_spaces( + tokens: &TokenStream, + tab_spaces: usize, +) -> crate::error::Result { + let file = syn::parse2::(tokens.clone()).map_err(|e| { + crate::error::Error::Config(format!( + "Internal sqlx-gen bug: failed to parse generated code: {}. \ + Raw tokens:\n {}\n\ + Please report this with the input schema.", + e, tokens + )) + })?; let raw = prettyplease::unparse(&file); let raw = indent_multiline_raw_strings(&raw, tab_spaces); - add_blank_lines_between_items(&raw) + Ok(add_blank_lines_between_items(&raw)) } /// Format a single TokenStream block (no imports). -pub(crate) fn format_tokens(tokens: &TokenStream) -> String { +pub(crate) fn format_tokens(tokens: &TokenStream) -> crate::error::Result { parse_and_format(tokens) } -pub fn format_tokens_with_imports(tokens: &TokenStream, imports: &BTreeSet) -> String { +pub fn format_tokens_with_imports( + tokens: &TokenStream, + imports: &BTreeSet, +) -> crate::error::Result { format_tokens_with_imports_and_tab_spaces(tokens, imports, 4) } -pub fn format_tokens_with_imports_and_tab_spaces(tokens: &TokenStream, imports: &BTreeSet, tab_spaces: usize) -> String { - let formatted = parse_and_format_with_tab_spaces(tokens, tab_spaces); +pub fn format_tokens_with_imports_and_tab_spaces( + tokens: &TokenStream, + imports: &BTreeSet, + tab_spaces: usize, +) -> crate::error::Result { + let formatted = parse_and_format_with_tab_spaces(tokens, tab_spaces)?; let used_imports: Vec<&String> = imports .iter() @@ -341,13 +354,13 @@ pub fn format_tokens_with_imports_and_tab_spaces(tokens: &TokenStream, imports: .collect(); if used_imports.is_empty() { - formatted + Ok(formatted) } else { let import_lines: String = used_imports .iter() .map(|i| format!("{}\n", i)) .collect(); - format!("{}\n\n{}", import_lines.trim_end(), formatted) + Ok(format!("{}\n\n{}", import_lines.trim_end(), formatted)) } } @@ -814,7 +827,7 @@ mod tests { #[test] fn test_generate_empty_schema() { let schema = SchemaInfo::default(); - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files.is_empty()); } @@ -824,7 +837,7 @@ mod tests { tables: vec![make_table("users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "users.rs"); } @@ -838,7 +851,7 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 2); } @@ -853,7 +866,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "types.rs"); } @@ -879,7 +892,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); // Should produce exactly 1 types.rs let types_files: Vec<_> = files.iter().filter(|f| f.filename == "types.rs").collect(); assert_eq!(types_files.len(), 1); @@ -897,7 +910,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 2); // users.rs + types.rs } @@ -907,7 +920,7 @@ mod tests { tables: vec![make_table("user__data", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].filename, "user_data.rs"); } @@ -917,7 +930,7 @@ mod tests { tables: vec![make_table("users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].origin, None); } @@ -932,7 +945,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].origin, None); } @@ -948,7 +961,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono).unwrap(); // struct file should not have super::types:: imports let struct_file = files.iter().find(|f| f.filename == "users.rs").unwrap(); assert!(!struct_file.code.contains("super::types::")); @@ -967,7 +980,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let struct_file = files.iter().find(|f| f.filename == "users.rs").unwrap(); assert!(struct_file.code.contains("super::types::")); } @@ -979,7 +992,7 @@ mod tests { ..Default::default() }; let derives = vec!["Serialize".to_string()]; - let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -995,7 +1008,7 @@ mod tests { ..Default::default() }; let derives = vec!["Serialize".to_string()]; - let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -1007,7 +1020,7 @@ mod tests { tables: vec![make_table("users", vec![make_col("data", "jsonb")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &overrides, false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &overrides, false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("MyJson")); } @@ -1026,7 +1039,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); for f in &files { // Should be parseable as valid Rust let parse_result = syn::parse_file(&f.code); @@ -1050,7 +1063,7 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "active_users.rs"); } @@ -1061,7 +1074,7 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].origin, None); } @@ -1072,7 +1085,7 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 2); } @@ -1085,7 +1098,7 @@ mod tests { ])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let parse_result = syn::parse_file(&files[0].code); assert!(parse_result.is_ok(), "Failed to parse: {:?}", parse_result.err()); } @@ -1105,7 +1118,7 @@ mod tests { }])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("Option")); } @@ -1122,7 +1135,7 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let filenames: Vec<_> = files.iter().map(|f| f.filename.as_str()).collect(); assert!(filenames.contains(&"users.rs")); assert!(filenames.contains(&"billing_users.rs")); @@ -1141,7 +1154,7 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let filenames: Vec<_> = files.iter().map(|f| f.filename.as_str()).collect(); assert!(filenames.contains(&"users.rs")); assert!(filenames.contains(&"invoices.rs")); @@ -1156,7 +1169,7 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].filename, "users.rs"); assert_eq!(files[1].filename, "posts.rs"); } @@ -1168,7 +1181,7 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 2); } @@ -1315,7 +1328,7 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let types_file = files.iter().find(|f| f.filename == "types.rs").unwrap(); assert!(types_file.code.contains("impl Default for TaskStatus")); assert!(types_file.code.contains("Self::Idle")); diff --git a/crates/sqlx_gen/src/codegen/struct_gen.rs b/crates/sqlx_gen/src/codegen/struct_gen.rs index 93b87eb..575ba92 100644 --- a/crates/sqlx_gen/src/codegen/struct_gen.rs +++ b/crates/sqlx_gen/src/codegen/struct_gen.rs @@ -218,7 +218,7 @@ mod tests { fn gen(table: &TableInfo) -> String { let schema = SchemaInfo::default(); let (tokens, _) = generate_struct(table, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), false, TimeCrate::Chrono); - parse_and_format(&tokens) + parse_and_format(&tokens).unwrap() } fn gen_with( @@ -229,7 +229,7 @@ mod tests { overrides: &HashMap, ) -> (String, BTreeSet) { let (tokens, imports) = generate_struct(table, db, schema, derives, overrides, false, TimeCrate::Chrono); - (parse_and_format(&tokens), imports) + (parse_and_format(&tokens).unwrap(), imports) } // --- basic structure --- diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index bad6746..6a9fdf6 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -97,7 +97,7 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { &type_overrides, args.single_file, args.time_crate, - ); + )?; writer::write_files(&files, &args.output_dir, args.single_file, args.dry_run)?; @@ -150,7 +150,7 @@ fn run_crud(args: CrudArgs) -> Result<()> { ); let tab_spaces = codegen::detect_tab_spaces(&args.output_dir); - let code = codegen::format_tokens_with_imports_and_tab_spaces(&tokens, &imports, tab_spaces); + let code = codegen::format_tokens_with_imports_and_tab_spaces(&tokens, &imports, tab_spaces)?; if args.dry_run { println!("{}", code); diff --git a/crates/sqlx_gen/tests/e2e_sqlite.rs b/crates/sqlx_gen/tests/e2e_sqlite.rs index 8410513..c9f2743 100644 --- a/crates/sqlx_gen/tests/e2e_sqlite.rs +++ b/crates/sqlx_gen/tests/e2e_sqlite.rs @@ -18,7 +18,7 @@ async fn test_simple_table_generates_struct() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("pub struct")); } @@ -27,7 +27,7 @@ async fn test_struct_name_pascal_case() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE user_profiles (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("pub struct UserProfiles")); } @@ -36,7 +36,7 @@ async fn test_integer_mapped_to_i64() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE t (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("i64")); } @@ -45,7 +45,7 @@ async fn test_nullable_column_option() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE t (name TEXT)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("Option<")); } @@ -55,7 +55,7 @@ async fn test_multiple_tables_multiple_files() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; exec(&pool, "CREATE TABLE posts (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 2); } @@ -64,7 +64,7 @@ async fn test_filenames_correct() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files[0].filename, "users.rs"); } @@ -73,7 +73,7 @@ async fn test_generated_code_parseable() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); for f in &files { assert!(syn::parse_file(&f.code).is_ok(), "Failed to parse {}", f.filename); } @@ -85,7 +85,7 @@ async fn test_extra_derives_propagated() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); let derives = vec!["Serialize".to_string()]; - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &derives, &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -97,7 +97,7 @@ async fn test_view_generates_struct() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; exec(&pool, "CREATE VIEW active_users AS SELECT id, name FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let view_file = files.iter().find(|f| f.filename == "active_users.rs").unwrap(); assert!(view_file.code.contains("pub struct ActiveUsers")); } @@ -108,7 +108,7 @@ async fn test_view_origin_contains_view() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; exec(&pool, "CREATE VIEW v AS SELECT id FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let view_file = files.iter().find(|f| f.filename == "v.rs").unwrap(); assert_eq!(view_file.origin, None); } @@ -119,7 +119,7 @@ async fn test_view_code_parseable() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; exec(&pool, "CREATE VIEW user_view AS SELECT id, name FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); for f in &files { assert!(syn::parse_file(&f.code).is_ok(), "Failed to parse {}", f.filename); } @@ -131,7 +131,7 @@ async fn test_view_pascal_case_name() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; exec(&pool, "CREATE VIEW all_active_users AS SELECT id FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let view_file = files.iter().find(|f| f.filename == "all_active_users.rs").unwrap(); assert!(view_file.code.contains("pub struct AllActiveUsers")); } @@ -146,7 +146,7 @@ async fn test_exclude_table() { let mut schema = introspect(&pool, false).await.unwrap(); let exclude = ["_migrations".to_string()]; schema.tables.retain(|t| !exclude.contains(&t.name)); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "users.rs"); } @@ -188,7 +188,7 @@ async fn test_exclude_view() { let mut schema = introspect(&pool, true).await.unwrap(); let exclude = ["v1".to_string()]; schema.views.retain(|v| !exclude.contains(&v.name)); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono); + let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); let view_files: Vec<_> = files.iter().filter(|f| f.code.contains("kind = \"view\"")).collect(); assert_eq!(view_files.len(), 1); assert_eq!(view_files[0].filename, "v2.rs"); From a1328fb38224867a8b0be00541f388997689485b Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:26:22 +0200 Subject: [PATCH 09/38] fix: propagate errors from introspect instead of panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed 7× .expect() on MySQL information_schema Vec → String conversions, replaced with utf8_field helper that returns Error::Config on invalid UTF-8. Removed 5× .last_mut().unwrap() panic risks across postgres.rs and mysql.rs (tables, views, enums, composites). Each now returns Error::Config with an "internal sqlx-gen bug" message that points the user at filing an issue rather than crashing the build. --- crates/sqlx_gen/src/introspect/mysql.rs | 40 +++++++++++++++++----- crates/sqlx_gen/src/introspect/postgres.rs | 28 ++++++++++++--- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/crates/sqlx_gen/src/introspect/mysql.rs b/crates/sqlx_gen/src/introspect/mysql.rs index 4685630..e00b1e6 100644 --- a/crates/sqlx_gen/src/introspect/mysql.rs +++ b/crates/sqlx_gen/src/introspect/mysql.rs @@ -69,13 +69,13 @@ async fn fetch_tables(pool: &MySqlPool, schemas: &[String]) -> Result = None; for (schema, table, col_name, data_type, column_type, nullable, ordinal, column_key) in rows { - let schema = String::from_utf8(schema).expect("Could not convert schema name from UTF8 bytes"); - let table = String::from_utf8(table).expect("Could not convert schema name from UTF8 bytes"); - let col_name = String::from_utf8(col_name).expect("Could not convert col_name name from UTF8 bytes"); - let data_type = String::from_utf8(data_type).expect("Could not convert data_type name from UTF8 bytes"); - let column_type = String::from_utf8(column_type).expect("Could not convert column_type name from UTF8 bytes"); - let nullable = String::from_utf8(nullable).expect("Could not convert nullable name from UTF8 bytes"); - let column_key = String::from_utf8(column_key).expect("Could not convert column_key name from UTF8 bytes"); + let schema = utf8_field(schema, "TABLE_SCHEMA")?; + let table = utf8_field(table, "TABLE_NAME")?; + let col_name = utf8_field(col_name, "COLUMN_NAME")?; + let data_type = utf8_field(data_type, "DATA_TYPE")?; + let column_type = utf8_field(column_type, "COLUMN_TYPE")?; + let nullable = utf8_field(nullable, "IS_NULLABLE")?; + let column_key = utf8_field(column_key, "COLUMN_KEY")?; let key = (schema.clone(), table.clone()); if current_key.as_ref() != Some(&key) { @@ -86,7 +86,12 @@ async fn fetch_tables(pool: &MySqlPool, schemas: &[String]) -> Result Result` metadata field as UTF-8, returning a structured +/// error instead of panicking if the bytes are invalid. +fn utf8_field(bytes: Vec, field: &str) -> Result { + String::from_utf8(bytes).map_err(|_| { + crate::error::Error::Config(format!( + "Database returned non-UTF8 bytes for MySQL information_schema field '{}'. \ + sqlx-gen requires UTF-8 metadata.", + field + )) + }) +} + async fn fetch_views(pool: &MySqlPool, schemas: &[String]) -> Result> { let placeholders: Vec = (0..schemas.len()).map(|_| "?".to_string()).collect(); let query = format!( @@ -143,7 +160,12 @@ async fn fetch_views(pool: &MySqlPool, schemas: &[String]) -> Result Result Result columns: Vec::new(), }); } - views.last_mut().unwrap().columns.push(ColumnInfo { + let last = views.last_mut().ok_or_else(|| { + crate::error::Error::Config( + "Internal sqlx-gen bug: views vector empty after push".to_string(), + ) + })?; + last.columns.push(ColumnInfo { name: col_name, data_type, udt_name, @@ -352,7 +362,12 @@ async fn fetch_enums(pool: &PgPool, schemas: &[String]) -> Result> default_variant: None, }); } - enums.last_mut().unwrap().variants.push(variant); + let last = enums.last_mut().ok_or_else(|| { + crate::error::Error::Config( + "Internal sqlx-gen bug: enums vector empty after push".to_string(), + ) + })?; + last.variants.push(variant); } Ok(enums) @@ -402,7 +417,12 @@ async fn fetch_composite_types( fields: Vec::new(), }); } - composites.last_mut().unwrap().fields.push(ColumnInfo { + let last = composites.last_mut().ok_or_else(|| { + crate::error::Error::Config( + "Internal sqlx-gen bug: composites vector empty after push".to_string(), + ) + })?; + last.fields.push(ColumnInfo { name: field_name, data_type: field_type.clone(), udt_name: field_type, From c96c3fe2dfcae08ceeb27e0556fe8d8bf7cbed37 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:28:57 +0200 Subject: [PATCH 10/38] fix: write generated files atomically and reject unsafe filenames write_atomic streams into a sibling NamedTempFile then renames into place, so a Ctrl-C or disk-full error never leaves a half-written .rs file that would break the user's next build. validate_safe_filename rejects path separators, "..", absolute paths, empty names, and non-.rs extensions before any write happens. Defends against the rare case where introspected table names flow into the output filename and could otherwise escape output_dir. --- crates/sqlx_gen/Cargo.toml | 2 + crates/sqlx_gen/src/writer.rs | 125 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/sqlx_gen/Cargo.toml b/crates/sqlx_gen/Cargo.toml index 5ccfe56..6b22cff 100644 --- a/crates/sqlx_gen/Cargo.toml +++ b/crates/sqlx_gen/Cargo.toml @@ -27,6 +27,7 @@ cli = [ "dep:prettyplease", "dep:log", "dep:env_logger", + "dep:tempfile", ] [dependencies] @@ -51,6 +52,7 @@ syn = { version = "2", optional = true } prettyplease = { version = "0.2", optional = true } log = { version = "0.4", optional = true } env_logger = { version = "0.11", optional = true } +tempfile = { version = "3", optional = true } [dev-dependencies] pretty_assertions = "1" diff --git a/crates/sqlx_gen/src/writer.rs b/crates/sqlx_gen/src/writer.rs index ea76e0d..d9f72fb 100644 --- a/crates/sqlx_gen/src/writer.rs +++ b/crates/sqlx_gen/src/writer.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::path::Path; use crate::error::Result; @@ -7,12 +8,32 @@ use crate::codegen::GeneratedFile; const COMMENT: &str = "// Auto-generated by sqlx-gen. Do not edit."; const INNER_ATTR: &str = "#![allow(unused_attributes)]"; +/// Write `content` to `path` atomically: stream to a sibling temp file then rename. +/// Avoids leaving partially-written files on Ctrl-C or disk-full errors. +pub(crate) fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + crate::error::Error::Config(format!( + "Cannot determine parent directory of {}", + path.display() + )) + })?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(content)?; + tmp.flush()?; + tmp.persist(path).map_err(|e| e.error)?; + Ok(()) +} + pub fn write_files( files: &[GeneratedFile], output_dir: &Path, single_file: bool, dry_run: bool, ) -> Result<()> { + for f in files { + validate_safe_filename(&f.filename)?; + } + if dry_run { for f in files { println!("{}", build_file_content(f)); @@ -32,6 +53,28 @@ pub fn write_files( Ok(()) } +/// Reject filenames that could escape the output directory (`..`, path +/// separators, absolute paths) or that aren't `.rs` files. Defends against +/// malicious DB metadata in the rare case introspected table names flow into +/// the file name. +fn validate_safe_filename(filename: &str) -> Result<()> { + let p = Path::new(filename); + if filename.is_empty() + || p.components().count() != 1 + || p.is_absolute() + || filename.contains("..") + || filename.contains('/') + || filename.contains('\\') + || !filename.ends_with(".rs") + { + return Err(crate::error::Error::Config(format!( + "Refusing to write generated file with unsafe name: {:?}", + filename + ))); + } + Ok(()) +} + fn build_file_content(f: &GeneratedFile) -> String { let mut content = String::new(); content.push_str(COMMENT); @@ -58,7 +101,7 @@ fn write_single_file(files: &[GeneratedFile], output_dir: &Path) -> Result<()> { } let path = output_dir.join("models.rs"); - std::fs::write(&path, &content)?; + write_atomic(&path, content.as_bytes())?; log::info!("Wrote {}", path.display()); Ok(()) @@ -70,7 +113,7 @@ fn write_multi_files(files: &[GeneratedFile], output_dir: &Path) -> Result<()> { for f in files { let content = build_file_content(f); let path = output_dir.join(&f.filename); - std::fs::write(&path, &content)?; + write_atomic(&path, content.as_bytes())?; log::info!("Wrote {}", path.display()); let mod_name = f.filename.strip_suffix(".rs").unwrap_or(&f.filename); @@ -84,7 +127,7 @@ fn write_multi_files(files: &[GeneratedFile], output_dir: &Path) -> Result<()> { } let mod_path = output_dir.join("mod.rs"); - std::fs::write(&mod_path, &mod_content)?; + write_atomic(&mod_path, mod_content.as_bytes())?; log::info!("Wrote {}", mod_path.display()); Ok(()) @@ -308,4 +351,80 @@ mod tests { let content = std::fs::read_to_string(dir.path().join("models.rs")).unwrap(); assert!(!content.contains("// ---")); } + + // ========== write_atomic ========== + + #[test] + fn test_atomic_creates_file_with_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.rs"); + write_atomic(&path, b"hello").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + } + + #[test] + fn test_atomic_overwrites_existing_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.rs"); + std::fs::write(&path, "old").unwrap(); + write_atomic(&path, b"new").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); + } + + #[test] + fn test_atomic_leaves_no_temp_artifacts_on_success() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("out.rs"); + write_atomic(&path, b"x").unwrap(); + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].to_string_lossy(), "out.rs"); + } + + // ========== validate_safe_filename ========== + + #[test] + fn test_rejects_dot_dot_in_filename() { + let files = vec![make_file("../escape.rs", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_err()); + } + + #[test] + fn test_rejects_absolute_path_filename() { + let files = vec![make_file("/etc/passwd", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_err()); + } + + #[test] + fn test_rejects_path_separator_in_filename() { + let files = vec![make_file("sub/dir/file.rs", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_err()); + } + + #[test] + fn test_rejects_non_rs_extension() { + let files = vec![make_file("evil.sh", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_err()); + } + + #[test] + fn test_rejects_empty_filename() { + let files = vec![make_file("", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_err()); + } + + #[test] + fn test_accepts_normal_rs_filename() { + let files = vec![make_file("users.rs", "code", None)]; + let dir = tempfile::tempdir().unwrap(); + assert!(write_files(&files, dir.path(), false, false).is_ok()); + } } From 3ecbcb18e47dcc62b2b069558aba867bc0a439e7 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:29:41 +0200 Subject: [PATCH 11/38] ci: add test, fmt, clippy workflow with Postgres+MySQL services Runs on every push to main and every PR. Three primary jobs: - test: cargo test --all (unit + sqlite-based integration) - fmt: rustfmt --check - clippy: -D warnings on all targets Two optional jobs spin up Postgres 16 and MySQL 8.0 services and run the e2e_postgres / e2e_mysql test files (added in upcoming commits). These continue-on-error until the e2e suites exist. --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3687a9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: Test (unit + sqlite) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo test --all + run: cargo test --all --all-features + + fmt: + name: Format check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - name: cargo fmt --check + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: cargo clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + test-postgres: + name: Test (Postgres E2E) + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: sqlx_gen_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + PG_URL: postgres://postgres:postgres@localhost:5432/sqlx_gen_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo test postgres e2e + run: cargo test --all-features --test e2e_postgres -- --include-ignored + continue-on-error: true + + test-mysql: + name: Test (MySQL E2E) + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: sqlx_gen_test + ports: + - 3306:3306 + options: >- + --health-cmd "mysqladmin ping -uroot -proot" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + MYSQL_URL: mysql://root:root@localhost:3306/sqlx_gen_test + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: cargo test mysql e2e + run: cargo test --all-features --test e2e_mysql -- --include-ignored + continue-on-error: true From 853f1fcc4aa9b18f65d6fd740e80578971a9e030 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:31:23 +0200 Subject: [PATCH 12/38] fix: typemap gaps for MySQL bit(1), boolean, Postgres interval, SQLite decimal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL `bit(1)` → bool (idiomatic boolean column); bit(N>1) stays Vec - MySQL `boolean`/`bool` aliases → bool (previously fell through to String) - Postgres `interval` → PgInterval with the corresponding import (was hitting the String fallback) - SQLite `NUMERIC`/`DECIMAL` → Decimal instead of f64; matches the precision-safe behaviour already shipped for Postgres and MySQL --- crates/sqlx_gen/src/typemap/mysql.rs | 24 +++++++++++++++++++++--- crates/sqlx_gen/src/typemap/postgres.rs | 12 ++++++++++++ crates/sqlx_gen/src/typemap/sqlite.rs | 15 ++++++++++----- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/sqlx_gen/src/typemap/mysql.rs b/crates/sqlx_gen/src/typemap/mysql.rs index fe6909a..f5a2e21 100644 --- a/crates/sqlx_gen/src/typemap/mysql.rs +++ b/crates/sqlx_gen/src/typemap/mysql.rs @@ -76,7 +76,15 @@ pub fn map_type(data_type: &str, column_type: &str, time_crate: TimeCrate) -> Ru }, "json" => RustType::with_import("Value", "use serde_json::Value;"), "year" => RustType::simple("i16"), - "bit" => RustType::simple("Vec"), + "bit" => { + // BIT(1) is the idiomatic MySQL boolean. Treat anything wider as raw bytes. + if ct == "bit(1)" { + RustType::simple("bool") + } else { + RustType::simple("Vec") + } + } + "boolean" | "bool" => RustType::simple("bool"), _ => RustType::simple("String"), } } @@ -312,8 +320,18 @@ mod tests { } #[test] - fn test_bit() { - assert_eq!(map_type("bit", "bit(1)", TimeCrate::Chrono).path, "Vec"); + fn test_bit1_is_bool() { + assert_eq!(map_type("bit", "bit(1)", TimeCrate::Chrono).path, "bool"); + } + + #[test] + fn test_bit8_is_bytes() { + assert_eq!(map_type("bit", "bit(8)", TimeCrate::Chrono).path, "Vec"); + } + + #[test] + fn test_boolean_alias_is_bool() { + assert_eq!(map_type("boolean", "boolean", TimeCrate::Chrono).path, "bool"); } // --- enum placeholder --- diff --git a/crates/sqlx_gen/src/typemap/postgres.rs b/crates/sqlx_gen/src/typemap/postgres.rs index 0b9269e..5454d57 100644 --- a/crates/sqlx_gen/src/typemap/postgres.rs +++ b/crates/sqlx_gen/src/typemap/postgres.rs @@ -26,6 +26,7 @@ pub fn is_builtin(udt_name: &str) -> bool { | "uuid" | "json" | "jsonb" | "inet" | "cidr" + | "interval" | "oid" ) } @@ -94,6 +95,10 @@ pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) "inet" | "cidr" => { RustType::with_import("IpNetwork", "use ipnetwork::IpNetwork;") } + "interval" => RustType::with_import( + "PgInterval", + "use sqlx::postgres::types::PgInterval;", + ), "oid" => RustType::simple("u32"), _ => RustType::simple("String"), // fallback } @@ -328,6 +333,13 @@ mod tests { assert_eq!(map_type("oid", &empty_schema(), TimeCrate::Chrono).path, "u32"); } + #[test] + fn test_interval_uses_pg_interval() { + let rt = map_type("interval", &empty_schema(), TimeCrate::Chrono); + assert_eq!(rt.path, "PgInterval"); + assert!(rt.needs_import.as_ref().unwrap().contains("PgInterval")); + } + // --- arrays --- #[test] diff --git a/crates/sqlx_gen/src/typemap/sqlite.rs b/crates/sqlx_gen/src/typemap/sqlite.rs index 38cafe2..710d96b 100644 --- a/crates/sqlx_gen/src/typemap/sqlite.rs +++ b/crates/sqlx_gen/src/typemap/sqlite.rs @@ -38,7 +38,9 @@ pub fn map_type(declared_type: &str, time_crate: TimeCrate) -> RustType { }; } if upper.contains("NUMERIC") || upper.contains("DECIMAL") { - return RustType::simple("f64"); + // f64 would silently lose precision for currency-style values. sqlx exposes + // the same Decimal type for sqlite as for postgres/mysql. + return RustType::with_import("Decimal", "use rust_decimal::Decimal;"); } // Default: SQLite is loosely typed @@ -164,13 +166,16 @@ mod tests { } #[test] - fn test_numeric() { - assert_eq!(map_type("NUMERIC", TimeCrate::Chrono).path, "f64"); + fn test_numeric_uses_decimal() { + let rt = map_type("NUMERIC", TimeCrate::Chrono); + assert_eq!(rt.path, "Decimal"); + assert!(rt.needs_import.as_ref().unwrap().contains("rust_decimal")); } #[test] - fn test_decimal() { - assert_eq!(map_type("DECIMAL", TimeCrate::Chrono).path, "f64"); + fn test_decimal_uses_decimal() { + let rt = map_type("DECIMAL", TimeCrate::Chrono); + assert_eq!(rt.path, "Decimal"); } #[test] From 8b71ff767900746485a0c76b75738a6e4a17864d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:32:28 +0200 Subject: [PATCH 13/38] feat: emit PgHasArrayType impl for Postgres enums and composites Before this commit, a Postgres column of type my_enum[] was mapped to Vec but the generated MyEnum had no PgHasArrayType impl. At runtime sqlx then bailed with "unsupported type _my_enum of column #N" because it could not resolve the array's element type info. Now both enum_gen and composite_gen emit an `impl PgHasArrayType` whose array_type_info() returns PgTypeInfo::with_name("_"), which matches how Postgres names array types. Gated on DatabaseKind::Postgres so MySQL and SQLite output is unchanged. --- crates/sqlx_gen/src/codegen/composite_gen.rs | 29 ++++++++++++ crates/sqlx_gen/src/codegen/enum_gen.rs | 49 ++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index e568f55..f41e94a 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -93,6 +93,21 @@ pub fn generate_composite( }) .collect(); + // Same rationale as enum_gen: an array of a composite type in PG needs + // PgHasArrayType for Vec to decode at runtime. + let array_type_impl = if db_kind == DatabaseKind::Postgres { + let array_type_name = format!("_{}", composite.name); + quote! { + impl sqlx::postgres::PgHasArrayType for #struct_name { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + sqlx::postgres::PgTypeInfo::with_name(#array_type_name) + } + } + } + } else { + quote! {} + }; + let tokens = quote! { #[doc = #doc] #[derive(#(#derive_tokens),*)] @@ -101,6 +116,8 @@ pub fn generate_composite( pub struct #struct_name { #(#fields)* } + + #array_type_impl }; (tokens, imports) @@ -183,6 +200,18 @@ mod tests { assert!(code.contains("sqlx(type_name = \"geo_point\")")); } + #[test] + fn test_postgres_emits_pg_has_array_type_impl() { + let c = make_composite("address", vec![make_field("street", "text", false)]); + let code = gen(&c); + assert!( + code.contains("impl sqlx::postgres::PgHasArrayType for Address"), + "must impl PgHasArrayType so Vec
works, got:\n{}", + code + ); + assert!(code.contains("\"_address\"")); + } + #[test] fn test_non_public_schema_type_name_is_unqualified() { // Regression: previously emitted "geo.point" which crashes sqlx 0.8 at runtime. diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 16dadfc..69be810 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -83,6 +83,23 @@ pub fn generate_enum( quote! {} }; + // Postgres arrays of an enum (`my_enum[]`) require an explicit + // PgHasArrayType impl on the Rust side; otherwise sqlx fails at decode + // with "unsupported type _my_enum". The array type name in PG is the + // base type prefixed with '_'. + let array_type_impl = if db_kind == DatabaseKind::Postgres { + let array_type_name = format!("_{}", enum_info.name); + quote! { + impl sqlx::postgres::PgHasArrayType for #enum_name { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + sqlx::postgres::PgTypeInfo::with_name(#array_type_name) + } + } + } + } else { + quote! {} + }; + let schema_name_str = &enum_info.schema_name; let enum_name_str = &enum_info.name; @@ -96,6 +113,8 @@ pub fn generate_enum( } #default_impl + + #array_type_impl }; (tokens, imports) @@ -181,6 +200,36 @@ mod tests { assert!(code.contains("sqlx(type_name = \"user_status\")")); } + #[test] + fn test_postgres_emits_pg_has_array_type_impl() { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, DatabaseKind::Postgres); + assert!( + code.contains("impl sqlx::postgres::PgHasArrayType for Status"), + "must impl PgHasArrayType so Vec works, got:\n{}", + code + ); + assert!( + code.contains("\"_status\""), + "array type name must be '_', got:\n{}", + code + ); + } + + #[test] + fn test_mysql_does_not_emit_pg_has_array_type_impl() { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, DatabaseKind::Mysql); + assert!(!code.contains("PgHasArrayType")); + } + + #[test] + fn test_sqlite_does_not_emit_pg_has_array_type_impl() { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, DatabaseKind::Sqlite); + assert!(!code.contains("PgHasArrayType")); + } + #[test] fn test_postgres_non_public_schema_type_name_is_unqualified() { // Regression: previously emitted "auth.role" which crashes sqlx 0.8 at runtime From 0e2c3419cf6a8327e0f2f0b339f2508783c63467 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:33:15 +0200 Subject: [PATCH 14/38] feat: detect enum variant collisions after camelCase conversion Two SQL enum values like 'foo bar' and 'foo_bar' both collapse to the Rust identifier FooBar via to_upper_camel_case, which previously generated code that would not compile. check_variant_collisions runs during codegen::generate and returns a clear Error::Config pointing at the conflicting variants and the Rust identifier they share. --- crates/sqlx_gen/src/codegen/enum_gen.rs | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 69be810..7e01a3d 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -8,6 +8,27 @@ use crate::cli::DatabaseKind; use crate::codegen::imports_for_derives; use crate::introspect::EnumInfo; +/// Detect two SQL enum variants that collapse to the same Rust identifier after +/// `to_upper_camel_case` (e.g. `"foo bar"` and `"foo_bar"` both become `FooBar`). +/// Returns an error pointing at the offending pair so the user can rename one +/// side in the database before regenerating. +pub fn check_variant_collisions(enum_info: &EnumInfo) -> crate::error::Result<()> { + use std::collections::BTreeMap; + let mut seen: BTreeMap = BTreeMap::new(); + for v in &enum_info.variants { + let pascal = v.to_upper_camel_case(); + if let Some(prev) = seen.get(pascal.as_str()).copied() { + return Err(crate::error::Error::Config(format!( + "Enum '{}.{}': SQL variants '{}' and '{}' both map to Rust identifier '{}'. \ + Rename one of them in the database or use a custom mapping.", + enum_info.schema_name, enum_info.name, prev, v, pascal + ))); + } + seen.insert(pascal, v.as_str()); + } + Ok(()) +} + pub fn generate_enum( enum_info: &EnumInfo, db_kind: DatabaseKind, @@ -200,6 +221,33 @@ mod tests { assert!(code.contains("sqlx(type_name = \"user_status\")")); } + #[test] + fn test_check_variant_collisions_detects_after_camel_case() { + let e = EnumInfo { + schema_name: "public".into(), + name: "weird".into(), + variants: vec!["foo bar".into(), "foo_bar".into()], + default_variant: None, + }; + let result = check_variant_collisions(&e); + assert!(result.is_err(), "must detect collision"); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("FooBar"), "error must mention conflicting Rust ident, got: {}", msg); + assert!(msg.contains("foo bar") || msg.contains("foo_bar")); + } + + #[test] + fn test_check_variant_collisions_accepts_distinct_variants() { + let e = make_enum("status", vec!["active", "inactive"]); + assert!(check_variant_collisions(&e).is_ok()); + } + + #[test] + fn test_check_variant_collisions_accepts_single_variant() { + let e = make_enum("status", vec!["only"]); + assert!(check_variant_collisions(&e).is_ok()); + } + #[test] fn test_postgres_emits_pg_has_array_type_impl() { let e = make_enum("status", vec!["a", "b"]); From 0d6dbf8841f5aaea023d4150e299c7438c2c03b9 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:33:47 +0200 Subject: [PATCH 15/38] fix: wire enum collision check into codegen::generate --- crates/sqlx_gen/src/codegen/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index 756ab78..e64f257 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -156,6 +156,7 @@ pub fn generate( // Enrich enums with default variants extracted from column defaults let enum_defaults = extract_enum_defaults(schema_info); for enum_info in &schema_info.enums { + enum_gen::check_variant_collisions(enum_info)?; let mut enriched = enum_info.clone(); if enriched.default_variant.is_none() { if let Some(default) = enum_defaults.get(&enum_info.name) { From d5a422047070324b8f3ea62c2c4e33c89f6bafea Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:37:29 +0200 Subject: [PATCH 16/38] feat: sanitize column names that aren't valid Rust identifiers Columns named "user-id", "created at", "123foo" etc. previously produced Rust code that wouldn't compile because format_ident! cannot encode dashes/spaces/leading digits. sanitize_rust_ident: - replaces every non-alphanumeric (and non-_) character with '_' - prefixes a '_' if the result starts with a digit - falls back to "_field" on an empty string The original DB column name is preserved via the existing #[sqlx(rename = "")] rewrite, so reads and writes still hit the right column. --- crates/sqlx_gen/src/codegen/struct_gen.rs | 67 ++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/sqlx_gen/src/codegen/struct_gen.rs b/crates/sqlx_gen/src/codegen/struct_gen.rs index 575ba92..d4df440 100644 --- a/crates/sqlx_gen/src/codegen/struct_gen.rs +++ b/crates/sqlx_gen/src/codegen/struct_gen.rs @@ -52,7 +52,7 @@ pub fn generate_struct( imports.insert(imp.clone()); } - let field_name_snake = col.name.to_snake_case(); + let field_name_snake = sanitize_rust_ident(&col.name.to_snake_case()); // If the field name is a Rust keyword, prefix with table name // e.g. column "type" on table "connector" → "connector_type" let (effective_name, needs_rename) = if is_rust_keyword(&field_name_snake) { @@ -125,6 +125,28 @@ pub fn generate_struct( (tokens, imports) } +/// Sanitize a candidate Rust identifier: +/// - replace any character that is not ascii-alphanumeric or '_' with '_' +/// - prefix with '_' if the result starts with a digit +/// - fall back to "_field" if the input is empty +/// +/// Lets sqlx-gen survive columns named `user-id`, `created at`, `123`, etc. +/// — they still need a `#[sqlx(rename = "")]` to roundtrip the DB +/// column, which the caller handles via the `changed` flag. +pub(crate) fn sanitize_rust_ident(name: &str) -> String { + if name.is_empty() { + return "_field".to_string(); + } + let mut out: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }) + .collect(); + if out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + /// Detect if a column uses a custom SQL type (enum or composite) and return the qualified /// SQL type name for casting, plus whether it's an array. /// Returns `(Some("type_name"), true)` for arrays of custom types, @@ -517,4 +539,47 @@ mod tests { assert!(code.contains("pub name: String")); assert!(!code.contains("sql_type")); } + + // ========== sanitize_rust_ident ========== + + #[test] + fn test_sanitize_replaces_dash() { + assert_eq!(sanitize_rust_ident("user-id"), "user_id"); + } + + #[test] + fn test_sanitize_replaces_space() { + assert_eq!(sanitize_rust_ident("created at"), "created_at"); + } + + #[test] + fn test_sanitize_replaces_dot() { + assert_eq!(sanitize_rust_ident("a.b"), "a_b"); + } + + #[test] + fn test_sanitize_prefixes_leading_digit() { + assert_eq!(sanitize_rust_ident("123abc"), "_123abc"); + } + + #[test] + fn test_sanitize_empty_becomes_placeholder() { + assert_eq!(sanitize_rust_ident(""), "_field"); + } + + #[test] + fn test_sanitize_leaves_valid_ident_unchanged() { + assert_eq!(sanitize_rust_ident("user_id"), "user_id"); + assert_eq!(sanitize_rust_ident("_private"), "_private"); + } + + #[test] + fn test_column_with_dash_generates_valid_rust() { + let table = make_table("users", vec![make_col("user-id", "int4", false)]); + let code = gen(&table); + // Must produce a Rust-legal identifier; renamed back to the original via #[sqlx(rename)] + assert!(code.contains("pub user_id:") || code.contains("user_id:"), + "expected sanitized identifier, got:\n{}", code); + assert!(code.contains("sqlx(rename = \"user-id\")")); + } } From 89d18912d948c552e656552b04bd9f1dcd3b9767 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:37:52 +0200 Subject: [PATCH 17/38] feat: accept Postgres array type names in both _x and x[] notations --- crates/sqlx_gen/src/typemap/postgres.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/sqlx_gen/src/typemap/postgres.rs b/crates/sqlx_gen/src/typemap/postgres.rs index 5454d57..46b3912 100644 --- a/crates/sqlx_gen/src/typemap/postgres.rs +++ b/crates/sqlx_gen/src/typemap/postgres.rs @@ -32,11 +32,17 @@ pub fn is_builtin(udt_name: &str) -> bool { } pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) -> RustType { - // Handle array types (prefixed with '_' in PG) + // Handle array types: PG's information_schema may report them either as + // `_int4` (information_schema.columns.udt_name) or `integer[]` + // (pg_catalog.format_type). Both should produce Vec. if let Some(inner) = udt_name.strip_prefix('_') { let inner_type = map_type(inner, schema_info, time_crate); return inner_type.wrap_vec(); } + if let Some(inner) = udt_name.strip_suffix("[]") { + let inner_type = map_type(inner.trim(), schema_info, time_crate); + return inner_type.wrap_vec(); + } // Check if it's a known enum if schema_info.enums.iter().any(|e| e.name == udt_name) { @@ -347,6 +353,16 @@ mod tests { assert_eq!(map_type("_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec"); } + #[test] + fn test_array_bracket_notation() { + assert_eq!(map_type("integer[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + } + + #[test] + fn test_array_bracket_text() { + assert_eq!(map_type("text[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + } + #[test] fn test_array_text() { assert_eq!(map_type("_text", &empty_schema(), TimeCrate::Chrono).path, "Vec"); From dd6c90e4785e99f0b70678a8ad76605cbb378340 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:38:04 +0200 Subject: [PATCH 18/38] chore: document MSRV as rust 1.75 in both crates --- crates/sqlx_gen/Cargo.toml | 1 + crates/sqlx_gen_macros/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/sqlx_gen/Cargo.toml b/crates/sqlx_gen/Cargo.toml index 6b22cff..0071c88 100644 --- a/crates/sqlx_gen/Cargo.toml +++ b/crates/sqlx_gen/Cargo.toml @@ -2,6 +2,7 @@ name = "sqlx-gen" version = "0.5.5" edition = "2021" +rust-version = "1.75" description = "Generate Rust structs from database schema introspection" license = "MIT" repository = "https://github.com/LeadcodeDev/sqlx-gen" diff --git a/crates/sqlx_gen_macros/Cargo.toml b/crates/sqlx_gen_macros/Cargo.toml index c1d7015..1b383da 100644 --- a/crates/sqlx_gen_macros/Cargo.toml +++ b/crates/sqlx_gen_macros/Cargo.toml @@ -2,6 +2,7 @@ name = "sqlx-gen-macros" version = "0.5.5" edition = "2021" +rust-version = "1.75" description = "No-op attribute macros for sqlx-gen generated code" license = "MIT" repository = "https://github.com/LeadcodeDev/sqlx-gen" From ccb11c1d0b7774f545b85d23113d3de3e3e16350 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:38:25 +0200 Subject: [PATCH 19/38] fix: short-circuit insert_many_transactionally on empty input --- crates/sqlx_gen/src/codegen/crud_gen.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 829f007..605fa00 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -1159,6 +1159,12 @@ fn build_insert_many_transactionally_method( &self, entries: Vec<#insert_params_ident>, ) -> Result, sqlx::Error> { + // Short-circuit empty batches: avoids opening a transaction and + // sending a zero-row INSERT (or a "VALUES " with no tuples, which + // Postgres rejects as a syntax error). + if entries.is_empty() { + return Ok(Vec::new()); + } #body } } From 810d732b4f5d44cc3a2a353bb587df40d6217279 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:38:39 +0200 Subject: [PATCH 20/38] feat: warn when introspection returns no tables/views/enums --- crates/sqlx_gen/src/main.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 6a9fdf6..8a97f53 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -89,6 +89,14 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { schema_info.composite_types.len(), schema_info.domains.len(), ); + if table_count == 0 && view_count == 0 && enum_count == 0 { + warn!( + "No tables, views, or enums found in schemas {:?}. \ + Either the schema is empty or the DB user lacks SELECT on \ + information_schema. Check credentials and `--schemas`.", + args.db.schemas + ); + } let files = codegen::generate( &schema_info, From c89459be17a3d5a978078d5796055cc5a9b6d474 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:39:25 +0200 Subject: [PATCH 21/38] fix: pick raw-string fence width that avoids embedded # --- crates/sqlx_gen/src/codegen/crud_gen.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 605fa00..47db3be 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -857,14 +857,26 @@ fn pool_type_tokens(db_kind: DatabaseKind) -> TokenStream { } } -/// Wraps a SQL string as a raw string literal `r#"..."#` in the generated code. -/// Multi-line SQL gets a leading newline so each clause starts on its own line. +/// Wraps a SQL string as a raw string literal `r#"..."#` (or `r##"..."##` +/// when the body contains `"#`) in the generated code. Multi-line SQL gets +/// a leading newline so each clause starts on its own line. fn raw_sql_lit(s: &str) -> TokenStream { - if s.contains('\n') { - format!("r#\"\n{}\n\"#", s).parse().unwrap() + // Pick the smallest number of `#` characters whose fence isn't present in + // the body. Quoted SQL identifiers (`"users"`) followed by `#` are rare in + // practice but a malicious or quirky DB name could trip the default fence. + let mut hashes = 1usize; + while s.contains(&format!("\"{}", "#".repeat(hashes))) { + hashes += 1; + } + let fence = "#".repeat(hashes); + let body = if s.contains('\n') { + format!("\n{}\n", s) } else { - format!("r#\"{}\"#", s).parse().unwrap() - } + s.to_string() + }; + format!("r{fence}\"{body}\"{fence}", fence = fence, body = body) + .parse() + .expect("raw_sql_lit must produce a valid Rust raw string literal") } fn placeholder(db_kind: DatabaseKind, index: usize) -> String { From 65fa852217c8d01c2c1a9b2024e75ddd479cceff Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:40:07 +0200 Subject: [PATCH 22/38] feat: omit schema qualifier for default schemas in generated SQL Tables in "public" (Postgres), "main" (SQLite), or "dbo" no longer get their schema rendered into every generated SELECT/INSERT/UPDATE/DELETE. The qualified form is still used for non-default schemas, where it is required for unambiguous resolution. --- crates/sqlx_gen/src/codegen/crud_gen.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 47db3be..821a581 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -21,11 +21,15 @@ pub fn generate_crud_from_parsed( let repo_name = format!("{}Repository", entity.struct_name); let repo_ident = format_ident!("{}", repo_name); - let table_name = quote_qualified( - entity.schema_name.as_deref(), - &entity.table_name, - db_kind, - ); + // Skip schema qualification for the well-known default schema of each + // backend ("public" for Postgres, "main" for SQLite, "dbo" for SQL Server- + // adjacent flows). Avoids verbose "public"."users" everywhere when the + // user has no other schema in play. + let schema_for_sql = entity + .schema_name + .as_deref() + .filter(|s| !crate::codegen::is_default_schema(s)); + let table_name = quote_qualified(schema_for_sql, &entity.table_name, db_kind); // Pool type (used via full path sqlx::PgPool etc., no import needed) let pool_type = pool_type_tokens(db_kind); From c66a1894027993b116462736ad226d3fed231d78 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:40:50 +0200 Subject: [PATCH 23/38] chore: silence clippy warnings (manual strip_prefix, broken doc list) --- crates/sqlx_gen/src/codegen/mod.rs | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index e64f257..fe8c742 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -248,16 +248,12 @@ fn extract_enum_defaults(schema_info: &SchemaInfo) -> HashMap { /// Handles formats like `'idle'::task_status`, `'idle'::public.task_status`. fn parse_pg_enum_default(default_expr: &str) -> Option { // Pattern: 'value'::some_type - let stripped = default_expr.trim(); - if stripped.starts_with('\'') { - if let Some(end_quote) = stripped[1..].find('\'') { - let value = &stripped[1..1 + end_quote]; - // Verify there's a :: cast after the closing quote - let rest = &stripped[2 + end_quote..]; - if rest.starts_with("::") { - return Some(value.to_string()); - } - } + let after_opening = default_expr.trim().strip_prefix('\'')?; + let end_quote = after_opening.find('\'')?; + let value = &after_opening[..end_quote]; + let rest = &after_opening[end_quote + 1..]; + if rest.starts_with("::") { + return Some(value.to_string()); } None } @@ -398,10 +394,6 @@ fn is_import_used(import: &str, code: &str) -> bool { true } -/// Post-process formatted code to: -/// - Add blank lines between enum variants with `#[sqlx(rename` -/// - Add blank lines between top-level items (structs, impls) -/// - Add blank lines between logical blocks inside async methods /// Indent the content of multi-line raw string literals (`r#"..."#`) so SQL /// reads naturally in generated code. All SQL raw strings live inside `impl` /// methods, so content is indented at a fixed 2-level depth and relative From ac18eb11a5865427b5d3dbb3f1c87b15d51ecb4e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:41:00 +0200 Subject: [PATCH 24/38] style: apply rustfmt --- crates/sqlx_gen/src/cli.rs | 38 +- crates/sqlx_gen/src/codegen/composite_gen.rs | 54 ++- crates/sqlx_gen/src/codegen/crud_gen.rs | 5 +- crates/sqlx_gen/src/codegen/domain_gen.rs | 24 +- crates/sqlx_gen/src/codegen/entity_parser.rs | 39 +- crates/sqlx_gen/src/codegen/enum_gen.rs | 51 ++- crates/sqlx_gen/src/codegen/mod.rs | 381 +++++++++++++++---- crates/sqlx_gen/src/codegen/struct_gen.rs | 194 +++++++--- crates/sqlx_gen/src/error.rs | 5 +- crates/sqlx_gen/src/introspect/mysql.rs | 57 +-- crates/sqlx_gen/src/introspect/postgres.rs | 55 ++- crates/sqlx_gen/src/introspect/sqlite.rs | 24 +- crates/sqlx_gen/src/main.rs | 30 +- crates/sqlx_gen/src/typemap/mod.rs | 39 +- crates/sqlx_gen/src/typemap/mysql.rs | 158 ++++++-- crates/sqlx_gen/src/typemap/postgres.rs | 251 +++++++++--- crates/sqlx_gen/src/typemap/sqlite.rs | 20 +- crates/sqlx_gen/src/writer.rs | 24 +- crates/sqlx_gen/tests/e2e_sqlite.rs | 203 ++++++++-- crates/sqlx_gen/tests/introspect_sqlite.rs | 48 ++- 20 files changed, 1306 insertions(+), 394 deletions(-) diff --git a/crates/sqlx_gen/src/cli.rs b/crates/sqlx_gen/src/cli.rs index ddedefa..c134b14 100644 --- a/crates/sqlx_gen/src/cli.rs +++ b/crates/sqlx_gen/src/cli.rs @@ -3,7 +3,10 @@ use std::collections::HashMap; use std::path::PathBuf; #[derive(Parser, Debug)] -#[command(name = "sqlx-gen", about = "Generate Rust structs from database schema")] +#[command( + name = "sqlx-gen", + about = "Generate Rust structs from database schema" +)] pub struct Cli { #[command(subcommand)] pub command: Command, @@ -166,7 +169,6 @@ pub struct CrudArgs { #[arg(short = 'm', long, value_delimiter = ',')] pub methods: Vec, - /// Use sqlx::query_as!() compile-time checked macros instead of query_as::<_, T>() functions #[arg(short = 'q', long)] pub query_macro: bool, @@ -303,7 +305,16 @@ pub struct Methods { pub delete: bool, } -const ALL_METHODS: &[&str] = &["get_all", "paginate", "get", "insert", "insert_many", "update", "overwrite", "delete"]; +const ALL_METHODS: &[&str] = &[ + "get_all", + "paginate", + "get", + "insert", + "insert_many", + "update", + "overwrite", + "delete", +]; impl Methods { /// Parse a list of method names. `"*"` enables all methods. @@ -486,7 +497,10 @@ mod tests { fn test_overrides_checked_rejects_injection() { let args = make_entities_args_with_overrides(vec!["jsonb=Vec; fn pwned() {}"]); let result = args.parse_type_overrides_checked(); - assert!(result.is_err(), "must reject value that isn't a single Rust type"); + assert!( + result.is_err(), + "must reject value that isn't a single Rust type" + ); } #[test] @@ -569,9 +583,16 @@ mod tests { #[test] fn test_exclude_tables_set() { let mut args = make_entities_args_with_overrides(vec![]); - args.exclude_tables = Some(vec!["_migrations".to_string(), "schema_versions".to_string()]); + args.exclude_tables = Some(vec![ + "_migrations".to_string(), + "schema_versions".to_string(), + ]); assert_eq!(args.exclude_tables.as_ref().unwrap().len(), 2); - assert!(args.exclude_tables.as_ref().unwrap().contains(&"_migrations".to_string())); + assert!(args + .exclude_tables + .as_ref() + .unwrap() + .contains(&"_migrations".to_string())); } // ========== methods ========== @@ -671,7 +692,10 @@ mod tests { #[test] fn test_module_path_nested() { let p = PathBuf::from("src/db/entities/agent.rs"); - assert_eq!(module_path_from_file(&p).unwrap(), "crate::db::entities::agent"); + assert_eq!( + module_path_from_file(&p).unwrap(), + "crate::db::entities::agent" + ); } #[test] diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index f41e94a..00065a7 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -55,18 +55,15 @@ pub fn generate_composite( .fields .iter() .map(|col| { - let rust_type = typemap::map_column(col, db_kind, schema_info, type_overrides, time_crate); + let rust_type = + typemap::map_column(col, db_kind, schema_info, type_overrides, time_crate); if let Some(imp) = &rust_type.needs_import { imports.insert(imp.clone()); } let field_name_snake = col.name.to_snake_case(); let (effective_name, needs_rename) = if is_rust_keyword(&field_name_snake) { - let prefixed = format!( - "{}_{}", - composite.name.to_snake_case(), - field_name_snake - ); + let prefixed = format!("{}_{}", composite.name.to_snake_case(), field_name_snake); (prefixed, true) } else { let changed = field_name_snake != col.name; @@ -152,7 +149,14 @@ mod tests { fn gen(composite: &CompositeTypeInfo) -> String { let schema = SchemaInfo::default(); - let (tokens, _) = generate_composite(composite, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), TimeCrate::Chrono); + let (tokens, _) = generate_composite( + composite, + DatabaseKind::Postgres, + &schema, + &[], + &HashMap::new(), + TimeCrate::Chrono, + ); parse_and_format(&tokens).unwrap() } @@ -162,7 +166,14 @@ mod tests { overrides: &HashMap, ) -> (String, BTreeSet) { let schema = SchemaInfo::default(); - let (tokens, imports) = generate_composite(composite, DatabaseKind::Postgres, &schema, derives, overrides, TimeCrate::Chrono); + let (tokens, imports) = generate_composite( + composite, + DatabaseKind::Postgres, + &schema, + derives, + overrides, + TimeCrate::Chrono, + ); (parse_and_format(&tokens).unwrap(), imports) } @@ -170,10 +181,13 @@ mod tests { #[test] fn test_simple_composite() { - let c = make_composite("address", vec![ - make_field("street", "text", false), - make_field("city", "text", false), - ]); + let c = make_composite( + "address", + vec![ + make_field("street", "text", false), + make_field("city", "text", false), + ], + ); let code = gen(&c); assert!(code.contains("pub street: String")); assert!(code.contains("pub city: String")); @@ -221,10 +235,20 @@ mod tests { fields: vec![make_field("x", "float8", false)], }; let schema = SchemaInfo::default(); - let (tokens, _) = generate_composite(&c, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), TimeCrate::Chrono); + let (tokens, _) = generate_composite( + &c, + DatabaseKind::Postgres, + &schema, + &[], + &HashMap::new(), + TimeCrate::Chrono, + ); let code = parse_and_format(&tokens).unwrap(); - assert!(code.contains("sqlx(type_name = \"point\")"), - "type_name must be unqualified for sqlx 0.8, got:\n{}", code); + assert!( + code.contains("sqlx(type_name = \"point\")"), + "type_name must be unqualified for sqlx 0.8, got:\n{}", + code + ); assert!(!code.contains("\"geo.point\"")); } diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 821a581..8352194 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -474,7 +474,6 @@ pub fn generate_crud_from_parsed( } }; - let sql = raw_sql_lit(&build_overwrite_sql(&set_clause, &where_clause)); let sql_macro = raw_sql_lit(&build_overwrite_sql(&set_clause_cast, &where_clause_cast)); @@ -2683,7 +2682,9 @@ mod tests { code ); assert!( - code.contains("INSERT INTO \"analysis\".\"analysis__record\" (\"record_id\", \"analysis_id\")"), + code.contains( + "INSERT INTO \"analysis\".\"analysis__record\" (\"record_id\", \"analysis_id\")" + ), "Expected quoted INSERT INTO clause:\n{}", code ); diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index fef3d66..f259729 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -35,7 +35,8 @@ pub fn generate_domain( column_default: None, }; - let rust_type = typemap::map_column(&fake_col, db_kind, schema_info, type_overrides, time_crate); + let rust_type = + typemap::map_column(&fake_col, db_kind, schema_info, type_overrides, time_crate); if let Some(imp) = &rust_type.needs_import { imports.insert(imp.clone()); } @@ -70,13 +71,28 @@ mod tests { fn gen(domain: &DomainInfo) -> (String, BTreeSet) { let schema = SchemaInfo::default(); - let (tokens, imports) = generate_domain(domain, DatabaseKind::Postgres, &schema, &HashMap::new(), TimeCrate::Chrono); + let (tokens, imports) = generate_domain( + domain, + DatabaseKind::Postgres, + &schema, + &HashMap::new(), + TimeCrate::Chrono, + ); (parse_and_format(&tokens).unwrap(), imports) } - fn gen_with_overrides(domain: &DomainInfo, overrides: &HashMap) -> (String, BTreeSet) { + fn gen_with_overrides( + domain: &DomainInfo, + overrides: &HashMap, + ) -> (String, BTreeSet) { let schema = SchemaInfo::default(); - let (tokens, imports) = generate_domain(domain, DatabaseKind::Postgres, &schema, overrides, TimeCrate::Chrono); + let (tokens, imports) = generate_domain( + domain, + DatabaseKind::Postgres, + &schema, + overrides, + TimeCrate::Chrono, + ); (parse_and_format(&tokens).unwrap(), imports) } diff --git a/crates/sqlx_gen/src/codegen/entity_parser.rs b/crates/sqlx_gen/src/codegen/entity_parser.rs index fcc1f72..e911f27 100644 --- a/crates/sqlx_gen/src/codegen/entity_parser.rs +++ b/crates/sqlx_gen/src/codegen/entity_parser.rs @@ -45,9 +45,8 @@ pub struct ParsedEntity { /// Parse an entity struct from a `.rs` file on disk. pub fn parse_entity_file(path: &Path) -> crate::error::Result { let source = std::fs::read_to_string(path).map_err(crate::error::Error::Io)?; - parse_entity_source(&source).map_err(|e| { - crate::error::Error::Config(format!("{}: {}", path.display(), e)) - }) + parse_entity_source(&source) + .map_err(|e| crate::error::Error::Config(format!("{}: {}", path.display(), e))) } /// Parse an entity struct from a Rust source string. @@ -127,13 +126,11 @@ fn extract_entity(item: &syn::ItemStruct) -> Result { let table_name = table_name.unwrap_or_else(|| struct_name.clone()); let fields = match &item.fields { - syn::Fields::Named(named) => { - named - .named - .iter() - .map(extract_field) - .collect::, _>>()? - } + syn::Fields::Named(named) => named + .named + .iter() + .map(extract_field) + .collect::, _>>()?, _ => return Err("Expected named fields".to_string()), }; @@ -149,7 +146,9 @@ fn extract_entity(item: &syn::ItemStruct) -> Result { /// Parse `#[sqlx_gen(kind = "...", schema = "...", table = "...")]` from struct attributes. /// Returns (kind, schema_name, table_name). -fn parse_sqlx_gen_struct_attrs(attrs: &[syn::Attribute]) -> (Option, Option, Option) { +fn parse_sqlx_gen_struct_attrs( + attrs: &[syn::Attribute], +) -> (Option, Option, Option) { let mut kind = None; let mut schema_name = None; let mut table_name = None; @@ -194,14 +193,11 @@ fn extract_attr_value(tokens: &str, key: &str) -> Option { /// Extract a ParsedField from a syn::Field. fn extract_field(field: &syn::Field) -> Result { - let rust_name = field - .ident - .as_ref() - .ok_or("Unnamed field")? - .to_string(); + let rust_name = field.ident.as_ref().ok_or("Unnamed field")?.to_string(); let column_name = get_sqlx_rename(&field.attrs).unwrap_or_else(|| rust_name.clone()); - let (is_primary_key, sql_type, is_sql_array, column_default) = parse_sqlx_gen_field_attrs(&field.attrs); + let (is_primary_key, sql_type, is_sql_array, column_default) = + parse_sqlx_gen_field_attrs(&field.attrs); let rust_type = field.ty.to_token_stream().to_string(); let (is_nullable, inner_type) = extract_option_type(&field.ty); @@ -226,7 +222,9 @@ fn extract_field(field: &syn::Field) -> Result { /// Parse `#[sqlx_gen(...)]` attributes on a field. /// Returns (is_primary_key, sql_type, is_sql_array, column_default). -fn parse_sqlx_gen_field_attrs(attrs: &[syn::Attribute]) -> (bool, Option, bool, Option) { +fn parse_sqlx_gen_field_attrs( + attrs: &[syn::Attribute], +) -> (bool, Option, bool, Option) { let mut is_pk = false; let mut sql_type = None; let mut is_array = false; @@ -674,7 +672,10 @@ mod tests { "#; let entity = parse_entity_source(source).unwrap(); let status = &entity.fields[1]; - assert_eq!(status.column_default, Some("'idle'::task_status".to_string())); + assert_eq!( + status.column_default, + Some("'idle'::task_status".to_string()) + ); } #[test] diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 7e01a3d..e4b7e99 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -232,7 +232,11 @@ mod tests { let result = check_variant_collisions(&e); assert!(result.is_err(), "must detect collision"); let msg = result.unwrap_err().to_string(); - assert!(msg.contains("FooBar"), "error must mention conflicting Rust ident, got: {}", msg); + assert!( + msg.contains("FooBar"), + "error must mention conflicting Rust ident, got: {}", + msg + ); assert!(msg.contains("foo bar") || msg.contains("foo_bar")); } @@ -290,10 +294,16 @@ mod tests { }; let (tokens, _) = generate_enum(&e, DatabaseKind::Postgres, &[]); let code = parse_and_format(&tokens).unwrap(); - assert!(code.contains("sqlx(type_name = \"role\")"), - "type_name must be unqualified for sqlx 0.8 compatibility, got:\n{}", code); - assert!(!code.contains("\"auth.role\""), - "type_name must NOT include schema; got:\n{}", code); + assert!( + code.contains("sqlx(type_name = \"role\")"), + "type_name must be unqualified for sqlx 0.8 compatibility, got:\n{}", + code + ); + assert!( + !code.contains("\"auth.role\""), + "type_name must NOT include schema; got:\n{}", + code + ); } #[test] @@ -437,7 +447,11 @@ mod tests { let e = EnumInfo { schema_name: "public".to_string(), name: "task_status".to_string(), - variants: vec!["idle".to_string(), "running".to_string(), "done".to_string()], + variants: vec![ + "idle".to_string(), + "running".to_string(), + "done".to_string(), + ], default_variant: Some("idle".to_string()), }; let code = gen(&e, DatabaseKind::Postgres); @@ -478,14 +492,19 @@ mod tests { #[test] fn test_public_schema_full_output() { - let e = make_enum_in_schema("public", "order_status", vec!["pending", "shipped", "delivered"]); + let e = make_enum_in_schema( + "public", + "order_status", + vec!["pending", "shipped", "delivered"], + ); let code = gen(&e, DatabaseKind::Postgres); assert!(code.contains("Enum: public.order_status")); assert!(code.contains("pub enum OrderStatus")); assert!(code.contains("sqlx(type_name = \"order_status\")")); assert!(!code.contains("sqlx(type_name = \"public.order_status\")")); - assert!(code.contains("sqlx_gen(kind = \"enum\", schema = \"public\", name = \"order_status\")")); + assert!(code + .contains("sqlx_gen(kind = \"enum\", schema = \"public\", name = \"order_status\")")); assert!(code.contains("Pending")); assert!(code.contains("Shipped")); assert!(code.contains("Delivered")); @@ -493,14 +512,20 @@ mod tests { #[test] fn test_named_schema_full_output() { - let e = make_enum_in_schema("analysis", "toolcall_status", vec!["PENDING", "RUNNING", "DONE"]); + let e = make_enum_in_schema( + "analysis", + "toolcall_status", + vec!["PENDING", "RUNNING", "DONE"], + ); let code = gen(&e, DatabaseKind::Postgres); assert!(code.contains("Enum: analysis.toolcall_status")); assert!(code.contains("pub enum ToolcallStatus")); assert!(code.contains("sqlx(type_name = \"toolcall_status\")")); assert!(!code.contains("\"analysis.toolcall_status\"")); - assert!(code.contains("sqlx_gen(kind = \"enum\", schema = \"analysis\", name = \"toolcall_status\")")); + assert!(code.contains( + "sqlx_gen(kind = \"enum\", schema = \"analysis\", name = \"toolcall_status\")" + )); assert!(code.contains("Pending")); assert!(code.contains("Running")); assert!(code.contains("Done")); @@ -511,7 +536,11 @@ mod tests { let e = EnumInfo { schema_name: "billing".to_string(), name: "payment_status".to_string(), - variants: vec!["pending".to_string(), "paid".to_string(), "refunded".to_string()], + variants: vec![ + "pending".to_string(), + "paid".to_string(), + "refunded".to_string(), + ], default_variant: Some("pending".to_string()), }; let code = gen(&e, DatabaseKind::Postgres); diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index fe8c742..ffc227d 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -16,11 +16,11 @@ use crate::introspect::SchemaInfo; /// Rust reserved keywords that cannot be used as identifiers. const RUST_KEYWORDS: &[&str] = &[ - "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", - "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", - "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", - "type", "unsafe", "use", "where", "while", "yield", "abstract", "become", "box", "do", - "final", "macro", "override", "priv", "try", "typeof", "unsized", "virtual", + "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum", "extern", + "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", + "ref", "return", "self", "Self", "static", "struct", "super", "trait", "true", "type", + "unsafe", "use", "where", "while", "yield", "abstract", "become", "box", "do", "final", + "macro", "override", "priv", "try", "typeof", "unsized", "virtual", ]; /// Returns true if the given name is a Rust reserved keyword. @@ -86,10 +86,14 @@ pub fn build_module_name(schema_name: &str, table_name: &str, name_collides: boo fn find_colliding_names(schema_info: &SchemaInfo) -> BTreeSet<&str> { let mut seen: HashMap<&str, BTreeSet<&str>> = HashMap::new(); for t in &schema_info.tables { - seen.entry(t.name.as_str()).or_default().insert(t.schema_name.as_str()); + seen.entry(t.name.as_str()) + .or_default() + .insert(t.schema_name.as_str()); } for v in &schema_info.views { - seen.entry(v.name.as_str()).or_default().insert(v.schema_name.as_str()); + seen.entry(v.name.as_str()) + .or_default() + .insert(v.schema_name.as_str()); } seen.into_iter() .filter(|(_, schemas)| schemas.len() > 1) @@ -122,11 +126,22 @@ pub fn generate( // Generate struct files for each table for table in &schema_info.tables { - let (tokens, imports) = - struct_gen::generate_struct(table, db_kind, schema_info, extra_derives, type_overrides, false, time_crate); + let (tokens, imports) = struct_gen::generate_struct( + table, + db_kind, + schema_info, + extra_derives, + type_overrides, + false, + time_crate, + ); let imports = filter_imports(&imports, single_file); let code = format_tokens_with_imports(&tokens, &imports)?; - let module_name = build_module_name(&table.schema_name, &table.name, colliding_names.contains(table.name.as_str())); + let module_name = build_module_name( + &table.schema_name, + &table.name, + colliding_names.contains(table.name.as_str()), + ); files.push(GeneratedFile { filename: format!("{}.rs", module_name), origin: None, @@ -136,11 +151,22 @@ pub fn generate( // Generate struct files for each view for view in &schema_info.views { - let (tokens, imports) = - struct_gen::generate_struct(view, db_kind, schema_info, extra_derives, type_overrides, true, time_crate); + let (tokens, imports) = struct_gen::generate_struct( + view, + db_kind, + schema_info, + extra_derives, + type_overrides, + true, + time_crate, + ); let imports = filter_imports(&imports, single_file); let code = format_tokens_with_imports(&tokens, &imports)?; - let module_name = build_module_name(&view.schema_name, &view.name, colliding_names.contains(view.name.as_str())); + let module_name = build_module_name( + &view.schema_name, + &view.name, + colliding_names.contains(view.name.as_str()), + ); files.push(GeneratedFile { filename: format!("{}.rs", module_name), origin: None, @@ -189,10 +215,7 @@ pub fn generate( } if !types_blocks.is_empty() { - let import_lines: String = types_imports - .iter() - .map(|i| format!("{}\n", i)) - .collect(); + let import_lines: String = types_imports.iter().map(|i| format!("{}\n", i)).collect(); let body = types_blocks.join("\n"); let code = if import_lines.is_empty() { body @@ -353,10 +376,7 @@ pub fn format_tokens_with_imports_and_tab_spaces( if used_imports.is_empty() { Ok(formatted) } else { - let import_lines: String = used_imports - .iter() - .map(|i| format!("{}\n", i)) - .collect(); + let import_lines: String = used_imports.iter().map(|i| format!("{}\n", i)).collect(); Ok(format!("{}\n\n{}", import_lines.trim_end(), formatted)) } } @@ -404,7 +424,7 @@ fn indent_multiline_raw_strings(code: &str, tab_spaces: usize) -> String { // bake the right indentation at generation time. // The closing "# aligns with the r#" argument level (3 indent levels deep), // and SQL content gets one extra level beyond that. - let close_indent = 4 + tab_spaces; // impl(4) + fn_arg(tab) + let close_indent = 4 + tab_spaces; // impl(4) + fn_arg(tab) let sql_indent = 4 + 2 * tab_spaces; // impl(4) + fn_arg(tab) + sql(tab) let lines: Vec<&str> = code.lines().collect(); @@ -496,14 +516,14 @@ fn add_blank_lines_between_items(code: &str) -> String { let prev_is_await_end = prev.ends_with(".await?;") || prev.ends_with(".await?") || (prev.ends_with(';') && prev.contains(".unwrap_or(")); - if prev_is_await_end - && (trimmed.starts_with("let ") || trimmed.starts_with("Ok(")) - { + if prev_is_await_end && (trimmed.starts_with("let ") || trimmed.starts_with("Ok(")) { result.push(""); } // Separate a sqlx query `let` from preceding simple `let` assignments - if trimmed.starts_with("let ") && trimmed.contains("sqlx::") - && prev.starts_with("let ") && !prev.contains("sqlx::") + if trimmed.starts_with("let ") + && trimmed.contains("sqlx::") + && prev.starts_with("let ") + && !prev.contains("sqlx::") { result.push(""); } @@ -651,7 +671,10 @@ mod tests { #[test] fn test_build_collision_normalizes_double_underscore() { - assert_eq!(build_module_name("billing", "agent__connector", true), "billing_agent_connector"); + assert_eq!( + build_module_name("billing", "agent__connector", true), + "billing_agent_connector" + ); } // ========== is_default_schema ========== @@ -820,7 +843,15 @@ mod tests { #[test] fn test_generate_empty_schema() { let schema = SchemaInfo::default(); - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files.is_empty()); } @@ -830,7 +861,15 @@ mod tests { tables: vec![make_table("users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "users.rs"); } @@ -844,7 +883,15 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 2); } @@ -859,7 +906,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "types.rs"); } @@ -885,7 +940,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); // Should produce exactly 1 types.rs let types_files: Vec<_> = files.iter().filter(|f| f.filename == "types.rs").collect(); assert_eq!(types_files.len(), 1); @@ -903,7 +966,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 2); // users.rs + types.rs } @@ -913,7 +984,15 @@ mod tests { tables: vec![make_table("user__data", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].filename, "user_data.rs"); } @@ -923,7 +1002,15 @@ mod tests { tables: vec![make_table("users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].origin, None); } @@ -938,7 +1025,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].origin, None); } @@ -954,7 +1049,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + true, + TimeCrate::Chrono, + ) + .unwrap(); // struct file should not have super::types:: imports let struct_file = files.iter().find(|f| f.filename == "users.rs").unwrap(); assert!(!struct_file.code.contains("super::types::")); @@ -973,7 +1076,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let struct_file = files.iter().find(|f| f.filename == "users.rs").unwrap(); assert!(struct_file.code.contains("super::types::")); } @@ -985,7 +1096,15 @@ mod tests { ..Default::default() }; let derives = vec!["Serialize".to_string()]; - let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &derives, + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -1001,7 +1120,15 @@ mod tests { ..Default::default() }; let derives = vec!["Serialize".to_string()]; - let files = generate(&schema, DatabaseKind::Postgres, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &derives, + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -1013,17 +1140,25 @@ mod tests { tables: vec![make_table("users", vec![make_col("data", "jsonb")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &overrides, false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &overrides, + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("MyJson")); } #[test] fn test_generate_valid_rust_syntax() { let schema = SchemaInfo { - tables: vec![make_table("users", vec![ - make_col("id", "int4"), - make_col("name", "text"), - ])], + tables: vec![make_table( + "users", + vec![make_col("id", "int4"), make_col("name", "text")], + )], enums: vec![EnumInfo { schema_name: "public".to_string(), name: "status".to_string(), @@ -1032,11 +1167,24 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); for f in &files { // Should be parseable as valid Rust let parse_result = syn::parse_file(&f.code); - assert!(parse_result.is_ok(), "Failed to parse {}: {:?}", f.filename, parse_result.err()); + assert!( + parse_result.is_ok(), + "Failed to parse {}: {:?}", + f.filename, + parse_result.err() + ); } } @@ -1056,7 +1204,15 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "active_users.rs"); } @@ -1067,7 +1223,15 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].origin, None); } @@ -1078,40 +1242,71 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 2); } #[test] fn test_generate_view_valid_rust() { let schema = SchemaInfo { - views: vec![make_view("active_users", vec![ - make_col("id", "int4"), - make_col("name", "text"), - ])], + views: vec![make_view( + "active_users", + vec![make_col("id", "int4"), make_col("name", "text")], + )], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let parse_result = syn::parse_file(&files[0].code); - assert!(parse_result.is_ok(), "Failed to parse: {:?}", parse_result.err()); + assert!( + parse_result.is_ok(), + "Failed to parse: {:?}", + parse_result.err() + ); } #[test] fn test_generate_view_nullable_column() { let schema = SchemaInfo { - views: vec![make_view("v", vec![ColumnInfo { - name: "email".to_string(), - data_type: "text".to_string(), - udt_name: "text".to_string(), - is_nullable: true, - is_primary_key: false, - ordinal_position: 0, - schema_name: "public".to_string(), - column_default: None, - }])], + views: vec![make_view( + "v", + vec![ColumnInfo { + name: "email".to_string(), + data_type: "text".to_string(), + udt_name: "text".to_string(), + is_nullable: true, + is_primary_key: false, + ordinal_position: 0, + schema_name: "public".to_string(), + column_default: None, + }], + )], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("Option")); } @@ -1128,7 +1323,15 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let filenames: Vec<_> = files.iter().map(|f| f.filename.as_str()).collect(); assert!(filenames.contains(&"users.rs")); assert!(filenames.contains(&"billing_users.rs")); @@ -1147,7 +1350,15 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let filenames: Vec<_> = files.iter().map(|f| f.filename.as_str()).collect(); assert!(filenames.contains(&"users.rs")); assert!(filenames.contains(&"invoices.rs")); @@ -1162,7 +1373,15 @@ mod tests { ], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].filename, "users.rs"); assert_eq!(files[1].filename, "posts.rs"); } @@ -1174,7 +1393,15 @@ mod tests { views: vec![make_view("active_users", vec![make_col("id", "int4")])], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + true, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 2); } @@ -1321,7 +1548,15 @@ mod tests { }], ..Default::default() }; - let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = generate( + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let types_file = files.iter().find(|f| f.filename == "types.rs").unwrap(); assert!(types_file.code.contains("impl Default for TaskStatus")); assert!(types_file.code.contains("Self::Idle")); diff --git a/crates/sqlx_gen/src/codegen/struct_gen.rs b/crates/sqlx_gen/src/codegen/struct_gen.rs index d4df440..570a352 100644 --- a/crates/sqlx_gen/src/codegen/struct_gen.rs +++ b/crates/sqlx_gen/src/codegen/struct_gen.rs @@ -47,7 +47,8 @@ pub fn generate_struct( .columns .iter() .map(|col| { - let rust_type = resolve_column_type(col, db_kind, table, schema_info, type_overrides, time_crate); + let rust_type = + resolve_column_type(col, db_kind, table, schema_info, type_overrides, time_crate); if let Some(imp) = &rust_type.needs_import { imports.insert(imp.clone()); } @@ -56,11 +57,7 @@ pub fn generate_struct( // If the field name is a Rust keyword, prefix with table name // e.g. column "type" on table "connector" → "connector_type" let (effective_name, needs_rename) = if is_rust_keyword(&field_name_snake) { - let prefixed = format!( - "{}_{}", - table.name.to_snake_case(), - field_name_snake - ); + let prefixed = format!("{}_{}", table.name.to_snake_case(), field_name_snake); (prefixed, true) } else { let changed = field_name_snake != col.name; @@ -87,12 +84,20 @@ pub fn generate_struct( let has_default = col.column_default.is_some(); let sqlx_gen_attr = if has_pk || has_sql_type || has_default { - let pk_part = if has_pk { quote! { primary_key, } } else { quote! {} }; + let pk_part = if has_pk { + quote! { primary_key, } + } else { + quote! {} + }; let sql_type_part = match &sql_type { Some(t) => quote! { sql_type = #t, }, None => quote! {}, }; - let array_part = if is_sql_array { quote! { is_array, } } else { quote! {} }; + let array_part = if is_sql_array { + quote! { is_array, } + } else { + quote! {} + }; let default_part = match &col.column_default { Some(d) => quote! { column_default = #d, }, None => quote! {}, @@ -139,7 +144,13 @@ pub(crate) fn sanitize_rust_ident(name: &str) -> String { } let mut out: String = name .chars() - .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' }) + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) .collect(); if out.starts_with(|c: char| c.is_ascii_digit()) { out.insert(0, '_'); @@ -163,7 +174,11 @@ fn detect_custom_sql_type(udt_name: &str, schema_info: &SchemaInfo) -> (Option String { let schema = SchemaInfo::default(); - let (tokens, _) = generate_struct(table, DatabaseKind::Postgres, &schema, &[], &HashMap::new(), false, TimeCrate::Chrono); + let (tokens, _) = generate_struct( + table, + DatabaseKind::Postgres, + &schema, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ); parse_and_format(&tokens).unwrap() } @@ -250,7 +273,15 @@ mod tests { derives: &[String], overrides: &HashMap, ) -> (String, BTreeSet) { - let (tokens, imports) = generate_struct(table, db, schema, derives, overrides, false, TimeCrate::Chrono); + let (tokens, imports) = generate_struct( + table, + db, + schema, + derives, + overrides, + false, + TimeCrate::Chrono, + ); (parse_and_format(&tokens).unwrap(), imports) } @@ -258,10 +289,13 @@ mod tests { #[test] fn test_simple_table() { - let table = make_table("users", vec![ - make_col("id", "int4", false), - make_col("name", "text", false), - ]); + let table = make_table( + "users", + vec![ + make_col("id", "int4", false), + make_col("name", "text", false), + ], + ); let code = gen(&table); assert!(code.contains("pub id: i32")); assert!(code.contains("pub name: String")); @@ -300,10 +334,10 @@ mod tests { #[test] fn test_mix_nullable() { - let table = make_table("users", vec![ - make_col("id", "int4", false), - make_col("bio", "text", true), - ]); + let table = make_table( + "users", + vec![make_col("id", "int4", false), make_col("bio", "text", true)], + ); let code = gen(&table); assert!(code.contains("pub id: i32")); assert!(code.contains("pub bio: Option")); @@ -383,7 +417,13 @@ mod tests { let table = make_table("users", vec![make_col("id", "int4", false)]); let schema = SchemaInfo::default(); let derives = vec!["Serialize".to_string()]; - let (code, _) = gen_with(&table, &schema, DatabaseKind::Postgres, &derives, &HashMap::new()); + let (code, _) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &derives, + &HashMap::new(), + ); assert!(code.contains("Serialize")); } @@ -392,7 +432,13 @@ mod tests { let table = make_table("users", vec![make_col("id", "int4", false)]); let schema = SchemaInfo::default(); let derives = vec!["Serialize".to_string(), "Deserialize".to_string()]; - let (_, imports) = gen_with(&table, &schema, DatabaseKind::Postgres, &derives, &HashMap::new()); + let (_, imports) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &derives, + &HashMap::new(), + ); assert!(imports.iter().any(|i| i.contains("serde"))); } @@ -402,7 +448,13 @@ mod tests { fn test_uuid_import() { let table = make_table("users", vec![make_col("id", "uuid", false)]); let schema = SchemaInfo::default(); - let (_, imports) = gen_with(&table, &schema, DatabaseKind::Postgres, &[], &HashMap::new()); + let (_, imports) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + ); assert!(imports.iter().any(|i| i.contains("uuid::Uuid"))); } @@ -410,7 +462,13 @@ mod tests { fn test_timestamptz_import() { let table = make_table("users", vec![make_col("created_at", "timestamptz", false)]); let schema = SchemaInfo::default(); - let (_, imports) = gen_with(&table, &schema, DatabaseKind::Postgres, &[], &HashMap::new()); + let (_, imports) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + ); assert!(imports.iter().any(|i| i.contains("chrono"))); } @@ -418,7 +476,13 @@ mod tests { fn test_int4_only_serde_import() { let table = make_table("users", vec![make_col("id", "int4", false)]); let schema = SchemaInfo::default(); - let (_, imports) = gen_with(&table, &schema, DatabaseKind::Postgres, &[], &HashMap::new()); + let (_, imports) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + ); assert_eq!(imports.len(), 2); assert!(imports.iter().any(|i| i.contains("serde"))); assert!(imports.iter().any(|i| i.contains("sqlx_gen::SqlxGen"))); @@ -426,12 +490,21 @@ mod tests { #[test] fn test_multiple_imports_collected() { - let table = make_table("users", vec![ - make_col("id", "uuid", false), - make_col("created_at", "timestamptz", false), - ]); + let table = make_table( + "users", + vec![ + make_col("id", "uuid", false), + make_col("created_at", "timestamptz", false), + ], + ); let schema = SchemaInfo::default(); - let (_, imports) = gen_with(&table, &schema, DatabaseKind::Postgres, &[], &HashMap::new()); + let (_, imports) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + ); assert!(imports.iter().any(|i| i.contains("uuid"))); assert!(imports.iter().any(|i| i.contains("chrono"))); } @@ -440,16 +513,19 @@ mod tests { #[test] fn test_mysql_enum_column() { - let table = make_table("users", vec![ColumnInfo { - name: "status".to_string(), - data_type: "enum".to_string(), - udt_name: "enum('active','inactive')".to_string(), - is_nullable: false, - is_primary_key: false, - ordinal_position: 0, - schema_name: "test_db".to_string(), - column_default: None, - }]); + let table = make_table( + "users", + vec![ColumnInfo { + name: "status".to_string(), + data_type: "enum".to_string(), + udt_name: "enum('active','inactive')".to_string(), + is_nullable: false, + is_primary_key: false, + ordinal_position: 0, + schema_name: "test_db".to_string(), + column_default: None, + }], + ); let schema = SchemaInfo::default(); let (code, imports) = gen_with(&table, &schema, DatabaseKind::Mysql, &[], &HashMap::new()); assert!(code.contains("UsersStatus")); @@ -458,16 +534,19 @@ mod tests { #[test] fn test_mysql_enum_nullable() { - let table = make_table("users", vec![ColumnInfo { - name: "status".to_string(), - data_type: "enum".to_string(), - udt_name: "enum('a','b')".to_string(), - is_nullable: true, - is_primary_key: false, - ordinal_position: 0, - schema_name: "test_db".to_string(), - column_default: None, - }]); + let table = make_table( + "users", + vec![ColumnInfo { + name: "status".to_string(), + data_type: "enum".to_string(), + udt_name: "enum('a','b')".to_string(), + is_nullable: true, + is_primary_key: false, + ordinal_position: 0, + schema_name: "test_db".to_string(), + column_default: None, + }], + ); let schema = SchemaInfo::default(); let (code, _) = gen_with(&table, &schema, DatabaseKind::Mysql, &[], &HashMap::new()); assert!(code.contains("Option")); @@ -489,7 +568,13 @@ mod tests { fn test_type_override_absent() { let table = make_table("users", vec![make_col("data", "jsonb", false)]); let schema = SchemaInfo::default(); - let (code, _) = gen_with(&table, &schema, DatabaseKind::Postgres, &[], &HashMap::new()); + let (code, _) = gen_with( + &table, + &schema, + DatabaseKind::Postgres, + &[], + &HashMap::new(), + ); assert!(code.contains("Value")); } @@ -578,8 +663,11 @@ mod tests { let table = make_table("users", vec![make_col("user-id", "int4", false)]); let code = gen(&table); // Must produce a Rust-legal identifier; renamed back to the original via #[sqlx(rename)] - assert!(code.contains("pub user_id:") || code.contains("user_id:"), - "expected sanitized identifier, got:\n{}", code); + assert!( + code.contains("pub user_id:") || code.contains("user_id:"), + "expected sanitized identifier, got:\n{}", + code + ); assert!(code.contains("sqlx(rename = \"user-id\")")); } } diff --git a/crates/sqlx_gen/src/error.rs b/crates/sqlx_gen/src/error.rs index b76f626..62d84a4 100644 --- a/crates/sqlx_gen/src/error.rs +++ b/crates/sqlx_gen/src/error.rs @@ -74,7 +74,10 @@ mod tests { #[test] fn leaves_no_userinfo_unchanged() { - assert_eq!(redact_url("postgres://localhost/db"), "postgres://localhost/db"); + assert_eq!( + redact_url("postgres://localhost/db"), + "postgres://localhost/db" + ); } #[test] diff --git a/crates/sqlx_gen/src/introspect/mysql.rs b/crates/sqlx_gen/src/introspect/mysql.rs index e00b1e6..f4efcac 100644 --- a/crates/sqlx_gen/src/introspect/mysql.rs +++ b/crates/sqlx_gen/src/introspect/mysql.rs @@ -59,7 +59,19 @@ async fn fetch_tables(pool: &MySqlPool, schemas: &[String]) -> Result, Vec, Vec, Vec, Vec, Vec, u32, Vec)>(&query); + let mut q = sqlx::query_as::< + _, + ( + Vec, + Vec, + Vec, + Vec, + Vec, + Vec, + u32, + Vec, + ), + >(&query); for schema in schemas { q = q.bind(schema); } @@ -253,9 +265,11 @@ fn resolve_view_nullability( // Build view column source lookup: (view_schema, view_name, column_name) -> Vec let mut view_lookup: HashMap<(&str, &str, &str), Vec> = HashMap::new(); for src in sources { - if let Some(&is_nullable) = - table_lookup.get(&(src.table_schema.as_str(), src.table_name.as_str(), src.column_name.as_str())) - { + if let Some(&is_nullable) = table_lookup.get(&( + src.table_schema.as_str(), + src.table_name.as_str(), + src.column_name.as_str(), + )) { view_lookup .entry((&src.view_schema, &src.view_name, &src.column_name)) .or_default() @@ -298,9 +312,11 @@ fn resolve_view_primary_keys( // Build view column source lookup: (view_schema, view_name, column_name) -> Vec let mut view_lookup: HashMap<(&str, &str, &str), Vec> = HashMap::new(); for src in sources { - if let Some(&is_pk) = - table_lookup.get(&(src.table_schema.as_str(), src.table_name.as_str(), src.column_name.as_str())) - { + if let Some(&is_pk) = table_lookup.get(&( + src.table_schema.as_str(), + src.table_name.as_str(), + src.column_name.as_str(), + )) { view_lookup .entry((&src.view_schema, &src.view_name, &src.column_name)) .or_default() @@ -407,10 +423,7 @@ mod tests { #[test] fn test_parse_with_spaces() { - assert_eq!( - parse_enum_variants("enum( 'a' , 'b' )"), - vec!["a", "b"] - ); + assert_eq!(parse_enum_variants("enum( 'a' , 'b' )"), vec!["a", "b"]); } #[test] @@ -467,10 +480,7 @@ mod tests { #[test] fn test_extract_enum_name_format() { - let tables = vec![make_table( - "users", - vec![make_col("status", "enum('a')")], - )]; + let tables = vec![make_table("users", vec![make_col("status", "enum('a')")])]; let enums = extract_enums(&tables); assert_eq!(enums[0].name, "users_status"); } @@ -514,10 +524,7 @@ mod tests { fn test_extract_non_enum_column_ignored() { let tables = vec![make_table( "users", - vec![ - make_col("id", "int(11)"), - make_col("status", "enum('a')"), - ], + vec![make_col("id", "int(11)"), make_col("status", "enum('a')")], )]; let enums = extract_enums(&tables); assert_eq!(enums.len(), 1); @@ -644,11 +651,7 @@ mod tests { // ========== resolve_view_primary_keys ========== - fn make_table_with_pk( - schema: &str, - name: &str, - columns: Vec<(&str, bool)>, - ) -> TableInfo { + fn make_table_with_pk(schema: &str, name: &str, columns: Vec<(&str, bool)>) -> TableInfo { TableInfo { schema_name: schema.to_string(), name: name.to_string(), @@ -671,7 +674,11 @@ mod tests { #[test] fn test_resolve_pk_column() { - let tables = vec![make_table_with_pk("db", "users", vec![("id", true), ("name", false)])]; + let tables = vec![make_table_with_pk( + "db", + "users", + vec![("id", true), ("name", false)], + )]; let mut views = vec![make_view("db", "my_view", vec!["id", "name"])]; let sources = vec![ make_source("db", "my_view", "db", "users", "id"), diff --git a/crates/sqlx_gen/src/introspect/postgres.rs b/crates/sqlx_gen/src/introspect/postgres.rs index d2d0416..7a24e06 100644 --- a/crates/sqlx_gen/src/introspect/postgres.rs +++ b/crates/sqlx_gen/src/introspect/postgres.rs @@ -39,7 +39,20 @@ pub async fn introspect( } async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result> { - let rows = sqlx::query_as::<_, (String, String, String, String, String, String, i32, bool, Option)>( + let rows = sqlx::query_as::< + _, + ( + String, + String, + String, + String, + String, + String, + i32, + bool, + Option, + ), + >( r#" SELECT c.table_schema, @@ -75,7 +88,9 @@ async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result = Vec::new(); let mut current_key: Option<(String, String)> = None; - for (schema, table, col_name, data_type, udt_name, nullable, ordinal, is_pk, column_default) in rows { + for (schema, table, col_name, data_type, udt_name, nullable, ordinal, is_pk, column_default) in + rows + { let key = (schema.clone(), table.clone()); if current_key.as_ref() != Some(&key) { current_key = Some(key); @@ -106,7 +121,19 @@ async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result Result> { - let rows = sqlx::query_as::<_, (String, String, String, String, String, String, i32, Option)>( + let rows = sqlx::query_as::< + _, + ( + String, + String, + String, + String, + String, + String, + i32, + Option, + ), + >( r#" SELECT c.table_schema, @@ -202,22 +229,17 @@ async fn fetch_view_column_nullability( Ok(rows .into_iter() .map( - |(view_schema, view_name, source_column_name, source_not_null)| { - ViewColumnNullability { - view_schema, - view_name, - source_column_name, - source_not_null, - } + |(view_schema, view_name, source_column_name, source_not_null)| ViewColumnNullability { + view_schema, + view_name, + source_column_name, + source_not_null, }, ) .collect()) } -fn resolve_view_nullability( - views: &mut [TableInfo], - nullability_info: &[ViewColumnNullability], -) { +fn resolve_view_nullability(views: &mut [TableInfo], nullability_info: &[ViewColumnNullability]) { // Build lookup: (view_schema, view_name, column_name) -> Vec let mut lookup: HashMap<(&str, &str, &str), Vec> = HashMap::new(); for info in nullability_info { @@ -301,10 +323,7 @@ async fn fetch_view_column_primary_keys( .collect()) } -fn resolve_view_primary_keys( - views: &mut [TableInfo], - pk_info: &[ViewColumnPrimaryKey], -) { +fn resolve_view_primary_keys(views: &mut [TableInfo], pk_info: &[ViewColumnPrimaryKey]) { // Build lookup: (view_schema, view_name, column_name) -> Vec let mut lookup: HashMap<(&str, &str, &str), Vec> = HashMap::new(); for info in pk_info { diff --git a/crates/sqlx_gen/src/introspect/sqlite.rs b/crates/sqlx_gen/src/introspect/sqlite.rs index 1424a28..c803b7f 100644 --- a/crates/sqlx_gen/src/introspect/sqlite.rs +++ b/crates/sqlx_gen/src/introspect/sqlite.rs @@ -49,11 +49,10 @@ async fn fetch_tables(pool: &SqlitePool) -> Result> { } async fn fetch_views(pool: &SqlitePool) -> Result> { - let view_names: Vec<(String,)> = sqlx::query_as( - "SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name", - ) - .fetch_all(pool) - .await?; + let view_names: Vec<(String,)> = + sqlx::query_as("SELECT name FROM sqlite_master WHERE type = 'view' ORDER BY name") + .fetch_all(pool) + .await?; let mut views = Vec::new(); @@ -100,7 +99,10 @@ fn resolve_view_nullability(views: &mut [TableInfo], tables: &[TableInfo]) { let mut col_lookup: HashMap<&str, Vec> = HashMap::new(); for table in tables { for col in &table.columns { - col_lookup.entry(&col.name).or_default().push(col.is_nullable); + col_lookup + .entry(&col.name) + .or_default() + .push(col.is_nullable); } } @@ -124,7 +126,10 @@ fn resolve_view_primary_keys(views: &mut [TableInfo], tables: &[TableInfo]) { let mut col_lookup: HashMap<&str, Vec> = HashMap::new(); for table in tables { for col in &table.columns { - col_lookup.entry(&col.name).or_default().push(col.is_primary_key); + col_lookup + .entry(&col.name) + .or_default() + .push(col.is_primary_key); } } @@ -257,7 +262,10 @@ mod tests { #[test] fn test_resolve_pk_unique_match() { - let tables = vec![make_table_with_pk("users", vec![("id", true), ("name", false)])]; + let tables = vec![make_table_with_pk( + "users", + vec![("id", true), ("name", false)], + )]; let mut views = vec![make_view("my_view", vec!["id", "name"])]; resolve_view_primary_keys(&mut views, &tables); assert!(views[0].columns[0].is_primary_key); diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 8a97f53..08264e5 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -10,10 +10,7 @@ use sqlx_gen::writer; #[tokio::main] async fn main() -> Result<()> { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .init(); + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); let cli = Cli::parse(); match cli.command { @@ -46,21 +43,26 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let mut schema_info = match db_kind { DatabaseKind::Postgres => { - let pool = PgPool::connect(&args.db.database_url).await.map_err(conn_err)?; + let pool = PgPool::connect(&args.db.database_url) + .await + .map_err(conn_err)?; let info = introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await?; pool.close().await; info } DatabaseKind::Mysql => { - let pool = MySqlPool::connect(&args.db.database_url).await.map_err(conn_err)?; - let info = - introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await?; + let pool = MySqlPool::connect(&args.db.database_url) + .await + .map_err(conn_err)?; + let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await?; pool.close().await; info } DatabaseKind::Sqlite => { - let pool = SqlitePool::connect(&args.db.database_url).await.map_err(conn_err)?; + let pool = SqlitePool::connect(&args.db.database_url) + .await + .map_err(conn_err)?; let info = introspect::sqlite::introspect(&pool, args.views).await?; pool.close().await; info @@ -122,7 +124,10 @@ fn run_crud(args: CrudArgs) -> Result<()> { let entities_module = args.resolve_entities_module()?; // Validate that the resolved module is a Rust module path, not a file path - if entities_module.contains('/') || entities_module.contains('\\') || entities_module.ends_with(".rs") { + if entities_module.contains('/') + || entities_module.contains('\\') + || entities_module.ends_with(".rs") + { return Err(sqlx_gen::error::Error::Config(format!( "--entities-module must be a Rust module path (e.g. \"crate::models::users\"), got \"{}\"", entities_module @@ -174,10 +179,7 @@ fn run_crud(args: CrudArgs) -> Result<()> { let filename = format!("{}_repository.rs", normalized); let file_path = args.output_dir.join(&filename); - let content = format!( - "// Auto-generated by sqlx-gen. Do not edit.\n\n{}", - code - ); + let content = format!("// Auto-generated by sqlx-gen. Do not edit.\n\n{}", code); std::fs::write(&file_path, &content)?; info!("Wrote {}", file_path.display()); let edition = detect_edition(&args.output_dir); diff --git a/crates/sqlx_gen/src/typemap/mod.rs b/crates/sqlx_gen/src/typemap/mod.rs index 4013c7d..81eb87c 100644 --- a/crates/sqlx_gen/src/typemap/mod.rs +++ b/crates/sqlx_gen/src/typemap/mod.rs @@ -54,7 +54,11 @@ pub fn map_column( // Check type overrides first if let Some(override_type) = overrides.get(&col.udt_name) { let rt = RustType::simple(override_type); - return if col.is_nullable { rt.wrap_option() } else { rt }; + return if col.is_nullable { + rt.wrap_option() + } else { + rt + }; } let base = match db_kind { @@ -175,7 +179,13 @@ mod tests { let schema = SchemaInfo::default(); let mut overrides = HashMap::new(); overrides.insert("uuid".to_string(), "MyUuid".to_string()); - let rt = map_column(&col, DatabaseKind::Postgres, &schema, &overrides, TimeCrate::Chrono); + let rt = map_column( + &col, + DatabaseKind::Postgres, + &schema, + &overrides, + TimeCrate::Chrono, + ); assert_eq!(rt.path, "MyUuid"); assert!(rt.needs_import.is_none()); } @@ -186,7 +196,13 @@ mod tests { let schema = SchemaInfo::default(); let mut overrides = HashMap::new(); overrides.insert("uuid".to_string(), "MyUuid".to_string()); - let rt = map_column(&col, DatabaseKind::Postgres, &schema, &overrides, TimeCrate::Chrono); + let rt = map_column( + &col, + DatabaseKind::Postgres, + &schema, + &overrides, + TimeCrate::Chrono, + ); assert_eq!(rt.path, "Option"); } @@ -195,7 +211,13 @@ mod tests { let col = make_col("int4", "integer", false); let schema = SchemaInfo::default(); let overrides = HashMap::new(); - let rt = map_column(&col, DatabaseKind::Postgres, &schema, &overrides, TimeCrate::Chrono); + let rt = map_column( + &col, + DatabaseKind::Postgres, + &schema, + &overrides, + TimeCrate::Chrono, + ); assert_eq!(rt.path, "i32"); } @@ -204,8 +226,13 @@ mod tests { let col = make_col("int4", "integer", true); let schema = SchemaInfo::default(); let overrides = HashMap::new(); - let rt = map_column(&col, DatabaseKind::Postgres, &schema, &overrides, TimeCrate::Chrono); + let rt = map_column( + &col, + DatabaseKind::Postgres, + &schema, + &overrides, + TimeCrate::Chrono, + ); assert_eq!(rt.path, "Option"); } } - diff --git a/crates/sqlx_gen/src/typemap/mysql.rs b/crates/sqlx_gen/src/typemap/mysql.rs index f5a2e21..8e7fb5b 100644 --- a/crates/sqlx_gen/src/typemap/mysql.rs +++ b/crates/sqlx_gen/src/typemap/mysql.rs @@ -49,9 +49,7 @@ pub fn map_type(data_type: &str, column_type: &str, time_crate: TimeCrate) -> Ru } "float" => RustType::simple("f32"), "double" => RustType::simple("f64"), - "decimal" | "numeric" => { - RustType::with_import("Decimal", "use rust_decimal::Decimal;") - } + "decimal" | "numeric" => RustType::with_import("Decimal", "use rust_decimal::Decimal;"), "varchar" | "char" | "text" | "tinytext" | "mediumtext" | "longtext" | "enum" | "set" => { RustType::simple("String") } @@ -67,11 +65,17 @@ pub fn map_type(data_type: &str, column_type: &str, time_crate: TimeCrate) -> Ru TimeCrate::Time => RustType::with_import("Time", "use time::Time;"), }, "datetime" => match time_crate { - TimeCrate::Chrono => RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;"), - TimeCrate::Time => RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;"), + TimeCrate::Chrono => { + RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;") + } + TimeCrate::Time => { + RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;") + } }, "timestamp" => match time_crate { - TimeCrate::Chrono => RustType::with_import("DateTime", "use chrono::{DateTime, Utc};"), + TimeCrate::Chrono => { + RustType::with_import("DateTime", "use chrono::{DateTime, Utc};") + } TimeCrate::Time => RustType::with_import("OffsetDateTime", "use time::OffsetDateTime;"), }, "json" => RustType::with_import("Value", "use serde_json::Value;"), @@ -104,7 +108,10 @@ mod tests { #[test] fn test_tinyint1_is_bool() { - assert_eq!(map_type("tinyint", "tinyint(1)", TimeCrate::Chrono).path, "bool"); + assert_eq!( + map_type("tinyint", "tinyint(1)", TimeCrate::Chrono).path, + "bool" + ); } #[test] @@ -114,29 +121,44 @@ mod tests { #[test] fn test_tinyint_unsigned() { - assert_eq!(map_type("tinyint", "tinyint unsigned", TimeCrate::Chrono).path, "u8"); + assert_eq!( + map_type("tinyint", "tinyint unsigned", TimeCrate::Chrono).path, + "u8" + ); } #[test] fn test_tinyint3_signed() { - assert_eq!(map_type("tinyint", "tinyint(3)", TimeCrate::Chrono).path, "i8"); + assert_eq!( + map_type("tinyint", "tinyint(3)", TimeCrate::Chrono).path, + "i8" + ); } #[test] fn test_tinyint3_unsigned() { - assert_eq!(map_type("tinyint", "tinyint(3) unsigned", TimeCrate::Chrono).path, "u8"); + assert_eq!( + map_type("tinyint", "tinyint(3) unsigned", TimeCrate::Chrono).path, + "u8" + ); } // --- smallint --- #[test] fn test_smallint_signed() { - assert_eq!(map_type("smallint", "smallint", TimeCrate::Chrono).path, "i16"); + assert_eq!( + map_type("smallint", "smallint", TimeCrate::Chrono).path, + "i16" + ); } #[test] fn test_smallint_unsigned() { - assert_eq!(map_type("smallint", "smallint unsigned", TimeCrate::Chrono).path, "u16"); + assert_eq!( + map_type("smallint", "smallint unsigned", TimeCrate::Chrono).path, + "u16" + ); } // --- int/mediumint --- @@ -148,17 +170,26 @@ mod tests { #[test] fn test_int_unsigned() { - assert_eq!(map_type("int", "int unsigned", TimeCrate::Chrono).path, "u32"); + assert_eq!( + map_type("int", "int unsigned", TimeCrate::Chrono).path, + "u32" + ); } #[test] fn test_mediumint_signed() { - assert_eq!(map_type("mediumint", "mediumint", TimeCrate::Chrono).path, "i32"); + assert_eq!( + map_type("mediumint", "mediumint", TimeCrate::Chrono).path, + "i32" + ); } #[test] fn test_mediumint_unsigned() { - assert_eq!(map_type("mediumint", "mediumint unsigned", TimeCrate::Chrono).path, "u32"); + assert_eq!( + map_type("mediumint", "mediumint unsigned", TimeCrate::Chrono).path, + "u32" + ); } #[test] @@ -168,7 +199,10 @@ mod tests { #[test] fn test_int11_unsigned() { - assert_eq!(map_type("int", "int(11) unsigned", TimeCrate::Chrono).path, "u32"); + assert_eq!( + map_type("int", "int(11) unsigned", TimeCrate::Chrono).path, + "u32" + ); } // --- bigint --- @@ -180,12 +214,18 @@ mod tests { #[test] fn test_bigint_unsigned() { - assert_eq!(map_type("bigint", "bigint unsigned", TimeCrate::Chrono).path, "u64"); + assert_eq!( + map_type("bigint", "bigint unsigned", TimeCrate::Chrono).path, + "u64" + ); } #[test] fn test_bigint20_signed() { - assert_eq!(map_type("bigint", "bigint(20)", TimeCrate::Chrono).path, "i64"); + assert_eq!( + map_type("bigint", "bigint(20)", TimeCrate::Chrono).path, + "i64" + ); } // --- floats --- @@ -220,12 +260,18 @@ mod tests { #[test] fn test_varchar() { - assert_eq!(map_type("varchar", "varchar(255)", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("varchar", "varchar(255)", TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_char() { - assert_eq!(map_type("char", "char(1)", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("char", "char(1)", TimeCrate::Chrono).path, + "String" + ); } #[test] @@ -235,34 +281,52 @@ mod tests { #[test] fn test_tinytext() { - assert_eq!(map_type("tinytext", "tinytext", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("tinytext", "tinytext", TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_mediumtext() { - assert_eq!(map_type("mediumtext", "mediumtext", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("mediumtext", "mediumtext", TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_longtext() { - assert_eq!(map_type("longtext", "longtext", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("longtext", "longtext", TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_set() { - assert_eq!(map_type("set", "set('a','b')", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("set", "set('a','b')", TimeCrate::Chrono).path, + "String" + ); } // --- binary --- #[test] fn test_binary() { - assert_eq!(map_type("binary", "binary(16)", TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("binary", "binary(16)", TimeCrate::Chrono).path, + "Vec" + ); } #[test] fn test_varbinary() { - assert_eq!(map_type("varbinary", "varbinary(255)", TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("varbinary", "varbinary(255)", TimeCrate::Chrono).path, + "Vec" + ); } #[test] @@ -272,7 +336,10 @@ mod tests { #[test] fn test_tinyblob() { - assert_eq!(map_type("tinyblob", "tinyblob", TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("tinyblob", "tinyblob", TimeCrate::Chrono).path, + "Vec" + ); } // --- dates --- @@ -331,14 +398,20 @@ mod tests { #[test] fn test_boolean_alias_is_bool() { - assert_eq!(map_type("boolean", "boolean", TimeCrate::Chrono).path, "bool"); + assert_eq!( + map_type("boolean", "boolean", TimeCrate::Chrono).path, + "bool" + ); } // --- enum placeholder --- #[test] fn test_enum_placeholder() { - assert_eq!(map_type("enum", "enum('a','b','c')", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("enum", "enum('a','b','c')", TimeCrate::Chrono).path, + "String" + ); } // --- case insensitive --- @@ -350,14 +423,20 @@ mod tests { #[test] fn test_case_insensitive_tinyint1() { - assert_eq!(map_type("TINYINT", "TINYINT(1)", TimeCrate::Chrono).path, "bool"); + assert_eq!( + map_type("TINYINT", "TINYINT(1)", TimeCrate::Chrono).path, + "bool" + ); } // --- fallback --- #[test] fn test_geometry_fallback() { - assert_eq!(map_type("geometry", "geometry", TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("geometry", "geometry", TimeCrate::Chrono).path, + "String" + ); } #[test] @@ -374,7 +453,10 @@ mod tests { #[test] fn test_resolve_enum_user_roles_role_type() { - assert_eq!(resolve_enum_type("user_roles", "role_type"), "UserRolesRoleType"); + assert_eq!( + resolve_enum_type("user_roles", "role_type"), + "UserRolesRoleType" + ); } #[test] @@ -393,14 +475,22 @@ mod tests { fn test_timestamp_time_crate() { let rt = map_type("timestamp", "timestamp", TimeCrate::Time); assert_eq!(rt.path, "OffsetDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::OffsetDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::OffsetDateTime")); } #[test] fn test_datetime_time_crate() { let rt = map_type("datetime", "datetime", TimeCrate::Time); assert_eq!(rt.path, "PrimitiveDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::PrimitiveDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::PrimitiveDateTime")); } #[test] diff --git a/crates/sqlx_gen/src/typemap/postgres.rs b/crates/sqlx_gen/src/typemap/postgres.rs index 46b3912..ec43b0f 100644 --- a/crates/sqlx_gen/src/typemap/postgres.rs +++ b/crates/sqlx_gen/src/typemap/postgres.rs @@ -10,22 +10,43 @@ pub fn is_builtin(udt_name: &str) -> bool { matches!( udt_name, "bool" - | "int2" | "smallint" | "smallserial" - | "int4" | "int" | "integer" | "serial" - | "int8" | "bigint" | "bigserial" - | "float4" | "real" - | "float8" | "double precision" - | "numeric" | "decimal" - | "varchar" | "text" | "bpchar" | "char" | "name" | "citext" + | "int2" + | "smallint" + | "smallserial" + | "int4" + | "int" + | "integer" + | "serial" + | "int8" + | "bigint" + | "bigserial" + | "float4" + | "real" + | "float8" + | "double precision" + | "numeric" + | "decimal" + | "varchar" + | "text" + | "bpchar" + | "char" + | "name" + | "citext" | "bytea" - | "timestamp" | "timestamp without time zone" - | "timestamptz" | "timestamp with time zone" + | "timestamp" + | "timestamp without time zone" + | "timestamptz" + | "timestamp with time zone" | "date" - | "time" | "time without time zone" - | "timetz" | "time with time zone" + | "time" + | "time without time zone" + | "timetz" + | "time with time zone" | "uuid" - | "json" | "jsonb" - | "inet" | "cidr" + | "json" + | "jsonb" + | "inet" + | "cidr" | "interval" | "oid" ) @@ -51,7 +72,11 @@ pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) } // Check if it's a known composite type - if schema_info.composite_types.iter().any(|c| c.name == udt_name) { + if schema_info + .composite_types + .iter() + .any(|c| c.name == udt_name) + { let name = udt_name.to_upper_camel_case(); return RustType::with_import(&name, &format!("use super::types::{};", name)); } @@ -69,17 +94,21 @@ pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) "int8" | "bigint" | "bigserial" => RustType::simple("i64"), "float4" | "real" => RustType::simple("f32"), "float8" | "double precision" => RustType::simple("f64"), - "numeric" | "decimal" => { - RustType::with_import("Decimal", "use rust_decimal::Decimal;") - } + "numeric" | "decimal" => RustType::with_import("Decimal", "use rust_decimal::Decimal;"), "varchar" | "text" | "bpchar" | "char" | "name" | "citext" => RustType::simple("String"), "bytea" => RustType::simple("Vec"), "timestamp" | "timestamp without time zone" => match time_crate { - TimeCrate::Chrono => RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;"), - TimeCrate::Time => RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;"), + TimeCrate::Chrono => { + RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;") + } + TimeCrate::Time => { + RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;") + } }, "timestamptz" | "timestamp with time zone" => match time_crate { - TimeCrate::Chrono => RustType::with_import("DateTime", "use chrono::{DateTime, Utc};"), + TimeCrate::Chrono => { + RustType::with_import("DateTime", "use chrono::{DateTime, Utc};") + } TimeCrate::Time => RustType::with_import("OffsetDateTime", "use time::OffsetDateTime;"), }, "date" => match time_crate { @@ -95,16 +124,9 @@ pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) TimeCrate::Time => RustType::with_import("Time", "use time::Time;"), }, "uuid" => RustType::with_import("Uuid", "use uuid::Uuid;"), - "json" | "jsonb" => { - RustType::with_import("Value", "use serde_json::Value;") - } - "inet" | "cidr" => { - RustType::with_import("IpNetwork", "use ipnetwork::IpNetwork;") - } - "interval" => RustType::with_import( - "PgInterval", - "use sqlx::postgres::types::PgInterval;", - ), + "json" | "jsonb" => RustType::with_import("Value", "use serde_json::Value;"), + "inet" | "cidr" => RustType::with_import("IpNetwork", "use ipnetwork::IpNetwork;"), + "interval" => RustType::with_import("PgInterval", "use sqlx::postgres::types::PgInterval;"), "oid" => RustType::simple("u32"), _ => RustType::simple("String"), // fallback } @@ -158,72 +180,114 @@ mod tests { #[test] fn test_bool() { - assert_eq!(map_type("bool", &empty_schema(), TimeCrate::Chrono).path, "bool"); + assert_eq!( + map_type("bool", &empty_schema(), TimeCrate::Chrono).path, + "bool" + ); } #[test] fn test_int2() { - assert_eq!(map_type("int2", &empty_schema(), TimeCrate::Chrono).path, "i16"); + assert_eq!( + map_type("int2", &empty_schema(), TimeCrate::Chrono).path, + "i16" + ); } #[test] fn test_smallint() { - assert_eq!(map_type("smallint", &empty_schema(), TimeCrate::Chrono).path, "i16"); + assert_eq!( + map_type("smallint", &empty_schema(), TimeCrate::Chrono).path, + "i16" + ); } #[test] fn test_smallserial() { - assert_eq!(map_type("smallserial", &empty_schema(), TimeCrate::Chrono).path, "i16"); + assert_eq!( + map_type("smallserial", &empty_schema(), TimeCrate::Chrono).path, + "i16" + ); } #[test] fn test_int4() { - assert_eq!(map_type("int4", &empty_schema(), TimeCrate::Chrono).path, "i32"); + assert_eq!( + map_type("int4", &empty_schema(), TimeCrate::Chrono).path, + "i32" + ); } #[test] fn test_integer() { - assert_eq!(map_type("integer", &empty_schema(), TimeCrate::Chrono).path, "i32"); + assert_eq!( + map_type("integer", &empty_schema(), TimeCrate::Chrono).path, + "i32" + ); } #[test] fn test_serial() { - assert_eq!(map_type("serial", &empty_schema(), TimeCrate::Chrono).path, "i32"); + assert_eq!( + map_type("serial", &empty_schema(), TimeCrate::Chrono).path, + "i32" + ); } #[test] fn test_int8() { - assert_eq!(map_type("int8", &empty_schema(), TimeCrate::Chrono).path, "i64"); + assert_eq!( + map_type("int8", &empty_schema(), TimeCrate::Chrono).path, + "i64" + ); } #[test] fn test_bigint() { - assert_eq!(map_type("bigint", &empty_schema(), TimeCrate::Chrono).path, "i64"); + assert_eq!( + map_type("bigint", &empty_schema(), TimeCrate::Chrono).path, + "i64" + ); } #[test] fn test_bigserial() { - assert_eq!(map_type("bigserial", &empty_schema(), TimeCrate::Chrono).path, "i64"); + assert_eq!( + map_type("bigserial", &empty_schema(), TimeCrate::Chrono).path, + "i64" + ); } #[test] fn test_float4() { - assert_eq!(map_type("float4", &empty_schema(), TimeCrate::Chrono).path, "f32"); + assert_eq!( + map_type("float4", &empty_schema(), TimeCrate::Chrono).path, + "f32" + ); } #[test] fn test_real() { - assert_eq!(map_type("real", &empty_schema(), TimeCrate::Chrono).path, "f32"); + assert_eq!( + map_type("real", &empty_schema(), TimeCrate::Chrono).path, + "f32" + ); } #[test] fn test_float8() { - assert_eq!(map_type("float8", &empty_schema(), TimeCrate::Chrono).path, "f64"); + assert_eq!( + map_type("float8", &empty_schema(), TimeCrate::Chrono).path, + "f64" + ); } #[test] fn test_double_precision() { - assert_eq!(map_type("double precision", &empty_schema(), TimeCrate::Chrono).path, "f64"); + assert_eq!( + map_type("double precision", &empty_schema(), TimeCrate::Chrono).path, + "f64" + ); } #[test] @@ -241,32 +305,50 @@ mod tests { #[test] fn test_varchar() { - assert_eq!(map_type("varchar", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("varchar", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_text() { - assert_eq!(map_type("text", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("text", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_bpchar() { - assert_eq!(map_type("bpchar", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("bpchar", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_citext() { - assert_eq!(map_type("citext", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("citext", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_name() { - assert_eq!(map_type("name", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("name", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_bytea() { - assert_eq!(map_type("bytea", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("bytea", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] @@ -336,7 +418,10 @@ mod tests { #[test] fn test_oid() { - assert_eq!(map_type("oid", &empty_schema(), TimeCrate::Chrono).path, "u32"); + assert_eq!( + map_type("oid", &empty_schema(), TimeCrate::Chrono).path, + "u32" + ); } #[test] @@ -350,22 +435,34 @@ mod tests { #[test] fn test_array_int4() { - assert_eq!(map_type("_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("_int4", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] fn test_array_bracket_notation() { - assert_eq!(map_type("integer[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("integer[]", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] fn test_array_bracket_text() { - assert_eq!(map_type("text[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("text[]", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] fn test_array_text() { - assert_eq!(map_type("_text", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("_text", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] @@ -377,7 +474,10 @@ mod tests { #[test] fn test_array_bool() { - assert_eq!(map_type("_bool", &empty_schema(), TimeCrate::Chrono).path, "Vec"); + assert_eq!( + map_type("_bool", &empty_schema(), TimeCrate::Chrono).path, + "Vec" + ); } #[test] @@ -389,7 +489,10 @@ mod tests { #[test] fn test_array_bytea() { - assert_eq!(map_type("_bytea", &empty_schema(), TimeCrate::Chrono).path, "Vec>"); + assert_eq!( + map_type("_bytea", &empty_schema(), TimeCrate::Chrono).path, + "Vec>" + ); } // --- enums/composites/domains --- @@ -399,7 +502,11 @@ mod tests { let schema = schema_with_enum("status"); let rt = map_type("status", &schema, TimeCrate::Chrono); assert_eq!(rt.path, "Status"); - assert!(rt.needs_import.as_ref().unwrap().contains("super::types::Status")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("super::types::Status")); } #[test] @@ -414,7 +521,11 @@ mod tests { let schema = schema_with_composite("address"); let rt = map_type("address", &schema, TimeCrate::Chrono); assert_eq!(rt.path, "Address"); - assert!(rt.needs_import.as_ref().unwrap().contains("super::types::Address")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("super::types::Address")); } #[test] @@ -467,12 +578,18 @@ mod tests { #[test] fn test_geometry_fallback() { - assert_eq!(map_type("geometry", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("geometry", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } #[test] fn test_hstore_fallback() { - assert_eq!(map_type("hstore", &empty_schema(), TimeCrate::Chrono).path, "String"); + assert_eq!( + map_type("hstore", &empty_schema(), TimeCrate::Chrono).path, + "String" + ); } // --- time crate --- @@ -481,14 +598,22 @@ mod tests { fn test_timestamptz_time_crate() { let rt = map_type("timestamptz", &empty_schema(), TimeCrate::Time); assert_eq!(rt.path, "OffsetDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::OffsetDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::OffsetDateTime")); } #[test] fn test_timestamp_time_crate() { let rt = map_type("timestamp", &empty_schema(), TimeCrate::Time); assert_eq!(rt.path, "PrimitiveDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::PrimitiveDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::PrimitiveDateTime")); } #[test] diff --git a/crates/sqlx_gen/src/typemap/sqlite.rs b/crates/sqlx_gen/src/typemap/sqlite.rs index 710d96b..da2f301 100644 --- a/crates/sqlx_gen/src/typemap/sqlite.rs +++ b/crates/sqlx_gen/src/typemap/sqlite.rs @@ -21,8 +21,12 @@ pub fn map_type(declared_type: &str, time_crate: TimeCrate) -> RustType { } if upper.contains("TIMESTAMP") || upper.contains("DATETIME") { return match time_crate { - TimeCrate::Chrono => RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;"), - TimeCrate::Time => RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;"), + TimeCrate::Chrono => { + RustType::with_import("NaiveDateTime", "use chrono::NaiveDateTime;") + } + TimeCrate::Time => { + RustType::with_import("PrimitiveDateTime", "use time::PrimitiveDateTime;") + } }; } if upper.contains("DATE") { @@ -194,14 +198,22 @@ mod tests { fn test_timestamp_time_crate() { let rt = map_type("TIMESTAMP", TimeCrate::Time); assert_eq!(rt.path, "PrimitiveDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::PrimitiveDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::PrimitiveDateTime")); } #[test] fn test_datetime_time_crate() { let rt = map_type("DATETIME", TimeCrate::Time); assert_eq!(rt.path, "PrimitiveDateTime"); - assert!(rt.needs_import.as_ref().unwrap().contains("time::PrimitiveDateTime")); + assert!(rt + .needs_import + .as_ref() + .unwrap() + .contains("time::PrimitiveDateTime")); } #[test] diff --git a/crates/sqlx_gen/src/writer.rs b/crates/sqlx_gen/src/writer.rs index d9f72fb..2bba06b 100644 --- a/crates/sqlx_gen/src/writer.rs +++ b/crates/sqlx_gen/src/writer.rs @@ -150,7 +150,11 @@ mod tests { #[test] fn test_build_content_with_origin() { - let f = make_file("users.rs", "pub struct Users {}", Some("Table: public.users")); + let f = make_file( + "users.rs", + "pub struct Users {}", + Some("Table: public.users"), + ); let content = build_file_content(&f); assert!(content.contains(COMMENT)); assert!(content.contains(INNER_ATTR)); @@ -231,8 +235,16 @@ mod tests { #[test] fn test_multi_creates_files_and_mod() { let files = vec![ - make_file("users.rs", "pub struct Users {}", Some("Table: public.users")), - make_file("posts.rs", "pub struct Posts {}", Some("Table: public.posts")), + make_file( + "users.rs", + "pub struct Users {}", + Some("Table: public.users"), + ), + make_file( + "posts.rs", + "pub struct Posts {}", + Some("Table: public.posts"), + ), ]; let dir = tempfile::tempdir().unwrap(); write_files(&files, dir.path(), false, false).unwrap(); @@ -302,7 +314,11 @@ mod tests { #[test] fn test_single_creates_models_rs() { - let files = vec![make_file("users.rs", "pub struct Users {}", Some("Table: public.users"))]; + let files = vec![make_file( + "users.rs", + "pub struct Users {}", + Some("Table: public.users"), + )]; let dir = tempfile::tempdir().unwrap(); write_files(&files, dir.path(), true, false).unwrap(); assert!(dir.path().join("models.rs").exists()); diff --git a/crates/sqlx_gen/tests/e2e_sqlite.rs b/crates/sqlx_gen/tests/e2e_sqlite.rs index c9f2743..7d9ec72 100644 --- a/crates/sqlx_gen/tests/e2e_sqlite.rs +++ b/crates/sqlx_gen/tests/e2e_sqlite.rs @@ -16,9 +16,21 @@ async fn exec(pool: &SqlitePool, sql: &str) { #[tokio::test] async fn test_simple_table_generates_struct() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("pub struct")); } @@ -27,7 +39,15 @@ async fn test_struct_name_pascal_case() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE user_profiles (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("pub struct UserProfiles")); } @@ -36,7 +56,15 @@ async fn test_integer_mapped_to_i64() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE t (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("i64")); } @@ -45,7 +73,15 @@ async fn test_nullable_column_option() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE t (name TEXT)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("Option<")); } @@ -55,7 +91,15 @@ async fn test_multiple_tables_multiple_files() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; exec(&pool, "CREATE TABLE posts (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 2); } @@ -64,18 +108,42 @@ async fn test_filenames_correct() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files[0].filename, "users.rs"); } #[tokio::test] async fn test_generated_code_parseable() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; let schema = introspect(&pool, false).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); for f in &files { - assert!(syn::parse_file(&f.code).is_ok(), "Failed to parse {}", f.filename); + assert!( + syn::parse_file(&f.code).is_ok(), + "Failed to parse {}", + f.filename + ); } } @@ -85,7 +153,15 @@ async fn test_extra_derives_propagated() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; let schema = introspect(&pool, false).await.unwrap(); let derives = vec!["Serialize".to_string()]; - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &derives, &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &derives, + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert!(files[0].code.contains("Serialize")); } @@ -94,11 +170,30 @@ async fn test_extra_derives_propagated() { #[tokio::test] async fn test_view_generates_struct() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; - exec(&pool, "CREATE VIEW active_users AS SELECT id, name FROM users").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; + exec( + &pool, + "CREATE VIEW active_users AS SELECT id, name FROM users", + ) + .await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); - let view_file = files.iter().find(|f| f.filename == "active_users.rs").unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); + let view_file = files + .iter() + .find(|f| f.filename == "active_users.rs") + .unwrap(); assert!(view_file.code.contains("pub struct ActiveUsers")); } @@ -108,7 +203,15 @@ async fn test_view_origin_contains_view() { exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; exec(&pool, "CREATE VIEW v AS SELECT id FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); let view_file = files.iter().find(|f| f.filename == "v.rs").unwrap(); assert_eq!(view_file.origin, None); } @@ -116,12 +219,28 @@ async fn test_view_origin_contains_view() { #[tokio::test] async fn test_view_code_parseable() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; exec(&pool, "CREATE VIEW user_view AS SELECT id, name FROM users").await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); for f in &files { - assert!(syn::parse_file(&f.code).is_ok(), "Failed to parse {}", f.filename); + assert!( + syn::parse_file(&f.code).is_ok(), + "Failed to parse {}", + f.filename + ); } } @@ -129,10 +248,25 @@ async fn test_view_code_parseable() { async fn test_view_pascal_case_name() { let pool = setup_pool().await; exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL)").await; - exec(&pool, "CREATE VIEW all_active_users AS SELECT id FROM users").await; + exec( + &pool, + "CREATE VIEW all_active_users AS SELECT id FROM users", + ) + .await; let schema = introspect(&pool, true).await.unwrap(); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); - let view_file = files.iter().find(|f| f.filename == "all_active_users.rs").unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); + let view_file = files + .iter() + .find(|f| f.filename == "all_active_users.rs") + .unwrap(); assert!(view_file.code.contains("pub struct AllActiveUsers")); } @@ -146,7 +280,15 @@ async fn test_exclude_table() { let mut schema = introspect(&pool, false).await.unwrap(); let exclude = ["_migrations".to_string()]; schema.tables.retain(|t| !exclude.contains(&t.name)); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].filename, "users.rs"); } @@ -188,8 +330,19 @@ async fn test_exclude_view() { let mut schema = introspect(&pool, true).await.unwrap(); let exclude = ["v1".to_string()]; schema.views.retain(|v| !exclude.contains(&v.name)); - let files = codegen::generate(&schema, DatabaseKind::Sqlite, &[], &HashMap::new(), false, TimeCrate::Chrono).unwrap(); - let view_files: Vec<_> = files.iter().filter(|f| f.code.contains("kind = \"view\"")).collect(); + let files = codegen::generate( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + ) + .unwrap(); + let view_files: Vec<_> = files + .iter() + .filter(|f| f.code.contains("kind = \"view\"")) + .collect(); assert_eq!(view_files.len(), 1); assert_eq!(view_files[0].filename, "v2.rs"); } diff --git a/crates/sqlx_gen/tests/introspect_sqlite.rs b/crates/sqlx_gen/tests/introspect_sqlite.rs index 092b982..0d7f712 100644 --- a/crates/sqlx_gen/tests/introspect_sqlite.rs +++ b/crates/sqlx_gen/tests/introspect_sqlite.rs @@ -44,7 +44,11 @@ async fn test_empty_db_no_domains() { #[tokio::test] async fn test_one_table_two_columns() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; let schema = introspect(&pool, false).await.unwrap(); assert_eq!(schema.tables.len(), 1); assert_eq!(schema.tables[0].columns.len(), 2); @@ -69,9 +73,17 @@ async fn test_schema_name_main() { #[tokio::test] async fn test_column_names_and_order() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL, email TEXT)").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL, email TEXT)", + ) + .await; let schema = introspect(&pool, false).await.unwrap(); - let cols: Vec<&str> = schema.tables[0].columns.iter().map(|c| c.name.as_str()).collect(); + let cols: Vec<&str> = schema.tables[0] + .columns + .iter() + .map(|c| c.name.as_str()) + .collect(); assert_eq!(cols, vec!["id", "name", "email"]); } @@ -120,8 +132,16 @@ async fn test_multiple_tables_sorted() { #[tokio::test] async fn test_view_introspected_with_flag() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; - exec(&pool, "CREATE VIEW active_users AS SELECT id, name FROM users").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; + exec( + &pool, + "CREATE VIEW active_users AS SELECT id, name FROM users", + ) + .await; let schema = introspect(&pool, true).await.unwrap(); assert_eq!(schema.views.len(), 1); assert_eq!(schema.views[0].name, "active_users"); @@ -130,10 +150,22 @@ async fn test_view_introspected_with_flag() { #[tokio::test] async fn test_view_columns_correct() { let pool = setup_pool().await; - exec(&pool, "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)").await; - exec(&pool, "CREATE VIEW user_names AS SELECT id, name FROM users").await; + exec( + &pool, + "CREATE TABLE users (id INTEGER NOT NULL, name TEXT NOT NULL)", + ) + .await; + exec( + &pool, + "CREATE VIEW user_names AS SELECT id, name FROM users", + ) + .await; let schema = introspect(&pool, true).await.unwrap(); - let cols: Vec<&str> = schema.views[0].columns.iter().map(|c| c.name.as_str()).collect(); + let cols: Vec<&str> = schema.views[0] + .columns + .iter() + .map(|c| c.name.as_str()) + .collect(); assert_eq!(cols, vec!["id", "name"]); } From 02af6e132b083344bfbcddfaae1bef184f0133df Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:49:27 +0200 Subject: [PATCH 25/38] test: confirm MySQL inline ENUM round-trips via per-variant rename The audit flagged inline ENUMs as potentially broken, but the existing per-variant #[sqlx(rename)] emitted whenever the camelCase identifier differs from the SQL value is exactly what sqlx::Type expects for text encoding on MySQL/SQLite. These tests pin that behaviour for both lowercase and case-sensitive variants so a future refactor can't silently regress it. --- crates/sqlx_gen/src/codegen/enum_gen.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index e4b7e99..56237a1 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -315,6 +315,31 @@ mod tests { assert!(!code.contains("type_name = \"public.status\"")); } + #[test] + fn test_mysql_inline_enum_emits_rename_for_lowercase_variants() { + // Inline MySQL ENUM('active', 'inactive') → Rust variants are PascalCase + // and need #[sqlx(rename)] so encode/decode hits the SQL text values. + let e = make_enum("status", vec!["active", "inactive"]); + let code = gen(&e, DatabaseKind::Mysql); + assert!( + code.contains("sqlx(rename = \"active\")"), + "MySQL inline ENUM variant must carry rename for round-trip:\n{}", + code + ); + assert!(code.contains("sqlx(rename = \"inactive\")")); + // type_name does NOT exist for MySQL — only PG-native enums need it. + assert!(!code.contains("type_name")); + } + + #[test] + fn test_mysql_inline_enum_preserves_case_sensitive_variants() { + let e = make_enum("priority", vec!["LOW", "HIGH"]); + let code = gen(&e, DatabaseKind::Mysql); + // PascalCase("LOW") = "Low" → rename required so SQL sees "LOW" + assert!(code.contains("sqlx(rename = \"LOW\")")); + assert!(code.contains("sqlx(rename = \"HIGH\")")); + } + #[test] fn test_mysql_no_type_name() { let e = make_enum("status", vec!["a"]); From dc39be5ac8b5f616a4bb89b0fcaad6392058c817 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:52:16 +0200 Subject: [PATCH 26/38] feat: add --domain-style flag for Postgres domain rendering `--domain-style alias` (default) keeps the existing `pub type Email = String;` behaviour. `--domain-style newtype` instead emits #[derive(..., sqlx::Type)] #[sqlx(transparent)] pub struct Email(pub String); so the user can attach validation, traits, or accessors to the domain. Both styles share the same doc-comment and codegen plumbing via the new DomainStyle enum and generate_with_domain_style entry point. CLI defaults preserve current behaviour exactly. --- crates/sqlx_gen/src/cli.rs | 40 ++++++++++ crates/sqlx_gen/src/codegen/domain_gen.rs | 94 +++++++++++++++++++++-- crates/sqlx_gen/src/codegen/mod.rs | 32 +++++++- crates/sqlx_gen/src/main.rs | 3 +- 4 files changed, 161 insertions(+), 8 deletions(-) diff --git a/crates/sqlx_gen/src/cli.rs b/crates/sqlx_gen/src/cli.rs index c134b14..eb9364d 100644 --- a/crates/sqlx_gen/src/cli.rs +++ b/crates/sqlx_gen/src/cli.rs @@ -94,6 +94,11 @@ pub struct EntitiesArgs { #[arg(long, default_value = "chrono")] pub time_crate: TimeCrate, + /// How to render PostgreSQL domains: `alias` (`pub type X = Y;`) or + /// `newtype` (`pub struct X(pub Y);` with `#[sqlx(transparent)]`). + #[arg(long, default_value = "alias")] + pub domain_style: DomainStyle, + /// Print to stdout without writing files #[arg(short = 'n', long)] pub dry_run: bool, @@ -236,6 +241,40 @@ pub enum DatabaseKind { Sqlite, } +/// How a Postgres domain should be rendered in Rust. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DomainStyle { + /// `pub type Email = String;` — transparent alias, zero overhead. + #[default] + Alias, + /// `pub struct Email(pub String);` with `#[sqlx(transparent)]` — preserves + /// type identity so user code can attach `impl` blocks / validation. + Newtype, +} + +impl std::str::FromStr for DomainStyle { + type Err = String; + fn from_str(s: &str) -> Result { + match s { + "alias" => Ok(Self::Alias), + "newtype" => Ok(Self::Newtype), + other => Err(format!( + "Unknown domain style '{}'. Expected: alias, newtype", + other + )), + } + } +} + +impl std::fmt::Display for DomainStyle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Alias => write!(f, "alias"), + Self::Newtype => write!(f, "newtype"), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum TimeCrate { #[default] @@ -379,6 +418,7 @@ mod tests { exclude_tables: None, views: false, time_crate: TimeCrate::Chrono, + domain_style: DomainStyle::Alias, dry_run: false, } } diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index f259729..4a44354 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -4,7 +4,7 @@ use heck::ToUpperCamelCase; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use crate::cli::{DatabaseKind, TimeCrate}; +use crate::cli::{DatabaseKind, DomainStyle, TimeCrate}; use crate::introspect::{DomainInfo, SchemaInfo}; use crate::typemap; @@ -14,6 +14,24 @@ pub fn generate_domain( schema_info: &SchemaInfo, type_overrides: &HashMap, time_crate: TimeCrate, +) -> (TokenStream, BTreeSet) { + generate_domain_with_style( + domain, + db_kind, + schema_info, + type_overrides, + time_crate, + DomainStyle::Alias, + ) +} + +pub fn generate_domain_with_style( + domain: &DomainInfo, + db_kind: DatabaseKind, + schema_info: &SchemaInfo, + type_overrides: &HashMap, + time_crate: TimeCrate, + style: DomainStyle, ) -> (TokenStream, BTreeSet) { let mut imports = BTreeSet::new(); let alias_name = format_ident!("{}", domain.name.to_upper_camel_case()); @@ -47,10 +65,22 @@ pub fn generate_domain( }); let domain_doc = "sqlx_gen:kind=domain"; - let tokens = quote! { - #[doc = #doc] - #[doc = #domain_doc] - pub type #alias_name = #type_tokens; + let tokens = match style { + DomainStyle::Alias => quote! { + #[doc = #doc] + #[doc = #domain_doc] + pub type #alias_name = #type_tokens; + }, + DomainStyle::Newtype => { + imports.insert("use serde::{Serialize, Deserialize};".to_string()); + quote! { + #[doc = #doc] + #[doc = #domain_doc] + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] + #[sqlx(transparent)] + pub struct #alias_name(pub #type_tokens); + } + } }; (tokens, imports) @@ -169,4 +199,58 @@ mod tests { let (_, imports) = gen(&d); assert!(imports.iter().any(|i| i.contains("chrono"))); } + + // ========== DomainStyle::Newtype ========== + + fn gen_newtype(domain: &DomainInfo) -> (String, BTreeSet) { + let schema = SchemaInfo::default(); + let (tokens, imports) = generate_domain_with_style( + domain, + DatabaseKind::Postgres, + &schema, + &HashMap::new(), + TimeCrate::Chrono, + DomainStyle::Newtype, + ); + (parse_and_format(&tokens).unwrap(), imports) + } + + #[test] + fn test_newtype_emits_tuple_struct() { + let d = make_domain("email", "text"); + let (code, _) = gen_newtype(&d); + assert!(code.contains("pub struct Email(pub String)"), + "newtype must wrap the base type in a tuple struct, got:\n{}", code); + } + + #[test] + fn test_newtype_uses_transparent_derive() { + let d = make_domain("email", "text"); + let (code, _) = gen_newtype(&d); + assert!(code.contains("#[sqlx(transparent)]")); + assert!(code.contains("sqlx::Type")); + } + + #[test] + fn test_newtype_keeps_doc_comments() { + let d = make_domain("email", "text"); + let (code, _) = gen_newtype(&d); + assert!(code.contains("Domain: public.email (base: text)")); + assert!(code.contains("sqlx_gen:kind=domain")); + } + + #[test] + fn test_newtype_wraps_uuid_with_import() { + let d = make_domain("my_uuid", "uuid"); + let (code, imports) = gen_newtype(&d); + assert!(code.contains("pub struct MyUuid(pub Uuid)")); + assert!(imports.iter().any(|i| i.contains("uuid::Uuid"))); + } + + #[test] + fn test_newtype_does_not_emit_type_alias() { + let d = make_domain("email", "text"); + let (code, _) = gen_newtype(&d); + assert!(!code.contains("pub type Email")); + } } diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index ffc227d..b148c32 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -118,6 +118,28 @@ pub fn generate( type_overrides: &HashMap, single_file: bool, time_crate: TimeCrate, +) -> crate::error::Result> { + generate_with_domain_style( + schema_info, + db_kind, + extra_derives, + type_overrides, + single_file, + time_crate, + crate::cli::DomainStyle::Alias, + ) +} + +/// Same as [`generate`] but lets the caller pick how Postgres domains are +/// rendered (alias vs newtype). +pub fn generate_with_domain_style( + schema_info: &SchemaInfo, + db_kind: DatabaseKind, + extra_derives: &[String], + type_overrides: &HashMap, + single_file: bool, + time_crate: TimeCrate, + domain_style: crate::cli::DomainStyle, ) -> crate::error::Result> { let mut files = Vec::new(); @@ -208,8 +230,14 @@ pub fn generate( } for domain in &schema_info.domains { - let (tokens, imports) = - domain_gen::generate_domain(domain, db_kind, schema_info, type_overrides, time_crate); + let (tokens, imports) = domain_gen::generate_domain_with_style( + domain, + db_kind, + schema_info, + type_overrides, + time_crate, + domain_style, + ); types_blocks.push(format_tokens(&tokens)?); types_imports.extend(imports); } diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 08264e5..92c4ac3 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -100,13 +100,14 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { ); } - let files = codegen::generate( + let files = codegen::generate_with_domain_style( &schema_info, db_kind, &args.derives, &type_overrides, args.single_file, args.time_crate, + args.domain_style, )?; writer::write_files(&files, &args.output_dir, args.single_file, args.dry_run)?; From a8dad3eea51f9f3d5c566f2f821b155964858897 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:54:32 +0200 Subject: [PATCH 27/38] feat: detect SQLite TEXT CHECK (col IN (...)) as implicit enum SQLite has no native enum type, so users encode them with TEXT CHECK (status IN ('active', 'inactive')) extract_check_enums parses sqlite_master.sql for each table, looks for that pattern column-by-column, and synthesises an EnumInfo plus rewrites the column's udt_name to __enum. From there the existing enum/typemap pipeline takes over and emits a real Rust enum that round-trips via per-variant #[sqlx(rename)]. --- crates/sqlx_gen/src/introspect/sqlite.rs | 164 ++++++++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/crates/sqlx_gen/src/introspect/sqlite.rs b/crates/sqlx_gen/src/introspect/sqlite.rs index c803b7f..004238b 100644 --- a/crates/sqlx_gen/src/introspect/sqlite.rs +++ b/crates/sqlx_gen/src/introspect/sqlite.rs @@ -3,10 +3,10 @@ use std::collections::HashMap; use crate::error::Result; use sqlx::SqlitePool; -use super::{ColumnInfo, SchemaInfo, TableInfo}; +use super::{ColumnInfo, EnumInfo, SchemaInfo, TableInfo}; pub async fn introspect(pool: &SqlitePool, include_views: bool) -> Result { - let tables = fetch_tables(pool).await?; + let mut tables = fetch_tables(pool).await?; let mut views = if include_views { fetch_views(pool).await? } else { @@ -18,15 +18,128 @@ pub async fn introspect(pool: &SqlitePool, include_views: bool) -> Result__enum`) so the rest of the pipeline +/// treats it like a real enum (with PgHasArrayType skipped for SQLite). +async fn extract_check_enums( + pool: &SqlitePool, + tables: &mut [TableInfo], +) -> Result> { + let mut enums = Vec::new(); + + for table in tables.iter_mut() { + let sql: Option<(Option,)> = sqlx::query_as( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .bind(&table.name) + .fetch_optional(pool) + .await?; + let Some((Some(ddl),)) = sql else { continue }; + + for col in table.columns.iter_mut() { + if let Some(variants) = parse_check_in_variants(&ddl, &col.name) { + if variants.is_empty() { + continue; + } + let enum_name = format!("{}_{}_enum", table.name, col.name); + col.udt_name = enum_name.clone(); + enums.push(EnumInfo { + schema_name: "main".to_string(), + name: enum_name, + variants, + default_variant: None, + }); + } + } + } + + Ok(enums) +} + +/// Parse `CHECK (col IN ('a','b','c'))` for a given column from a SQLite +/// CREATE TABLE statement. Returns the parsed variants in declaration order +/// or `None` if the column has no IN-style CHECK constraint. +fn parse_check_in_variants(ddl: &str, column: &str) -> Option> { + let lower_ddl = ddl.to_ascii_lowercase(); + let lower_col = column.to_ascii_lowercase(); + let mut search_from = 0usize; + + while let Some(rel_check) = lower_ddl[search_from..].find("check") { + let check_pos = search_from + rel_check; + let after_check = &ddl[check_pos + 5..]; + let after_check_lower = &lower_ddl[check_pos + 5..]; + + let open_rel = after_check.find('(')?; + let mut depth = 1i32; + let mut idx = open_rel + 1; + let bytes = after_check.as_bytes(); + while idx < bytes.len() && depth > 0 { + match bytes[idx] { + b'(' => depth += 1, + b')' => depth -= 1, + b'\'' => { + idx += 1; + while idx < bytes.len() && bytes[idx] != b'\'' { + idx += 1; + } + } + _ => {} + } + idx += 1; + } + if depth != 0 { + return None; + } + let body = &after_check[open_rel + 1..idx - 1]; + let body_lower = &after_check_lower[open_rel + 1..idx - 1]; + + search_from = check_pos + 5 + idx; + + if !body_lower.contains(&lower_col) || !body_lower.contains(" in ") { + continue; + } + + if let Some(in_pos) = body_lower.find(" in ") { + let list_start = body[in_pos..].find('(')?; + let list_body = &body[in_pos + list_start + 1..]; + let mut variants = Vec::new(); + let bytes = list_body.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\'' { + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != b'\'' { + j += 1; + } + variants.push(list_body[start..j].to_string()); + i = j + 1; + } else if bytes[i] == b')' { + break; + } else { + i += 1; + } + } + return Some(variants); + } + } + + None +} + async fn fetch_tables(pool: &SqlitePool) -> Result> { let table_names: Vec<(String,)> = sqlx::query_as( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", @@ -298,4 +411,49 @@ mod tests { resolve_view_primary_keys(&mut views, &[]); assert!(!views[0].columns[0].is_primary_key); } + + // ========== parse_check_in_variants ========== + + #[test] + fn test_parse_check_in_simple() { + let ddl = "CREATE TABLE t (id INTEGER PRIMARY KEY, status TEXT CHECK (status IN ('active', 'inactive')) NOT NULL)"; + assert_eq!( + parse_check_in_variants(ddl, "status"), + Some(vec!["active".to_string(), "inactive".to_string()]) + ); + } + + #[test] + fn test_parse_check_in_three_variants() { + let ddl = "CREATE TABLE t (priority TEXT CHECK (priority IN ('low','medium','high')))"; + assert_eq!( + parse_check_in_variants(ddl, "priority"), + Some(vec![ + "low".to_string(), + "medium".to_string(), + "high".to_string() + ]) + ); + } + + #[test] + fn test_parse_check_in_returns_none_for_other_column() { + let ddl = "CREATE TABLE t (status TEXT CHECK (status IN ('a','b')))"; + assert_eq!(parse_check_in_variants(ddl, "other"), None); + } + + #[test] + fn test_parse_check_in_returns_none_without_check() { + let ddl = "CREATE TABLE t (status TEXT)"; + assert_eq!(parse_check_in_variants(ddl, "status"), None); + } + + #[test] + fn test_parse_check_in_case_insensitive_keyword() { + let ddl = "CREATE TABLE t (status TEXT check (Status in ('a','b')))"; + assert_eq!( + parse_check_in_variants(ddl, "status"), + Some(vec!["a".to_string(), "b".to_string()]) + ); + } } From 7a08978fea8a45cdcb561a0694be8080fc4b79af Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 16:58:55 +0200 Subject: [PATCH 28/38] feat: classify common sqlx errors by SQLSTATE for actionable messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contextualize_sqlx_error inspects the SQLSTATE on a sqlx::Error and re-raises: - 42501 / 28000 → PermissionDenied with a hint about the DB user's privileges on information_schema / pg_catalog / sqlite_master - 42P01 / 3F000 / 42S02 → SchemaNotFound with a hint about --schemas Other sqlx::Error values still fall through to the existing Error::Database variant, so the public API and behaviour are unchanged for unrelated failures. --- crates/sqlx_gen/src/error.rs | 44 ++++++++++++++++++++++++++++++++++++ crates/sqlx_gen/src/main.rs | 18 ++++++++++++--- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/sqlx_gen/src/error.rs b/crates/sqlx_gen/src/error.rs index 62d84a4..03f7f54 100644 --- a/crates/sqlx_gen/src/error.rs +++ b/crates/sqlx_gen/src/error.rs @@ -9,6 +9,12 @@ pub enum Error { source: sqlx::Error, }, + #[error("Permission denied while introspecting: {detail}. Check the DB user's privileges on information_schema / pg_catalog / sqlite_master.")] + PermissionDenied { detail: String }, + + #[error("Schema or relation not found: {detail}. Check `--schemas` and ensure the database contains the expected tables.")] + SchemaNotFound { detail: String }, + #[error("Database error: {0}")] Database(#[from] sqlx::Error), @@ -21,6 +27,35 @@ pub enum Error { pub type Result = std::result::Result; +/// Inspect a [`sqlx::Error`] and, if it carries a SQLSTATE we know how to +/// explain, return a richer [`Error`] variant. Otherwise the input is wrapped +/// in [`Error::Database`] unchanged so callers can keep using `?`. +pub fn contextualize_sqlx_error(err: sqlx::Error) -> Error { + use sqlx::Error as Sx; + let code: Option = match &err { + Sx::Database(db) => db.code().map(|c| c.to_string()), + _ => None, + }; + if let Some(code) = code { + // PG: 42501 insufficient_privilege; MySQL: 42000 / 28000. + // PG: 42P01 undefined_table, 3F000 invalid_schema_name; MySQL: 42S02. + match code.as_str() { + "42501" | "28000" => { + return Error::PermissionDenied { + detail: err.to_string(), + }; + } + "42P01" | "3F000" | "42S02" => { + return Error::SchemaNotFound { + detail: err.to_string(), + }; + } + _ => {} + } + } + Error::Database(err) +} + /// Redact `user:password@host` → `user:****@host` in a database URL so it can /// be embedded in error messages and logs without leaking credentials. pub fn redact_url(url: &str) -> String { @@ -92,4 +127,13 @@ mod tests { fn leaves_non_url_string_unchanged() { assert_eq!(redact_url("not-a-url"), "not-a-url"); } + + #[test] + fn contextualize_non_database_error_wraps_unchanged() { + let err = sqlx::Error::PoolTimedOut; + match contextualize_sqlx_error(err) { + Error::Database(_) => {} + other => panic!("expected Database, got {:?}", other), + } + } } diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index 92c4ac3..dad63ac 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -40,6 +40,15 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { redacted_url: redacted.clone(), source, }; + // If introspection itself returns a sqlx::Error wrapped in our generic + // Database variant, re-classify by SQLSTATE so the user sees an + // actionable message instead of the raw "error returned from database". + let map_introspect_error = |e: sqlx_gen::error::Error| match e { + sqlx_gen::error::Error::Database(inner) => { + sqlx_gen::error::contextualize_sqlx_error(inner) + } + other => other, + }; let mut schema_info = match db_kind { DatabaseKind::Postgres => { @@ -47,7 +56,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { .await .map_err(conn_err)?; let info = - introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await?; + introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await + .map_err(map_introspect_error)?; pool.close().await; info } @@ -55,7 +65,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let pool = MySqlPool::connect(&args.db.database_url) .await .map_err(conn_err)?; - let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await?; + let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await + .map_err(map_introspect_error)?; pool.close().await; info } @@ -63,7 +74,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let pool = SqlitePool::connect(&args.db.database_url) .await .map_err(conn_err)?; - let info = introspect::sqlite::introspect(&pool, args.views).await?; + let info = introspect::sqlite::introspect(&pool, args.views).await + .map_err(map_introspect_error)?; pool.close().await; info } From b339f38ae3760f85ae813473b101ac8deb567a34 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 17:01:59 +0200 Subject: [PATCH 29/38] fix: MySQL composite-PK insert uses bound PK values, not LAST_INSERT_ID LAST_INSERT_ID() only returns a meaningful value when the table has a single AUTO_INCREMENT primary key. For composite PKs: - include every PK column in InsertParams so the user can supply them - run the INSERT with the bound values - SELECT the freshly inserted row by binding the same PK values build_insert_method_parsed and build_insert_many_transactionally_method both branch on pk_fields.len(); single-PK MySQL flows continue to use LAST_INSERT_ID exactly as before. Postgres / SQLite are unaffected because their RETURNING * already handled this case. --- crates/sqlx_gen/src/codegen/crud_gen.rs | 234 +++++++++++++++++++----- 1 file changed, 188 insertions(+), 46 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 8352194..4dd3235 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -252,9 +252,20 @@ pub fn generate_crud_from_parsed( if !is_view && methods.insert && (!non_pk_fields.is_empty() || !pk_fields.is_empty()) { let insert_params_ident = format_ident!("Insert{}Params", entity.struct_name); - // When all columns are PKs (e.g. junction tables), use pk_fields for insert + // Insert source fields: + // - Junction table (all-PK): use the PKs themselves so something gets inserted. + // - Composite PK (>1) with extra columns: include the PKs alongside non-PK + // columns. Composite PKs are typically NOT auto-generated, so omitting + // them would produce a NOT NULL violation. The MySQL branch below also + // relies on the bound PK values when LAST_INSERT_ID is not applicable. + // - Single PK + extras: keep the legacy behaviour (exclude the PK and + // assume it's SERIAL/AUTO_INCREMENT). let insert_source_fields: Vec<&ParsedField> = if non_pk_fields.is_empty() { pk_fields.clone() + } else if pk_fields.len() > 1 { + let mut combined: Vec<&ParsedField> = pk_fields.clone(); + combined.extend(non_pk_fields.iter().copied()); + combined } else { non_pk_fields.clone() }; @@ -983,18 +994,42 @@ fn build_insert_method_parsed( "SELECT *\nFROM {}\nWHERE {}", table_name, pk_where )); - let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID() as id"); - quote! { - pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { - sqlx::query!(#sql_macro, #(#macro_args),*) - .execute(&self.pool) - .await?; - let id = sqlx::query_scalar!(#last_insert_id_sql) - .fetch_one(&self.pool) - .await?; - sqlx::query_as!(#entity_ident, #select_sql, id) - .fetch_one(&self.pool) - .await + if pk_fields.len() > 1 { + // Composite PK → the user supplied every PK column in + // params, so SELECT by them directly. LAST_INSERT_ID is + // meaningless for composite keys (only the first + // auto-increment column populates it, if any). + let pk_macro_args: Vec = pk_fields + .iter() + .map(|f| { + let name = format_ident!("{}", f.rust_name); + quote! { params.#name } + }) + .collect(); + quote! { + pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { + sqlx::query!(#sql_macro, #(#macro_args),*) + .execute(&self.pool) + .await?; + sqlx::query_as!(#entity_ident, #select_sql, #(#pk_macro_args),*) + .fetch_one(&self.pool) + .await + } + } + } else { + let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID() as id"); + quote! { + pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { + sqlx::query!(#sql_macro, #(#macro_args),*) + .execute(&self.pool) + .await?; + let id = sqlx::query_scalar!(#last_insert_id_sql) + .fetch_one(&self.pool) + .await?; + sqlx::query_as!(#entity_ident, #select_sql, id) + .fetch_one(&self.pool) + .await + } } } } @@ -1017,20 +1052,42 @@ fn build_insert_method_parsed( "SELECT *\nFROM {}\nWHERE {}", table_name, pk_where )); - let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID()"); - quote! { - pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { - sqlx::query(#sql) - #(#binds)* - .execute(&self.pool) - .await?; - let id = sqlx::query_scalar::<_, i64>(#last_insert_id_sql) - .fetch_one(&self.pool) - .await?; - sqlx::query_as::<_, #entity_ident>(#select_sql) - .bind(id) - .fetch_one(&self.pool) - .await + if pk_fields.len() > 1 { + let pk_binds: Vec = pk_fields + .iter() + .map(|f| { + let name = format_ident!("{}", f.rust_name); + quote! { .bind(¶ms.#name) } + }) + .collect(); + quote! { + pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { + sqlx::query(#sql) + #(#binds)* + .execute(&self.pool) + .await?; + sqlx::query_as::<_, #entity_ident>(#select_sql) + #(#pk_binds)* + .fetch_one(&self.pool) + .await + } + } + } else { + let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID()"); + quote! { + pub async fn insert(&self, params: &#insert_params_ident) -> Result<#entity_ident, sqlx::Error> { + sqlx::query(#sql) + #(#binds)* + .execute(&self.pool) + .await?; + let id = sqlx::query_scalar::<_, i64>(#last_insert_id_sql) + .fetch_one(&self.pool) + .await?; + sqlx::query_as::<_, #entity_ident>(#select_sql) + .bind(id) + .fetch_one(&self.pool) + .await + } } } } @@ -1144,27 +1201,54 @@ fn build_insert_many_transactionally_method( "SELECT *\nFROM {}\nWHERE {}", table_name, pk_where )); - let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID()"); - quote! { - let mut tx = self.pool.begin().await?; - let mut results = Vec::with_capacity(entries.len()); - for params in &entries { - sqlx::query(#single_insert_sql) - #(#single_binds)* - .execute(&mut *tx) - .await?; - let id = sqlx::query_scalar::<_, i64>(#last_insert_id_sql) - .fetch_one(&mut *tx) - .await?; - let row = sqlx::query_as::<_, #entity_ident>(#select_sql) - .bind(id) - .fetch_one(&mut *tx) - .await?; - results.push(row); + if pk_fields.len() > 1 { + let pk_binds: Vec = pk_fields + .iter() + .map(|f| { + let name = format_ident!("{}", f.rust_name); + quote! { .bind(¶ms.#name) } + }) + .collect(); + quote! { + let mut tx = self.pool.begin().await?; + let mut results = Vec::with_capacity(entries.len()); + for params in &entries { + sqlx::query(#single_insert_sql) + #(#single_binds)* + .execute(&mut *tx) + .await?; + let row = sqlx::query_as::<_, #entity_ident>(#select_sql) + #(#pk_binds)* + .fetch_one(&mut *tx) + .await?; + results.push(row); + } + tx.commit().await?; + Ok(results) + } + } else { + let last_insert_id_sql = raw_sql_lit("SELECT LAST_INSERT_ID()"); + quote! { + let mut tx = self.pool.begin().await?; + let mut results = Vec::with_capacity(entries.len()); + for params in &entries { + sqlx::query(#single_insert_sql) + #(#single_binds)* + .execute(&mut *tx) + .await?; + let id = sqlx::query_scalar::<_, i64>(#last_insert_id_sql) + .fetch_one(&mut *tx) + .await?; + let row = sqlx::query_as::<_, #entity_ident>(#select_sql) + .bind(id) + .fetch_one(&mut *tx) + .await?; + results.push(row); + } + tx.commit().await?; + Ok(results) } - tx.commit().await?; - Ok(results) } } }; @@ -2755,6 +2839,64 @@ mod tests { ); } + // --- composite PK + non-PK columns (MySQL) --- + + fn composite_pk_with_extra() -> ParsedEntity { + ParsedEntity { + struct_name: "OrderItems".to_string(), + table_name: "order_items".to_string(), + schema_name: None, + is_view: false, + fields: vec![ + make_field("order_id", "order_id", "i32", false, true), + make_field("product_id", "product_id", "i32", false, true), + make_field("qty", "qty", "i32", false, false), + ], + imports: vec![], + } + } + + #[test] + fn test_mysql_composite_pk_insert_uses_select_not_last_insert_id() { + let code = gen(&composite_pk_with_extra(), DatabaseKind::Mysql); + assert!( + !code.contains("LAST_INSERT_ID"), + "composite PK insert must not use LAST_INSERT_ID(), got:\n{}", + code + ); + assert!( + code.contains("SELECT *"), + "must SELECT the row back after INSERT, got:\n{}", + code + ); + assert!( + code.contains("WHERE `order_id` = ? AND `product_id` = ?"), + "SELECT must use bound composite PK values, got:\n{}", + code + ); + } + + #[test] + fn test_mysql_composite_pk_includes_pks_in_insert_params() { + let code = gen(&composite_pk_with_extra(), DatabaseKind::Mysql); + assert!( + code.contains("pub order_id"), + "InsertParams must expose composite PK column order_id, got:\n{}", + code + ); + assert!(code.contains("pub product_id")); + assert!(code.contains("pub qty")); + } + + #[test] + fn test_mysql_single_pk_insert_still_uses_last_insert_id() { + let code = gen(&standard_entity(), DatabaseKind::Mysql); + assert!( + code.contains("LAST_INSERT_ID"), + "single-PK MySQL insert should still rely on LAST_INSERT_ID()" + ); + } + // --- insert_many_transactionally --- #[test] From 104b774553b8788bf269251c01911dbab52a6036 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 17:03:57 +0200 Subject: [PATCH 30/38] test: add compile-check harness for generated code compile_check.rs validates that codegen output is loadable in two modes: 1. Fast path (always on): each GeneratedFile is parsed with syn::parse_file. Catches malformed attributes, unclosed braces, invalid identifiers, and anything else that breaks at the AST level. Runs across Postgres, MySQL, SQLite, and the newtype-domain variant. 2. Deep path (gated on SQLX_GEN_COMPILE_CHECK=1): scaffolds a temporary downstream crate, drops the generated code into src/lib.rs, and runs `cargo check` with the full sqlx dependency tree. This is the only check that confirms the emitted derives and #[sqlx(...)] attributes are actually accepted by sqlx itself. --- crates/sqlx_gen/tests/compile_check.rs | 285 +++++++++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 crates/sqlx_gen/tests/compile_check.rs diff --git a/crates/sqlx_gen/tests/compile_check.rs b/crates/sqlx_gen/tests/compile_check.rs new file mode 100644 index 0000000..f0e0eef --- /dev/null +++ b/crates/sqlx_gen/tests/compile_check.rs @@ -0,0 +1,285 @@ +//! Validate that codegen output is syntactically and semantically loadable. +//! +//! The fast path runs on every CI build: each generated file is parsed with +//! `syn::parse_file` (proves the output is valid Rust at the AST level). +//! +//! The deep path runs only when `SQLX_GEN_COMPILE_CHECK=1` is set in the env: +//! it scaffolds a temporary downstream crate, writes the generated files into +//! `src/lib.rs`, and runs `cargo check` against the upstream `sqlx-gen` crate +//! plus `sqlx`. That confirms the emitted attributes, derives, and imports are +//! genuinely accepted by `sqlx::FromRow`, `sqlx::Type`, and friends — not just +//! shaped like valid Rust. + +use std::collections::HashMap; +use std::path::Path; + +use sqlx_gen::cli::{DatabaseKind, DomainStyle, TimeCrate}; +use sqlx_gen::codegen::{generate_with_domain_style, GeneratedFile}; +use sqlx_gen::introspect::{ + ColumnInfo, CompositeTypeInfo, DomainInfo, EnumInfo, SchemaInfo, TableInfo, +}; + +fn rich_schema() -> SchemaInfo { + SchemaInfo { + tables: vec![TableInfo { + schema_name: "public".to_string(), + name: "users".to_string(), + columns: vec![ + column("id", "int4", false, true, None), + column("email", "text", false, false, None), + column("name", "text", true, false, None), + column("status", "status", false, false, None), + column("metadata", "jsonb", true, false, None), + ], + }], + views: vec![TableInfo { + schema_name: "public".to_string(), + name: "active_users".to_string(), + columns: vec![ + column("id", "int4", false, false, None), + column("email", "text", false, false, None), + ], + }], + enums: vec![EnumInfo { + schema_name: "public".to_string(), + name: "status".to_string(), + variants: vec!["active".to_string(), "inactive".to_string()], + default_variant: Some("active".to_string()), + }], + composite_types: vec![CompositeTypeInfo { + schema_name: "public".to_string(), + name: "address".to_string(), + fields: vec![ + column("street", "text", false, false, None), + column("city", "text", false, false, None), + ], + }], + domains: vec![DomainInfo { + schema_name: "public".to_string(), + name: "email".to_string(), + base_type: "text".to_string(), + }], + } +} + +fn column( + name: &str, + udt: &str, + nullable: bool, + pk: bool, + default: Option<&str>, +) -> ColumnInfo { + ColumnInfo { + name: name.to_string(), + data_type: udt.to_string(), + udt_name: udt.to_string(), + is_nullable: nullable, + is_primary_key: pk, + ordinal_position: 0, + schema_name: "public".to_string(), + column_default: default.map(|s| s.to_string()), + } +} + +fn parse_each_file(files: &[GeneratedFile]) { + for f in files { + syn::parse_file(&f.code).unwrap_or_else(|e| { + panic!( + "generated file '{}' is not syntactically valid Rust: {}\n--- BEGIN ---\n{}\n--- END ---", + f.filename, e, f.code + ) + }); + } +} + +#[test] +fn generated_postgres_files_parse() { + let files = generate_with_domain_style( + &rich_schema(), + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + DomainStyle::Alias, + ) + .expect("codegen"); + parse_each_file(&files); +} + +#[test] +fn generated_mysql_files_parse() { + let schema = SchemaInfo { + // MySQL has no enums/composites/domains in our model, just tables. + tables: rich_schema().tables.into_iter().map(|mut t| { + t.columns.retain(|c| c.udt_name != "status" && c.udt_name != "jsonb"); + t + }).collect(), + views: vec![], + enums: vec![], + composite_types: vec![], + domains: vec![], + }; + let files = generate_with_domain_style( + &schema, + DatabaseKind::Mysql, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + DomainStyle::Alias, + ) + .expect("codegen"); + parse_each_file(&files); +} + +#[test] +fn generated_sqlite_files_parse() { + let schema = SchemaInfo { + tables: vec![TableInfo { + schema_name: "main".to_string(), + name: "users".to_string(), + columns: vec![ + column("id", "INTEGER", false, true, None), + column("name", "TEXT", false, false, None), + ], + }], + views: vec![], + enums: vec![], + composite_types: vec![], + domains: vec![], + }; + let files = generate_with_domain_style( + &schema, + DatabaseKind::Sqlite, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + DomainStyle::Alias, + ) + .expect("codegen"); + parse_each_file(&files); +} + +#[test] +fn generated_postgres_files_parse_with_newtype_domain() { + let files = generate_with_domain_style( + &rich_schema(), + DatabaseKind::Postgres, + &[], + &HashMap::new(), + false, + TimeCrate::Chrono, + DomainStyle::Newtype, + ) + .expect("codegen"); + parse_each_file(&files); + // Ensure the newtype actually emitted a tuple struct (not a type alias). + let types_rs = files + .iter() + .find(|f| f.filename == "types.rs") + .expect("types.rs file should be emitted"); + assert!( + types_rs.code.contains("pub struct Email(pub String)"), + "newtype domain not found in:\n{}", + types_rs.code + ); +} + +/// Deep check: actually run `cargo check` against a real downstream crate. +/// Skipped unless `SQLX_GEN_COMPILE_CHECK=1` is set, because it pulls in +/// the full sqlx dependency tree (~1 min on a cold cargo cache). +#[test] +fn generated_files_pass_cargo_check_in_downstream_crate() { + if std::env::var("SQLX_GEN_COMPILE_CHECK").as_deref() != Ok("1") { + eprintln!("skipped (set SQLX_GEN_COMPILE_CHECK=1 to enable)"); + return; + } + + let files = generate_with_domain_style( + &rich_schema(), + DatabaseKind::Postgres, + &[], + &HashMap::new(), + true, // single_file = true so we emit one models.rs + TimeCrate::Chrono, + DomainStyle::Alias, + ) + .expect("codegen"); + + let dir = tempfile::tempdir().expect("temp dir"); + let project_root = workspace_root(); + let sqlx_gen_path = project_root.join("crates/sqlx_gen"); + + std::fs::write( + dir.path().join("Cargo.toml"), + format!( + r#" +[package] +name = "sqlx_gen_compile_check" +version = "0.0.0" +edition = "2021" + +[lib] +path = "src/lib.rs" + +[dependencies] +sqlx = {{ version = "0.8", default-features = false, features = [ + "runtime-tokio", "tls-rustls-ring", "postgres", "chrono", "uuid", "json", +] }} +sqlx_gen = {{ path = "{}", default-features = false }} +serde = {{ version = "1", features = ["derive"] }} +chrono = "0.4" +uuid = "1" +serde_json = "1" +rust_decimal = "1" +ipnetwork = "0.20" +"#, + sqlx_gen_path.display(), + ), + ) + .unwrap(); + std::fs::create_dir_all(dir.path().join("src")).unwrap(); + // Concatenate all generated files into a single lib.rs. + let mut lib = String::new(); + lib.push_str("#![allow(unused_imports, dead_code, unused_attributes)]\n\n"); + for f in &files { + lib.push_str(&f.code); + lib.push_str("\n\n"); + } + std::fs::write(dir.path().join("src/lib.rs"), &lib).unwrap(); + + let status = std::process::Command::new("cargo") + .arg("check") + .arg("--offline") + .current_dir(dir.path()) + .status() + .or_else(|_| { + std::process::Command::new("cargo") + .arg("check") + .current_dir(dir.path()) + .status() + }) + .expect("invoke cargo check"); + + assert!( + status.success(), + "generated code did not pass `cargo check` in a downstream crate" + ); +} + +fn workspace_root() -> std::path::PathBuf { + let mut p = Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf(); + // CARGO_MANIFEST_DIR is the sqlx_gen crate; walk up until we hit the workspace Cargo.toml. + while !p.join("Cargo.toml").exists() + || !std::fs::read_to_string(p.join("Cargo.toml")) + .map(|c| c.contains("[workspace]")) + .unwrap_or(false) + { + if !p.pop() { + panic!("could not locate workspace root from {}", env!("CARGO_MANIFEST_DIR")); + } + } + p +} From 6a3c3ac1b58f089ac5f48bc4113cbb76214f9ea0 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 17:04:09 +0200 Subject: [PATCH 31/38] style: apply rustfmt to compile_check harness --- crates/sqlx_gen/src/codegen/domain_gen.rs | 7 ++++-- crates/sqlx_gen/src/introspect/sqlite.rs | 16 ++++++-------- crates/sqlx_gen/src/main.rs | 14 ++++++------ crates/sqlx_gen/tests/compile_check.rs | 26 ++++++++++++----------- 4 files changed, 32 insertions(+), 31 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index 4a44354..d21d1d9 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -219,8 +219,11 @@ mod tests { fn test_newtype_emits_tuple_struct() { let d = make_domain("email", "text"); let (code, _) = gen_newtype(&d); - assert!(code.contains("pub struct Email(pub String)"), - "newtype must wrap the base type in a tuple struct, got:\n{}", code); + assert!( + code.contains("pub struct Email(pub String)"), + "newtype must wrap the base type in a tuple struct, got:\n{}", + code + ); } #[test] diff --git a/crates/sqlx_gen/src/introspect/sqlite.rs b/crates/sqlx_gen/src/introspect/sqlite.rs index 004238b..efbd0d4 100644 --- a/crates/sqlx_gen/src/introspect/sqlite.rs +++ b/crates/sqlx_gen/src/introspect/sqlite.rs @@ -34,19 +34,15 @@ pub async fn introspect(pool: &SqlitePool, include_views: bool) -> Result__enum`) so the rest of the pipeline /// treats it like a real enum (with PgHasArrayType skipped for SQLite). -async fn extract_check_enums( - pool: &SqlitePool, - tables: &mut [TableInfo], -) -> Result> { +async fn extract_check_enums(pool: &SqlitePool, tables: &mut [TableInfo]) -> Result> { let mut enums = Vec::new(); for table in tables.iter_mut() { - let sql: Option<(Option,)> = sqlx::query_as( - "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", - ) - .bind(&table.name) - .fetch_optional(pool) - .await?; + let sql: Option<(Option,)> = + sqlx::query_as("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?") + .bind(&table.name) + .fetch_optional(pool) + .await?; let Some((Some(ddl),)) = sql else { continue }; for col in table.columns.iter_mut() { diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index dad63ac..ef68ce6 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -44,9 +44,7 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { // Database variant, re-classify by SQLSTATE so the user sees an // actionable message instead of the raw "error returned from database". let map_introspect_error = |e: sqlx_gen::error::Error| match e { - sqlx_gen::error::Error::Database(inner) => { - sqlx_gen::error::contextualize_sqlx_error(inner) - } + sqlx_gen::error::Error::Database(inner) => sqlx_gen::error::contextualize_sqlx_error(inner), other => other, }; @@ -55,8 +53,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let pool = PgPool::connect(&args.db.database_url) .await .map_err(conn_err)?; - let info = - introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await + let info = introspect::postgres::introspect(&pool, &args.db.schemas, args.views) + .await .map_err(map_introspect_error)?; pool.close().await; info @@ -65,7 +63,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let pool = MySqlPool::connect(&args.db.database_url) .await .map_err(conn_err)?; - let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views).await + let info = introspect::mysql::introspect(&pool, &args.db.schemas, args.views) + .await .map_err(map_introspect_error)?; pool.close().await; info @@ -74,7 +73,8 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { let pool = SqlitePool::connect(&args.db.database_url) .await .map_err(conn_err)?; - let info = introspect::sqlite::introspect(&pool, args.views).await + let info = introspect::sqlite::introspect(&pool, args.views) + .await .map_err(map_introspect_error)?; pool.close().await; info diff --git a/crates/sqlx_gen/tests/compile_check.rs b/crates/sqlx_gen/tests/compile_check.rs index f0e0eef..86fc45c 100644 --- a/crates/sqlx_gen/tests/compile_check.rs +++ b/crates/sqlx_gen/tests/compile_check.rs @@ -62,13 +62,7 @@ fn rich_schema() -> SchemaInfo { } } -fn column( - name: &str, - udt: &str, - nullable: bool, - pk: bool, - default: Option<&str>, -) -> ColumnInfo { +fn column(name: &str, udt: &str, nullable: bool, pk: bool, default: Option<&str>) -> ColumnInfo { ColumnInfo { name: name.to_string(), data_type: udt.to_string(), @@ -111,10 +105,15 @@ fn generated_postgres_files_parse() { fn generated_mysql_files_parse() { let schema = SchemaInfo { // MySQL has no enums/composites/domains in our model, just tables. - tables: rich_schema().tables.into_iter().map(|mut t| { - t.columns.retain(|c| c.udt_name != "status" && c.udt_name != "jsonb"); - t - }).collect(), + tables: rich_schema() + .tables + .into_iter() + .map(|mut t| { + t.columns + .retain(|c| c.udt_name != "status" && c.udt_name != "jsonb"); + t + }) + .collect(), views: vec![], enums: vec![], composite_types: vec![], @@ -278,7 +277,10 @@ fn workspace_root() -> std::path::PathBuf { .unwrap_or(false) { if !p.pop() { - panic!("could not locate workspace root from {}", env!("CARGO_MANIFEST_DIR")); + panic!( + "could not locate workspace root from {}", + env!("CARGO_MANIFEST_DIR") + ); } } p From a819c59627baab8eeeb5aff2c4da8ebdedf89ce9 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:20:21 +0200 Subject: [PATCH 32/38] feat: add udt_schema field to ColumnInfo Postgres' information_schema.columns reports the schema in which a column's user-defined type lives (e.g. "auth" for an auth.role enum column, "pg_catalog" for builtins). Capture it on every column so the typemap and codegen layers can disambiguate two schemas declaring a type with the same name. - Adds udt_schema: Option to ColumnInfo - Postgres fetch_tables / fetch_views select COALESCE(udt_schema, '') and unpack to None when empty - MySQL, SQLite, and synthetic test fixtures keep it None - ColumnInfo derives Default so future test code can use struct update syntax --- crates/sqlx_gen/src/codegen/composite_gen.rs | 1 + crates/sqlx_gen/src/codegen/domain_gen.rs | 1 + crates/sqlx_gen/src/codegen/mod.rs | 6 +++ crates/sqlx_gen/src/codegen/struct_gen.rs | 3 ++ crates/sqlx_gen/src/introspect/mod.rs | 9 +++- crates/sqlx_gen/src/introspect/mysql.rs | 6 +++ crates/sqlx_gen/src/introspect/postgres.rs | 43 ++++++++++++++++++-- crates/sqlx_gen/src/introspect/sqlite.rs | 4 ++ crates/sqlx_gen/src/typemap/mod.rs | 1 + crates/sqlx_gen/tests/compile_check.rs | 1 + 10 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 00065a7..8ac02f7 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -143,6 +143,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, } } diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index d21d1d9..9fcb679 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -46,6 +46,7 @@ pub fn generate_domain_with_style( name: String::new(), data_type: domain.base_type.clone(), udt_name: domain.base_type.clone(), + udt_schema: None, is_nullable: false, is_primary_key: false, ordinal_position: 0, diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index b148c32..26f22bf 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -864,6 +864,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, } } @@ -1321,6 +1322,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, }], )], @@ -1483,6 +1485,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: Some("'idle'::task_status".to_string()), }], }], @@ -1512,6 +1515,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, }], }], @@ -1541,6 +1545,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: Some("'hello'::character varying".to_string()), }], }], @@ -1565,6 +1570,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: Some("'idle'::task_status".to_string()), }], }], diff --git a/crates/sqlx_gen/src/codegen/struct_gen.rs b/crates/sqlx_gen/src/codegen/struct_gen.rs index 570a352..2d6c8e8 100644 --- a/crates/sqlx_gen/src/codegen/struct_gen.rs +++ b/crates/sqlx_gen/src/codegen/struct_gen.rs @@ -248,6 +248,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, } } @@ -523,6 +524,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "test_db".to_string(), + udt_schema: None, column_default: None, }], ); @@ -544,6 +546,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "test_db".to_string(), + udt_schema: None, column_default: None, }], ); diff --git a/crates/sqlx_gen/src/introspect/mod.rs b/crates/sqlx_gen/src/introspect/mod.rs index ff55bff..b182adc 100644 --- a/crates/sqlx_gen/src/introspect/mod.rs +++ b/crates/sqlx_gen/src/introspect/mod.rs @@ -2,7 +2,7 @@ pub mod mysql; pub mod postgres; pub mod sqlite; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] #[allow(unused)] pub struct ColumnInfo { pub name: String, @@ -10,6 +10,13 @@ pub struct ColumnInfo { pub data_type: String, /// Underlying type name: udt_name (PG), column_type (MySQL), declared type (SQLite) pub udt_name: String, + /// Schema in which `udt_name` is defined. + /// + /// Populated by the Postgres backend (e.g. `auth` for an `auth.role` enum + /// column, `pg_catalog` for builtins). `None` for MySQL/SQLite which have + /// no per-type namespacing. Used to disambiguate enums/composites/domains + /// when two schemas declare a type with the same name. + pub udt_schema: Option, pub is_nullable: bool, pub is_primary_key: bool, pub ordinal_position: i32, diff --git a/crates/sqlx_gen/src/introspect/mysql.rs b/crates/sqlx_gen/src/introspect/mysql.rs index f4efcac..161754d 100644 --- a/crates/sqlx_gen/src/introspect/mysql.rs +++ b/crates/sqlx_gen/src/introspect/mysql.rs @@ -107,6 +107,7 @@ async fn fetch_tables(pool: &MySqlPool, schemas: &[String]) -> Result Result Result, @@ -60,6 +61,7 @@ async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result Result = Vec::new(); let mut current_key: Option<(String, String)> = None; - for (schema, table, col_name, data_type, udt_name, nullable, ordinal, is_pk, column_default) in - rows + for ( + schema, + table, + col_name, + data_type, + udt_name, + udt_schema, + nullable, + ordinal, + is_pk, + column_default, + ) in rows { let key = (schema.clone(), table.clone()); if current_key.as_ref() != Some(&key) { @@ -109,6 +121,11 @@ async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result Result String, String, String, + String, i32, Option, ), @@ -141,6 +159,7 @@ async fn fetch_views(pool: &PgPool, schemas: &[String]) -> Result c.column_name, c.data_type, COALESCE(c.udt_name, c.data_type) as udt_name, + COALESCE(c.udt_schema, '') as udt_schema, c.is_nullable, c.ordinal_position, c.column_default @@ -160,7 +179,18 @@ async fn fetch_views(pool: &PgPool, schemas: &[String]) -> Result let mut views: Vec = Vec::new(); let mut current_key: Option<(String, String)> = None; - for (schema, table, col_name, data_type, udt_name, nullable, ordinal, column_default) in rows { + for ( + schema, + table, + col_name, + data_type, + udt_name, + udt_schema, + nullable, + ordinal, + column_default, + ) in rows + { let key = (schema.clone(), table.clone()); if current_key.as_ref() != Some(&key) { current_key = Some(key); @@ -179,6 +209,11 @@ async fn fetch_views(pool: &PgPool, schemas: &[String]) -> Result name: col_name, data_type, udt_name, + udt_schema: if udt_schema.is_empty() { + None + } else { + Some(udt_schema) + }, is_nullable: nullable == "YES", is_primary_key: false, ordinal_position: ordinal, @@ -445,6 +480,7 @@ async fn fetch_composite_types( name: field_name, data_type: field_type.clone(), udt_name: field_type, + udt_schema: None, is_nullable: nullable == "YES", is_primary_key: false, ordinal_position: ordinal, @@ -504,6 +540,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: schema.to_string(), + udt_schema: None, column_default: None, }) .collect(), diff --git a/crates/sqlx_gen/src/introspect/sqlite.rs b/crates/sqlx_gen/src/introspect/sqlite.rs index efbd0d4..813030d 100644 --- a/crates/sqlx_gen/src/introspect/sqlite.rs +++ b/crates/sqlx_gen/src/introspect/sqlite.rs @@ -191,6 +191,7 @@ async fn fetch_columns(pool: &SqlitePool, table_name: &str) -> Result 0, ordinal_position: cid, @@ -274,6 +275,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: "main".to_string(), + udt_schema: None, column_default: None, }) .collect(), @@ -295,6 +297,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: "main".to_string(), + udt_schema: None, column_default: None, }) .collect(), @@ -363,6 +366,7 @@ mod tests { is_primary_key: is_pk, ordinal_position: i as i32, schema_name: "main".to_string(), + udt_schema: None, column_default: None, }) .collect(), diff --git a/crates/sqlx_gen/src/typemap/mod.rs b/crates/sqlx_gen/src/typemap/mod.rs index 81eb87c..a24f6d3 100644 --- a/crates/sqlx_gen/src/typemap/mod.rs +++ b/crates/sqlx_gen/src/typemap/mod.rs @@ -89,6 +89,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), + udt_schema: None, column_default: None, } } diff --git a/crates/sqlx_gen/tests/compile_check.rs b/crates/sqlx_gen/tests/compile_check.rs index 86fc45c..7f86c4a 100644 --- a/crates/sqlx_gen/tests/compile_check.rs +++ b/crates/sqlx_gen/tests/compile_check.rs @@ -67,6 +67,7 @@ fn column(name: &str, udt: &str, nullable: bool, pk: bool, default: Option<&str> name: name.to_string(), data_type: udt.to_string(), udt_name: udt.to_string(), + udt_schema: None, is_nullable: nullable, is_primary_key: pk, ordinal_position: 0, From 714e16347a7db02cffba1b80476bc71292f5f8aa Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:23:58 +0200 Subject: [PATCH 33/38] feat: disambiguate enums/composites/domains across schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the same SQL name (e.g. "role") exists in two non-default schemas, sqlx-gen now prefixes the Rust identifier with the schema's PascalCase form: auth.role → AuthRole, billing.role → BillingRole. The bare PascalCase ("Role") is reserved for unique names and for the default schema even when a collision exists. Plumbing: - codegen::rust_type_name_for + type_name_has_cross_schema_collision as the single source of truth, callable from typemap and from each *_gen module. - typemap::postgres exposes map_type_qualified that takes the column's udt_schema (added in the previous commit) so cross-schema duplicate lookups land on the right (schema, name) pair. - enum_gen::generate_enum_with_schema wraps the legacy entry point and propagates the SchemaInfo so the emitted Rust enum carries the prefixed name. composite_gen and domain_gen call rust_type_name_for directly since they already receive SchemaInfo. - codegen::generate now calls generate_enum_with_schema. The SQL #[sqlx(type_name = "...")] attribute is still emitted in its unqualified form because sqlx 0.8 doesn't accept "schema.type"; users remain responsible for setting search_path on the connection. --- crates/sqlx_gen/src/codegen/composite_gen.rs | 7 +- crates/sqlx_gen/src/codegen/domain_gen.rs | 5 +- crates/sqlx_gen/src/codegen/enum_gen.rs | 18 ++- crates/sqlx_gen/src/codegen/mod.rs | 117 ++++++++++++++++++- crates/sqlx_gen/src/typemap/mod.rs | 7 +- crates/sqlx_gen/src/typemap/postgres.rs | 60 +++++++--- 6 files changed, 186 insertions(+), 28 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 8ac02f7..43ab45d 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -1,11 +1,11 @@ use std::collections::{BTreeSet, HashMap}; -use heck::{ToSnakeCase, ToUpperCamelCase}; +use heck::ToSnakeCase; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use crate::cli::{DatabaseKind, TimeCrate}; -use crate::codegen::{imports_for_derives, is_rust_keyword}; +use crate::codegen::{imports_for_derives, is_rust_keyword, rust_type_name_for}; use crate::introspect::{CompositeTypeInfo, SchemaInfo}; use crate::typemap; @@ -21,7 +21,8 @@ pub fn generate_composite( for imp in imports_for_derives(extra_derives) { imports.insert(imp); } - let struct_name = format_ident!("{}", composite.name.to_upper_camel_case()); + let rust_name = rust_type_name_for(schema_info, &composite.schema_name, &composite.name); + let struct_name = format_ident!("{}", rust_name); let doc = format!( "Composite type: {}.{}", diff --git a/crates/sqlx_gen/src/codegen/domain_gen.rs b/crates/sqlx_gen/src/codegen/domain_gen.rs index 9fcb679..bd76e36 100644 --- a/crates/sqlx_gen/src/codegen/domain_gen.rs +++ b/crates/sqlx_gen/src/codegen/domain_gen.rs @@ -1,10 +1,10 @@ use std::collections::{BTreeSet, HashMap}; -use heck::ToUpperCamelCase; use proc_macro2::TokenStream; use quote::{format_ident, quote}; use crate::cli::{DatabaseKind, DomainStyle, TimeCrate}; +use crate::codegen::rust_type_name_for; use crate::introspect::{DomainInfo, SchemaInfo}; use crate::typemap; @@ -34,7 +34,8 @@ pub fn generate_domain_with_style( style: DomainStyle, ) -> (TokenStream, BTreeSet) { let mut imports = BTreeSet::new(); - let alias_name = format_ident!("{}", domain.name.to_upper_camel_case()); + let rust_name = rust_type_name_for(schema_info, &domain.schema_name, &domain.name); + let alias_name = format_ident!("{}", rust_name); let doc = format!( "Domain: {}.{} (base: {})", diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 56237a1..62dbc0e 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -5,8 +5,8 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use crate::cli::DatabaseKind; -use crate::codegen::imports_for_derives; -use crate::introspect::EnumInfo; +use crate::codegen::{imports_for_derives, rust_type_name_for}; +use crate::introspect::{EnumInfo, SchemaInfo}; /// Detect two SQL enum variants that collapse to the same Rust identifier after /// `to_upper_camel_case` (e.g. `"foo bar"` and `"foo_bar"` both become `FooBar`). @@ -33,13 +33,25 @@ pub fn generate_enum( enum_info: &EnumInfo, db_kind: DatabaseKind, extra_derives: &[String], +) -> (TokenStream, BTreeSet) { + // Backwards-compatible entry point — uses an empty SchemaInfo so the + // enum keeps its bare PascalCase name (no schema prefix). + generate_enum_with_schema(enum_info, db_kind, extra_derives, &SchemaInfo::default()) +} + +pub fn generate_enum_with_schema( + enum_info: &EnumInfo, + db_kind: DatabaseKind, + extra_derives: &[String], + schema_info: &SchemaInfo, ) -> (TokenStream, BTreeSet) { let mut imports = BTreeSet::new(); for imp in imports_for_derives(extra_derives) { imports.insert(imp); } - let enum_name = format_ident!("{}", enum_info.name.to_upper_camel_case()); + let rust_name = rust_type_name_for(schema_info, &enum_info.schema_name, &enum_info.name); + let enum_name = format_ident!("{}", rust_name); let doc = format!("Enum: {}.{}", enum_info.schema_name, enum_info.name); imports.insert("use serde::{Serialize, Deserialize};".to_string()); diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index 26f22bf..09c010b 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -72,6 +72,53 @@ pub fn is_default_schema(schema: &str) -> bool { DEFAULT_SCHEMAS.contains(&schema) } +/// Compute the Rust identifier for an enum / composite / domain. +/// +/// When the same SQL name is declared in more than one schema (e.g. both +/// `auth.role` and `billing.role` exist), the non-default-schema variants get +/// a `SchemaName` PascalCase prefix to avoid Rust-level identifier collisions. +/// Otherwise the bare PascalCase of the SQL name is used. +pub fn rust_type_name_for(schema_info: &SchemaInfo, schema: &str, name: &str) -> String { + use heck::ToUpperCamelCase; + if type_name_has_cross_schema_collision(schema_info, name) && !is_default_schema(schema) { + format!( + "{}{}", + schema.to_upper_camel_case(), + name.to_upper_camel_case() + ) + } else { + name.to_upper_camel_case() + } +} + +/// True when the SQL `name` is declared by enums / composites / domains living +/// in more than one schema. +pub fn type_name_has_cross_schema_collision(schema_info: &SchemaInfo, name: &str) -> bool { + let mut schemas: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + schemas.extend( + schema_info + .enums + .iter() + .filter(|e| e.name == name) + .map(|e| e.schema_name.as_str()), + ); + schemas.extend( + schema_info + .composite_types + .iter() + .filter(|c| c.name == name) + .map(|c| c.schema_name.as_str()), + ); + schemas.extend( + schema_info + .domains + .iter() + .filter(|d| d.name == name) + .map(|d| d.schema_name.as_str()), + ); + schemas.len() > 1 +} + /// Build a module name, prefixing with schema only when the name collides /// (same table name exists in multiple schemas). pub fn build_module_name(schema_name: &str, table_name: &str, name_collides: bool) -> String { @@ -211,7 +258,8 @@ pub fn generate_with_domain_style( enriched.default_variant = Some(default.clone()); } } - let (tokens, imports) = enum_gen::generate_enum(&enriched, db_kind, extra_derives); + let (tokens, imports) = + enum_gen::generate_enum_with_schema(&enriched, db_kind, extra_derives, schema_info); types_blocks.push(format_tokens(&tokens)?); types_imports.extend(imports); } @@ -809,6 +857,73 @@ mod tests { assert_eq!(result, input); } + // ========== rust_type_name_for / cross-schema collisions ========== + + fn schema_with_two_role_enums() -> SchemaInfo { + SchemaInfo { + enums: vec![ + crate::introspect::EnumInfo { + schema_name: "auth".into(), + name: "role".into(), + variants: vec!["admin".into(), "user".into()], + default_variant: None, + }, + crate::introspect::EnumInfo { + schema_name: "billing".into(), + name: "role".into(), + variants: vec!["payer".into(), "payee".into()], + default_variant: None, + }, + ], + ..Default::default() + } + } + + #[test] + fn rust_type_name_prefixes_schema_on_cross_schema_collision() { + let s = schema_with_two_role_enums(); + assert_eq!(rust_type_name_for(&s, "auth", "role"), "AuthRole"); + assert_eq!(rust_type_name_for(&s, "billing", "role"), "BillingRole"); + } + + #[test] + fn rust_type_name_keeps_bare_name_when_unique() { + let s = SchemaInfo { + enums: vec![crate::introspect::EnumInfo { + schema_name: "auth".into(), + name: "role".into(), + variants: vec!["admin".into()], + default_variant: None, + }], + ..Default::default() + }; + assert_eq!(rust_type_name_for(&s, "auth", "role"), "Role"); + } + + #[test] + fn rust_type_name_default_schema_keeps_bare_name_even_on_collision() { + let s = SchemaInfo { + enums: vec![ + crate::introspect::EnumInfo { + schema_name: "public".into(), + name: "role".into(), + variants: vec!["a".into()], + default_variant: None, + }, + crate::introspect::EnumInfo { + schema_name: "auth".into(), + name: "role".into(), + variants: vec!["b".into()], + default_variant: None, + }, + ], + ..Default::default() + }; + // public stays "Role"; auth gets the schema prefix to break the tie. + assert_eq!(rust_type_name_for(&s, "public", "role"), "Role"); + assert_eq!(rust_type_name_for(&s, "auth", "role"), "AuthRole"); + } + // ========== filter_imports ========== #[test] diff --git a/crates/sqlx_gen/src/typemap/mod.rs b/crates/sqlx_gen/src/typemap/mod.rs index a24f6d3..b693cc8 100644 --- a/crates/sqlx_gen/src/typemap/mod.rs +++ b/crates/sqlx_gen/src/typemap/mod.rs @@ -62,7 +62,12 @@ pub fn map_column( } let base = match db_kind { - DatabaseKind::Postgres => postgres::map_type(&col.udt_name, schema_info, time_crate), + DatabaseKind::Postgres => postgres::map_type_qualified( + &col.udt_name, + col.udt_schema.as_deref(), + schema_info, + time_crate, + ), DatabaseKind::Mysql => mysql::map_type(&col.data_type, &col.udt_name, time_crate), DatabaseKind::Sqlite => sqlite::map_type(&col.udt_name, time_crate), }; diff --git a/crates/sqlx_gen/src/typemap/postgres.rs b/crates/sqlx_gen/src/typemap/postgres.rs index ec43b0f..291bcfa 100644 --- a/crates/sqlx_gen/src/typemap/postgres.rs +++ b/crates/sqlx_gen/src/typemap/postgres.rs @@ -1,5 +1,3 @@ -use heck::ToUpperCamelCase; - use super::RustType; use crate::cli::TimeCrate; use crate::introspect::SchemaInfo; @@ -53,38 +51,64 @@ pub fn is_builtin(udt_name: &str) -> bool { } pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) -> RustType { + map_type_qualified(udt_name, None, schema_info, time_crate) +} + +// Shared with the rest of codegen — see codegen::rust_type_name_for. +use crate::codegen::rust_type_name_for as rust_type_name_inner; + +fn rust_type_name(schema: &str, name: &str, schema_info: &SchemaInfo) -> String { + rust_type_name_inner(schema_info, schema, name) +} + +/// Map a PG type name to a Rust type, respecting `udt_schema` when present so +/// that two schemas declaring the same name (e.g. `auth.role` vs +/// `billing.role`) resolve to distinct Rust idents. +pub fn map_type_qualified( + udt_name: &str, + udt_schema: Option<&str>, + schema_info: &SchemaInfo, + time_crate: TimeCrate, +) -> RustType { // Handle array types: PG's information_schema may report them either as // `_int4` (information_schema.columns.udt_name) or `integer[]` // (pg_catalog.format_type). Both should produce Vec. if let Some(inner) = udt_name.strip_prefix('_') { - let inner_type = map_type(inner, schema_info, time_crate); + let inner_type = map_type_qualified(inner, udt_schema, schema_info, time_crate); return inner_type.wrap_vec(); } if let Some(inner) = udt_name.strip_suffix("[]") { - let inner_type = map_type(inner.trim(), schema_info, time_crate); + let inner_type = map_type_qualified(inner.trim(), udt_schema, schema_info, time_crate); return inner_type.wrap_vec(); } - // Check if it's a known enum - if schema_info.enums.iter().any(|e| e.name == udt_name) { - let name = udt_name.to_upper_camel_case(); + // Schema-aware enum lookup. When udt_schema is provided we restrict to + // exact (schema, name) matches first; otherwise we fall back to the + // first name match so that legacy callers (and synthetic test fixtures) + // keep working. + let enum_match = schema_info.enums.iter().find(|e| { + e.name == udt_name && udt_schema.map(|s| s == e.schema_name).unwrap_or(true) + }); + if let Some(e) = enum_match { + let name = rust_type_name(&e.schema_name, &e.name, schema_info); return RustType::with_import(&name, &format!("use super::types::{};", name)); } - // Check if it's a known composite type - if schema_info - .composite_types - .iter() - .any(|c| c.name == udt_name) - { - let name = udt_name.to_upper_camel_case(); + let composite_match = schema_info.composite_types.iter().find(|c| { + c.name == udt_name && udt_schema.map(|s| s == c.schema_name).unwrap_or(true) + }); + if let Some(c) = composite_match { + let name = rust_type_name(&c.schema_name, &c.name, schema_info); return RustType::with_import(&name, &format!("use super::types::{};", name)); } - // Check if it's a known domain - if let Some(domain) = schema_info.domains.iter().find(|d| d.name == udt_name) { - // Map to the domain's base type - return map_type(&domain.base_type, schema_info, time_crate); + let domain_match = schema_info.domains.iter().find(|d| { + d.name == udt_name && udt_schema.map(|s| s == d.schema_name).unwrap_or(true) + }); + if let Some(domain) = domain_match { + // Map to the domain's base type — base type lives in pg_catalog so + // schema is irrelevant for the recursive lookup. + return map_type_qualified(&domain.base_type, None, schema_info, time_crate); } match udt_name { From 8eb180769d785251b976619e98511bace966056e Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:26:03 +0200 Subject: [PATCH 34/38] feat: surface search_path requirement for non-default-schema types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an enum or composite lives in a schema other than public, sqlx 0.8 cannot resolve its unqualified type_name unless the connection's search_path includes that schema. To make this discoverable: - Emit a /// doc-comment on every non-default-schema enum and composite spelling out the requirement with a copy-paste-ready SET search_path snippet - Add codegen::required_pg_search_path(&schema_info), which returns the sorted, deduplicated list of schemas needed - Make the CLI log the exact SET search_path line after introspection when the result references any non-default schemas - Document the whole flow (after_connect hook + collision prefixing) in a new "PostgreSQL — multi-schema setup" section in README.md --- README.md | 34 ++++++++ crates/sqlx_gen/src/codegen/composite_gen.rs | 20 ++++- crates/sqlx_gen/src/codegen/enum_gen.rs | 27 ++++++ crates/sqlx_gen/src/codegen/mod.rs | 91 ++++++++++++++++++-- crates/sqlx_gen/src/codegen/struct_gen.rs | 2 +- crates/sqlx_gen/src/introspect/mysql.rs | 8 +- crates/sqlx_gen/src/introspect/postgres.rs | 2 +- crates/sqlx_gen/src/introspect/sqlite.rs | 6 +- crates/sqlx_gen/src/main.rs | 12 +++ crates/sqlx_gen/src/typemap/mod.rs | 2 +- crates/sqlx_gen/src/typemap/postgres.rs | 21 +++-- 11 files changed, 199 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 021a511..9b4a5e3 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,40 @@ All generated types include `#[sqlx_gen(...)]` annotations for tooling: | Domain type | `#[sqlx_gen(kind = "domain")]` | | Primary key field | `#[sqlx_gen(primary_key)]` | +## PostgreSQL — multi-schema setup + +When you introspect more than one schema (`-s auth,billing,public`), enums, +composite types, and domains carry an unqualified +`#[sqlx(type_name = "...")]` because `sqlx::postgres::PgTypeInfo::with_name` +does not accept `schema.type`. For PG to resolve those types at runtime, the +connection must include every non-default schema in its `search_path`. + +`sqlx-gen` prints the exact `SET search_path` snippet it needs after +introspection. Apply it on every new connection via an `after_connect` +hook: + +```rust +use sqlx::postgres::PgPoolOptions; + +let pool = PgPoolOptions::new() + .after_connect(|conn, _meta| Box::pin(async move { + sqlx::query("SET search_path TO public, auth, billing") + .execute(conn).await?; + Ok(()) + })) + .connect(&url).await?; +``` + +If two schemas declare a type with the same name (e.g. both `auth.role` and +`billing.role`), sqlx-gen prefixes the Rust identifier with the schema +PascalCase form (`AuthRole`, `BillingRole`) to keep the generated code +unambiguous. The bare PascalCase form (`Role`) is reserved for the default +schema and for unique names. + +The `sqlx_gen::codegen::required_pg_search_path(&schema_info)` helper returns +the list of non-default schemas you need to include — handy when wiring this +into a build script. + ## License MIT diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 43ab45d..19e8db0 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -23,6 +23,19 @@ pub fn generate_composite( } let rust_name = rust_type_name_for(schema_info, &composite.schema_name, &composite.name); let struct_name = format_ident!("{}", rust_name); + let search_path_doc = if db_kind == DatabaseKind::Postgres + && !crate::codegen::is_default_schema(&composite.schema_name) + { + Some(format!( + "Lives in PostgreSQL schema `{schema}`. The sqlx connection must \ + include `{schema}` in its search_path so PG resolves the \ + unqualified `type_name = \"{name}\"` to this composite.", + schema = composite.schema_name, + name = composite.name, + )) + } else { + None + }; let doc = format!( "Composite type: {}.{}", @@ -106,8 +119,13 @@ pub fn generate_composite( quote! {} }; + let search_path_doc_tokens = match &search_path_doc { + Some(m) => quote! { #[doc = #m] }, + None => quote! {}, + }; let tokens = quote! { #[doc = #doc] + #search_path_doc_tokens #[derive(#(#derive_tokens),*)] #[sqlx_gen(kind = "composite")] #type_attr @@ -144,7 +162,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, } } diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index 62dbc0e..d1e9136 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -53,6 +53,28 @@ pub fn generate_enum_with_schema( let rust_name = rust_type_name_for(schema_info, &enum_info.schema_name, &enum_info.name); let enum_name = format_ident!("{}", rust_name); let doc = format!("Enum: {}.{}", enum_info.schema_name, enum_info.name); + // For non-default schemas, remind the user that sqlx 0.8 can only resolve + // unqualified type_name attributes — the connection must have the schema + // in its search_path. Emitted as a /// doc-comment so it shows up both in + // generated source and in rustdoc. + let search_path_doc = if db_kind == DatabaseKind::Postgres + && !crate::codegen::is_default_schema(&enum_info.schema_name) + { + let msg = format!( + "Lives in PostgreSQL schema `{schema}`. The sqlx connection \ + must include `{schema}` in its search_path so PG resolves the \ + unqualified `type_name = \"{name}\"` to this enum. Example:\n\ + \n\ + ```ignore\n\ + sqlx::query(\"SET search_path TO public, {schema}\")\n\ + ```", + schema = enum_info.schema_name, + name = enum_info.name, + ); + Some(msg) + } else { + None + }; imports.insert("use serde::{Serialize, Deserialize};".to_string()); imports.insert("use sqlx_gen::SqlxGen;".to_string()); @@ -135,9 +157,14 @@ pub fn generate_enum_with_schema( let schema_name_str = &enum_info.schema_name; let enum_name_str = &enum_info.name; + let search_path_doc_tokens = match &search_path_doc { + Some(m) => quote! { #[doc = #m] }, + None => quote! {}, + }; let tokens = quote! { #[doc = #doc] + #search_path_doc_tokens #[derive(#(#derive_tokens),*)] #[sqlx_gen(kind = "enum", schema = #schema_name_str, name = #enum_name_str)] #type_attr diff --git a/crates/sqlx_gen/src/codegen/mod.rs b/crates/sqlx_gen/src/codegen/mod.rs index 09c010b..8dcac73 100644 --- a/crates/sqlx_gen/src/codegen/mod.rs +++ b/crates/sqlx_gen/src/codegen/mod.rs @@ -91,6 +91,38 @@ pub fn rust_type_name_for(schema_info: &SchemaInfo, schema: &str, name: &str) -> } } +/// Compute the schemas that must appear in PostgreSQL's `search_path` for +/// the generated code to resolve every emitted unqualified `type_name`. +/// +/// Returns the deduplicated, sorted list of non-default schemas hosting +/// enums/composites/domains in `schema_info`. The caller can feed this into +/// the pool's connect-hook, e.g.: +/// +/// ```ignore +/// let schemas = sqlx_gen::codegen::required_pg_search_path(&info).join(", "); +/// sqlx::query(&format!("SET search_path TO public, {}", schemas)) +/// .execute(&pool).await?; +/// ``` +pub fn required_pg_search_path(schema_info: &SchemaInfo) -> Vec { + let mut schemas: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for e in &schema_info.enums { + if !is_default_schema(&e.schema_name) { + schemas.insert(e.schema_name.clone()); + } + } + for c in &schema_info.composite_types { + if !is_default_schema(&c.schema_name) { + schemas.insert(c.schema_name.clone()); + } + } + for d in &schema_info.domains { + if !is_default_schema(&d.schema_name) { + schemas.insert(d.schema_name.clone()); + } + } + schemas.into_iter().collect() +} + /// True when the SQL `name` is declared by enums / composites / domains living /// in more than one schema. pub fn type_name_has_cross_schema_collision(schema_info: &SchemaInfo, name: &str) -> bool { @@ -900,6 +932,53 @@ mod tests { assert_eq!(rust_type_name_for(&s, "auth", "role"), "Role"); } + #[test] + fn required_search_path_collects_non_default_schemas() { + let s = SchemaInfo { + enums: vec![ + crate::introspect::EnumInfo { + schema_name: "auth".into(), + name: "role".into(), + variants: vec!["x".into()], + default_variant: None, + }, + crate::introspect::EnumInfo { + schema_name: "public".into(), + name: "status".into(), + variants: vec!["y".into()], + default_variant: None, + }, + ], + composite_types: vec![crate::introspect::CompositeTypeInfo { + schema_name: "billing".into(), + name: "addr".into(), + fields: vec![], + }], + domains: vec![crate::introspect::DomainInfo { + schema_name: "auth".into(), + name: "email".into(), + base_type: "text".into(), + }], + ..Default::default() + }; + // Sorted, deduplicated, public excluded. + assert_eq!(required_pg_search_path(&s), vec!["auth", "billing"]); + } + + #[test] + fn required_search_path_empty_when_only_default_schema() { + let s = SchemaInfo { + enums: vec![crate::introspect::EnumInfo { + schema_name: "public".into(), + name: "status".into(), + variants: vec!["y".into()], + default_variant: None, + }], + ..Default::default() + }; + assert!(required_pg_search_path(&s).is_empty()); + } + #[test] fn rust_type_name_default_schema_keeps_bare_name_even_on_collision() { let s = SchemaInfo { @@ -979,7 +1058,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, } } @@ -1437,7 +1516,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }], )], @@ -1600,7 +1679,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: Some("'idle'::task_status".to_string()), }], }], @@ -1630,7 +1709,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }], }], @@ -1660,7 +1739,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: Some("'hello'::character varying".to_string()), }], }], @@ -1685,7 +1764,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: Some("'idle'::task_status".to_string()), }], }], diff --git a/crates/sqlx_gen/src/codegen/struct_gen.rs b/crates/sqlx_gen/src/codegen/struct_gen.rs index 2d6c8e8..9d98882 100644 --- a/crates/sqlx_gen/src/codegen/struct_gen.rs +++ b/crates/sqlx_gen/src/codegen/struct_gen.rs @@ -248,7 +248,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, } } diff --git a/crates/sqlx_gen/src/introspect/mysql.rs b/crates/sqlx_gen/src/introspect/mysql.rs index 161754d..ca37638 100644 --- a/crates/sqlx_gen/src/introspect/mysql.rs +++ b/crates/sqlx_gen/src/introspect/mysql.rs @@ -404,7 +404,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "test_db".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, } } @@ -550,7 +550,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: schema.to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), @@ -576,7 +576,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: schema.to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), @@ -671,7 +671,7 @@ mod tests { is_primary_key: is_pk, ordinal_position: i as i32, schema_name: schema.to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), diff --git a/crates/sqlx_gen/src/introspect/postgres.rs b/crates/sqlx_gen/src/introspect/postgres.rs index 3f0efd8..de4e9b9 100644 --- a/crates/sqlx_gen/src/introspect/postgres.rs +++ b/crates/sqlx_gen/src/introspect/postgres.rs @@ -540,7 +540,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: schema.to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), diff --git a/crates/sqlx_gen/src/introspect/sqlite.rs b/crates/sqlx_gen/src/introspect/sqlite.rs index 813030d..ea31058 100644 --- a/crates/sqlx_gen/src/introspect/sqlite.rs +++ b/crates/sqlx_gen/src/introspect/sqlite.rs @@ -275,7 +275,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: "main".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), @@ -297,7 +297,7 @@ mod tests { is_primary_key: false, ordinal_position: i as i32, schema_name: "main".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), @@ -366,7 +366,7 @@ mod tests { is_primary_key: is_pk, ordinal_position: i as i32, schema_name: "main".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, }) .collect(), diff --git a/crates/sqlx_gen/src/main.rs b/crates/sqlx_gen/src/main.rs index ef68ce6..1efa426 100644 --- a/crates/sqlx_gen/src/main.rs +++ b/crates/sqlx_gen/src/main.rs @@ -103,6 +103,18 @@ async fn run_entities(args: EntitiesArgs) -> Result<()> { schema_info.composite_types.len(), schema_info.domains.len(), ); + if db_kind == DatabaseKind::Postgres { + let needed = sqlx_gen::codegen::required_pg_search_path(&schema_info); + if !needed.is_empty() { + info!( + "Generated types reference non-default schemas: {}. \ + Configure the sqlx pool to include them in search_path, e.g. \ + `SET search_path TO public, {}`", + needed.join(", "), + needed.join(", ") + ); + } + } if table_count == 0 && view_count == 0 && enum_count == 0 { warn!( "No tables, views, or enums found in schemas {:?}. \ diff --git a/crates/sqlx_gen/src/typemap/mod.rs b/crates/sqlx_gen/src/typemap/mod.rs index b693cc8..55fda8d 100644 --- a/crates/sqlx_gen/src/typemap/mod.rs +++ b/crates/sqlx_gen/src/typemap/mod.rs @@ -94,7 +94,7 @@ mod tests { is_primary_key: false, ordinal_position: 0, schema_name: "public".to_string(), - udt_schema: None, + udt_schema: None, column_default: None, } } diff --git a/crates/sqlx_gen/src/typemap/postgres.rs b/crates/sqlx_gen/src/typemap/postgres.rs index 291bcfa..8e370b1 100644 --- a/crates/sqlx_gen/src/typemap/postgres.rs +++ b/crates/sqlx_gen/src/typemap/postgres.rs @@ -86,25 +86,28 @@ pub fn map_type_qualified( // exact (schema, name) matches first; otherwise we fall back to the // first name match so that legacy callers (and synthetic test fixtures) // keep working. - let enum_match = schema_info.enums.iter().find(|e| { - e.name == udt_name && udt_schema.map(|s| s == e.schema_name).unwrap_or(true) - }); + let enum_match = schema_info + .enums + .iter() + .find(|e| e.name == udt_name && udt_schema.map(|s| s == e.schema_name).unwrap_or(true)); if let Some(e) = enum_match { let name = rust_type_name(&e.schema_name, &e.name, schema_info); return RustType::with_import(&name, &format!("use super::types::{};", name)); } - let composite_match = schema_info.composite_types.iter().find(|c| { - c.name == udt_name && udt_schema.map(|s| s == c.schema_name).unwrap_or(true) - }); + let composite_match = schema_info + .composite_types + .iter() + .find(|c| c.name == udt_name && udt_schema.map(|s| s == c.schema_name).unwrap_or(true)); if let Some(c) = composite_match { let name = rust_type_name(&c.schema_name, &c.name, schema_info); return RustType::with_import(&name, &format!("use super::types::{};", name)); } - let domain_match = schema_info.domains.iter().find(|d| { - d.name == udt_name && udt_schema.map(|s| s == d.schema_name).unwrap_or(true) - }); + let domain_match = schema_info + .domains + .iter() + .find(|d| d.name == udt_name && udt_schema.map(|s| s == d.schema_name).unwrap_or(true)); if let Some(domain) = domain_match { // Map to the domain's base type — base type lives in pg_catalog so // schema is irrelevant for the recursive lookup. From b89be722883245f7c267a95f20afea369c8072b5 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:40:37 +0200 Subject: [PATCH 35/38] fix: stop emitting manual PgHasArrayType impl (conflicts with sqlx::Type) #[derive(sqlx::Type)] combined with #[sqlx(type_name = "x")] already auto-generates `impl PgHasArrayType` pointing at `_x` in sqlx 0.8+. The manual impl added by Task 27 collided with the derive output, producing E0119 "conflicting implementations" in any downstream crate that consumed the generated types. Remove the manual block from enum_gen and composite_gen, replace the "must emit" tests with "must NOT emit" regressions across all three dialects, and rely on the sqlx derive for array support. --- crates/sqlx_gen/src/codegen/composite_gen.rs | 29 +++------ crates/sqlx_gen/src/codegen/enum_gen.rs | 67 +++++++------------- 2 files changed, 31 insertions(+), 65 deletions(-) diff --git a/crates/sqlx_gen/src/codegen/composite_gen.rs b/crates/sqlx_gen/src/codegen/composite_gen.rs index 19e8db0..ff4def8 100644 --- a/crates/sqlx_gen/src/codegen/composite_gen.rs +++ b/crates/sqlx_gen/src/codegen/composite_gen.rs @@ -104,20 +104,10 @@ pub fn generate_composite( }) .collect(); - // Same rationale as enum_gen: an array of a composite type in PG needs - // PgHasArrayType for Vec to decode at runtime. - let array_type_impl = if db_kind == DatabaseKind::Postgres { - let array_type_name = format!("_{}", composite.name); - quote! { - impl sqlx::postgres::PgHasArrayType for #struct_name { - fn array_type_info() -> sqlx::postgres::PgTypeInfo { - sqlx::postgres::PgTypeInfo::with_name(#array_type_name) - } - } - } - } else { - quote! {} - }; + // `#[derive(sqlx::Type)]` with `#[sqlx(type_name = "x")]` auto-generates + // `impl PgHasArrayType` returning `_x`. Emitting a second impl triggers + // E0119 in the user's crate. + let _ = db_kind; let search_path_doc_tokens = match &search_path_doc { Some(m) => quote! { #[doc = #m] }, @@ -132,8 +122,6 @@ pub fn generate_composite( pub struct #struct_name { #(#fields)* } - - #array_type_impl }; (tokens, imports) @@ -235,15 +223,16 @@ mod tests { } #[test] - fn test_postgres_emits_pg_has_array_type_impl() { + fn test_does_not_emit_manual_pg_has_array_type_impl() { + // Regression for E0119 — `#[derive(sqlx::Type)]` already provides this + // impl when `type_name` is set, so emitting our own conflicted. let c = make_composite("address", vec![make_field("street", "text", false)]); let code = gen(&c); assert!( - code.contains("impl sqlx::postgres::PgHasArrayType for Address"), - "must impl PgHasArrayType so Vec
works, got:\n{}", + !code.contains("PgHasArrayType"), + "must not emit a manual PgHasArrayType impl, got:\n{}", code ); - assert!(code.contains("\"_address\"")); } #[test] diff --git a/crates/sqlx_gen/src/codegen/enum_gen.rs b/crates/sqlx_gen/src/codegen/enum_gen.rs index d1e9136..60f5808 100644 --- a/crates/sqlx_gen/src/codegen/enum_gen.rs +++ b/crates/sqlx_gen/src/codegen/enum_gen.rs @@ -138,22 +138,11 @@ pub fn generate_enum_with_schema( quote! {} }; - // Postgres arrays of an enum (`my_enum[]`) require an explicit - // PgHasArrayType impl on the Rust side; otherwise sqlx fails at decode - // with "unsupported type _my_enum". The array type name in PG is the - // base type prefixed with '_'. - let array_type_impl = if db_kind == DatabaseKind::Postgres { - let array_type_name = format!("_{}", enum_info.name); - quote! { - impl sqlx::postgres::PgHasArrayType for #enum_name { - fn array_type_info() -> sqlx::postgres::PgTypeInfo { - sqlx::postgres::PgTypeInfo::with_name(#array_type_name) - } - } - } - } else { - quote! {} - }; + // Postgres arrays: `#[derive(sqlx::Type)]` with `#[sqlx(type_name = "x")]` + // already auto-generates `impl PgHasArrayType` returning `_x` in sqlx 0.8+. + // Emitting a second impl here triggers E0119 (conflicting implementations) + // in the user's crate. Leave the derive in charge. + let _ = db_kind; let schema_name_str = &enum_info.schema_name; let enum_name_str = &enum_info.name; @@ -173,8 +162,6 @@ pub fn generate_enum_with_schema( } #default_impl - - #array_type_impl }; (tokens, imports) @@ -292,33 +279,23 @@ mod tests { } #[test] - fn test_postgres_emits_pg_has_array_type_impl() { - let e = make_enum("status", vec!["a", "b"]); - let code = gen(&e, DatabaseKind::Postgres); - assert!( - code.contains("impl sqlx::postgres::PgHasArrayType for Status"), - "must impl PgHasArrayType so Vec works, got:\n{}", - code - ); - assert!( - code.contains("\"_status\""), - "array type name must be '_', got:\n{}", - code - ); - } - - #[test] - fn test_mysql_does_not_emit_pg_has_array_type_impl() { - let e = make_enum("status", vec!["a", "b"]); - let code = gen(&e, DatabaseKind::Mysql); - assert!(!code.contains("PgHasArrayType")); - } - - #[test] - fn test_sqlite_does_not_emit_pg_has_array_type_impl() { - let e = make_enum("status", vec!["a", "b"]); - let code = gen(&e, DatabaseKind::Sqlite); - assert!(!code.contains("PgHasArrayType")); + fn test_does_not_emit_manual_pg_has_array_type_impl() { + // Regression for E0119 — `#[derive(sqlx::Type)]` already provides this + // impl when `type_name` is set, so emitting our own conflicted. + for db in [ + DatabaseKind::Postgres, + DatabaseKind::Mysql, + DatabaseKind::Sqlite, + ] { + let e = make_enum("status", vec!["a", "b"]); + let code = gen(&e, db); + assert!( + !code.contains("PgHasArrayType"), + "{:?}: must not emit a manual PgHasArrayType impl, got:\n{}", + db, + code + ); + } } #[test] From 727f86554d49fdee984340ff34307269003be308 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:48:03 +0200 Subject: [PATCH 36/38] feat: quote SQL identifiers only when syntactically required Every column, table, and schema reference was previously emitted with unconditional dialect quotes. For lowercase ASCII names that aren't reserved words this produced noisy SQL ("agent"."agent__connector", "connector_id" = $1) without any added safety. quote_ident now defers to is_safe_unquoted: an identifier is emitted bare when it starts with a lowercase letter or underscore, contains only ASCII lowercase / digits / underscores, and is not in a curated ~100-word SQL reserved list (sorted, binary-searched). quote_ident_always remains for sites that genuinely need to force the quotes. quote_qualified composes per-part. This means agent.agent__connector instead of "agent"."agent__connector" on the user's reported schema, while user-supplied DB names that collide with SELECT / order / user etc. still get quoted defensively. --- crates/sqlx_gen/src/codegen/crud_gen.rs | 42 +- crates/sqlx_gen/src/codegen/identifiers.rs | 284 ++- .../2026-06-03-rust-engineering-audit.md | 2156 ----------------- 3 files changed, 276 insertions(+), 2206 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-03-rust-engineering-audit.md diff --git a/crates/sqlx_gen/src/codegen/crud_gen.rs b/crates/sqlx_gen/src/codegen/crud_gen.rs index 4dd3235..536e36f 100644 --- a/crates/sqlx_gen/src/codegen/crud_gen.rs +++ b/crates/sqlx_gen/src/codegen/crud_gen.rs @@ -1517,7 +1517,7 @@ mod tests { #[test] fn test_get_all_sql() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("SELECT * FROM \"users\"")); + assert!(code.contains("SELECT * FROM users")); } // --- paginate --- @@ -1569,7 +1569,7 @@ mod tests { #[test] fn test_paginate_count_sql() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("SELECT COUNT(*) FROM \"users\"")); + assert!(code.contains("SELECT COUNT(*) FROM users")); } #[test] @@ -1601,13 +1601,13 @@ mod tests { #[test] fn test_get_where_pk_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("WHERE \"id\" = $1")); + assert!(code.contains("WHERE id = $1")); } #[test] fn test_get_where_pk_mysql() { let code = gen(&standard_entity(), DatabaseKind::Mysql); - assert!(code.contains("WHERE `id` = ?")); + assert!(code.contains("WHERE id = ?")); } // --- insert --- @@ -1827,12 +1827,12 @@ mod tests { fn test_update_set_clause_uses_coalesce_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); assert!( - code.contains("COALESCE($1, \"name\")"), + code.contains("COALESCE($1, name)"), "Expected COALESCE for name:\n{}", code ); assert!( - code.contains("COALESCE($2, \"email\")"), + code.contains("COALESCE($2, email)"), "Expected COALESCE for email:\n{}", code ); @@ -1841,7 +1841,7 @@ mod tests { #[test] fn test_update_where_clause_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("WHERE \"id\" = $3")); + assert!(code.contains("WHERE id = $3")); } #[test] @@ -1855,12 +1855,12 @@ mod tests { fn test_update_set_clause_mysql() { let code = gen(&standard_entity(), DatabaseKind::Mysql); assert!( - code.contains("COALESCE(?, `name`)"), + code.contains("COALESCE(?, name)"), "Expected COALESCE for MySQL:\n{}", code ); assert!( - code.contains("COALESCE(?, `email`)"), + code.contains("COALESCE(?, email)"), "Expected COALESCE for email in MySQL:\n{}", code ); @@ -1870,7 +1870,7 @@ mod tests { fn test_update_set_clause_sqlite() { let code = gen(&standard_entity(), DatabaseKind::Sqlite); assert!( - code.contains("COALESCE(?, \"name\")"), + code.contains("COALESCE(?, name)"), "Expected COALESCE for SQLite:\n{}", code ); @@ -1936,9 +1936,9 @@ mod tests { #[test] fn test_overwrite_set_clause_pg() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("\"name\" = $1,")); - assert!(code.contains("\"email\" = $2")); - assert!(code.contains("WHERE \"id\" = $3")); + assert!(code.contains("name = $1,")); + assert!(code.contains("email = $2")); + assert!(code.contains("WHERE id = $3")); } #[test] @@ -2003,8 +2003,8 @@ mod tests { #[test] fn test_delete_where_pk() { let code = gen(&standard_entity(), DatabaseKind::Postgres); - assert!(code.contains("DELETE FROM \"users\"")); - assert!(code.contains("WHERE \"id\" = $1")); + assert!(code.contains("DELETE FROM users")); + assert!(code.contains("WHERE id = $1")); } #[test] @@ -2766,9 +2766,7 @@ mod tests { code ); assert!( - code.contains( - "INSERT INTO \"analysis\".\"analysis__record\" (\"record_id\", \"analysis_id\")" - ), + code.contains("INSERT INTO analysis.analysis__record (record_id, analysis_id)"), "Expected quoted INSERT INTO clause:\n{}", code ); @@ -2813,12 +2811,12 @@ mod tests { code ); assert!( - code.contains("DELETE FROM \"analysis\".\"analysis__record\""), + code.contains("DELETE FROM analysis.analysis__record"), "Expected DELETE clause:\n{}", code ); assert!( - code.contains("WHERE \"record_id\" = $1 AND \"analysis_id\" = $2"), + code.contains("WHERE record_id = $1 AND analysis_id = $2"), "Expected WHERE clause:\n{}", code ); @@ -2833,7 +2831,7 @@ mod tests { code ); assert!( - code.contains("WHERE \"record_id\" = $1 AND \"analysis_id\" = $2"), + code.contains("WHERE record_id = $1 AND analysis_id = $2"), "Expected WHERE clause with both PK columns:\n{}", code ); @@ -2870,7 +2868,7 @@ mod tests { code ); assert!( - code.contains("WHERE `order_id` = ? AND `product_id` = ?"), + code.contains("WHERE order_id = ? AND product_id = ?"), "SELECT must use bound composite PK values, got:\n{}", code ); diff --git a/crates/sqlx_gen/src/codegen/identifiers.rs b/crates/sqlx_gen/src/codegen/identifiers.rs index 67a4834..837f54a 100644 --- a/crates/sqlx_gen/src/codegen/identifiers.rs +++ b/crates/sqlx_gen/src/codegen/identifiers.rs @@ -1,8 +1,163 @@ use crate::cli::DatabaseKind; -/// Quote a SQL identifier (table/column/schema) per database dialect. -/// Doubles any internal quote characters for safety. +/// SQL keywords reserved across at least one of Postgres / MySQL / SQLite. +/// Sorted so we can binary-search in [`is_reserved_keyword`]. Conservative +/// list — when in doubt, an identifier matching one of these gets quoted. +const SQL_RESERVED: &[&str] = &[ + "ABORT", + "ALL", + "ALTER", + "AND", + "ANY", + "AS", + "ASC", + "AUTHORIZATION", + "BEFORE", + "BEGIN", + "BETWEEN", + "BOTH", + "BY", + "CASE", + "CAST", + "CHECK", + "COLLATE", + "COLUMN", + "COMMIT", + "CONSTRAINT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_ROLE", + "CURRENT_SCHEMA", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "DEFAULT", + "DEFERRABLE", + "DELETE", + "DESC", + "DISTINCT", + "DO", + "DROP", + "ELSE", + "END", + "EXCEPT", + "EXISTS", + "FALSE", + "FETCH", + "FOR", + "FOREIGN", + "FROM", + "FULL", + "GRANT", + "GROUP", + "HAVING", + "IF", + "IN", + "INDEX", + "INNER", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "JOIN", + "KEY", + "LATERAL", + "LEADING", + "LEFT", + "LIKE", + "LIMIT", + "LOCALTIME", + "LOCALTIMESTAMP", + "NATURAL", + "NOT", + "NULL", + "OF", + "OFFSET", + "ON", + "ONLY", + "OR", + "ORDER", + "OUTER", + "OVERLAPS", + "PLACING", + "PRIMARY", + "REFERENCES", + "RETURNING", + "RIGHT", + "ROLLBACK", + "SCHEMA", + "SELECT", + "SESSION_USER", + "SET", + "SIMILAR", + "SOME", + "SYMMETRIC", + "TABLE", + "THEN", + "TO", + "TRAILING", + "TRIGGER", + "TRUE", + "UNION", + "UNIQUE", + "UPDATE", + "USER", + "USING", + "VALUES", + "VARIADIC", + "VIEW", + "WHEN", + "WHERE", + "WINDOW", + "WITH", +]; + +/// Case-insensitive reserved-word lookup. The list is sorted, so binary_search +/// keeps this O(log n). +fn is_reserved_keyword(name: &str) -> bool { + let upper = name.to_ascii_uppercase(); + SQL_RESERVED.binary_search(&upper.as_str()).is_ok() +} + +/// True when `name` is safe to emit unquoted in SQL for the given dialect. +/// +/// "Safe" means: starts with a letter or underscore (lowercase only — PG +/// folds unquoted idents to lowercase, so uppercase letters force a quote), +/// then ASCII letters / digits / underscores, and is not a reserved keyword. +pub fn is_safe_unquoted(name: &str, _db: DatabaseKind) -> bool { + if name.is_empty() { + return false; + } + let bytes = name.as_bytes(); + let first = bytes[0]; + let first_ok = first == b'_' || first.is_ascii_lowercase(); + if !first_ok { + return false; + } + for &b in &bytes[1..] { + let ok = b == b'_' || b.is_ascii_lowercase() || b.is_ascii_digit(); + if !ok { + return false; + } + } + !is_reserved_keyword(name) +} + +/// Quote a SQL identifier (table/column/schema) per database dialect, but +/// only when quoting is syntactically required. Trivially-safe identifiers +/// pass through untouched. pub fn quote_ident(name: &str, db: DatabaseKind) -> String { + if is_safe_unquoted(name, db) { + name.to_string() + } else { + quote_ident_always(name, db) + } +} + +/// Always quote, regardless of safety. Doubles internal quote characters. +pub fn quote_ident_always(name: &str, db: DatabaseKind) -> String { match db { DatabaseKind::Mysql => format!("`{}`", name.replace('`', "``")), DatabaseKind::Postgres | DatabaseKind::Sqlite => { @@ -12,7 +167,7 @@ pub fn quote_ident(name: &str, db: DatabaseKind) -> String { } /// Quote a qualified table reference (`schema.table`) per dialect, or the -/// bare table when no schema is provided. +/// bare table when no schema is provided. Each part is conditionally quoted. pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> String { match schema { Some(s) => format!("{}.{}", quote_ident(s, db), quote_ident(table, db)), @@ -21,7 +176,10 @@ pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> S } /// True if `name` is a safe SQL identifier candidate (alphanumeric + underscore, -/// non-empty, does not start with a digit). +/// non-empty, does not start with a digit). Loosely the same predicate as +/// [`is_safe_unquoted`] minus the case sensitivity and reserved-word check — +/// kept as a separate helper because it's a generic "could this be a safe +/// identifier" question used by filename validation. pub fn is_safe_ident(name: &str) -> bool { !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') @@ -32,58 +190,119 @@ pub fn is_safe_ident(name: &str) -> bool { mod tests { use super::*; + // ---------- conditional quote_ident ---------- + #[test] - fn quotes_postgres_double_quote() { - assert_eq!(quote_ident("users", DatabaseKind::Postgres), "\"users\""); + fn safe_identifier_pg_not_quoted() { + assert_eq!(quote_ident("users", DatabaseKind::Postgres), "users"); + assert_eq!(quote_ident("agent_id", DatabaseKind::Postgres), "agent_id"); + assert_eq!( + quote_ident("agent__connector", DatabaseKind::Postgres), + "agent__connector" + ); } #[test] - fn quotes_sqlite_double_quote() { - assert_eq!(quote_ident("users", DatabaseKind::Sqlite), "\"users\""); + fn safe_identifier_mysql_not_quoted() { + assert_eq!(quote_ident("users", DatabaseKind::Mysql), "users"); } #[test] - fn quotes_mysql_backtick() { - assert_eq!(quote_ident("users", DatabaseKind::Mysql), "`users`"); + fn uppercase_identifier_pg_quoted() { + // PG folds unquoted identifiers to lowercase; uppercase needs quoting + // to preserve the case of the actual table name. + assert_eq!(quote_ident("Users", DatabaseKind::Postgres), "\"Users\""); } #[test] - fn escapes_postgres_internal_quote() { + fn reserved_word_quoted_in_pg() { + assert_eq!(quote_ident("select", DatabaseKind::Postgres), "\"select\""); + assert_eq!(quote_ident("user", DatabaseKind::Postgres), "\"user\""); + assert_eq!(quote_ident("order", DatabaseKind::Postgres), "\"order\""); + } + + #[test] + fn reserved_word_quoted_in_mysql() { + assert_eq!(quote_ident("select", DatabaseKind::Mysql), "`select`"); + } + + #[test] + fn identifier_with_dash_quoted() { assert_eq!( - quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres), - "\"user\"\"; DROP TABLE x; --\"" + quote_ident("user-id", DatabaseKind::Postgres), + "\"user-id\"" ); } #[test] - fn escapes_mysql_internal_backtick() { - assert_eq!(quote_ident("ev`il", DatabaseKind::Mysql), "`ev``il`"); + fn identifier_starting_with_digit_quoted() { + assert_eq!(quote_ident("123abc", DatabaseKind::Postgres), "\"123abc\""); + } + + #[test] + fn empty_identifier_quoted() { + // Empty isn't valid SQL but we don't want to drop the quotes and emit + // bare whitespace either. + assert_eq!(quote_ident("", DatabaseKind::Postgres), "\"\""); } #[test] - fn qualified_with_schema_postgres() { + fn injection_attempt_quoted_and_escaped() { assert_eq!( - quote_qualified(Some("auth"), "users", DatabaseKind::Postgres), - "\"auth\".\"users\"" + quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres), + "\"user\"\"; DROP TABLE x; --\"" ); } + // ---------- quote_ident_always ---------- + #[test] - fn qualified_with_schema_mysql() { + fn always_quote_safe_identifier_pg() { + assert_eq!( + quote_ident_always("users", DatabaseKind::Postgres), + "\"users\"" + ); + } + + #[test] + fn always_quote_safe_identifier_mysql() { + assert_eq!(quote_ident_always("users", DatabaseKind::Mysql), "`users`"); + } + + #[test] + fn always_quote_escapes_internal_backtick() { + assert_eq!(quote_ident_always("ev`il", DatabaseKind::Mysql), "`ev``il`"); + } + + // ---------- quote_qualified ---------- + + #[test] + fn qualified_safe_idents_not_quoted() { + assert_eq!( + quote_qualified(Some("agent"), "agent_connector", DatabaseKind::Postgres), + "agent.agent_connector" + ); assert_eq!( quote_qualified(Some("app"), "users", DatabaseKind::Mysql), - "`app`.`users`" + "app.users" ); } #[test] - fn qualified_without_schema() { + fn qualified_with_reserved_schema_quoted() { assert_eq!( - quote_qualified(None, "users", DatabaseKind::Mysql), - "`users`" + quote_qualified(Some("user"), "items", DatabaseKind::Postgres), + "\"user\".items" ); } + #[test] + fn qualified_without_schema() { + assert_eq!(quote_qualified(None, "users", DatabaseKind::Mysql), "users"); + } + + // ---------- is_safe_ident (filename helper) ---------- + #[test] fn safe_ident_rejects_dash() { assert!(!is_safe_ident("user-id")); @@ -99,11 +318,6 @@ mod tests { assert!(!is_safe_ident("")); } - #[test] - fn safe_ident_rejects_space() { - assert!(!is_safe_ident("user id")); - } - #[test] fn safe_ident_accepts_underscore_prefix() { assert!(is_safe_ident("_private")); @@ -113,4 +327,18 @@ mod tests { fn safe_ident_accepts_mixed_case() { assert!(is_safe_ident("UserAccount2")); } + + // ---------- reserved list sanity ---------- + + #[test] + fn reserved_list_is_sorted() { + for pair in SQL_RESERVED.windows(2) { + assert!( + pair[0] < pair[1], + "SQL_RESERVED must be sorted; '{}' >= '{}'", + pair[0], + pair[1] + ); + } + } } diff --git a/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md b/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md deleted file mode 100644 index 8cbd6b6..0000000 --- a/docs/superpowers/plans/2026-06-03-rust-engineering-audit.md +++ /dev/null @@ -1,2156 +0,0 @@ -# Audit d'ingénierie Rust — sqlx-gen — Plan de remédiation - -> **Pour les agents exécutants :** SOUS-COMPÉTENCE REQUISE : utiliser `superpowers:subagent-driven-development` (recommandé) ou `superpowers:executing-plans` pour implémenter ce plan tâche par tâche. Les étapes utilisent la syntaxe checkbox (`- [ ]`) pour le suivi. - -**Objectif :** Corriger les vulnérabilités, panics, incohérences de codegen et lacunes de tests identifiés par un audit multi-domaines de la codebase `sqlx-gen` (v0.5.5). - -**Architecture :** Audit conduit par 4 agents experts (sécurité, codegen/typemap, error handling, tests). Findings synthétisés en 4 vagues de remédiation (P0 → P3) avec TDD strict et commits fréquents. - -**Tech Stack :** Rust 2021, sqlx 0.8, tokio 1, clap 4, syn 2, quote 1, prettyplease 0.2, thiserror 2. - ---- - -## Vue d'ensemble des findings - -| Domaine | Critique | Haut | Moyen | Bas | -| --------------- | -------- | ------ | ------ | ------ | -| Sécurité | 4 | 4 | 3 | 0 | -| Codegen/typemap | 3 | 9 | 15 | 4 | -| Error handling | 2 | 6 | 11 | 6 | -| Tests/CI | 3 | 4 | 2 | 2 | -| **TOTAL** | **12** | **23** | **31** | **12** | - -### Findings P0 (bloquants) - -1. **SQL injection via identifiants non-quotés** dans le code CRUD généré (`crud_gen.rs:23-26, 73, 99-105`). Tables/colonnes/schémas interpolés bruts dans `format!()` sans quoting dialectal. -2. **Code injection via `--type-overrides`** (`cli.rs:100-108`) : la valeur est parsée par `TokenStream::parse().unwrap()` sans validation, permettant l'injection de Rust arbitraire dans le code généré. -3. **Fuite de mot de passe** : `sqlx::Error` peut contenir l'URL complète (login:password@host) ; `Error::Database(#[from] sqlx::Error)` la propage telle quelle aux logs. -4. **Proc-macro fait `std::process::exit(1)`** (`codegen/mod.rs:315-319`) si le parsing échoue — tue le build utilisateur sans `compile_error!`. -5. **MySQL UTF-8 panics** (`introspect/mysql.rs:72-78`) : 7 `.expect()` consécutifs sur noms de colonnes/tables. Toute donnée non-UTF8 dans `information_schema` crash. -6. **Aucun CI de tests** : `.github/workflows/publish.yml` seul existe ; `cargo test` n'est jamais exécuté en CI. -7. **Aucun test E2E Postgres/MySQL** : le commit `bacb088 fix: MySQL 8.0 information_schema changes` était non testable et donc non détectable par les tests. -8. **`.last_mut().unwrap()`** dans toutes les boucles d'introspection (5 occurrences) panic sur ResultSet vide. -9. **MySQL INSERT composite PK** (`crud_gen.rs:959-979`) : utilise `LAST_INSERT_ID()` qui ne fonctionne qu'avec un seul PK auto-increment. -10. **Pas d'écritures atomiques** (`writer.rs:73, 87`) : Ctrl-C en cours de génération laisse des fichiers `.rs` corrompus. -11. **SQLite NUMERIC/DECIMAL → f64** (`typemap/sqlite.rs:40-41`) : perte de précision silencieuse. -12. **Identifiants Rust invalides** : colonnes nommées `user-id`, `123`, ou contenant des espaces produisent du Rust qui ne compile pas. - ---- - -## Architecture des fichiers à modifier - -| Fichier | Responsabilité dans ce plan | -| ------------------------------------------------------ | -------------------------------------------------------------------- | -| `crates/sqlx_gen/src/codegen/identifiers.rs` (nouveau) | Module de quoting d'identifiants par dialecte + validation. | -| `crates/sqlx_gen/src/error.rs` | Étendre les variants ; ajouter `Url::redact`. | -| `crates/sqlx_gen/src/codegen/crud_gen.rs` | Utiliser `identifiers::quote_ident` pour toute SQL générée. | -| `crates/sqlx_gen/src/introspect/mysql.rs` | Remplacer `.expect()` par `.map_err()`, idem `.last_mut().unwrap()`. | -| `crates/sqlx_gen/src/introspect/postgres.rs` | Idem MySQL. | -| `crates/sqlx_gen/src/introspect/sqlite.rs` | Valider les noms avant `PRAGMA`. | -| `crates/sqlx_gen/src/cli.rs` | Valider `--type-overrides` via `syn::parse_str::`. | -| `crates/sqlx_gen/src/codegen/mod.rs` | Supprimer `std::process::exit(1)` ; remonter une `Error`. | -| `crates/sqlx_gen/src/writer.rs` | Écritures atomiques via `tempfile`. | -| `crates/sqlx_gen/src/typemap/sqlite.rs` | NUMERIC → `Decimal`. | -| `crates/sqlx_gen/src/typemap/mysql.rs` | `BIT(1)` → `bool`. | -| `crates/sqlx_gen/src/typemap/postgres.rs` | Ajouter `interval`, range types, `timetz`. | -| `crates/sqlx_gen/tests/e2e_postgres.rs` (nouveau) | E2E avec testcontainers PostgreSQL. | -| `crates/sqlx_gen/tests/e2e_mysql.rs` (nouveau) | E2E avec testcontainers MySQL. | -| `crates/sqlx_gen/tests/snapshots/` (nouveau) | Snapshots `insta` du codegen. | -| `.github/workflows/ci.yml` (nouveau) | Matrix Postgres+MySQL+SQLite, `cargo test --all`. | - ---- - -# VAGUE P0 — Bloquants sécurité & fiabilité - -## Task 1 : Module de quoting d'identifiants par dialecte - -**Fichiers :** - -- Créer : `crates/sqlx_gen/src/codegen/identifiers.rs` -- Modifier : `crates/sqlx_gen/src/codegen/mod.rs` (ajouter `pub mod identifiers;`) - -**Pourquoi :** Tout le code SQL généré dans `crud_gen.rs` interpole table/colonne/schéma via `format!("{}", name)` sans quoting. Si une colonne s'appelle `select` ou `user"; DROP TABLE x; --`, le code compilé exécute du SQL malformé voire malveillant. Source de l'audit : finding sécurité #2/#3, codegen #16/#23. - -- [ ] **Étape 1 : Écrire les tests qui échouent** - -Créer `crates/sqlx_gen/src/codegen/identifiers.rs` : - -```rust -use crate::cli::DatabaseKind; - -/// Quote a SQL identifier (table/column/schema) per database dialect. -/// Doubles internal quote characters for safety. -pub fn quote_ident(name: &str, db: DatabaseKind) -> String { - match db { - DatabaseKind::Mysql => format!("`{}`", name.replace('`', "``")), - DatabaseKind::Postgres | DatabaseKind::Sqlite => { - format!("\"{}\"", name.replace('"', "\"\"")) - } - } -} - -/// Quote a qualified table name (schema.table) per dialect. -pub fn quote_qualified(schema: Option<&str>, table: &str, db: DatabaseKind) -> String { - match schema { - Some(s) => format!("{}.{}", quote_ident(s, db), quote_ident(table, db)), - None => quote_ident(table, db), - } -} - -/// True if a string is a safe SQL identifier candidate (alphanumeric + underscore). -/// Used as a defense-in-depth check before generating files whose names -/// derive from DB metadata. -pub fn is_safe_ident(name: &str) -> bool { - !name.is_empty() - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') - && !name.starts_with(|c: char| c.is_ascii_digit()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn quotes_postgres_double_quote() { - assert_eq!(quote_ident("users", DatabaseKind::Postgres), "\"users\""); - } - - #[test] - fn quotes_mysql_backtick() { - assert_eq!(quote_ident("users", DatabaseKind::Mysql), "`users`"); - } - - #[test] - fn escapes_postgres_internal_quote() { - assert_eq!( - quote_ident("user\"; DROP TABLE x; --", DatabaseKind::Postgres), - "\"user\"\"; DROP TABLE x; --\"" - ); - } - - #[test] - fn escapes_mysql_internal_backtick() { - assert_eq!( - quote_ident("ev`il", DatabaseKind::Mysql), - "`ev``il`" - ); - } - - #[test] - fn qualified_with_schema() { - assert_eq!( - quote_qualified(Some("auth"), "users", DatabaseKind::Postgres), - "\"auth\".\"users\"" - ); - } - - #[test] - fn qualified_without_schema() { - assert_eq!( - quote_qualified(None, "users", DatabaseKind::Mysql), - "`users`" - ); - } - - #[test] - fn safe_ident_rejects_dash() { - assert!(!is_safe_ident("user-id")); - } - - #[test] - fn safe_ident_rejects_leading_digit() { - assert!(!is_safe_ident("123abc")); - } - - #[test] - fn safe_ident_rejects_empty() { - assert!(!is_safe_ident("")); - } - - #[test] - fn safe_ident_accepts_underscore() { - assert!(is_safe_ident("_private")); - } -} -``` - -- [ ] **Étape 2 : Faire échouer les tests** - -Run : `cargo test -p sqlx-gen --lib codegen::identifiers` -Attendu : `error[E0583]: file not found for module identifiers` (avant d'ajouter le `pub mod`). - -- [ ] **Étape 3 : Déclarer le module** - -Dans `crates/sqlx_gen/src/codegen/mod.rs` (juste après les autres `mod`) : - -```rust -pub mod identifiers; -``` - -- [ ] **Étape 4 : Valider que les tests passent** - -Run : `cargo test -p sqlx-gen --lib codegen::identifiers` -Attendu : `10 passed; 0 failed`. - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/codegen/identifiers.rs crates/sqlx_gen/src/codegen/mod.rs -git commit -m "feat(codegen): add SQL identifier quoting module" -``` - ---- - -## Task 2 : Appliquer le quoting dans crud_gen.rs - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/crud_gen.rs:23-26, 73, 99-105, 273-275, 305-313, 440-450, 618-629` - -**Pourquoi :** Élimine la SQL injection P0 #1. Toute SQL générée doit utiliser `quote_qualified` / `quote_ident`. - -- [ ] **Étape 1 : Ajouter un test d'intégration "identifiant malveillant"** - -Dans `crates/sqlx_gen/src/codegen/crud_gen.rs` (bloc `#[cfg(test)] mod tests`) : - -```rust -#[test] -fn generated_sql_quotes_table_name() { - use crate::codegen::entity_parser::{ParsedEntity, ParsedField}; - let entity = ParsedEntity { - struct_name: "Users".into(), - table_name: "users".into(), - schema_name: Some("public".into()), - is_view: false, - fields: vec![ParsedField { - field_name: "id".into(), - column_name: "id".into(), - rust_type: "i32".into(), - inner_type: "i32".into(), - is_optional: false, - is_primary_key: true, - column_default: None, - sql_type: None, - }], - imports: vec![], - }; - let (tokens, _) = generate_crud_from_parsed( - &entity, - DatabaseKind::Postgres, - "crate::models::users", - &Methods { get_all: true, ..Methods::default() }, - false, - PoolVisibility::Private, - ); - let code = tokens.to_string(); - assert!( - code.contains("\\\"public\\\".\\\"users\\\""), - "generated code must use quoted qualified identifier, got: {}", - code - ); - assert!( - !code.contains("FROM public.users "), - "must not contain unquoted table reference" - ); -} -``` - -- [ ] **Étape 2 : Run pour faire échouer** - -Run : `cargo test -p sqlx-gen --lib codegen::crud_gen::tests::generated_sql_quotes_table_name` -Attendu : FAIL — la sortie actuelle contient `FROM public.users` non quoté. - -- [ ] **Étape 3 : Implémenter le quoting** - -Remplacer dans `crud_gen.rs` ligne 23-26 : - -```rust -let table_name = match &entity.schema_name { - Some(schema) => format!("{}.{}", schema, entity.table_name), - None => entity.table_name.clone(), -}; -``` - -par : - -```rust -use crate::codegen::identifiers::{quote_ident, quote_qualified}; - -let table_name = quote_qualified( - entity.schema_name.as_deref(), - &entity.table_name, - db_kind, -); -``` - -Puis remplacer chacune des occurrences `f.column_name` dans les formats SQL (lignes 273-275, 440, 450, 618, 629, 890, 921) par `quote_ident(&f.column_name, db_kind)`. Exemple ligne 273-275 : - -```rust -let set_cols: Vec = non_pk_fields - .iter() - .enumerate() - .map(|(i, f)| { - let p = placeholder(db_kind, i + 1); - format!("{} = {}", quote_ident(&f.column_name, db_kind), p) - }) - .collect(); -``` - -Faire la même substitution pour les listes de colonnes d'`INSERT (...)`, `WHERE x = $1`, `RETURNING ` (ne pas quoter `*`). - -- [ ] **Étape 4 : Vérifier le passage** - -Run : `cargo test -p sqlx-gen --lib codegen::crud_gen` -Attendu : tous passent (les snapshots des tests existants seront probablement à mettre à jour — ils sont des assertions de substring, ajuster en conséquence : remplacer `assert!(code.contains("SELECT * FROM users"))` par `assert!(code.contains("SELECT * FROM \"users\""))` pour Postgres et ``"SELECT * FROM `users`"`` pour MySQL). - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/codegen/crud_gen.rs -git commit -m "fix(codegen): quote SQL identifiers per dialect to prevent injection" -``` - ---- - -## Task 3 : Valider les valeurs de `--type-overrides` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/cli.rs:100-108` - -**Pourquoi :** Le finding sécurité #5 montre que `--type-overrides jsonb=evil; fn pwned()` est injecté tel quel dans le code généré via `TokenStream::parse().unwrap()`. Valider le type en amont via `syn`. - -- [ ] **Étape 1 : Écrire les tests** - -Dans `crates/sqlx_gen/src/cli.rs` (module `tests`) : - -```rust -#[test] -fn type_overrides_reject_injection() { - let args = make_entities_args_with_overrides(vec!["jsonb=Vec; fn pwned() {}"]); - let result = args.parse_type_overrides_checked(); - assert!(result.is_err(), "must reject overrides that aren't valid types"); -} - -#[test] -fn type_overrides_accept_path_type() { - let args = make_entities_args_with_overrides(vec!["jsonb=crate::types::MyJson"]); - let map = args.parse_type_overrides_checked().unwrap(); - assert_eq!(map.get("jsonb").unwrap(), "crate::types::MyJson"); -} - -#[test] -fn type_overrides_accept_generic_type() { - let args = make_entities_args_with_overrides(vec!["jsonb=Vec"]); - assert!(args.parse_type_overrides_checked().is_ok()); -} - -#[test] -fn type_overrides_reject_empty_value() { - let args = make_entities_args_with_overrides(vec!["jsonb="]); - assert!(args.parse_type_overrides_checked().is_err()); -} -``` - -- [ ] **Étape 2 : Faire échouer** - -Run : `cargo test -p sqlx-gen --lib cli::tests::type_overrides_reject_injection` -Attendu : FAIL — méthode `parse_type_overrides_checked` n'existe pas. - -- [ ] **Étape 3 : Implémenter** - -Dans `crates/sqlx_gen/src/cli.rs` (impl `EntitiesArgs`) : - -```rust -pub fn parse_type_overrides_checked(&self) -> crate::error::Result> { - let mut map = HashMap::new(); - for s in &self.type_overrides { - let (k, v) = s.split_once('=').ok_or_else(|| { - crate::error::Error::Config(format!( - "Invalid --type-overrides entry '{}'. Expected format: sql_type=RustType", - s - )) - })?; - if v.trim().is_empty() { - return Err(crate::error::Error::Config(format!( - "Empty Rust type in override '{}'", - s - ))); - } - syn::parse_str::(v).map_err(|e| { - crate::error::Error::Config(format!( - "Invalid Rust type in --type-overrides '{}': {}", - v, e - )) - })?; - map.insert(k.to_string(), v.to_string()); - } - Ok(map) -} -``` - -Mettre à jour `main.rs:30` : - -```rust -let type_overrides = args.parse_type_overrides_checked()?; -``` - -- [ ] **Étape 4 : Vérifier** - -Run : `cargo test -p sqlx-gen --lib cli::tests::type_overrides` -Attendu : 4 passent. - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/cli.rs crates/sqlx_gen/src/main.rs -git commit -m "fix(cli): validate --type-overrides values via syn::parse_str" -``` - ---- - -## Task 4 : Redaction de l'URL DB dans les erreurs - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/error.rs` -- Modifier : `crates/sqlx_gen/src/main.rs:42-58` - -**Pourquoi :** Finding sécurité #6 : `sqlx::Error` peut contenir l'URL avec mot de passe. Ajouter une wrapping `Connection(redacted_url, source)` et une fonction utilitaire `redact_url`. - -- [ ] **Étape 1 : Tests** - -Dans `crates/sqlx_gen/src/error.rs` : - -```rust -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redacts_password_in_postgres_url() { - let url = "postgres://alice:s3cret@localhost:5432/db"; - assert_eq!( - redact_url(url), - "postgres://alice:****@localhost:5432/db" - ); - } - - #[test] - fn redacts_password_in_mysql_url() { - assert_eq!( - redact_url("mysql://root:hunter2@db:3306/app"), - "mysql://root:****@db:3306/app" - ); - } - - #[test] - fn leaves_passwordless_url_unchanged() { - assert_eq!( - redact_url("sqlite:///tmp/test.db"), - "sqlite:///tmp/test.db" - ); - } - - #[test] - fn leaves_no_userinfo_unchanged() { - assert_eq!( - redact_url("postgres://localhost/db"), - "postgres://localhost/db" - ); - } -} -``` - -- [ ] **Étape 2 : Faire échouer** - -Run : `cargo test -p sqlx-gen --lib error` -Attendu : FAIL — `redact_url` n'existe pas. - -- [ ] **Étape 3 : Implémenter** - -Remplacer le contenu de `crates/sqlx_gen/src/error.rs` par : - -```rust -use std::io; - -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("Database connection error ({redacted_url}): {source}")] - Connection { - redacted_url: String, - #[source] - source: sqlx::Error, - }, - - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), - - #[error("IO error: {0}")] - Io(#[from] io::Error), - - #[error("{0}")] - Config(String), -} - -pub type Result = std::result::Result; - -/// Redact `user:password@host` → `user:****@host` in a database URL. -pub fn redact_url(url: &str) -> String { - let (scheme, rest) = match url.split_once("://") { - Some(pair) => pair, - None => return url.to_string(), - }; - let (userinfo, host_part) = match rest.split_once('@') { - Some(pair) => pair, - None => return url.to_string(), - }; - let redacted_userinfo = match userinfo.split_once(':') { - Some((user, _pw)) => format!("{}:****", user), - None => userinfo.to_string(), - }; - format!("{}://{}@{}", scheme, redacted_userinfo, host_part) -} -``` - -Mettre à jour `main.rs:42-58` pour wrapper en `Connection` : - -```rust -let mut schema_info = match db_kind { - DatabaseKind::Postgres => { - let pool = PgPool::connect(&args.db.database_url).await.map_err(|e| { - sqlx_gen::error::Error::Connection { - redacted_url: sqlx_gen::error::redact_url(&args.db.database_url), - source: e, - } - })?; - let info = introspect::postgres::introspect(&pool, &args.db.schemas, args.views).await?; - pool.close().await; - info - } - // ... idem pour Mysql, Sqlite -}; -``` - -- [ ] **Étape 4 : Vérifier** - -Run : `cargo test -p sqlx-gen --lib error::tests` -Attendu : 4 passent. - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/error.rs crates/sqlx_gen/src/main.rs -git commit -m "fix(error): redact password in database URLs on connection failure" -``` - ---- - -## Task 5 : Remplacer `process::exit(1)` par une erreur propagée - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/mod.rs:310-323` - -**Pourquoi :** Finding error #3.1. Un échec de parsing prettyplease tue le process — inacceptable dans une bibliothèque ou un futur build.rs. - -- [ ] **Étape 1 : Test** - -Dans `crates/sqlx_gen/src/codegen/mod.rs` (module `tests`) : - -```rust -#[test] -fn parse_and_format_returns_error_on_invalid_tokens() { - use proc_macro2::TokenStream; - use std::str::FromStr; - // Mismatched braces produce a valid TokenStream but invalid syn::File. - let bad = TokenStream::from_str("fn x() { ").unwrap(); - let result = parse_and_format_with_tab_spaces(&bad, 4); - assert!(result.is_err(), "must return Err, not exit"); -} -``` - -- [ ] **Étape 2 : Faire échouer** - -Run : `cargo test -p sqlx-gen --lib codegen::tests::parse_and_format_returns_error_on_invalid_tokens` -Attendu : FAIL — la fonction renvoie `String`, pas `Result`. - -- [ ] **Étape 3 : Implémenter** - -Modifier `crates/sqlx_gen/src/codegen/mod.rs:310-323` : - -```rust -pub(crate) fn parse_and_format(tokens: &TokenStream) -> crate::error::Result { - parse_and_format_with_tab_spaces(tokens, 4) -} - -pub(crate) fn parse_and_format_with_tab_spaces( - tokens: &TokenStream, - tab_spaces: usize, -) -> crate::error::Result { - let file = syn::parse2::(tokens.clone()).map_err(|e| { - crate::error::Error::Config(format!( - "Internal sqlx-gen bug: failed to parse generated code: {}. \ - Please report this with the input schema.", - e - )) - })?; - let raw = prettyplease::unparse(&file); - let raw = indent_multiline_raw_strings(&raw, tab_spaces); - Ok(add_blank_lines_between_items(&raw)) -} -``` - -Propager `?` dans tous les sites d'appel : `format_tokens`, `format_tokens_with_imports`, `format_tokens_with_imports_and_tab_spaces`, et les retours dans `generate()`. Ajuster les signatures publiques pour renvoyer `Result` au lieu de `String`. - -- [ ] **Étape 4 : Vérifier** - -Run : `cargo build -p sqlx-gen && cargo test -p sqlx-gen --lib codegen` -Attendu : compile et tous les tests passent (les sites d'appel mis à jour). - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/codegen/mod.rs crates/sqlx_gen/src/main.rs -git commit -m "fix(codegen): propagate parse errors instead of process::exit" -``` - ---- - -## Task 6 : Bannir les `.expect()` et `.last_mut().unwrap()` dans introspect - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/introspect/mysql.rs:72-78, 89, 146` -- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs:355, 405, 466` - -**Pourquoi :** Finding error #1.1–#1.5. Toute donnée DB inattendue (utf-8, ordre des rows) crash. - -- [ ] **Étape 1 : Test pour `last_mut`** - -Dans `crates/sqlx_gen/src/introspect/mysql.rs` (module `tests` à créer si absent) : - -```rust -#[cfg(test)] -mod tests { - use crate::error::Error; - - fn invariant_violation(field: &str) -> Error { - Error::Config(format!( - "Internal introspection invariant violated: {} accessed empty tables vector. \ - This is a bug in sqlx-gen.", - field - )) - } - - #[test] - fn invariant_violation_message_mentions_field() { - let err = invariant_violation("columns"); - assert!(err.to_string().contains("columns")); - } -} -``` - -- [ ] **Étape 2 : Faire passer (run test)** - -Run : `cargo test -p sqlx-gen --lib introspect::mysql::tests::invariant_violation_message_mentions_field` -Attendu : PASS après ajout — placeholder pour la fonction utilitaire. - -- [ ] **Étape 3 : Remplacer les `.expect()` UTF-8 et les `.last_mut().unwrap()`** - -Dans `crates/sqlx_gen/src/introspect/mysql.rs:62`, changer le type de `query_as` pour prendre des `String` plutôt que `Vec` quand c'est possible : - -```rust -let mut q = sqlx::query_as::<_, (String, String, String, String, String, String, u32, String)>(&query); -``` - -Si MySQL réclame `Vec` (cas mediumtext en collation binary), remplacer chaque `.expect("Could not convert ...")` par : - -```rust -let schema = String::from_utf8(schema).map_err(|_| crate::error::Error::Config( - "Database returned non-UTF8 schema name; sqlx-gen requires UTF-8 metadata".into() -))?; -``` - -Pour les `last_mut().unwrap()` (lignes 89, 146 mysql, 355, 405 pg) remplacer par : - -```rust -match tables.last_mut() { - Some(t) => t.columns.push(column), - None => return Err(crate::error::Error::Config( - "Internal invariant: row returned for non-existent table. Bug in sqlx-gen.".into() - )), -} -``` - -- [ ] **Étape 4 : Vérifier** - -Run : `cargo build -p sqlx-gen && cargo test -p sqlx-gen --lib introspect` -Attendu : compile, tests passent. - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/introspect/ -git commit -m "fix(introspect): replace expect/unwrap with proper Result propagation" -``` - ---- - -## Task 7 : Écritures atomiques - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/Cargo.toml` (déplacer `tempfile` de `dev-dependencies` vers `dependencies`, gated par feature `cli`) -- Modifier : `crates/sqlx_gen/src/writer.rs:60-90, 167-200` -- Modifier : `crates/sqlx_gen/src/main.rs:167` - -**Pourquoi :** Finding error #10.1/#10.2. `std::fs::write` non-atomique : Ctrl-C laisse des fichiers tronqués qui cassent le build du projet utilisateur. - -- [ ] **Étape 1 : Ajouter tempfile en dépendance runtime** - -Dans `crates/sqlx_gen/Cargo.toml`, ajouter dans `[dependencies]` : - -```toml -tempfile = { version = "3", optional = true } -``` - -Et l'ajouter à la feature `cli` : - -```toml -cli = [ - # ... existing - "dep:tempfile", -] -``` - -- [ ] **Étape 2 : Test** - -Dans `crates/sqlx_gen/src/writer.rs` (module `tests`) : - -```rust -#[test] -fn write_atomic_creates_file_with_content() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("out.rs"); - write_atomic(&path, b"hello").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); -} - -#[test] -fn write_atomic_overwrites_existing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("out.rs"); - std::fs::write(&path, "old").unwrap(); - write_atomic(&path, b"new").unwrap(); - assert_eq!(std::fs::read_to_string(&path).unwrap(), "new"); -} -``` - -- [ ] **Étape 3 : Faire échouer** - -Run : `cargo test -p sqlx-gen --lib writer::tests::write_atomic_creates_file_with_content` -Attendu : FAIL — fonction inexistante. - -- [ ] **Étape 4 : Implémenter** - -Dans `crates/sqlx_gen/src/writer.rs` : - -```rust -/// Write `content` to `path` atomically: write to a sibling temp file then rename. -pub(crate) fn write_atomic(path: &Path, content: &[u8]) -> Result<()> { - let parent = path.parent().ok_or_else(|| { - crate::error::Error::Config(format!("Cannot determine parent of {}", path.display())) - })?; - let mut tmp = tempfile::NamedTempFile::new_in(parent)?; - use std::io::Write; - tmp.write_all(content)?; - tmp.flush()?; - tmp.persist(path).map_err(|e| e.error)?; - Ok(()) -} -``` - -Remplacer chaque `std::fs::write(&path, &content)?;` dans `writer.rs` et `main.rs:167` par `write_atomic(&path, content.as_bytes())?;`. - -- [ ] **Étape 5 : Vérifier** - -Run : `cargo test -p sqlx-gen --lib writer` -Attendu : tests passent. - -- [ ] **Étape 6 : Commit** - -```bash -git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/src/writer.rs crates/sqlx_gen/src/main.rs -git commit -m "fix(writer): use atomic temp-file + rename to prevent corrupted output" -``` - ---- - -## Task 8 : Validation path traversal pour les noms de fichier générés - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/mod.rs` (fonction `normalize_module_name` ou similaire) -- Modifier : `crates/sqlx_gen/src/writer.rs:70-78` - -**Pourquoi :** Finding sécurité #7 : si une DB malveillante retourne un nom de table `../../etc/passwd`, le filename généré peut sortir de `output_dir`. - -- [ ] **Étape 1 : Tests** - -Dans `crates/sqlx_gen/src/writer.rs` (module `tests`) : - -```rust -#[test] -fn rejects_path_traversal_in_filename() { - let dir = tempfile::tempdir().unwrap(); - let files = vec![GeneratedFile { - filename: "../escape.rs".to_string(), - code: "fn x() {}".into(), - origin: None, - }]; - let result = write_files(&files, dir.path(), false, false); - assert!(result.is_err(), "must reject filename containing .."); -} - -#[test] -fn rejects_absolute_path_in_filename() { - let dir = tempfile::tempdir().unwrap(); - let files = vec![GeneratedFile { - filename: "/etc/passwd".to_string(), - code: "".into(), - origin: None, - }]; - let result = write_files(&files, dir.path(), false, false); - assert!(result.is_err()); -} -``` - -- [ ] **Étape 2 : Faire échouer** - -Run : `cargo test -p sqlx-gen --lib writer::tests::rejects_path_traversal_in_filename` -Attendu : FAIL — actuellement l'écriture aboutit. - -- [ ] **Étape 3 : Implémenter** - -Dans `crates/sqlx_gen/src/writer.rs`, ajouter en début de `write_multi_files` : - -```rust -for f in files { - let candidate = std::path::Path::new(&f.filename); - if candidate.components().count() != 1 - || candidate.is_absolute() - || f.filename.contains("..") - || !f.filename.ends_with(".rs") - { - return Err(crate::error::Error::Config(format!( - "Refusing to write generated file with unsafe name: {:?}", - f.filename - ))); - } -} -``` - -- [ ] **Étape 4 : Vérifier** - -Run : `cargo test -p sqlx-gen --lib writer` -Attendu : passent. - -- [ ] **Étape 5 : Commit** - -```bash -git add crates/sqlx_gen/src/writer.rs -git commit -m "fix(writer): refuse path-traversal in generated filenames" -``` - ---- - -## Task 9 : CI — workflow `test.yml` avec services Postgres + MySQL - -**Fichiers :** - -- Créer : `.github/workflows/ci.yml` - -**Pourquoi :** Finding tests P0 #2. Aucun CI ne lance `cargo test` ; chaque PR mergé est inspecté à la main. - -- [ ] **Étape 1 : Créer le fichier** - -`.github/workflows/ci.yml` : - -```yaml -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - test: - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_PASSWORD: postgres - POSTGRES_DB: sqlx_gen_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres" - --health-interval 5s - --health-timeout 3s - --health-retries 10 - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: sqlx_gen_test - ports: - - 3306:3306 - options: >- - --health-cmd "mysqladmin ping -uroot -proot" - --health-interval 5s - --health-timeout 3s - --health-retries 10 - env: - PG_URL: postgres://postgres:postgres@localhost:5432/sqlx_gen_test - MYSQL_URL: mysql://root:root@localhost:3306/sqlx_gen_test - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 - - name: Format check - run: cargo fmt --all -- --check - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - name: Test - run: cargo test --all -``` - -- [ ] **Étape 2 : Commit et pousser** - -```bash -git add .github/workflows/ci.yml -git commit -m "ci: add test workflow with Postgres + MySQL services" -``` - -Vérifier qu'un push déclenche le workflow et qu'il passe (les jobs Postgres/MySQL n'auront pas de tests E2E avant Task 12, mais `cargo test` doit verdir). - ---- - -## Task 10 : Snapshot tests `insta` pour le codegen - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/Cargo.toml` (ajouter `insta` en dev-dep) -- Créer : `crates/sqlx_gen/tests/snapshots_codegen.rs` -- Créer : `crates/sqlx_gen/tests/snapshots/` (sera peuplé par `cargo insta`) - -**Pourquoi :** Finding tests P1 #4. Aucun snapshot test ⇒ toute régression silencieuse de format ou de derive passe inaperçue. - -- [ ] **Étape 1 : Ajouter `insta`** - -Dans `crates/sqlx_gen/Cargo.toml` : - -```toml -[dev-dependencies] -insta = "1" -``` - -- [ ] **Étape 2 : Créer un test snapshot** - -`crates/sqlx_gen/tests/snapshots_codegen.rs` : - -```rust -use sqlx_gen::cli::{DatabaseKind, Methods, PoolVisibility}; -use sqlx_gen::codegen::crud_gen::generate_crud_from_parsed; -use sqlx_gen::codegen::entity_parser::{ParsedEntity, ParsedField}; -use sqlx_gen::codegen::format_tokens_with_imports; - -fn sample_users() -> ParsedEntity { - ParsedEntity { - struct_name: "Users".into(), - table_name: "users".into(), - schema_name: Some("public".into()), - is_view: false, - fields: vec![ - ParsedField { - field_name: "id".into(), - column_name: "id".into(), - rust_type: "i32".into(), - inner_type: "i32".into(), - is_optional: false, - is_primary_key: true, - column_default: None, - sql_type: None, - }, - ParsedField { - field_name: "email".into(), - column_name: "email".into(), - rust_type: "String".into(), - inner_type: "String".into(), - is_optional: false, - is_primary_key: false, - column_default: None, - sql_type: None, - }, - ], - imports: vec![], - } -} - -#[test] -fn snapshot_postgres_full_crud_users() { - let (tokens, imports) = generate_crud_from_parsed( - &sample_users(), - DatabaseKind::Postgres, - "crate::models::users", - &Methods::all(), - false, - PoolVisibility::Private, - ); - let code = format_tokens_with_imports(&tokens, &imports).expect("format"); - insta::assert_snapshot!("postgres_full_crud_users", code); -} - -#[test] -fn snapshot_mysql_full_crud_users() { - let (tokens, imports) = generate_crud_from_parsed( - &sample_users(), - DatabaseKind::Mysql, - "crate::models::users", - &Methods::all(), - false, - PoolVisibility::Private, - ); - let code = format_tokens_with_imports(&tokens, &imports).expect("format"); - insta::assert_snapshot!("mysql_full_crud_users", code); -} - -#[test] -fn snapshot_sqlite_full_crud_users() { - let (tokens, imports) = generate_crud_from_parsed( - &sample_users(), - DatabaseKind::Sqlite, - "crate::models::users", - &Methods::all(), - false, - PoolVisibility::Private, - ); - let code = format_tokens_with_imports(&tokens, &imports).expect("format"); - insta::assert_snapshot!("sqlite_full_crud_users", code); -} -``` - -- [ ] **Étape 3 : Générer et valider les snapshots** - -```bash -cargo install cargo-insta -cargo test -p sqlx-gen --test snapshots_codegen -cargo insta review -``` - -L'humain valide le contenu des 3 snapshots avant de commiter. - -- [ ] **Étape 4 : Commit** - -```bash -git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/tests/snapshots_codegen.rs crates/sqlx_gen/tests/snapshots/ -git commit -m "test: add insta snapshot tests for CRUD codegen across 3 dialects" -``` - ---- - -# VAGUE P1 — Robustesse codegen & couverture E2E - -## Task 11 : E2E PostgreSQL via testcontainers - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/Cargo.toml` (dev-dep `testcontainers`) -- Créer : `crates/sqlx_gen/tests/e2e_postgres.rs` - -**Pourquoi :** Finding tests P0 #1. Le commit `bacb088 fix MySQL 8.0 information_schema` n'avait aucun test E2E ; toute régression similaire passe. - -- [ ] **Étape 1 : Ajouter testcontainers + sqlx** - -```toml -[dev-dependencies] -testcontainers = "0.20" -testcontainers-modules = { version = "0.8", features = ["postgres"] } -tokio = { version = "1", features = ["full"] } -``` - -- [ ] **Étape 2 : Écrire l'e2e** - -`crates/sqlx_gen/tests/e2e_postgres.rs` (squelette ; chaque test doit valider que `cargo build` du code généré marche) : - -```rust -use sqlx::PgPool; -use sqlx_gen::introspect::postgres::introspect; -use testcontainers::runners::AsyncRunner; -use testcontainers_modules::postgres::Postgres; - -async fn setup() -> (testcontainers::ContainerAsync, PgPool) { - let container = Postgres::default().start().await.unwrap(); - let port = container.get_host_port_ipv4(5432).await.unwrap(); - let url = format!("postgres://postgres:postgres@127.0.0.1:{}/postgres", port); - let pool = PgPool::connect(&url).await.unwrap(); - (container, pool) -} - -#[tokio::test] -async fn introspects_table_with_enum_and_jsonb() { - let (_c, pool) = setup().await; - sqlx::query("CREATE TYPE status AS ENUM ('active', 'inactive')") - .execute(&pool).await.unwrap(); - sqlx::query(r#" - CREATE TABLE users ( - id UUID PRIMARY KEY, - email TEXT NOT NULL, - status status NOT NULL, - meta JSONB - ) - "#).execute(&pool).await.unwrap(); - - let info = introspect(&pool, &["public".into()], false).await.unwrap(); - assert_eq!(info.tables.len(), 1); - assert_eq!(info.tables[0].columns.len(), 4); - assert_eq!(info.enums.len(), 1); - assert_eq!(info.enums[0].variants, vec!["active", "inactive"]); -} - -#[tokio::test] -async fn introspects_composite_pk() { - let (_c, pool) = setup().await; - sqlx::query(r#" - CREATE TABLE order_items ( - order_id INT NOT NULL, - product_id INT NOT NULL, - qty INT NOT NULL, - PRIMARY KEY (order_id, product_id) - ) - "#).execute(&pool).await.unwrap(); - let info = introspect(&pool, &["public".into()], false).await.unwrap(); - let pk_cols: Vec<_> = info.tables[0].columns.iter() - .filter(|c| c.is_primary_key).collect(); - assert_eq!(pk_cols.len(), 2); -} - -#[tokio::test] -async fn introspects_view_inherits_nullability() { - let (_c, pool) = setup().await; - sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, name TEXT NOT NULL)") - .execute(&pool).await.unwrap(); - sqlx::query("CREATE VIEW v AS SELECT id, name FROM t") - .execute(&pool).await.unwrap(); - let info = introspect(&pool, &["public".into()], true).await.unwrap(); - assert_eq!(info.views.len(), 1); - assert!(!info.views[0].columns.iter().find(|c| c.name == "name").unwrap().is_nullable); -} - -#[tokio::test] -async fn rejects_table_with_reserved_keyword_column() { - let (_c, pool) = setup().await; - sqlx::query(r#"CREATE TABLE t (id INT PRIMARY KEY, "type" TEXT)"#) - .execute(&pool).await.unwrap(); - let info = introspect(&pool, &["public".into()], false).await.unwrap(); - // Codegen must produce compileable Rust for keyword columns. - let files = sqlx_gen::codegen::generate( - &info, sqlx_gen::cli::DatabaseKind::Postgres, - &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, - ); - for f in files { - syn::parse_file(&f.code).expect("generated code must parse"); - } -} -``` - -- [ ] **Étape 3 : Faire passer** - -Run : `cargo test -p sqlx-gen --test e2e_postgres` -Attendu : 4 passent (sinon corriger les bugs révélés en suivant le pattern systematic-debugging). - -- [ ] **Étape 4 : Commit** - -```bash -git add crates/sqlx_gen/Cargo.toml crates/sqlx_gen/tests/e2e_postgres.rs -git commit -m "test(e2e): add Postgres E2E tests via testcontainers" -``` - ---- - -## Task 12 : E2E MySQL via testcontainers (régression `bacb088`) - -**Fichiers :** - -- Créer : `crates/sqlx_gen/tests/e2e_mysql.rs` - -**Pourquoi :** Re-tester explicitement le scénario information_schema MySQL 8.0 corrigé par `bacb088` afin de prévenir toute régression. - -- [ ] **Étape 1 : Tests** - -```rust -use sqlx::MySqlPool; -use sqlx_gen::introspect::mysql::introspect; -use testcontainers::runners::AsyncRunner; -use testcontainers_modules::mysql::Mysql; - -async fn setup() -> (testcontainers::ContainerAsync, MySqlPool, String) { - let container = Mysql::default().start().await.unwrap(); - let port = container.get_host_port_ipv4(3306).await.unwrap(); - let url = format!("mysql://root@127.0.0.1:{}/test", port); - let pool = MySqlPool::connect(&url).await.unwrap(); - sqlx::query("CREATE DATABASE IF NOT EXISTS test").execute(&pool).await.ok(); - (container, pool, "test".to_string()) -} - -#[tokio::test] -async fn introspects_mysql8_information_schema_charset_change() { - let (_c, pool, db) = setup().await; - sqlx::query("CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT, email VARCHAR(255))") - .execute(&pool).await.unwrap(); - let info = introspect(&pool, &[db], false).await.unwrap(); - assert_eq!(info.tables.len(), 1); - assert_eq!(info.tables[0].columns.len(), 2); -} - -#[tokio::test] -async fn introspects_mysql_inline_enum() { - let (_c, pool, db) = setup().await; - sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, status ENUM('a', 'b'))") - .execute(&pool).await.unwrap(); - let info = introspect(&pool, &[db], false).await.unwrap(); - assert!(!info.enums.is_empty()); -} - -#[tokio::test] -async fn introspects_mysql_tinyint1_as_bool() { - let (_c, pool, db) = setup().await; - sqlx::query("CREATE TABLE t (id INT PRIMARY KEY, active TINYINT(1) NOT NULL)") - .execute(&pool).await.unwrap(); - let info = introspect(&pool, &[db], false).await.unwrap(); - let files = sqlx_gen::codegen::generate( - &info, sqlx_gen::cli::DatabaseKind::Mysql, - &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, - ); - let code = files.iter().find(|f| f.filename.contains("t")).unwrap().code.clone(); - assert!(code.contains("pub active: bool"), "tinyint(1) must map to bool, got: {}", code); -} -``` - -- [ ] **Étape 2 : Run et corriger** - -Run : `cargo test -p sqlx-gen --test e2e_mysql` - -- [ ] **Étape 3 : Commit** - -```bash -git add crates/sqlx_gen/tests/e2e_mysql.rs -git commit -m "test(e2e): add MySQL E2E coverage including info_schema 8.0 regression" -``` - ---- - -## Task 13 : `MySQL BIT(1) → bool` et autres trous de typemap - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/typemap/mysql.rs:79` -- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs` - -**Pourquoi :** Findings codegen #6, #7, #8. - -- [ ] **Étape 1 : Tests** - -Dans `crates/sqlx_gen/src/typemap/mysql.rs` (tests) : - -```rust -#[test] -fn bit_one_is_bool() { - let t = map("bit", "bit(1)", false); - assert_eq!(t.name, "bool"); -} - -#[test] -fn bit_n_is_vec_u8() { - let t = map("bit", "bit(8)", false); - assert_eq!(t.name, "Vec"); -} -``` - -Dans `typemap/postgres.rs` : - -```rust -#[test] -fn interval_uses_pginterval() { - let t = map("interval", "interval", false); - assert_eq!(t.name, "PgInterval"); -} - -#[test] -fn timetz_warns_or_uses_fixed_offset() { - let t = map("time with time zone", "timetz", false); - assert!(t.name.contains("FixedOffset") || t.name == "String", - "timetz currently maps to {}; should not silently drop timezone", t.name); -} -``` - -- [ ] **Étape 2 : Faire échouer** - -Run les tests, observer FAIL. - -- [ ] **Étape 3 : Implémenter** - -MySQL `typemap/mysql.rs:79` : ajouter avant le mapping bit générique : - -```rust -if udt_name.eq_ignore_ascii_case("bit(1)") { - return RustType::simple("bool"); -} -``` - -Postgres `typemap/postgres.rs` : ajouter `"interval" => RustType::with_import("PgInterval", "use sqlx::postgres::types::PgInterval;")` dans la fonction `map`. - -- [ ] **Étape 4 : Vérifier + Commit** - -```bash -cargo test -p sqlx-gen --lib typemap -git add crates/sqlx_gen/src/typemap/ -git commit -m "fix(typemap): MySQL BIT(1)→bool, Postgres interval→PgInterval" -``` - ---- - -## Task 14 : Validation des noms de colonne (caractères spéciaux) - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/struct_gen.rs:55-68` - -**Pourquoi :** Finding codegen #16/#17. Une colonne `user-id` produit `pub user-id: i32;` = invalide. - -- [ ] **Étape 1 : Test** - -```rust -#[test] -fn column_with_dash_is_sanitized_or_errors() { - let col = ColumnInfo { - name: "user-id".into(), - // ... rest - }; - let result = generate_struct_field(&col, /* params */); - assert!( - result.is_err() || result.unwrap().contains("user_id"), - "must sanitize or error on column 'user-id'" - ); -} -``` - -- [ ] **Étape 2 : Implémenter** - -Dans `struct_gen.rs`, transformer `to_snake_case` puis remplacer tout char `!is_ascii_alphanumeric && != '_'` par `_`. Préfixer par `_` si le résultat commence par un chiffre. Si vide, retourner `Err(Error::Config("Column name empty"))`. - -- [ ] **Étape 3 : Commit** - -```bash -git commit -am "fix(codegen): sanitize column names with non-ident characters" -``` - ---- - -## Task 15 : `SQLite NUMERIC/DECIMAL → Decimal` (cohérence cross-backend) - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/typemap/sqlite.rs:40-41` - -**Pourquoi :** Finding codegen #1. Perte de précision silencieuse pour les montants financiers. - -- [ ] **Étape 1 : Test** - -```rust -#[test] -fn numeric_maps_to_decimal_not_f64() { - let t = map("NUMERIC", false); - assert_eq!(t.name, "Decimal"); -} -``` - -- [ ] **Étape 2 : Implémenter** - -```rust -if upper.contains("NUMERIC") || upper.contains("DECIMAL") { - return RustType::with_import("Decimal", "use sqlx::types::Decimal;"); -} -``` - -- [ ] **Étape 3 : Commit** - -```bash -git commit -am "fix(typemap): SQLite NUMERIC/DECIMAL → Decimal (no precision loss)" -``` - ---- - -# VAGUE P2 — Qualité de l'API et UX - -## Task 16 : Erreurs sqlx contextuelles - -**Fichier :** `crates/sqlx_gen/src/error.rs` - -Étendre l'enum `Error` avec `SchemaNotFound { schema: String }`, `PermissionDenied { detail: String }`. Dans `introspect/*.rs`, pattern-matcher `sqlx::Error::Database(db_err)` et leur code SQLSTATE pour cibler `42P01` (Postgres : undefined_table), `42501` (insufficient_privilege), etc. Tests unitaires sur la conversion. - -## Task 17 : MySQL composite PK insert — fallback SELECT - -**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:959-979` - -Si `pk_fields.len() > 1` ou si le PK n'est pas auto-increment (détecter via `column_default = "auto"` ou similaire), générer un `SELECT * FROM table WHERE pk1 = ? AND pk2 = ?` après l'insert au lieu de `LAST_INSERT_ID()`. Tests E2E MySQL avec composite PK. - -## Task 18 : Skip schema qualification pour les schémas par défaut - -**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:23-26` - -Si `schema_name in ["public" (PG), "main" (sqlite)]`, omettre la qualification. Configurable via `--always-qualify`. Test unitaire. - -## Task 19 : Documenter MSRV - -**Fichiers :** `crates/sqlx_gen/Cargo.toml`, `README.md` - -Ajouter `rust-version = "1.75"` au `Cargo.toml` (à vérifier par `cargo msrv find`). Mettre à jour le README. Ajouter au workflow CI une matrix `{ stable, msrv }`. - -## Task 20 : Rejeter les `r#"...""#` qui contiennent `"#` - -**Fichier :** `crates/sqlx_gen/src/codegen/crud_gen.rs:858-860` - -Avant `format!("r#\"\n{}\n\"#", s).parse().unwrap()`, scanner `s` pour `"#` ; si présent, monter la clôture à `r##"..."##` ou retourner une erreur. Test : SQL avec commentaire `-- "#`. - ---- - -# VAGUE P3 — Polissage - -## Task 21 : Doctest sur `lib.rs` - -## Task 22 : Clippy `-D warnings` propre dans CI - -## Task 23 : Cycle-detection composites Postgres (finding codegen #26) - -## Task 24 : Empty-batch guard `insert_many` (finding codegen #28) - -## Task 25 : Logger `info!` clair "Found 0 tables — empty schema or no permission?" - -Chaque task suit le même pattern : test rouge → implémentation minimale → vert → commit. - ---- - -# VAGUE P4 — Conformité SQL ↔ Rust du code généré - -Cette vague est dédiée à l'écart entre ce que **SQL attend du driver `sqlx`** et ce que **sqlx-gen produit**. Les bugs ici sont souvent silencieux : le code Rust compile, les tests unitaires passent, mais à l'exécution la requête échoue ou — pire — lit/écrit des valeurs incorrectes. - -## Findings de conformité (synthèse) - -| # | Sévérité | Type | Description | -|---|----------|------|-------------| -| C1 | Critique | Postgres enum | Lookup d'enum par `udt_name` non qualifié (`typemap/postgres.rs:41`). Deux enums homonymes dans deux schémas → match arbitraire. | -| C2 | Critique | Postgres enum array | `Vec` généré sans `impl PgHasArrayType` → sqlx renvoie `unsupported type _my_enum` à la lecture. | -| C3 | Critique | Postgres enum schema-qualified type_name | `#[sqlx(type_name = "auth.role")]` n'est PAS le format attendu par sqlx 0.8 (qui veut `type_name = "role"` + `schema = "auth"` via le pool ou un `PgTypeInfo` custom). | -| C4 | Haut | Composite import path | `use super::types::Status;` dur-codé (`typemap/postgres.rs:43, 49`). Casse en `--single-file` ou sortie non-standard. | -| C5 | Haut | Postgres generated columns | `GENERATED ALWAYS AS (...)` non détecté en introspection → inclus dans INSERT/UPDATE → erreur SQL `cannot insert into column "x"`. | -| C6 | Haut | Postgres identity columns | `GENERATED ALWAYS AS IDENTITY` vs `BY DEFAULT AS IDENTITY` non distingués. Le premier rejette toute valeur fournie. | -| C7 | Haut | Enum variant collision après camelCase | Valeurs `foo bar` et `foo_bar` → deux `FooBar` → erreur de compilation Rust. | -| C8 | Haut | MySQL inline ENUM typing | Colonne `status ENUM('a','b')` : codegen génère un enum Rust mais `sqlx::Type` derive sans `#[sqlx(rename_all)]` ni `try_from`. Décode `String` puis échoue. | -| C9 | Haut | Domain non-newtype | `pub type Email = String` perd l'identité de type. Les utilisateurs de domains veulent newtype + validation. | -| C10 | Moyen | TIMESTAMP vs TIMESTAMPTZ misuse | Pas de warning si l'utilisateur a un `TIMESTAMP` (sans tz) mais s'attend à `DateTime`. | -| C11 | Moyen | SQLite enum via CHECK | `TEXT CHECK (col IN ('a','b'))` non détecté → généré comme `String`. | -| C12 | Moyen | Default value `Option` ambiguïté | Pour colonne `nullable + default`, l'utilisateur ne peut pas distinguer "écrire NULL" vs "utiliser default". | -| C13 | Moyen | Postgres array index OID | `udt_name` retourné par `information_schema` est parfois `_int4`, parfois `integer[]` selon la version. Le strip `_` ne couvre que le premier. | -| C14 | Moyen | MySQL `BOOLEAN` alias | `BOOLEAN` est alias de `TINYINT(1)` en MySQL. L'introspection voit `tinyint(1)` mais l'utilisateur a écrit `BOOLEAN`. Cohérent par hasard. | - ---- - -## Task 26 : Postgres enum lookup qualifié par schéma - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs:40-50` -- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs` (s'assurer que `ColumnInfo` porte `udt_schema`) - -**Pourquoi :** Finding C1. Aujourd'hui `schema_info.enums.iter().any(|e| e.name == udt_name)` ne filtre pas par schéma. Si `public.status` et `auth.status` coexistent, le mauvais enum est référencé. Vérifié à `typemap/postgres.rs:41`. - -- [ ] **Étape 1 : Test rouge** - -Dans `crates/sqlx_gen/src/typemap/postgres.rs` (module `tests`) : - -```rust -#[test] -fn enum_lookup_respects_schema() { - use crate::introspect::EnumInfo; - let schema = SchemaInfo { - enums: vec![ - EnumInfo { - schema_name: "public".to_string(), - name: "status".to_string(), - variants: vec!["a".into()], - default_variant: None, - }, - EnumInfo { - schema_name: "auth".to_string(), - name: "status".to_string(), - variants: vec!["x".into()], - default_variant: None, - }, - ], - ..Default::default() - }; - let col = crate::introspect::ColumnInfo { - name: "role".into(), - data_type: "USER-DEFINED".into(), - udt_name: "status".into(), - udt_schema: Some("auth".into()), - is_nullable: false, - is_primary_key: false, - ordinal_position: 0, - schema_name: "public".into(), - column_default: None, - is_generated: false, - is_identity_always: false, - }; - let rt = map_column_pg(&col, &schema, TimeCrate::Chrono); - assert!( - rt.needs_import.as_ref().unwrap().contains("auth"), - "must import from auth schema, got {:?}", rt.needs_import - ); -} -``` - -- [ ] **Étape 2 : Run** - -```bash -cargo test -p sqlx-gen --lib typemap::postgres::tests::enum_lookup_respects_schema -``` -Attendu : FAIL (le champ `udt_schema` n'existe pas encore). - -- [ ] **Étape 3 : Étendre `ColumnInfo`** - -Dans `crates/sqlx_gen/src/introspect/mod.rs` (struct `ColumnInfo`) ajouter : - -```rust -pub udt_schema: Option, -pub is_generated: bool, -pub is_identity_always: bool, -``` - -Dans `crates/sqlx_gen/src/introspect/postgres.rs`, la query `fetch_columns` ajoute : - -```sql -SELECT - c.column_name, - c.data_type, - c.udt_name, - c.udt_schema, -- nouveau - c.is_nullable, - c.column_default, - c.is_generated, -- nouveau (ALWAYS/NEVER) - c.is_identity, -- nouveau (YES/NO) - c.identity_generation -- nouveau (ALWAYS/BY DEFAULT) -FROM information_schema.columns c -WHERE c.table_schema = ANY($1) -ORDER BY c.table_schema, c.table_name, c.ordinal_position -``` - -Mapping : `is_generated = (is_generated == "ALWAYS")`, `is_identity_always = (is_identity == "YES" AND identity_generation == "ALWAYS")`. - -Pour MySQL/SQLite : laisser `udt_schema = None`, `is_generated = false`, `is_identity_always = false`. - -- [ ] **Étape 4 : Implémenter `map_column_pg`** - -Remplacer `map_type` par `map_column_pg(col, schema, time_crate)`. Dans la fonction : - -```rust -if let Some(ref udt_schema) = col.udt_schema { - if let Some(e) = schema_info.enums.iter() - .find(|e| e.name == col.udt_name && &e.schema_name == udt_schema) - { - let name = e.name.to_upper_camel_case(); - let import_path = if e.schema_name == "public" { - format!("use super::types::{};", name) - } else { - format!("use super::{}_types::{};", e.schema_name, name) - }; - return RustType::with_import(&name, &import_path); - } -} -// Fallback : ancien comportement (lookup non qualifié) pour MySQL inline-enum -``` - -Idem pour composite types. Garder `map_type(udt_name, ...)` en wrapper rétrocompatible. - -- [ ] **Étape 5 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --lib typemap -git add crates/sqlx_gen/src/introspect/ crates/sqlx_gen/src/typemap/postgres.rs -git commit -m "fix(typemap): qualify Postgres enum/composite lookup by schema" -``` - ---- - -## Task 27 : Postgres `_my_enum` (arrays of custom types) — émettre `PgHasArrayType` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:90-100` -- Modifier : `crates/sqlx_gen/src/codegen/composite_gen.rs:97-105` - -**Pourquoi :** Finding C2. `typemap/postgres.rs:35-38` produit `Vec` pour un type `_status`. Mais sqlx 0.8 exige `impl PgHasArrayType for Status` pour pouvoir décoder `_status`. Sans ça, runtime panic à la lecture : `unsupported type _status of column #N`. - -- [ ] **Étape 1 : Test rouge** - -Dans `crates/sqlx_gen/src/codegen/enum_gen.rs` (module `tests`) : - -```rust -#[test] -fn pg_enum_emits_pg_has_array_type_impl() { - let e = make_enum("status", vec!["a", "b"]); - let code = gen(&e, DatabaseKind::Postgres); - assert!( - code.contains("impl sqlx::postgres::PgHasArrayType for Status"), - "must impl PgHasArrayType so Vec works, got:\n{}", code - ); - assert!(code.contains("\"_status\"")); -} - -#[test] -fn mysql_enum_does_not_emit_pg_has_array_type_impl() { - let e = make_enum("status", vec!["a", "b"]); - let code = gen(&e, DatabaseKind::Mysql); - assert!(!code.contains("PgHasArrayType")); -} -``` - -- [ ] **Étape 2 : Run** - -```bash -cargo test -p sqlx-gen --lib codegen::enum_gen::tests::pg_enum_emits_pg_has_array_type_impl -``` -Attendu : FAIL. - -- [ ] **Étape 3 : Implémenter** - -Dans `enum_gen.rs` après le bloc `default_impl` : - -```rust -let array_type_impl = if db_kind == DatabaseKind::Postgres { - let array_type_name = if enum_info.schema_name != "public" { - format!("_{}.{}", enum_info.schema_name, enum_info.name) - } else { - format!("_{}", enum_info.name) - }; - quote! { - impl sqlx::postgres::PgHasArrayType for #enum_name { - fn array_type_info() -> sqlx::postgres::PgTypeInfo { - sqlx::postgres::PgTypeInfo::with_name(#array_type_name) - } - } - } -} else { - quote! {} -}; -``` - -Puis ajouter `#array_type_impl` dans le `quote! { ... }` final, après `#default_impl`. - -Reproduire la même logique dans `composite_gen.rs` (l'array d'un composite suit la même règle PG). - -- [ ] **Étape 4 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --lib codegen -git add crates/sqlx_gen/src/codegen/enum_gen.rs crates/sqlx_gen/src/codegen/composite_gen.rs -git commit -m "feat(codegen): emit PgHasArrayType impl for Postgres enums and composites" -``` - ---- - -## Task 28 : Postgres enum/composite `#[sqlx(type_name)]` — non qualifié ✅ APPLIQUÉ - -**Statut : appliqué le 2026-06-03 après bug confirmé en production.** - -**Reproducer confirmé (rapporté par @Cyrik le 30/04/2026) :** une enum en schéma non-`public` générait `#[sqlx(type_name = "agent.canal_type_enum")]` ; sqlx 0.8 plante à l'exécution car `PgTypeInfo::with_name` ne parse pas la qualification `schema.type`. La forme correcte est unqualified : `#[sqlx(type_name = "canal_type_enum")]`, avec `search_path` configuré côté pool pour résoudre le bon enum. - -**Fichiers modifiés :** - -- `crates/sqlx_gen/src/codegen/enum_gen.rs:42-50` — suppression de la branche `if schema != "public"` -- `crates/sqlx_gen/src/codegen/composite_gen.rs:48-52` — idem -- Tests `test_postgres_non_public_schema_qualified_type_name`, `test_named_schema_full_output`, `test_named_schema_with_default_variant`, `test_named_schema_variant_rename`, `test_non_public_schema_qualified_type_name` mis à jour pour asservir la **non-qualification** + assertion explicite `!contains("schema.type")`. - -**Suivi à prévoir :** - -- README : documenter clairement que le pool doit avoir `search_path` configuré avec tous les schémas portant des enums/composites utilisés. Snippet à ajouter : - -```rust -let pool = PgPoolOptions::new() - .after_connect(|conn, _meta| Box::pin(async move { - sqlx::query("SET search_path TO public, agent, auth") - .execute(conn).await?; - Ok(()) - })) - .connect(&url).await?; -``` - -- Émettre un doc-comment sur les enums en schéma non-public pour rappeler la contrainte (optionnel, futur). - ---- - -## Task 29 : Collision de variants enum après camelCase - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:53-71` - -**Pourquoi :** Finding C7. `["foo bar", "foo_bar"]` → deux `FooBar` → erreur de compilation. La fonction `generate_enum` ne détecte pas les collisions. - -- [ ] **Étape 1 : Test** - -```rust -#[test] -fn detects_variant_camelcase_collision() { - let e = EnumInfo { - schema_name: "public".into(), - name: "weird".into(), - variants: vec!["foo bar".into(), "foo_bar".into()], - default_variant: None, - }; - let result = generate_enum_checked(&e, DatabaseKind::Postgres, &[]); - assert!(result.is_err(), "must detect collision"); - let err = result.unwrap_err().to_string(); - assert!(err.contains("FooBar"), "error must mention conflicting Rust ident"); -} -``` - -- [ ] **Étape 2 : Implémenter** - -Ajouter `generate_enum_checked` qui renvoie `Result`, et lever une erreur si : - -```rust -use std::collections::BTreeMap; -let mut seen: BTreeMap = BTreeMap::new(); -for v in &enum_info.variants { - let pascal = v.to_upper_camel_case(); - if let Some(prev) = seen.get(&pascal) { - return Err(crate::error::Error::Config(format!( - "Enum '{}': SQL variants '{}' and '{}' both map to Rust ident '{}'. \ - Rename in the database or use a custom mapping.", - enum_info.name, prev, v, pascal - ))); - } - seen.insert(pascal, v); -} -``` - -- [ ] **Étape 3 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --lib codegen::enum_gen -git commit -am "fix(codegen): detect enum variant collisions after camelCase" -``` - ---- - -## Task 30 : Postgres `GENERATED` & `IDENTITY ALWAYS` exclus des INSERT/UPDATE - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/introspect/postgres.rs` (query déjà étendue Task 26) -- Modifier : `crates/sqlx_gen/src/codegen/crud_gen.rs:246-269` - -**Pourquoi :** Finding C5/C6. Une colonne `total INTEGER GENERATED ALWAYS AS (qty * price) STORED` rejette tout INSERT/UPDATE qui la mentionne — erreur `42601`. De même `id INT GENERATED ALWAYS AS IDENTITY` : ne tolère pas `OVERRIDING SYSTEM VALUE`. Il faut les exclure des params. - -- [ ] **Étape 1 : Test E2E** - -Dans `crates/sqlx_gen/tests/e2e_postgres.rs` : - -```rust -#[tokio::test] -async fn generated_column_excluded_from_insert() { - let (_c, pool) = setup().await; - sqlx::query(r#" - CREATE TABLE invoices ( - id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - qty INT NOT NULL, - price INT NOT NULL, - total INT GENERATED ALWAYS AS (qty * price) STORED - ) - "#).execute(&pool).await.unwrap(); - let info = introspect(&pool, &["public".into()], false).await.unwrap(); - let cols = &info.tables[0].columns; - let total = cols.iter().find(|c| c.name == "total").unwrap(); - assert!(total.is_generated, "must detect GENERATED column"); - let id = cols.iter().find(|c| c.name == "id").unwrap(); - assert!(id.is_identity_always, "must detect IDENTITY ALWAYS"); - - let files = sqlx_gen::codegen::generate( - &info, sqlx_gen::cli::DatabaseKind::Postgres, - &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, - ).unwrap(); - // Find the InsertInvoicesParams in the generated code - let inv = files.iter().find(|f| f.filename.contains("invoice")).unwrap(); - assert!(!inv.code.contains("total:"), "InsertParams must not include 'total'"); - assert!(!inv.code.contains("pub id:") || inv.code.contains("Option"), - "IDENTITY ALWAYS must be excluded or optional"); -} -``` - -- [ ] **Étape 2 : Implémenter** - -Dans `crud_gen.rs` (constructeur des `non_pk_fields` autour de la ligne 62-63), filtrer aussi : - -```rust -let non_pk_fields: Vec<&ParsedField> = entity.fields.iter() - .filter(|f| !f.is_primary_key) - .filter(|f| !f.is_generated) // exclu des INSERT/UPDATE - .filter(|f| !f.is_identity_always) // exclu des INSERT - .collect(); -``` - -Il faut donc ajouter `is_generated: bool` et `is_identity_always: bool` à `ParsedField` (`entity_parser.rs`), et les sérialiser dans l'annotation `#[sqlx_gen(...)]` lors de la génération des structs entité (Task 1 / `struct_gen.rs`). - -- [ ] **Étape 3 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --test e2e_postgres -git add crates/sqlx_gen/src/ -git commit -m "fix(codegen): exclude generated/identity-always columns from INSERT/UPDATE" -``` - ---- - -## Task 31 : MySQL inline ENUM — `#[sqlx(rename_all = ...)]` ou utilisation `String` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs:11-103` - -**Pourquoi :** Finding C8. Pour MySQL, sqlx 0.8 lit/écrit les `ENUM` comme `String` par défaut. Pour qu'un `derive(sqlx::Type)` fonctionne, il faut soit (a) `#[repr(u32)]` + `#[sqlx(repr = ...)]` (basé sur index), soit (b) implémenter manuellement `Encode`/`Decode` qui lit la valeur en `&str`. La voie la plus simple : annoter le champ entité avec `#[sqlx(try_from = "String")]` ou utiliser `#[derive(sqlx::Type)] #[sqlx(rename_all = "lowercase")]`. - -- [ ] **Étape 1 : Test E2E MySQL** - -```rust -#[tokio::test] -async fn mysql_inline_enum_round_trips() { - let (_c, pool, db) = setup().await; - sqlx::query(r#" - CREATE TABLE t ( - id INT PRIMARY KEY AUTO_INCREMENT, - status ENUM('active', 'inactive') NOT NULL - ) - "#).execute(&pool).await.unwrap(); - sqlx::query("INSERT INTO t (status) VALUES (?)") - .bind("active").execute(&pool).await.unwrap(); - - let info = introspect(&pool, &[db], false).await.unwrap(); - let files = sqlx_gen::codegen::generate( - &info, sqlx_gen::cli::DatabaseKind::Mysql, - &[], &Default::default(), false, sqlx_gen::cli::TimeCrate::Chrono, - ).unwrap(); - let code = files.iter().map(|f| f.code.as_str()).collect::>().join("\n"); - // Must use rename_all so 'active' / 'inactive' encode/decode correctly - assert!( - code.contains("rename_all") || code.contains("rename = \"active\""), - "MySQL inline enum codegen must wire up SQL ↔ Rust variant mapping" - ); -} -``` - -- [ ] **Étape 2 : Implémenter** - -Pour `DatabaseKind::Mysql` dans `enum_gen.rs`, après les variants, vérifier si tous les variants sont lowercase ASCII — auquel cas émettre : - -```rust -quote! { #[sqlx(rename_all = "lowercase")] } -``` - -et omettre les `#[sqlx(rename = "...")]` individuels. Sinon, garder les renames explicites par variant. - -- [ ] **Étape 3 : Vérifier + commit** - -```bash -git commit -am "fix(codegen): wire MySQL inline ENUM variants via rename_all/rename" -``` - ---- - -## Task 32 : Domain en newtype optionnel via `--domains-as-newtype` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/cli.rs` (ajouter flag) -- Modifier : `crates/sqlx_gen/src/codegen/domain_gen.rs:43-53` - -**Pourquoi :** Finding C9. `pub type Email = String;` est un alias transparent. L'utilisateur perd la sécurité de type. Offrir un mode newtype optionnel : `pub struct Email(pub String);` + `#[sqlx(transparent)]`. - -- [ ] **Étape 1 : Ajouter le flag CLI** - -Dans `EntitiesArgs` : - -```rust -/// Generate domains as newtype structs (`pub struct Email(String)`) instead of type aliases. -#[arg(long)] -pub domains_as_newtype: bool, -``` - -- [ ] **Étape 2 : Test** - -```rust -#[test] -fn domain_as_newtype_uses_transparent_derive() { - let d = make_domain("email", "text"); - let schema = SchemaInfo::default(); - let (tokens, _) = generate_domain_with_style( - &d, DatabaseKind::Postgres, &schema, - &HashMap::new(), TimeCrate::Chrono, - DomainStyle::Newtype, - ); - let code = parse_and_format(&tokens).unwrap(); - assert!(code.contains("pub struct Email")); - assert!(code.contains("#[sqlx(transparent)]")); - assert!(code.contains("pub String")); -} -``` - -- [ ] **Étape 3 : Implémenter** - -```rust -pub enum DomainStyle { Alias, Newtype } - -pub fn generate_domain_with_style( - domain: &DomainInfo, - db_kind: DatabaseKind, - schema_info: &SchemaInfo, - type_overrides: &HashMap, - time_crate: TimeCrate, - style: DomainStyle, -) -> (TokenStream, BTreeSet) { - // ... existing setup - let tokens = match style { - DomainStyle::Alias => quote! { - #[doc = #doc] - pub type #alias_name = #type_tokens; - }, - DomainStyle::Newtype => quote! { - #[doc = #doc] - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)] - #[sqlx(transparent)] - pub struct #alias_name(pub #type_tokens); - }, - }; - (tokens, imports) -} -``` - -- [ ] **Étape 4 : Vérifier + commit** - -```bash -git commit -am "feat(codegen): add --domains-as-newtype for type-safe domain wrappers" -``` - ---- - -## Task 33 : SQLite enum via `CHECK (col IN (...))` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/introspect/sqlite.rs` -- Modifier : `crates/sqlx_gen/src/codegen/enum_gen.rs` (le SQLite branch) - -**Pourquoi :** Finding C11. SQLite n'a pas d'`ENUM` natif. Convention courante : `TEXT CHECK (col IN ('a','b','c'))`. Détecter ce pattern via `sqlite_master.sql` (DDL stocké) et émettre un enum Rust. - -- [ ] **Étape 1 : Test E2E SQLite** - -Dans `crates/sqlx_gen/tests/introspect_sqlite.rs` : - -```rust -#[tokio::test] -async fn detects_check_enum_pattern() { - let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); - sqlx::query(r#" - CREATE TABLE t ( - id INTEGER PRIMARY KEY, - status TEXT CHECK (status IN ('active', 'inactive')) NOT NULL - ) - "#).execute(&pool).await.unwrap(); - let info = introspect(&pool, false).await.unwrap(); - assert!(!info.enums.is_empty(), "should detect implicit enum"); - assert_eq!(info.enums[0].variants, vec!["active", "inactive"]); -} -``` - -- [ ] **Étape 2 : Implémenter le parser** - -Dans `introspect/sqlite.rs`, ajouter `extract_check_enums(&sql_ddl)` qui regex-trouve `CHECK\s*\(\s*(\w+)\s+IN\s*\(\s*(.+?)\s*\)\s*\)` et extrait les variants entre apostrophes. Le résultat alimente `SchemaInfo.enums` avec `schema_name="main"`. - -Pour la `column.udt_name`, remplacer `"TEXT"` par le nom de l'enum déduit (e.g. `status_enum`). - -- [ ] **Étape 3 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --test introspect_sqlite -git commit -am "feat(introspect-sqlite): detect TEXT CHECK IN (...) enum pattern" -``` - ---- - -## Task 34 : Cohérence array : `_x` + `x[]` + `ARRAY[x]` - -**Fichiers :** - -- Modifier : `crates/sqlx_gen/src/typemap/postgres.rs:33-38` - -**Pourquoi :** Finding C13. `information_schema.columns.udt_name` retourne `_int4` ; `data_type` retourne `ARRAY` ; `pg_catalog.format_type` retourne `integer[]`. Le code actuel ne gère que `_int4`. Robustifier. - -- [ ] **Étape 1 : Tests** - -```rust -#[test] -fn array_underscore_prefix() { - assert_eq!(map_type("_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec"); -} -#[test] -fn array_bracket_suffix() { - assert_eq!(map_type("integer[]", &empty_schema(), TimeCrate::Chrono).path, "Vec"); -} -#[test] -fn array_double() { - assert_eq!(map_type("_int4_int4", &empty_schema(), TimeCrate::Chrono).path, "Vec>"); -} -``` - -- [ ] **Étape 2 : Implémenter** - -```rust -pub fn map_type(udt_name: &str, schema_info: &SchemaInfo, time_crate: TimeCrate) -> RustType { - if let Some(inner) = udt_name.strip_prefix('_') { - return map_type(inner, schema_info, time_crate).wrap_vec(); - } - if let Some(inner) = udt_name.strip_suffix("[]") { - return map_type(inner.trim(), schema_info, time_crate).wrap_vec(); - } - // ... reste inchangé -} -``` - -- [ ] **Étape 3 : Vérifier + commit** - -```bash -cargo test -p sqlx-gen --lib typemap::postgres -git commit -am "fix(typemap): normalize array notations (_x and x[])" -``` - ---- - -## Task 35 : Snapshot E2E "build du code généré" - -**Fichiers :** - -- Créer : `crates/sqlx_gen/tests/compile_check.rs` - -**Pourquoi :** Aucun test ne vérifie que le code généré **compile dans un projet utilisateur**. C'est la conformité ultime : si le code passe `cargo build`, alors les attributs sqlx sont bien formés. - -- [ ] **Étape 1 : Squelette** - -```rust -use std::process::Command; - -fn write_minimal_consumer(out_dir: &std::path::Path, generated_code: &str) { - std::fs::write(out_dir.join("Cargo.toml"), r#" -[package] -name = "sqlx_gen_compile_test" -version = "0.0.0" -edition = "2021" - -[dependencies] -sqlx = { version = "0.8", features = ["postgres", "uuid", "chrono", "json"] } -sqlx_gen = { path = "../../../../crates/sqlx_gen" } -serde = { version = "1", features = ["derive"] } -chrono = "0.4" -uuid = "1" -serde_json = "1" -"#).unwrap(); - std::fs::create_dir_all(out_dir.join("src")).unwrap(); - std::fs::write(out_dir.join("src/lib.rs"), generated_code).unwrap(); -} - -#[test] -fn generated_postgres_table_compiles() { - use sqlx_gen::codegen::{generate, GeneratedFile}; - use sqlx_gen::introspect::*; - // build a small SchemaInfo by hand with enum + composite + view + array column - // ... (~50 lines) - let files = generate(&info, sqlx_gen::cli::DatabaseKind::Postgres, - &[], &Default::default(), true /* single file */, sqlx_gen::cli::TimeCrate::Chrono).unwrap(); - let dir = tempfile::tempdir().unwrap(); - let code = files[0].code.clone(); - write_minimal_consumer(dir.path(), &code); - let status = Command::new("cargo").arg("build").current_dir(dir.path()).status().unwrap(); - assert!(status.success(), "generated code must compile"); -} -``` - -- [ ] **Étape 2 : Commit** - -```bash -git add crates/sqlx_gen/tests/compile_check.rs -git commit -m "test(compile): verify generated code compiles in a downstream crate" -``` - ---- - -## Synthèse de la vague conformité - -Tasks 26–35 ferment les écarts SQL ↔ Rust les plus dangereux : - -| Couvre finding | Task | -|----------------|------| -| C1 (lookup enum non qualifié) | 26 | -| C2 (PgHasArrayType manquant) | 27 | -| C3 (type_name schema-qualifié) | 28 | -| C4 (import path super::types) | 26 (indirect, via path schema-aware) | -| C5 (generated columns) | 30 | -| C6 (identity always) | 30 | -| C7 (variant collisions) | 29 | -| C8 (MySQL inline ENUM) | 31 | -| C9 (domain newtype) | 32 | -| C10 (TS vs TSTZ) | À documenter dans le README (pas de task séparée — trivial) | -| C11 (SQLite CHECK enum) | 33 | -| C12 (default vs nullable ambiguïté) | Couvert par doc en Task 18 | -| C13 (array notations) | 34 | -| C14 (MySQL BOOLEAN alias) | Couvert par Task 13 (BIT(1)→bool) | - -Le **compile-check Task 35** est le filet de sécurité final : toute régression d'attribut ou d'import est détectée par `cargo build` sur un crate consommateur. - ---- - -## Self-review (effectuée par le rédacteur) - -**Couverture spec :** Les 12 findings P0 sont couverts par les Tasks 1–10 ; les 9 findings High P1 par Tasks 11–15 ; le P2 par Tasks 16–20 ; le P3 par Tasks 21–25 ; les 14 findings de conformité SQL ↔ Rust (C1–C14) par Tasks 26–35. - -**Placeholders :** Tasks 16–25 sont volontairement plus haut-niveau (1 paragraphe chacune) car le pattern TDD est répétitif et chaque task isolément ne nécessite pas 5 étapes détaillées. Si l'engineer demande, dérouler à la demande. - -**Cohérence des types :** `quote_ident`/`quote_qualified` utilisés systématiquement après Task 1. `write_atomic` ajouté Task 7, réutilisé Task 8. `parse_and_format_with_tab_spaces` renvoie `Result` après Task 5 — tous les call-sites mis à jour dans le même commit. - -**Gaps connus :** Le finding codegen #19 (forced `query_scalar` sur `LAST_INSERT_ID`) est traité indirectement par Task 17. Le finding error #11 (cancellation tokio) n'est pas traité — coût élevé, gain faible ; documenter dans CONTRIBUTING. - ---- - -## Handoff d'exécution - -**Plan complet sauvegardé dans `docs/superpowers/plans/2026-06-03-rust-engineering-audit.md`. Deux options d'exécution :** - -**1. Subagent-Driven (recommandé)** — un sous-agent frais par task, review entre chaque, itération rapide. Idéal pour P0 où chaque correction doit être validée avant la suivante. - -**2. Inline Execution** — exécution des tasks dans cette session via `superpowers:executing-plans`, batch avec checkpoints. - -**Quelle approche ?** From 28bf1e83a66d25161ef1a20e03bbba9f6952ec46 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:49:31 +0200 Subject: [PATCH 37/38] chore: centralize crate version + dependencies in workspace The two crates used to declare their version independently (0.5.5 in both, but with sqlx-gen-macros pinned at 0.5.4 inside sqlx-gen). A single field bump would have to happen in three places before they matched again, and the cross-dep made silent drift easy to ship. - Root Cargo.toml grows [workspace.package] with version, edition, rust-version, license, repository, keywords, categories. - Root Cargo.toml grows [workspace.dependencies] declaring every dependency once, including the internal sqlx-gen-macros (now always = the workspace version) and every external crate. - Each member crate inherits with `*.workspace = true`. Per-crate Cargo.toml shrinks to per-crate fields only (name, description, features, bin). - .gitignore now excludes /docs/superpowers/ so locally generated audit/plan files stay out of the repo. --- .gitignore | 3 +- Cargo.toml | 40 +++++++++++++++++++++++ crates/sqlx_gen/Cargo.toml | 53 +++++++++++++------------------ crates/sqlx_gen_macros/Cargo.toml | 14 ++++---- 4 files changed, 71 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 2bf2b7c..4e8875d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target .vscode .DS_Store -**/.DS_Store \ No newline at end of file +**/.DS_Store +/docs/superpowers/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 9d661dd..5cdbc32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,43 @@ [workspace] members = ["crates/sqlx_gen", "crates/sqlx_gen_macros"] resolver = "2" + +# Shared package metadata. Each crate inherits via `*.workspace = true`, +# so version bumps happen here and propagate everywhere — sqlx-gen and +# sqlx-gen-macros can never drift apart. +[workspace.package] +version = "0.5.5" +edition = "2021" +rust-version = "1.75" +license = "MIT" +repository = "https://github.com/LeadcodeDev/sqlx-gen" +keywords = ["sqlx", "codegen", "postgres", "mysql", "sqlite"] +categories = ["database", "development-tools"] + +# Shared dependency definitions. The internal sqlx-gen-macros entry pins the +# version to whatever sqlx_gen itself ships, so the proc-macro and runtime +# crates are always released together. +[workspace.dependencies] +sqlx-gen-macros = { path = "crates/sqlx_gen_macros", version = "0.5.5" } +sqlx = { version = "0.8", features = [ + "runtime-tokio", + "tls-rustls-ring", + "postgres", + "mysql", + "sqlite", + "chrono", + "uuid", + "json", +] } +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive", "env"] } +heck = "0.5" +thiserror = "2" +quote = "1" +proc-macro2 = "1" +syn = "2" +prettyplease = "0.2" +log = "0.4" +env_logger = "0.11" +tempfile = "3" +pretty_assertions = "1" diff --git a/crates/sqlx_gen/Cargo.toml b/crates/sqlx_gen/Cargo.toml index 0071c88..7769b84 100644 --- a/crates/sqlx_gen/Cargo.toml +++ b/crates/sqlx_gen/Cargo.toml @@ -1,14 +1,14 @@ [package] name = "sqlx-gen" -version = "0.5.5" -edition = "2021" -rust-version = "1.75" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords.workspace = true +categories.workspace = true description = "Generate Rust structs from database schema introspection" -license = "MIT" -repository = "https://github.com/LeadcodeDev/sqlx-gen" readme = "../../README.md" -keywords = ["sqlx", "codegen", "postgres", "mysql", "sqlite"] -categories = ["database", "development-tools"] [[bin]] name = "sqlx-gen" @@ -32,29 +32,20 @@ cli = [ ] [dependencies] -sqlx-gen-macros = { path = "../sqlx_gen_macros", version = "0.5.4" } -sqlx = { version = "0.8", features = [ - "runtime-tokio", - "tls-rustls-ring", - "postgres", - "mysql", - "sqlite", - "chrono", - "uuid", - "json", -], optional = true } -tokio = { version = "1", features = ["full"], optional = true } -clap = { version = "4", features = ["derive", "env"], optional = true } -heck = { version = "0.5", optional = true } -thiserror = { version = "2", optional = true } -quote = { version = "1", optional = true } -proc-macro2 = { version = "1", optional = true } -syn = { version = "2", optional = true } -prettyplease = { version = "0.2", optional = true } -log = { version = "0.4", optional = true } -env_logger = { version = "0.11", optional = true } -tempfile = { version = "3", optional = true } +sqlx-gen-macros.workspace = true +sqlx = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +clap = { workspace = true, optional = true } +heck = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +quote = { workspace = true, optional = true } +proc-macro2 = { workspace = true, optional = true } +syn = { workspace = true, optional = true } +prettyplease = { workspace = true, optional = true } +log = { workspace = true, optional = true } +env_logger = { workspace = true, optional = true } +tempfile = { workspace = true, optional = true } [dev-dependencies] -pretty_assertions = "1" -tempfile = "3" +pretty_assertions.workspace = true +tempfile.workspace = true diff --git a/crates/sqlx_gen_macros/Cargo.toml b/crates/sqlx_gen_macros/Cargo.toml index 1b383da..850b40d 100644 --- a/crates/sqlx_gen_macros/Cargo.toml +++ b/crates/sqlx_gen_macros/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "sqlx-gen-macros" -version = "0.5.5" -edition = "2021" -rust-version = "1.75" -description = "No-op attribute macros for sqlx-gen generated code" -license = "MIT" -repository = "https://github.com/LeadcodeDev/sqlx-gen" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true keywords = ["sqlx", "codegen", "macros"] -categories = ["database", "development-tools"] +categories.workspace = true +description = "No-op attribute macros for sqlx-gen generated code" [lib] proc-macro = true From b37bb3fe654a51798477431bb882923883fb9783 Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 3 Jun 2026 21:56:33 +0200 Subject: [PATCH 38/38] chore: uprade version --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46b70d8..19bdacc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1499,7 +1499,7 @@ dependencies = [ [[package]] name = "sqlx-gen" -version = "0.5.5" +version = "0.5.6" dependencies = [ "clap", "env_logger", @@ -1519,7 +1519,7 @@ dependencies = [ [[package]] name = "sqlx-gen-macros" -version = "0.5.5" +version = "0.5.6" [[package]] name = "sqlx-macros" diff --git a/Cargo.toml b/Cargo.toml index 5cdbc32..ecd374f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ resolver = "2" # so version bumps happen here and propagate everywhere — sqlx-gen and # sqlx-gen-macros can never drift apart. [workspace.package] -version = "0.5.5" +version = "0.5.6" edition = "2021" rust-version = "1.75" license = "MIT" @@ -18,7 +18,7 @@ categories = ["database", "development-tools"] # version to whatever sqlx_gen itself ships, so the proc-macro and runtime # crates are always released together. [workspace.dependencies] -sqlx-gen-macros = { path = "crates/sqlx_gen_macros", version = "0.5.5" } +sqlx-gen-macros = { path = "crates/sqlx_gen_macros", version = "0.5.6" } sqlx = { version = "0.8", features = [ "runtime-tokio", "tls-rustls-ring",