Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 94 additions & 2 deletions crates/entity-derive-impl/src/entity/migrations/postgres/ddl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}
};

Expand Down Expand Up @@ -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
Expand All @@ -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();
Expand Down Expand Up @@ -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<chrono::Utc>,
#[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<chrono::Utc>,
}
});
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<chrono::DateTime<chrono::Utc>>,
}
});
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! {
Expand Down
5 changes: 2 additions & 3 deletions crates/entity-derive/tests/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>
}

Expand Down
5 changes: 5 additions & 0 deletions wiki/Atributos.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,11 @@ Marca campos auto-generados (timestamps, secuencias).
- Obtiene `Default::default()` en `From<CreateRequest>`
- 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]
Expand Down
5 changes: 5 additions & 0 deletions wiki/Attributes-en.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ Marks auto-generated fields (timestamps, sequences).
- Gets `Default::default()` in `From<CreateRequest>`
- 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]
Expand Down
5 changes: 5 additions & 0 deletions wiki/Атрибуты.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ pub id: Uuid,
- Получает `Default::default()` в `From<CreateRequest>`
- Исключается из `CreateRequest` и `UpdateRequest`
- Может включаться в `Response` с `#[field(response)]`
- Исключается из генерируемого `INSERT`, поэтому с `migrations`
non-nullable колонка с временным типом получает соответствующий
default в БД: `NOW()` для `TIMESTAMPTZ` и `TIMESTAMP`, `CURRENT_DATE`
для `DATE`, `CURRENT_TIME` для `TIME`. Явный
`#[column(default = "...")]` имеет приоритет

```rust
#[auto]
Expand Down
4 changes: 4 additions & 0 deletions wiki/属性.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@ pub id: Uuid,
- 在 `From<CreateRequest>` 中获取 `Default::default()`
- 从 `CreateRequest` 和 `UpdateRequest` 中排除
- 可通过 `#[field(response)]` 包含在 `Response` 中
- 从生成的 `INSERT` 中排除,因此启用 `migrations` 时,非空的时间类型列
会获得相应的数据库默认值:`TIMESTAMPTZ` 和 `TIMESTAMP` 为 `NOW()`,
`DATE` 为 `CURRENT_DATE`,`TIME` 为 `CURRENT_TIME`。显式的
`#[column(default = "...")]` 优先

```rust
#[auto]
Expand Down
5 changes: 5 additions & 0 deletions wiki/속성.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,11 @@ pub id: Uuid,
- `From<CreateRequest>`에서 `Default::default()` 획득
- `CreateRequest`와 `UpdateRequest`에서 제외
- `#[field(response)]`로 `Response`에 포함 가능
- 생성된 `INSERT`에서 제외되므로 `migrations` 사용 시 NULL을 허용하지
않는 시간 계열 컬럼은 해당하는 데이터베이스 기본값을 받습니다:
`TIMESTAMPTZ`와 `TIMESTAMP`는 `NOW()`, `DATE`는 `CURRENT_DATE`,
`TIME`은 `CURRENT_TIME`. 명시적인 `#[column(default = "...")]`이
우선합니다

```rust
#[auto]
Expand Down