From c44aec7cf30e0679deed12188bbba39b1f6104b9 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 26 Jul 2026 12:36:43 +0700 Subject: [PATCH] #237 fix: give auto temporal columns a database default in migrations --- README.md | 2 +- .../src/entity/migrations/postgres/ddl.rs | 96 ++++++++++++++++++- crates/entity-derive/tests/postgres.rs | 5 +- wiki/Atributos.md | 5 + wiki/Attributes-en.md | 5 + ...20\270\320\261\321\203\321\202\321\213.md" | 5 + "wiki/\345\261\236\346\200\247.md" | 4 + "wiki/\354\206\215\354\204\261.md" | 5 + 8 files changed, 121 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 609f308..0f14a0b 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ tracing-subscriber = "0.3" ```rust,ignore #[id] // Primary key (auto-generated UUID) -#[auto] // Auto-generated (timestamps) +#[auto] // Auto-generated: skipped by INSERT, gets a DB default in migrations #[owner] // Ownership column: adds *_scoped methods #[version] // Optimistic locking: guarded, auto-bumped #[embed(prefix, fields(...))] // Flatten a value object to prefixed columns diff --git a/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs b/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs index 1c66a24..8c882e2 100644 --- a/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs +++ b/crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs @@ -8,7 +8,7 @@ use convert_case::{Case, Casing}; use crate::entity::{ - migrations::types::{PostgresTypeMapper, TypeMapper}, + migrations::types::{PostgresTypeMapper, SqlType, TypeMapper}, parse::{CompositeIndexDef, EntityDef, FieldDef} }; @@ -96,9 +96,13 @@ fn generate_column_def( parts.push("UNIQUE".to_string()); } - // DEFAULT value + // DEFAULT value: explicit declaration wins, otherwise an #[auto] + // temporal column gets the default that makes the generated INSERT + // (which skips auto columns) valid. if let Some(ref default) = field.column().default { parts.push(format!("DEFAULT {default}")); + } else if let Some(default) = implicit_auto_default(field, &sql_type) { + parts.push(format!("DEFAULT {default}")); } // CHECK constraint @@ -124,6 +128,26 @@ fn generate_column_def( parts.join(" ") } +/// Database-side default for an `#[auto]` column that carries no +/// explicit `#[column(default = ...)]`. +/// +/// The generated INSERT skips `#[auto]` columns, so a `NOT NULL` one +/// without a default rejects every row. Temporal columns get the clock +/// function matching their type; anything else keeps no default, since +/// the macro has no meaningful value to invent. +fn implicit_auto_default(field: &FieldDef, sql_type: &SqlType) -> Option<&'static str> { + if !field.is_auto() || sql_type.nullable || sql_type.array_dim > 0 { + return None; + } + + match sql_type.name.as_str() { + "TIMESTAMPTZ" | "TIMESTAMP" => Some("NOW()"), + "DATE" => Some("CURRENT_DATE"), + "TIME" | "TIMETZ" => Some("CURRENT_TIME"), + _ => None + } +} + /// Generate CREATE INDEX for a single column. fn generate_single_index(entity: &EntityDef, field: &FieldDef) -> String { let table = entity.table.clone(); @@ -282,6 +306,74 @@ mod tests { assert!(sql.contains("email TEXT NOT NULL UNIQUE")); } + #[test] + fn auto_temporal_columns_get_a_clock_default() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users", migrations)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(response)] + #[auto] + pub created_at: chrono::DateTime, + #[field(response)] + #[auto] + pub born_on: chrono::NaiveDate, + #[field(response)] + #[auto] + pub rings_at: chrono::NaiveTime, + } + }); + let sql = generate_up(&entity); + assert!( + sql.contains("created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"), + "the generated INSERT skips auto columns, so the DDL must supply the value: {sql}" + ); + assert!(sql.contains("born_on DATE NOT NULL DEFAULT CURRENT_DATE")); + assert!(sql.contains("rings_at TIME NOT NULL DEFAULT CURRENT_TIME")); + } + + #[test] + fn explicit_default_wins_over_the_implicit_one() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users", migrations)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(response)] + #[auto] + #[column(default = "'epoch'")] + pub created_at: chrono::DateTime, + } + }); + let sql = generate_up(&entity); + assert!(sql.contains("created_at TIMESTAMPTZ NOT NULL DEFAULT 'epoch'")); + assert!(!sql.contains("NOW()")); + } + + #[test] + fn non_temporal_and_nullable_auto_columns_keep_no_default() { + let entity = parse_entity(quote::quote! { + #[entity(table = "users", migrations)] + pub struct User { + #[id] + pub id: uuid::Uuid, + #[field(response)] + #[auto] + pub token: String, + #[field(response)] + #[auto] + pub seen_at: Option>, + } + }); + let sql = generate_up(&entity); + assert!(sql.contains("token TEXT NOT NULL,")); + assert!( + !sql.contains("seen_at TIMESTAMPTZ DEFAULT"), + "a nullable auto column already accepts the absent value: {sql}" + ); + } + #[test] fn generate_up_with_default() { let entity = parse_entity(quote::quote! { diff --git a/crates/entity-derive/tests/postgres.rs b/crates/entity-derive/tests/postgres.rs index ab7f845..9b70d91 100644 --- a/crates/entity-derive/tests/postgres.rs +++ b/crates/entity-derive/tests/postgres.rs @@ -51,11 +51,10 @@ mod articles { #[filter(range)] pub views: i64, - /// Populated by the database default: the generated INSERT skips - /// `#[auto]` columns, so the DDL has to supply the value. + /// Populated by the database: the generated INSERT skips + /// `#[auto]` columns, so the DDL supplies the value. #[field(response)] #[auto] - #[column(default = "NOW()")] pub created_at: DateTime } diff --git a/wiki/Atributos.md b/wiki/Atributos.md index aaecb77..9677268 100644 --- a/wiki/Atributos.md +++ b/wiki/Atributos.md @@ -340,6 +340,11 @@ Marca campos auto-generados (timestamps, secuencias). - Obtiene `Default::default()` en `From` - Excluido de `CreateRequest` y `UpdateRequest` - Puede incluirse en `Response` con `#[field(response)]` +- Excluido del `INSERT` generado, por lo que con `migrations` una + columna temporal no nulable recibe el valor por defecto correspondiente + en la base de datos: `NOW()` para `TIMESTAMPTZ` y `TIMESTAMP`, + `CURRENT_DATE` para `DATE`, `CURRENT_TIME` para `TIME`. Un + `#[column(default = "...")]` explícito tiene prioridad ```rust #[auto] diff --git a/wiki/Attributes-en.md b/wiki/Attributes-en.md index 91398f2..5ed5bc0 100644 --- a/wiki/Attributes-en.md +++ b/wiki/Attributes-en.md @@ -399,6 +399,11 @@ Marks auto-generated fields (timestamps, sequences). - Gets `Default::default()` in `From` - Excluded from `CreateRequest` and `UpdateRequest` - Can be included in `Response` with `#[field(response)]` +- Excluded from the generated `INSERT`, so with `migrations` a + non-nullable temporal column receives a matching database default: + `NOW()` for `TIMESTAMPTZ` and `TIMESTAMP`, `CURRENT_DATE` for `DATE`, + `CURRENT_TIME` for `TIME`. An explicit `#[column(default = "...")]` + overrides it ```rust #[auto] diff --git "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" index c1be657..b08bc79 100644 --- "a/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" +++ "b/wiki/\320\220\321\202\321\200\320\270\320\261\321\203\321\202\321\213.md" @@ -399,6 +399,11 @@ pub id: Uuid, - Получает `Default::default()` в `From` - Исключается из `CreateRequest` и `UpdateRequest` - Может включаться в `Response` с `#[field(response)]` +- Исключается из генерируемого `INSERT`, поэтому с `migrations` + non-nullable колонка с временным типом получает соответствующий + default в БД: `NOW()` для `TIMESTAMPTZ` и `TIMESTAMP`, `CURRENT_DATE` + для `DATE`, `CURRENT_TIME` для `TIME`. Явный + `#[column(default = "...")]` имеет приоритет ```rust #[auto] diff --git "a/wiki/\345\261\236\346\200\247.md" "b/wiki/\345\261\236\346\200\247.md" index 2d675c0..990946d 100644 --- "a/wiki/\345\261\236\346\200\247.md" +++ "b/wiki/\345\261\236\346\200\247.md" @@ -340,6 +340,10 @@ pub id: Uuid, - 在 `From` 中获取 `Default::default()` - 从 `CreateRequest` 和 `UpdateRequest` 中排除 - 可通过 `#[field(response)]` 包含在 `Response` 中 +- 从生成的 `INSERT` 中排除,因此启用 `migrations` 时,非空的时间类型列 + 会获得相应的数据库默认值:`TIMESTAMPTZ` 和 `TIMESTAMP` 为 `NOW()`, + `DATE` 为 `CURRENT_DATE`,`TIME` 为 `CURRENT_TIME`。显式的 + `#[column(default = "...")]` 优先 ```rust #[auto] diff --git "a/wiki/\354\206\215\354\204\261.md" "b/wiki/\354\206\215\354\204\261.md" index 404290b..ab00976 100644 --- "a/wiki/\354\206\215\354\204\261.md" +++ "b/wiki/\354\206\215\354\204\261.md" @@ -340,6 +340,11 @@ pub id: Uuid, - `From`에서 `Default::default()` 획득 - `CreateRequest`와 `UpdateRequest`에서 제외 - `#[field(response)]`로 `Response`에 포함 가능 +- 생성된 `INSERT`에서 제외되므로 `migrations` 사용 시 NULL을 허용하지 + 않는 시간 계열 컬럼은 해당하는 데이터베이스 기본값을 받습니다: + `TIMESTAMPTZ`와 `TIMESTAMP`는 `NOW()`, `DATE`는 `CURRENT_DATE`, + `TIME`은 `CURRENT_TIME`. 명시적인 `#[column(default = "...")]`이 + 우선합니다 ```rust #[auto]