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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,21 @@ for historical context.

## Unreleased

Companion runtime/macros version: **0.58.0**.
Companion runtime/macros version: **0.59.0**.

- Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply.

- Revalidate configured cursor ordering and all composite key fields before SQL
and before release, independently of the selected payload. Reject hidden raw
cursor values on GraphQL keyset and scan surfaces.

- Generate static keyset reads and authorized scans for entities with composite primary keys. Configured order must end with the complete primary-key tuple in declaration order, with no duplicate order fields. Every component is retained in cursor ordering; existing authorization, limits and read-only schema posture remain in force.

## 0.57.0 (2026-10-22)

- Reject private configured cursor fields despite explicit read/order surface
overrides, before GraphQL keyset or scan reads; preserve repository policy.

Companion macros crate: `graphql-orm-macros` **0.57.0**.

- Add the SQL Server-only `#[graphql_orm(mssql_money)]` field domain for exact
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 @@ -40,7 +40,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.58.0", default-features = false }
graphql-orm = { path = "crates/graphql-orm", version = "0.59.0", default-features = false }
graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.1" }
graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false }
graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.1" }
Expand Down
15 changes: 15 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ published to crates.io.

Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks.

## 0.58.0 to 0.59.0: composite-primary-key keysets

Explicit `read = true` or `order = true` never overrides a private configured
GraphQL cursor field. Keyset and scan queries reject it before the first read;
trusted repository pagination retains its independent field policy.

Configured cursors carry raw ordering values, so their fields now require current
read authority even when clients select only `cursor`. GraphQL rejects private,
skipped and transformed cursor fields. Repository policies remain independent.
Manual `AuthorizedKeysetEntity` implementations must supply `KEYSET_CURSOR_FIELDS`;
missing field metadata fails closed. Generated derives populate it automatically.
Ordering, fingerprints and cursor bytes remain unchanged for authorized reads.

Upgrade runtime/macros together. Composite-key entities may now declare `keyset = "rank desc, partition_key asc, item_no asc"` when `(partition_key, item_no)` is the primary key in declaration order. Every primary-key member must appear as the final unique tiebreaker tuple; order fields may not repeat. The generated keyset and scan read surfaces use the existing bounded, authorized cursor implementation. Existing valid single-key orders/cursors are unchanged. No generated GraphQL writes or subscriptions are enabled for composite-key entities by this change. No data or schema migration is required, and an externally managed source stays externally managed.

## 0.56.0 to 0.57.0: exact SQL Server MONEY reads

Upgrade `graphql-orm` and `graphql-orm-macros` together to 0.57.0 using one
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.58.0"
version = "0.59.0"
edition = "2024"
authors = ["Toby Martin"]
description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations."
Expand Down
13 changes: 13 additions & 0 deletions crates/graphql-orm-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str
values without replacing source timestamps with the mutation clock. See the
[migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps).

GraphQL-private configured ordering fields remain unavailable even when
`read = true` or `order = true` is explicitly set. Repository field policy and
permitted repository pagination retain their independent behavior.

Configured keyset cursors require current read authority for every order field
and complete primary-key suffix, even for cursor-only selections. Checks run
before queries and again against returned rows. Private, skipped or transformed
GraphQL order fields fail closed; repository field policy remains independent.

Version 0.59.0 supports static keyset reads with a complete composite-primary-key
tiebreaker tuple. Schema ownership and write posture are unchanged. See the
[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets).

Version 0.57.0 adds the SQL Server-only `#[graphql_orm(mssql_money)]` field
annotation and identifier-safe exact read projections for native `MONEY`.
It pairs with runtime 0.57.0; see the
Expand Down

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

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

