diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1dbb4f5..15dff49c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,10 @@ jobs: - run: cargo test -p graphql-orm --no-default-features --features sqlite,field-case-pascal,argument-case-snake --test federation_entity_keys_case_features - run: cargo check -p graphql-orm --no-default-features --features postgres - run: cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres + - name: Execute host timestamps on owned PostgreSQL + run: cargo test -p graphql-orm --locked --no-default-features --features postgres --test host_timestamps -- --ignored --test-threads=1 + - name: Execute the private PostgreSQL host timestamp consumer + run: cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test host_timestamps -- --ignored --test-threads=1 - run: cargo check -p graphql-orm --no-default-features --features mssql - run: cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql - run: cargo clippy -p graphql-orm --all-targets -- -D warnings diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85b97b5c..10ddbdca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,10 +156,10 @@ jobs: cargo check -p graphql-orm --no-default-features --features postgres --locked cargo test -p graphql-orm --no-default-features --features postgres --test federation_entity_keys_postgres --locked cargo test -p graphql-orm --no-default-features --features postgres --test projection_visibility --test cross_crate_relations_fixture --test repository_counts --locked - cargo test -p graphql-orm --no-default-features --features postgres --test grouped_aggregates_postgres --test portable_group_pages --locked -- --ignored --test-threads=1 + cargo test -p graphql-orm --no-default-features --features postgres --test grouped_aggregates_postgres --test portable_group_pages --test host_timestamps --locked -- --ignored --test-threads=1 cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example policy_projections cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example complete_events - cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test portable_groups -- --ignored --test-threads=1 + cargo test --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features postgres --test portable_groups --test host_timestamps -- --ignored --test-threads=1 cargo check -p graphql-orm --no-default-features --features mssql --locked cargo test -p graphql-orm --no-default-features --features mssql --test federation_entity_keys_mssql --locked cargo clippy -p graphql-orm --all-targets --locked -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index b389bae3..cf7dd72b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,24 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. -## 0.38.0 - Unreleased +## 0.39.0 - unreleased + +Companion macros crate: `graphql-orm-macros` **0.39.0**. + +- Add repository-only per-field `#[graphql_orm(timestamp = "host")]` for ordinary + signed Integer timestamp persistence, including Rust/physical aliases, nullable + inputs, existing conditional/bounded writes and upsert conflict updates. +- Compare the known PostgreSQL-deparsed form of existing ORM epoch-second + defaults without false column alterations; preserve their values, units and + runtime Integer/DateTime distinctions. +- Preserve legacy timestamp generation, GraphQL SDL, physical metadata/defaults, + explicit UUID keys and normal authorization/hooks. Fix generated repository + upsert field-policy calls to use the repository authorization surface. +- Add owned SQLite/PostgreSQL exact-value, migration no-op, policy, rollback and + whole-transaction cancellation regressions plus standalone SQL-free consumers. + No unfinished-operation poisoning or runtime A–D capability is introduced. + +## 0.38.0 - 2026-10-03 Companion macros crate: `graphql-orm-macros` **0.38.0**. diff --git a/Cargo.lock b/Cargo.lock index a48dbb8a..7e7f894f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 5c7aa5ff..bc445f99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.38.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.39.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } diff --git a/MIGRATION.md b/MIGRATION.md index c10eced7..575f1b6c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,33 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## Repository host timestamps (0.39.0) + +Adopt aligned ORM/macros 0.39.0. Existing declarations require no edits and keep +legacy seconds-based timestamp writes and GraphQL SDL. Add per-field +`#[graphql_orm(timestamp = "host")]` only to `RepositoryEntity` Integer timestamp +fields whose Rust/physical name is `created_at` or `updated_at`. Writable non-null +create inputs now require that annotated field; update inputs use ordinary +omission/null semantics. Explicit UUID keys and `auto_generated = false` remain +unchanged. Host-managed creation timestamps are writable, including on upsert +conflict: enforce immutability through existing restrictions/policies when needed. + +PostgreSQL's parentheses/capitalization when deparsing the known ORM epoch-second +default now compare equivalent, avoiding false column alterations. The expression, +storage units and runtime DateTime rules remain unchanged. Regenerate unapplied +migration plans with this revision so their guarded hashes use the same default +comparison. + +The annotation alone changes no physical metadata/default or stored value and +replans to no-op. Database defaults remain available to other writers. Removing +defaults is a separate migration. Runtime Integer/DateTime distinctions are not +changed. Propagate transaction/hook/journal errors or cancel the whole transaction; +catching an inner timeout then returning `Ok` does not guarantee rollback. + +See [repository timestamp behavior, examples and backend tests](docs/reference/graphql-orm/repository-only-entities.md#host-managed-integer-timestamps). +This repository capability is independent of the runtime migration/mutation and +dynamic GraphQL contracts; it does not implement those proposed APIs. + ## Portable group pages and SQL-visible aggregates (0.38.0) Adopt aligned ORM/macros 0.38.0. No physical schema, data, GraphQL SDL, existing diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index a2dbb08c..a5a1fcaf 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index afa22716..2f64e77c 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.38.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.38.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -175,4 +175,7 @@ aligns with the core policy-aware projection fix. Existing generated aggregate enums/builders and ordinary GraphQL aggregate SDL remain unchanged; repository-only consumers still need no direct async-graphql dependency. +Repository-only Integer timestamps can use per-field `#[graphql_orm(timestamp = "host")]`. +See [host timestamp inputs, migration compatibility and cancellation limits](../../docs/reference/graphql-orm/repository-only-entities.md#host-managed-integer-timestamps). + Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index e4efbdc9..648cdfeb 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index 29ca299c..61f9abac 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -1269,6 +1269,7 @@ pub(crate) struct FieldMetadata { pub(crate) transform_read: Option, pub(crate) default: Option, pub(crate) suppress_implicit_default: bool, + pub(crate) host_managed_timestamp: bool, pub(crate) auto_generated: Option, pub(crate) backup_policy: Option, /// If true, include in Create/Update inputs even if #[graphql(skip)] is set. @@ -1434,6 +1435,7 @@ impl Default for FieldMetadata { transform_read: None, default: None, suppress_implicit_default: false, + host_managed_timestamp: false, auto_generated: None, backup_policy: None, input_only: false, @@ -2027,6 +2029,15 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result )); } } + } else if nested.path.is_ident("timestamp") { + if meta.host_managed_timestamp { + return Err(nested.error("timestamp may only be declared once per field")); + } + let value: syn::LitStr = nested.value()?.parse()?; + if value.value() != "host" { + return Err(syn::Error::new(value.span(), "timestamp must be \"host\"")); + } + meta.host_managed_timestamp = true; } else if nested.path.is_ident("auto_generated") { let value = nested.value()?; let lit: syn::LitBool = value.parse()?; @@ -2419,6 +2430,92 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result Ok(meta) } +/// Keep legacy Rust-name input exclusion distinct from physical-column updates. +/// Host management disables both; it never changes column/default metadata. +#[derive(Clone, Copy)] +pub(crate) struct TimestampBehavior { + pub(crate) exclude_input: bool, + pub(crate) update_updated_at: bool, +} + +impl FieldMetadata { + pub(crate) fn timestamp_behavior(&self, rust_name: &str) -> TimestampBehavior { + TimestampBehavior { + exclude_input: !self.host_managed_timestamp + && matches!(rust_name, "created_at" | "updated_at"), + update_updated_at: !self.host_managed_timestamp + && !self.is_relation + && !self.skip_db + && self.db_column.as_deref().unwrap_or(rust_name) == "updated_at", + } + } +} + +fn validate_host_timestamp( + field: &Field, + repository: bool, + inferred_primary_key: bool, +) -> syn::Result<()> { + let meta = parse_field_metadata(field)?; + if !meta.host_managed_timestamp { + return Ok(()); + } + let invalid = |message| syn::Error::new_spanned(field, message); + if !repository { + return Err(invalid("timestamp = \"host\" requires RepositoryEntity")); + } + let rust_name = field + .ident + .as_ref() + .expect("named fields validated") + .to_string(); + let column = meta.db_column.as_deref().unwrap_or(&rust_name); + if !matches!(rust_name.as_str(), "created_at" | "updated_at") + && !matches!(column, "created_at" | "updated_at") + { + return Err(invalid( + "host timestamp requires a created_at or updated_at Rust field or physical column", + )); + } + let ty = option_inner_type(&field.ty).unwrap_or(&field.ty); + if !type_path_last_ident(ty).is_some_and(|ident| ident == "i64") + || meta.is_date_field + || meta.is_boolean_field + || meta.is_json_field + { + return Err(invalid( + "host timestamp requires an ordinary i64 or Option Integer field", + )); + } + if meta.is_relation + || meta.skip_db + || meta.is_primary_key + || inferred_primary_key + || meta.is_version + || meta.auto_generated == Some(true) + { + return Err(invalid( + "host timestamp cannot be a relation, skipped database field, primary key, version, or generated field", + )); + } + Ok(()) +} + +pub(crate) fn validate_host_timestamp_fields( + fields: &syn::punctuated::Punctuated, + repository: bool, +) -> syn::Result<()> { + let explicit_primary_key = fields + .iter() + .any(|field| parse_field_metadata(field).is_ok_and(|meta| meta.is_primary_key)); + for field in fields { + let inferred_primary_key = + !explicit_primary_key && field.ident.as_ref().is_some_and(|ident| ident == "id"); + validate_host_timestamp(field, repository, inferred_primary_key)?; + } + Ok(()) +} + pub(crate) fn collect_parsed_fields<'a>( fields: impl IntoIterator, ) -> syn::Result> { @@ -2820,6 +2917,10 @@ fn generate_entity_impl( let entity_name_lit = struct_name.to_string(); let schema_only = schema_only_override || entity_meta.schema_only; + validate_host_timestamp_fields( + fields, + has_repository_entity_attribute(&input.attrs) && !schema_only, + )?; if schema_only && !entity_meta.projections.is_empty() { return Err(syn::Error::new_spanned( input, @@ -4158,6 +4259,7 @@ fn generate_entity_impl( if field_meta.suppress_implicit_default && rust_name != "created_at" && rust_name != "updated_at" + && !field_meta.host_managed_timestamp { return Err(syn::Error::new( field.span(), @@ -6916,3 +7018,69 @@ fn decimal_default_sql( BackendKind::Postgres | BackendKind::Mssql => Ok(normalized.to_string()), } } + +#[cfg(test)] +mod host_timestamp_tests { + use super::*; + use syn::parse_quote; + + #[test] + fn classification_preserves_distinct_legacy_name_rules() { + let rust_timestamp: Field = parse_quote!(created_at: i64); + let physical_timestamp: Field = + parse_quote!(#[graphql_orm(db_column = "updated_at")] modified: i64); + let meta = parse_field_metadata(&rust_timestamp).unwrap(); + assert!(meta.timestamp_behavior("created_at").exclude_input); + assert!(!meta.timestamp_behavior("created_at").update_updated_at); + let meta = parse_field_metadata(&physical_timestamp).unwrap(); + assert!(!meta.timestamp_behavior("modified").exclude_input); + assert!(meta.timestamp_behavior("modified").update_updated_at); + let host: Field = parse_quote!(#[graphql_orm(timestamp = "host", db_column = "updated_at")] created_at: i64); + let meta = parse_field_metadata(&host).unwrap(); + assert!(!meta.timestamp_behavior("created_at").exclude_input); + assert!(!meta.timestamp_behavior("created_at").update_updated_at); + } + + #[test] + fn unsupported_modes_and_duplicate_annotations_fail() { + for field in [ + parse_quote!(#[graphql_orm(timestamp = "automatic")] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host", timestamp = "host")] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host")] #[graphql_orm(timestamp = "host")] created_at: i64), + ] { + assert!(parse_field_metadata(&field).is_err()); + } + } + + #[test] + fn invalid_declarations_fail_before_emission() { + let invalid: Vec = vec![ + parse_quote!(#[graphql_orm(timestamp = "host")] unrelated: i64), + parse_quote!(#[graphql_orm(timestamp = "host")] created_at: String), + parse_quote!(#[graphql_orm(timestamp = "host")] created_at: Option>), + parse_quote!(#[graphql_orm(timestamp = "host")] #[date_field] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host")] #[skip_db] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host")] #[primary_key] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host", version)] updated_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host", auto_generated = true)] created_at: i64), + parse_quote!(#[graphql_orm(timestamp = "host")] #[relation(target = "Other", from = "id", to = "id")] created_at: Option), + ]; + for field in invalid { + assert!(validate_host_timestamp(&field, true, false).is_err()); + } + let valid: Field = parse_quote!(#[graphql_orm(timestamp = "host", db_column = "updated_at", default = false, auto_generated = false)] modified: Option); + assert!(validate_host_timestamp(&valid, true, false).is_ok()); + assert!(validate_host_timestamp(&valid, false, false).is_err()); + let restricted: Field = + parse_quote!(#[graphql_orm(timestamp = "host", write = false)] created_at: i64); + assert!(validate_host_timestamp(&restricted, true, false).is_ok()); + let inferred: Field = parse_quote!(#[graphql_orm(timestamp = "host", db_column = "updated_at", auto_generated = false)] id: i64); + assert!(validate_host_timestamp(&inferred, true, true).is_err()); + assert!(validate_host_timestamp(&inferred, true, false).is_ok()); + let fields = syn::punctuated::Punctuated::from_iter([inferred.clone()]); + assert!(validate_host_timestamp_fields(&fields, true).is_err()); + let explicit: Field = parse_quote!(#[primary_key] key: String); + let fields = syn::punctuated::Punctuated::from_iter([explicit, inferred]); + assert!(validate_host_timestamp_fields(&fields, true).is_ok()); + } +} diff --git a/crates/graphql-orm-macros/src/lib.rs b/crates/graphql-orm-macros/src/lib.rs index 9632d8ba..8dcd9dbb 100644 --- a/crates/graphql-orm-macros/src/lib.rs +++ b/crates/graphql-orm-macros/src/lib.rs @@ -380,6 +380,13 @@ pub fn derive_graphql_entity(input: TokenStream) -> TokenStream { ) )] /// Derive managed-schema and typed repository APIs without any GraphQL type surface. +/// +/// Persisted conventional Integer timestamp fields may opt into ordinary typed +/// writes with `#[graphql_orm(timestamp = "host")]`. This accepts `i64` or +/// `Option` where the Rust or physical column name is `created_at` or +/// `updated_at`, disables automatic timestamp writes per field, and preserves +/// column/default metadata. Normal field restrictions, policies and hooks apply. +/// This mode is unavailable to GraphQL and schema-only derives. pub fn derive_repository_entity(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let result = (|| { diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index f2622a7d..6f9094e7 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -721,7 +721,7 @@ struct PrimaryKeyField { } fn create_input_visibility(meta: &FieldMetadata, rust_name: &str, auto_generated_pk: bool) -> bool { - let is_timestamp = rust_name == "created_at" || rust_name == "updated_at"; + let is_timestamp = meta.timestamp_behavior(rust_name).exclude_input; let include_in_create = (!meta.is_primary_key || !auto_generated_pk) && !is_timestamp && meta.write; include_in_create && (!meta.skip_input || meta.input_only) @@ -1139,7 +1139,7 @@ fn generated_resolver_schema_signature( graphql_field_name(&meta, &rust_name, graphql_rename_fields, serde_rename_all); let field_type = &field.ty; let rust_type = quote! { #field_type }.to_string(); - let is_timestamp = rust_name == "created_at" || rust_name == "updated_at"; + let is_timestamp = meta.timestamp_behavior(&rust_name).exclude_input; let create_visible = !meta.is_relation && !meta.skip_db && (!meta.is_primary_key || !auto_generated_pk) @@ -1355,6 +1355,10 @@ pub(crate) fn generate_graphql_operations( )); } }; + crate::entity::validate_host_timestamp_fields( + fields, + crate::entity::has_repository_entity_attribute(&input.attrs), + )?; let graphql_rename_fields = entity_meta.graphql_rename_fields.as_deref(); let serde_rename_all = entity_meta.serde_rename_all.as_deref(); @@ -2173,7 +2177,7 @@ pub(crate) fn generate_graphql_operations( // Skip auto-generated primary key, timestamps, and skip_input fields (e.g. password_hash) // But #[input_only] overrides skip_input (allows write-only fields like encrypted credentials) - let is_timestamp = rust_name == "created_at" || rust_name == "updated_at"; + let is_timestamp = meta.timestamp_behavior(&rust_name).exclude_input; let include_in_create = (!meta.is_primary_key || !auto_generated_pk) && !is_timestamp && meta.write; let include_generated_default_in_create = (!meta.is_primary_key || !auto_generated_pk) @@ -2337,36 +2341,36 @@ pub(crate) fn generate_graphql_operations( let repo_check = if upsert_update_candidate { quote! { if let Some(current_entity) = current_entity.as_ref() { - db.ensure_writable_field( + db.ensure_repository_writable_field( None, #entity_name_lit, #graphql_name, Some(#policy_key), Some(current_entity as &(dyn ::std::any::Any + Send + Sync)), Some(&input.#field_name as &(dyn ::std::any::Any + Send + Sync)), - ).await.map_err(|e| Self::__gom_runtime_error(format!("{e:?}")))?; + ).await.map_err(::graphql_orm::graphql::errors::sqlx_error_from_public)?; } else { - db.ensure_writable_field( + db.ensure_repository_writable_field( None, #entity_name_lit, #graphql_name, Some(#policy_key), None, Some(&input.#field_name as &(dyn ::std::any::Any + Send + Sync)), - ).await.map_err(|e| Self::__gom_runtime_error(format!("{e:?}")))?; + ).await.map_err(::graphql_orm::graphql::errors::sqlx_error_from_public)?; } } } else { quote! { if current_entity.is_none() { - db.ensure_writable_field( + db.ensure_repository_writable_field( None, #entity_name_lit, #graphql_name, Some(#policy_key), None, Some(&input.#field_name as &(dyn ::std::any::Any + Send + Sync)), - ).await.map_err(|e| Self::__gom_runtime_error(format!("{e:?}")))?; + ).await.map_err(::graphql_orm::graphql::errors::sqlx_error_from_public)?; } } }; @@ -2637,7 +2641,7 @@ pub(crate) fn generate_graphql_operations( // For update: wrap in Option to make all fields optional (skip PK, timestamps, skip_input) // But #[input_only] overrides skip_input (allows write-only fields like encrypted credentials) - let is_timestamp = rust_name == "created_at" || rust_name == "updated_at"; + let is_timestamp = meta.timestamp_behavior(&rust_name).exclude_input; if !meta.is_primary_key && !is_timestamp && meta.write { // All update fields are wrapped in Option (even if already optional) // This allows distinguishing between "not provided" and "set to null" @@ -3110,16 +3114,13 @@ pub(crate) fn generate_graphql_operations( } } - let has_updated_at_column = fields.iter().any(|f| { - parse_field_metadata(f) - .ok() - .filter(|m| !m.is_relation && !m.skip_db) - .and_then(|m| { - f.ident - .as_ref() - .map(|ident| m.db_column.unwrap_or_else(|| ident.to_string())) + let has_updated_at_column = fields.iter().any(|field| { + field.ident.as_ref().is_some_and(|ident| { + parse_field_metadata(field).is_ok_and(|meta| { + meta.timestamp_behavior(&ident.to_string()) + .update_updated_at }) - .is_some_and(|col| col == "updated_at") + }) }); if upsert_config.is_some() { diff --git a/crates/graphql-orm-macros/src/repository.rs b/crates/graphql-orm-macros/src/repository.rs index 94063d26..8ec15308 100644 --- a/crates/graphql-orm-macros/src/repository.rs +++ b/crates/graphql-orm-macros/src/repository.rs @@ -268,7 +268,7 @@ fn repository_write_authorizers( .as_deref() .map(|policy| quote! { Some(#policy) }) .unwrap_or_else(|| quote! { None }); - let timestamp = rust_name == "created_at" || rust_name == "updated_at"; + let timestamp = field_meta.timestamp_behavior(&rust_name).exclude_input; let include_create = (!field_meta.is_primary_key || !auto_generated_pk) && !timestamp && field_meta.write; if include_create { diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 6d00629e..9c2359f3 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -68,7 +68,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.38.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.39.0", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 7f37bac0..da91c71c 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.38.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.39.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -193,4 +193,7 @@ existing aggregate builder. See [typed aggregates](../../docs/reference/graphql- and the SQL-free private consumer example for native distinct identities, current SQL visibility and typed continuation. Existing aggregate SDL/cursors are unchanged. +Repository-only Integer timestamps can use per-field `#[graphql_orm(timestamp = "host")]`. +See [host timestamp inputs, migration compatibility and cancellation limits](../../docs/reference/graphql-orm/repository-only-entities.md#host-managed-integer-timestamps). + Complete SQLite/PostgreSQL text-group pages and SQL-visible ordinary aggregates: see [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md). diff --git a/crates/graphql-orm/src/graphql/orm/dialect.rs b/crates/graphql-orm/src/graphql/orm/dialect.rs index 2201f2a8..452b6ab4 100644 --- a/crates/graphql-orm/src/graphql/orm/dialect.rs +++ b/crates/graphql-orm/src/graphql/orm/dialect.rs @@ -45,9 +45,10 @@ impl DatabaseBackend { /// - trim surrounding whitespace /// - strip balanced outer parentheses that wrap the entire expression /// - normalize SQL keyword defaults (`CURRENT_TIMESTAMP`, `NULL`, booleans) -/// - leave string/blob literals and non-keyword identifiers untouched +/// - recognize PostgreSQL's deparsed form of the ORM's known epoch-second default +/// - leave string/blob literals and other non-keyword identifiers untouched /// -/// Does **not** rewrite operators, function names, or argument order, so +/// Apart from that exact known default, does **not** rewrite operators, function names, or argument order, so /// genuinely different expressions remain distinct. pub fn canonicalize_column_default_expression(default: &str) -> String { let mut value = default.trim().to_string(); @@ -55,6 +56,16 @@ pub fn canonicalize_column_default_expression(default: &str) -> String { value = value[1..value.len() - 1].trim().to_string(); } + // PostgreSQL deparses the ORM's default with lowercase function/epoch names + // and parentheses around EXTRACT before its bigint cast. Whitelist only these + // exact equivalent forms; do not interpret arbitrary SQL or change units. + if matches!( + value.to_ascii_lowercase().as_str(), + "extract(epoch from now())::bigint" | "(extract(epoch from now()))::bigint" + ) { + return "EXTRACT(EPOCH FROM NOW())::bigint".to_string(); + } + let uppercase = value.to_ascii_uppercase(); match uppercase.as_str() { "CURRENT_TIMESTAMP" | "CURRENT_DATE" | "CURRENT_TIME" | "NULL" => uppercase, diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index ae344fa2..5d8b00e2 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index 72790b98..a80141d6 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index e77ae08d..1c404321 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 7d76a2c5..d584b978 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.38.0" +version = "0.39.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.38.0" +version = "0.39.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/host_timestamps.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/host_timestamps.rs new file mode 100644 index 00000000..4d539ea6 --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/host_timestamps.rs @@ -0,0 +1,167 @@ +//! Framework-neutral host timestamp inputs, including a physical updated_at alias. +use graphql_orm::prelude::*; +use repository_aggregate_consumer::Backend; + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "host_clock_example", + plural = "HostClockExamples", + upsert = "id" + ) +)] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + repository_entity( + backend = "postgres", + table = "host_clock_example", + plural = "HostClockExamples", + upsert = "id" + ) +)] +#[cfg_attr( + all(feature = "mssql", not(any(feature = "sqlite", feature = "postgres"))), + repository_entity( + backend = "mssql", + table = "host_clock_example", + plural = "HostClockExamples", + schema_policy = "external_writable", + upsert = "id" + ) +)] +pub struct HostClockExample { + #[primary_key] + #[graphql_orm(auto_generated = false)] + pub id: graphql_orm::uuid::Uuid, + pub label: String, + #[graphql_orm(timestamp = "host", default = "0")] + pub created_at: i64, + #[graphql_orm(timestamp = "host", db_column = "updated_at", default = "0")] + pub modified_ms: i64, +} + +// Ordinary write=false restrictions are respected; host mode grants no authority. +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "sqlite", + repository_entity( + backend = "sqlite", + table = "restricted_host_clock", + plural = "RestrictedHostClocks" + ) +)] +#[cfg_attr( + all(feature = "postgres", not(feature = "sqlite")), + repository_entity( + backend = "postgres", + table = "restricted_host_clock", + plural = "RestrictedHostClocks" + ) +)] +#[cfg_attr( + all(feature = "mssql", not(any(feature = "sqlite", feature = "postgres"))), + repository_entity( + backend = "mssql", + table = "restricted_host_clock", + plural = "RestrictedHostClocks", + schema_policy = "external_writable" + ) +)] +pub struct RestrictedHostClock { + #[primary_key] + pub id: String, + pub label: String, + #[graphql_orm(timestamp = "host", write = false, default = "0")] + pub updated_at: i64, +} + +#[cfg(any(feature = "sqlite", feature = "postgres"))] +pub async fn run(db: &Database) -> Result<(), Box> { + // Only call this setup on an owned disposable database, never an external source. + let plan = db + .schema() + .plan_migration_to_entities( + "example-init", + "owned host clock example", + &[ + HostClockExample::metadata(), + RestrictedHostClock::metadata(), + ], + ) + .await?; + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + let id = graphql_orm::uuid::Uuid::new_v4(); + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + let row = tx + .insert::(CreateHostClockExampleInput { + id, + label: "caller-clock".into(), + created_at: 1_765_432_100_123, + modified_ms: -123, + }) + .await?; + assert_eq!(row.id, id); + let row = tx + .update_by_id::( + &id, + UpdateHostClockExampleInput { + modified_ms: Some(0), + ..Default::default() + }, + ) + .await? + .expect("own inserted record"); + assert_eq!((row.created_at, row.modified_ms), (1_765_432_100_123, 0)); + Ok::<_, OrmPublicError>(()) + }) + }) + .await?; + let row = HostClockExample::update_by_id( + db, + &id, + UpdateHostClockExampleInput { + label: Some("omitted-timestamps".into()), + ..Default::default() + }, + ) + .await? + .expect("committed record"); + assert_eq!((row.created_at, row.modified_ms), (1_765_432_100_123, 0)); + let restricted = RestrictedHostClock::insert( + db, + CreateRestrictedHostClockInput { + label: "ordinary-restrictions".into(), + }, + ) + .await?; + let restricted = RestrictedHostClock::update_by_id( + db, + &restricted.id, + UpdateRestrictedHostClockInput { + label: Some("still-restricted".into()), + }, + ) + .await? + .expect("restricted row"); + assert_eq!(restricted.updated_at, 0); + assert!(HostClockExample::delete_by_id(db, &id).await?); + Ok(()) +} + +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> Result<(), Box> { + let db = Database::::connect_sqlite("sqlite::memory:").await?; + run(&db).await +} +#[cfg(not(feature = "sqlite"))] +#[allow(dead_code)] +fn main() { + // PostgreSQL executes through tests/host_timestamps.rs with owned infrastructure. + // MSSQL declarations compile only; this example issues no external schema operations. +} diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/host_timestamps.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/host_timestamps.rs new file mode 100644 index 00000000..7b065b0d --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/tests/host_timestamps.rs @@ -0,0 +1,24 @@ +#[path = "../examples/host_timestamps.rs"] +#[allow(dead_code)] +mod example; +#[cfg(feature = "postgres")] +#[path = "../../../support/owned_postgres.rs"] +mod owned_postgres; + +#[cfg(feature = "sqlite")] +#[tokio::test] +async fn standalone_host_timestamp_usage_executes_on_owned_sqlite() +-> Result<(), Box> { + let db = graphql_orm::db::Database::connect_sqlite("sqlite::memory:").await?; + example::run(&db).await +} +#[cfg(feature = "postgres")] +#[tokio::test] +#[ignore = "owns a disposable Docker PostgreSQL container"] +async fn standalone_host_timestamp_usage_executes_on_owned_postgres() +-> Result<(), Box> { + let mut owned = owned_postgres::OwnedPostgres::start("host-timestamp-consumer")?; + let db = graphql_orm::db::Database::connect_postgres(&owned.url).await?; + example::run(&db).await?; + owned.cleanup() +} diff --git a/crates/graphql-orm/tests/host_timestamp_ui.rs b/crates/graphql-orm/tests/host_timestamp_ui.rs new file mode 100644 index 00000000..1a095f88 --- /dev/null +++ b/crates/graphql-orm/tests/host_timestamp_ui.rs @@ -0,0 +1,7 @@ +#![cfg(all(feature = "sqlite", not(any(feature = "postgres", feature = "mssql"))))] +#[test] +fn host_timestamps_require_repository_integer_fields_and_create_values() { + let cases = trybuild::TestCases::new(); + cases.compile_fail("tests/ui/host_timestamp_unsupported.rs"); + cases.compile_fail("tests/ui/host_timestamp_required_create.rs"); +} diff --git a/crates/graphql-orm/tests/host_timestamps.rs b/crates/graphql-orm/tests/host_timestamps.rs new file mode 100644 index 00000000..c13e2c26 --- /dev/null +++ b/crates/graphql-orm/tests/host_timestamps.rs @@ -0,0 +1,1029 @@ +//! Repository host timestamps; PostgreSQL execution uses only test-owned containers. +#![cfg(any( + all(feature = "sqlite", not(any(feature = "postgres", feature = "mssql"))), + all(feature = "postgres", not(any(feature = "sqlite", feature = "mssql"))) +))] +use graphql_orm::{async_graphql, prelude::*}; +use std::sync::{Arc, Mutex}; + +#[cfg(feature = "postgres")] +#[path = "support/owned_postgres.rs"] +mod owned_postgres; + +#[cfg(feature = "sqlite")] +type Backend = SqliteBackend; +#[cfg(feature = "postgres")] +type Backend = PostgresBackend; + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[repository_entity(table = "clocked_records", plural = "ClockedRecords", upsert = "label")] +struct ClockedRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: graphql_orm::uuid::Uuid, + #[unique] + #[filterable(type = "string")] + label: String, + #[graphql_orm(timestamp = "host", default = "0", write_policy = "clock.write")] + #[filterable(type = "number")] + created_at: i64, + #[graphql_orm( + timestamp = "host", + db_column = "updated_at", + default = "0", + write_policy = "clock.write" + )] + #[filterable(type = "number")] + modified_ms: i64, + #[graphql_orm(version, default = "0")] + revision: i64, +} + +// Identical physical declaration without the opt-in, including explicit defaults. +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity( + table = "clocked_records", + plural = "LegacyClockedRecords", + upsert = "label" +)] +struct LegacyClockedRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: graphql_orm::uuid::Uuid, + #[unique] + #[filterable(type = "string")] + label: String, + #[graphql_orm(default = "0", write_policy = "clock.write")] + #[filterable(type = "number")] + created_at: i64, + #[graphql_orm(db_column = "updated_at", default = "0", write_policy = "clock.write")] + #[filterable(type = "number")] + modified_ms: i64, + #[graphql_orm(version, default = "0")] + revision: i64, +} + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity( + table = "nullable_clocks", + plural = "NullableClocks", + repository_mutations = true, + unique_composite = "partition,name", + upsert = "partition,name", + write_policy = "clock.write" +)] +struct NullableClock { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + partition: String, + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "string")] + name: String, + #[graphql_orm(timestamp = "host", default = false, write_policy = "clock.write")] + #[filterable(type = "number")] + created_at: Option, + #[graphql_orm(timestamp = "host", db_column = "updated_at", default = false)] + modified_ms: Option, +} + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(table = "mixed_clocks", plural = "MixedClocks", upsert = "label")] +struct MixedClock { + #[primary_key] + id: String, + #[unique] + label: String, + #[graphql_orm(timestamp = "host", default = "0")] + created_at: i64, + #[graphql_orm(default = "0")] + updated_at: i64, +} + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(table = "implicit_clocks", plural = "ImplicitClocks")] +struct ImplicitClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host")] + created_at: i64, + #[graphql_orm(timestamp = "host")] + updated_at: i64, +} +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(table = "implicit_clocks", plural = "LegacyImplicitClocks")] +struct LegacyImplicitClock { + #[primary_key] + id: String, + created_at: i64, + updated_at: i64, +} + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(table = "clock_journal", plural = "ClockJournals")] +struct ClockJournal { + #[primary_key] + id: String, + #[unique] + marker: String, +} + +#[derive(Clone)] +struct AllowEntities; +impl EntityPolicy for AllowEntities { + fn can_access_entity<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a Database, + _: &'static str, + _: Option<&'static str>, + _: EntityAccessKind, + _: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} +#[derive(Clone)] +struct Fields(bool); +impl FieldPolicy for Fields { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } + fn can_write_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + _: &'static str, + _: &'static str, + policy: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(self.0 || policy.is_none()) }) + } +} + +struct TestDatabase { + db: Database, + #[cfg(feature = "postgres")] + owned: owned_postgres::OwnedPostgres, +} +impl TestDatabase { + async fn new() -> Result> { + #[cfg(feature = "sqlite")] + let db = Database::::connect_sqlite_with_options( + "sqlite::memory:", + graphql_orm::db::ConnectionOptions::default().max_connections(1), + ) + .await?; + #[cfg(feature = "postgres")] + let owned = owned_postgres::OwnedPostgres::start("host-timestamps")?; + #[cfg(feature = "postgres")] + let db = Database::::connect_postgres_with_options( + &owned.url, + graphql_orm::db::ConnectionOptions::default().max_connections(1), + ) + .await?; + let mut fixture = Self { + db, + #[cfg(feature = "postgres")] + owned, + }; + fixture.db.set_entity_policy(AllowEntities); + fixture.db.set_field_policy(Fields(true)); + let plan = fixture + .db + .schema() + .plan_migration_to_entities( + "clock-init", + "owned timestamp fixture", + &[ + ClockedRecord::metadata(), + NullableClock::metadata(), + MixedClock::metadata(), + ImplicitClock::metadata(), + ClockJournal::metadata(), + ], + ) + .await?; + fixture + .db + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + Ok(fixture) + } + #[cfg(feature = "postgres")] + fn cleanup(mut self) -> Result<(), Box> { + self.owned.cleanup() + } + #[cfg(feature = "sqlite")] + fn cleanup(self) -> Result<(), Box> { + Ok(()) + } +} +fn input(label: &str, value: i64) -> CreateClockedRecordInput { + CreateClockedRecordInput { + id: graphql_orm::uuid::Uuid::new_v4(), + label: label.into(), + created_at: value, + modified_ms: value, + } +} +fn filter(label: &str) -> ClockedRecordWhereInput { + ClockedRecordWhereInput { + label: Some(StringFilter { + eq: Some(label.into()), + ..Default::default() + }), + ..Default::default() + } +} + +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn exact_values_direct_and_transaction_updates_cas_and_bounded_sets() +-> Result<(), Box> { + let fixture = TestDatabase::new().await?; + let db = &fixture.db; + for value in [1_765_432_100_123, 0, -123, 42, i64::MIN, i64::MAX] { + let supplied = input("exact", value); + let id = supplied.id; + let row = ClockedRecord::insert(db, supplied).await?; + assert_eq!(row.id, id); + assert_eq!((row.created_at, row.modified_ms), (value, value)); + let read = ClockedRecord::find_by_id(db, &id).await?.unwrap(); + assert_eq!((read.created_at, read.modified_ms), (value, value)); + let row = ClockedRecord::update_by_id( + db, + &id, + UpdateClockedRecordInput { + label: Some("changed".into()), + ..Default::default() + }, + ) + .await? + .unwrap(); + assert_eq!((row.created_at, row.modified_ms), (value, value)); + let row = ClockedRecord::update_by_id( + db, + &id, + UpdateClockedRecordInput { + modified_ms: Some(value.wrapping_add(1)), + ..Default::default() + }, + ) + .await? + .unwrap(); + assert_eq!(row.modified_ms, value.wrapping_add(1)); + ClockedRecord::delete_by_id(db, &id).await?; + } + let row = db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + let row = tx + .insert::(input("transaction", -77)) + .await?; + let row = tx + .update_by_id::( + &row.id, + UpdateClockedRecordInput { + modified_ms: Some(1_765_432_100_999), + ..Default::default() + }, + ) + .await? + .unwrap(); + let success = tx + .compare_and_swap::( + &row.id, + row.revision, + ClockedRecordWhereInput::default(), + UpdateClockedRecordInput { + modified_ms: Some(-999), + ..Default::default() + }, + ) + .await?; + assert!(matches!(success, ConditionalUpdateOutcome::Updated(_))); + let conflict = tx + .compare_and_swap::( + &row.id, + row.revision, + ClockedRecordWhereInput::default(), + UpdateClockedRecordInput { + modified_ms: Some(10), + ..Default::default() + }, + ) + .await?; + assert!(matches!(conflict, ConditionalUpdateOutcome::Conflict)); + tx.find_by_id::(&row.id) + .await + .map_err(OrmPublicError::from) + }) + }) + .await? + .unwrap(); + assert_eq!((row.created_at, row.modified_ms), (-77, -999)); + let success = ClockedRecord::compare_and_swap( + db, + &row.id, + row.revision, + ClockedRecordWhereInput::default(), + UpdateClockedRecordInput { + modified_ms: Some(-444), + ..Default::default() + }, + ) + .await?; + assert!(matches!(success, ConditionalUpdateOutcome::Updated(_))); + assert!(matches!( + ClockedRecord::compare_and_swap( + db, + &row.id, + row.revision, + ClockedRecordWhereInput::default(), + UpdateClockedRecordInput { + modified_ms: Some(55), + ..Default::default() + } + ) + .await?, + ConditionalUpdateOutcome::Conflict + )); + ClockedRecord::update_where( + db, + filter("transaction"), + UpdateClockedRecordInput { + modified_ms: Some(-333), + ..Default::default() + }, + ) + .await?; + + let result = ClockedRecord::update_where_bounded( + db, + filter("transaction"), + UpdateClockedRecordInput { + created_at: Some(123_456_789_123), + ..Default::default() + }, + MutationLimit::new(1)?, + ) + .await?; + assert!(matches!(result, BoundedMutationOutcome::Applied { .. })); + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + tx.update_where::( + filter("transaction"), + UpdateClockedRecordInput { + modified_ms: Some(-876), + ..Default::default() + }, + ) + .await?; + tx.update_where_bounded::( + filter("transaction"), + UpdateClockedRecordInput { + modified_ms: Some(-987), + ..Default::default() + }, + MutationLimit::new(1)?, + ) + .await?; + Ok::<_, OrmPublicError>(()) + }) + }) + .await?; + assert_eq!( + ClockedRecord::find_by_id(db, &row.id) + .await? + .unwrap() + .modified_ms, + -987 + ); + fixture.cleanup() +} + +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn nullable_complete_key_conditional_insert_if_absent_and_upsert() +-> Result<(), Box> { + let fixture = TestDatabase::new().await?; + let db = &fixture.db; + let make = || CreateNullableClockInput { + partition: "owned".into(), + name: "first".into(), + created_at: Some(-17), + modified_ms: None, + }; + let key = NullableClockKey { + partition: "owned".into(), + name: "first".into(), + }; + assert!(matches!( + NullableClock::insert_if_absent(db, make()).await?, + InsertIfAbsentOutcome::Inserted(_) + )); + assert!(matches!( + NullableClock::insert_if_absent(db, make()).await?, + InsertIfAbsentOutcome::AlreadyPresent(_) + )); + let row = NullableClock::update_by_key( + db, + &key, + UpdateNullableClockInput { + modified_ms: Some(Some(1_765_432_100_123)), + ..Default::default() + }, + ) + .await? + .unwrap(); + assert_eq!( + (row.created_at, row.modified_ms), + (Some(-17), Some(1_765_432_100_123)) + ); + let expected = || NullableClockWhereInput { + created_at: Some(IntFilter { + eq: Some(-17), + ..Default::default() + }), + ..Default::default() + }; + assert!(matches!( + NullableClock::update_if( + db, + &key, + expected(), + UpdateNullableClockInput { + created_at: Some(None), + ..Default::default() + } + ) + .await?, + PredicateUpdateOutcome::Updated(_) + )); + assert!(matches!( + NullableClock::update_if( + db, + &key, + expected(), + UpdateNullableClockInput { + modified_ms: Some(Some(5)), + ..Default::default() + } + ) + .await?, + PredicateUpdateOutcome::PredicateConflict + )); + let row = NullableClock::find_by_key(db, &key).await?.unwrap(); + assert_eq!( + (row.created_at, row.modified_ms), + (None, Some(1_765_432_100_123)) + ); + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + tx.update_by_key::( + &key, + UpdateNullableClockInput { + modified_ms: Some(None), + ..Default::default() + }, + ) + .await?; + tx.update_if::( + &key, + NullableClockWhereInput { + created_at: Some(IntFilter { + is_null: Some(true), + ..Default::default() + }), + ..Default::default() + }, + UpdateNullableClockInput { + created_at: Some(Some(42)), + ..Default::default() + }, + ) + .await?; + let outcome = tx + .upsert::(CreateNullableClockInput { + partition: key.partition.clone(), + name: key.name.clone(), + created_at: Some(-99), + modified_ms: Some(0), + }) + .await?; + assert_eq!( + (outcome.entity.created_at, outcome.entity.modified_ms), + (Some(-99), Some(0)) + ); + let absent = tx + .insert_if_absent::(CreateNullableClockInput { + partition: "owned".into(), + name: "second".into(), + created_at: None, + modified_ms: Some(i64::MAX), + }) + .await?; + assert!(matches!(absent, InsertIfAbsentOutcome::Inserted(_))); + Ok::<_, OrmPublicError>(()) + }) + }) + .await?; + let supplied = input("upsert", 123_456_789_999); + let id = supplied.id; + assert_eq!(ClockedRecord::upsert(db, supplied).await?.entity.id, id); + let changed = ClockedRecord::upsert(db, input("upsert", -1)).await?; + assert_eq!( + (changed.entity.created_at, changed.entity.modified_ms), + (-1, -1) + ); + assert_eq!(changed.entity.id, id); + fixture.cleanup() +} + +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn mixed_legacy_timestamps_and_annotation_only_migration_noop() +-> Result<(), Box> { + let fixture = TestDatabase::new().await?; + let db = &fixture.db; + for (host, legacy) in [ + (ClockedRecord::metadata(), LegacyClockedRecord::metadata()), + (ImplicitClock::metadata(), LegacyImplicitClock::metadata()), + ] { + let metadata_unchanged = format!("{:?}", host.fields) == format!("{:?}", legacy.fields); + assert!(metadata_unchanged, "annotation changed field metadata"); + let mut all = vec![ + ClockedRecord::metadata(), + NullableClock::metadata(), + MixedClock::metadata(), + ImplicitClock::metadata(), + ClockJournal::metadata(), + ]; + *all.iter_mut() + .find(|entity| entity.table_name == legacy.table_name) + .unwrap() = legacy; + let plan = db + .schema() + .plan_migration_to_entities("legacy-target", "unchanged physical target", &all) + .await?; + let differences = plan + .steps + .iter() + .map(|step| match &step.step { + MigrationStep::AlterColumn { + table_name, after, .. + } => ("alter_column", table_name.as_str(), after.name.as_str()), + MigrationStep::CreateIndex { table_name, index } => { + ("create_index", table_name.as_str(), index.name) + } + MigrationStep::DropIndex { + table_name, + index_name, + } => ("drop_index", table_name.as_str(), index_name.as_str()), + _ => ("other", "", ""), + }) + .collect::>(); + assert!( + differences.is_empty(), + "physical metadata differences: {differences:?}" + ); + *all.iter_mut() + .find(|entity| entity.table_name == host.table_name) + .unwrap() = host; + let plan = db + .schema() + .plan_migration_to_entities("host-target", "annotation only", &all) + .await?; + let differences = plan + .steps + .iter() + .map(|step| match &step.step { + MigrationStep::AlterColumn { + table_name, after, .. + } => ("alter_column", table_name.as_str(), after.name.as_str()), + MigrationStep::CreateIndex { table_name, index } => { + ("create_index", table_name.as_str(), index.name) + } + MigrationStep::DropIndex { + table_name, + index_name, + } => ("drop_index", table_name.as_str(), index_name.as_str()), + _ => ("other", "", ""), + }) + .collect::>(); + assert!( + differences.is_empty(), + "physical metadata differences: {differences:?}" + ); + } + let row = MixedClock::insert( + db, + CreateMixedClockInput { + label: "mixed".into(), + created_at: -123, + }, + ) + .await?; + assert_eq!((row.created_at, row.updated_at), (-123, 0)); + let row = MixedClock::update_by_id( + db, + &row.id, + UpdateMixedClockInput { + created_at: Some(1_765_432_100_123), + ..Default::default() + }, + ) + .await? + .unwrap(); + assert_eq!(row.created_at, 1_765_432_100_123); + assert!(row.updated_at > 1_000_000_000 && row.updated_at < 10_000_000_000); + let row = MixedClock::upsert( + db, + CreateMixedClockInput { + label: "mixed".into(), + created_at: -42, + }, + ) + .await? + .entity; + assert_eq!(row.created_at, -42); + assert!(row.updated_at > 1_000_000_000 && row.updated_at < 10_000_000_000); + let runtime = RuntimeSchema::from_static_entities(&[MixedClock::metadata()])?; + assert!( + runtime + .collections + .iter() + .flat_map(|c| &c.fields) + .filter(|f| matches!(f.physical_column.as_str(), "created_at" | "updated_at")) + .all(|f| f.value_kind == RuntimeValueKind::Integer + && f.default == Some(RuntimeDefault::Literal("0".into()))) + ); + fixture.cleanup() +} + +#[derive(Clone, Default)] +struct RecordingHook(Arc>>); +impl MutationHook for RecordingHook { + fn on_mutation<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a mut MutationContext<'_, Backend>, + event: &'a MutationEvent, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result<()>> { + Box::pin(async move { + self.0.lock().unwrap().push(event.clone()); + Ok(()) + }) + } +} +#[derive(Clone)] +struct FailingJournal; +impl MutationHook for FailingJournal { + fn on_mutation<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + tx: &'a mut MutationContext<'_, Backend>, + event: &'a MutationEvent, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result<()>> { + Box::pin(async move { + if event.entity_name == "ClockedRecord" && event.phase == MutationPhase::After { + tx.insert::(CreateClockJournalInput { + marker: "same-marker".into(), + }) + .await + .map_err(|_| async_graphql::Error::new("journal failed"))?; + return Err(async_graphql::Error::new("hook failed")); + } + Ok(()) + }) + } +} + +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn denied_timestamp_writes_precede_dml_and_hook_journal_errors_rollback() +-> Result<(), Box> { + let mut fixture = TestDatabase::new().await?; + let hook = RecordingHook::default(); + fixture.db.set_mutation_hook(hook.clone()); + let row = ClockedRecord::insert(&fixture.db, input("protected", -12)).await?; + hook.0.lock().unwrap().clear(); + fixture.db.set_field_policy(Fields(false)); + assert!( + ClockedRecord::insert(&fixture.db, input("denied", -3)) + .await + .is_err() + ); + assert!( + ClockedRecord::update_by_id( + &fixture.db, + &row.id, + UpdateClockedRecordInput { + modified_ms: Some(99), + ..Default::default() + } + ) + .await + .is_err() + ); + assert!( + ClockedRecord::upsert(&fixture.db, input("protected", 99)) + .await + .is_err() + ); + assert!(hook.0.lock().unwrap().is_empty()); + let read = ClockedRecord::find_by_id(&fixture.db, &row.id) + .await? + .unwrap(); + assert_eq!((read.created_at, read.modified_ms), (-12, -12)); + fixture.db.set_field_policy(Fields(true)); + ClockedRecord::update_by_id( + &fixture.db, + &row.id, + UpdateClockedRecordInput { + modified_ms: Some(-77), + ..Default::default() + }, + ) + .await?; + assert!(hook.0.lock().unwrap().iter().any(|event| { + event.phase == MutationPhase::After + && event + .changes + .iter() + .any(|change| change.field == "updated_at" && change.value == SqlValue::Int(-77)) + })); + fixture.db.set_mutation_hook(FailingJournal); + let failed = fixture + .db + .transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + tx.update_by_id::( + &row.id, + UpdateClockedRecordInput { + modified_ms: Some(1_765_432_100_123), + ..Default::default() + }, + ) + .await?; + Ok::<_, OrmPublicError>(()) + }) + }) + .await; + assert!(failed.is_err()); + assert_eq!( + ClockedRecord::find_by_id(&fixture.db, &row.id) + .await? + .unwrap() + .modified_ms, + -77 + ); + assert_eq!(ClockJournal::count_all(&fixture.db).await?, 0); + assert!( + ClockedRecord::insert(&fixture.db, input("hook-denied", 88)) + .await + .is_err() + ); + assert_eq!( + ClockedRecord::query(&fixture.db) + .filter(filter("hook-denied")) + .fetch_all() + .await? + .len(), + 0 + ); + ClockJournal::insert( + &fixture.db, + CreateClockJournalInput { + marker: "same-marker".into(), + }, + ) + .await?; + assert!( + fixture + .db + .transaction(TransactionMode::StateMachine, |tx| Box::pin(async move { + tx.insert::(input("journal-denied", 88)) + .await?; + Ok::<_, OrmPublicError>(()) + })) + .await + .is_err() + ); + assert_eq!( + ClockedRecord::query(&fixture.db) + .filter(filter("journal-denied")) + .fetch_all() + .await? + .len(), + 0 + ); + assert_eq!(ClockJournal::count_all(&fixture.db).await?, 1); + fixture.cleanup() +} + +#[derive(Clone)] +struct PendingHook { + entered: Arc, +} +impl MutationHook for PendingHook { + fn on_mutation<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a mut MutationContext<'_, Backend>, + event: &'a MutationEvent, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result<()>> { + Box::pin(async move { + if event.entity_name == "ClockedRecord" && event.phase == MutationPhase::After { + self.entered.notify_one(); + std::future::pending::<()>().await; + } + Ok(()) + }) + } +} +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn whole_transaction_cancellation_with_staged_journal_or_pending_hook_rolls_back() +-> Result<(), Box> { + let mut fixture = TestDatabase::new().await?; + for pending_hook in [false, true] { + let entered = Arc::new(tokio::sync::Notify::new()); + if pending_hook { + fixture.db.set_mutation_hook(PendingHook { + entered: entered.clone(), + }); + } + let mut events = fixture + .db + .ensure_event_sender::() + .subscribe(); + let task = { + let db = fixture.db.clone(); + let entered = entered.clone(); + tokio::spawn(async move { + db.transaction(TransactionMode::StateMachine, |tx| { + Box::pin(async move { + tx.insert::(CreateClockJournalInput { + marker: "cancelled".into(), + }) + .await?; + tx.insert::(input("cancelled", -123)).await?; + entered.notify_one(); + std::future::pending::<()>().await; + Ok::<_, OrmPublicError>(()) + }) + }) + .await + }) + }; + tokio::time::timeout(std::time::Duration::from_secs(10), entered.notified()).await?; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert!(events.try_recv().is_err()); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + assert_eq!(ClockedRecord::count_all(&fixture.db).await?, 0); + assert_eq!(ClockJournal::count_all(&fixture.db).await?, 0); + Ok::<_, graphql_orm::sqlx::Error>(()) + }) + .await??; + } + fixture.cleanup() +} + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(table = "legacy_public_clocks", plural = "LegacyPublicClocks")] +struct LegacyPublicClock { + #[primary_key] + id: String, + #[filterable(type = "string")] + #[sortable] + label: String, + #[graphql_orm(default = "0")] + created_at: i64, + #[graphql_orm(default = "0")] + updated_at: i64, +} +schema_roots! { query_custom_ops: [], entities: [LegacyPublicClock], } + +#[tokio::test] +#[cfg_attr( + feature = "postgres", + ignore = "owns a disposable Docker PostgreSQL container" +)] +async fn legacy_graphql_sdl_and_automatic_repository_writes_are_unchanged() +-> Result<(), Box> { + let fixture = TestDatabase::new().await?; + let plan = fixture + .db + .schema() + .plan_migration_to_entities( + "legacy-public", + "owned legacy GraphQL fixture", + &[ + ClockedRecord::metadata(), + NullableClock::metadata(), + MixedClock::metadata(), + ImplicitClock::metadata(), + ClockJournal::metadata(), + LegacyPublicClock::metadata(), + ], + ) + .await?; + fixture + .db + .schema() + .apply_migration(&plan, ApplyOptions::default()) + .await?; + let schema = async_graphql::Schema::build( + QueryRoot::default(), + MutationRoot::default(), + SubscriptionRoot::default(), + ) + .finish(); + let sdl = schema.sdl(); + let create = sdl + .split("input CreateLegacyPublicClockInput {") + .nth(1) + .expect("legacy create input") + .split('}') + .next() + .unwrap(); + let update = sdl + .split("input UpdateLegacyPublicClockInput {") + .nth(1) + .expect("legacy update input") + .split('}') + .next() + .unwrap(); + assert_eq!(create.trim(), "label: String!"); + assert_eq!(update.trim(), "label: String"); + let object = sdl + .split("type LegacyPublicClock {") + .nth(1) + .expect("legacy object") + .split('}') + .next() + .unwrap(); + assert!(object.contains("createdAt: Int!") && object.contains("updatedAt: Int!")); + let row = LegacyPublicClock::insert( + &fixture.db, + CreateLegacyPublicClockInput { + label: "legacy".into(), + }, + ) + .await?; + assert_eq!((row.created_at, row.updated_at), (0, 0)); + let row = LegacyPublicClock::update_by_id( + &fixture.db, + &row.id, + UpdateLegacyPublicClockInput { + label: Some("updated".into()), + }, + ) + .await? + .unwrap(); + assert_eq!(row.created_at, 0); + assert!(row.updated_at > 1_000_000_000 && row.updated_at < 10_000_000_000); + fixture.cleanup() +} diff --git a/crates/graphql-orm/tests/migration_planner.rs b/crates/graphql-orm/tests/migration_planner.rs index 48b1c184..84172213 100644 --- a/crates/graphql-orm/tests/migration_planner.rs +++ b/crates/graphql-orm/tests/migration_planner.rs @@ -546,3 +546,30 @@ fn migration_file_renderer_includes_headers_and_semicolons() { "2026032501_create_users.sql" ); } + +#[test] +fn known_postgres_epoch_defaults_compare_without_widening_sql_equivalence() { + use graphql_orm::graphql::orm::canonicalize_column_default_expression; + let generated = "EXTRACT(EPOCH FROM NOW())::bigint"; + for deparsed in [ + "(EXTRACT(epoch FROM now()))::bigint", + "((EXTRACT(epoch FROM now()))::bigint)", + "extract(epoch from now())::BIGINT", + ] { + assert!( + canonicalize_column_default_expression(deparsed) == generated, + "known default representation mismatch" + ); + } + for different in [ + "(EXTRACT(epoch FROM now()))::bigint * 1000", + "EXTRACT(EPOCH FROM clock_timestamp())::bigint", + "EXTRACT(EPOCH FROM NOW())::integer", + "'EXTRACT(EPOCH FROM NOW())::bigint'", + ] { + assert!( + canonicalize_column_default_expression(different) != generated, + "different default collapsed" + ); + } +} diff --git a/crates/graphql-orm/tests/ui/host_timestamp_required_create.rs b/crates/graphql-orm/tests/ui/host_timestamp_required_create.rs new file mode 100644 index 00000000..dafd99b3 --- /dev/null +++ b/crates/graphql-orm/tests/ui/host_timestamp_required_create.rs @@ -0,0 +1,12 @@ +use graphql_orm::prelude::*; +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(table = "required_clock", plural = "RequiredClocks")] +struct RequiredClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host", default = "0")] + created_at: i64, +} +fn main() { + let _ = CreateRequiredClockInput {}; +} diff --git a/crates/graphql-orm/tests/ui/host_timestamp_required_create.stderr b/crates/graphql-orm/tests/ui/host_timestamp_required_create.stderr new file mode 100644 index 00000000..a96bfa3d --- /dev/null +++ b/crates/graphql-orm/tests/ui/host_timestamp_required_create.stderr @@ -0,0 +1,5 @@ +error[E0063]: missing field `created_at` in initializer of `CreateRequiredClockInput` + --> tests/ui/host_timestamp_required_create.rs:11:13 + | +11 | let _ = CreateRequiredClockInput {}; + | ^^^^^^^^^^^^^^^^^^^^^^^^ missing `created_at` diff --git a/crates/graphql-orm/tests/ui/host_timestamp_unsupported.rs b/crates/graphql-orm/tests/ui/host_timestamp_unsupported.rs new file mode 100644 index 00000000..07795190 --- /dev/null +++ b/crates/graphql-orm/tests/ui/host_timestamp_unsupported.rs @@ -0,0 +1,40 @@ +use graphql_orm::prelude::*; +#[derive(GraphQLEntity)] +#[graphql_entity(table = "public_clock", plural = "PublicClocks")] +struct PublicClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host")] + created_at: i64, +} +#[derive(GraphQLSchemaEntity)] +#[graphql_entity(table = "schema_clock", plural = "SchemaClocks")] +struct SchemaClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host")] + created_at: i64, +} +#[derive(RepositoryEntity)] +#[repository_entity(table = "text_clock", plural = "TextClocks")] +struct TextClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host")] + updated_at: String, +} +#[derive(GraphQLOperations)] +#[graphql_entity(table = "operations_clock", plural = "OperationsClocks")] +struct OperationsClock { + #[primary_key] + id: String, + #[graphql_orm(timestamp = "host")] + updated_at: i64, +} +#[derive(RepositoryEntity)] +#[repository_entity(table = "inferred_key_clock", plural = "InferredKeyClocks")] +struct InferredKeyClock { + #[graphql_orm(timestamp = "host", db_column = "updated_at", auto_generated = false)] + id: i64, +} +fn main() {} diff --git a/crates/graphql-orm/tests/ui/host_timestamp_unsupported.stderr b/crates/graphql-orm/tests/ui/host_timestamp_unsupported.stderr new file mode 100644 index 00000000..cb3032a0 --- /dev/null +++ b/crates/graphql-orm/tests/ui/host_timestamp_unsupported.stderr @@ -0,0 +1,34 @@ +error: timestamp = "host" requires RepositoryEntity + --> tests/ui/host_timestamp_unsupported.rs:7:5 + | +7 | / #[graphql_orm(timestamp = "host")] +8 | | created_at: i64, + | |___________________^ + +error: timestamp = "host" requires RepositoryEntity + --> tests/ui/host_timestamp_unsupported.rs:15:5 + | +15 | / #[graphql_orm(timestamp = "host")] +16 | | created_at: i64, + | |___________________^ + +error: host timestamp requires an ordinary i64 or Option Integer field + --> tests/ui/host_timestamp_unsupported.rs:23:5 + | +23 | / #[graphql_orm(timestamp = "host")] +24 | | updated_at: String, + | |______________________^ + +error: timestamp = "host" requires RepositoryEntity + --> tests/ui/host_timestamp_unsupported.rs:31:5 + | +31 | / #[graphql_orm(timestamp = "host")] +32 | | updated_at: i64, + | |___________________^ + +error: host timestamp cannot be a relation, skipped database field, primary key, version, or generated field + --> tests/ui/host_timestamp_unsupported.rs:37:5 + | +37 | / #[graphql_orm(timestamp = "host", db_column = "updated_at", auto_generated = false)] +38 | | id: i64, + | |___________^ diff --git a/docs/reference/graphql-orm/repository-only-entities.md b/docs/reference/graphql-orm/repository-only-entities.md index b407928a..329770e2 100644 --- a/docs/reference/graphql-orm/repository-only-entities.md +++ b/docs/reference/graphql-orm/repository-only-entities.md @@ -3,7 +3,7 @@ title: "Repository-only entities" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-09-17 +last_reviewed: 2026-10-02 review_by: 2027-02-01 supersedes: [] --- @@ -84,6 +84,107 @@ metadata without copying the protected value into the event bus. Hooks can still deliberately inspect and transform the typed create/update input in the normal before-write hook phase. +## Host-managed Integer timestamps + +The legacy convention omits Rust fields named `created_at` and `updated_at` +from typed create/update inputs. Updates also assign epoch seconds whenever a +persisted physical column is named `updated_at`, including Rust aliases. Changing +`default = "0"` or `default = false` alone does not disable that write behavior. + +Repository entities can opt each conventional timestamp field into ordinary +host-supplied writes: + +```rust,ignore +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity(backend = "sqlite", table = "clocked_records", plural = "ClockedRecords")] +struct ClockedRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: graphql_orm::uuid::Uuid, + label: String, + #[graphql_orm(timestamp = "host", default = "0")] + created_at: i64, + #[graphql_orm(timestamp = "host", db_column = "updated_at", default = "0")] + modified_ms: i64, +} +``` + +`timestamp = "host"` supports persisted `i64` and `Option` fields where +either the Rust name or physical column is `created_at` or `updated_at`. +It disables both typed-input exclusion and automatic seconds assignment for +that field. Supplied signed integers are bound unchanged: the ORM neither +selects a clock nor interprets/rescales their units. Mixed host-managed and +legacy fields retain their independent behavior. + +Non-null writable create fields are required even with a database default. +Updates use `Option`: `None` preserves the existing value. Nullable fields +use `Option` on create and `Option>` on update, distinguishing +omission (`None`), SQL NULL (`Some(None)`), and a value (`Some(Some(value))`). +Normal restrictions, constraints, policies, input transforms, hooks, and +redacted change metadata still apply. Host management grants no authority. + +Host-managed `created_at` is ordinarily writable, including during an upsert +conflict update. It is **not immutable**; use existing restrictions and field +policies for immutability. Upsert accepts the existing complete create input, +while insert-if-absent leaves a conflicting existing row unchanged. No new +upsert, CAS, conflict, or bulk-write semantics are introduced. Direct and +transaction-bound insert, ID/key update, conditional/CAS update, bounded update, +upsert, and insert-if-absent use the same field decision where those operations +are supported by the declaration/backend. + +The annotation changes no column/default/schema metadata. Explicit defaults and +legacy implicit epoch-second defaults remain intact, and adding the annotation +alone replans to no-op on SQLite/PostgreSQL. Typed creates provide the host value +explicitly; other writers that omit the column can still invoke its unchanged +database default. Removing a default remains a separate schema change. +`default = false` also works on an opted-in alias of a conventional physical +column. Static-to-runtime conversion retains `Integer` and its default; this +annotation is not a runtime write mode or a DateTime adapter. It neither implements +nor changes the separate owned migration contract. That contract's approved +rejection of legacy epoch-second DateTime storage, supported Integer epoch-second +defaults, and canonical runtime DateTime semantics remain independent requirements. + +Whole-transaction cancellation and propagated hook/journal failures retain the +existing rollback and commit-before-event guarantees. **Catching an inner +mutation timeout/error and returning `Ok` does not guarantee rollback**: the +static transaction context has no unfinished-operation poisoning invariant. +Cancel the whole transaction or propagate the error. A lost commit response +remains ambiguous; do not blindly retry. This capability does not implement the +stronger runtime mutation contract. + +The annotation is rejected on GraphQL/schema-only derives, unrelated names, +non-Integer representations (including `date_field`), relations, skipped database +fields, primary keys, version fields, and explicitly generated fields. Ordinary +`write = false` restrictions remain supported. Existing GraphQL SDL/automatic +behavior and unannotated repository declarations remain unchanged. MSSQL keeps +its existing external schema/write gates; the annotation does not authorize +schema management or enable otherwise unavailable operations. + +The standalone [host timestamp example](../../../crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/host_timestamps.rs) +uses no direct async-graphql dependency. SQLite runs it with: + +```bash +CARGO_BUILD_JOBS=2 cargo run \ + --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ + --locked --no-default-features --features sqlite --example host_timestamps +``` + +PostgreSQL regression/example execution creates test-owned Docker containers, +checks their ownership before cleanup, and never reads application connection +settings. Run backend builds sequentially: + +```bash +CARGO_BUILD_JOBS=2 cargo test -p graphql-orm --locked \ + --no-default-features --features sqlite --test host_timestamps +CARGO_BUILD_JOBS=2 cargo test -p graphql-orm --locked \ + --no-default-features --features postgres --test host_timestamps \ + -- --ignored --test-threads=1 +CARGO_BUILD_JOBS=2 cargo test \ + --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ + --locked --no-default-features --features postgres --test host_timestamps \ + -- --ignored --test-threads=1 +``` + ## Reads, writes, and transactions Start bounded list reads with the generated Database-bound builder: diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index d00181d3..64322a2d 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.38.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.39.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.38.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.39.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | diff --git a/scripts/run-owned-database-lanes.sh b/scripts/run-owned-database-lanes.sh index 801024b0..00ae8a74 100755 --- a/scripts/run-owned-database-lanes.sh +++ b/scripts/run-owned-database-lanes.sh @@ -27,7 +27,7 @@ cd "${repository_root}" run_sqlite() { cargo test -p graphql-orm --no-default-features --features sqlite \ - --test grouped_aggregates --test complete_group_pages --test portable_group_pages --locked + --test grouped_aggregates --test complete_group_pages --test portable_group_pages --test host_timestamps --locked cargo test --manifest-path \ crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ --no-default-features --features sqlite --locked @@ -35,10 +35,10 @@ run_sqlite() { run_postgres() { cargo test -p graphql-orm --no-default-features --features postgres \ - --test grouped_aggregates_postgres --test portable_group_pages --locked -- --ignored --test-threads=1 + --test grouped_aggregates_postgres --test portable_group_pages --test host_timestamps --locked -- --ignored --test-threads=1 cargo test --manifest-path \ crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml \ - --no-default-features --features postgres --locked --test owned_backend --test portable_groups \ + --no-default-features --features postgres --locked --test owned_backend --test portable_groups --test host_timestamps \ -- --ignored --test-threads=1 }