From 78beb1d04e812c07d7fad2385de6eed51b5048d8 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 19:57:48 +1100 Subject: [PATCH] feat: support runtime ordering for authorized keyset reads --- CHANGELOG.md | 6 + Cargo.lock | 4 +- Cargo.toml | 2 +- MIGRATION.md | 8 + crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 5 + .../fixtures/cross-crate-relations/Cargo.lock | 4 +- .../relationship-capability/Cargo.lock | 4 +- crates/graphql-orm-macros/src/operations.rs | 223 ++++++++- crates/graphql-orm-macros/src/repository.rs | 39 ++ crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 5 + .../src/graphql/orm/authorized_pagination.rs | 206 ++++++-- .../tests/composite_keyset_pagination.rs | 144 ++++++ .../fixtures/backend-coexistence/Cargo.lock | 4 +- .../fixtures/federation-end-to-end/Cargo.lock | 4 +- .../migration-api-compatibility/Cargo.lock | 4 +- .../repository-aggregate-consumer/Cargo.lock | 4 +- .../tests/keyset_cursor_field_authority.rs | 53 +- .../graphql-orm/tests/runtime_keyset_order.rs | 461 ++++++++++++++++++ docs/reference/workspace-packages.md | 4 +- scripts/generate-workspace-inventory.py | 3 + 22 files changed, 1116 insertions(+), 77 deletions(-) create mode 100644 crates/graphql-orm/tests/runtime_keyset_order.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dc9931e3..dd2728f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ for historical context. ## Unreleased +Companion runtime/macros version: **0.62.0**. + +- Add typed runtime `orderBy` to generated keyset queries and repository forward/backward reads. Whitelist persisted scalar order fields, append the complete primary-key suffix, bind dynamic cursors to the exact order, authorize all raw cursor fields, and retain existing configured cursor bytes when `orderBy` is absent. + +## 0.61.0 (2026-10-23) + Companion runtime/macros version: **0.61.0**. - Generated single-primary-key `…Changed` subscriptions accept an optional typed `key` argument. Omission retains all authorized changes; a key composes with the action filter. Registration and every delivered event retain current authorization and provenance checks. The generated operation catalogue includes the same argument. diff --git a/Cargo.lock b/Cargo.lock index 11e3976b..1fb1ba62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index b5f4a73d..6a401469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.61.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.62.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.2" } 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.2" } diff --git a/MIGRATION.md b/MIGRATION.md index d46b4be2..bbb783e1 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,6 +15,14 @@ workspace release tag for every chosen workspace package and record its full commit from the release manifest. Neither the runtime nor macros crate is published to crates.io. +## 0.61.0 to 0.62.0: runtime keyset ordering + +Upgrade runtime and macros together. A generated `…Keyset` query now accepts `orderBy: [EntityKeysetOrderByInput!]`. Each entry selects exactly one readable, persisted sortable scalar, for example `[{title: ASC}, {rank: DESC}]`. Primary-key fields are accepted. Computed, relation, spatial and decimal orders are absent from this input; ordinary list ordering retains its separate input type. A complete readable scalar primary key is required. Entities with private or unsupported primary keys retain their configured keyset surface without registering an unusable runtime order input. + +Each dynamic order appends missing primary-key columns in ascending order. NULL values sort last in either direction; backward windows reverse comparison and null placement internally, then return canonical order. Duplicate fields, empty entries and empty lists fail before SELECT. Every order field, including an implicit key component, requires current field policy even when omitted from the payload. + +Dynamic cursors bind the entity, exact ordered columns, directions and null placement. A cursor for a different order or the configured default is rejected. Omit `orderBy` to retain the configured order and existing cursor bytes. Repository callers may use `keyset_page_ordered` or `keyset_connection_page_ordered`; existing methods stay unchanged. Every composite primary-key component participates in configured and runtime validation. No schema or data migration is required. + ## 0.60.0 to 0.61.0: subscription primary-key selection Upgrade runtime/macros together. Generated single-key subscriptions gain an optional `key` argument, using the entity primary key scalar and configured argument casing. Existing operations remain valid. For example, `recordChanged(key: "…", filter: {actions: [UPDATED]})` watches one row. Key selection narrows notifications only: it never grants row, field, scope, assurance, deleted-preimage or propagated-source authority. Refresh schema/catalogue snapshots. Composite-key subscriptions remain unsupported by this change. No data or schema migration is required. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 60237b77..6805f29b 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.61.0" +version = "0.62.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 a91c5526..7e68b1ff 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -10,6 +10,11 @@ supersedes: [] # `graphql-orm-macros` +Version 0.62.0 adds optional typed runtime `orderBy` to generated keyset queries. +The scalar-only input, complete primary-key suffix, order-bound cursor and current +field authorization are described in the +[migration guide](../../MIGRATION.md#0610-to-0620-runtime-keyset-ordering). + Version 0.61.0 adds an optional typed `key` argument to generated single-primary-key change subscriptions, preserving current authorization and action filtering. See the [migration guide](../../MIGRATION.md#0600-to-0610-subscription-primary-key-selection). 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 bdc36d62..6788d2c9 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock index 72b81bc8..63591074 100644 --- a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 6a7ef895..0a919ced 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -16,6 +16,36 @@ use crate::naming::{ use std::collections::HashMap; use syn::spanned::Spanned; +fn runtime_keyset_value(ty: &syn::Type) -> Option { + let (inner, optional) = option_inner_type(ty) + .map(|inner| (inner, true)) + .unwrap_or((ty, false)); + let type_name = quote! { #inner }.to_string().replace(' ', ""); + let value = if is_string_type(inner) { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::String(value.clone()) } + } else if is_byte_vec_type(inner) { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::Bytes(value.clone()) } + } else if is_uuid_type(inner) { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::Uuid(value.to_string()) } + } else if is_bool_type(inner) { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::Bool(*value) } + } else if matches!(type_name.as_str(), "f32" | "f64") { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::Float(*value as f64) } + } else if matches!( + type_name.as_str(), + "i8" | "i16" | "i32" | "i64" | "isize" | "u8" | "u16" | "u32" + ) { + quote! { ::graphql_orm::graphql::pagination::KeysetValue::Int(*value as i64) } + } else { + return None; + }; + Some(if optional { + quote! { match value.as_ref() { Some(value) => #value, None => ::graphql_orm::graphql::pagination::KeysetValue::Null } } + } else { + value + }) +} + fn semantic_argument_description(value: &str) -> String { let mut output = String::new(); for (index, character) in value.chars().enumerate() { @@ -4341,7 +4371,76 @@ fn generate_graphql_operations_unfiltered( let scan_connection_type = quote::format_ident!("{}AuthorizedScanPage", struct_name); let scan_query_name = apply_graphql_case(&format!("{}Scan", plural_name), resolver_case); + let keyset_order_input = quote::format_ident!("{}KeysetOrderByInput", struct_name); + let runtime_keyset_available = primary_key_fields.iter().all(|field| { + field.meta.read + && !field.meta.is_private + && !field.meta.input_only + && !field.meta.graphql_skip + && field.meta.transform_read.is_none() + && field.meta.transform_write.is_none() + && runtime_keyset_value(&field.field_type).is_some() + }); let keyset_generated = if let Some(parts) = &keyset_parts { + let mut runtime_fields = Vec::new(); + let mut runtime_metadata = Vec::new(); + let mut runtime_values = Vec::new(); + let mut runtime_orders = Vec::new(); + let mut primary_columns = Vec::new(); + for field in fields { + let meta = parse_field_metadata(field)?; + let ident = field.ident.as_ref().expect("named field"); + let is_primary_key = primary_key_fields + .iter() + .any(|key| &key.field_name == ident); + if meta.is_relation + || meta.skip_db + || meta.is_private + || !meta.read + || meta.input_only + || meta.graphql_skip + || meta.transform_read.is_some() + || meta.transform_write.is_some() + || !(is_primary_key || (meta.sortable && meta.order)) + { + continue; + } + let Some(value_tokens) = runtime_keyset_value(&field.ty) else { + continue; + }; + let rust_name = ident.to_string(); + let graphql_name = + graphql_field_name(&meta, &rust_name, graphql_rename_fields, serde_rename_all); + let column = backend_quote_identifier_path( + backend, + meta.db_column.as_deref().unwrap_or(&rust_name), + ); + let policy = match meta.read_policy.as_deref() { + Some(policy) => quote! { Some(#policy) }, + None => quote! { None }, + }; + runtime_fields.push(quote! { + #[graphql(name = #graphql_name)] + pub #ident: Option<::graphql_orm::graphql::orm::OrderDirection>, + }); + runtime_metadata.push(quote! { ::graphql_orm::graphql::orm::KeysetField { + column: #column, api_name: #graphql_name, read_policy: #policy, + } }); + runtime_values + .push(quote! { #column => { let value = &self.#ident; Some(#value_tokens) } }); + runtime_orders.push(quote! { + if let Some(direction) = item.#ident { + selected += 1; + columns.push(match direction { + ::graphql_orm::graphql::orm::OrderDirection::Asc => ::graphql_orm::graphql::orm::KeysetOrderColumn::asc(#column), + ::graphql_orm::graphql::orm::OrderDirection::Desc => ::graphql_orm::graphql::orm::KeysetOrderColumn::desc(#column), + }); + } + }); + if is_primary_key { + primary_columns.push(column); + } + } let mut order_tokens = Vec::new(); let mut cursor_tokens = Vec::new(); let mut cursor_field_metadata = Vec::new(); @@ -4430,6 +4529,76 @@ fn generate_graphql_operations_unfiltered( }); } let fingerprint = format!("v1:{}", fingerprint_parts.join(",")); + let runtime_order_definitions = if runtime_keyset_available { + quote! { + /// Scalar-only runtime keyset order. Each list entry must select + /// exactly one field; NULL values sort last in either direction. + #[derive(::graphql_orm::async_graphql::InputObject, Default, Clone, Debug)] + pub struct #keyset_order_input { #(#runtime_fields)* } + impl #keyset_order_input { + fn __gom_columns(items: Vec) -> ::graphql_orm::async_graphql::Result> { + let mut columns = Vec::new(); + for item in items { + let mut selected = 0; + #(#runtime_orders)* + if selected != 1 { + return Err(::graphql_orm::graphql::errors::OrmPublicError::new( + ::graphql_orm::graphql::errors::OrmErrorCode::InvalidInput + ).into_graphql_error()); + } + } + Ok(columns) + } + } + + } + } else { + quote! {} + }; + let runtime_order_trait_items = if runtime_keyset_available { + quote! { + const KEYSET_FIELDS: &'static [::graphql_orm::graphql::orm::KeysetField] = &[#(#runtime_metadata),*]; + const KEYSET_PRIMARY_COLUMNS: &'static [&'static str] = &[#(#primary_columns),*]; + fn keyset_value_for_column(&self, column: &str) -> Option<::graphql_orm::graphql::pagination::KeysetValue> { + match column { #(#runtime_values),*, _ => None } + } + + } + } else { + quote! {} + }; + let runtime_order_repository_methods = if runtime_keyset_available { + quote! { + /// Fetch a bounded page with optional scalar ordering and a complete primary-key suffix. + pub async fn keyset_page_ordered( + db: &::graphql_orm::db::Database<#backend_marker>, + filter: #where_input, + order_by: Option>, + page: ::graphql_orm::graphql::pagination::KeysetPageInput, + ) -> ::graphql_orm::async_graphql::Result<::graphql_orm::graphql::pagination::Connection> { + let order = order_by.map(#keyset_order_input::__gom_columns).transpose()?; + ::graphql_orm::graphql::orm::EntityQuery::::new() + .filter_with_entity_matching(&filter) + .fetch_authorized_keyset_ordered(db, None, None, page, order).await + } + + /// Fetch a forward or backward page with optional scalar ordering. + pub async fn keyset_connection_page_ordered( + db: &::graphql_orm::db::Database<#backend_marker>, + filter: #where_input, + order_by: Option>, + page: ::graphql_orm::graphql::pagination::KeysetConnectionInput, + ) -> ::graphql_orm::async_graphql::Result<::graphql_orm::graphql::pagination::Connection> { + let order = order_by.map(#keyset_order_input::__gom_columns).transpose()?; + ::graphql_orm::graphql::orm::EntityQuery::::new() + .filter_with_entity_matching(&filter) + .fetch_authorized_keyset_connection_with_order(db, None, None, page, order).await + } + + } + } else { + quote! {} + }; let definitions = quote! { #[derive(::graphql_orm::async_graphql::SimpleObject, Clone, Debug)] #[graphql(rename_fields = #field_case_rule)] @@ -4438,7 +4607,10 @@ fn generate_graphql_operations_unfiltered( pub page_info: ::graphql_orm::graphql::orm::ScanPageInfo, } + #runtime_order_definitions + impl ::graphql_orm::graphql::orm::AuthorizedKeysetEntity for #struct_name { + #runtime_order_trait_items 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; @@ -4463,6 +4635,8 @@ fn generate_graphql_operations_unfiltered( .fetch_authorized_scan(db, None, None, page).await } + #runtime_order_repository_methods + /// Fetch a bounded, stable keyset page using the entity's configured order. pub async fn keyset_page( db: &::graphql_orm::db::Database<#backend_marker>, @@ -4673,6 +4847,29 @@ fn generate_graphql_operations_unfiltered( }; let (keyset_repository_methods, keyset_trait_impl, authorized_keyset_definitions) = keyset_generated; + let runtime_keyset_argument = if runtime_keyset_available { + quote! { + #[graphql(name = #order_by_arg_name, desc = #order_by_arg_description)] order_by: Option>, + } + } else { + quote! {} + }; + let runtime_keyset_prepare = if runtime_keyset_available { + quote! { + let order = order_by.map(#keyset_order_input::__gom_columns).transpose()?; + } + } else { + quote! {} + }; + let runtime_keyset_fetch = if runtime_keyset_available { + quote! { + .fetch_authorized_keyset_ordered(db, Some(ctx), auth, page, order).await? + } + } else { + quote! { + .fetch_authorized_keyset(db, Some(ctx), auth, page).await? + } + }; let keyset_graphql_method = if keyset_parts.is_some() { quote! { #[doc = #keyset_operation_description] @@ -4681,6 +4878,7 @@ fn generate_graphql_operations_unfiltered( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, #[graphql(name = #where_arg_name, desc = #where_arg_description)] where_input: Option<#where_input>, + #runtime_keyset_argument #[graphql(name = #page_arg_name, desc = #page_arg_description)] page: ::graphql_orm::graphql::pagination::KeysetPageInput, ) -> ::graphql_orm::async_graphql::Result<#connection_type> { let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; @@ -4692,9 +4890,10 @@ fn generate_graphql_operations_unfiltered( let db = ctx.data_unchecked::<::graphql_orm::db::Database<#backend_marker>>(); let auth = ctx.data_opt::<::graphql_orm::graphql::orm::DbAuthContext>(); let filter = where_input.unwrap_or_default(); + #runtime_keyset_prepare let connection = ::graphql_orm::graphql::orm::EntityQuery::<#struct_name, #backend_marker>::new() .filter_with_entity_matching(&filter) - .fetch_authorized_keyset(db, Some(ctx), auth, page).await?; + #runtime_keyset_fetch; Ok(#connection_type::from_generic(connection)) } @@ -6488,17 +6687,25 @@ fn generate_graphql_operations_unfiltered( )); } if keyset_parts.is_some() { + let mut arguments = vec![argument_descriptor( + &where_arg_name, + quote! { Option<#where_input> }, + )]; + if runtime_keyset_available { + arguments.push(argument_descriptor( + &order_by_arg_name, + quote! { Option> }, + )); + } + arguments.push(argument_descriptor( + &page_arg_name, + quote! { ::graphql_orm::graphql::pagination::KeysetPageInput }, + )); operation_descriptors.push(descriptor( quote! { ::graphql_orm::graphql::orm::GraphqlOperationKind::Query }, quote! { ::graphql_orm::graphql::orm::GeneratedGraphqlOperationCategory::KeysetList }, &keyset_query_name, - vec![ - argument_descriptor(&where_arg_name, quote! { Option<#where_input> }), - argument_descriptor( - &page_arg_name, - quote! { ::graphql_orm::graphql::pagination::KeysetPageInput }, - ), - ], + arguments, quote! { #connection_type }, &keyset_list_operation_authorization, )); diff --git a/crates/graphql-orm-macros/src/repository.rs b/crates/graphql-orm-macros/src/repository.rs index 8ec15308..0aa3f375 100644 --- a/crates/graphql-orm-macros/src/repository.rs +++ b/crates/graphql-orm-macros/src/repository.rs @@ -362,6 +362,7 @@ pub(crate) fn strip_operations_graphql_surface( ]); let create_name = format!("Create{entity_name}Input"); let update_name = format!("Update{entity_name}Input"); + let keyset_order_name = format!("{entity_name}KeysetOrderByInput"); let key_name = format!("{entity_name}Key"); let changed_event_name = format!("{entity_name}ChangedEvent"); let sensitive = sensitive_fields(input)?; @@ -391,6 +392,10 @@ pub(crate) fn strip_operations_graphql_surface( sanitize_plain_struct(item_struct, false, false); true } + Item::Struct(item_struct) if item_struct.ident == keyset_order_name => { + sanitize_plain_struct(item_struct, true, false); + true + } Item::Struct(item_struct) if item_struct.ident == create_name || item_struct.ident == update_name @@ -739,6 +744,40 @@ mod aggregate_tests { } } + #[test] + #[cfg(any(feature = "sqlite", feature = "postgres"))] + fn repository_runtime_keyset_order_keeps_plain_input_and_repository_methods() { + let mut input = entity(false); + input + .attrs + .push(parse_quote!(#[repository_entity(keyset = "amount asc, id asc")])); + let generated = crate::operations::generate_graphql_operations(&input) + .expect("generate runtime order operations"); + let plain = strip_operations_graphql_surface(&input, generated) + .expect("strip runtime order GraphQL surface"); + let rendered = plain.to_string(); + assert!(rendered.contains("PlainAggregateKeysetOrderByInput")); + assert!(rendered.contains("keyset_page_ordered")); + assert!(!contains_generated_graphql_impl(&plain)); + let file = syn::parse2::(plain).expect("plain generated Rust"); + let input = file + .items + .iter() + .find_map(|item| match item { + Item::Struct(item) if item.ident == "PlainAggregateKeysetOrderByInput" => { + Some(item) + } + _ => None, + }) + .expect("repository input remains"); + assert!( + input + .attrs + .iter() + .all(|attribute| !attribute.path().is_ident("graphql")) + ); + } + #[test] fn repository_surface_guard_rejects_a_remaining_graphql_enum() { assert!(contains_generated_graphql_impl("e! { diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index d75b37f3..51ed68a3 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -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.61.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.62.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 cdc427e8..aa142f0f 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -10,6 +10,11 @@ supersedes: [] # `graphql-orm` +Version 0.62.0 adds optional typed runtime `orderBy` to generated keyset queries. +The scalar-only input, complete primary-key suffix, order-bound cursor and current +field authorization are described in the +[migration guide](../../MIGRATION.md#0610-to-0620-runtime-keyset-ordering). + Version 0.61.0 adds an optional typed `key` argument to generated single-primary-key change subscriptions, preserving current authorization and action filtering. See the [migration guide](../../MIGRATION.md#0600-to-0610-subscription-primary-key-selection). diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index 02bc3125..462bf6d0 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -168,10 +168,32 @@ impl ReadVisibility { pub trait AuthorizedKeysetEntity: Entity { const KEYSET_ORDER: &'static [KeysetOrderColumn]; const KEYSET_FINGERPRINT: &'static str; - /// Authority for every configured ordering value carried by a cursor. - /// Missing metadata fails closed; generated derives supply it. + /// Field authority for every configured ordering value carried by a cursor. + /// Missing metadata fails closed for pagination; generated derives supply it. const KEYSET_CURSOR_FIELDS: &'static [KeysetCursorField] = &[]; fn keyset_values(&self) -> Vec; + + /// Persisted scalar fields accepted by generated runtime keyset ordering. + /// Handwritten implementations retain the configured order only. + const KEYSET_FIELDS: &'static [KeysetField] = &[]; + + /// All primary-key components, in declaration order, for a unique suffix. + const KEYSET_PRIMARY_COLUMNS: &'static [&'static str] = &[]; + + fn keyset_value_for_column(&self, column: &str) -> Option { + Self::KEYSET_ORDER + .iter() + .position(|part| part.column == column) + .and_then(|index| self.keyset_values().get(index).cloned()) + } +} + +/// A generated, identifier-quoted scalar column and its policy identity. +#[derive(Clone, Copy, Debug)] +pub struct KeysetField { + pub column: &'static str, + pub api_name: &'static str, + pub read_policy: Option<&'static str>, } /// Configured ordering metadata, independent of GraphQL payload selection. @@ -184,6 +206,54 @@ pub struct KeysetCursorField { pub graphql_readable: bool, } +fn runtime_keyset_order( + mut order: Vec, +) -> async_graphql::Result<(Vec, String)> { + if order.is_empty() || order.len() > T::KEYSET_FIELDS.len() { + return Err(input_error()); + } + let mut seen = std::collections::HashSet::new(); + for part in &order { + if !T::KEYSET_FIELDS + .iter() + .any(|field| field.column == part.column) + || !seen.insert(part.column) + { + return Err(input_error()); + } + } + if T::KEYSET_PRIMARY_COLUMNS.is_empty() { + return Err(input_error()); + } + for column in T::KEYSET_PRIMARY_COLUMNS { + if !T::KEYSET_FIELDS.iter().any(|field| field.column == *column) { + return Err(input_error()); + } + if seen.insert(column) { + order.push(KeysetOrderColumn::asc(column)); + } + } + // Generated identifiers cannot contain separators unescaped: serialize the + // complete structural order rather than concatenating ambiguous names. + let fingerprint = serde_json::to_string( + &order + .iter() + .map(|part| { + ( + part.column, + matches!(part.direction, KeysetDirection::Asc), + matches!(part.nulls, KeysetNulls::First), + ) + }) + .collect::>(), + ) + .map_err(|_| input_error())?; + Ok(( + order, + format!("runtime-keyset-v1:{}:{fingerprint}", T::entity_name()), + )) +} + /// Diagnostics for actual fetched database rows, before entity decoding. /// SQL contains placeholders; parameter values are deliberately omitted. pub trait ReadQueryObserver: Send + Sync { @@ -384,15 +454,23 @@ where db: &Database, ctx: Option<&async_graphql::Context<'_>>, order: &[KeysetOrderColumn], + runtime: bool, row: Option<&T>, ) -> async_graphql::Result<()> { for part in order { - let field = T::KEYSET_CURSOR_FIELDS - .iter() - .find(|field| field.column == part.column) - .ok_or_else(invalid)?; - let (api_name, policy, graphql_readable) = - (field.api_name, field.read_policy, field.graphql_readable); + let (api_name, policy, graphql_readable) = if runtime { + let field = T::KEYSET_FIELDS + .iter() + .find(|field| field.column == part.column) + .ok_or_else(invalid)?; + (field.api_name, field.read_policy, true) + } else { + let field = T::KEYSET_CURSOR_FIELDS + .iter() + .find(|field| field.column == part.column) + .ok_or_else(invalid)?; + (field.api_name, field.read_policy, field.graphql_readable) + }; let record = row.map(|row| row as &(dyn std::any::Any + Send + Sync)); if let Some(ctx) = ctx { if !graphql_readable { @@ -416,20 +494,30 @@ where } fn keyset_bound( + self, + values: &[KeysetValue], + before: bool, + inclusive: bool, + ) -> async_graphql::Result { + self.keyset_bound_with_order(T::KEYSET_ORDER, values, before, inclusive) + } + + fn keyset_bound_with_order( mut self, + order: &[KeysetOrderColumn], values: &[KeysetValue], before: bool, inclusive: bool, ) -> async_graphql::Result { let (mut clause, mut binds) = if before { - render_keyset_before(B::DIALECT, T::KEYSET_ORDER, values, self.values.len() + 1) + render_keyset_before(B::DIALECT, order, values, self.values.len() + 1) } else { - render_keyset_after(B::DIALECT, T::KEYSET_ORDER, values, self.values.len() + 1) + render_keyset_after(B::DIALECT, order, values, self.values.len() + 1) } .map_err(|e| e.into_graphql_error())?; if inclusive { let mut equal = Vec::new(); - for (column, value) in T::KEYSET_ORDER.iter().zip(values) { + for (column, value) in order.iter().zip(values) { if matches!(value, KeysetValue::Null) { equal.push(format!("{} IS NULL", column.column)); } else { @@ -518,12 +606,68 @@ where auth: Option<&DbAuthContext>, page: KeysetConnectionInput, ) -> async_graphql::Result> { + self.fetch_authorized_keyset_connection_with_order(db, ctx, auth, page, None) + .await + } + + /// Fetch a forward page using a generated scalar order. The complete primary + /// key is appended and every order field is authorized before database I/O. + pub async fn fetch_authorized_keyset_ordered( + self, + db: &Database, + ctx: Option<&async_graphql::Context<'_>>, + auth: Option<&DbAuthContext>, + page: KeysetPageInput, + order: Option>, + ) -> async_graphql::Result> { + self.fetch_authorized_keyset_connection_with_order( + db, + ctx, + auth, + KeysetConnectionInput { + after: page.after, + first: page.limit, + include_total_count: page.include_total_count, + ..Default::default() + }, + order, + ) + .await + } + + /// Bidirectional counterpart with an exact order-bound cursor. Omission + /// preserves the configured order and its existing cursor encoding. + pub async fn fetch_authorized_keyset_connection_with_order( + self, + db: &Database, + ctx: Option<&async_graphql::Context<'_>>, + auth: Option<&DbAuthContext>, + page: KeysetConnectionInput, + order: Option>, + ) -> async_graphql::Result> { + let runtime = order.map(runtime_keyset_order::).transpose()?; + let (order, fingerprint) = runtime + .as_ref() + .map(|(order, fingerprint)| (order.as_slice(), fingerprint.as_str())) + .unwrap_or((T::KEYSET_ORDER, T::KEYSET_FINGERPRINT)); + let values_for = |node: &T| -> async_graphql::Result> { + if runtime.is_none() { + return Ok(node.keyset_values()); + } + order + .iter() + .map(|part| { + node.keyset_value_for_column(part.column) + .ok_or_else(input_error) + }) + .collect() + }; let page = page .validate(PaginationConfig::DEFAULT_LIMIT, i64::MAX) .map_err(|e| e.into_graphql_error())?; let backward = page.direction == KeysetWindowDirection::Backward; let visibility = self.authorize_pagination(db, ctx).await?; - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; + Self::authorize_keyset_order_fields(db, ctx, order, runtime.is_some(), None).await?; if visibility.requires_residual_checks() && (ctx.is_some() || page.include_total_count) { return Err(invalid()); } @@ -532,7 +676,7 @@ where } let mut base = self.with_read_visibility(&visibility)?; base.page = None; - base.order_clauses = T::KEYSET_ORDER.iter().map(|c| c.order_sql()).collect(); + base.order_clauses = order.iter().map(|c| c.order_sql()).collect(); base.order_values.clear(); let limit = db .pagination_config() @@ -550,7 +694,7 @@ where .cursor .as_deref() .map(|cursor| { - decode_keyset_cursor(cursor, T::KEYSET_FINGERPRINT, T::KEYSET_ORDER.len()) + decode_keyset_cursor(cursor, fingerprint, order.len()) .map_err(|e| e.into_graphql_error()) }) .transpose()?; @@ -560,15 +704,13 @@ where None }; let mut query = if let Some(values) = &after { - base.clone().keyset_bound(values, backward, false)? + base.clone() + .keyset_bound_with_order(order, values, backward, false)? } else { base.clone() }; if backward { - query.order_clauses = T::KEYSET_ORDER - .iter() - .map(|c| c.reversed().order_sql()) - .collect(); + query.order_clauses = order.iter().map(|c| c.reversed().order_sql()).collect(); } let mut nodes = query .bounded_rows(db, auth, limit.saturating_add(1)) @@ -576,10 +718,10 @@ where Self::check_repository_candidates(db, &visibility, &nodes).await?; let has_more = nodes.len() > limit as usize; nodes.truncate(limit as usize); - let opposite = if let Some(values) = nodes.first().map(T::keyset_values).or(after) { + let opposite = if let Some(values) = nodes.first().map(&values_for).transpose()?.or(after) { let probe = base .clone() - .keyset_bound(&values, !backward, nodes.is_empty())? + .keyset_bound_with_order(order, &values, !backward, nodes.is_empty())? .bounded_rows(db, auth, 1) .await?; Self::check_repository_candidates(db, &visibility, &probe).await?; @@ -597,16 +739,19 @@ where }; Self::authorize_page_fields(db, ctx, &nodes).await?; for node in &nodes { - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + Self::authorize_keyset_order_fields(db, ctx, order, runtime.is_some(), Some(node)) + .await?; } - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; + Self::authorize_keyset_order_fields(db, ctx, order, runtime.is_some(), None).await?; let edges = nodes .into_iter() - .map(|node| Edge { - cursor: encode_keyset_cursor(T::KEYSET_FINGERPRINT, node.keyset_values()), - node, + .map(|node| { + Ok(Edge { + cursor: encode_keyset_cursor(fingerprint, values_for(&node)?), + node, + }) }) - .collect::>(); + .collect::>>()?; Ok(Connection { page_info: PageInfo { has_next_page, @@ -630,7 +775,7 @@ where page: AuthorizedScanInput, ) -> async_graphql::Result> { let visibility = self.authorize_pagination(db, ctx).await?; - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, false, None).await?; let config = db.authorized_scan_config().ok_or_else(invalid)?; if config.batch_size == 0 || config.max_scanned_rows == 0 { return Err(invalid()); @@ -714,9 +859,10 @@ where }; Self::authorize_page_fields(db, ctx, &nodes).await?; for node in &nodes { - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, Some(node)).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, false, Some(node)) + .await?; } - Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, None).await?; + Self::authorize_keyset_order_fields(db, ctx, T::KEYSET_ORDER, false, None).await?; let continuation = if status == ScanStatus::Exhausted { None } else { diff --git a/crates/graphql-orm/tests/composite_keyset_pagination.rs b/crates/graphql-orm/tests/composite_keyset_pagination.rs index f28dc7cc..faefc335 100644 --- a/crates/graphql-orm/tests/composite_keyset_pagination.rs +++ b/crates/graphql-orm/tests/composite_keyset_pagination.rs @@ -20,6 +20,7 @@ struct CompositeRecord { #[filterable(type = "number")] item_no: i64, #[filterable(type = "number")] + #[sortable] rank: Option, } @@ -162,9 +163,152 @@ async fn generated_composite_keyset_is_admitted_without_graphql_writes() -> grap .len(), 2 ); + let ordered = schema.execute("{ compositeKeysetRecordsKeyset(orderBy: [{rank: ASC}], page: {limit: 2}) { edges { node { partitionKey itemNo } } } }").await; + assert!(ordered.errors.is_empty(), "{:?}", ordered.errors); + let first = + &ordered.data.into_json().unwrap()["compositeKeysetRecordsKeyset"]["edges"][0]["node"]; + assert_eq!(first["partitionKey"], "b"); + assert_eq!(first["itemNo"], 2); Ok(()) } +fn runtime_rank_order(direction: OrderDirection) -> Option> { + Some(vec![CompositeRecordKeysetOrderByInput { + rank: Some(direction), + ..Default::default() + }]) +} + +#[tokio::test] +async fn runtime_composite_order_traverses_collisions_nulls_and_reverse_windows() { + let database = setup().await.unwrap(); + let mut after = None; + let mut keys = Vec::new(); + let mut second_start = None; + for page_index in 0..4 { + let page = CompositeRecord::keyset_page_ordered( + &database, + Default::default(), + runtime_rank_order(OrderDirection::Desc), + KeysetPageInput { + after, + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + if page_index == 1 { + second_start = page.page_info.start_cursor.clone(); + } + keys.extend( + page.edges + .iter() + .map(|edge| (edge.node.partition_key.clone(), edge.node.item_no)), + ); + if !page.page_info.has_next_page { + break; + } + after = page.page_info.end_cursor; + } + assert_eq!( + keys, + vec![ + ("a".into(), 1), + ("a".into(), 2), + ("b".into(), 1), + ("b".into(), 2), + ("a".into(), 3), + ("b".into(), 3), + ] + ); + assert_eq!(keys.iter().collect::>().len(), 6); + let previous = CompositeRecord::keyset_connection_page_ordered( + &database, + Default::default(), + runtime_rank_order(OrderDirection::Desc), + KeysetConnectionInput { + before: second_start, + last: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + previous + .edges + .iter() + .map(|edge| (edge.node.partition_key.as_str(), edge.node.item_no)) + .collect::>(), + [("a", 1), ("a", 2)] + ); +} + +#[tokio::test] +async fn runtime_composite_cursor_rejects_changed_order_before_select() { + let database = setup().await.unwrap(); + let first = CompositeRecord::keyset_page_ordered( + &database, + Default::default(), + runtime_rank_order(OrderDirection::Desc), + KeysetPageInput { + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let database = database.with_read_query_observer(CompositeReadCounter(reads.clone())); + assert!( + CompositeRecord::keyset_page_ordered( + &database, + Default::default(), + runtime_rank_order(OrderDirection::Asc), + KeysetPageInput { + after: first.page_info.end_cursor.clone(), + ..Default::default() + }, + ) + .await + .is_err() + ); + assert!( + CompositeRecord::keyset_page( + &database, + Default::default(), + KeysetPageInput { + after: first.page_info.end_cursor, + ..Default::default() + }, + ) + .await + .is_err() + ); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn runtime_composite_cursor_requires_both_implicit_key_fields_before_select() { + for denied in ["rank", "partitionKey", "itemNo"] { + let mut database = setup().await.unwrap(); + database.set_field_policy(CompositeCursorAuthority(denied)); + let reads = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let database = database.with_read_query_observer(CompositeReadCounter(reads.clone())); + let schema = schema_builder(database) + .data(AuthSubject::new("fixture-principal")) + .finish(); + let response = schema.execute("{ compositeKeysetRecordsKeyset(orderBy: [{rank: DESC}], page: {limit: 2}) { edges { cursor } } }").await; + assert!( + !response.errors.is_empty(), + "unselected runtime cursor component {denied} was not checked" + ); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(response.data.into_json().unwrap()["compositeKeysetRecordsKeyset"].is_null()); + } +} + struct CompositeCursorAuthority(&'static str); impl FieldPolicy for CompositeCursorAuthority { fn can_read_field<'a>( diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 326bfa32..7a589e08 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.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 b7c8a030..abf1533f 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.61.0" +version = "0.62.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.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 27dacbc4..45504991 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.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 e566ea17..e9a5103d 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.61.0" +version = "0.62.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.61.0" +version = "0.62.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs index bfb0931c..56cedba0 100644 --- a/crates/graphql-orm/tests/keyset_cursor_field_authority.rs +++ b/crates/graphql-orm/tests/keyset_cursor_field_authority.rs @@ -163,25 +163,20 @@ async fn setup() -> (Database, RankAuthority, Arc) { (db, authority, reads) } #[tokio::test] -async fn configured_orders_deny_before_select_even_for_cursor_only_payload() { - for denied in ["rank", "key"] { - let (db, authority, reads) = setup().await; - if denied == "rank" { - authority.allowed.store(false, Ordering::SeqCst); - } else { - authority.deny_keys.store(true, Ordering::SeqCst); +async fn configured_and_runtime_orders_deny_before_select_even_for_cursor_only_payload() { + for order in ["", "orderBy: [{protectedRank: ASC}],"] { + for denied in ["rank", "key"] { + let (db, authority, reads) = setup().await; + if denied == "rank" { + authority.allowed.store(false, Ordering::SeqCst); + } else { + authority.deny_keys.store(true, Ordering::SeqCst); + } + let schema = schema_builder(db).finish(); + let response = schema.execute(Request::new(format!("{{ cursorPolicyRecordsKeyset({order} page: {{limit: 1}}) {{ edges {{ cursor }} }} }}")).data(AuthSubject::new("fixture-principal"))).await; + assert!(!response.errors.is_empty()); + assert_eq!(reads.load(Ordering::SeqCst), 0); } - let schema = schema_builder(db).finish(); - let response = schema - .execute( - Request::new( - "{ cursorPolicyRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", - ) - .data(AuthSubject::new("fixture-principal")), - ) - .await; - assert!(!response.errors.is_empty()); - assert_eq!(reads.load(Ordering::SeqCst), 0); } } #[tokio::test] @@ -221,9 +216,18 @@ async fn repository_backward_and_scan_paths_retain_current_field_checks() { configured .page_info .start_cursor + .as_deref() .unwrap() .starts_with("gomk1.") ); + let omitted = + CursorPolicyRecord::keyset_page_ordered(&db, Default::default(), None, Default::default()) + .await + .unwrap(); + assert_eq!( + configured.page_info.start_cursor, + omitted.page_info.start_cursor + ); authority.allowed.store(false, Ordering::SeqCst); reads.store(0, Ordering::SeqCst); assert!( @@ -337,7 +341,7 @@ mod private_order { id: String, // Keep the generated update input valid while testing hidden cursor fields. label: String, - // Explicit repository surface overrides cannot publish a private cursor value. + // Repository surface overrides must never make a private GraphQL sort public. #[graphql_orm(private, read = true, order = true)] #[sortable] hidden_rank: i64, @@ -361,9 +365,20 @@ mod private_order { }) .with_authorized_scan_config(AuthorizedScanConfig::new([7; 32], 2, 4)); let schema = schema_builder(db.clone()).finish(); + let sdl = schema.sdl(); + let input = sdl + .split("input HiddenOrderRecordKeysetOrderByInput {") + .nth(1) + .and_then(|body| body.split("\n}").next()) + .expect("runtime order input"); + assert!( + !input.contains("hiddenRank"), + "private repository surface flags cannot publish an ordering input" + ); for operation in [ "{ privateCursorRecordsKeyset(page: {limit: 1}) { edges { cursor } } }", "{ privateCursorRecordsScan(page: {limit: 1}) { pageInfo { continuation status } } }", + "{ privateCursorRecordsKeyset(orderBy: [{hiddenRank: ASC}], page: {limit: 1}) { edges { cursor } } }", ] { let response = schema .execute(Request::new(operation).data(AuthSubject::new("fixture-principal"))) diff --git a/crates/graphql-orm/tests/runtime_keyset_order.rs b/crates/graphql-orm/tests/runtime_keyset_order.rs new file mode 100644 index 00000000..9b61e557 --- /dev/null +++ b/crates/graphql-orm/tests/runtime_keyset_order.rs @@ -0,0 +1,461 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; +use serde_json::json; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "runtime_order_rows", + plural = "RuntimeOrderRows", + keyset = "rank asc, id asc" +)] +struct RuntimeOrderRow { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[sortable] + #[graphql_orm(db_column = "source_title", read_policy = "title.read")] + title: Option, + #[sortable] + rank: i64, + payload: String, +} + +schema_roots! { query_custom_ops: [], entities: [RuntimeOrderRow], } + +async fn setup_unconfigured() -> Database { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + let plan = db + .schema() + .plan_migration_to_entities( + "runtime-order", + "owned test", + &[RuntimeOrderRow::metadata()], + ) + .await + .unwrap(); + db.schema() + .apply_migration(&plan, ApplyOptions::default()) + .await + .unwrap(); + for (id, title, rank) in [ + ("b", Some("same"), 1), + ("a", Some("same"), 4), + ("c", Some("z"), 2), + ("d", None, 3), + ] { + RuntimeOrderRow::insert( + &db, + CreateRuntimeOrderRowInput { + id: id.into(), + title: title.map(str::to_owned), + rank, + payload: "public".into(), + }, + ) + .await + .unwrap(); + } + db +} +async fn setup() -> Database { + let mut db = setup_unconfigured().await; + db.set_field_policy(TitlePolicy(true)); + db +} +fn title_order(direction: OrderDirection) -> Option> { + Some(vec![RuntimeOrderRowKeysetOrderByInput { + title: Some(direction), + ..Default::default() + }]) +} +async fn forward( + db: &Database, + after: Option, + direction: OrderDirection, +) -> async_graphql::Result> { + RuntimeOrderRow::keyset_page_ordered( + db, + Default::default(), + title_order(direction), + KeysetPageInput { + after, + limit: Some(2), + include_total_count: true, + }, + ) + .await +} + +#[tokio::test] +async fn collisions_nulls_and_primary_suffix_traverse_in_both_directions() { + let db = setup().await; + let first = forward(&db, None, OrderDirection::Asc).await.unwrap(); + assert_eq!( + first + .edges + .iter() + .map(|e| e.node.id.as_str()) + .collect::>(), + ["a", "b"] + ); + assert_eq!(first.page_info.total_count, Some(4)); + let second = forward(&db, first.page_info.end_cursor.clone(), OrderDirection::Asc) + .await + .unwrap(); + assert_eq!( + second + .edges + .iter() + .map(|e| e.node.id.as_str()) + .collect::>(), + ["c", "d"] + ); + assert!(!second.page_info.has_next_page); + let previous = RuntimeOrderRow::keyset_connection_page_ordered( + &db, + Default::default(), + title_order(OrderDirection::Asc), + KeysetConnectionInput { + before: second.page_info.start_cursor, + last: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + previous + .edges + .iter() + .map(|e| e.node.id.as_str()) + .collect::>(), + ["a", "b"] + ); + let descending = forward(&db, None, OrderDirection::Desc).await.unwrap(); + assert_eq!( + descending + .edges + .iter() + .map(|e| e.node.id.as_str()) + .collect::>(), + ["c", "a"] + ); + let remaining = forward(&db, descending.page_info.end_cursor, OrderDirection::Desc) + .await + .unwrap(); + assert_eq!( + remaining + .edges + .iter() + .map(|e| e.node.id.as_str()) + .collect::>(), + ["b", "d"] + ); +} + +#[tokio::test] +async fn changed_order_and_ambiguous_or_duplicate_fields_fail_before_select() { + let db = setup().await; + let first = forward(&db, None, OrderDirection::Asc).await.unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let db = db.with_read_query_observer(Observe(reads.clone())); + assert!( + forward( + &db, + first.page_info.end_cursor.clone(), + OrderDirection::Desc + ) + .await + .is_err() + ); + assert!( + RuntimeOrderRow::keyset_page( + &db, + Default::default(), + KeysetPageInput { + after: first.page_info.end_cursor, + ..Default::default() + } + ) + .await + .is_err() + ); + for orders in [ + vec![], + vec![Default::default()], + vec![RuntimeOrderRowKeysetOrderByInput { + title: Some(OrderDirection::Asc), + rank: Some(OrderDirection::Asc), + ..Default::default() + }], + vec![ + RuntimeOrderRowKeysetOrderByInput { + title: Some(OrderDirection::Asc), + ..Default::default() + }; + 2 + ], + ] { + assert!( + RuntimeOrderRow::keyset_page_ordered( + &db, + Default::default(), + Some(orders), + Default::default() + ) + .await + .is_err() + ); + } + assert_eq!(reads.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn omitted_order_preserves_configured_cursor_and_nodes() { + let db = setup().await; + let configured = RuntimeOrderRow::keyset_page(&db, Default::default(), Default::default()) + .await + .unwrap(); + let omitted = + RuntimeOrderRow::keyset_page_ordered(&db, Default::default(), None, Default::default()) + .await + .unwrap(); + assert_eq!( + configured + .edges + .iter() + .map(|e| (&e.cursor, &e.node.id)) + .collect::>(), + omitted + .edges + .iter() + .map(|e| (&e.cursor, &e.node.id)) + .collect::>() + ); +} + +struct Observe(Arc); +impl ReadQueryObserver for Observe { + fn on_read(&self, _: &str, _: usize) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} +struct TitlePolicy(bool); +impl TitlePolicy { + fn permits(&self, entity: &str, field: &str, policy: Option<&str>) -> bool { + policy.is_none() + || (self.0 + && entity == "RuntimeOrderRow" + && field == "title" + && policy == Some("title.read")) + } +} +impl FieldPolicy for TitlePolicy { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + entity: &'static str, + field: &'static str, + policy: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(self.permits(entity, field, policy)) }) + } + fn can_read_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + entity: &'static str, + field: &'static str, + policy: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(self.permits(entity, field, policy)) }) + } + 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) }) + } +} + +#[tokio::test] +async fn declared_sort_policy_without_provider_fails_before_select() { + let reads = Arc::new(AtomicUsize::new(0)); + let db = setup_unconfigured() + .await + .with_read_query_observer(Observe(reads.clone())); + assert!(forward(&db, None, OrderDirection::Asc).await.is_err()); + assert_eq!(reads.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn unselected_sort_field_denial_prevents_order_leak_and_database_select() { + let reads = Arc::new(AtomicUsize::new(0)); + let mut db = setup().await; + db.set_field_policy(TitlePolicy(false)); + let db = db.with_read_query_observer(Observe(reads.clone())); + let schema = schema_builder(db).data("owned-user".to_string()).finish(); + let response = schema.execute("{ runtimeOrderRowsKeyset(orderBy: [{title: ASC}], page: {limit: 2}) { edges { node { id } } } }").await; + assert!(!response.errors.is_empty()); + assert_eq!(reads.load(Ordering::Relaxed), 0); +} + +#[tokio::test] +async fn generated_sdl_catalogue_and_typed_order_agree() { + let schema = schema_builder(setup().await) + .data("owned-user".to_string()) + .finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("orderBy: [RuntimeOrderRowKeysetOrderByInput!]")); + let catalogue = graphql_orm_operation_catalog(); + let operation = catalogue + .operations() + .iter() + .find(|operation| operation.field_name() == "runtimeOrderRowsKeyset") + .unwrap(); + assert!( + operation + .arguments() + .iter() + .any(|arg| arg.graphql_name() == "orderBy") + ); + let response = schema.execute("{ runtimeOrderRowsKeyset(orderBy: [{rank: DESC}], page: {limit: 2}) { edges { node { id } } } }").await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + assert_eq!( + response.data.into_json().unwrap()["runtimeOrderRowsKeyset"]["edges"], + json!([{"node":{"id":"a"}}, {"node":{"id":"d"}}]) + ); + let invalid = schema.execute("{ runtimeOrderRowsKeyset(orderBy: [{payload: ASC}], page: {limit: 2}) { edges { node { id } } } }").await; + assert!(!invalid.errors.is_empty()); +} + +mod implicit_key_compatibility { + use graphql_orm::prelude::*; + + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity(table = "implicit_runtime_order", keyset = "rank asc, id asc")] + struct ImplicitOrderRow { + #[graphql_orm(auto_generated = false)] + id: String, + #[sortable] + rank: i64, + } + + #[tokio::test] + async fn inferred_id_is_the_complete_runtime_tiebreaker_without_sortable_or_primary_key_flags() + { + let database = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query( + "CREATE TABLE implicit_runtime_order (id TEXT PRIMARY KEY, rank INTEGER NOT NULL)", + ) + .execute(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO implicit_runtime_order VALUES ('c',1),('a',1),('b',1)") + .execute(database.pool()) + .await + .unwrap(); + let order = || { + Some(vec![ImplicitOrderRowKeysetOrderByInput { + rank: Some(OrderDirection::Asc), + ..Default::default() + }]) + }; + let first = ImplicitOrderRow::keyset_page_ordered( + &database, + Default::default(), + order(), + KeysetPageInput { + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + first + .edges + .iter() + .map(|edge| edge.node.id.as_str()) + .collect::>(), + ["a", "b"] + ); + let second = ImplicitOrderRow::keyset_page_ordered( + &database, + Default::default(), + order(), + KeysetPageInput { + after: first.page_info.end_cursor, + limit: Some(2), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!( + second + .edges + .iter() + .map(|edge| edge.node.id.as_str()) + .collect::>(), + ["c"] + ); + assert!(!second.page_info.has_next_page); + } +} + +mod private_key_compatibility { + use graphql_orm::prelude::*; + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "private_fixed_order", + plural = "PrivateFixedOrderRows", + keyset = "digest asc" + )] + struct PrivateFixedOrderRow { + #[primary_key] + #[graphql_orm(private, auto_generated = false)] + digest: String, + #[sortable] + payload: String, + } + schema_roots! { query_custom_ops: [], entities: [PrivateFixedOrderRow], } + #[tokio::test] + async fn fixed_keyset_with_private_primary_key_keeps_valid_schema_without_runtime_order() { + let schema = + schema_builder(Database::connect_sqlite("sqlite::memory:").await.unwrap()).finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("privateFixedOrderRowsKeyset")); + assert!(!sdl.contains("PrivateFixedOrderRowKeysetOrderByInput")); + let catalog = graphql_orm_operation_catalog(); + let keyset = catalog + .operations() + .iter() + .find(|operation| operation.field_name() == "privateFixedOrderRowsKeyset") + .unwrap(); + assert!( + !keyset + .arguments() + .iter() + .any(|argument| argument.graphql_name() == "orderBy") + ); + } +} diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index efe9e9c6..494298ac 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.61.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.62.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.2` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.61.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.62.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.2` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.6.5` | `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/generate-workspace-inventory.py b/scripts/generate-workspace-inventory.py index 3c8f31ff..53ebc534 100644 --- a/scripts/generate-workspace-inventory.py +++ b/scripts/generate-workspace-inventory.py @@ -96,6 +96,9 @@ def update_document(generated: str, check: bool) -> int: if existing == expected: return 0 if check: + # Preserve the canonical Cargo-derived result for review in CI logs. + # Check mode still refuses the stale tracked file and never rewrites it. + print(generated) print( "docs/reference/workspace-packages.md is stale; run " "python3 scripts/generate-workspace-inventory.py",