From 82737304f0f6bbf7411ebd39979c6f05845ec7ae Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:01:45 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(naming):=20to=5Fcamel=5Fcase=C2=B7infe?= =?UTF-8?q?r=5Frelation=5Ffield=5Fname=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/vespertide-naming/src/lib.rs | 63 +++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/vespertide-naming/src/lib.rs b/crates/vespertide-naming/src/lib.rs index 492427f8..2915de26 100644 --- a/crates/vespertide-naming/src/lib.rs +++ b/crates/vespertide-naming/src/lib.rs @@ -170,6 +170,53 @@ pub fn to_pascal_case(s: &str) -> String { result } +/// Convert a database name into `camelCase`. +/// +/// Same word-splitting rule as [`to_pascal_case`] — `_` and `-` both separate — +/// with the first character lowered. TypeScript exporters use it for the +/// `const` bindings and object keys they generate. +/// +/// Case conversion only: the result can still be an invalid identifier (`1st` +/// keeps its leading digit, and a reserved word stays reserved). Pass it +/// through [`sanitize_identifier`] before emitting it. +/// +/// # Examples +/// ``` +/// use vespertide_naming::to_camel_case; +/// +/// assert_eq!(to_camel_case("user_profiles"), "userProfiles"); +/// assert_eq!(to_camel_case("order-status"), "orderStatus"); +/// assert_eq!(to_camel_case("id"), "id"); +/// assert_eq!(to_camel_case("1st_place"), "1stPlace"); +/// ``` +pub fn to_camel_case(s: &str) -> String { + let mut pascal = to_pascal_case(s); + if let Some(first) = pascal.chars().next() { + let lowered: String = first.to_lowercase().collect(); + pascal.replace_range(..first.len_utf8(), &lowered); + } + pascal +} + +/// Infer a relation field's base name from its foreign-key column. +/// +/// Strips one trailing `_id`, the conventional FK-column suffix — `user_id` +/// names its relation `user`. A column without the suffix passes through +/// unchanged; the caller decides how to disambiguate it from the column's own +/// field (each ORM exporter has its own collision rule). +/// +/// # Examples +/// ``` +/// use vespertide_naming::infer_relation_field_name; +/// +/// assert_eq!(infer_relation_field_name("user_id"), "user"); +/// assert_eq!(infer_relation_field_name("owner"), "owner"); +/// assert_eq!(infer_relation_field_name("id"), "id"); +/// ``` +pub fn infer_relation_field_name(fk_col: &str) -> &str { + fk_col.strip_suffix("_id").unwrap_or(fk_col) +} + /// Convert an arbitrary schema value into `SCREAMING_SNAKE_CASE`. /// /// Word boundaries are detected on the lower→upper transition rather than on @@ -703,6 +750,22 @@ mod tests { assert_eq!(pluralize("address"), "address"); } + /// `to_camel_case` splits on the same separators as `to_pascal_case` and + /// lowers only the first character, so an already-camel or all-caps name + /// keeps its interior casing. + #[rstest] + #[case::snake("user_profiles", "userProfiles")] + #[case::hyphen("order-status", "orderStatus")] + #[case::single_word("id", "id")] + #[case::already_camel("orderStatus", "orderStatus")] + #[case::leading_digit("1st_place", "1stPlace")] + #[case::empty("", "")] + #[case::trailing_separator("status_", "status")] + #[case::unicode("café_category", "caféCategory")] + fn to_camel_case_lowers_first_character(#[case] input: &str, #[case] expected: &str) { + assert_eq!(to_camel_case(input), expected); + } + // ======================================================================== // Constraint Naming Tests // ======================================================================== From 1745b740774439dd2cbbc96798137116f2807918 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:01:45 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(exporter):=20enum=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=C2=B7PK=20=EC=A1=B0=ED=9A=8C=C2=B7=EA=B4=80=EA=B3=84?= =?UTF-8?q?=20=EB=84=A4=EC=9D=B4=EB=B0=8D=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20Prisma=EA=B0=80=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/constraint_scan.rs | 152 ++++++++++++++- crates/vespertide-exporter/src/enum_scan.rs | 26 +++ crates/vespertide-exporter/src/jpa/render.rs | 14 +- crates/vespertide-exporter/src/lib.rs | 1 + .../vespertide-exporter/src/prisma/enums.rs | 35 ++-- .../vespertide-exporter/src/prisma/render.rs | 181 +----------------- .../vespertide-exporter/src/utils/common.rs | 36 ++++ 7 files changed, 236 insertions(+), 209 deletions(-) create mode 100644 crates/vespertide-exporter/src/enum_scan.rs diff --git a/crates/vespertide-exporter/src/constraint_scan.rs b/crates/vespertide-exporter/src/constraint_scan.rs index fec30e0c..0eacc4cf 100644 --- a/crates/vespertide-exporter/src/constraint_scan.rs +++ b/crates/vespertide-exporter/src/constraint_scan.rs @@ -3,12 +3,13 @@ //! Every backend needs the same lookup sets when rendering a table: //! the columns covered by table-level primary keys, the columns that //! carry a single-column unique constraint, and the columns that carry -//! a single-column index. Centralising the scans keeps the four -//! renderers from drifting apart. +//! a single-column index. Centralising the scans keeps the renderers +//! from drifting apart. use std::collections::{HashMap, HashSet}; -use vespertide_core::{ColumnName, TableConstraint}; +use vespertide_core::{ColumnName, TableConstraint, TableDef}; +use vespertide_naming::{infer_relation_field_name, to_pascal_case}; /// Collect the column names from every single-column constraint that `extract` /// matches. Shared body for [`single_column_uniques`] and @@ -30,6 +31,13 @@ fn single_column_scan<'a>( cols } +/// The table's `PrimaryKey` constraint, if it declares one. +pub(crate) fn primary_key(constraints: &[TableConstraint]) -> Option<&TableConstraint> { + constraints + .iter() + .find(|c| matches!(c, TableConstraint::PrimaryKey { .. })) +} + /// Collect the column names covered by table-level `PrimaryKey` constraints. /// /// Lookup-only, ordering unused. @@ -92,3 +100,141 @@ pub(crate) fn single_column_fk_targets( } map } + +/// Name segment a relation derives from its FK columns. +/// +/// Every column takes part — two composite FKs to the same target can share a +/// first column, and a segment built from that column alone would hand both +/// relations one name. +pub(crate) fn relation_segment(columns: &[ColumnName]) -> String { + columns + .iter() + .map(|col| infer_relation_field_name(col.as_str())) + .collect::>() + .join("_") +} + +/// Relation name for every FK of `table`, keyed by constraint index. +/// +/// Both ends of a relation must carry the same name, so the forward side and +/// each backend's back-relation collector derive it from the same table +/// through this one function. Distinct columns can still strip to one segment +/// (`a_id` and `a` both become `a`), and both Prisma and Drizzle reject two +/// same-named relations between one model pair — repeats within a target's +/// group get numbered in constraint order, which is the one order both ends +/// share. +pub(crate) fn fk_relation_names(table: &TableDef) -> HashMap { + let table_pascal = to_pascal_case(&table.name); + let mut used_per_target: HashMap<(&str, String), usize> = HashMap::new(); + let mut names = HashMap::new(); + for (idx, c) in table.constraints.iter().enumerate() { + let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + else { + continue; + }; + let base = format!( + "{table_pascal}{}", + to_pascal_case(&relation_segment(columns)) + ); + let seen = used_per_target + .entry((ref_table.as_str(), base.clone())) + .or_insert(0); + *seen += 1; + let name = if *seen == 1 { + base + } else { + format!("{base}{seen}") + }; + names.insert(idx, name); + } + names +} + +/// One table's reverse view of a foreign key another table points at it with. +/// +/// Field naming and rendering differ per backend, but the scan — which FKs +/// target this table, whether each is one-to-one, and the shared relation +/// name both ends must agree on — is the same everywhere. +pub(crate) struct BackRelation { + pub(crate) source_table: String, + pub(crate) rel_segment: String, + pub(crate) is_one_to_one: bool, + pub(crate) relation_name: Option, +} + +/// Reverse relations for `target_table`: one entry per FK any table in +/// `schema` (including itself) points at it with. +pub(crate) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> Vec { + let mut result = Vec::new(); + + for source in schema { + let fks_to_target: Vec<(usize, &[ColumnName])> = source + .constraints + .iter() + .enumerate() + .filter_map(|(idx, c)| { + if let TableConstraint::ForeignKey { + columns, ref_table, .. + } = c + { + if ref_table.as_str() == target_table { + Some((idx, columns.as_slice())) + } else { + None + } + } else { + None + } + }) + .collect(); + + if fks_to_target.is_empty() { + continue; + } + + let source_relation_names = fk_relation_names(source); + let multi_fk = fks_to_target.len() > 1; + let is_self_ref = source.name.as_str() == target_table; + + for (constraint_idx, fk_cols) in &fks_to_target { + let is_one_to_one = if let [fk_col] = fk_cols { + source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == 1 && columns[0] == *fk_col) + }) + } else { + // A composite FK is one-to-one when the source can hold at + // most one row per target key: its FK columns are exactly its + // own PK, or a composite unique covers exactly that set. + let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); + let pk_cols = primary_key(&source.constraints) + .map(TableConstraint::columns) + .unwrap_or_default(); + pk_cols.len() == fk_set.len() && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) + || source.constraints.iter().any(|c| { + matches!(c, TableConstraint::Unique { columns, .. } + if columns.len() == fk_set.len() + && columns.iter().all(|col| fk_set.contains(col.as_str()))) + }) + }; + + let rel_segment = relation_segment(fk_cols); + let relation_name = if multi_fk || is_self_ref { + source_relation_names.get(constraint_idx).cloned() + } else { + None + }; + + result.push(BackRelation { + source_table: source.name.as_str().to_string(), + rel_segment, + is_one_to_one, + relation_name, + }); + } + } + + result +} diff --git a/crates/vespertide-exporter/src/enum_scan.rs b/crates/vespertide-exporter/src/enum_scan.rs new file mode 100644 index 00000000..461b88c1 --- /dev/null +++ b/crates/vespertide-exporter/src/enum_scan.rs @@ -0,0 +1,26 @@ +//! Shared enum-column scan for the single-file ORM renderers. +//! +//! Backends that write one file per table get enum scoping for free; Prisma +//! and Drizzle emit one file for the whole schema and both start from the same +//! per-table scan. What they do with it differs — Prisma deduplicates +//! identifiers globally (see `prisma::enums`), Drizzle table-prefixes every +//! type — so only the scan itself lives here. + +use vespertide_core::TableDef; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; + +use std::collections::HashSet; + +/// Enum columns of a table, first declaration winning per name. +pub(crate) fn collect_table_enums(table: &TableDef) -> Vec<(&str, &EnumValues)> { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for col in &table.columns { + if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type + && seen.insert(name.as_str()) + { + result.push((name.as_str(), values)); + } + } + result +} diff --git a/crates/vespertide-exporter/src/jpa/render.rs b/crates/vespertide-exporter/src/jpa/render.rs index 36476506..be5c9663 100644 --- a/crates/vespertide-exporter/src/jpa/render.rs +++ b/crates/vespertide-exporter/src/jpa/render.rs @@ -9,7 +9,7 @@ use vespertide_core::{ColumnDef, TableDef}; use crate::jpa::types::{UsedImports, java_type_for_column}; use crate::utils::common::{push_attr, unquote}; -use vespertide_naming::{IdentifierStart, sanitize_identifier}; +use vespertide_naming::{IdentifierStart, infer_relation_field_name, sanitize_identifier}; pub(super) fn render_entity_inner(table: &TableDef) -> String { let mut lines: Vec = Vec::new(); @@ -470,12 +470,13 @@ fn build_default_initializer(col: &ColumnDef) -> Option { pub(super) use crate::python_naming::to_pascal_case; +/// Not `vespertide_naming::to_camel_case`: that one builds on the naming +/// crate's PascalCase (splits on `-` too, ASCII-only uppercasing), while JPA +/// shares `python_naming`'s semantics (`_`-only split, Unicode-aware) with the +/// Python backends — see `to_pascal_case_shared_semantics` in the cross-ORM +/// tests for the documented divergence. pub(super) fn to_camel_case(s: &str) -> String { let mut pascal = to_pascal_case(s); - // Lowercase only the leading character in place, preserving the exact - // `char::to_lowercase()` semantics of the previous implementation (Unicode - // multi-char lowercase mappings included) without the extra - // `chars.collect::()` + `format!` allocations. let Some(first) = pascal.chars().next() else { return pascal; }; @@ -489,6 +490,5 @@ pub(super) fn to_camel_case(s: &str) -> String { } pub(super) fn infer_fk_field_name(column_name: &str) -> String { - let base = column_name.strip_suffix("_id").unwrap_or(column_name); - to_camel_case(base) + to_camel_case(infer_relation_field_name(column_name)) } diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 71b6a2d3..968a920b 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -2,6 +2,7 @@ //! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, and Prisma. mod constraint_scan; +mod enum_scan; pub mod jpa; pub mod orm; mod parallel_config; diff --git a/crates/vespertide-exporter/src/prisma/enums.rs b/crates/vespertide-exporter/src/prisma/enums.rs index 3a390364..85ed3402 100644 --- a/crates/vespertide-exporter/src/prisma/enums.rs +++ b/crates/vespertide-exporter/src/prisma/enums.rs @@ -1,32 +1,33 @@ use std::collections::{HashMap, HashSet}; use vespertide_core::TableDef; -use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; +use vespertide_core::schema::column::EnumValues; use vespertide_naming::{ IdentifierStart, build_enum_type_name, sanitize_identifier, to_pascal_case, to_screaming_snake_case, }; +pub(super) use crate::enum_scan::collect_table_enums; + /// Bare `PascalCase` identifiers that the schema declares with more than one /// value set. /// -/// Every other backend writes one file per table, so a repeated enum is -/// naturally scoped; Prisma emits a single file, where reusing an identifier -/// would silently give both tables the first table's values. Names are compared -/// *after* the case conversion, since distinct names can collapse onto the same -/// identifier (`doc_status` and `docStatus` are both `DocStatus`). +/// Prisma's single file would silently give both tables the first declaration's +/// values if it reused such an identifier. Names are compared *after* the case +/// conversion, since distinct names can collapse onto the same identifier +/// (`doc_status` and `docStatus` are both `DocStatus`). pub(super) fn ambiguous_enum_identifiers(schema: &[TableDef]) -> HashSet { let mut declared: HashMap = HashMap::new(); let mut ambiguous = HashSet::new(); for table in schema { for (name, values) in collect_table_enums(table) { - let identifier = to_pascal_case(name); - if let Some(first) = declared.get(&identifier) { + let ident = to_pascal_case(name); + if let Some(first) = declared.get(&ident) { if *first != values { - ambiguous.insert(identifier); + ambiguous.insert(ident); } } else { - declared.insert(identifier, values); + declared.insert(ident, values); } } } @@ -48,20 +49,6 @@ pub(super) fn enum_identifier(table: &str, name: &str, ambiguous: &HashSet Vec<(&str, &EnumValues)> { - let mut seen = HashSet::new(); - let mut result = Vec::new(); - for col in &table.columns { - if let ColumnType::Complex(ComplexColumnType::Enum { name, values }) = &col.r#type - && seen.insert(name.as_str()) - { - result.push((name.as_str(), values)); - } - } - result -} - /// Prisma identifier for one enum variant. /// /// Prisma's parser rejects a leading `_`, so a value that starts with a digit is diff --git a/crates/vespertide-exporter/src/prisma/render.rs b/crates/vespertide-exporter/src/prisma/render.rs index a3c942a2..0eb2f812 100644 --- a/crates/vespertide-exporter/src/prisma/render.rs +++ b/crates/vespertide-exporter/src/prisma/render.rs @@ -6,26 +6,14 @@ use vespertide_core::schema::constraint::TableConstraint; use vespertide_core::schema::names::ColumnName; use vespertide_core::schema::reference::ReferenceAction; use vespertide_naming::{ - IdentifierStart, build_index_name, build_unique_constraint_name, sanitize_identifier, - to_pascal_case, + IdentifierStart, build_index_name, build_unique_constraint_name, infer_relation_field_name, + sanitize_identifier, to_pascal_case, }; use super::enums::enum_variant; use super::types::column_type_to_prisma; -use crate::utils::common::unquote; - -fn primary_key(constraints: &[TableConstraint]) -> Option<&TableConstraint> { - constraints - .iter() - .find(|c| matches!(c, TableConstraint::PrimaryKey { .. })) -} - -pub(super) struct BackRelation { - pub(super) source_table: String, - pub(super) rel_segment: String, - pub(super) is_one_to_one: bool, - pub(super) relation_name: Option, -} +use crate::constraint_scan::{BackRelation, collect_back_relations, fk_relation_names}; +use crate::utils::common::{claim_field_name, unquote}; pub(super) fn back_rel_field(br: &BackRelation) -> (String, String) { let source_pascal = prisma_model_name(&br.source_table); @@ -46,79 +34,6 @@ pub(super) fn back_rel_field(br: &BackRelation) -> (String, String) { (field_name, rel_type) } -pub(super) fn collect_back_relations(target_table: &str, schema: &[TableDef]) -> Vec { - let mut result = Vec::new(); - - for source in schema { - let fks_to_target: Vec<(usize, &[ColumnName])> = source - .constraints - .iter() - .enumerate() - .filter_map(|(idx, c)| { - if let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - { - if ref_table.as_str() == target_table { - Some((idx, columns.as_slice())) - } else { - None - } - } else { - None - } - }) - .collect(); - - if fks_to_target.is_empty() { - continue; - } - - let source_relation_names = fk_relation_names(source); - let multi_fk = fks_to_target.len() > 1; - let is_self_ref = source.name.as_str() == target_table; - - for (constraint_idx, fk_cols) in &fks_to_target { - let is_one_to_one = if let [fk_col] = fk_cols { - source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == 1 && columns[0] == *fk_col) - }) - } else { - // A composite FK is one-to-one when the source can hold at - // most one row per target key: its FK columns are exactly its - // own PK, or a composite unique covers exactly that set. - let fk_set: HashSet<&str> = fk_cols.iter().map(ColumnName::as_str).collect(); - let pk_cols = primary_key(&source.constraints) - .map(TableConstraint::columns) - .unwrap_or_default(); - pk_cols.len() == fk_set.len() && pk_cols.iter().all(|c| fk_set.contains(c.as_str())) - || source.constraints.iter().any(|c| { - matches!(c, TableConstraint::Unique { columns, .. } - if columns.len() == fk_set.len() - && columns.iter().all(|col| fk_set.contains(col.as_str()))) - }) - }; - - let rel_segment = relation_segment(fk_cols); - let relation_name = if multi_fk || is_self_ref { - source_relation_names.get(constraint_idx).cloned() - } else { - None - }; - - result.push(BackRelation { - source_table: source.name.as_str().to_string(), - rel_segment, - is_one_to_one, - relation_name, - }); - } - } - - result -} - pub(super) fn render_model( table: &TableDef, schema: &[TableDef], @@ -135,7 +50,7 @@ pub(super) fn render_model( let model_name = prisma_model_name(&table.name); lines.push(format!("model {model_name} {{")); - let pk = primary_key(&table.constraints); + let pk = crate::constraint_scan::primary_key(&table.constraints); let pk_cols = pk.map(TableConstraint::columns).unwrap_or_default(); let pk_auto_increment = matches!( pk, @@ -261,7 +176,7 @@ pub(super) fn render_model( } = fk { let rel_field_name = claim_field_name( - prisma_field_name(&infer_relation_field_name(db_name)), + prisma_field_name(infer_relation_field_name(db_name)), &mut field_names, ); let rel_model = prisma_model_name(ref_table); @@ -526,90 +441,6 @@ fn prisma_field_name(column: &str) -> String { sanitize_identifier(column, IdentifierStart::Letter) } -fn infer_relation_field_name(fk_col: &str) -> String { - fk_col.strip_suffix("_id").unwrap_or(fk_col).to_string() -} - -/// Name segment a relation derives from its FK columns. -/// -/// Every column takes part — two composite FKs to the same target can share a -/// first column, and a segment built from that column alone would hand both -/// relations one name. -fn relation_segment(columns: &[ColumnName]) -> String { - columns - .iter() - .map(|col| infer_relation_field_name(col.as_str())) - .collect::>() - .join("_") -} - -/// `@relation` name for every FK of `table`, keyed by constraint index. -/// -/// Both ends of a relation must carry the same name, so the forward side and -/// `collect_back_relations` derive it from the same table through this one -/// function. Distinct columns can still strip to one segment (`a_id` and `a` -/// both become `a`), and Prisma rejects two same-named relations between one -/// model pair — repeats within a target's group get numbered in constraint -/// order, which is the one order both ends share. -fn fk_relation_names(table: &TableDef) -> HashMap { - let table_pascal = to_pascal_case(&table.name); - let mut used_per_target: HashMap<(&str, String), usize> = HashMap::new(); - let mut names = HashMap::new(); - for (idx, c) in table.constraints.iter().enumerate() { - let TableConstraint::ForeignKey { - columns, ref_table, .. - } = c - else { - continue; - }; - let base = format!( - "{table_pascal}{}", - to_pascal_case(&relation_segment(columns)) - ); - let seen = used_per_target - .entry((ref_table.as_str(), base.clone())) - .or_insert(0); - *seen += 1; - let name = if *seen == 1 { - base - } else { - format!("{base}{seen}") - }; - names.insert(idx, name); - } - names -} - -/// Claim a model field name, recording it in `taken` so later fields avoid it. -fn claim_field_name(preferred: String, taken: &mut HashSet) -> String { - let chosen = first_unused(preferred, taken); - taken.insert(chosen.clone()); - chosen -} - -/// `preferred` if free, then `{preferred}_rel`, then numbered variants. `_rel` -/// comes before the numbers so the names already emitted for FK fields that -/// clash with their own column stay unchanged. -fn first_unused(preferred: String, taken: &HashSet) -> String { - if !taken.contains(&preferred) { - return preferred; - } - - let suffixed = format!("{preferred}_rel"); - if !taken.contains(&suffixed) { - return suffixed; - } - - let mut index = 2; - loop { - let candidate = format!("{preferred}_rel{index}"); - if !taken.contains(&candidate) { - return candidate; - } - index += 1; - } -} - #[cfg(test)] mod tests { use rstest::rstest; diff --git a/crates/vespertide-exporter/src/utils/common.rs b/crates/vespertide-exporter/src/utils/common.rs index f7c78e05..d3e50462 100644 --- a/crates/vespertide-exporter/src/utils/common.rs +++ b/crates/vespertide-exporter/src/utils/common.rs @@ -88,6 +88,42 @@ pub(crate) fn unquote(s: &str) -> &str { s } +/// Claim a relation field name, recording it in `taken` so later fields +/// avoid it. Seed `taken` with the table's column field names first — relation +/// names are derived from column/table names, so a relation must not take a +/// column's name (nor an earlier relation's). +pub(crate) fn claim_field_name( + preferred: String, + taken: &mut std::collections::HashSet, +) -> String { + let chosen = first_unused(preferred, taken); + taken.insert(chosen.clone()); + chosen +} + +/// `preferred` if free, then `{preferred}_rel`, then numbered variants. `_rel` +/// comes before the numbers so the names already emitted for FK fields that +/// clash with their own column stay unchanged. +fn first_unused(preferred: String, taken: &std::collections::HashSet) -> String { + if !taken.contains(&preferred) { + return preferred; + } + + let suffixed = format!("{preferred}_rel"); + if !taken.contains(&suffixed) { + return suffixed; + } + + let mut index = 2; + loop { + let candidate = format!("{preferred}_rel{index}"); + if !taken.contains(&candidate) { + return candidate; + } + index += 1; + } +} + #[cfg(test)] mod tests { use rstest::rstest; From 48c4cf5a785bf045422e19a3bf3138449afd94e5 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:01:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(cli):=20export=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=84=B0=EB=A6=AC=20=EC=A4=80=EB=B9=84=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9D=EC=9D=84=20=EA=B3=B5=EC=9A=A9=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vespertide-cli/src/commands/export/mod.rs | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index d0d0922f..5647a0df 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -41,13 +41,7 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { } // Clean the export directory before regenerating - clean_export_dir(&target_root, orm).await?; - - if !target_root.exists() { - fs::create_dir_all(&target_root) - .await - .with_context(|| format!("create export dir {}", target_root.display()))?; - } + prepare_export_dir(&target_root, orm).await?; // Extract all tables for schema context (used for FK chain resolution) let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); @@ -215,6 +209,19 @@ fn resolve_export_dir(export_dir: Option, config: &VespertideConfig) -> config.model_export_dir().to_path_buf() } +/// Clean stale output for `orm` and make sure the directory exists — the +/// shared preamble of every export path. +async fn prepare_export_dir(root: &Path, orm: Orm) -> Result<()> { + clean_export_dir(root, orm).await?; + + if !root.exists() { + fs::create_dir_all(root) + .await + .with_context(|| format!("create export dir {}", root.display()))?; + } + Ok(()) +} + /// Clean the export directory by removing all generated files. /// This ensures no stale files remain from previous exports. async fn clean_export_dir(root: &Path, orm: Orm) -> Result<()> { @@ -400,13 +407,7 @@ async fn cmd_export_prisma( let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); let content = prisma::render_schema(&all_tables); - clean_export_dir(&target_root, Orm::Prisma).await?; - - if !target_root.exists() { - fs::create_dir_all(&target_root) - .await - .with_context(|| format!("create export dir {}", target_root.display()))?; - } + prepare_export_dir(&target_root, Orm::Prisma).await?; // Not `schema.prisma`: that name belongs to the user's own file holding the // datasource/generator blocks. From 51d0fdcf956c883b0659458a7394d6fdb588e004 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:11:57 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat(exporter,cli):=20Drizzle=20ORM=20?= =?UTF-8?q?=EC=9D=B5=EC=8A=A4=ED=8F=AC=ED=84=B0=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=E2=80=94=20pg/mysql/sqlite=203=EB=B0=A9=EC=96=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 22 +- Cargo.toml | 2 +- .../vespertide-cli/src/commands/export/mod.rs | 44 +- crates/vespertide-exporter/Cargo.toml | 2 +- .../src/drizzle/bindings.rs | 229 +++++++ .../vespertide-exporter/src/drizzle/enums.rs | 45 ++ crates/vespertide-exporter/src/drizzle/mod.rs | 306 +++++++++ .../vespertide-exporter/src/drizzle/render.rs | 589 ++++++++++++++++++ .../vespertide-exporter/src/drizzle/types.rs | 343 ++++++++++ crates/vespertide-exporter/src/lib.rs | 4 +- crates/vespertide-exporter/src/orm.rs | 10 +- crates/vespertide-exporter/src/tests/mod.rs | 7 +- ...er_schema_full_file_per_dialect@mysql.snap | 70 +++ ...ender_schema_full_file_per_dialect@pg.snap | 72 +++ ...r_schema_full_file_per_dialect@sqlite.snap | 70 +++ ...pes_snapshot@all_simple_types_Drizzle.snap | 26 + ...e_pk_snapshot@basic_single_pk_Drizzle.snap | 9 + ...@basic_table_with_description_Drizzle.snap | 13 + ..._types_snapshot@complex_types_Drizzle.snap | 12 + ...napshot@composite_constraints_Drizzle.snap | 21 + ...napshot@composite_fk_relation_Drizzle.snap | 28 + ...ndex_snapshot@composite_index_Drizzle.snap | 12 + ...site_pk_snapshot@composite_pk_Drizzle.snap | 11 + ...napshot@composite_primary_key_Drizzle.snap | 12 + ...t@composite_unique_constraint_Drizzle.snap | 12 + ...que_snapshot@composite_unique_Drizzle.snap | 12 + ...s__defaults_snapshot@defaults_Drizzle.snap | 11 + ...napshot@enum_multiple_columns_Drizzle.snap | 14 + ...m_shared_snapshot@enum_shared_Drizzle.snap | 12 + ..._snapshot@enum_special_values_Drizzle.snap | 11 + ...lt_snapshot@enum_with_default_Drizzle.snap | 13 + ...napshot@false_boolean_default_Drizzle.snap | 9 + ..._names_collide_after_id_strip_Drizzle.snap | 28 + ...th_comment_and_auto_increment_Drizzle.snap | 16 + ..._inline_pk_snapshot@inline_pk_Drizzle.snap | 9 + ...nteger_enum_all_variant_types_Drizzle.snap | 9 + ...hot@integer_enum_with_default_Drizzle.snap | 9 + ...ger_enum_with_variant_default_Drizzle.snap | 9 + ...default_snapshot@json_default_Drizzle.snap | 9 + ...pe_snapshot@jsonb_custom_type_Drizzle.snap | 11 + ...ption_snapshot@no_description_Drizzle.snap | 8 + ...entifier_names_in_constraints_Drizzle.snap | 15 + ...snapshot@non_identifier_names_Drizzle.snap | 18 + ...non_identifier_relation_names_Drizzle.snap | 28 + ...mns_snapshot@nullable_columns_Drizzle.snap | 10 + ...e_enum_snapshot@nullable_enum_Drizzle.snap | 11 + ...napshot@numeric_default_value_Drizzle.snap | 9 + ...r_snapshot@pk_and_fk_together_Drizzle.snap | 24 + ...relation_name_taken_by_column_Drizzle.snap | 23 + ...ite_and_single_fk_same_target_Drizzle.snap | 19 + ...snapshots@composite_fk_parent_Drizzle.snap | 16 + ...pshots@dual_reverse_relations_Drizzle.snap | 13 + ...napshots@many_to_many_article_Drizzle.snap | 12 + ...s@many_to_many_missing_target_Drizzle.snap | 12 + ...ny_to_many_multiple_junctions_Drizzle.snap | 13 + ...a_snapshots@many_to_many_user_Drizzle.snap | 12 + ...pshots@multiple_fk_same_table_Drizzle.snap | 18 + ...ts@multiple_has_one_relations_Drizzle.snap | 13 + ...ts@multiple_reverse_relations_Drizzle.snap | 13 + ...junction_fk_not_in_pk_another_Drizzle.snap | 12 + ...t_junction_fk_not_in_pk_other_Drizzle.snap | 12 + ...pshots@not_junction_single_pk_Drizzle.snap | 12 + ...hots@triple_reverse_relations_Drizzle.snap | 14 + ..._schema_snapshots@username_fk_Drizzle.snap | 15 + ...hot@reserved_word_identifiers_Drizzle.snap | 10 + ..._snapshot@self_referencing_fk_Drizzle.snap | 16 + ...rver_default_and_true_boolean_Drizzle.snap | 12 + ...ults_snapshot@server_defaults_Drizzle.snap | 11 + ...small_multi_schema_sequential_Drizzle.snap | 25 + ...fault_snapshot@string_default_Drizzle.snap | 9 + ...el_pk_snapshot@table_level_pk_Drizzle.snap | 10 + ...eck_snapshot@table_with_check_Drizzle.snap | 11 + ...h_check_snapshot@table_with_check_Jpa.snap | 21 + ...heck_snapshot@table_with_check_Prisma.snap | 11 + ...heck_snapshot@table_with_check_SeaOrm.snap | 18 + ..._snapshot@table_with_check_SqlAlchemy.snap | 17 + ...ck_snapshot@table_with_check_SqlModel.snap | 16 + ...pshot@table_with_composite_fk_Drizzle.snap | 17 + ...enum_snapshot@table_with_enum_Drizzle.snap | 11 + ...ith_fk_snapshot@table_with_fk_Drizzle.snap | 16 + ...s_snapshot@table_with_indexes_Drizzle.snap | 13 + ...pshot@table_with_integer_enum_Drizzle.snap | 9 + ...d_snapshot@unique_and_indexed_Drizzle.snap | 14 + ...shot@unknown_constant_default_Drizzle.snap | 9 + ...shot@unknown_function_default_Drizzle.snap | 9 + ...pshot@unnamed_composite_index_Drizzle.snap | 12 + ...shot@unnamed_composite_unique_Drizzle.snap | 12 + ...shot@unnamed_index_and_unique_Drizzle.snap | 13 + .../vespertide-exporter/src/utils/common.rs | 31 + crates/vespertide-exporter/src/utils/mod.rs | 2 +- .../src/utils/typescript.rs | 143 +++++ .../tests/parallel_consolidated.rs | 1 + 92 files changed, 2985 insertions(+), 19 deletions(-) create mode 100644 crates/vespertide-exporter/src/drizzle/bindings.rs create mode 100644 crates/vespertide-exporter/src/drizzle/enums.rs create mode 100644 crates/vespertide-exporter/src/drizzle/mod.rs create mode 100644 crates/vespertide-exporter/src/drizzle/render.rs create mode 100644 crates/vespertide-exporter/src/drizzle/types.rs create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlModel.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/utils/typescript.rs diff --git a/Cargo.lock b/Cargo.lock index a3f57c06..598dfdd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2737,7 +2737,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vespertide" -version = "0.2.1" +version = "0.3.0" dependencies = [ "sea-orm", "tokio", @@ -2747,7 +2747,7 @@ dependencies = [ [[package]] name = "vespertide-cli" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "assert_cmd", @@ -2781,7 +2781,7 @@ dependencies = [ [[package]] name = "vespertide-config" -version = "0.2.1" +version = "0.3.0" dependencies = [ "clap", "schemars", @@ -2791,7 +2791,7 @@ dependencies = [ [[package]] name = "vespertide-core" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "proptest", @@ -2806,7 +2806,7 @@ dependencies = [ [[package]] name = "vespertide-exporter" -version = "0.2.1" +version = "0.4.0" dependencies = [ "clap", "criterion", @@ -2836,7 +2836,7 @@ dependencies = [ [[package]] name = "vespertide-loader" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "rayon", @@ -2852,7 +2852,7 @@ dependencies = [ [[package]] name = "vespertide-lsp" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "dashmap", @@ -2883,7 +2883,7 @@ dependencies = [ [[package]] name = "vespertide-macro" -version = "0.2.1" +version = "0.3.0" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -2903,7 +2903,7 @@ dependencies = [ [[package]] name = "vespertide-naming" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "proptest", @@ -2912,7 +2912,7 @@ dependencies = [ [[package]] name = "vespertide-planner" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "insta", @@ -2927,7 +2927,7 @@ dependencies = [ [[package]] name = "vespertide-query" -version = "0.2.1" +version = "0.3.0" dependencies = [ "criterion", "insta", diff --git a/Cargo.toml b/Cargo.toml index ba249b12..24512590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,7 @@ vespertide-macro = { path = "crates/vespertide-macro", version = "=0.3.0" } vespertide-naming = { path = "crates/vespertide-naming", version = "=0.3.0" } vespertide-planner = { path = "crates/vespertide-planner", version = "=0.3.0" } vespertide-query = { path = "crates/vespertide-query", version = "=0.3.0" } -vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.3.0" } +vespertide-exporter = { path = "crates/vespertide-exporter", version = "=0.4.0" } vespertide-lsp = { path = "crates/vespertide-lsp", version = "=0.3.0" } [profile.dev] diff --git a/crates/vespertide-cli/src/commands/export/mod.rs b/crates/vespertide-cli/src/commands/export/mod.rs index 5647a0df..3d5e4f9b 100644 --- a/crates/vespertide-cli/src/commands/export/mod.rs +++ b/crates/vespertide-cli/src/commands/export/mod.rs @@ -8,7 +8,7 @@ use tokio::fs; use vespertide_config::VespertideConfig; use vespertide_core::TableDef; use vespertide_exporter::{ - Orm, prisma, python_naming::to_pascal_case, render_entity_with_schema, + Orm, drizzle, prisma, python_naming::to_pascal_case, render_entity_with_schema, seaorm::SeaOrmExporterWithConfig, }; use vespertide_naming::{IdentifierStart, sanitize_identifier, seaorm_module_name}; @@ -35,10 +35,13 @@ pub async fn cmd_export(orm: Orm, export_dir: Option) -> Result<()> { let target_root = resolve_export_dir(export_dir, &config); - // Prisma uses a single-file output strategy + // Prisma and Drizzle use a single-file output strategy if matches!(orm, Orm::Prisma) { return cmd_export_prisma(normalized_models, target_root).await; } + if matches!(orm, Orm::Drizzle) { + return cmd_export_drizzle(normalized_models, target_root).await; + } // Clean the export directory before regenerating prepare_export_dir(&target_root, orm).await?; @@ -425,6 +428,43 @@ async fn cmd_export_prisma( Ok(()) } +/// Drizzle has no backend-neutral output — the table constructors fork at the +/// `import` line — so one export writes one file per dialect: +/// `models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`. Not `schema.ts`: +/// that name belongs to the user's own schema entry file. +async fn cmd_export_drizzle( + normalized_models: Vec<(TableDef, PathBuf)>, + target_root: PathBuf, +) -> Result<()> { + let all_tables: Vec = normalized_models.iter().map(|(t, _)| t.clone()).collect(); + + // No `prepare_export_dir`: its extension sweep would take every `.ts` + // under the root — the user's own source files included — and Drizzle + // writes exactly three fixed names, so `fs::write` overwriting them below + // is all the cleaning a re-export needs. + if !target_root.exists() { + fs::create_dir_all(&target_root) + .await + .with_context(|| format!("create export dir {}", target_root.display()))?; + } + + for dialect in drizzle::DrizzleDialect::ALL { + let content = drizzle::render_schema(&all_tables, dialect); + let out_path = target_root.join(format!("models.{}.ts", dialect.file_suffix())); + fs::write(&out_path, &content) + .await + .with_context(|| format!("write {}", out_path.display()))?; + + println!( + "Exported {} model(s) -> {}", + normalized_models.len(), + out_path.display() + ); + } + + Ok(()) +} + #[async_recursion::async_recursion] async fn walk_models( root: &Path, diff --git a/crates/vespertide-exporter/Cargo.toml b/crates/vespertide-exporter/Cargo.toml index 426c4641..aa7cc6df 100644 --- a/crates/vespertide-exporter/Cargo.toml +++ b/crates/vespertide-exporter/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vespertide-exporter" -version = "0.3.0" +version = "0.4.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/vespertide-exporter/src/drizzle/bindings.rs b/crates/vespertide-exporter/src/drizzle/bindings.rs new file mode 100644 index 00000000..f4b76b92 --- /dev/null +++ b/crates/vespertide-exporter/src/drizzle/bindings.rs @@ -0,0 +1,229 @@ +//! File-scope binding names for one rendered Drizzle file. +//! +//! Four kinds of top-level `const`s share the module namespace with the +//! import symbols: `customType` helpers, `pgEnum` declarations, table +//! declarations and `relations` blocks. Distinct database names can collapse +//! onto one binding (`js_name` folds `_` and `-` alike), and a table can be +//! named after an import (`sql`, `integer`) — so every binding is claimed +//! here, in declaration order, and a later claimant takes a numeric suffix. +//! A schema with no collisions keeps every natural name, byte for byte. + +use std::collections::{HashMap, HashSet}; + +use vespertide_core::TableDef; +use vespertide_core::schema::column::EnumValues; + +use super::enums::enum_const_name; +use super::types::custom_column; +use super::{DrizzleDialect, js_name}; +use crate::utils::common::claim_binding; + +/// The generated callbacks' parameter names: the table callback is +/// `(t) => [...]` and the relations callback `({ one, many }) => ({...})`, +/// and claimed consts are interpolated *inside* those scopes — a table named +/// `t` would otherwise render foreign columns against the callback parameter +/// (compiling, silently wrong). +const CALLBACK_SCOPE_NAMES: [&str; 3] = ["t", "one", "many"]; + +/// Every symbol a rendered file can import for the dialect — from the +/// dialect-core line and the `drizzle-orm` line — so a binding never shadows +/// one. Guarded by `vocabulary_covers_every_import` below against new +/// constructors in `types.rs`. +fn import_vocabulary(dialect: DrizzleDialect) -> &'static [&'static str] { + const PG: [&str; 29] = [ + "pgTable", + "pgEnum", + "customType", + "primaryKey", + "foreignKey", + "uniqueIndex", + "index", + "check", + "relations", + "sql", + "bigint", + "boolean", + "char", + "cidr", + "date", + "doublePrecision", + "inet", + "integer", + "interval", + "json", + "macaddr", + "numeric", + "real", + "smallint", + "text", + "time", + "timestamp", + "uuid", + "varchar", + ]; + const MYSQL: [&str; 25] = [ + "mysqlTable", + "mysqlEnum", + "customType", + "primaryKey", + "foreignKey", + "uniqueIndex", + "index", + "check", + "relations", + "sql", + "bigint", + "binary", + "boolean", + "char", + "date", + "decimal", + "double", + "float", + "int", + "json", + "smallint", + "text", + "time", + "timestamp", + "varchar", + ]; + const SQLITE: [&str; 14] = [ + "sqliteTable", + "customType", + "primaryKey", + "foreignKey", + "uniqueIndex", + "index", + "check", + "relations", + "sql", + "blob", + "integer", + "numeric", + "real", + "text", + ]; + match dialect { + DrizzleDialect::Pg => &PG, + DrizzleDialect::Mysql => &MYSQL, + DrizzleDialect::Sqlite => &SQLITE, + } +} + +/// Binding names for one file, keyed by identity rather than by natural name +/// so colliding claimants each find their own (suffixed) binding. +pub(super) struct FileBindings { + /// `customType` const per SQL data type. + customs: HashMap, + /// `pgEnum` const per `(table, enum name)`. + enums: HashMap<(String, String), String>, + /// Table const per table name. + tables: HashMap, + /// `relations` const per table name. + relations: HashMap, +} + +impl FileBindings { + /// Claim every binding the file will declare, in declaration order: + /// `customType` consts, then `pgEnum` consts, then tables, then + /// `relations` blocks — seeded with the import vocabulary and the + /// callback parameter names so no binding shadows either. + pub(super) fn collect(tables: &[TableDef], dialect: DrizzleDialect) -> Self { + let mut taken: HashSet = import_vocabulary(dialect) + .iter() + .chain(CALLBACK_SCOPE_NAMES.iter()) + .map(|s| (*s).to_string()) + .collect(); + + let mut customs: HashMap = HashMap::new(); + for table in tables { + for col in &table.columns { + if let Some(decl) = custom_column(&col.r#type, dialect) + && !customs.contains_key(&decl.data_type) + { + let claimed = claim_binding(decl.const_name.clone(), &mut taken); + customs.insert(decl.data_type, claimed); + } + } + } + + // Each map claims once per key: the export does not validate its + // input, so a schema carrying two tables with one name (impossible in + // a real database) renders them onto one binding rather than + // suffixing names apart. + let mut enums: HashMap<(String, String), String> = HashMap::new(); + if dialect == DrizzleDialect::Pg { + for table in tables { + for (name, values) in crate::enum_scan::collect_table_enums(table) { + let key = (table.name.to_string(), name.to_string()); + // Integer enums stay plain integer columns and declare + // nothing. + if matches!(values, EnumValues::String(_)) && !enums.contains_key(&key) { + let claimed = claim_binding(enum_const_name(&table.name, name), &mut taken); + enums.insert(key, claimed); + } + } + } + } + + let mut table_consts: HashMap = HashMap::new(); + for table in tables { + if !table_consts.contains_key(table.name.as_str()) { + let claimed = claim_binding(js_name(&table.name), &mut taken); + table_consts.insert(table.name.to_string(), claimed); + } + } + + let mut relations: HashMap = HashMap::new(); + for table in tables { + if !relations.contains_key(table.name.as_str()) { + let preferred = format!("{}Relations", table_consts[table.name.as_str()]); + let claimed = claim_binding(preferred, &mut taken); + relations.insert(table.name.to_string(), claimed); + } + } + + Self { + customs, + enums, + tables: table_consts, + relations, + } + } + + /// The `customType` const for a data type; falls back to the natural name + /// for a type the collect pass never saw. + pub(super) fn custom_const(&self, data_type: &str) -> String { + self.customs + .get(data_type) + .cloned() + .unwrap_or_else(|| js_name(data_type)) + } + + /// The `pgEnum` const a column of `table` calls for `enum_name`. + pub(super) fn enum_const(&self, table: &str, enum_name: &str) -> String { + self.enums + .get(&(table.to_string(), enum_name.to_string())) + .cloned() + .unwrap_or_else(|| enum_const_name(table, enum_name)) + } + + /// The table's const. The natural-name fallback is a live path: a foreign + /// key may reference a table outside the schema slice (the export does not + /// validate dangling references). + pub(super) fn table_const(&self, table: &str) -> String { + self.tables + .get(table) + .cloned() + .unwrap_or_else(|| js_name(table)) + } + + /// The table's `relations` const. + pub(super) fn relations_const(&self, table: &str) -> String { + self.relations + .get(table) + .cloned() + .unwrap_or_else(|| format!("{}Relations", js_name(table))) + } +} diff --git a/crates/vespertide-exporter/src/drizzle/enums.rs b/crates/vespertide-exporter/src/drizzle/enums.rs new file mode 100644 index 00000000..9b91d284 --- /dev/null +++ b/crates/vespertide-exporter/src/drizzle/enums.rs @@ -0,0 +1,45 @@ +//! PostgreSQL enum declarations and the naming they share with their columns. +//! +//! Only PostgreSQL declares an enum type of its own. MySQL inlines the variant +//! list into `mysqlEnum(…)` and SQLite has no enum at all, so both spell the +//! variants directly on the column — see `types::complex_ctor`. + +use vespertide_naming::build_enum_type_name; + +use crate::utils::typescript::ts_string; + +/// The enum type's database name: `{table}_{enum}`. +/// +/// The SQL layer runs **every** PostgreSQL enum through +/// [`build_enum_type_name`] — the `CREATE TYPE` is table-prefixed even when no +/// other table declares the same enum — so the model does too, or `drizzle-kit` +/// sees a type the database never had. This also means two tables sharing an +/// enum declaration in the model own two separate database types, and the file +/// declares one `pgEnum` per table accordingly. +pub(super) fn enum_db_name(table: &str, enum_name: &str) -> String { + build_enum_type_name(table, enum_name) +} + +/// The natural `const` binding for an enum declaration, derived from the +/// database type name so the two stay recognisably paired. The final binding +/// comes from `FileBindings`, which suffixes this name on a file-scope +/// collision. +pub(super) fn enum_const_name(table: &str, enum_name: &str) -> String { + super::js_name(&enum_db_name(table, enum_name)) +} + +/// Render a `pgEnum` declaration: +/// `export const ordersStatus = pgEnum("orders_status", ["draft", "published"]);` +/// +/// Takes the string values directly — integer enums render as plain integer +/// columns and never declare a type. The values stay verbatim: Drizzle sends +/// them to PostgreSQL as written, so there is no variant-name normalization +/// and nothing that would need a `@map` equivalent. +pub(super) fn render_enum_decl(const_name: &str, db_name: &str, values: &[String]) -> String { + let variants: Vec = values.iter().map(|v| ts_string(v)).collect(); + format!( + "export const {const_name} = pgEnum({}, [{}]);", + ts_string(db_name), + variants.join(", ") + ) +} diff --git a/crates/vespertide-exporter/src/drizzle/mod.rs b/crates/vespertide-exporter/src/drizzle/mod.rs new file mode 100644 index 00000000..e7d50186 --- /dev/null +++ b/crates/vespertide-exporter/src/drizzle/mod.rs @@ -0,0 +1,306 @@ +//! Drizzle ORM (TypeScript) schema generation. +//! +//! Drizzle has no backend-neutral output: `pgTable`, `mysqlTable` and +//! `sqliteTable` live in three different packages that fork at the `import` +//! line, so — unlike Prisma, which stays neutral by omitting native type +//! attributes — one schema renders to one file *per dialect*. The CLI writes +//! all three; [`render_schema`] renders one. + +mod bindings; +mod enums; +mod render; +mod types; + +use std::collections::HashSet; + +use vespertide_core::TableDef; +use vespertide_core::schema::column::EnumValues; + +use crate::orm::OrmExporter; +use crate::utils::typescript::ts_binding; +use bindings::FileBindings; +use enums::{enum_db_name, render_enum_decl}; +use render::{render_relations_block, render_table}; +use types::{custom_column, render_custom_type_decl}; +use vespertide_naming::to_camel_case; + +/// A database name as a TypeScript binding or object key. +/// +/// Bindings, object keys and property accesses all have to agree, so every +/// site derives them from here rather than casing the name itself. +fn js_name(db_name: &str) -> String { + ts_binding(&to_camel_case(db_name)) +} + +pub struct DrizzleExporter; + +impl OrmExporter for DrizzleExporter { + fn render_entity(&self, table: &TableDef) -> Result { + // A lone table is its own schema: a self-referential FK needs both + // relation ends in scope for its `relationName` pair to be emitted. + Ok(render_entity_with_schema( + table, + std::slice::from_ref(table), + )) + } + + fn render_entity_with_schema( + &self, + table: &TableDef, + schema: &[TableDef], + ) -> Result { + Ok(render_entity_with_schema(table, schema)) + } +} + +/// The Drizzle package family a rendered file targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DrizzleDialect { + Pg, + Mysql, + Sqlite, +} + +impl DrizzleDialect { + /// Every dialect, in the order the CLI writes their files. + pub const ALL: [DrizzleDialect; 3] = [ + DrizzleDialect::Pg, + DrizzleDialect::Mysql, + DrizzleDialect::Sqlite, + ]; + + /// Segment naming the dialect's output file (`models.pg.ts`). + #[must_use] + pub fn file_suffix(self) -> &'static str { + match self { + DrizzleDialect::Pg => "pg", + DrizzleDialect::Mysql => "mysql", + DrizzleDialect::Sqlite => "sqlite", + } + } + + /// The table-declaration function, which also identifies the dialect in + /// the import header's rank ordering. + fn table_fn(self) -> &'static str { + match self { + DrizzleDialect::Pg => "pgTable", + DrizzleDialect::Mysql => "mysqlTable", + DrizzleDialect::Sqlite => "sqliteTable", + } + } + + /// The package the dialect's constructors are imported from. + fn import_path(self) -> &'static str { + match self { + DrizzleDialect::Pg => "drizzle-orm/pg-core", + DrizzleDialect::Mysql => "drizzle-orm/mysql-core", + DrizzleDialect::Sqlite => "drizzle-orm/sqlite-core", + } + } +} + +/// Import requirements collected while rendering. +/// +/// `render_table` records each decision — constructor symbols, constraint +/// helpers, whether a default reached for the `sql` tag — as it makes it, so +/// the header assembled afterwards cannot disagree with the body. +#[derive(Default)] +struct Imports { + /// Dialect-core symbols; `header()` imposes the final order. Locally + /// declared consts (`pgEnum`, `customType`) never enter this set. + symbols: HashSet, + /// A default rendered through the `sql` tagged template. + needs_sql: bool, + /// At least one `relations(...)` block was emitted. + needs_relations: bool, +} + +impl Imports { + /// The import block: dialect-core line first, then `drizzle-orm` if needed. + fn header(&self, dialect: DrizzleDialect) -> String { + let table_fn = dialect.table_fn(); + let mut symbols: Vec<&str> = self.symbols.iter().map(String::as_str).collect(); + symbols.sort_by(|a, b| { + symbol_rank(a, table_fn) + .cmp(&symbol_rank(b, table_fn)) + .then(a.cmp(b)) + }); + + let mut lines = vec![format!( + "import {{ {} }} from \"{}\";", + symbols.join(", "), + dialect.import_path() + )]; + + let mut orm_symbols: Vec<&str> = Vec::new(); + if self.needs_relations { + orm_symbols.push("relations"); + } + if self.needs_sql { + orm_symbols.push("sql"); + } + if !orm_symbols.is_empty() { + lines.push(format!( + "import {{ {} }} from \"drizzle-orm\";", + orm_symbols.join(", ") + )); + } + lines.join("\n") + } +} + +/// Sort key for the dialect-core import list. +fn symbol_rank(symbol: &str, table_fn: &str) -> usize { + // Type and constraint helpers in import order; the dialect's table + // function precedes them and column constructors follow, alphabetical + // within that tail. + const RANKED_HELPERS: [&str; 7] = [ + "pgEnum", + "customType", + "primaryKey", + "foreignKey", + "uniqueIndex", + "index", + "check", + ]; + if symbol == table_fn { + 0 + } else { + let mut rank = RANKED_HELPERS.len() + 1; + for (position, helper) in RANKED_HELPERS.iter().enumerate() { + if *helper == symbol { + rank = position + 1; + break; + } + } + rank + } +} + +/// Render every table into one Drizzle schema file for `dialect`. +/// +/// Output order: imports → `customType` declarations → enum declarations +/// (PostgreSQL only — MySQL and SQLite inline their variants into the column) +/// → table declarations → relations declarations. Every enum type is table-prefixed +/// (`{table}_{enum}`) because that is the `CREATE TYPE` the SQL layer emits — +/// two tables sharing a model-level enum own two database types, and the file +/// declares one `pgEnum` per table accordingly. +pub fn render_schema(tables: &[TableDef], dialect: DrizzleDialect) -> String { + let mut imports = Imports::default(); + let bindings = FileBindings::collect(tables, dialect); + + let custom_blocks = custom_type_decls(tables, dialect, &bindings); + if !custom_blocks.is_empty() { + imports.symbols.insert("customType".to_string()); + } + + let mut enum_blocks: Vec = Vec::new(); + if dialect == DrizzleDialect::Pg { + for table in tables { + enum_blocks.extend(table_enum_decls(table, &bindings)); + } + if !enum_blocks.is_empty() { + imports.symbols.insert("pgEnum".to_string()); + } + } + + let table_blocks: Vec = tables + .iter() + .map(|table| render_table(table, dialect, &mut imports, &bindings)) + .collect(); + + let mut relation_blocks: Vec = Vec::new(); + for table in tables { + if let Some(block) = render_relations_block(table, tables, &bindings) { + imports.needs_relations = true; + relation_blocks.push(block); + } + } + + let mut parts = vec![imports.header(dialect)]; + parts.extend(custom_blocks); + parts.extend(enum_blocks); + parts.extend(table_blocks); + parts.extend(relation_blocks); + parts.join("\n\n") + "\n" +} + +/// One `customType` helper const per distinct data type the dialect has no +/// constructor for, in first-appearance order across the file's columns. +fn custom_type_decls( + tables: &[TableDef], + dialect: DrizzleDialect, + bindings: &FileBindings, +) -> Vec { + let mut seen: HashSet = HashSet::new(); + let mut decls = Vec::new(); + for table in tables { + for col in &table.columns { + if let Some(decl) = custom_column(&col.r#type, dialect) + && seen.insert(decl.data_type.clone()) + { + decls.push(render_custom_type_decl( + &decl, + &bindings.custom_const(&decl.data_type), + )); + } + } + } + decls +} + +/// Render enum + table + relations blocks with full schema context. +/// +/// No import header: this is the cross-ORM harness's per-table view, which — +/// like the other backends' single-entity output — carries declarations only. +/// The trait has a single `String` result and so no dialect axis; PostgreSQL +/// is the canonical dialect for cross-ORM comparison, matching the enum-typed +/// output the other backends produce. +pub fn render_entity_with_schema(table: &TableDef, schema: &[TableDef]) -> String { + // Discarded: header-free output has no use for the collected symbols. + let mut imports = Imports::default(); + // Bindings over the whole schema, so this fragment names things exactly + // as the full file would. + let bindings = FileBindings::collect(schema, DrizzleDialect::Pg); + + let mut parts = custom_type_decls(std::slice::from_ref(table), DrizzleDialect::Pg, &bindings); + parts.extend(table_enum_decls(table, &bindings)); + parts.push(render_table( + table, + DrizzleDialect::Pg, + &mut imports, + &bindings, + )); + if let Some(block) = render_relations_block(table, schema, &bindings) { + parts.push(block); + } + parts.join("\n\n") +} + +/// One `pgEnum` declaration per string enum the table's columns use, under the +/// table-prefixed name the SQL layer's `CREATE TYPE` carries. Integer enums +/// are plain integer columns in every dialect and declare nothing. +fn table_enum_decls(table: &TableDef, bindings: &FileBindings) -> Vec { + let mut decls = Vec::new(); + for (name, values) in crate::enum_scan::collect_table_enums(table) { + let EnumValues::String(vals) = values else { + continue; + }; + let db_name = enum_db_name(table.name.as_str(), name); + let const_name = bindings.enum_const(table.name.as_str(), name); + decls.push(render_enum_decl(&const_name, &db_name, vals)); + } + decls +} + +/// Multi-table entry point: render every table (enum + table + relations +/// blocks) with full schema context and join them. Mirrors the other ORMs' +/// `export` so the cross-ORM test harness can dispatch Drizzle through a +/// single call. +pub fn export(schema: &[TableDef]) -> Result { + Ok(schema + .iter() + .map(|table| render_entity_with_schema(table, schema)) + .collect::>() + .join("\n\n")) +} diff --git a/crates/vespertide-exporter/src/drizzle/render.rs b/crates/vespertide-exporter/src/drizzle/render.rs new file mode 100644 index 00000000..081e560f --- /dev/null +++ b/crates/vespertide-exporter/src/drizzle/render.rs @@ -0,0 +1,589 @@ +//! Table, relation and default-value rendering. + +use std::collections::{HashMap, HashSet}; + +use vespertide_core::TableDef; +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; +use vespertide_core::schema::constraint::TableConstraint; +use vespertide_core::schema::names::ColumnName; +use vespertide_core::schema::reference::ReferenceAction; +use vespertide_naming::{build_foreign_key_name, build_index_name, build_unique_constraint_name}; + +use super::bindings::FileBindings; +use super::types::column_ctor; +use super::{DrizzleDialect, Imports, js_name}; +use crate::constraint_scan::{collect_back_relations, fk_relation_names, relation_segment}; +use crate::utils::common::{claim_field_name, unquote}; +use crate::utils::typescript::ts_string; + +// ─── Constraint lookups ────────────────────────────────────────────────────── + +/// PK column names and whether the key carries a sequence. +fn pk_shape(constraints: &[TableConstraint]) -> (Vec<&str>, bool) { + match crate::constraint_scan::primary_key(constraints) { + Some(TableConstraint::PrimaryKey { + columns, + auto_increment, + .. + }) => ( + columns.iter().map(ColumnName::as_str).collect(), + *auto_increment, + ), + _ => (Vec::new(), false), + } +} + +// ─── Table renderer ────────────────────────────────────────────────────────── + +/// Render one table declaration. +/// +/// Everything the file's import header depends on — the constructors a column +/// resolved to, whether a default reached for the `sql` tag, which constraint +/// helpers the callback used — is recorded into `imports` at the moment the +/// decision is made, so the header can never disagree with the body. +pub(super) fn render_table( + table: &TableDef, + dialect: DrizzleDialect, + imports: &mut Imports, + bindings: &FileBindings, +) -> String { + let (pk_columns, pk_auto_increment) = pk_shape(&table.constraints); + let pk_cols: HashSet<&str> = pk_columns.iter().copied().collect(); + let is_composite_pk = pk_columns.len() > 1; + + // ── Column lines ──────────────────────────────────────────────────────── + let mut col_lines: Vec = Vec::new(); + + for col in &table.columns { + let col_db = col.name.as_str(); + let key = js_name(col_db); + let in_pk = pk_cols.contains(col_db); + let is_single_pk = in_pk && !is_composite_pk; + let auto_inc = is_single_pk && pk_auto_increment; + + if let Some(comment) = &col.comment { + for line in comment.lines() { + col_lines.push(format!(" // {line}")); + } + } + + let ctor = column_ctor(&col.r#type, dialect, &table.name, bindings); + if !ctor.local { + imports.symbols.insert(ctor.symbol.clone()); + } + let mut chain: Vec = Vec::new(); + + if is_single_pk { + chain.push(primary_key_chain(dialect, auto_inc)); + } + + // A primary key already implies NOT NULL in every dialect. + if !col.nullable && !is_single_pk { + chain.push(".notNull()".to_string()); + } + + // A sequence supplies the value, so a default alongside it would be + // dead weight at best and contradictory at worst. + if !auto_inc && let Some(default) = &col.default { + let rendered = default_chain(&default.to_sql(), &col.r#type, dialect); + imports.needs_sql |= rendered.needs_sql; + chain.push(rendered.text); + } + + col_lines.push(format!(" {key}: {}{},", ctor.call(col_db), chain.concat())); + } + + // ── Table-level constraints (array-form callback) ──────────────────────── + let mut constraint_lines: Vec = Vec::new(); + + if is_composite_pk { + imports.symbols.insert("primaryKey".to_string()); + // The name `drizzle-kit` expects back, or it drops and re-adds the + // key: on PostgreSQL the constraint its inline `PRIMARY KEY (…)` + // syntax creates is `{table}_pkey`; on MySQL no name is stored at all, + // but the kit snapshot books the introspected key under + // `{table}_{columns}` (measured against drizzle-kit 0.31). SQLite + // stores no name and its kit compares by columns alone. + let name_field = match dialect { + DrizzleDialect::Pg => { + format!("name: {}, ", ts_string(&format!("{}_pkey", table.name))) + } + DrizzleDialect::Mysql => { + let joined = pk_columns.join("_"); + format!("name: {}, ", ts_string(&format!("{}_{joined}", table.name))) + } + DrizzleDialect::Sqlite => String::new(), + }; + constraint_lines.push(format!( + " primaryKey({{ {name_field}columns: [{}] }}),", + column_refs(&pk_columns) + )); + } + + for c in &table.constraints { + match c { + // `uniqueIndex`, not `unique`: the SQL layer creates every unique + // rule as `CREATE UNIQUE INDEX`, and PostgreSQL introspection + // tells an index from a table constraint — `unique(…)` here would + // read as "drop the index, add a constraint" to `drizzle-kit`. + // The name always comes from the naming builder (a user-supplied + // name is a key inside the convention, not the final name), so the + // model names the exact index vespertide created. + TableConstraint::Unique { name, columns, .. } => { + imports.symbols.insert("uniqueIndex".to_string()); + let n = build_unique_constraint_name(&table.name, columns, name.as_deref()); + constraint_lines.push(table_level_entry("uniqueIndex", &n, columns)); + } + TableConstraint::Index { name, columns } => { + imports.symbols.insert("index".to_string()); + let n = build_index_name(&table.name, columns, name.as_deref()); + constraint_lines.push(table_level_entry("index", &n, columns)); + } + TableConstraint::ForeignKey { + name, + columns, + ref_table, + ref_columns, + on_delete, + on_update, + .. + } => { + imports.symbols.insert("foreignKey".to_string()); + // SQLite stores no foreign-key constraint names — the SQL + // layer emits them inline and unnamed there — so a named key + // would read as permanent drift to `drizzle-kit` introspection. + let n = (dialect != DrizzleDialect::Sqlite) + .then(|| build_foreign_key_name(&table.name, columns, name.as_deref())); + constraint_lines.push(foreign_key_entry( + n.as_deref(), + columns, + ref_table.as_str(), + ref_columns, + on_delete.as_ref(), + on_update.as_ref(), + &table.name, + bindings, + )); + } + TableConstraint::Check { name, expr, .. } => { + imports.symbols.insert("check".to_string()); + imports.needs_sql = true; + // The SQL layer emits the check's name verbatim (no builder), + // so the model does too. + constraint_lines.push(format!( + " check({}, sql`{}`),", + ts_string(name), + escape_backtick(expr) + )); + } + // The primary key is rendered from `pk_shape` above. + // `TableConstraint` is `#[non_exhaustive]`, so future variants + // also land here — the table still renders, minus the constraint + // this version cannot know about. + _ => {} + } + } + + // ── Assemble the table call ────────────────────────────────────────────── + let mut lines: Vec = Vec::new(); + + if let Some(desc) = &table.description { + for line in desc.lines() { + lines.push(format!("// {line}")); + } + } + + imports.symbols.insert(dialect.table_fn().to_string()); + lines.push(format!( + "export const {} = {}({}, {{", + bindings.table_const(&table.name), + dialect.table_fn(), + ts_string(&table.name) + )); + lines.extend(col_lines); + if constraint_lines.is_empty() { + lines.push("});".to_string()); + } else { + lines.push("}, (t) => [".to_string()); + lines.extend(constraint_lines); + lines.push("]);".to_string()); + } + + lines.join("\n") +} + +/// How each dialect spells a single-column primary key. +/// +/// Each dialect spells the sequence differently — and on PostgreSQL it must be +/// the identity chain, not `serial`: the SQL layer emits `GENERATED BY DEFAULT +/// AS IDENTITY`, and a `serial` model column reads as "drop the identity" to +/// `drizzle-kit`. +fn primary_key_chain(dialect: DrizzleDialect, auto_increment: bool) -> String { + match (dialect, auto_increment) { + (DrizzleDialect::Pg, true) => ".primaryKey().generatedByDefaultAsIdentity()".to_string(), + (DrizzleDialect::Mysql, true) => ".autoincrement().primaryKey()".to_string(), + (DrizzleDialect::Sqlite, true) => ".primaryKey({ autoIncrement: true })".to_string(), + _ => ".primaryKey()".to_string(), + } +} + +/// `t.first, t.second` — the column list a table-level constraint builds on. +fn column_refs>(columns: &[T]) -> String { + columns + .iter() + .map(|c| format!("t.{}", js_name(c.as_ref()))) + .collect::>() + .join(", ") +} + +/// One entry of the table-level constraint callback. +/// +/// `builder` is the Drizzle helper (`uniqueIndex` / `index`) and `name` is the +/// already-resolved database name (see the naming-builder comment at the +/// single-column unique site). The name is mandatory: `mysql-core` and +/// `sqlite-core` reject a nameless call. +fn table_level_entry(builder: &str, name: &str, columns: &[ColumnName]) -> String { + format!( + " {builder}({}).on({}),", + ts_string(name), + column_refs(columns) + ) +} + +/// `foreignKey({ columns: […], foreignColumns: […], name: "fk_…" })` plus the +/// `.onDelete(…)`/`.onUpdate(…)` chain. +/// +/// The operator form rather than a `.references()` chain, for three reasons it +/// covers and the chain cannot: it carries the constraint's *name* — the SQL +/// layer names PostgreSQL/MySQL foreign keys via `build_foreign_key_name`, and +/// a differently-named key reads as drift to `drizzle-kit` (`None` on SQLite, +/// which stores no FK names) — it spells composite keys, and a self-referential +/// key can take its foreign columns from the callback's `t` (the key targets +/// this very table), which keeps the table const out of its own initializer's +/// type inference. +#[expect( + clippy::too_many_arguments, + reason = "one call site; the args are the FK's own fields plus the two naming contexts" +)] +fn foreign_key_entry( + name: Option<&str>, + columns: &[ColumnName], + ref_table: &str, + ref_columns: &[ColumnName], + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, + table: &str, + bindings: &FileBindings, +) -> String { + let foreign_owner = if ref_table == table { + "t".to_string() + } else { + bindings.table_const(ref_table) + }; + // A foreign key with no explicit target column references the parent's + // primary key, which vespertide names `id` by convention. + let foreign_cols = if ref_columns.is_empty() { + format!("{foreign_owner}.id") + } else { + ref_columns + .iter() + .map(|c| format!("{foreign_owner}.{}", js_name(c))) + .collect::>() + .join(", ") + }; + + let name_field = name.map_or_else(String::new, |n| format!(", name: {}", ts_string(n))); + let mut parts = vec![format!( + " foreignKey({{ columns: [{}], foreignColumns: [{foreign_cols}]{name_field} }})", + column_refs(columns) + )]; + if let Some(action) = on_delete { + parts.push(format!( + ".onDelete({})", + ts_string(reference_action_to_drizzle(action)) + )); + } + if let Some(action) = on_update { + parts.push(format!( + ".onUpdate({})", + ts_string(reference_action_to_drizzle(action)) + )); + } + parts.push(",".to_string()); + parts.concat() +} + +// ─── Relations renderer ────────────────────────────────────────────────────── + +/// Render a `relations(...)` export block, or `None` when the table has none. +pub(super) fn render_relations_block( + table: &TableDef, + schema: &[TableDef], + bindings: &FileBindings, +) -> Option { + let table_js = bindings.table_const(&table.name); + let relation_names = fk_relation_names(table); + + let mut ref_table_fk_count: HashMap<&str, usize> = HashMap::new(); + for c in &table.constraints { + if let TableConstraint::ForeignKey { ref_table, .. } = c { + *ref_table_fk_count.entry(ref_table.as_str()).or_default() += 1; + } + } + + let back_rels = collect_back_relations(&table.name, schema); + + // Drizzle merges columns and relations into one namespace in query + // results, and the object literal itself rejects a repeated key, so every + // relation field is claimed against the column names and one another. + let mut field_names: HashSet = table + .columns + .iter() + .map(|col| js_name(col.name.as_str())) + .collect(); + + let mut rel_lines: Vec = Vec::new(); + + // Forward relations, in constraint order. + for (constraint_idx, c) in table.constraints.iter().enumerate() { + let TableConstraint::ForeignKey { + columns, + ref_table, + ref_columns, + .. + } = c + else { + continue; + }; + + let segment = relation_segment(columns); + let mut preferred = js_name(&segment); + // A segment that is already a column's name (an FK column without an + // `_id` suffix, or any other column) reads better with the target + // table appended than with the generic `_rel` suffix. + if field_names.contains(&preferred) { + preferred = js_name(&format!("{segment}_{ref_table}")); + } + let field = claim_field_name(preferred, &mut field_names); + let target = bindings.table_const(ref_table.as_str()); + + let fields_list = columns + .iter() + .map(|c| format!("{table_js}.{}", js_name(c))) + .collect::>() + .join(", "); + let refs_list = if ref_columns.is_empty() { + format!("{target}.id") + } else { + ref_columns + .iter() + .map(|c| format!("{target}.{}", js_name(c))) + .collect::>() + .join(", ") + }; + + let mut opts: Vec = vec![ + format!("fields: [{fields_list}]"), + format!("references: [{refs_list}]"), + ]; + let ambiguous = ref_table_fk_count + .get(ref_table.as_str()) + .is_some_and(|n| *n > 1) + || ref_table.as_str() == table.name.as_str(); + if ambiguous && let Some(name) = relation_names.get(&constraint_idx) { + opts.push(format!("relationName: {}", ts_string(name))); + } + + rel_lines.push(format!( + " {field}: one({target}, {{ {} }}),", + opts.join(", ") + )); + } + + // Back relations. + for br in &back_rels { + let source = bindings.table_const(&br.source_table); + let preferred = match &br.relation_name { + Some(_) => js_name(&format!("{}_{}", br.rel_segment, br.source_table)), + None => source.clone(), + }; + let field = claim_field_name(preferred, &mut field_names); + let opts = br.relation_name.as_ref().map_or_else(String::new, |n| { + format!(", {{ relationName: {} }}", ts_string(n)) + }); + let builder = if br.is_one_to_one { "one" } else { "many" }; + rel_lines.push(format!(" {field}: {builder}({source}{opts}),")); + } + + if rel_lines.is_empty() { + return None; + } + + let mut lines: Vec = vec![format!( + "export const {} = relations({table_js}, ({{ one, many }}) => ({{", + bindings.relations_const(&table.name) + )]; + lines.extend(rel_lines); + lines.push("}));".to_string()); + + Some(lines.join("\n")) +} + +// ─── Default value rendering ───────────────────────────────────────────────── + +/// A rendered `.default(...)` chain and whether it reached for the `sql` tag. +/// +/// The import header needs to know about `sql` before any column is written, so +/// both answers come from one pass — deciding twice would let the header and +/// the body disagree. +pub(super) struct DefaultChain { + pub(super) text: String, + pub(super) needs_sql: bool, +} + +impl DefaultChain { + fn literal(text: String) -> Self { + Self { + text, + needs_sql: false, + } + } + + /// `.default(sql`…`)` — a server-side expression Drizzle passes through. + fn tagged(expr: &str) -> Self { + Self { + text: format!(".default(sql`{}`)", escape_backtick(expr)), + needs_sql: true, + } + } +} + +/// Backticks and `${` would end the tagged template or open an interpolation. +fn escape_backtick(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('`', "\\`") + .replace("${", "\\${") +} + +/// Render the `.default(...)` chain for a column default. +/// +/// A `DefaultValue` string is a SQL expression rather than a literal, so most +/// of the work is recognising the handful of expressions Drizzle has a helper +/// for and passing everything else through the `sql` tag untouched. +pub(super) fn default_chain( + default_sql: &str, + col_type: &ColumnType, + dialect: DrizzleDialect, +) -> DefaultChain { + if default_sql == "true" || default_sql == "false" { + return DefaultChain::literal(format!(".default({default_sql})")); + } + + let lower = default_sql.to_lowercase(); + + // The SQL layer normalizes both spellings to `DEFAULT CURRENT_TIMESTAMP` + // in the DDL it runs, and each dialect has exactly one model spelling + // `drizzle-kit` reads back as equal (measured on live PostgreSQL 17 and + // MySQL 8 round-trips): PostgreSQL deparses `CURRENT_TIMESTAMP` as itself + // — `defaultNow()` would write `now()` and read as a default change — + // while MySQL introspects to `(now())`, which is precisely `defaultNow()`, + // and SQLite requires the parentheses around an expression default. + if lower.starts_with("current_timestamp") || lower.contains("now()") { + return match dialect { + DrizzleDialect::Pg => DefaultChain::tagged("CURRENT_TIMESTAMP"), + DrizzleDialect::Mysql => DefaultChain::literal(".defaultNow()".to_string()), + DrizzleDialect::Sqlite => DefaultChain::tagged("(CURRENT_TIMESTAMP)"), + }; + } + + // Only `gen_random_uuid()` is normalized per backend by the SQL layer; + // other generator spellings (`uuid_generate_v4()`, `newid()`) pass through + // verbatim everywhere, so they fall to the generic call branch below. + if lower.contains("gen_random_uuid()") { + return match dialect { + // `defaultRandom()` is a `pg-core` column method emitting + // `gen_random_uuid()` — the same call the SQL layer uses. + DrizzleDialect::Pg => DefaultChain::literal(".defaultRandom()".to_string()), + // The other dialects mirror the generator the SQL layer actually + // put on the column, not the PostgreSQL spelling of the model. + // Lowercase on MySQL: `information_schema` stores `(uuid())`, and + // the kit's comparison is case-sensitive (measured on MySQL 8). + DrizzleDialect::Mysql => DefaultChain::tagged("(uuid())"), + DrizzleDialect::Sqlite => DefaultChain::tagged("(lower(hex(randomblob(16))))"), + }; + } + + // A JSON object or array literal has to reach the database as a JSON + // literal, not as a bare SQL fragment. + if matches!(col_type, ColumnType::Simple(SimpleColumnType::Json)) { + let trimmed = default_sql.trim(); + if trimmed.starts_with('{') || trimmed.starts_with('[') { + let quoted = trimmed.replace('\'', "''"); + return match dialect { + DrizzleDialect::Pg => DefaultChain::tagged(&format!("'{quoted}'::json")), + _ => DefaultChain::tagged(&format!("'{quoted}'")), + }; + } + } + + // Anything else that looks like a call is a server-side expression. + if default_sql.contains('(') { + return DefaultChain::tagged(default_sql); + } + + if default_sql.starts_with('\'') || default_sql.starts_with('"') { + // `unquote` keeps the doubled SQL escape (its other consumers re-emit + // into SQL); a TS string wants the actual value, so undouble here. + let inner = unquote(default_sql); + let value = if default_sql.starts_with('\'') { + inner.replace("''", "'") + } else { + inner.to_string() + }; + return DefaultChain::literal(format!(".default({})", ts_string(&value))); + } + + if default_sql.parse::().is_ok() { + // Drizzle types a numeric/decimal default as a string — arbitrary + // precision exceeds a JS number — so those keep the literal quoted. + let numeric_col = matches!( + col_type, + ColumnType::Complex(ComplexColumnType::Numeric { .. }) + ); + return DefaultChain::literal(if numeric_col { + format!(".default({})", ts_string(default_sql)) + } else { + format!(".default({default_sql})") + }); + } + + // An integer enum's default names a variant; the column stores its value. + if let ColumnType::Complex(ComplexColumnType::Enum { + values: EnumValues::Integer(variants), + .. + }) = col_type + && let Some(variant) = variants.iter().find(|v| v.name == default_sql) + { + return DefaultChain::literal(format!(".default({})", variant.value)); + } + + // A bare keyword such as `CURRENT_USER`. + DefaultChain::tagged(default_sql) +} + +// ─── Reference action ──────────────────────────────────────────────────────── + +fn reference_action_to_drizzle(action: &ReferenceAction) -> &'static str { + match action { + ReferenceAction::Cascade => "cascade", + ReferenceAction::Restrict => "restrict", + ReferenceAction::SetNull => "set null", + ReferenceAction::SetDefault => "set default", + // `NoAction`, plus — `ReferenceAction` is `#[non_exhaustive]` — any + // action added later, which falls back to the SQL default rather than + // to a keyword Drizzle cannot parse. + _ => "no action", + } +} diff --git a/crates/vespertide-exporter/src/drizzle/types.rs b/crates/vespertide-exporter/src/drizzle/types.rs new file mode 100644 index 00000000..2e401bdd --- /dev/null +++ b/crates/vespertide-exporter/src/drizzle/types.rs @@ -0,0 +1,343 @@ +//! Column type mapping: `ColumnType` → a Drizzle column constructor. +//! +//! Each dialect exposes its own set of constructors, so the mapping forks +//! three ways. Every arm produces a [`ColumnCtor`], which carries both the +//! import symbol and the call text — the import header and the column body are +//! derived from the same decision rather than from two parallel matches. + +use vespertide_core::schema::column::{ + ColumnType, ComplexColumnType, EnumValues, SimpleColumnType, +}; + +use super::bindings::FileBindings; +use super::{DrizzleDialect, js_name}; +use crate::utils::typescript::ts_string; + +/// One resolved Drizzle column constructor. +pub(super) struct ColumnCtor { + /// Bare constructor name — also the symbol to import, unless `local`. + pub(super) symbol: String, + /// Everything after the column-name argument, e.g. `, { length: 255 }`. + args: String, + /// Trailing comment naming the source type when the dialect has no exact + /// counterpart and the column is widened onto another constructor. + note: String, + /// `symbol` names a `const` declared in this file (a `pgEnum` or + /// `customType` helper), so it must be kept out of the dialect-core + /// import list. + pub(super) local: bool, +} + +impl ColumnCtor { + /// The full constructor call, e.g. `varchar("email", { length: 255 })`. + pub(super) fn call(&self, col_db: &str) -> String { + format!( + "{}({}{}){}", + self.symbol, + ts_string(col_db), + self.args, + self.note + ) + } +} + +fn ctor(symbol: &str) -> ColumnCtor { + ColumnCtor { + symbol: symbol.to_string(), + args: String::new(), + note: String::new(), + local: false, + } +} + +fn ctor_args(symbol: &str, args: String) -> ColumnCtor { + ColumnCtor { + args, + ..ctor(symbol) + } +} + +/// A column the dialect cannot represent exactly, mapped onto a wider +/// constructor with the source type recorded in a comment. +fn widened(symbol: &str, args: String, source: &str) -> ColumnCtor { + ColumnCtor { + args, + note: format!(" /* {source} */"), + ..ctor(symbol) + } +} + +/// `["draft", "published"]` — the variant list MySQL and SQLite inline into the +/// column, since neither declares an enum type separately. +fn enum_value_list(values: &[String]) -> String { + let items: Vec = values.iter().map(|v| ts_string(v)).collect(); + format!("[{}]", items.join(", ")) +} + +// ─── customType helpers ────────────────────────────────────────────────────── + +/// A `customType` helper const: the column resolves to it, and the file has to +/// declare it. One source feeds both, so the declaration and its call sites +/// cannot disagree. +pub(super) struct CustomTypeDecl { + /// The natural binding (`js_name` of the data type); the final one comes + /// from `FileBindings`, which suffixes it on a file-scope collision. + pub(super) const_name: String, + pub(super) data_type: String, + /// The TypeScript type query results carry for the column. + ts_data: &'static str, +} + +impl CustomTypeDecl { + fn new(data_type: &str, ts_data: &'static str) -> Self { + Self { + const_name: js_name(data_type), + data_type: data_type.to_string(), + ts_data, + } + } +} + +/// The call side of a `customType` column: the (claimed) const it invokes. +fn custom_ctor(decl: &CustomTypeDecl, bindings: &FileBindings) -> ColumnCtor { + ColumnCtor { + symbol: bindings.custom_const(&decl.data_type), + local: true, + ..ctor("") + } +} + +/// `const bytea = customType<{ data: Uint8Array }>({ dataType() { return "bytea"; } });` +/// +/// `const_name` is the claimed binding — possibly suffixed — not necessarily +/// the decl's natural one. +pub(super) fn render_custom_type_decl(decl: &CustomTypeDecl, const_name: &str) -> String { + format!( + "const {const_name} = customType<{{ data: {} }}>({{ dataType() {{ return {}; }} }});", + decl.ts_data, + ts_string(&decl.data_type) + ) +} + +// `Uint8Array` rather than `Buffer`: the `pg` driver hands back a `Buffer`, +// which *is* a `Uint8Array` — and the model file keeps compiling without +// `@types/node`. +fn pg_bytea() -> CustomTypeDecl { + CustomTypeDecl::new("bytea", "Uint8Array") +} + +fn pg_xml() -> CustomTypeDecl { + CustomTypeDecl::new("xml", "string") +} + +/// The SQL layer passes a `Custom` type's name to every backend verbatim, so +/// every dialect's model spells it back verbatim through `customType`. +fn custom_passthrough(custom_type: &str) -> CustomTypeDecl { + CustomTypeDecl::new(custom_type, "string") +} + +/// The `customType` declaration `ty` needs under `dialect`, if any. +/// +/// PostgreSQL adds `bytea` and `xml` here because `pg-core` has no constructor +/// for either and widening them onto `text` reads as a column-type change to +/// `drizzle-kit`; on MySQL and SQLite those two map onto real constructors. +pub(super) fn custom_column(ty: &ColumnType, dialect: DrizzleDialect) -> Option { + match ty { + ColumnType::Simple(SimpleColumnType::Bytea) if dialect == DrizzleDialect::Pg => { + Some(pg_bytea()) + } + ColumnType::Simple(SimpleColumnType::Xml) if dialect == DrizzleDialect::Pg => { + Some(pg_xml()) + } + ColumnType::Complex(ComplexColumnType::Custom { custom_type }) => { + Some(custom_passthrough(custom_type)) + } + _ => None, + } +} + +/// Resolve the constructor for `ty` under `dialect`. +pub(super) fn column_ctor( + ty: &ColumnType, + dialect: DrizzleDialect, + table: &str, + bindings: &FileBindings, +) -> ColumnCtor { + match dialect { + DrizzleDialect::Pg => pg_ctor(ty, table, bindings), + DrizzleDialect::Mysql => mysql_ctor(ty, table, bindings), + DrizzleDialect::Sqlite => sqlite_ctor(ty, table, bindings), + } +} + +fn pg_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCtor { + match ty { + ColumnType::Simple(s) => match s { + SimpleColumnType::SmallInt => ctor("smallint"), + SimpleColumnType::Integer => ctor("integer"), + // `bigint` is arbitrary-precision in PostgreSQL but a JS `number` + // loses precision past 2^53, so the mode has to be explicit. + SimpleColumnType::BigInt => ctor_args("bigint", ", { mode: \"number\" }".to_string()), + SimpleColumnType::Real => ctor("real"), + SimpleColumnType::DoublePrecision => ctor("doublePrecision"), + SimpleColumnType::Boolean => ctor("boolean"), + SimpleColumnType::Date => ctor("date"), + SimpleColumnType::Time => ctor("time"), + SimpleColumnType::Timestamp => ctor("timestamp"), + SimpleColumnType::Timestamptz => { + ctor_args("timestamp", ", { withTimezone: true }".to_string()) + } + SimpleColumnType::Uuid => ctor("uuid"), + // The SQL layer creates `json`, not `jsonb` — the model has to + // match the column the migration actually made. + SimpleColumnType::Json => ctor("json"), + SimpleColumnType::Interval => ctor("interval"), + SimpleColumnType::Inet => ctor("inet"), + SimpleColumnType::Cidr => ctor("cidr"), + SimpleColumnType::Macaddr => ctor("macaddr"), + // `pg-core` exports no `bytea` or `xml` constructor; both go + // through a `customType` helper so the type name reaches + // `drizzle-kit` unchanged. + SimpleColumnType::Bytea => custom_ctor(&pg_bytea(), bindings), + SimpleColumnType::Xml => custom_ctor(&pg_xml(), bindings), + SimpleColumnType::Text => ctor("text"), + _ => unreachable!( + "SimpleColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Pg, table, bindings), + } +} + +fn mysql_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCtor { + match ty { + ColumnType::Simple(s) => match s { + SimpleColumnType::SmallInt => ctor("smallint"), + SimpleColumnType::Integer => ctor("int"), + SimpleColumnType::BigInt => ctor_args("bigint", ", { mode: \"number\" }".to_string()), + SimpleColumnType::Real => ctor("float"), + SimpleColumnType::DoublePrecision => ctor("double"), + SimpleColumnType::Boolean => ctor("boolean"), + SimpleColumnType::Date => ctor("date"), + SimpleColumnType::Time => ctor("time"), + // vespertide maps both timestamp types onto MySQL `TIMESTAMP`, so + // the generated model follows the SQL layer rather than splitting + // them across `datetime` and `timestamp`. + SimpleColumnType::Timestamp | SimpleColumnType::Timestamptz => ctor("timestamp"), + // The SQL layer stores MySQL uuids as `binary(16)`. + SimpleColumnType::Uuid => widened("binary", ", { length: 16 }".to_string(), "uuid"), + SimpleColumnType::Json => ctor("json"), + // MySQL has no counterpart for the PostgreSQL-specific types. + SimpleColumnType::Interval => widened("text", String::new(), "interval"), + // `binary(1)` mirrors the SQL layer's MySQL bytea column. + SimpleColumnType::Bytea => widened("binary", ", { length: 1 }".to_string(), "bytea"), + SimpleColumnType::Inet => widened("text", String::new(), "inet"), + SimpleColumnType::Cidr => widened("text", String::new(), "cidr"), + SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), + SimpleColumnType::Xml => widened("text", String::new(), "xml"), + SimpleColumnType::Text => ctor("text"), + _ => unreachable!( + "SimpleColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Mysql, table, bindings), + } +} + +fn sqlite_ctor(ty: &ColumnType, table: &str, bindings: &FileBindings) -> ColumnCtor { + match ty { + ColumnType::Simple(s) => match s { + // SQLite stores every integer as a 64-bit INTEGER; drizzle's + // `bigint` mode lives on `blob`, which would change the storage + // class, so `big_int` stays an integer column like the SQL layer's. + SimpleColumnType::SmallInt | SimpleColumnType::Integer | SimpleColumnType::BigInt => { + ctor("integer") + } + SimpleColumnType::Real | SimpleColumnType::DoublePrecision => ctor("real"), + SimpleColumnType::Boolean => { + ctor_args("integer", ", { mode: \"boolean\" }".to_string()) + } + // SQLite has no date/time storage class; vespertide's own SQLite + // SQL stores these as TEXT, and the model matches it. + SimpleColumnType::Date => widened("text", String::new(), "date"), + SimpleColumnType::Time => widened("text", String::new(), "time"), + SimpleColumnType::Timestamp => widened("text", String::new(), "timestamp"), + SimpleColumnType::Timestamptz => widened("text", String::new(), "timestamptz"), + SimpleColumnType::Uuid => widened("text", String::new(), "uuid"), + SimpleColumnType::Json => ctor_args("text", ", { mode: \"json\" }".to_string()), + // SQLite has no counterpart for the PostgreSQL-specific types. + SimpleColumnType::Interval => widened("text", String::new(), "interval"), + SimpleColumnType::Bytea => widened("blob", String::new(), "bytea"), + SimpleColumnType::Inet => widened("text", String::new(), "inet"), + SimpleColumnType::Cidr => widened("text", String::new(), "cidr"), + SimpleColumnType::Macaddr => widened("text", String::new(), "macaddr"), + SimpleColumnType::Xml => widened("text", String::new(), "xml"), + SimpleColumnType::Text => ctor("text"), + _ => unreachable!( + "SimpleColumnType is #[non_exhaustive]; all variants are matched above" + ), + }, + ColumnType::Complex(c) => complex_ctor(c, DrizzleDialect::Sqlite, table, bindings), + } +} + +fn complex_ctor( + c: &ComplexColumnType, + dialect: DrizzleDialect, + table: &str, + bindings: &FileBindings, +) -> ColumnCtor { + match c { + ComplexColumnType::Varchar { length } => match dialect { + DrizzleDialect::Pg | DrizzleDialect::Mysql => { + ctor_args("varchar", format!(", {{ length: {length} }}")) + } + DrizzleDialect::Sqlite => ctor_args("text", format!(", {{ length: {length} }}")), + }, + ComplexColumnType::Char { length } => match dialect { + DrizzleDialect::Pg | DrizzleDialect::Mysql => { + ctor_args("char", format!(", {{ length: {length} }}")) + } + DrizzleDialect::Sqlite => widened("text", format!(", {{ length: {length} }}"), "char"), + }, + ComplexColumnType::Numeric { precision, scale } => match dialect { + DrizzleDialect::Pg => ctor_args( + "numeric", + format!(", {{ precision: {precision}, scale: {scale} }}"), + ), + DrizzleDialect::Mysql => ctor_args( + "decimal", + format!(", {{ precision: {precision}, scale: {scale} }}"), + ), + // SQLite's `numeric` takes no precision or scale. + DrizzleDialect::Sqlite => ctor("numeric"), + }, + ComplexColumnType::Custom { custom_type } => { + custom_ctor(&custom_passthrough(custom_type), bindings) + } + ComplexColumnType::Enum { name, values } => match values { + // Integer enums are stored as their numeric value, so they stay + // plain integer columns in every dialect. + EnumValues::Integer(_) => match dialect { + DrizzleDialect::Pg | DrizzleDialect::Sqlite => ctor("integer"), + DrizzleDialect::Mysql => ctor("int"), + }, + EnumValues::String(vals) => match dialect { + // PostgreSQL declares the enum as its own type; the column + // calls that const. + DrizzleDialect::Pg => ColumnCtor { + symbol: bindings.enum_const(table, name), + local: true, + ..ctor("") + }, + DrizzleDialect::Mysql => { + ctor_args("mysqlEnum", format!(", {}", enum_value_list(vals))) + } + DrizzleDialect::Sqlite => { + ctor_args("text", format!(", {{ enum: {} }}", enum_value_list(vals))) + } + }, + }, + _ => unreachable!("ComplexColumnType is #[non_exhaustive]; all variants are matched above"), + } +} diff --git a/crates/vespertide-exporter/src/lib.rs b/crates/vespertide-exporter/src/lib.rs index 968a920b..c860a920 100644 --- a/crates/vespertide-exporter/src/lib.rs +++ b/crates/vespertide-exporter/src/lib.rs @@ -1,7 +1,8 @@ //! Helpers to convert `TableDef` models into ORM-specific representations -//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, and Prisma. +//! such as `SeaORM`, `SQLAlchemy`, `SQLModel`, JPA, Prisma, and Drizzle. mod constraint_scan; +pub mod drizzle; mod enum_scan; pub mod jpa; pub mod orm; @@ -15,6 +16,7 @@ pub mod sqlmodel; mod tests; mod utils; +pub use drizzle::DrizzleExporter; pub use jpa::JpaExporter; pub use orm::{Orm, OrmExporter, render_entity, render_entity_with_schema}; pub use prisma::PrismaExporter; diff --git a/crates/vespertide-exporter/src/orm.rs b/crates/vespertide-exporter/src/orm.rs index fa410426..c520b5fa 100644 --- a/crates/vespertide-exporter/src/orm.rs +++ b/crates/vespertide-exporter/src/orm.rs @@ -1,7 +1,7 @@ use vespertide_core::TableDef; use crate::{ - jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, + drizzle::DrizzleExporter, jpa::JpaExporter, prisma::PrismaExporter, seaorm::SeaOrmExporter, sqlalchemy::SqlAlchemyExporter, sqlmodel::SqlModelExporter, }; @@ -16,6 +16,7 @@ pub enum Orm { SqlModel, Jpa, Prisma, + Drizzle, } impl Orm { @@ -26,6 +27,7 @@ impl Orm { Orm::SqlAlchemy | Orm::SqlModel => "py", Orm::Jpa => "java", Orm::Prisma => "prisma", + Orm::Drizzle => "ts", } } } @@ -53,6 +55,7 @@ pub fn render_entity(orm: Orm, table: &TableDef) -> Result { Orm::SqlModel => SqlModelExporter.render_entity(table), Orm::Jpa => JpaExporter.render_entity(table), Orm::Prisma => PrismaExporter.render_entity(table), + Orm::Drizzle => DrizzleExporter.render_entity(table), } } @@ -68,6 +71,7 @@ pub fn render_entity_with_schema( Orm::SqlModel => SqlModelExporter.render_entity_with_schema(table, schema), Orm::Jpa => JpaExporter.render_entity_with_schema(table, schema), Orm::Prisma => PrismaExporter.render_entity_with_schema(table, schema), + Orm::Drizzle => DrizzleExporter.render_entity_with_schema(table, schema), } } @@ -83,6 +87,7 @@ mod tests { #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] + #[case::drizzle(Orm::Drizzle)] fn dispatch_render_entity_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); assert!(render_entity(orm, &table).is_ok()); @@ -94,6 +99,7 @@ mod tests { #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] + #[case::drizzle(Orm::Drizzle)] fn dispatch_render_entity_with_schema_succeeds(#[case] orm: Orm) { let table = basic_single_pk(); let schema = vec![table.clone()]; @@ -106,6 +112,7 @@ mod tests { #[case::sqlmodel(Orm::SqlModel, "py")] #[case::jpa(Orm::Jpa, "java")] #[case::prisma(Orm::Prisma, "prisma")] + #[case::drizzle(Orm::Drizzle, "ts")] fn file_extension_matches_backend(#[case] orm: Orm, #[case] expected: &str) { assert_eq!(orm.file_extension(), expected); } @@ -118,6 +125,7 @@ mod tests { #[case::sqlmodel("sqlmodel", Orm::SqlModel)] #[case::jpa("jpa", Orm::Jpa)] #[case::prisma("prisma", Orm::Prisma)] + #[case::drizzle("drizzle", Orm::Drizzle)] fn value_enum_parses_cli_name(#[case] input: &str, #[case] expected: Orm) { assert_eq!( clap::ValueEnum::from_str(input, false), diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 4cb6fc61..5a20e3a8 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -18,6 +18,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { Orm::SqlModel => crate::sqlmodel::render_entities(schema), Orm::Jpa => crate::jpa::render_entities(schema).map(|entities| entities.join("\n")), Orm::Prisma => crate::prisma::export(schema), + Orm::Drizzle => crate::drizzle::export(schema), } } @@ -31,6 +32,7 @@ macro_rules! orm_cases { #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] + #[case::drizzle(Orm::Drizzle)] fn $test_name(#[case] orm: Orm) { let table = $fixture(); let rendered = render_entity(orm, &table).unwrap(); @@ -48,6 +50,7 @@ macro_rules! orm_cases { #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] + #[case::drizzle(Orm::Drizzle)] fn $test_name(#[case] orm: Orm) { let schema: Vec = $fixture(); let rendered = render_schema(orm, &schema).unwrap(); @@ -359,7 +362,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { Orm::SqlAlchemy => crate::sqlalchemy::to_pascal_case_for_tests(s), Orm::SqlModel => crate::sqlmodel::to_pascal_case_for_tests(s), Orm::Jpa => crate::jpa::to_pascal_case_for_tests(s), - Orm::Prisma => vespertide_naming::to_pascal_case(s), + Orm::Prisma | Orm::Drizzle => vespertide_naming::to_pascal_case(s), } } @@ -381,6 +384,7 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] +#[case::drizzle(Orm::Drizzle)] fn to_pascal_case_shared_semantics( #[values( ("", ""), @@ -408,6 +412,7 @@ fn to_pascal_case_shared_semantics( #[case::sqlmodel(Orm::SqlModel)] #[case::jpa(Orm::Jpa)] #[case::prisma(Orm::Prisma)] +#[case::drizzle(Orm::Drizzle)] fn render_entity_with_schema_snapshots( #[values( "many_to_many_article", diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap new file mode 100644 index 00000000..ade2de92 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap @@ -0,0 +1,70 @@ +--- +source: crates/vespertide-exporter/src/drizzle/mod.rs +assertion_line: 285 +expression: rendered +--- +import { mysqlTable, primaryKey, foreignKey, unique, index, check, bigint, int, mysqlEnum, text } from "drizzle-orm/mysql-core"; +import { relations, sql } from "drizzle-orm"; + +export const users = mysqlTable("users", { + id: int("id").primaryKey(), + displayName: text("display_name"), +}); + +export const posts = mysqlTable("posts", { + id: int("id").primaryKey(), + userId: int("user_id").notNull(), + title: text("title").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), +]); + +export const documents = mysqlTable("documents", { + id: int("id").notNull(), + status: mysqlEnum("status", ["draft", "published", "archived"]).notNull(), + reviewStatus: mysqlEnum("review_status", ["draft", "published", "archived"]), +}); + +export const accounts = mysqlTable("accounts", { + id: int("id").notNull(), + tenantId: bigint("tenant_id", { mode: "number" }).notNull(), +}, (t) => [ + primaryKey({ columns: [t.id, t.tenantId] }), +]); + +export const compositeUnique = mysqlTable("composite_unique", { + id: int("id").primaryKey(), + tenantId: int("tenant_id").notNull(), + name: text("name").notNull(), +}, (t) => [ + unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), +]); + +export const users = mysqlTable("users", { + id: int("id").notNull(), + email: text("email").notNull().unique("uq_users__email"), + username: text("username").notNull().unique("uq_users__uq_username"), + department: text("department"), + status: text("status").notNull().default("active"), +}, (t) => [ + index("ix_users__idx_department").on(t.department), +]); + +export const products = mysqlTable("products", { + id: int("id").primaryKey(), + price: int("price").notNull(), +}, (t) => [ + check("chk_products_price", sql`price >= 0`), +]); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), +})); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap new file mode 100644 index 00000000..33e50dbc --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap @@ -0,0 +1,72 @@ +--- +source: crates/vespertide-exporter/src/drizzle/mod.rs +assertion_line: 285 +expression: rendered +--- +import { pgTable, pgEnum, primaryKey, foreignKey, unique, index, check, bigint, integer, text } from "drizzle-orm/pg-core"; +import { relations, sql } from "drizzle-orm"; + +export const docStatus = pgEnum("doc_status", ["draft", "published", "archived"]); + +export const users = pgTable("users", { + id: integer("id").primaryKey(), + displayName: text("display_name"), +}); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + title: text("title").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), +]); + +export const documents = pgTable("documents", { + id: integer("id").notNull(), + status: docStatus("status").notNull(), + reviewStatus: docStatus("review_status"), +}); + +export const accounts = pgTable("accounts", { + id: integer("id").notNull(), + tenantId: bigint("tenant_id", { mode: "number" }).notNull(), +}, (t) => [ + primaryKey({ columns: [t.id, t.tenantId] }), +]); + +export const compositeUnique = pgTable("composite_unique", { + id: integer("id").primaryKey(), + tenantId: integer("tenant_id").notNull(), + name: text("name").notNull(), +}, (t) => [ + unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), +]); + +export const users = pgTable("users", { + id: integer("id").notNull(), + email: text("email").notNull().unique("uq_users__email"), + username: text("username").notNull().unique("uq_users__uq_username"), + department: text("department"), + status: text("status").notNull().default("active"), +}, (t) => [ + index("ix_users__idx_department").on(t.department), +]); + +export const products = pgTable("products", { + id: integer("id").primaryKey(), + price: integer("price").notNull(), +}, (t) => [ + check("chk_products_price", sql`price >= 0`), +]); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), +})); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap new file mode 100644 index 00000000..a0f78d89 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap @@ -0,0 +1,70 @@ +--- +source: crates/vespertide-exporter/src/drizzle/mod.rs +assertion_line: 285 +expression: rendered +--- +import { sqliteTable, primaryKey, foreignKey, unique, index, check, integer, text } from "drizzle-orm/sqlite-core"; +import { relations, sql } from "drizzle-orm"; + +export const users = sqliteTable("users", { + id: integer("id").primaryKey(), + displayName: text("display_name"), +}); + +export const posts = sqliteTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + title: text("title").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id] }), +]); + +export const documents = sqliteTable("documents", { + id: integer("id").notNull(), + status: text("status", { enum: ["draft", "published", "archived"] }).notNull(), + reviewStatus: text("review_status", { enum: ["draft", "published", "archived"] }), +}); + +export const accounts = sqliteTable("accounts", { + id: integer("id").notNull(), + tenantId: integer("tenant_id").notNull(), +}, (t) => [ + primaryKey({ columns: [t.id, t.tenantId] }), +]); + +export const compositeUnique = sqliteTable("composite_unique", { + id: integer("id").primaryKey(), + tenantId: integer("tenant_id").notNull(), + name: text("name").notNull(), +}, (t) => [ + unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), +]); + +export const users = sqliteTable("users", { + id: integer("id").notNull(), + email: text("email").notNull().unique("uq_users__email"), + username: text("username").notNull().unique("uq_users__uq_username"), + department: text("department"), + status: text("status").notNull().default("active"), +}, (t) => [ + index("ix_users__idx_department").on(t.department), +]); + +export const products = sqliteTable("products", { + id: integer("id").primaryKey(), + price: integer("price").notNull(), +}, (t) => [ + check("chk_products_price", sql`price >= 0`), +]); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), +})); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap new file mode 100644 index 00000000..8420739c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap @@ -0,0 +1,26 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 141 +expression: rendered +--- +export const allTypes = pgTable("all_types", { + id: integer("id").primaryKey(), + small: smallint("small").notNull(), + big: bigint("big", { mode: "number" }).notNull(), + realNum: real("real_num").notNull(), + doubleNum: doublePrecision("double_num").notNull(), + textCol: text("text_col").notNull(), + boolCol: boolean("bool_col").notNull(), + dateCol: date("date_col").notNull(), + timeCol: time("time_col").notNull(), + tsCol: timestamp("ts_col").notNull(), + tstzCol: timestamp("tstz_col", { withTimezone: true }).notNull(), + intervalCol: interval("interval_col").notNull(), + byteaCol: text("bytea_col") /* bytea */.notNull(), + uuidCol: uuid("uuid_col").notNull(), + jsonCol: json("json_col").notNull(), + inetCol: inet("inet_col").notNull(), + cidrCol: cidr("cidr_col").notNull(), + macaddrCol: macaddr("macaddr_col").notNull(), + xmlCol: text("xml_col") /* xml */.notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Drizzle.snap new file mode 100644 index 00000000..c4fa140b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_single_pk_snapshot@basic_single_pk_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 69 +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").primaryKey(), + displayName: text("display_name"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap new file mode 100644 index 00000000..cfa2d822 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 64 +expression: rendered +--- +// User accounts table +export const users = pgTable("users", { + // Primary key + id: serial("id").primaryKey(), + // User email address + email: text("email").notNull().unique("uq_users__email"), + name: text("name"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap new file mode 100644 index 00000000..721d8257 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 146 +expression: rendered +--- +export const complexTypes = pgTable("complex_types", { + id: integer("id").primaryKey(), + varcharCol: varchar("varchar_col", { length: 100 }).notNull(), + charCol: char("char_col", { length: 10 }).notNull(), + numericCol: numeric("numeric_col", { precision: 10, scale: 2 }).notNull(), + customCol: text("custom_col") /* CUSTOM_TYPE */.notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap new file mode 100644 index 00000000..60700ee1 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 172 +expression: rendered +--- +export const orderItems = pgTable("order_items", { + orderId: integer("order_id").notNull(), + productId: integer("product_id").notNull(), + quantity: integer("quantity").notNull(), +}, (t) => [ + primaryKey({ columns: [t.orderId, t.productId] }), + foreignKey({ columns: [t.orderId], foreignColumns: [orders.id], name: "fk_order_items__order_id" }), + foreignKey({ columns: [t.productId], foreignColumns: [products.id], name: "fk_order_items__product_id" }), + unique("uq_order_items__uq_order_items__order_product").on(t.orderId, t.productId), + index("ix_order_items__ix_order_items__order_id").on(t.orderId), +]); + +export const orderItemsRelations = relations(orderItems, ({ one, many }) => ({ + order: one(orders, { fields: [orderItems.orderId], references: [orders.id] }), + product: one(products, { fields: [orderItems.productId], references: [products.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap new file mode 100644 index 00000000..51a488ad --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 305 +expression: rendered +--- +export const orders = pgTable("orders", { + id: integer("id").notNull(), + version: integer("version").notNull(), +}, (t) => [ + primaryKey({ columns: [t.id, t.version] }), +]); + +export const ordersRelations = relations(orders, ({ one, many }) => ({ + lineItems: many(lineItems), +})); + +export const lineItems = pgTable("line_items", { + id: integer("id").primaryKey(), + orderId: integer("order_id").notNull(), + orderVersion: integer("order_version").notNull(), + sku: text("sku").notNull(), +}, (t) => [ + foreignKey({ columns: [t.orderId, t.orderVersion], foreignColumns: [orders.id, orders.version], name: "fk_line_items__order_id_order_version" }), +]); + +export const lineItemsRelations = relations(lineItems, ({ one, many }) => ({ + orderOrderVersion: one(orders, { fields: [lineItems.orderId, lineItems.orderVersion], references: [orders.id, orders.version] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Drizzle.snap new file mode 100644 index 00000000..cbd7b33d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_index_snapshot@composite_index_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 182 +expression: rendered +--- +export const compositeIndex = pgTable("composite_index", { + id: integer("id").primaryKey(), + tenantId: integer("tenant_id").notNull(), + name: text("name").notNull(), +}, (t) => [ + index("ix_composite_index__idx_tenant_name").on(t.tenantId, t.name), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap new file mode 100644 index 00000000..5eb07582 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +export const accounts = pgTable("accounts", { + id: integer("id").notNull(), + tenantId: bigint("tenant_id", { mode: "number" }).notNull(), +}, (t) => [ + primaryKey({ columns: [t.id, t.tenantId] }), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap new file mode 100644 index 00000000..46bbbd74 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 247 +expression: rendered +--- +export const membership = pgTable("membership", { + tenantId: integer("tenant_id").notNull(), + userId: integer("user_id").notNull(), + role: text("role").notNull(), +}, (t) => [ + primaryKey({ columns: [t.tenantId, t.userId] }), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap new file mode 100644 index 00000000..762a3a6d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 252 +expression: rendered +--- +export const accountAliases = pgTable("account_aliases", { + id: integer("id").primaryKey(), + tenantId: integer("tenant_id").notNull(), + slug: text("slug").notNull(), +}, (t) => [ + unique("uq_account_aliases__uq_account_aliases__tenant_slug").on(t.tenantId, t.slug), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap new file mode 100644 index 00000000..f334b3ce --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 177 +expression: rendered +--- +export const compositeUnique = pgTable("composite_unique", { + id: integer("id").primaryKey(), + tenantId: integer("tenant_id").notNull(), + name: text("name").notNull(), +}, (t) => [ + unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap new file mode 100644 index 00000000..437163e6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 156 +expression: rendered +--- +export const articles = pgTable("articles", { + id: serial("id").primaryKey(), + published: boolean("published").notNull().default(false), + viewCount: integer("view_count").notNull().default(0), + status: text("status").notNull().default("draft"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap new file mode 100644 index 00000000..1ecc8dcb --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 110 +expression: rendered +--- +export const productCategory = pgEnum("product_category", ["electronics", "clothing", "food"]); + +export const availabilityStatus = pgEnum("availability_status", ["in_stock", "out_of_stock", "pre_order"]); + +export const products = pgTable("products", { + id: integer("id").notNull(), + category: productCategory("category").notNull(), + availability: availabilityStatus("availability").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap new file mode 100644 index 00000000..5b5f7b70 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 115 +expression: rendered +--- +export const docStatus = pgEnum("doc_status", ["draft", "published", "archived"]); + +export const documents = pgTable("documents", { + id: integer("id").notNull(), + status: docStatus("status").notNull(), + reviewStatus: docStatus("review_status"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap new file mode 100644 index 00000000..f6214a1a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 116 +expression: rendered +--- +export const eventSeverity = pgEnum("event_severity", ["info-level", "warning_level", "ERROR_LEVEL", "1critical"]); + +export const events = pgTable("events", { + id: integer("id").notNull(), + severity: eventSeverity("severity").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap new file mode 100644 index 00000000..40973f01 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 121 +expression: rendered +--- +export const taskStatus = pgEnum("task_status", ["pending", "in_progress", "completed"]); + +export const tasks = pgTable("tasks", { + id: integer("id").notNull(), + status: taskStatus("status").notNull().default("pending"), + priority: integer("priority").notNull().default(0), + isArchived: boolean("is_archived").notNull().default(false), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Drizzle.snap new file mode 100644 index 00000000..48d9608c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__false_boolean_default_snapshot@false_boolean_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 212 +expression: rendered +--- +export const boolDefaults = pgTable("bool_defaults", { + id: integer("id").primaryKey(), + isDeleted: boolean("is_deleted").notNull().default(false), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap new file mode 100644 index 00000000..5078ee40 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 313 +expression: rendered +--- +export const target = pgTable("target", { + id: integer("id").primaryKey(), + alt: integer("alt").notNull().unique("uq_target__alt"), +}); + +export const targetRelations = relations(target, ({ one, many }) => ({ + aSrc: many(src, { relationName: "SrcA" }), + aSrc_rel: many(src, { relationName: "SrcA2" }), +})); + +export const src = pgTable("src", { + pk: integer("pk").primaryKey(), + aId: integer("a_id"), + a: integer("a"), +}, (t) => [ + foreignKey({ columns: [t.aId], foreignColumns: [target.id], name: "fk_src__a_id" }), + foreignKey({ columns: [t.a], foreignColumns: [target.alt], name: "fk_src__a" }), +]); + +export const srcRelations = relations(src, ({ one, many }) => ({ + aTarget: one(target, { fields: [src.aId], references: [target.id], relationName: "SrcA" }), + aTarget_rel: one(target, { fields: [src.a], references: [target.alt], relationName: "SrcA2" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap new file mode 100644 index 00000000..2a222dc6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 227 +expression: rendered +--- +export const child = pgTable("child", { + // References parent table + parentId: serial("parent_id").primaryKey(), + value: text("value").notNull(), +}, (t) => [ + foreignKey({ columns: [t.parentId], foreignColumns: [parent.id], name: "fk_child__parent_id" }), +]); + +export const childRelations = relations(child, ({ one, many }) => ({ + parent: one(parent, { fields: [child.parentId], references: [parent.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Drizzle.snap new file mode 100644 index 00000000..9b596b20 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__inline_pk_snapshot@inline_pk_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 89 +expression: rendered +--- +export const users = pgTable("users", { + id: uuid("id").notNull().defaultRandom(), + email: text("email").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Drizzle.snap new file mode 100644 index 00000000..7a81fdb8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_all_variant_types_snapshot@integer_enum_all_variant_types_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 257 +expression: rendered +--- +export const workflowRuns = pgTable("workflow_runs", { + id: integer("id").primaryKey(), + state: integer("state").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Drizzle.snap new file mode 100644 index 00000000..21965ad2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_default_snapshot@integer_enum_with_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 267 +expression: rendered +--- +export const tasks = pgTable("tasks", { + id: integer("id").notNull(), + status: integer("status").notNull().default(1), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Drizzle.snap new file mode 100644 index 00000000..e2e3ccc6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__integer_enum_with_variant_default_snapshot@integer_enum_with_variant_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 333 +expression: rendered +--- +export const taskRuns = pgTable("task_runs", { + id: integer("id").notNull(), + status: integer("status").notNull().default(100), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Drizzle.snap new file mode 100644 index 00000000..831a6d7c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__json_default_snapshot@json_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 232 +expression: rendered +--- +export const configs = pgTable("configs", { + id: integer("id").primaryKey(), + data: json("data").notNull().default(sql`'{"hello": "world"}'::json`), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap new file mode 100644 index 00000000..530d3c02 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 151 +expression: rendered +--- +export const jsonStruct = pgTable("json_struct", { + id: integer("id").notNull(), + jsonData: json("json_data").notNull(), + jsonbData: text("jsonb_data") /* JSONB */.notNull(), + jsonbNullable: text("jsonb_nullable") /* jsonb */, +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Drizzle.snap new file mode 100644 index 00000000..7eb4c7ae --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__no_description_snapshot@no_description_Drizzle.snap @@ -0,0 +1,8 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 202 +expression: rendered +--- +export const noDesc = pgTable("no_desc", { + id: integer("id").primaryKey(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap new file mode 100644 index 00000000..ce8ce5d0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 286 +expression: rendered +--- +export const membership = pgTable("membership", { + x1tenantId: integer("1tenant_id").notNull(), + x2userId: integer("2user_id").notNull(), + userEmail: text("user-email").notNull(), + x3created: text("3created").notNull(), +}, (t) => [ + primaryKey({ columns: [t.x1tenantId, t.x2userId] }), + unique("uq_membership__1tenant_id_user-email").on(t.userEmail, t.x1tenantId), + index("ix_membership__3created").on(t.x3created), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Drizzle.snap new file mode 100644 index 00000000..9b867251 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_snapshot@non_identifier_names_Drizzle.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 277 +expression: rendered +--- +export const x1users = pgTable("1users", { + id: integer("id").primaryKey(), + x1stPlace: integer("1st_place"), + userId: text("user-id"), + x1stOwnerId: integer("1st_owner_id"), +}, (t) => [ + foreignKey({ columns: [t.x1stOwnerId], foreignColumns: [t.id], name: "fk_1users__1st_owner_id" }), +]); + +export const x1usersRelations = relations(x1users, ({ one, many }) => ({ + x1stOwner: one(x1users, { fields: [x1users.x1stOwnerId], references: [x1users.id], relationName: "1users1stOwner" }), + x1stOwner1users: many(x1users, { relationName: "1users1stOwner" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Drizzle.snap new file mode 100644 index 00000000..f55d1261 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_relation_names_snapshot@non_identifier_relation_names_Drizzle.snap @@ -0,0 +1,28 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 296 +expression: rendered +--- +export const x1users = pgTable("1users", { + x1id: integer("1id").primaryKey(), + email: text("email").notNull(), +}); + +export const x1usersRelations = relations(x1users, ({ one, many }) => ({ + x1stOwnerPosts: many(posts, { relationName: "Posts1stOwner" }), + x2ndOwnerPosts: many(posts, { relationName: "Posts2ndOwner" }), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + x1stOwnerId: integer("1st_owner_id"), + x2ndOwnerId: integer("2nd_owner_id"), +}, (t) => [ + foreignKey({ columns: [t.x1stOwnerId], foreignColumns: [x1users.x1id], name: "fk_posts__1st_owner_id" }), + foreignKey({ columns: [t.x2ndOwnerId], foreignColumns: [x1users.x1id], name: "fk_posts__2nd_owner_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + x1stOwner: one(x1users, { fields: [posts.x1stOwnerId], references: [x1users.x1id], relationName: "Posts1stOwner" }), + x2ndOwner: one(x1users, { fields: [posts.x2ndOwnerId], references: [x1users.x1id], relationName: "Posts2ndOwner" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap new file mode 100644 index 00000000..b95adef8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 167 +expression: rendered +--- +export const profiles = pgTable("profiles", { + id: serial("id").primaryKey(), + bio: text("bio"), + avatarUrl: varchar("avatar_url", { length: 500 }), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap new file mode 100644 index 00000000..3c742212 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 105 +expression: rendered +--- +export const statusType = pgEnum("status_type", ["active", "inactive"]); + +export const nullableEnum = pgTable("nullable_enum", { + id: integer("id").primaryKey(), + status: statusType("status"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Drizzle.snap new file mode 100644 index 00000000..6da5d5da --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__numeric_default_value_snapshot@numeric_default_value_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 262 +expression: rendered +--- +export const products = pgTable("products", { + id: integer("id").notNull(), + price: numeric("price", { precision: 10, scale: 2 }).notNull().default("0"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap new file mode 100644 index 00000000..5f70cca8 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap @@ -0,0 +1,24 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 90 +expression: rendered +--- +export const articleUser = pgTable("article_user", { + articleId: uuid("article_id").notNull(), + userId: uuid("user_id").notNull(), + authorOrder: integer("author_order").notNull().default(1), + role: varchar("role", { length: 20 }).notNull().default("contributor"), + isLead: boolean("is_lead").notNull().default(false), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (t) => [ + primaryKey({ columns: [t.articleId, t.userId] }), + foreignKey({ columns: [t.articleId], foreignColumns: [article.id], name: "fk_article_user__article_id" }).onDelete("cascade"), + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_article_user__user_id" }).onDelete("cascade"), + index("ix_article_user__article_id").on(t.articleId), + index("ix_article_user__user_id").on(t.userId), +]); + +export const articleUserRelations = relations(articleUser, ({ one, many }) => ({ + article: one(article, { fields: [articleUser.articleId], references: [article.id] }), + user: one(user, { fields: [articleUser.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Drizzle.snap new file mode 100644 index 00000000..02cb8517 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__relation_name_taken_by_column_snapshot@relation_name_taken_by_column_Drizzle.snap @@ -0,0 +1,23 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 322 +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").primaryKey(), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + items: many(items), +})); + +export const items = pgTable("items", { + id: integer("id").primaryKey(), + owner: integer("owner"), +}, (t) => [ + foreignKey({ columns: [t.owner], foreignColumns: [users.id], name: "fk_items__owner" }), +]); + +export const itemsRelations = relations(items, ({ one, many }) => ({ + ownerUsers: one(users, { fields: [items.owner], references: [users.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Drizzle.snap new file mode 100644 index 00000000..e577b9c0 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_and_single_fk_same_target_Drizzle.snap @@ -0,0 +1,19 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const src = pgTable("src", { + id: integer("id").primaryKey(), + aId: integer("a_id").notNull(), + bId: integer("b_id").notNull(), + solo: integer("solo").notNull(), +}, (t) => [ + foreignKey({ columns: [t.aId, t.bId], foreignColumns: [target.a, target.b], name: "fk_src__a_id_b_id" }), + foreignKey({ columns: [t.solo], foreignColumns: [target.u], name: "fk_src__solo" }), +]); + +export const srcRelations = relations(src, ({ one, many }) => ({ + aB: one(target, { fields: [src.aId, src.bId], references: [target.a, target.b], relationName: "SrcAB" }), + soloTarget: one(target, { fields: [src.solo], references: [target.u], relationName: "SrcSolo" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap new file mode 100644 index 00000000..c4f4a624 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const parent = pgTable("parent", { + id1: integer("id1").notNull(), + id2: integer("id2").notNull(), +}, (t) => [ + primaryKey({ columns: [t.id1, t.id2] }), +]); + +export const parentRelations = relations(parent, ({ one, many }) => ({ + childOne: one(childOne), + childMany: many(childMany), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Drizzle.snap new file mode 100644 index 00000000..d5d728e4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@dual_reverse_relations_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const dual = pgTable("dual", { + username: text("username").primaryKey(), +}); + +export const dualRelations = relations(dual, ({ one, many }) => ({ + usernameDualRel: many(dualRel, { relationName: "DualRelUsername" }), + checkerUsernameDualRel: many(dualRel, { relationName: "DualRelCheckerUsername" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Drizzle.snap new file mode 100644 index 00000000..d602b916 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_article_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const article = pgTable("article", { + id: bigint("id", { mode: "number" }).primaryKey(), +}); + +export const articleRelations = relations(article, ({ one, many }) => ({ + articleUser: many(articleUser), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Drizzle.snap new file mode 100644 index 00000000..d602b916 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_missing_target_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const article = pgTable("article", { + id: bigint("id", { mode: "number" }).primaryKey(), +}); + +export const articleRelations = relations(article, ({ one, many }) => ({ + articleUser: many(articleUser), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Drizzle.snap new file mode 100644 index 00000000..44278c0c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_multiple_junctions_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + userMediaRole: many(userMediaRole), + userMediaFavorite: many(userMediaFavorite), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Drizzle.snap new file mode 100644 index 00000000..55ed9c6a --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@many_to_many_user_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + articleUser: many(articleUser), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Drizzle.snap new file mode 100644 index 00000000..cd7224fd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_fk_same_table_Drizzle.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const post = pgTable("post", { + id: uuid("id").primaryKey(), + creatorUserId: uuid("creator_user_id").notNull(), + usedByUserId: uuid("used_by_user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.creatorUserId], foreignColumns: [user.id], name: "fk_post__creator_user_id" }), + foreignKey({ columns: [t.usedByUserId], foreignColumns: [user.id], name: "fk_post__used_by_user_id" }), +]); + +export const postRelations = relations(post, ({ one, many }) => ({ + creatorUser: one(user, { fields: [post.creatorUserId], references: [user.id], relationName: "PostCreatorUser" }), + usedByUser: one(user, { fields: [post.usedByUserId], references: [user.id], relationName: "PostUsedByUser" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Drizzle.snap new file mode 100644 index 00000000..074358e9 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_has_one_relations_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + createdByUserSettings: one(settings, { relationName: "SettingsCreatedByUser" }), + updatedByUserSettings: one(settings, { relationName: "SettingsUpdatedByUser" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Drizzle.snap new file mode 100644 index 00000000..0a4d464d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@multiple_reverse_relations_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const user = pgTable("user", { + id: uuid("id").primaryKey(), +}); + +export const userRelations = relations(user, ({ one, many }) => ({ + preferredUserProfile: many(profile, { relationName: "ProfilePreferredUser" }), + backupUserProfile: many(profile, { relationName: "ProfileBackupUser" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Drizzle.snap new file mode 100644 index 00000000..3ab61643 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_another_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const another = pgTable("another", { + id: integer("id").primaryKey(), +}); + +export const anotherRelations = relations(another, ({ one, many }) => ({ + notJunction: many(notJunction), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Drizzle.snap new file mode 100644 index 00000000..f792c53f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_fk_not_in_pk_other_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const other = pgTable("other", { + id: integer("id").primaryKey(), +}); + +export const otherRelations = relations(other, ({ one, many }) => ({ + notJunction: many(notJunction), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Drizzle.snap new file mode 100644 index 00000000..949f07dd --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@not_junction_single_pk_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const other = pgTable("other", { + id: integer("id").primaryKey(), +}); + +export const otherRelations = relations(other, ({ one, many }) => ({ + regular: many(regular), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Drizzle.snap new file mode 100644 index 00000000..47456fce --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@triple_reverse_relations_Drizzle.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const dual = pgTable("dual", { + username: text("username").primaryKey(), +}); + +export const dualRelations = relations(dual, ({ one, many }) => ({ + usernameTripleRel: many(tripleRel, { relationName: "TripleRelUsername" }), + checkerUsernameTripleRel: many(tripleRel, { relationName: "TripleRelCheckerUsername" }), + otherUsernameTripleRel: many(tripleRel, { relationName: "TripleRelOtherUsername" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Drizzle.snap new file mode 100644 index 00000000..64905f64 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@username_fk_Drizzle.snap @@ -0,0 +1,15 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 440 +expression: rendered +--- +export const session = pgTable("session", { + id: uuid("id").primaryKey(), + username: text("username").notNull(), +}, (t) => [ + foreignKey({ columns: [t.username], foreignColumns: [user.username], name: "fk_session__username" }), +]); + +export const sessionRelations = relations(session, ({ one, many }) => ({ + usernameUser: one(user, { fields: [session.username], references: [user.username] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Drizzle.snap new file mode 100644 index 00000000..4be02566 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__reserved_word_identifiers_snapshot@reserved_word_identifiers_Drizzle.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 242 +expression: rendered +--- +export const order = pgTable("order", { + id: integer("id").primaryKey(), + user: text("user").notNull(), + select: integer("select").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Drizzle.snap new file mode 100644 index 00000000..8a6f6b64 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__self_referencing_fk_snapshot@self_referencing_fk_Drizzle.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 237 +expression: rendered +--- +export const employees = pgTable("employees", { + id: integer("id").primaryKey(), + managerId: integer("manager_id"), +}, (t) => [ + foreignKey({ columns: [t.managerId], foreignColumns: [t.id], name: "fk_employees__manager_id" }).onDelete("set null"), +]); + +export const employeesRelations = relations(employees, ({ one, many }) => ({ + manager: one(employees, { fields: [employees.managerId], references: [employees.id], relationName: "EmployeesManager" }), + managerEmployees: many(employees, { relationName: "EmployeesManager" }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap new file mode 100644 index 00000000..ade3a56d --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 162 +expression: rendered +--- +export const logs = pgTable("logs", { + id: serial("id").primaryKey(), + active: boolean("active").notNull().default(true), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + score: real("score").notNull().default(1.5), + tag: text("tag").notNull().default(sql`UNKNOWN_EXPR`), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap new file mode 100644 index 00000000..45eef47f --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 157 +expression: rendered +--- +export const withDefaults = pgTable("with_defaults", { + id: integer("id").primaryKey(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + status: text("status").notNull().default("active"), + count: integer("count").notNull().default(0), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Drizzle.snap new file mode 100644 index 00000000..82245283 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__small_multi_schema_sequential_snapshot@small_multi_schema_sequential_Drizzle.snap @@ -0,0 +1,25 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 348 +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").primaryKey(), + displayName: text("display_name"), +}); + +export const usersRelations = relations(users, ({ one, many }) => ({ + posts: many(posts), +})); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + title: text("title").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Drizzle.snap new file mode 100644 index 00000000..35d6a491 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__string_default_snapshot@string_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 207 +expression: rendered +--- +export const stringDefaults = pgTable("string_defaults", { + id: integer("id").primaryKey(), + status: text("status").notNull().default("active"), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Drizzle.snap new file mode 100644 index 00000000..ea05e66c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_level_pk_snapshot@table_level_pk_Drizzle.snap @@ -0,0 +1,10 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 136 +expression: rendered +--- +export const orders = pgTable("orders", { + id: uuid("id").primaryKey(), + customerId: uuid("customer_id").notNull(), + total: real("total").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Drizzle.snap new file mode 100644 index 00000000..f1ffa6e2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +export const products = pgTable("products", { + id: integer("id").primaryKey(), + price: integer("price").notNull(), +}, (t) => [ + check("chk_products_price", sql`price >= 0`), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Jpa.snap new file mode 100644 index 00000000..a48b923e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Jpa.snap @@ -0,0 +1,21 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "products") +public class Products { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "price", nullable = false) + private Integer price; + + protected Products() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Prisma.snap new file mode 100644 index 00000000..5122468e --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_Prisma.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +model Products { + id Int @id + price Int + + @@map("products") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SeaOrm.snap new file mode 100644 index 00000000..13a55177 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SeaOrm.snap @@ -0,0 +1,18 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "products")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub price: i32, +} + +vespera::schema_type!(Schema from Model, name = "ProductsSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlAlchemy.snap new file mode 100644 index 00000000..dbf9a719 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlAlchemy.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Products(DeclarativeBase): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + price: Mapped[int] = mapped_column(Integer, nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlModel.snap new file mode 100644 index 00000000..4f28049c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_check_snapshot@table_with_check_SqlModel.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 74 +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class Products(SQLModel, table=True): + __tablename__ = "products" + + id: int = Field(primary_key=True) + price: int = Field(...) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Drizzle.snap new file mode 100644 index 00000000..e7ae9e65 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_composite_fk_snapshot@table_with_composite_fk_Drizzle.snap @@ -0,0 +1,17 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 84 +expression: rendered +--- +export const lineItems = pgTable("line_items", { + id: integer("id").primaryKey(), + orderId: integer("order_id").notNull(), + orderVersion: integer("order_version").notNull(), + sku: text("sku").notNull(), +}, (t) => [ + foreignKey({ columns: [t.orderId, t.orderVersion], foreignColumns: [orders.id, orders.version], name: "fk_line_items__order_id_order_version" }), +]); + +export const lineItemsRelations = relations(lineItems, ({ one, many }) => ({ + orderOrderVersion: one(orders, { fields: [lineItems.orderId, lineItems.orderVersion], references: [orders.id, orders.version] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap new file mode 100644 index 00000000..53061aa4 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap @@ -0,0 +1,11 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 95 +expression: rendered +--- +export const orderStatus = pgEnum("order_status", ["pending", "shipped", "delivered"]); + +export const orders = pgTable("orders", { + id: integer("id").notNull(), + status: orderStatus("status").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Drizzle.snap new file mode 100644 index 00000000..98fab725 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_fk_snapshot@table_with_fk_Drizzle.snap @@ -0,0 +1,16 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 79 +expression: rendered +--- +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), + title: text("title").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__user_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(users, { fields: [posts.userId], references: [users.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Drizzle.snap new file mode 100644 index 00000000..51cab5ea --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_indexes_snapshot@table_with_indexes_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 131 +expression: rendered +--- +export const articles = pgTable("articles", { + id: integer("id").primaryKey(), + title: text("title").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), +}, (t) => [ + index("ix_articles__idx_articles_created_at").on(t.createdAt), + index("ix_articles__title").on(t.title), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Drizzle.snap new file mode 100644 index 00000000..40ee9a2b --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_integer_enum_snapshot@table_with_integer_enum_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 100 +expression: rendered +--- +export const tasks = pgTable("tasks", { + id: integer("id").primaryKey(), + priority: integer("priority").notNull(), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap new file mode 100644 index 00000000..bdd306f6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap @@ -0,0 +1,14 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 126 +expression: rendered +--- +export const users = pgTable("users", { + id: integer("id").notNull(), + email: text("email").notNull().unique("uq_users__email"), + username: text("username").notNull().unique("uq_users__uq_username"), + department: text("department"), + status: text("status").notNull().default("active"), +}, (t) => [ + index("ix_users__idx_department").on(t.department), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Drizzle.snap new file mode 100644 index 00000000..217bf858 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_constant_default_snapshot@unknown_constant_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 222 +expression: rendered +--- +export const unknownDefault = pgTable("unknown_default", { + id: integer("id").primaryKey(), + value: text("value").notNull().default(sql`SOME_CONSTANT`), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Drizzle.snap new file mode 100644 index 00000000..4488b1ae --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unknown_function_default_snapshot@unknown_function_default_Drizzle.snap @@ -0,0 +1,9 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 217 +expression: rendered +--- +export const unknownDefaults = pgTable("unknown_defaults", { + id: integer("id").primaryKey(), + code: text("code").notNull().default(sql`gen_code()`), +}); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Drizzle.snap new file mode 100644 index 00000000..28e71273 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_index_snapshot@unnamed_composite_index_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 192 +expression: rendered +--- +export const unnamedIndex = pgTable("unnamed_index", { + id: integer("id").primaryKey(), + colA: integer("col_a").notNull(), + colB: integer("col_b").notNull(), +}, (t) => [ + index("ix_unnamed_index__col_a_col_b").on(t.colA, t.colB), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap new file mode 100644 index 00000000..abac14c6 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap @@ -0,0 +1,12 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 197 +expression: rendered +--- +export const unnamedUnique = pgTable("unnamed_unique", { + id: integer("id").primaryKey(), + colA: integer("col_a").notNull(), + colB: integer("col_b").notNull(), +}, (t) => [ + unique("uq_unnamed_unique__col_a_col_b").on(t.colA, t.colB), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap new file mode 100644 index 00000000..4090ae41 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap @@ -0,0 +1,13 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 187 +expression: rendered +--- +export const events = pgTable("events", { + id: integer("id").primaryKey(), + venueId: integer("venue_id").notNull(), + date: date("date").notNull(), +}, (t) => [ + index("ix_events__date_venue_id").on(t.venueId, t.date), + unique("uq_events__date_venue_id").on(t.venueId, t.date), +]); diff --git a/crates/vespertide-exporter/src/utils/common.rs b/crates/vespertide-exporter/src/utils/common.rs index d3e50462..48d47bec 100644 --- a/crates/vespertide-exporter/src/utils/common.rs +++ b/crates/vespertide-exporter/src/utils/common.rs @@ -101,6 +101,27 @@ pub(crate) fn claim_field_name( chosen } +/// Claim a file-scope binding name, recording it in `taken`. Unlike +/// [`claim_field_name`], a collision takes a bare numeric suffix (`name2`, +/// `name3`, …) — the `_rel` step is a relation-field convention that would +/// mislead on a table or type binding. +pub(crate) fn claim_binding( + preferred: String, + taken: &mut std::collections::HashSet, +) -> String { + if taken.insert(preferred.clone()) { + return preferred; + } + let mut n = 2usize; + loop { + let candidate = format!("{preferred}{n}"); + if taken.insert(candidate.clone()) { + return candidate; + } + n += 1; + } +} + /// `preferred` if free, then `{preferred}_rel`, then numbered variants. `_rel` /// comes before the numbers so the names already emitted for FK fields that /// clash with their own column stay unchanged. @@ -184,6 +205,16 @@ mod tests { assert_eq!(buf, "String"); } + /// Bindings suffix numerically — no `_rel` step — and each claim records + /// itself, so a third claimant walks past the second's suffix. + #[test] + fn claim_binding_suffixes_numerically() { + let mut taken = std::collections::HashSet::new(); + assert_eq!(claim_binding("user".to_string(), &mut taken), "user"); + assert_eq!(claim_binding("user".to_string(), &mut taken), "user2"); + assert_eq!(claim_binding("user".to_string(), &mut taken), "user3"); + } + #[rstest] #[case::single_quoted("'draft'", "draft")] #[case::double_quoted("\"draft\"", "draft")] diff --git a/crates/vespertide-exporter/src/utils/mod.rs b/crates/vespertide-exporter/src/utils/mod.rs index f4aa9b50..47836fcb 100644 --- a/crates/vespertide-exporter/src/utils/mod.rs +++ b/crates/vespertide-exporter/src/utils/mod.rs @@ -11,7 +11,7 @@ pub(crate) mod common; pub(crate) mod python; +pub(crate) mod typescript; // Add future language helpers as siblings, e.g. // pub(crate) mod rust; -// pub(crate) mod typescript; // pub(crate) mod java; diff --git a/crates/vespertide-exporter/src/utils/typescript.rs b/crates/vespertide-exporter/src/utils/typescript.rs new file mode 100644 index 00000000..cde05b9c --- /dev/null +++ b/crates/vespertide-exporter/src/utils/typescript.rs @@ -0,0 +1,143 @@ +//! TypeScript-specific naming helpers. +//! +//! Shared by every exporter whose output is TypeScript source (currently +//! Drizzle). Anything that is not tied to TypeScript belongs in +//! `vespertide-naming` instead. + +use vespertide_naming::{IdentifierStart, sanitize_identifier}; + +/// Words that may not name a `const` binding: the ECMAScript reserved words +/// plus the strict-mode set, which a generated module is always subject to. +/// `await` is included because module code is an implicit async context. +/// +/// Object keys would not strictly need this rewrite — a reserved word is legal +/// both as a key (`{ class: … }`) and after a dot (`posts.class`) — but keys +/// and bindings share one naming rule anyway: a table's const, its column +/// keys, and every `t.…` / relations reference must resolve to the same +/// identifier, so escaping only some of them would tear those apart. +const RESERVED_BINDINGS: &[&str] = &[ + // Not reserved words, but strict mode — which module code always is — + // rejects either as a binding name outright. + "arguments", + "eval", + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]; + +/// Rewrite `name` into an identifier usable as a TypeScript `const` binding. +/// +/// Runs [`sanitize_identifier`] with the letter rule — TypeScript would accept +/// a leading `_`, but keeping the rule the other letter-start backends use +/// means a digit-leading table reads the same across generated output — and +/// then appends `_` to anything colliding with a reserved word. +/// +/// The database name is never carried by the result, so a caller **must** emit +/// the original alongside it. In Drizzle that is free: the name is already the +/// first argument of every constructor (`pgTable("class", …)`). +pub(crate) fn ts_binding(name: &str) -> String { + let sanitized = sanitize_identifier(name, IdentifierStart::Letter); + if RESERVED_BINDINGS.contains(&sanitized.as_str()) { + return sanitized + "_"; + } + sanitized +} + +/// Quote `value` as a double-quoted TypeScript string literal. +/// +/// Backslashes, quotes and the line terminators are escaped so a database name +/// or enum value containing any of them cannot end the literal early. Every +/// literal Drizzle emits — table names, column names, enum values — goes +/// through here. +pub(crate) fn ts_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '\\' | '"' => { + out.push('\\'); + out.push(ch); + } + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('"'); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::plain("users", "users")] + #[case::leading_digit("1users", "x1users")] + #[case::hyphen("user-id", "user_id")] + #[case::reserved("class", "class_")] + #[case::reserved_default("default", "default_")] + #[case::reserved_new("new", "new_")] + #[case::strict_restricted_eval("eval", "eval_")] + #[case::strict_restricted_arguments("arguments", "arguments_")] + // `order` and `select` are reserved in SQL, not in TypeScript. + #[case::sql_reserved_only("order", "order")] + fn ts_binding_escapes_digits_and_reserved_words(#[case] input: &str, #[case] expected: &str) { + assert_eq!(ts_binding(input), expected); + } + + #[rstest] + #[case::plain("users", r#""users""#)] + #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] + #[case::backslash("back\\slash", r#""back\\slash""#)] + #[case::newline("two\nlines", r#""two\nlines""#)] + #[case::empty("", r#""""#)] + fn ts_string_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { + assert_eq!(ts_string(input), expected); + } +} diff --git a/crates/vespertide-exporter/tests/parallel_consolidated.rs b/crates/vespertide-exporter/tests/parallel_consolidated.rs index 423b5486..693711e4 100644 --- a/crates/vespertide-exporter/tests/parallel_consolidated.rs +++ b/crates/vespertide-exporter/tests/parallel_consolidated.rs @@ -38,6 +38,7 @@ fn render_schema(orm: Orm, schema: &[TableDef]) -> Result { vespertide_exporter::jpa::render_entities(schema).map(|entities| entities.join("\n")) } Orm::Prisma => vespertide_exporter::prisma::export(schema), + Orm::Drizzle => vespertide_exporter::drizzle::export(schema), } } From c85aa71e48eb50a363cd365d0bb9df4b7c7bb8b8 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:11:57 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test(exporter,cli):=20Drizzle=20=EB=B0=A9?= =?UTF-8?q?=EC=96=B8=20=EB=A7=A4=ED=8A=B8=EB=A6=AD=EC=8A=A4=C2=B7=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=C2=B7CLI=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commands/export/tests/drizzle.rs | 66 ++++ .../src/commands/export/tests/mod.rs | 1 + .../src/drizzle/bindings.rs | 204 ++++++++++++ crates/vespertide-exporter/src/drizzle/mod.rs | 147 +++++++++ .../vespertide-exporter/src/drizzle/render.rs | 290 ++++++++++++++++++ .../vespertide-exporter/src/drizzle/types.rs | 252 +++++++++++++++ .../src/tests/fixtures/collisions.rs | 65 ++++ .../src/tests/fixtures/mod.rs | 29 +- crates/vespertide-exporter/src/tests/mod.rs | 22 +- ...er_schema_full_file_per_dialect@mysql.snap | 19 +- ...ender_schema_full_file_per_dialect@pg.snap | 25 +- ...r_schema_full_file_per_dialect@sqlite.snap | 17 +- ...pes_snapshot@all_simple_types_Drizzle.snap | 10 +- ...@basic_table_with_description_Drizzle.snap | 8 +- ...s_snapshot@binding_collisions_Drizzle.snap | 37 +++ ...sions_snapshot@binding_collisions_Jpa.snap | 70 +++++ ...ns_snapshot@binding_collisions_Prisma.snap | 33 ++ ...ns_snapshot@binding_collisions_SeaOrm.snap | 67 ++++ ...napshot@binding_collisions_SqlAlchemy.snap | 34 ++ ..._snapshot@binding_collisions_SqlModel.snap | 33 ++ ..._types_snapshot@complex_types_Drizzle.snap | 6 +- ...napshot@composite_constraints_Drizzle.snap | 6 +- ...napshot@composite_fk_relation_Drizzle.snap | 4 +- ...site_pk_snapshot@composite_pk_Drizzle.snap | 4 +- ...napshot@composite_primary_key_Drizzle.snap | 4 +- ...t@composite_unique_constraint_Drizzle.snap | 4 +- ...que_snapshot@composite_unique_Drizzle.snap | 4 +- ...s__defaults_snapshot@defaults_Drizzle.snap | 4 +- ...napshot@enum_multiple_columns_Drizzle.snap | 10 +- ...m_shared_snapshot@enum_shared_Drizzle.snap | 8 +- ..._snapshot@enum_special_values_Drizzle.snap | 6 +- ...lt_snapshot@enum_with_default_Drizzle.snap | 6 +- ..._names_collide_after_id_strip_Drizzle.snap | 8 +- ...th_comment_and_auto_increment_Drizzle.snap | 4 +- ...pe_snapshot@jsonb_custom_type_Drizzle.snap | 10 +- ...entifier_names_in_constraints_Drizzle.snap | 6 +- ...mns_snapshot@nullable_columns_Drizzle.snap | 4 +- ...e_enum_snapshot@nullable_enum_Drizzle.snap | 6 +- ...r_snapshot@pk_and_fk_together_Drizzle.snap | 6 +- ...snapshots@composite_fk_parent_Drizzle.snap | 4 +- ...rver_default_and_true_boolean_Drizzle.snap | 6 +- ...ults_snapshot@server_defaults_Drizzle.snap | 4 +- ...enum_snapshot@table_with_enum_Drizzle.snap | 6 +- ...d_snapshot@unique_and_indexed_Drizzle.snap | 8 +- ...shot@unnamed_composite_unique_Drizzle.snap | 4 +- ...shot@unnamed_index_and_unique_Drizzle.snap | 4 +- .../src/utils/typescript.rs | 2 + 47 files changed, 1474 insertions(+), 103 deletions(-) create mode 100644 crates/vespertide-cli/src/commands/export/tests/drizzle.rs create mode 100644 crates/vespertide-exporter/src/tests/fixtures/collisions.rs create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Drizzle.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Jpa.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Prisma.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SeaOrm.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlAlchemy.snap create mode 100644 crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlModel.snap diff --git a/crates/vespertide-cli/src/commands/export/tests/drizzle.rs b/crates/vespertide-cli/src/commands/export/tests/drizzle.rs new file mode 100644 index 00000000..e0f1e48c --- /dev/null +++ b/crates/vespertide-cli/src/commands/export/tests/drizzle.rs @@ -0,0 +1,66 @@ +use super::*; + +/// One export writes one file per dialect — Drizzle's table constructors fork +/// at the `import` line, so there is no backend-neutral single file. +#[tokio::test] +#[serial] +async fn export_drizzle_writes_one_file_per_dialect() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/events.json"), &sample_table("events")); + + cmd_export(Orm::Drizzle, None).await.unwrap(); + + let root = PathBuf::from("src/models"); + let pg = std_fs::read_to_string(root.join("models.pg.ts")).unwrap(); + let mysql = std_fs::read_to_string(root.join("models.mysql.ts")).unwrap(); + let sqlite = std_fs::read_to_string(root.join("models.sqlite.ts")).unwrap(); + + assert!(pg.contains("pgTable(\"events\"")); + assert!(pg.contains("from \"drizzle-orm/pg-core\"")); + assert!(mysql.contains("mysqlTable(\"events\"")); + assert!(mysql.contains("from \"drizzle-orm/mysql-core\"")); + assert!(sqlite.contains("sqliteTable(\"events\"")); + assert!(sqlite.contains("from \"drizzle-orm/sqlite-core\"")); +} + +#[test] +fn build_output_path_drizzle_uses_ts_extension() { + use std::path::Path; + let root = Path::new("src/models"); + let out = build_output_path(root, Path::new("user.json"), Orm::Drizzle); + assert_eq!(out, Path::new("src/models/user.ts")); +} + +/// The Drizzle path deliberately skips the `.ts` extension sweep the other +/// ORMs run: the export root doubles as a source directory, so the user's own +/// files must survive an export, and the three fixed outputs are simply +/// overwritten in place. +#[tokio::test] +#[serial] +async fn export_drizzle_preserves_user_ts_files() { + let tmp = tempdir().unwrap(); + let _guard = CwdGuard::new(&tmp.path().to_path_buf()); + write_config(); + write_model(Path::new("models/events.json"), &sample_table("events")); + + let root = PathBuf::from("src/models"); + std_fs::create_dir_all(root.join("helpers")).unwrap(); + std_fs::write(root.join("index.ts"), "export {};").unwrap(); + std_fs::write(root.join("helpers/util.ts"), "export {};").unwrap(); + std_fs::write(root.join("models.pg.ts"), "stale").unwrap(); + + cmd_export(Orm::Drizzle, None).await.unwrap(); + + assert_eq!( + std_fs::read_to_string(root.join("index.ts")).unwrap(), + "export {};" + ); + assert_eq!( + std_fs::read_to_string(root.join("helpers/util.ts")).unwrap(), + "export {};" + ); + let pg = std_fs::read_to_string(root.join("models.pg.ts")).unwrap(); + assert!(pg.contains("pgTable(\"events\"")); +} diff --git a/crates/vespertide-cli/src/commands/export/tests/mod.rs b/crates/vespertide-cli/src/commands/export/tests/mod.rs index cdabb86c..5dc489fa 100644 --- a/crates/vespertide-cli/src/commands/export/tests/mod.rs +++ b/crates/vespertide-cli/src/commands/export/tests/mod.rs @@ -6,6 +6,7 @@ pub(super) use std::fs as std_fs; pub(super) use tempfile::tempdir; pub(super) use vespertide_core::{ColumnDef, ColumnType, SimpleColumnType, TableConstraint}; +mod drizzle; mod prisma; fn write_config() { diff --git a/crates/vespertide-exporter/src/drizzle/bindings.rs b/crates/vespertide-exporter/src/drizzle/bindings.rs index f4b76b92..93cd2678 100644 --- a/crates/vespertide-exporter/src/drizzle/bindings.rs +++ b/crates/vespertide-exporter/src/drizzle/bindings.rs @@ -227,3 +227,207 @@ impl FileBindings { .unwrap_or_else(|| format!("{}Relations", js_name(table))) } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::ColumnType; + use vespertide_core::schema::constraint::TableConstraint; + use vespertide_core::schema::primary_key::PrimaryKeySyntax; + use vespertide_core::{ComplexColumnType, SimpleColumnType, TableDef}; + + use super::*; + use crate::drizzle::DrizzleDialect::{Mysql, Pg, Sqlite}; + use crate::drizzle::render_schema; + use crate::tests::fixtures::col; + + /// One column per constructor the dialects can map to, so the rendered + /// import line exercises the whole vocabulary. + fn vocab_schema() -> Vec { + use SimpleColumnType as S; + let mut types: Vec = [ + S::SmallInt, + S::Integer, + S::BigInt, + S::Real, + S::DoublePrecision, + S::Boolean, + S::Date, + S::Time, + S::Timestamp, + S::Timestamptz, + S::Uuid, + S::Json, + S::Interval, + S::Bytea, + S::Inet, + S::Cidr, + S::Macaddr, + S::Xml, + S::Text, + ] + .into_iter() + .map(ColumnType::Simple) + .collect(); + types.push(ColumnType::Complex(ComplexColumnType::Varchar { + length: 8, + })); + types.push(ColumnType::Complex(ComplexColumnType::Char { length: 2 })); + types.push(ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + })); + types.push(ColumnType::Complex(ComplexColumnType::Enum { + name: "vocab_enum".to_string(), + values: EnumValues::String(vec!["a".to_string()]), + })); + + let parent = TableDef { + name: "vocab_parent".into(), + description: None, + columns: vec![ + col("id", ColumnType::Simple(S::Integer)).primary_key(PrimaryKeySyntax::Bool(true)), + ], + constraints: vec![], + } + .normalize() + .expect("parent normalizes"); + + let mut columns: Vec<_> = types + .into_iter() + .enumerate() + .map(|(i, ty)| col(&format!("c{i}"), ty)) + .collect(); + columns.push(col("parent_id", ColumnType::Simple(S::Integer))); + let table = TableDef { + name: "vocab".into(), + description: None, + columns, + constraints: vec![ + TableConstraint::PrimaryKey { + columns: vec!["c0".into(), "c1".into()], + auto_increment: false, + strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(), + }, + TableConstraint::Unique { + name: None, + columns: vec!["c2".into()], + strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates { + keep: vespertide_core::KeepPolicy::First, + }, + }, + TableConstraint::Index { + name: None, + columns: vec!["c3".into()], + }, + TableConstraint::Check { + name: "chk_vocab".into(), + expr: "c1 > 0".into(), + strategy: vespertide_core::CheckViolationStrategy::default(), + }, + TableConstraint::ForeignKey { + name: None, + columns: vec!["parent_id".into()], + ref_table: "vocab_parent".into(), + ref_columns: vec!["id".into()], + on_delete: None, + on_update: None, + orphan_strategy: vespertide_core::ForeignKeyOrphanStrategy::default(), + }, + ], + } + .normalize() + .expect("vocab table normalizes"); + + vec![parent, table] + } + + /// Every symbol the renderer can put on an import line must be in the + /// vocabulary, or a same-named binding would silently shadow it. + #[rstest] + #[case::pg(Pg)] + #[case::mysql(Mysql)] + #[case::sqlite(Sqlite)] + fn vocabulary_covers_every_import(#[case] dialect: DrizzleDialect) { + let rendered = render_schema(&vocab_schema(), dialect); + let vocabulary = import_vocabulary(dialect); + let mut checked = 0usize; + for line in rendered.lines().filter(|l| l.starts_with("import {")) { + let inner = line + .trim_start_matches("import {") + .split('}') + .next() + .expect("import line has a closing brace"); + for symbol in inner.split(',').map(str::trim).filter(|s| !s.is_empty()) { + assert!( + vocabulary.contains(&symbol), + "import `{symbol}` missing from the {dialect:?} vocabulary" + ); + checked += 1; + } + } + assert!(checked > 10, "expected a populated import header"); + } + + /// A table named after a callback parameter must not shadow it: `t` is + /// the table-callback parameter every FK entry dereferences, and `one` / + /// `many` are the relations builders. All three claims suffix away, and + /// the references follow. + #[test] + fn callback_scope_names_are_never_claimed() { + use vespertide_core::TableDef; + + use crate::drizzle::render_schema; + use crate::tests::fixtures::{fk, pk, simple}; + + let scoped = |name: &str| { + TableDef { + name: name.into(), + description: None, + columns: vec![simple("id", vespertide_core::SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + } + .normalize() + .expect("scope-named table normalizes") + }; + let refs = TableDef { + name: "refs".into(), + description: None, + columns: vec![ + simple("id", vespertide_core::SimpleColumnType::Integer), + simple("t_id", vespertide_core::SimpleColumnType::Integer), + simple("one_id", vespertide_core::SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + fk(&["t_id"], "t", &["id"]), + fk(&["one_id"], "one", &["id"]), + ], + } + .normalize() + .expect("refs normalizes"); + + let rendered = render_schema(&[scoped("t"), scoped("one"), refs], Pg); + assert!(rendered.contains(r#"export const t2 = pgTable("t""#)); + assert!(rendered.contains(r#"export const one2 = pgTable("one""#)); + assert!(rendered.contains("foreignColumns: [t2.id]")); + // Relation *field* names are object keys — table scope, no claim — + // so the field stays `one` while the target follows the suffix. + assert!(rendered.contains("one: one(one2, ")); + assert!(rendered.contains("t: one(t2, ")); + } + + /// Identity-keyed misses fall back to natural names — the live case is a + /// foreign key referencing a table outside the schema slice. + #[test] + fn accessors_fall_back_to_natural_names() { + let bindings = FileBindings::collect(&[], Pg); + assert_eq!(bindings.table_const("ghost_table"), "ghostTable"); + assert_eq!( + bindings.relations_const("ghost_table"), + "ghostTableRelations" + ); + assert_eq!(bindings.enum_const("orders", "status"), "ordersStatus"); + assert_eq!(bindings.custom_const("tsvector"), "tsvector"); + } +} diff --git a/crates/vespertide-exporter/src/drizzle/mod.rs b/crates/vespertide-exporter/src/drizzle/mod.rs index e7d50186..3239cc46 100644 --- a/crates/vespertide-exporter/src/drizzle/mod.rs +++ b/crates/vespertide-exporter/src/drizzle/mod.rs @@ -304,3 +304,150 @@ pub fn export(schema: &[TableDef]) -> Result { .collect::>() .join("\n\n")) } + +#[cfg(test)] +mod tests { + use insta::{assert_snapshot, with_settings}; + use rstest::rstest; + + use super::*; + use crate::tests::fixtures::{ + binding_collisions, composite_pk, composite_unique, enum_shared, small_multi_schema, + table_with_check, table_with_integer_enum, unique_and_indexed, + }; + + /// The complete per-dialect file: import header (rank-ordered), enum + /// declarations (PostgreSQL only), table declarations, relations. One + /// schema, three files — this is the dialect fork the cross-ORM harness + /// cannot see, since the trait path renders PostgreSQL only. + #[rstest] + #[case::pg(DrizzleDialect::Pg)] + #[case::mysql(DrizzleDialect::Mysql)] + #[case::sqlite(DrizzleDialect::Sqlite)] + fn render_schema_full_file_per_dialect(#[case] dialect: DrizzleDialect) { + // Every constraint family is present so the import header exercises + // its full rank ordering — primaryKey (composite), foreignKey, + // unique, index, check — plus the `sql` import the check forces. + let mut tables = small_multi_schema(); + tables.push(enum_shared()); + tables.push(composite_pk()); + tables.push(composite_unique()); + tables.push(unique_and_indexed()); + tables.push(table_with_check()); + // An integer enum stays a plain integer column and must not add a + // `pgEnum` declaration. + tables.push(table_with_integer_enum()); + let rendered = render_schema(&tables, dialect); + with_settings!( + { snapshot_path => "../tests/snapshots", snapshot_suffix => dialect.file_suffix() }, + { assert_snapshot!(rendered); } + ); + } + + /// The import header lists the table function first, the type and + /// constraint helpers next, and column constructors last (alphabetical + /// within the tail rank). + #[rstest] + #[case::table_fn("pgTable", 0)] + #[case::pg_enum("pgEnum", 1)] + #[case::custom_type("customType", 2)] + #[case::primary_key("primaryKey", 3)] + #[case::foreign_key("foreignKey", 4)] + #[case::unique_index("uniqueIndex", 5)] + #[case::index("index", 6)] + #[case::check("check", 7)] + #[case::constructor("integer", 8)] + fn symbol_rank_orders_helpers_before_constructors( + #[case] symbol: &str, + #[case] expected: usize, + ) { + assert_eq!(symbol_rank(symbol, "pgTable"), expected); + } + + /// The SQL layer's `CREATE TYPE` is table-prefixed for every enum, so two + /// tables sharing an enum name — same values or not — own two database + /// types and two `pgEnum` consts. + #[test] + fn render_schema_table_prefixes_every_enum_type() { + let orders = table_with_enum("orders", "status", &["new", "paid"]); + let tickets = table_with_enum("tickets", "status", &["new", "paid"]); + let rendered = render_schema(&[orders, tickets], DrizzleDialect::Pg); + assert!( + rendered.contains( + r#"export const ordersStatus = pgEnum("orders_status", ["new", "paid"]);"# + ) + ); + assert!(rendered.contains( + r#"export const ticketsStatus = pgEnum("tickets_status", ["new", "paid"]);"# + )); + assert!(rendered.contains(r#"st: ordersStatus("st")"#)); + } + + fn table_with_enum(name: &str, enum_name: &str, values: &[&str]) -> TableDef { + use vespertide_core::schema::column::{ColumnType, ComplexColumnType, EnumValues}; + use vespertide_core::schema::primary_key::PrimaryKeySyntax; + use vespertide_core::{ColumnDef, SimpleColumnType}; + + TableDef { + name: name.into(), + description: None, + columns: vec![ + ColumnDef::new("id", ColumnType::Simple(SimpleColumnType::Integer), false) + .primary_key(PrimaryKeySyntax::Bool(true)), + ColumnDef::new( + "st", + ColumnType::Complex(ComplexColumnType::Enum { + name: enum_name.into(), + values: EnumValues::String( + values.iter().copied().map(Into::into).collect(), + ), + }), + false, + ), + ], + constraints: vec![], + } + .normalize() + .expect("fixture table normalizes") + } + + /// File-scope bindings suffix their way around collisions: a table + /// claiming another table's would-be `relations` const, a table named + /// after an import, and a custom type named after a column constructor — + /// and every reference follows the suffixed name. + #[test] + fn render_schema_suffixes_colliding_bindings() { + let rendered = render_schema(&binding_collisions(), DrizzleDialect::Pg); + assert!(rendered.contains( + r#"const integer2 = customType<{ data: string }>({ dataType() { return "integer"; } });"# + )); + assert!(rendered.contains(r#"export const userRelations = pgTable("user_relations""#)); + assert!(rendered.contains(r#"export const sql2 = pgTable("sql""#)); + assert!(rendered.contains("export const userRelations2 = relations(user, ")); + assert!(rendered.contains(r#"kind: integer2("kind")"#)); + assert!(rendered.contains("foreignColumns: [user.id]")); + } + + /// A model-level shared enum still becomes one database type per table — + /// mirroring the per-table `CREATE TYPE` the SQL layer emits. + #[test] + fn render_schema_declares_one_enum_type_per_table() { + let t1 = enum_shared(); + let mut t2 = enum_shared(); + t2.name = "archived_documents".into(); + let rendered = render_schema(&[t1, t2], DrizzleDialect::Pg); + assert_eq!(rendered.matches("pgEnum(").count(), 2); + } + + /// MySQL and SQLite never declare enum types — the variants live inline on + /// the column — so their files must not import or call `pgEnum`. + #[rstest] + #[case::mysql(DrizzleDialect::Mysql)] + #[case::sqlite(DrizzleDialect::Sqlite)] + fn non_postgres_dialects_have_no_enum_declarations(#[case] dialect: DrizzleDialect) { + let rendered = render_schema(&[enum_shared()], DrizzleDialect::Pg); + assert!(rendered.contains("pgEnum(")); + let rendered = render_schema(&[enum_shared()], dialect); + assert!(!rendered.contains("pgEnum")); + } +} diff --git a/crates/vespertide-exporter/src/drizzle/render.rs b/crates/vespertide-exporter/src/drizzle/render.rs index 081e560f..34d3d168 100644 --- a/crates/vespertide-exporter/src/drizzle/render.rs +++ b/crates/vespertide-exporter/src/drizzle/render.rs @@ -587,3 +587,293 @@ fn reference_action_to_drizzle(action: &ReferenceAction) -> &'static str { _ => "no action", } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::NumValue; + + use super::*; + use crate::drizzle::DrizzleDialect::{Mysql, Pg, Sqlite}; + + fn text_type() -> ColumnType { + ColumnType::Simple(SimpleColumnType::Text) + } + + // ── default_chain ──────────────────────────────────────────────────────── + + #[rstest] + #[case::bool_true("true", ".default(true)")] + #[case::bool_false("false", ".default(false)")] + #[case::number("42", ".default(42)")] + #[case::float("1.5", ".default(1.5)")] + #[case::quoted_string("'draft'", r#".default("draft")"#)] + // SQL doubles quotes inside a single-quoted literal; the TS string wants + // the actual value back. + #[case::undoubled_quote("'it''s'", r#".default("it's")"#)] + #[case::double_quoted("\"draft\"", r#".default("draft")"#)] + fn literal_defaults_stay_literals( + #[case] input: &str, + #[case] expected: &str, + #[values(Pg, Mysql, Sqlite)] dialect: DrizzleDialect, + ) { + let chain = default_chain(input, &text_type(), dialect); + assert_eq!(chain.text, expected); + assert!(!chain.needs_sql); + } + + /// Both timestamp spellings collapse to each dialect's one drift-free + /// spelling (measured on live round-trips): PostgreSQL deparses + /// `CURRENT_TIMESTAMP` as itself, MySQL introspects to `defaultNow()`'s + /// own `(now())`, SQLite needs the parenthesized expression. + #[rstest] + #[case::now_pg("now()", Pg, ".default(sql`CURRENT_TIMESTAMP`)", true)] + #[case::now_mysql("now()", Mysql, ".defaultNow()", false)] + #[case::now_sqlite("now()", Sqlite, ".default(sql`(CURRENT_TIMESTAMP)`)", true)] + #[case::ct_pg("CURRENT_TIMESTAMP", Pg, ".default(sql`CURRENT_TIMESTAMP`)", true)] + #[case::ct_mysql("CURRENT_TIMESTAMP", Mysql, ".defaultNow()", false)] + #[case::ct_sqlite( + "CURRENT_TIMESTAMP", + Sqlite, + ".default(sql`(CURRENT_TIMESTAMP)`)", + true + )] + fn timestamp_defaults_fork_per_dialect( + #[case] input: &str, + #[case] dialect: DrizzleDialect, + #[case] expected: &str, + #[case] needs_sql: bool, + ) { + let chain = default_chain(input, &text_type(), dialect); + assert_eq!(chain.text, expected); + assert_eq!(chain.needs_sql, needs_sql); + } + + /// `defaultRandom()` is a `pg-core` column method; the other dialects pass + /// the generator expression through. + #[rstest] + #[case::pg(Pg, ".defaultRandom()", false)] + #[case::mysql(Mysql, ".default(sql`(uuid())`)", true)] + #[case::sqlite(Sqlite, ".default(sql`(lower(hex(randomblob(16))))`)", true)] + fn uuid_defaults_fork_on_postgres( + #[case] dialect: DrizzleDialect, + #[case] expected: &str, + #[case] needs_sql: bool, + ) { + let chain = default_chain("gen_random_uuid()", &text_type(), dialect); + assert_eq!(chain.text, expected); + assert_eq!(chain.needs_sql, needs_sql); + } + + /// The SQL layer only normalizes `gen_random_uuid()`; other generator + /// spellings reach every backend verbatim, and so does the model — + /// `defaultRandom()` here would put `gen_random_uuid()` on a column whose + /// database default is `uuid_generate_v4()`. + #[rstest] + #[case::pg(Pg)] + #[case::mysql(Mysql)] + #[case::sqlite(Sqlite)] + fn other_uuid_generators_pass_through_verbatim(#[case] dialect: DrizzleDialect) { + let chain = default_chain("uuid_generate_v4()", &text_type(), dialect); + assert_eq!(chain.text, ".default(sql`uuid_generate_v4()`)"); + assert!(chain.needs_sql); + } + + /// A JSON literal reaches the database as a JSON literal — `::json` on + /// PostgreSQL because that is the type the SQL layer creates. + #[rstest] + #[case::pg(Pg, ".default(sql`'{\"a\": 1}'::json`)")] + #[case::mysql(Mysql, ".default(sql`'{\"a\": 1}'`)")] + fn json_literal_defaults_are_tagged(#[case] dialect: DrizzleDialect, #[case] expected: &str) { + let ty = ColumnType::Simple(SimpleColumnType::Json); + let chain = default_chain("{\"a\": 1}", &ty, dialect); + assert_eq!(chain.text, expected); + assert!(chain.needs_sql); + } + + #[test] + fn unknown_function_passes_through_the_sql_tag() { + let chain = default_chain("gen_code()", &text_type(), Pg); + assert_eq!(chain.text, ".default(sql`gen_code()`)"); + assert!(chain.needs_sql); + } + + #[test] + fn bare_keyword_passes_through_the_sql_tag() { + let chain = default_chain("CURRENT_USER", &text_type(), Pg); + assert_eq!(chain.text, ".default(sql`CURRENT_USER`)"); + assert!(chain.needs_sql); + } + + /// Drizzle types a numeric/decimal default as a string — arbitrary + /// precision exceeds a JS number. + #[test] + fn numeric_column_defaults_keep_the_literal_quoted() { + let ty = ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }); + assert_eq!(default_chain("0.00", &ty, Pg).text, r#".default("0.00")"#); + } + + /// An integer enum's default names a variant; the column stores its value. + #[test] + fn integer_enum_variant_default_resolves_to_its_value() { + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: "prio".to_string(), + values: EnumValues::Integer(vec![NumValue { + name: "low".to_string(), + value: 7, + }]), + }); + assert_eq!(default_chain("low", &ty, Pg).text, ".default(7)"); + } + + #[test] + fn escape_backtick_guards_template_syntax() { + assert_eq!(escape_backtick("a`b${c}\\d"), "a\\`b\\${c}\\\\d"); + } + + // ── constraint entries ────────────────────────────────────────────────── + + #[rstest] + #[case::pg_plain(Pg, false, ".primaryKey()")] + #[case::pg_auto(Pg, true, ".primaryKey().generatedByDefaultAsIdentity()")] + #[case::mysql_plain(Mysql, false, ".primaryKey()")] + #[case::mysql_auto(Mysql, true, ".autoincrement().primaryKey()")] + #[case::sqlite_plain(Sqlite, false, ".primaryKey()")] + #[case::sqlite_auto(Sqlite, true, ".primaryKey({ autoIncrement: true })")] + fn primary_key_chain_forks_per_dialect( + #[case] dialect: DrizzleDialect, + #[case] auto_inc: bool, + #[case] expected: &str, + ) { + assert_eq!(primary_key_chain(dialect, auto_inc), expected); + } + + #[test] + fn table_level_entry_names_the_builder_call() { + let columns: Vec = vec!["a_col".into(), "b".into()]; + assert_eq!( + table_level_entry("uniqueIndex", "uq_t__a_col_b", &columns), + r#" uniqueIndex("uq_t__a_col_b").on(t.aCol, t.b),"# + ); + } + + /// Bindings over an empty schema: every lookup falls back to the natural + /// name, which is exactly what these entries assert. + fn empty_bindings() -> FileBindings { + FileBindings::collect(&[], Pg) + } + + fn fk_entry_for( + columns: &[&str], + ref_table: &str, + ref_columns: &[&str], + on_delete: Option<&ReferenceAction>, + on_update: Option<&ReferenceAction>, + ) -> String { + let columns: Vec = columns.iter().map(|c| (*c).into()).collect(); + let ref_columns: Vec = ref_columns.iter().map(|c| (*c).into()).collect(); + foreign_key_entry( + Some("fk_posts__x"), + &columns, + ref_table, + &ref_columns, + on_delete, + on_update, + "posts", + &empty_bindings(), + ) + } + + /// SQLite stores no FK constraint names, so its entries omit the field. + #[test] + fn unnamed_foreign_key_entry_omits_the_name_field() { + let columns: Vec = vec!["user_id".into()]; + let entry = foreign_key_entry( + None, + &columns, + "users", + &[], + None, + None, + "posts", + &empty_bindings(), + ); + assert_eq!( + entry, + r" foreignKey({ columns: [t.userId], foreignColumns: [users.id] })," + ); + } + + #[test] + fn foreign_key_entry_spells_the_operator_form() { + assert_eq!( + fk_entry_for(&["user_id"], "users", &["id"], None, None), + r#" foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__x" }),"# + ); + } + + #[test] + fn composite_foreign_key_lists_every_column_in_order() { + assert_eq!( + fk_entry_for(&["a", "b"], "pair", &["x", "y"], None, None), + r#" foreignKey({ columns: [t.a, t.b], foreignColumns: [pair.x, pair.y], name: "fk_posts__x" }),"# + ); + } + + /// A self-referential key takes its foreign columns from the callback's + /// `t`, which keeps the table const out of its own initializer. + #[test] + fn self_referential_foreign_key_uses_the_callback_columns() { + assert_eq!( + fk_entry_for(&["parent_id"], "posts", &["id"], None, None), + r#" foreignKey({ columns: [t.parentId], foreignColumns: [t.id], name: "fk_posts__x" }),"# + ); + } + + /// A foreign key with no explicit target column references the parent's + /// primary key, which vespertide names `id` by convention. + #[test] + fn empty_ref_columns_fall_back_to_id() { + assert_eq!( + fk_entry_for(&["user_id"], "users", &[], None, None), + r#" foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__x" }),"# + ); + } + + #[test] + fn referential_actions_chain_after_the_operator() { + assert_eq!( + fk_entry_for( + &["user_id"], + "users", + &["id"], + Some(&ReferenceAction::Cascade), + Some(&ReferenceAction::Restrict), + ), + r#" foreignKey({ columns: [t.userId], foreignColumns: [users.id], name: "fk_posts__x" }).onDelete("cascade").onUpdate("restrict"),"# + ); + } + + #[rstest] + #[case::cascade(ReferenceAction::Cascade, "cascade")] + #[case::restrict(ReferenceAction::Restrict, "restrict")] + #[case::set_null(ReferenceAction::SetNull, "set null")] + #[case::set_default(ReferenceAction::SetDefault, "set default")] + #[case::no_action(ReferenceAction::NoAction, "no action")] + fn reference_actions_map_to_drizzle_keywords( + #[case] action: ReferenceAction, + #[case] expected: &str, + ) { + assert_eq!(reference_action_to_drizzle(&action), expected); + } + + #[test] + fn js_name_escapes_digits_and_reserved_words() { + assert_eq!(js_name("user_id"), "userId"); + assert_eq!(js_name("1st_place"), "x1stPlace"); + assert_eq!(js_name("default"), "default_"); + } +} diff --git a/crates/vespertide-exporter/src/drizzle/types.rs b/crates/vespertide-exporter/src/drizzle/types.rs index 2e401bdd..2af9d086 100644 --- a/crates/vespertide-exporter/src/drizzle/types.rs +++ b/crates/vespertide-exporter/src/drizzle/types.rs @@ -341,3 +341,255 @@ fn complex_ctor( _ => unreachable!("ComplexColumnType is #[non_exhaustive]; all variants are matched above"), } } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vespertide_core::schema::column::NumValue; + + use super::*; + use crate::drizzle::DrizzleDialect::{Mysql, Pg, Sqlite}; + + fn simple(s: SimpleColumnType) -> ColumnType { + ColumnType::Simple(s) + } + + /// Bindings over an empty schema: every lookup falls back to the + /// natural name, which is exactly what these mappings assert. + fn empty_bindings() -> FileBindings { + FileBindings::collect(&[], Pg) + } + + fn call(ty: &ColumnType, dialect: DrizzleDialect) -> String { + column_ctor(ty, dialect, "orders", &empty_bindings()).call("col") + } + + #[rstest] + // ── integers ── + #[case::small_pg(SimpleColumnType::SmallInt, Pg, r#"smallint("col")"#)] + #[case::small_mysql(SimpleColumnType::SmallInt, Mysql, r#"smallint("col")"#)] + #[case::small_sqlite(SimpleColumnType::SmallInt, Sqlite, r#"integer("col")"#)] + #[case::int_pg(SimpleColumnType::Integer, Pg, r#"integer("col")"#)] + #[case::int_mysql(SimpleColumnType::Integer, Mysql, r#"int("col")"#)] + #[case::int_sqlite(SimpleColumnType::Integer, Sqlite, r#"integer("col")"#)] + #[case::big_pg(SimpleColumnType::BigInt, Pg, r#"bigint("col", { mode: "number" })"#)] + #[case::big_mysql( + SimpleColumnType::BigInt, + Mysql, + r#"bigint("col", { mode: "number" })"# + )] + #[case::big_sqlite(SimpleColumnType::BigInt, Sqlite, r#"integer("col")"#)] + // ── floats ── + #[case::real_pg(SimpleColumnType::Real, Pg, r#"real("col")"#)] + #[case::real_mysql(SimpleColumnType::Real, Mysql, r#"float("col")"#)] + #[case::real_sqlite(SimpleColumnType::Real, Sqlite, r#"real("col")"#)] + #[case::double_pg(SimpleColumnType::DoublePrecision, Pg, r#"doublePrecision("col")"#)] + #[case::double_mysql(SimpleColumnType::DoublePrecision, Mysql, r#"double("col")"#)] + #[case::double_sqlite(SimpleColumnType::DoublePrecision, Sqlite, r#"real("col")"#)] + // ── text / boolean ── + #[case::text_pg(SimpleColumnType::Text, Pg, r#"text("col")"#)] + #[case::text_mysql(SimpleColumnType::Text, Mysql, r#"text("col")"#)] + #[case::text_sqlite(SimpleColumnType::Text, Sqlite, r#"text("col")"#)] + #[case::bool_pg(SimpleColumnType::Boolean, Pg, r#"boolean("col")"#)] + #[case::bool_mysql(SimpleColumnType::Boolean, Mysql, r#"boolean("col")"#)] + #[case::bool_sqlite( + SimpleColumnType::Boolean, + Sqlite, + r#"integer("col", { mode: "boolean" })"# + )] + // ── date / time ── + #[case::date_pg(SimpleColumnType::Date, Pg, r#"date("col")"#)] + #[case::date_mysql(SimpleColumnType::Date, Mysql, r#"date("col")"#)] + #[case::date_sqlite(SimpleColumnType::Date, Sqlite, r#"text("col") /* date */"#)] + #[case::time_pg(SimpleColumnType::Time, Pg, r#"time("col")"#)] + #[case::time_mysql(SimpleColumnType::Time, Mysql, r#"time("col")"#)] + #[case::time_sqlite(SimpleColumnType::Time, Sqlite, r#"text("col") /* time */"#)] + #[case::ts_pg(SimpleColumnType::Timestamp, Pg, r#"timestamp("col")"#)] + #[case::ts_mysql(SimpleColumnType::Timestamp, Mysql, r#"timestamp("col")"#)] + #[case::ts_sqlite(SimpleColumnType::Timestamp, Sqlite, r#"text("col") /* timestamp */"#)] + #[case::tstz_pg( + SimpleColumnType::Timestamptz, + Pg, + r#"timestamp("col", { withTimezone: true })"# + )] + #[case::tstz_mysql(SimpleColumnType::Timestamptz, Mysql, r#"timestamp("col")"#)] + #[case::tstz_sqlite( + SimpleColumnType::Timestamptz, + Sqlite, + r#"text("col") /* timestamptz */"# + )] + // ── uuid / json — the SQL layer creates pg `json`, mysql `binary(16)` ── + #[case::uuid_pg(SimpleColumnType::Uuid, Pg, r#"uuid("col")"#)] + #[case::uuid_mysql( + SimpleColumnType::Uuid, + Mysql, + r#"binary("col", { length: 16 }) /* uuid */"# + )] + #[case::uuid_sqlite(SimpleColumnType::Uuid, Sqlite, r#"text("col") /* uuid */"#)] + #[case::json_pg(SimpleColumnType::Json, Pg, r#"json("col")"#)] + #[case::json_mysql(SimpleColumnType::Json, Mysql, r#"json("col")"#)] + #[case::json_sqlite(SimpleColumnType::Json, Sqlite, r#"text("col", { mode: "json" })"#)] + // ── PostgreSQL-specific types ── + #[case::interval_pg(SimpleColumnType::Interval, Pg, r#"interval("col")"#)] + #[case::interval_mysql(SimpleColumnType::Interval, Mysql, r#"text("col") /* interval */"#)] + #[case::interval_sqlite(SimpleColumnType::Interval, Sqlite, r#"text("col") /* interval */"#)] + #[case::bytea_pg(SimpleColumnType::Bytea, Pg, r#"bytea("col")"#)] + #[case::bytea_mysql( + SimpleColumnType::Bytea, + Mysql, + r#"binary("col", { length: 1 }) /* bytea */"# + )] + #[case::bytea_sqlite(SimpleColumnType::Bytea, Sqlite, r#"blob("col") /* bytea */"#)] + #[case::inet_pg(SimpleColumnType::Inet, Pg, r#"inet("col")"#)] + #[case::inet_mysql(SimpleColumnType::Inet, Mysql, r#"text("col") /* inet */"#)] + #[case::inet_sqlite(SimpleColumnType::Inet, Sqlite, r#"text("col") /* inet */"#)] + #[case::cidr_pg(SimpleColumnType::Cidr, Pg, r#"cidr("col")"#)] + #[case::cidr_mysql(SimpleColumnType::Cidr, Mysql, r#"text("col") /* cidr */"#)] + #[case::cidr_sqlite(SimpleColumnType::Cidr, Sqlite, r#"text("col") /* cidr */"#)] + #[case::macaddr_pg(SimpleColumnType::Macaddr, Pg, r#"macaddr("col")"#)] + #[case::macaddr_mysql(SimpleColumnType::Macaddr, Mysql, r#"text("col") /* macaddr */"#)] + #[case::macaddr_sqlite(SimpleColumnType::Macaddr, Sqlite, r#"text("col") /* macaddr */"#)] + #[case::xml_pg(SimpleColumnType::Xml, Pg, r#"xml("col")"#)] + #[case::xml_mysql(SimpleColumnType::Xml, Mysql, r#"text("col") /* xml */"#)] + #[case::xml_sqlite(SimpleColumnType::Xml, Sqlite, r#"text("col") /* xml */"#)] + fn simple_types_map_per_dialect( + #[case] ty: SimpleColumnType, + #[case] dialect: DrizzleDialect, + #[case] expected: &str, + ) { + assert_eq!(call(&simple(ty), dialect), expected); + } + + /// The two `pg-core` gaps declare a `customType` helper; on MySQL and + /// SQLite the same types map onto real constructors and declare nothing. + #[rstest] + #[case::bytea_pg(SimpleColumnType::Bytea, Pg, true)] + #[case::xml_pg(SimpleColumnType::Xml, Pg, true)] + #[case::bytea_mysql(SimpleColumnType::Bytea, Mysql, false)] + #[case::xml_mysql(SimpleColumnType::Xml, Mysql, false)] + #[case::bytea_sqlite(SimpleColumnType::Bytea, Sqlite, false)] + #[case::xml_sqlite(SimpleColumnType::Xml, Sqlite, false)] + fn custom_column_flags_only_the_pg_core_gaps( + #[case] ty: SimpleColumnType, + #[case] dialect: DrizzleDialect, + #[case] declares: bool, + ) { + assert_eq!(custom_column(&simple(ty), dialect).is_some(), declares); + } + + /// `bytea` keeps `Uint8Array` (the `pg` driver's `Buffer` is one, and the + /// file compiles without `@types/node`); everything else carries `string`. + #[rstest] + #[case::bytea( + ColumnType::Simple(SimpleColumnType::Bytea), + "const bytea = customType<{ data: Uint8Array }>({ dataType() { return \"bytea\"; } });" + )] + #[case::xml( + ColumnType::Simple(SimpleColumnType::Xml), + "const xml = customType<{ data: string }>({ dataType() { return \"xml\"; } });" + )] + fn custom_type_decls_render_the_helper_const(#[case] ty: ColumnType, #[case] expected: &str) { + let decl = custom_column(&ty, Pg).expect("declares a customType"); + assert_eq!(render_custom_type_decl(&decl, &decl.const_name), expected); + } + + #[rstest] + #[case::varchar_pg(Pg, r#"varchar("col", { length: 255 })"#)] + #[case::varchar_mysql(Mysql, r#"varchar("col", { length: 255 })"#)] + #[case::varchar_sqlite(Sqlite, r#"text("col", { length: 255 })"#)] + fn varchar_maps_per_dialect(#[case] dialect: DrizzleDialect, #[case] expected: &str) { + let ty = ColumnType::Complex(ComplexColumnType::Varchar { length: 255 }); + assert_eq!(call(&ty, dialect), expected); + } + + #[rstest] + #[case::char_pg(Pg, r#"char("col", { length: 3 })"#)] + #[case::char_mysql(Mysql, r#"char("col", { length: 3 })"#)] + #[case::char_sqlite(Sqlite, r#"text("col", { length: 3 }) /* char */"#)] + fn char_maps_per_dialect(#[case] dialect: DrizzleDialect, #[case] expected: &str) { + let ty = ColumnType::Complex(ComplexColumnType::Char { length: 3 }); + assert_eq!(call(&ty, dialect), expected); + } + + /// SQLite's `numeric` takes no precision or scale. + #[rstest] + #[case::numeric_pg(Pg, r#"numeric("col", { precision: 10, scale: 2 })"#)] + #[case::numeric_mysql(Mysql, r#"decimal("col", { precision: 10, scale: 2 })"#)] + #[case::numeric_sqlite(Sqlite, r#"numeric("col")"#)] + fn numeric_maps_per_dialect(#[case] dialect: DrizzleDialect, #[case] expected: &str) { + let ty = ColumnType::Complex(ComplexColumnType::Numeric { + precision: 10, + scale: 2, + }); + assert_eq!(call(&ty, dialect), expected); + } + + /// The SQL layer hands a `Custom` type's name to every backend verbatim, + /// so every dialect's column calls a local `customType` helper of that + /// name. + #[rstest] + #[case::custom_pg(Pg)] + #[case::custom_mysql(Mysql)] + #[case::custom_sqlite(Sqlite)] + fn custom_types_call_a_local_customtype_helper(#[case] dialect: DrizzleDialect) { + let ty = ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "tsvector".to_string(), + }); + let ctor = column_ctor(&ty, dialect, "orders", &empty_bindings()); + assert!(ctor.local); + assert_eq!(ctor.call("col"), r#"tsvector("col")"#); + let decl = custom_column(&ty, dialect).expect("declares a customType"); + assert_eq!( + render_custom_type_decl(&decl, &decl.const_name), + "const tsvector = customType<{ data: string }>({ dataType() { return \"tsvector\"; } });" + ); + } + + fn string_enum() -> ColumnType { + ColumnType::Complex(ComplexColumnType::Enum { + name: "order_status".to_string(), + values: EnumValues::String(vec!["draft".to_string(), "sent".to_string()]), + }) + } + + /// PostgreSQL calls a locally declared `pgEnum` const — the ctor is + /// `local`, so the import collector must skip it. + #[test] + fn pg_string_enum_calls_the_table_qualified_const() { + let ctor = column_ctor(&string_enum(), Pg, "orders", &empty_bindings()); + assert!(ctor.local); + assert_eq!(ctor.symbol, "ordersOrderStatus"); + assert_eq!(ctor.call("col"), r#"ordersOrderStatus("col")"#); + } + + /// MySQL and SQLite inline the variant list; the ctor is a plain import. + #[rstest] + #[case::mysql(Mysql, r#"mysqlEnum("col", ["draft", "sent"])"#)] + #[case::sqlite(Sqlite, r#"text("col", { enum: ["draft", "sent"] })"#)] + fn inline_string_enums_carry_the_variant_list( + #[case] dialect: DrizzleDialect, + #[case] expected: &str, + ) { + let ctor = column_ctor(&string_enum(), dialect, "orders", &empty_bindings()); + assert!(!ctor.local); + assert_eq!(ctor.call("col"), expected); + } + + #[rstest] + #[case::pg(Pg, r#"integer("col")"#)] + #[case::mysql(Mysql, r#"int("col")"#)] + #[case::sqlite(Sqlite, r#"integer("col")"#)] + fn integer_enums_are_plain_integer_columns( + #[case] dialect: DrizzleDialect, + #[case] expected: &str, + ) { + let ty = ColumnType::Complex(ComplexColumnType::Enum { + name: "prio".to_string(), + values: EnumValues::Integer(vec![NumValue { + name: "low".to_string(), + value: 1, + }]), + }); + assert_eq!(call(&ty, dialect), expected); + } +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/collisions.rs b/crates/vespertide-exporter/src/tests/fixtures/collisions.rs new file mode 100644 index 00000000..272b4ede --- /dev/null +++ b/crates/vespertide-exporter/src/tests/fixtures/collisions.rs @@ -0,0 +1,65 @@ +//! Adversarial-name fixtures for the single-file backends' binding claims. + +use vespertide_core::TableDef; +use vespertide_core::schema::column::{ColumnType, ComplexColumnType, SimpleColumnType}; +use vespertide_core::schema::constraint::TableConstraint; + +use super::{col, fk, pk, simple}; + +/// Adversarial file-scope binding names for the single-file backends: a table +/// whose const collides with another table's would-be `relations` const +/// (`user_relations` vs `user`), a table named after a drizzle-orm import +/// (`sql`), and a custom type named after a column constructor (`integer`). +/// Drizzle suffixes its way around each; the per-table backends are +/// unaffected. +pub(crate) fn binding_collisions() -> Vec { + let user_relations = TableDef { + name: "user_relations".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + col( + "kind", + ColumnType::Complex(ComplexColumnType::Custom { + custom_type: "integer".to_string(), + }), + ), + ], + constraints: vec![pk(&["id"])], + }; + let user = TableDef { + name: "user".into(), + description: None, + columns: vec![simple("id", SimpleColumnType::Integer)], + constraints: vec![pk(&["id"])], + }; + let sql_table = TableDef { + name: "sql".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("amount", SimpleColumnType::Integer), + ], + constraints: vec![ + pk(&["id"]), + TableConstraint::Check { + name: "chk_sql_amount".into(), + expr: "amount > 0".into(), + strategy: vespertide_core::CheckViolationStrategy::default(), + }, + ], + }; + let posts = TableDef { + name: "posts".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer), + simple("user_id", SimpleColumnType::Integer), + ], + constraints: vec![pk(&["id"]), fk(&["user_id"], "user", &["id"])], + }; + [user_relations, user, sql_table, posts] + .into_iter() + .map(|t| t.normalize().expect("binding_collisions normalizes")) + .collect() +} diff --git a/crates/vespertide-exporter/src/tests/fixtures/mod.rs b/crates/vespertide-exporter/src/tests/fixtures/mod.rs index bd2e0a99..7f3797ab 100644 --- a/crates/vespertide-exporter/src/tests/fixtures/mod.rs +++ b/crates/vespertide-exporter/src/tests/fixtures/mod.rs @@ -8,6 +8,9 @@ use vespertide_core::{ SimpleColumnType, StrOrBoolOrArray, TableConstraint, TableDef, }; +mod collisions; +pub(crate) use collisions::binding_collisions; + pub(crate) fn col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, false) } @@ -16,7 +19,7 @@ fn nullable_col(name: &str, ty: ColumnType) -> ColumnDef { ColumnDef::new(name, ty, true) } -pub(super) fn simple(name: &str, ty: SimpleColumnType) -> ColumnDef { +pub(crate) fn simple(name: &str, ty: SimpleColumnType) -> ColumnDef { col(name, ColumnType::Simple(ty)) } @@ -37,7 +40,7 @@ pub(super) fn table( } } -pub(super) fn pk(columns: &[&str]) -> TableConstraint { +pub(crate) fn pk(columns: &[&str]) -> TableConstraint { TableConstraint::PrimaryKey { auto_increment: false, columns: columns.iter().copied().map(Into::into).collect(), @@ -53,7 +56,7 @@ fn auto_pk(columns: &[&str]) -> TableConstraint { } } -pub(super) fn fk(columns: &[&str], ref_table: &str, ref_columns: &[&str]) -> TableConstraint { +pub(crate) fn fk(columns: &[&str], ref_table: &str, ref_columns: &[&str]) -> TableConstraint { TableConstraint::ForeignKey { name: None, columns: columns.iter().copied().map(Into::into).collect(), @@ -1075,6 +1078,26 @@ pub(crate) fn integer_enum_with_variant_default() -> TableDef { ) } +/// A CHECK constraint the SQL layer enforces on every write. Drizzle renders +/// it through its `check` builder; the other backends have no schema-level +/// CHECK syntax and drop it — the cross-ORM snapshots document that split. +pub(crate) fn table_with_check() -> TableDef { + let raw = TableDef { + name: "products".into(), + description: None, + columns: vec![ + simple("id", SimpleColumnType::Integer).primary_key(PrimaryKeySyntax::Bool(true)), + simple("price", SimpleColumnType::Integer), + ], + constraints: vec![TableConstraint::Check { + name: "chk_products_price".into(), + expr: "price >= 0".into(), + strategy: vespertide_core::CheckViolationStrategy::default(), + }], + }; + raw.normalize().expect("table_with_check normalizes") +} + /// Small (< 50-table) multi-table schema for exercising the **sequential** /// branch of the multi-table entry points: /// * `vespertide-exporter::sqlalchemy::render::export` lines 21-29 diff --git a/crates/vespertide-exporter/src/tests/mod.rs b/crates/vespertide-exporter/src/tests/mod.rs index 5a20e3a8..18bdbcab 100644 --- a/crates/vespertide-exporter/src/tests/mod.rs +++ b/crates/vespertide-exporter/src/tests/mod.rs @@ -7,7 +7,7 @@ use crate::orm::{Orm, render_entity, render_entity_with_schema}; pub(crate) mod fixtures; /// Dispatch the per-ORM **multi-table** entry point so the cross-ORM -/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all five +/// `orm_cases!(multi ...)` arm renders a `Vec` schema for all six /// ORMs through a single call. JPA's `render_entities` returns `Vec` /// (one entry per entity); we join with `"\n"` to match the /// `String`-returning shape of the other four. @@ -71,6 +71,11 @@ orm_cases!( "basic_single_pk", fixtures::basic_single_pk ); +orm_cases!( + table_with_check_snapshot, + "table_with_check", + fixtures::table_with_check +); orm_cases!( composite_pk_snapshot, "composite_pk", @@ -271,7 +276,7 @@ orm_cases!( ); // Cross-ORM comparison of identifier escaping. Each language starts identifiers // differently — Prisma and Pydantic reject a leading `_`, the rest accept it — -// so the five snapshots must differ, and every one has to carry the original +// so the six snapshots must differ, and every one has to carry the original // name (`@@map` / `@map`, `column_name`, the positional column name, // `sa_column_kwargs`, `@Table`/`@Column`). orm_cases!( @@ -301,7 +306,7 @@ orm_cases!( // A composite FK becomes a relation only where the backend can express one // (`SeaORM`'s tuple `from`/`to`, Prisma's multi-column `fields`/`references`); // the Python backends keep it as a `ForeignKeyConstraint` and JPA currently -// drops it, so the five outputs disagree in a way worth pinning. +// drops it, so the six outputs disagree in a way worth pinning. orm_cases!( multi composite_fk_relation_snapshot, "composite_fk_relation", @@ -350,6 +355,11 @@ orm_cases!( "small_multi_schema_sequential", fixtures::small_multi_schema ); +orm_cases!( + multi binding_collisions_snapshot, + "binding_collisions", + fixtures::binding_collisions +); /// Dispatch the per-ORM `to_pascal_case` helper from a single entry point so /// the cross-ORM consolidation test can exercise every implementation without @@ -368,11 +378,11 @@ fn to_pascal_case_for(orm: Orm, s: &str) -> String { /// Cross-ORM `to_pascal_case` consolidation. Inputs in this matrix are /// restricted to ASCII with `_` as the only separator — the subset where all -/// five ORM implementations agree. +/// six ORM implementations agree. /// /// Divergences intentionally NOT covered here: -/// * `-` as separator: `SeaORM` and Prisma treat it as a separator (Prisma via -/// `vespertide_naming::to_pascal_case`), the other three ORMs leave it +/// * `-` as separator: `SeaORM`, Prisma and Drizzle treat it as a separator +/// (the latter two via `vespertide_naming`), the other three ORMs leave it /// intact (their splits operate on `_` only). /// * Non-ASCII characters: `SeaORM` and Prisma use `to_ascii_uppercase`, the /// others use `to_uppercase` (Unicode-aware). diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap index ade2de92..3df1b992 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@mysql.snap @@ -1,9 +1,9 @@ --- source: crates/vespertide-exporter/src/drizzle/mod.rs -assertion_line: 285 +assertion_line: 316 expression: rendered --- -import { mysqlTable, primaryKey, foreignKey, unique, index, check, bigint, int, mysqlEnum, text } from "drizzle-orm/mysql-core"; +import { mysqlTable, primaryKey, foreignKey, uniqueIndex, index, check, bigint, int, mysqlEnum, text } from "drizzle-orm/mysql-core"; import { relations, sql } from "drizzle-orm"; export const users = mysqlTable("users", { @@ -29,7 +29,7 @@ export const accounts = mysqlTable("accounts", { id: int("id").notNull(), tenantId: bigint("tenant_id", { mode: "number" }).notNull(), }, (t) => [ - primaryKey({ columns: [t.id, t.tenantId] }), + primaryKey({ name: "accounts_id_tenant_id", columns: [t.id, t.tenantId] }), ]); export const compositeUnique = mysqlTable("composite_unique", { @@ -37,16 +37,18 @@ export const compositeUnique = mysqlTable("composite_unique", { tenantId: int("tenant_id").notNull(), name: text("name").notNull(), }, (t) => [ - unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), + uniqueIndex("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), ]); export const users = mysqlTable("users", { id: int("id").notNull(), - email: text("email").notNull().unique("uq_users__email"), - username: text("username").notNull().unique("uq_users__uq_username"), + email: text("email").notNull(), + username: text("username").notNull(), department: text("department"), status: text("status").notNull().default("active"), }, (t) => [ + uniqueIndex("uq_users__email").on(t.email), + uniqueIndex("uq_users__uq_username").on(t.username), index("ix_users__idx_department").on(t.department), ]); @@ -57,6 +59,11 @@ export const products = mysqlTable("products", { check("chk_products_price", sql`price >= 0`), ]); +export const tasks = mysqlTable("tasks", { + id: int("id").primaryKey(), + priority: int("priority").notNull(), +}); + export const usersRelations = relations(users, ({ one, many }) => ({ posts: many(posts), })); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap index 33e50dbc..7ab1739c 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@pg.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/drizzle/mod.rs -assertion_line: 285 +assertion_line: 316 expression: rendered --- -import { pgTable, pgEnum, primaryKey, foreignKey, unique, index, check, bigint, integer, text } from "drizzle-orm/pg-core"; +import { pgTable, pgEnum, primaryKey, foreignKey, uniqueIndex, index, check, bigint, integer, text } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; -export const docStatus = pgEnum("doc_status", ["draft", "published", "archived"]); +export const documentsDocStatus = pgEnum("documents_doc_status", ["draft", "published", "archived"]); export const users = pgTable("users", { id: integer("id").primaryKey(), @@ -23,15 +23,15 @@ export const posts = pgTable("posts", { export const documents = pgTable("documents", { id: integer("id").notNull(), - status: docStatus("status").notNull(), - reviewStatus: docStatus("review_status"), + status: documentsDocStatus("status").notNull(), + reviewStatus: documentsDocStatus("review_status"), }); export const accounts = pgTable("accounts", { id: integer("id").notNull(), tenantId: bigint("tenant_id", { mode: "number" }).notNull(), }, (t) => [ - primaryKey({ columns: [t.id, t.tenantId] }), + primaryKey({ name: "accounts_pkey", columns: [t.id, t.tenantId] }), ]); export const compositeUnique = pgTable("composite_unique", { @@ -39,16 +39,18 @@ export const compositeUnique = pgTable("composite_unique", { tenantId: integer("tenant_id").notNull(), name: text("name").notNull(), }, (t) => [ - unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), + uniqueIndex("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), ]); export const users = pgTable("users", { id: integer("id").notNull(), - email: text("email").notNull().unique("uq_users__email"), - username: text("username").notNull().unique("uq_users__uq_username"), + email: text("email").notNull(), + username: text("username").notNull(), department: text("department"), status: text("status").notNull().default("active"), }, (t) => [ + uniqueIndex("uq_users__email").on(t.email), + uniqueIndex("uq_users__uq_username").on(t.username), index("ix_users__idx_department").on(t.department), ]); @@ -59,6 +61,11 @@ export const products = pgTable("products", { check("chk_products_price", sql`price >= 0`), ]); +export const tasks = pgTable("tasks", { + id: integer("id").primaryKey(), + priority: integer("priority").notNull(), +}); + export const usersRelations = relations(users, ({ one, many }) => ({ posts: many(posts), })); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap index a0f78d89..f9855056 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__drizzle__tests__render_schema_full_file_per_dialect@sqlite.snap @@ -1,9 +1,9 @@ --- source: crates/vespertide-exporter/src/drizzle/mod.rs -assertion_line: 285 +assertion_line: 316 expression: rendered --- -import { sqliteTable, primaryKey, foreignKey, unique, index, check, integer, text } from "drizzle-orm/sqlite-core"; +import { sqliteTable, primaryKey, foreignKey, uniqueIndex, index, check, integer, text } from "drizzle-orm/sqlite-core"; import { relations, sql } from "drizzle-orm"; export const users = sqliteTable("users", { @@ -37,16 +37,18 @@ export const compositeUnique = sqliteTable("composite_unique", { tenantId: integer("tenant_id").notNull(), name: text("name").notNull(), }, (t) => [ - unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), + uniqueIndex("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), ]); export const users = sqliteTable("users", { id: integer("id").notNull(), - email: text("email").notNull().unique("uq_users__email"), - username: text("username").notNull().unique("uq_users__uq_username"), + email: text("email").notNull(), + username: text("username").notNull(), department: text("department"), status: text("status").notNull().default("active"), }, (t) => [ + uniqueIndex("uq_users__email").on(t.email), + uniqueIndex("uq_users__uq_username").on(t.username), index("ix_users__idx_department").on(t.department), ]); @@ -57,6 +59,11 @@ export const products = sqliteTable("products", { check("chk_products_price", sql`price >= 0`), ]); +export const tasks = sqliteTable("tasks", { + id: integer("id").primaryKey(), + priority: integer("priority").notNull(), +}); + export const usersRelations = relations(users, ({ one, many }) => ({ posts: many(posts), })); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap index 8420739c..ba69a0ff 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__all_simple_types_snapshot@all_simple_types_Drizzle.snap @@ -1,8 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 141 +assertion_line: 146 expression: rendered --- +const bytea = customType<{ data: Uint8Array }>({ dataType() { return "bytea"; } }); + +const xml = customType<{ data: string }>({ dataType() { return "xml"; } }); + export const allTypes = pgTable("all_types", { id: integer("id").primaryKey(), small: smallint("small").notNull(), @@ -16,11 +20,11 @@ export const allTypes = pgTable("all_types", { tsCol: timestamp("ts_col").notNull(), tstzCol: timestamp("tstz_col", { withTimezone: true }).notNull(), intervalCol: interval("interval_col").notNull(), - byteaCol: text("bytea_col") /* bytea */.notNull(), + byteaCol: bytea("bytea_col").notNull(), uuidCol: uuid("uuid_col").notNull(), jsonCol: json("json_col").notNull(), inetCol: inet("inet_col").notNull(), cidrCol: cidr("cidr_col").notNull(), macaddrCol: macaddr("macaddr_col").notNull(), - xmlCol: text("xml_col") /* xml */.notNull(), + xmlCol: xml("xml_col").notNull(), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap index cfa2d822..3d6a7ed5 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__basic_table_with_description_snapshot@basic_table_with_description_Drizzle.snap @@ -6,8 +6,10 @@ expression: rendered // User accounts table export const users = pgTable("users", { // Primary key - id: serial("id").primaryKey(), + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), // User email address - email: text("email").notNull().unique("uq_users__email"), + email: text("email").notNull(), name: text("name"), -}); +}, (t) => [ + uniqueIndex("uq_users__email").on(t.email), +]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Drizzle.snap new file mode 100644 index 00000000..e40d62f3 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Drizzle.snap @@ -0,0 +1,37 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +const integer2 = customType<{ data: string }>({ dataType() { return "integer"; } }); + +export const userRelations = pgTable("user_relations", { + id: integer("id").primaryKey(), + kind: integer2("kind").notNull(), +}); + +export const user = pgTable("user", { + id: integer("id").primaryKey(), +}); + +export const userRelations2 = relations(user, ({ one, many }) => ({ + posts: many(posts), +})); + +export const sql2 = pgTable("sql", { + id: integer("id").primaryKey(), + amount: integer("amount").notNull(), +}, (t) => [ + check("chk_sql_amount", sql`amount > 0`), +]); + +export const posts = pgTable("posts", { + id: integer("id").primaryKey(), + userId: integer("user_id").notNull(), +}, (t) => [ + foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_posts__user_id" }), +]); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + user: one(user, { fields: [posts.userId], references: [user.id] }), +})); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Jpa.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Jpa.snap new file mode 100644 index 00000000..da1dd1da --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Jpa.snap @@ -0,0 +1,70 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +import jakarta.persistence.*; + +@Entity +@Table(name = "user_relations") +public class UserRelations { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "kind", nullable = false, columnDefinition = "integer") + private String kind; + + protected UserRelations() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "user") +public class User { + + @Id + @Column(name = "id") + private Integer id; + + protected User() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "sql") +public class Sql { + + @Id + @Column(name = "id") + private Integer id; + + @Column(name = "amount", nullable = false) + private Integer amount; + + protected Sql() { + } +} + +import jakarta.persistence.*; + +@Entity +@Table(name = "posts") +public class Posts { + + @Id + @Column(name = "id") + private Integer id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + protected Posts() { + } +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Prisma.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Prisma.snap new file mode 100644 index 00000000..fe09d9c7 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_Prisma.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +model UserRelations { + id Int @id + kind Unsupported("integer") + + @@map("user_relations") +} + +model User { + id Int @id + posts Posts[] + + @@map("user") +} + +model Sql { + id Int @id + amount Int + + @@map("sql") +} + +model Posts { + id Int @id + user_id Int + user User @relation(fields: [user_id], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("posts") +} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SeaOrm.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SeaOrm.snap new file mode 100644 index 00000000..cafe7349 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SeaOrm.snap @@ -0,0 +1,67 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user_relations")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(column_type = "integer")] + pub kind: String, +} + +vespera::schema_type!(Schema from Model, name = "UserRelationsSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "user")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(has_many)] + pub posts: HasMany, +} + +vespera::schema_type!(Schema from Model, name = "UserSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "sql")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub amount: i32, +} + +vespera::schema_type!(Schema from Model, name = "SqlSchema"); +impl ActiveModelBehavior for ActiveModel {} + + +use sea_orm::entity::prelude::*; + +#[sea_orm::model] +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "posts")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub user_id: i32, + #[sea_orm(belongs_to, from = "user_id", to = "id")] + pub user: HasOne, +} + +vespera::schema_type!(Schema from Model, name = "PostsSchema"); +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlAlchemy.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlAlchemy.snap new file mode 100644 index 00000000..2236e1d2 --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlAlchemy.snap @@ -0,0 +1,34 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +from __future__ import annotations + + +from sqlalchemy import ForeignKey, Integer +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class UserRelations(DeclarativeBase): + __tablename__ = "user_relations" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + kind: Mapped[str] = mapped_column("integer", nullable=False) + +class User(DeclarativeBase): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class Sql(DeclarativeBase): + __tablename__ = "sql" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + amount: Mapped[int] = mapped_column(Integer, nullable=False) + +class Posts(DeclarativeBase): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("user.id"), nullable=False) diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlModel.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlModel.snap new file mode 100644 index 00000000..b239b93c --- /dev/null +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__binding_collisions_snapshot@binding_collisions_SqlModel.snap @@ -0,0 +1,33 @@ +--- +source: crates/vespertide-exporter/src/tests/mod.rs +assertion_line: 358 +expression: rendered +--- +from __future__ import annotations + + +from sqlmodel import Field, SQLModel + + +class UserRelations(SQLModel, table=True): + __tablename__ = "user_relations" + + id: int = Field(primary_key=True) + kind: str = Field(...) + +class User(SQLModel, table=True): + __tablename__ = "user" + + id: int = Field(primary_key=True) + +class Sql(SQLModel, table=True): + __tablename__ = "sql" + + id: int = Field(primary_key=True) + amount: int = Field(...) + +class Posts(SQLModel, table=True): + __tablename__ = "posts" + + id: int = Field(primary_key=True) + user_id: int = Field(foreign_key="user.id") diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap index 721d8257..4d6f9173 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__complex_types_snapshot@complex_types_Drizzle.snap @@ -1,12 +1,14 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 146 +assertion_line: 151 expression: rendered --- +const cUSTOMTYPE = customType<{ data: string }>({ dataType() { return "CUSTOM_TYPE"; } }); + export const complexTypes = pgTable("complex_types", { id: integer("id").primaryKey(), varcharCol: varchar("varchar_col", { length: 100 }).notNull(), charCol: char("char_col", { length: 10 }).notNull(), numericCol: numeric("numeric_col", { precision: 10, scale: 2 }).notNull(), - customCol: text("custom_col") /* CUSTOM_TYPE */.notNull(), + customCol: cUSTOMTYPE("custom_col").notNull(), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap index 60700ee1..c0c10963 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_constraints_snapshot@composite_constraints_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 172 +assertion_line: 177 expression: rendered --- export const orderItems = pgTable("order_items", { @@ -8,10 +8,10 @@ export const orderItems = pgTable("order_items", { productId: integer("product_id").notNull(), quantity: integer("quantity").notNull(), }, (t) => [ - primaryKey({ columns: [t.orderId, t.productId] }), + primaryKey({ name: "order_items_pkey", columns: [t.orderId, t.productId] }), foreignKey({ columns: [t.orderId], foreignColumns: [orders.id], name: "fk_order_items__order_id" }), foreignKey({ columns: [t.productId], foreignColumns: [products.id], name: "fk_order_items__product_id" }), - unique("uq_order_items__uq_order_items__order_product").on(t.orderId, t.productId), + uniqueIndex("uq_order_items__uq_order_items__order_product").on(t.orderId, t.productId), index("ix_order_items__ix_order_items__order_id").on(t.orderId), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap index 51a488ad..2b27df12 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_fk_relation_snapshot@composite_fk_relation_Drizzle.snap @@ -1,13 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 305 +assertion_line: 310 expression: rendered --- export const orders = pgTable("orders", { id: integer("id").notNull(), version: integer("version").notNull(), }, (t) => [ - primaryKey({ columns: [t.id, t.version] }), + primaryKey({ name: "orders_pkey", columns: [t.id, t.version] }), ]); export const ordersRelations = relations(orders, ({ one, many }) => ({ diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap index 5eb07582..a2fcd9d8 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_pk_snapshot@composite_pk_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 74 +assertion_line: 79 expression: rendered --- export const accounts = pgTable("accounts", { id: integer("id").notNull(), tenantId: bigint("tenant_id", { mode: "number" }).notNull(), }, (t) => [ - primaryKey({ columns: [t.id, t.tenantId] }), + primaryKey({ name: "accounts_pkey", columns: [t.id, t.tenantId] }), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap index 46bbbd74..264b197d 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_primary_key_snapshot@composite_primary_key_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 247 +assertion_line: 252 expression: rendered --- export const membership = pgTable("membership", { @@ -8,5 +8,5 @@ export const membership = pgTable("membership", { userId: integer("user_id").notNull(), role: text("role").notNull(), }, (t) => [ - primaryKey({ columns: [t.tenantId, t.userId] }), + primaryKey({ name: "membership_pkey", columns: [t.tenantId, t.userId] }), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap index 762a3a6d..a0ddda4c 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_constraint_snapshot@composite_unique_constraint_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 252 +assertion_line: 257 expression: rendered --- export const accountAliases = pgTable("account_aliases", { @@ -8,5 +8,5 @@ export const accountAliases = pgTable("account_aliases", { tenantId: integer("tenant_id").notNull(), slug: text("slug").notNull(), }, (t) => [ - unique("uq_account_aliases__uq_account_aliases__tenant_slug").on(t.tenantId, t.slug), + uniqueIndex("uq_account_aliases__uq_account_aliases__tenant_slug").on(t.tenantId, t.slug), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap index f334b3ce..40b2477c 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__composite_unique_snapshot@composite_unique_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 177 +assertion_line: 182 expression: rendered --- export const compositeUnique = pgTable("composite_unique", { @@ -8,5 +8,5 @@ export const compositeUnique = pgTable("composite_unique", { tenantId: integer("tenant_id").notNull(), name: text("name").notNull(), }, (t) => [ - unique("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), + uniqueIndex("uq_composite_unique__uq_tenant_name").on(t.tenantId, t.name), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap index 437163e6..8cc94708 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__defaults_snapshot@defaults_Drizzle.snap @@ -1,10 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 156 +assertion_line: 161 expression: rendered --- export const articles = pgTable("articles", { - id: serial("id").primaryKey(), + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), published: boolean("published").notNull().default(false), viewCount: integer("view_count").notNull().default(0), status: text("status").notNull().default("draft"), diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap index 1ecc8dcb..b87694ae 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_multiple_columns_snapshot@enum_multiple_columns_Drizzle.snap @@ -1,14 +1,14 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 110 +assertion_line: 115 expression: rendered --- -export const productCategory = pgEnum("product_category", ["electronics", "clothing", "food"]); +export const productsProductCategory = pgEnum("products_product_category", ["electronics", "clothing", "food"]); -export const availabilityStatus = pgEnum("availability_status", ["in_stock", "out_of_stock", "pre_order"]); +export const productsAvailabilityStatus = pgEnum("products_availability_status", ["in_stock", "out_of_stock", "pre_order"]); export const products = pgTable("products", { id: integer("id").notNull(), - category: productCategory("category").notNull(), - availability: availabilityStatus("availability").notNull(), + category: productsProductCategory("category").notNull(), + availability: productsAvailabilityStatus("availability").notNull(), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap index 5b5f7b70..24112138 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_shared_snapshot@enum_shared_Drizzle.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 115 +assertion_line: 120 expression: rendered --- -export const docStatus = pgEnum("doc_status", ["draft", "published", "archived"]); +export const documentsDocStatus = pgEnum("documents_doc_status", ["draft", "published", "archived"]); export const documents = pgTable("documents", { id: integer("id").notNull(), - status: docStatus("status").notNull(), - reviewStatus: docStatus("review_status"), + status: documentsDocStatus("status").notNull(), + reviewStatus: documentsDocStatus("review_status"), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap index f6214a1a..df26fae2 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_special_values_snapshot@enum_special_values_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 116 +assertion_line: 121 expression: rendered --- -export const eventSeverity = pgEnum("event_severity", ["info-level", "warning_level", "ERROR_LEVEL", "1critical"]); +export const eventsEventSeverity = pgEnum("events_event_severity", ["info-level", "warning_level", "ERROR_LEVEL", "1critical"]); export const events = pgTable("events", { id: integer("id").notNull(), - severity: eventSeverity("severity").notNull(), + severity: eventsEventSeverity("severity").notNull(), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap index 40973f01..45a5db3a 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__enum_with_default_snapshot@enum_with_default_Drizzle.snap @@ -1,13 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 121 +assertion_line: 126 expression: rendered --- -export const taskStatus = pgEnum("task_status", ["pending", "in_progress", "completed"]); +export const tasksTaskStatus = pgEnum("tasks_task_status", ["pending", "in_progress", "completed"]); export const tasks = pgTable("tasks", { id: integer("id").notNull(), - status: taskStatus("status").notNull().default("pending"), + status: tasksTaskStatus("status").notNull().default("pending"), priority: integer("priority").notNull().default(0), isArchived: boolean("is_archived").notNull().default(false), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap index 5078ee40..2eae21c4 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_names_collide_after_id_strip_snapshot@fk_names_collide_after_id_strip_Drizzle.snap @@ -1,12 +1,14 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 313 +assertion_line: 318 expression: rendered --- export const target = pgTable("target", { id: integer("id").primaryKey(), - alt: integer("alt").notNull().unique("uq_target__alt"), -}); + alt: integer("alt").notNull(), +}, (t) => [ + uniqueIndex("uq_target__alt").on(t.alt), +]); export const targetRelations = relations(target, ({ one, many }) => ({ aSrc: many(src, { relationName: "SrcA" }), diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap index 2a222dc6..a4132723 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__fk_with_comment_and_auto_increment_snapshot@fk_with_comment_and_auto_increment_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 227 +assertion_line: 232 expression: rendered --- export const child = pgTable("child", { // References parent table - parentId: serial("parent_id").primaryKey(), + parentId: integer("parent_id").primaryKey().generatedByDefaultAsIdentity(), value: text("value").notNull(), }, (t) => [ foreignKey({ columns: [t.parentId], foreignColumns: [parent.id], name: "fk_child__parent_id" }), diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap index 530d3c02..87ba5b39 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__jsonb_custom_type_snapshot@jsonb_custom_type_Drizzle.snap @@ -1,11 +1,15 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 151 +assertion_line: 156 expression: rendered --- +const jSONB = customType<{ data: string }>({ dataType() { return "JSONB"; } }); + +const jsonb = customType<{ data: string }>({ dataType() { return "jsonb"; } }); + export const jsonStruct = pgTable("json_struct", { id: integer("id").notNull(), jsonData: json("json_data").notNull(), - jsonbData: text("jsonb_data") /* JSONB */.notNull(), - jsonbNullable: text("jsonb_nullable") /* jsonb */, + jsonbData: jSONB("jsonb_data").notNull(), + jsonbNullable: jsonb("jsonb_nullable"), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap index ce8ce5d0..63153c06 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__non_identifier_names_in_constraints_snapshot@non_identifier_names_in_constraints_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 286 +assertion_line: 291 expression: rendered --- export const membership = pgTable("membership", { @@ -9,7 +9,7 @@ export const membership = pgTable("membership", { userEmail: text("user-email").notNull(), x3created: text("3created").notNull(), }, (t) => [ - primaryKey({ columns: [t.x1tenantId, t.x2userId] }), - unique("uq_membership__1tenant_id_user-email").on(t.userEmail, t.x1tenantId), + primaryKey({ name: "membership_pkey", columns: [t.x1tenantId, t.x2userId] }), + uniqueIndex("uq_membership__1tenant_id_user-email").on(t.userEmail, t.x1tenantId), index("ix_membership__3created").on(t.x3created), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap index b95adef8..07c13cc0 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_columns_snapshot@nullable_columns_Drizzle.snap @@ -1,10 +1,10 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 167 +assertion_line: 172 expression: rendered --- export const profiles = pgTable("profiles", { - id: serial("id").primaryKey(), + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), bio: text("bio"), avatarUrl: varchar("avatar_url", { length: 500 }), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap index 3c742212..8d2fb936 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__nullable_enum_snapshot@nullable_enum_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 105 +assertion_line: 110 expression: rendered --- -export const statusType = pgEnum("status_type", ["active", "inactive"]); +export const nullableEnumStatusType = pgEnum("nullable_enum_status_type", ["active", "inactive"]); export const nullableEnum = pgTable("nullable_enum", { id: integer("id").primaryKey(), - status: statusType("status"), + status: nullableEnumStatusType("status"), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap index 5f70cca8..b3e3a882 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__pk_and_fk_together_snapshot@pk_and_fk_together_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 90 +assertion_line: 95 expression: rendered --- export const articleUser = pgTable("article_user", { @@ -9,9 +9,9 @@ export const articleUser = pgTable("article_user", { authorOrder: integer("author_order").notNull().default(1), role: varchar("role", { length: 20 }).notNull().default("contributor"), isLead: boolean("is_lead").notNull().default(false), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`CURRENT_TIMESTAMP`), }, (t) => [ - primaryKey({ columns: [t.articleId, t.userId] }), + primaryKey({ name: "article_user_pkey", columns: [t.articleId, t.userId] }), foreignKey({ columns: [t.articleId], foreignColumns: [article.id], name: "fk_article_user__article_id" }).onDelete("cascade"), foreignKey({ columns: [t.userId], foreignColumns: [user.id], name: "fk_article_user__user_id" }).onDelete("cascade"), index("ix_article_user__article_id").on(t.articleId), diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap index c4f4a624..73ee7112 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__render_entity_with_schema_snapshots@composite_fk_parent_Drizzle.snap @@ -1,13 +1,13 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 440 +assertion_line: 445 expression: rendered --- export const parent = pgTable("parent", { id1: integer("id1").notNull(), id2: integer("id2").notNull(), }, (t) => [ - primaryKey({ columns: [t.id1, t.id2] }), + primaryKey({ name: "parent_pkey", columns: [t.id1, t.id2] }), ]); export const parentRelations = relations(parent, ({ one, many }) => ({ diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap index ade3a56d..61610fbc 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_default_and_true_boolean_snapshot@server_default_and_true_boolean_Drizzle.snap @@ -1,12 +1,12 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 162 +assertion_line: 167 expression: rendered --- export const logs = pgTable("logs", { - id: serial("id").primaryKey(), + id: integer("id").primaryKey().generatedByDefaultAsIdentity(), active: boolean("active").notNull().default(true), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`CURRENT_TIMESTAMP`), score: real("score").notNull().default(1.5), tag: text("tag").notNull().default(sql`UNKNOWN_EXPR`), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap index 45eef47f..c00020e1 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__server_defaults_snapshot@server_defaults_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 157 +assertion_line: 162 expression: rendered --- export const withDefaults = pgTable("with_defaults", { id: integer("id").primaryKey(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().default(sql`CURRENT_TIMESTAMP`), status: text("status").notNull().default("active"), count: integer("count").notNull().default(0), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap index 53061aa4..b83a6155 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__table_with_enum_snapshot@table_with_enum_Drizzle.snap @@ -1,11 +1,11 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 95 +assertion_line: 100 expression: rendered --- -export const orderStatus = pgEnum("order_status", ["pending", "shipped", "delivered"]); +export const ordersOrderStatus = pgEnum("orders_order_status", ["pending", "shipped", "delivered"]); export const orders = pgTable("orders", { id: integer("id").notNull(), - status: orderStatus("status").notNull(), + status: ordersOrderStatus("status").notNull(), }); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap index bdd306f6..76e4b651 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unique_and_indexed_snapshot@unique_and_indexed_Drizzle.snap @@ -1,14 +1,16 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 126 +assertion_line: 131 expression: rendered --- export const users = pgTable("users", { id: integer("id").notNull(), - email: text("email").notNull().unique("uq_users__email"), - username: text("username").notNull().unique("uq_users__uq_username"), + email: text("email").notNull(), + username: text("username").notNull(), department: text("department"), status: text("status").notNull().default("active"), }, (t) => [ + uniqueIndex("uq_users__email").on(t.email), + uniqueIndex("uq_users__uq_username").on(t.username), index("ix_users__idx_department").on(t.department), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap index abac14c6..f8d5c97b 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_composite_unique_snapshot@unnamed_composite_unique_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 197 +assertion_line: 202 expression: rendered --- export const unnamedUnique = pgTable("unnamed_unique", { @@ -8,5 +8,5 @@ export const unnamedUnique = pgTable("unnamed_unique", { colA: integer("col_a").notNull(), colB: integer("col_b").notNull(), }, (t) => [ - unique("uq_unnamed_unique__col_a_col_b").on(t.colA, t.colB), + uniqueIndex("uq_unnamed_unique__col_a_col_b").on(t.colA, t.colB), ]); diff --git a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap index 4090ae41..943b012d 100644 --- a/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap +++ b/crates/vespertide-exporter/src/tests/snapshots/vespertide_exporter__tests__unnamed_index_and_unique_snapshot@unnamed_index_and_unique_Drizzle.snap @@ -1,6 +1,6 @@ --- source: crates/vespertide-exporter/src/tests/mod.rs -assertion_line: 187 +assertion_line: 192 expression: rendered --- export const events = pgTable("events", { @@ -9,5 +9,5 @@ export const events = pgTable("events", { date: date("date").notNull(), }, (t) => [ index("ix_events__date_venue_id").on(t.venueId, t.date), - unique("uq_events__date_venue_id").on(t.venueId, t.date), + uniqueIndex("uq_events__date_venue_id").on(t.venueId, t.date), ]); diff --git a/crates/vespertide-exporter/src/utils/typescript.rs b/crates/vespertide-exporter/src/utils/typescript.rs index cde05b9c..9270609d 100644 --- a/crates/vespertide-exporter/src/utils/typescript.rs +++ b/crates/vespertide-exporter/src/utils/typescript.rs @@ -136,6 +136,8 @@ mod tests { #[case::double_quote("say \"hi\"", r#""say \"hi\"""#)] #[case::backslash("back\\slash", r#""back\\slash""#)] #[case::newline("two\nlines", r#""two\nlines""#)] + #[case::carriage_return("a\rb", r#""a\rb""#)] + #[case::tab("a\tb", r#""a\tb""#)] #[case::empty("", r#""""#)] fn ts_string_escapes_literal_terminators(#[case] input: &str, #[case] expected: &str) { assert_eq!(ts_string(input), expected); From eea2fae61e375cf0892fb07bf99724832bd9200b Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:11:57 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20Drizzle=20=EC=9D=B5=EC=8A=A4?= =?UTF-8?q?=ED=8F=AC=ED=84=B0=20=EB=AC=B8=EC=84=9C=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?Prisma=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=84=A4=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 12 +++++----- README.md | 3 ++- crates/vespertide-cli/AGENTS.md | 6 ++--- crates/vespertide-exporter/AGENTS.md | 34 ++++++++++++++++++++++------ 4 files changed, 38 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c07198a..10eed2aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ vespertide/ │ ├── vespertide-planner/ # Schema diffing, baseline reconstruction, validation │ ├── vespertide-query/ # SQL generation (Postgres/MySQL/SQLite) │ ├── vespertide-cli/ # CLI commands: init, diff, sql, revision, export -│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma +│ ├── vespertide-exporter/ # ORM codegen: SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle │ ├── vespertide-loader/ # Filesystem loading of models/migrations │ ├── vespertide-config/ # vespertide.json configuration │ ├── vespertide-lsp/ # Language server: 13 LSP capabilities + HS-7~11 caching @@ -47,7 +47,7 @@ vespertide/ | Schema diffing | `vespertide-planner/src/diff/` | topological sort for FK deps | | SQL generation | `vespertide-query/src/sql/` | One file per action type | | CLI commands | `vespertide-cli/src/commands/` | `cmd_*` functions | -| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma}/` | Backend-specific generators | +| ORM export | `vespertide-exporter/src/{seaorm,sqlalchemy,sqlmodel,jpa,prisma,drizzle}/` | Backend-specific generators | | Compile-time macro | `vespertide-macro/src/lib.rs` | `vespertide_migration!` proc macro | | **LSP RingCache (HS-7~11)** | `vespertide-lsp/src/cache.rs` | Generic ring-buffer LRU shared across symbols/diagnostics/drift/semantic-token caches | | **LSP drift cache** | `vespertide-lsp/src/drift/cache.rs` | HS-10 drift cache implementation | @@ -169,7 +169,7 @@ See `docs/clippy-allow-audit.md` for the full audit history. | `QueryError::Other(...)` in new code | Emits deprecation warning. Use `SchemaError` / `InvalidColumnType` / `BackendError` / `UnsupportedAction` | | Exhaustive struct literal for `MigrationOptions` / `VespertideConfig` | `#[non_exhaustive]` — use `..Default::default()` | | Comparing newtype with `String::eq(&name.to_string(), "user")` | `TableName: PartialEq<&str>` — use `name == "user"` directly | -| Per-ORM exporter snapshot test (single ORM) | Use the 5-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | +| Per-ORM exporter snapshot test (single ORM) | Use the 6-ORM `orm_cases!` macro; snapshots must cross-compare all ORMs | ## COMMANDS @@ -234,7 +234,7 @@ Files near the ceiling (next split candidates — line counts as of the | `query/src/sql/delete_column/mod.rs` | 1138 | prod+inline-tests (≤1200) | DROP COLUMN with SQLite rebuild | | `query/src/sql/add_constraint/mod.rs` | 1138 | prod+inline-tests (≤1200) | ADD CONSTRAINT | | `core/src/schema/table/tests/mod.rs` | 1137 | test-file (≤1200) | Table normalization tests | -| `exporter/src/tests/fixtures/mod.rs` | 1126 | test-file (≤1200) | Shared 5-ORM fixture schemas | +| `exporter/src/tests/fixtures/mod.rs` | 1146 | test-file (≤1200) | Shared 6-ORM fixture schemas | | `planner/src/validate/check_strengthening.rs` | 1121 | prod+inline-tests (≤1200) | CHECK strengthening analysis | | `query/src/sql/helpers.rs` | 1109 | prod+inline-tests (≤1200) | Identifier quoting / type-cast helpers | | `lsp/src/code_actions.rs` | 1107 | prod+inline-tests (≤1200) | LSP code actions (incl. CHECK BETWEEN-swap) | @@ -373,9 +373,9 @@ fn create_table_snapshot(#[case] backend: DatabaseBackend) { This is the same pattern used by `vespertide-query` (3 backends, 357 snapshots) and `vespertide-exporter` (5 ORMs via `Orm` enum, 335 cross-ORM snapshots). When adding a new backend / ORM / format, the change is **one `#[case::name(Value)]` line**. ### Exporter snapshots MUST cover ALL ORMs (no per-ORM snapshots) -Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all five ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly five snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. +Every `vespertide-exporter` snapshot test MUST be written through the shared `orm_cases!` rstest macro in `crates/vespertide-exporter/src/tests/mod.rs`, which renders each fixture for **all six ORMs** (`Orm::SeaOrm`, `Orm::SqlAlchemy`, `Orm::SqlModel`, `Orm::Jpa`, `Orm::Prisma`, `Orm::Drizzle`). A new export scenario = ONE fixture + ONE `orm_cases!(...)` line, producing exactly six snapshots (one per ORM) in the single shared `crates/vespertide-exporter/src/tests/snapshots/` directory. -FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all five. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. +FORBIDDEN: per-ORM `#[test]` snapshot functions inside `src/seaorm/`, `src/sqlalchemy/`, `src/sqlmodel/`, `src/jpa/`, `src/prisma/`, or any `snapshots/` directory other than `src/tests/snapshots/`. A scenario snapshotted for only one ORM is a defect — ORM output must always be cross-compared across all six. When adding a new ORM the change is a single `#[case::(Orm::)]` line in the macro, never a new per-ORM test. Exception: an entry point that exists in only one backend (e.g. Prisma's single-file `render_schema`, which deduplicates enums globally) is not a cross-ORM scenario, so its snapshot tests live as inline tests of that module — with the snapshot files still written to the shared `src/tests/snapshots/` via `with_settings!(snapshot_path => ...)`. diff --git a/README.md b/README.md index 7f774f5e..6fbfb2d9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Declarative database schema management. Define your schemas in JSON, and Vespert - **Enum Types**: Native string enums and integer enums (no migration needed for new values) - **Zero-Runtime Migrations**: Compile-time macro generates database-specific SQL - **JSON Schema Validation**: Ships with JSON Schemas for IDE autocompletion and validation -- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma +- **ORM Export**: Export schemas to SeaORM, SQLAlchemy, SQLModel, JPA, Prisma, Drizzle - **Language Server**: First-class editor support via the bundled `vespertide-lsp` — see [LSP Features](#lsp-features) below ## What's new in 0.2.0 @@ -238,6 +238,7 @@ vespertide export --orm sqlalchemy # Python - SQLAlchemy models vespertide export --orm sqlmodel # Python - SQLModel (FastAPI) vespertide export --orm jpa # Java - JPA/Hibernate entities vespertide export --orm prisma # Prisma - schema.prisma models +vespertide export --orm drizzle # TypeScript - Drizzle ORM (pg/mysql/sqlite files) ``` ## Runtime Migrations (Macro) diff --git a/crates/vespertide-cli/AGENTS.md b/crates/vespertide-cli/AGENTS.md index d5b7fd5c..f22c62c1 100644 --- a/crates/vespertide-cli/AGENTS.md +++ b/crates/vespertide-cli/AGENTS.md @@ -19,8 +19,8 @@ src/ │ # choices_and_apply/), tests/ ├── status.rs # Show config and sync status ├── log.rs # List applied migrations with SQL - ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma) — - │ # mod.rs + tests/ (mod.rs, prisma.rs) + ├── export/ # Export to ORM code (SeaORM/SQLAlchemy/SQLModel/JPA/Prisma/Drizzle) — + │ # mod.rs + tests/ (mod.rs, prisma.rs, drizzle.rs) └── erd/ # ERD diagram export — mod.rs, mermaid.rs, dot.rs, svg/ (style, model, # layout, edges, render, util), tests/ ``` @@ -53,7 +53,7 @@ src/ ## NOTES - **revision/**: Most complex command — handles interactive `--fill-with` prompts for NOT NULL columns without defaults; long ago split from a single 3064-line file into `revision/{mod,parse,emit,write,timezones}.rs` + `prompts/` + `tests/` -- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma takes a separate single-file path (`prisma::render_schema` → one `schema.prisma`) rather than one file per model +- **export/**: Generates the `mod.rs` chain for SeaORM exports; Python/Java ORMs skip it. Prisma and Drizzle take separate single-file paths rather than one file per model — Prisma writes one `models.prisma`, Drizzle one file per dialect (`models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts`) - All commands use `load_config()`, `load_models()`, `load_migrations()` from `vespertide_loader` - YAML and JSON are both fully supported for models and migrations; `new -f yaml` creates YAML templates. - Prefer typed `MigrationAction` enums; `RawSql` exists as a documented emergency escape hatch, but is not recommended for normal use. diff --git a/crates/vespertide-exporter/AGENTS.md b/crates/vespertide-exporter/AGENTS.md index 87d781b5..5869cd56 100644 --- a/crates/vespertide-exporter/AGENTS.md +++ b/crates/vespertide-exporter/AGENTS.md @@ -1,15 +1,17 @@ # vespertide-exporter -ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma). +ORM code generation from `TableDef` schemas → SeaORM (Rust), SQLAlchemy (Python), SQLModel (Python), JPA (Java), Prisma (schema.prisma), Drizzle (TypeScript). ## STRUCTURE ``` src/ ├── lib.rs # Re-exports all backends -├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma), +├── orm.rs # OrmExporter trait, Orm enum (SeaOrm/SqlAlchemy/SqlModel/Jpa/Prisma/Drizzle), │ # Orm::file_extension(), dispatch -├── constraint_scan.rs # Shared constraint scanning helpers +├── constraint_scan.rs # Shared constraint scans + FK relation naming +│ # (fk_relation_names/relation_segment/collect_back_relations) +├── enum_scan.rs # Shared per-table enum-column scan (Prisma/Drizzle) ├── parallel_config.rs # Rayon parallelism thresholds ├── python_naming.rs # Shared Python PascalCase naming (SQLAlchemy/SQLModel/JPA/CLI) ├── seaorm/ # mod.rs, render.rs, types.rs, enums.rs, imports.rs, @@ -18,13 +20,15 @@ src/ ├── sqlmodel/ # mod.rs, render.rs, types.rs, enums.rs — SQLModel + Pydantic models ├── jpa/ # mod.rs, render.rs, types.rs — JPA/Hibernate entities ├── prisma/ # mod.rs, render.rs, types.rs, enums.rs — schema.prisma models -├── utils/ # common.rs (join_quoted/push_attr/join_qualified_refs/unquote), python.rs +├── drizzle/ # mod.rs, render.rs, types.rs, enums.rs — Drizzle TypeScript models +├── utils/ # common.rs (join_quoted/unquote/claim_field_name), python.rs, +│ # typescript.rs (ts_binding/ts_string) └── tests/ # Shared orm_cases! cross-ORM snapshot suite + fixtures/ + snapshots/ ``` Identifier escaping is centralized in `vespertide-naming`: `sanitize_identifier` with `IdentifierStart::Underscore` (Java, SQLAlchemy, ERD) or -`IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma), plus +`IdentifierStart::Letter` (SeaORM, SQLModel/Pydantic, Prisma, Drizzle), plus `seaorm_module_name` and `to_screaming_snake_case`. A backend that renames an identifier MUST also emit the original database name (`@map`, `column_name`, SQLAlchemy's positional column name). @@ -67,12 +71,28 @@ SQLAlchemy's positional column name). ### Prisma (schema.prisma) - Emits models only — no `datasource`/`generator` block, so the output drops into an existing schema -- Backend-neutral: provider-specific `@db.*` mapping is derived from `DatabaseBackend` +- Backend-neutral: no provider-specific `@db.*` native attributes are emitted - `render_schema` is a Prisma-only single-file entry point that deduplicates enums globally, so its snapshot tests live inline in the module (still writing into `src/tests/snapshots/`) - Renamed identifiers carry `@map` / `@@map`; enum members go through `to_screaming_snake_case` + `sanitize_identifier(IdentifierStart::Letter)` +### Drizzle (TypeScript) +- No backend-neutral form exists (`pgTable`/`mysqlTable`/`sqliteTable` fork at the + `import` line), so one export writes one file per dialect: + `models.pg.ts` / `models.mysql.ts` / `models.sqlite.ts` (`DrizzleDialect::ALL`) +- Constraint names go through the same `vespertide-naming` builders as the SQL + layer (`build_unique_constraint_name` / `build_index_name` / + `build_foreign_key_name`), so `drizzle-kit` sees the indexes vespertide created; + SQLite FKs stay unnamed (SQLite stores no FK constraint names) +- FKs use the named `foreignKey({...})` operator in an array-form table callback; + a self-referential key takes its foreign columns from the callback's `t`, which + keeps the table const out of its own initializer's type inference +- The only backend that renders `TableConstraint::Check`, via its `check` builder with a `sql` template +- The `OrmExporter` trait path renders the Pg dialect (single-`String` trait); + `render_schema(tables, dialect)` is the dialect-aware single-file entry point, + so its snapshot tests live inline in the module (Prisma-exception pattern) + ## TESTING ```bash @@ -86,7 +106,7 @@ cargo insta accept - Snapshot testing with `insta` crate (YAML format) - `rstest` for parameterized tests across all ORM backends -- 339 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all five) — a scenario snapshotted for only one ORM is a defect +- 415 snapshot files, all in the single shared `src/tests/snapshots/` directory; every export scenario goes through the shared `orm_cases!` macro in `src/tests/mod.rs`, producing one snapshot per ORM (all six) — a scenario snapshotted for only one ORM is a defect ## NOTES