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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
27 changes: 27 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
7 changes: 5 additions & 2 deletions crates/graphql-orm-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ macro/runtime versions aligned:

```toml
[dependencies]
graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.38.0", default-features = false, features = ["sqlite"] }
graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", 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 = "<reviewed-full-40-character-commit-sha>", version = "0.38.0", default-features = false, features = ["sqlite"] }
graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.39.0", default-features = false, features = ["sqlite"] }
```

The direct dependency still requires a compatible `graphql-orm` runtime in the
Expand Down Expand Up @@ -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).

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

168 changes: 168 additions & 0 deletions crates/graphql-orm-macros/src/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,7 @@ pub(crate) struct FieldMetadata {
pub(crate) transform_read: Option<String>,
pub(crate) default: Option<String>,
pub(crate) suppress_implicit_default: bool,
pub(crate) host_managed_timestamp: bool,
pub(crate) auto_generated: Option<bool>,
pub(crate) backup_policy: Option<String>,
/// If true, include in Create/Update inputs even if #[graphql(skip)] is set.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2027,6 +2029,15 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result<FieldMetadata>
));
}
}
} 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()?;
Expand Down Expand Up @@ -2419,6 +2430,92 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result<FieldMetadata>
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<i64> 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<Field, syn::token::Comma>,
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<Item = &'a Field>,
) -> syn::Result<Vec<ParsedField>> {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Field> = 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<Option<i64>>),
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<i64>),
];
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<i64>);
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());
}
}
7 changes: 7 additions & 0 deletions crates/graphql-orm-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>` 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 = (|| {
Expand Down
Loading
Loading