112 changes: 100 additions & 12 deletions crates/graphql-orm-macros/src/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1534,12 +1534,6 @@ pub(crate) fn generate_graphql_operations(
}
}
let keyset_parts = if let Some(config) = entity_meta.keyset.as_deref() {
if has_composite_primary_key {
return Err(syn::Error::new_spanned(
struct_name,
"keyset pagination currently requires a single-column primary key",
));
}
let mut parts = Vec::new();
for raw in config.split(',') {
let tokens = raw.split_whitespace().collect::<Vec<_>>();
Expand Down Expand Up @@ -1597,16 +1591,32 @@ pub(crate) fn generate_graphql_operations(
nulls_first,
));
}
if parts.is_empty()
|| parts
.last()
.is_none_or(|(field, _, _, _, _)| field != &pk_field)
{
let complete_key_suffix = parts.len() >= primary_key_fields.len()
&& parts[parts.len().saturating_sub(primary_key_fields.len())..]
.iter()
.zip(&primary_key_fields)
.all(|((field, _, _, _, _), key)| field == &key.field_name);
if !complete_key_suffix {
return Err(syn::Error::new_spanned(
struct_name,
"keyset order must end with the primary key as a unique tiebreaker",
if has_composite_primary_key {
"keyset order must end with every primary-key field in declaration order as a unique tiebreaker"
} else {
"keyset order must end with the primary key as a unique tiebreaker"
},
));
}
if has_composite_primary_key {
let mut ordered_fields = std::collections::BTreeSet::new();
for (field, _, _, _, _) in &parts {
if !ordered_fields.insert(field.to_string()) {
return Err(syn::Error::new_spanned(
field,
"composite keyset order must not repeat a field",
));
}
}
}
Some(parts)
} else {
None
Expand Down Expand Up @@ -4263,9 +4273,34 @@ pub(crate) fn generate_graphql_operations(
let keyset_generated = if let Some(parts) = &keyset_parts {
let mut order_tokens = Vec::new();
let mut cursor_tokens = Vec::new();
let mut cursor_field_metadata = Vec::new();
let mut fingerprint_parts = Vec::new();
for (field, ty, column, ascending, nulls_first) in parts {
let quoted_column = backend_quote_identifier_path(backend, column);
let source = fields
.iter()
.find(|candidate| candidate.ident.as_ref() == Some(field))
.expect("keyset field was resolved during parsing");
let meta = parse_field_metadata(source)?;
let api_name = graphql_field_name(
&meta,
&field.to_string(),
graphql_rename_fields,
serde_rename_all,
);
let graphql_readable = meta.read
&& !meta.is_private
&& !meta.input_only
&& !meta.graphql_skip
&& meta.transform_read.is_none()
&& meta.transform_write.is_none();
let policy = match meta.read_policy.as_deref() {
Some(policy) => quote! { Some(#policy) },
None => quote! { None },
};
cursor_field_metadata.push(quote! { ::graphql_orm::graphql::orm::KeysetCursorField {
column: #quoted_column, api_name: #api_name, read_policy: #policy, graphql_readable: #graphql_readable,
} });
let direction = if *ascending {
quote! { ::graphql_orm::graphql::orm::KeysetOrderColumn::asc(#quoted_column) }
} else {
Expand Down Expand Up @@ -4333,6 +4368,7 @@ pub(crate) fn generate_graphql_operations(
}

impl ::graphql_orm::graphql::orm::AuthorizedKeysetEntity for #struct_name {
const KEYSET_CURSOR_FIELDS: &'static [::graphql_orm::graphql::orm::KeysetCursorField] = &[#(#cursor_field_metadata),*];
const KEYSET_ORDER: &'static [::graphql_orm::graphql::orm::KeysetOrderColumn] = &[#(#order_tokens),*];
const KEYSET_FINGERPRINT: &'static str = #fingerprint;
fn keyset_values(&self) -> Vec<::graphql_orm::graphql::pagination::KeysetValue> {
Expand Down Expand Up @@ -9321,3 +9357,55 @@ pub(crate) fn generate_graphql_operations(
}
})
}

#[cfg(test)]
mod composite_keyset_tests {
use super::generate_graphql_operations;

fn entity(order: &str) -> syn::DeriveInput {
let order = syn::LitStr::new(order, proc_macro2::Span::call_site());
syn::parse_quote! {
#[graphql_entity(table = "composite_keyset_fixture", keyset = #order)]
struct CompositeKeysetFixture {
#[primary_key]
#[graphql_orm(auto_generated = false)]
partition_key: String,
#[primary_key]
#[graphql_orm(auto_generated = false)]
item_no: i64,
rank: i64,
}
}
}

#[test]
fn complete_tuple_is_required_as_the_unique_order_suffix() {
assert!(
generate_graphql_operations(&entity("rank desc, partition_key asc, item_no asc"))
.is_ok()
);
for order in [
"rank desc, partition_key asc",
"rank desc, item_no asc",
"rank desc, item_no asc, partition_key asc",
] {
let error = generate_graphql_operations(&entity(order)).unwrap_err();
assert!(
error.to_string().contains("every primary-key field"),
"{error}"
);
}
}

#[test]
fn duplicate_composite_order_fields_are_rejected() {
let error = generate_graphql_operations(&entity(
"partition_key asc, rank desc, partition_key asc, item_no asc",
))
.unwrap_err();
assert!(
error.to_string().contains("must not repeat a field"),
"{error}"
);
}
}
4 changes: 2 additions & 2 deletions crates/graphql-orm/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "graphql-orm"
version = "0.58.0"
version = "0.59.0"
edition = "2024"
description = "Runtime support crate for graphql-orm-macros"
license = "MIT"
Expand Down Expand Up @@ -73,7 +73,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.58.0", default-features = false }
graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.59.0", default-features = false }
graphql-orm-operation-catalog = { workspace = true }
rust_decimal = { workspace = true }
serde = { version = "1", features = ["derive"] }
Expand Down
13 changes: 13 additions & 0 deletions crates/graphql-orm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ Version 0.58.0 extends explicit host-owned timestamps to public entities and Str
values without replacing source timestamps with the mutation clock. See the
[migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps).

GraphQL-private configured ordering fields remain unavailable even when
`read = true` or `order = true` is explicitly set. Repository field policy and
permitted repository pagination retain their independent behavior.

Configured keyset cursors require current read authority for every order field
and complete primary-key suffix, even for cursor-only selections. Checks run
before queries and again against returned rows. Private, skipped or transformed
GraphQL order fields fail closed; repository field policy remains independent.

Version 0.59.0 supports static keyset reads with a complete composite-primary-key
tiebreaker tuple. Schema ownership and write posture are unchanged. See the
[migration guide](../../MIGRATION.md#0580-to-0590-composite-primary-key-keysets).

Version 0.57.0 adds exact SQL Server `MONEY` typed reads through
`#[graphql_orm(mssql_money)]`. The generated read projection casts the native
column to `DECIMAL(19,4)` under its original alias before decoding; ordinary
Expand Down
Loading
Loading