From ffb55a48a3e700a525f2f2095388f4277aa399e0 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Mon, 31 Aug 2026 05:25:26 +0000 Subject: [PATCH] Complete contextual computed ordering release --- CHANGELOG.md | 9 +- Cargo.lock | 8 +- Cargo.toml | 6 +- MIGRATION.md | 21 +- README.md | 4 +- crates/graphql-orm-backup/AGENTS.md | 8 +- crates/graphql-orm-backup/CHANGELOG.md | 9 + crates/graphql-orm-backup/Cargo.toml | 2 +- crates/graphql-orm-backup/MIGRATION.md | 9 + crates/graphql-orm-backup/README.md | 4 +- crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 14 +- crates/graphql-orm-macros/src/entity.rs | 180 +++++++++++++++++- crates/graphql-orm-macros/src/operations.rs | 4 +- crates/graphql-orm-macros/src/relations.rs | 7 +- crates/graphql-orm-storage/AGENTS.md | 8 +- crates/graphql-orm-storage/CHANGELOG.md | 9 + crates/graphql-orm-storage/Cargo.toml | 2 +- crates/graphql-orm-storage/MIGRATION.md | 9 + crates/graphql-orm-storage/README.md | 6 +- .../docs/backup-integration.md | 2 +- crates/graphql-orm-storage/docs/usage.md | 4 +- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 10 +- crates/graphql-orm/src/graphql/loaders.rs | 8 + crates/graphql-orm/src/graphql/orm/query.rs | 136 +++++++++++-- .../computed_order_and_complex_relations.rs | 81 +++++--- .../fixtures/backend-coexistence/Cargo.lock | 6 +- crates/graphql-orm/tests/query_ir.rs | 34 ++++ .../graphql-orm/macros-and-attributes.md | 30 ++- docs/reference/workspace-packages.md | 8 +- 31 files changed, 536 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 689612a1..40267265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,18 @@ 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.28.0 - 2026-08-31 +## 0.29.0 - 2026-08-31 -Companion macros crate: `graphql-orm-macros` **0.28.0**. +Companion macros crate: `graphql-orm-macros` **0.29.0**. - Added repeatable, server-defined `order_expression` declarations for safe computed-field ordering. GraphQL clients select only direction; fixed SQL expressions are validated at compile time and never accepted from requests. + Expressions may declare `:named` binds resolved by an entity-owned function + from GraphQL server context; raw placeholders and missing values fail closed. +- Generated entity and relation ordering appends missing primary-key columns as + ascending tie-breakers, keeping equal computed values stable across + limit/offset pages. - Added opt-in `order_aggregate(name = "...", aggregate = "count")` on relations. It generates a direction-only order field backed by a correlated count over the relation's declared source and target keys. diff --git a/Cargo.lock b/Cargo.lock index 51ae259e..0db5b512 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.28.0" +version = "0.29.0" dependencies = [ "agql-auth", "async-graphql", @@ -3152,7 +3152,7 @@ dependencies = [ [[package]] name = "graphql-orm-backup" -version = "0.7.1" +version = "0.7.2" dependencies = [ "async-trait", "bytes", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.28.0" +version = "0.29.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -3229,7 +3229,7 @@ dependencies = [ [[package]] name = "graphql-orm-storage" -version = "0.6.1" +version = "0.6.2" dependencies = [ "async-trait", "aws-credential-types", diff --git a/Cargo.toml b/Cargo.toml index 35457d3b..98f65e35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,12 +37,12 @@ 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.28.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.29.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.2" } -graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.1", default-features = false } +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.3.0" } graphql-orm-router-protocol = { path = "crates/graphql-orm-router-protocol", version = "0.2.1" } -graphql-orm-storage = { path = "crates/graphql-orm-storage", version = "0.6.1", default-features = false } +graphql-orm-storage = { path = "crates/graphql-orm-storage", version = "0.6.2", default-features = false } hive-router = "=0.0.87" hive-router-config = "=0.1.10" jsonwebtoken = { version = "=11.0.0", features = ["rust_crypto"] } diff --git a/MIGRATION.md b/MIGRATION.md index af509864..9146cf12 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,20 +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. -## 0.27.0 to 0.28.0: computed/relation ordering and complex relation composition +## 0.27.0 to 0.29.0: computed/relation ordering and complex relation composition Computed fields can join generated ordering without a handwritten query: ```rust #[graphql_orm(order_expression( name = "Duration", - expression = "finished_at - started_at" + expression = "COALESCE(finished_at, :as_of) - started_at", + parameters = "duration_order_parameters" ))] ``` The expression is trusted, backend-specific server configuration. Requests -continue to supply only `ASC` or `DESC`. Review the resulting query plan and -add an expression/index strategy where the selected backend supports one. +continue to supply only `ASC` or `DESC`. For `:named` binds, the configured +synchronous function receives `&async_graphql::Context` and returns +`async_graphql::Result`; use `SqlValue` variants for +the values. Raw `?`, `$n`, and `@Pn` placeholders are rejected. Programmatic +queries using a contextual expression call `EntityQuery::order_by_with_context`. +Generated pagination now adds missing primary-key columns as ascending +tie-breakers. Review the resulting query plan and add an expression/index +strategy where the selected backend supports one. + +`SortExpression` now carries bound `values` so query rendering can number +order-expression placeholders after filter placeholders on every backend. +Handwritten parameter-free sort construction should use +`SortExpression::unbound("column ASC")`; handwritten struct literals must add +`values: Vec::new()`. A readable relation can expose a server-generated count order without a projection: diff --git a/README.md b/README.md index c2c43597..314b33d8 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,12 @@ schema changes explicitly. Packages are distributed from this repository, not crates.io. Pin the reviewed release revision, not a moving branch or tag. The current coordinated -`graphql-orm` version is 0.28.0. Replace the placeholder below with the final +`graphql-orm` version is 0.29.0. Replace the placeholder below with the final reviewed full SHA for the release: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } ``` Choose exactly the backend support needed by each service. Cargo can unify diff --git a/crates/graphql-orm-backup/AGENTS.md b/crates/graphql-orm-backup/AGENTS.md index 295cd1c0..b86d2267 100644 --- a/crates/graphql-orm-backup/AGENTS.md +++ b/crates/graphql-orm-backup/AGENTS.md @@ -31,14 +31,14 @@ This crate is a reusable backup and restore companion for applications that use ## Current Agent Handoff -- Current crate version is `0.7.1`. -- The optional ORM adapter resolves `graphql-orm` 0.24.0 from the workspace. +- Current crate version is `0.7.2`. +- The optional ORM adapter resolves `graphql-orm` 0.29.0 from the workspace. Internal packages use workspace path dependencies and the root `Cargo.lock`. Keep downstream applications on one reviewed monorepo revision so ORM, backup, and storage share the same canonical source/type universe. - `graphql-orm` owns its optional `agql-auth` integration and pins - `agql-auth` 0.16.0 at - `3bc38cd94794f1e868a9cc3a5551047b95a32105`. This crate must not enable or + `agql-auth` 0.19.0 at + `1d2e9fe2e1576105212a7b340a11abf8cad0382d`. This crate must not enable or depend directly on application authorization. - Applying and dry-run restore compare the manifest backend/schema hash with the target before target checks or writes. Preserve that fail-closed diff --git a/crates/graphql-orm-backup/CHANGELOG.md b/crates/graphql-orm-backup/CHANGELOG.md index c27824be..92e3da32 100644 --- a/crates/graphql-orm-backup/CHANGELOG.md +++ b/crates/graphql-orm-backup/CHANGELOG.md @@ -10,6 +10,15 @@ supersedes: [] # Changelog +## 0.7.2 - 2026-08-31 + +- Refreshed the package's maintained workspace handoff to the current ORM, + storage, and authorization revisions. Backup APIs, snapshot manifests, + repository layout, restore behavior, and feature defaults are unchanged. +- This patch establishes a new immutable package tag because maintained files + under the package source tree changed after 0.7.1. No schema or data + migration is required. + ## 0.7.1 - 2026-08-13 - Published the maintained backup configuration catalogue, provider-neutral diff --git a/crates/graphql-orm-backup/Cargo.toml b/crates/graphql-orm-backup/Cargo.toml index dcd6b596..8f4a7830 100644 --- a/crates/graphql-orm-backup/Cargo.toml +++ b/crates/graphql-orm-backup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-backup" -version = "0.7.1" +version = "0.7.2" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm" diff --git a/crates/graphql-orm-backup/MIGRATION.md b/crates/graphql-orm-backup/MIGRATION.md index efb5abba..d4c70291 100644 --- a/crates/graphql-orm-backup/MIGRATION.md +++ b/crates/graphql-orm-backup/MIGRATION.md @@ -10,6 +10,15 @@ supersedes: [] # Migration Guide +## 0.7.1 to 0.7.2 + +Version 0.7.2 refreshes maintained workspace guidance and establishes a new +immutable package tag for the changed package source tree. The public API, +snapshot manifest, repository-key layout, restore preflight, persisted data, +and feature defaults are unchanged. No schema or data migration is required. +Git consumers should move every selected monorepo package to the same reviewed +full revision and regenerate `Cargo.lock`. + ## 0.7.0 to 0.7.1 Version 0.7.1 publishes the maintained documentation and examples and aligns diff --git a/crates/graphql-orm-backup/README.md b/crates/graphql-orm-backup/README.md index 6d323548..2e6eb4c8 100644 --- a/crates/graphql-orm-backup/README.md +++ b/crates/graphql-orm-backup/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-backup" kind: reference status: active owner: graphql-orm-backup-maintainers -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -31,7 +31,7 @@ restore before replacing a production pin. ```toml [dependencies] -graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.7.1" } +graphql-orm-backup = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.7.2" } ``` The default `local` feature provides `LocalBackupRepository`. To supply only a diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index f1d1d410..19116ada 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.28.0" +version = "0.29.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 6e6314d7..5f99fba5 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -121,9 +121,13 @@ single, pageable, DataLoader, and nested bulk-preload paths. Server-defined computed ordering uses repeatable entity-level `graphql_orm(order_expression(name = "...", expression = "..."))` -declarations. The generated input exposes only `OrderDirection`; the fixed, -validated expression remains compile-time server configuration. Entities that -need relationship counts can add +declarations. An expression containing `:named` bind parameters also declares +`parameters = "server_function_path"`; that function returns +`OrderExpressionParameters` from the GraphQL server context. The generated +input exposes only `OrderDirection`; the fixed, validated expression and bind +names remain compile-time server configuration. Raw backend placeholders are +rejected, and generated pagination adds missing primary-key tie-breakers. +Entities that need relationship counts can add `order_aggregate(name = "...", aggregate = "count")` to an unconditional, readable relation; the generated correlated count uses only its declared key mapping and target entity table. Entities that also have handwritten complex fields use diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index 602e3879..e98c8341 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -50,6 +50,8 @@ pub(crate) struct EntityMetadata { pub(crate) struct OrderExpressionMetadata { pub(crate) name: String, pub(crate) expression: String, + pub(crate) parameter_provider: Option, + pub(crate) parameter_names: Vec, } #[derive(Clone, Copy)] @@ -455,6 +457,7 @@ pub(crate) fn parse_entity_metadata(attrs: &[syn::Attribute]) -> syn::Result syn::Result(&lit.value()).map_err( + |_| { + syn::Error::new( + lit.span(), + "order_expression parameters must be a Rust function path", + ) + }, + )?); } else { return Err(syn::Error::new( option.path.span(), - "unsupported order_expression option; expected name or expression", + "unsupported order_expression option; expected name, expression, or parameters", )); } Ok(()) })?; - metadata.order_expressions.push(OrderExpressionMetadata { - name: name.ok_or_else(|| { + let name = name.ok_or_else(|| { syn::Error::new( meta.path.span(), "order_expression requires name = \"GraphQLField\"", ) - })?, - expression: expression.ok_or_else(|| { + })?; + let expression = expression.ok_or_else(|| { syn::Error::new( meta.path.span(), "order_expression requires expression = \"trusted SQL expression\"", ) - })?, + })?; + let (expression, parameter_names) = + rewrite_named_order_parameters(&expression, meta.path.span())?; + if !parameter_names.is_empty() && parameter_provider.is_none() { + return Err(syn::Error::new( + meta.path.span(), + "order_expression named parameters require parameters = \"server_function_path\"", + )); + } + if parameter_names.is_empty() && parameter_provider.is_some() { + return Err(syn::Error::new( + meta.path.span(), + "order_expression parameters requires at least one :named_parameter in expression", + )); + } + metadata.order_expressions.push(OrderExpressionMetadata { + name, + expression, + parameter_provider, + parameter_names, }); } else if meta.path.is_ident("conditional_index") { let mut index = ConditionalIndexMetadata::default(); @@ -674,9 +706,81 @@ fn validate_order_expression(value: &str, span: proc_macro2::Span) -> syn::Resul Ok(()) } +fn rewrite_named_order_parameters( + value: &str, + span: proc_macro2::Span, +) -> syn::Result<(String, Vec)> { + let chars = value.chars().collect::>(); + let mut output = String::with_capacity(value.len()); + let mut names = Vec::new(); + let mut index = 0usize; + let mut quote = None; + while index < chars.len() { + let character = chars[index]; + if let Some(terminator) = quote { + output.push(character); + if character == terminator { + if index + 1 < chars.len() && chars[index + 1] == terminator { + output.push(chars[index + 1]); + index += 2; + continue; + } + quote = None; + } + index += 1; + continue; + } + if matches!(character, '\'' | '"') { + quote = Some(character); + output.push(character); + index += 1; + continue; + } + if character == '[' { + quote = Some(']'); + output.push(character); + index += 1; + continue; + } + if matches!(character, '?' | '$' | '@') { + return Err(syn::Error::new( + span, + "order_expression bind placeholders must use :name and a server parameter provider", + )); + } + if character == ':' { + if index + 1 < chars.len() && chars[index + 1] == ':' { + output.push(':'); + output.push(':'); + index += 2; + continue; + } + if index + 1 < chars.len() + && (chars[index + 1] == '_' || chars[index + 1].is_ascii_alphabetic()) + { + let start = index + 1; + let mut end = start + 1; + while end < chars.len() && (chars[end] == '_' || chars[end].is_ascii_alphanumeric()) + { + end += 1; + } + names.push(chars[start..end].iter().collect()); + output.push('?'); + index = end; + continue; + } + } + output.push(character); + index += 1; + } + Ok((output, names)) +} + #[cfg(test)] mod order_expression_tests { - use super::{validate_graphql_order_name, validate_order_expression}; + use super::{ + rewrite_named_order_parameters, validate_graphql_order_name, validate_order_expression, + }; #[test] fn accepts_one_server_defined_expression() { @@ -706,6 +810,29 @@ mod order_expression_tests { .is_err() ); } + + #[test] + fn rewrites_only_named_server_parameters() { + let (sql, names) = rewrite_named_order_parameters( + "COALESCE(exit_time, :as_of) - entry_time + :offset", + proc_macro2::Span::call_site(), + ) + .unwrap(); + assert_eq!(sql, "COALESCE(exit_time, ?) - entry_time + ?"); + assert_eq!(names, ["as_of", "offset"]); + + let (sql, names) = rewrite_named_order_parameters( + "payload::text || ':literal'", + proc_macro2::Span::call_site(), + ) + .unwrap(); + assert_eq!(sql, "payload::text || ':literal'"); + assert!(names.is_empty()); + assert!( + rewrite_named_order_parameters("exit_time - ?", proc_macro2::Span::call_site()) + .is_err() + ); + } } pub(crate) fn has_repository_entity_attribute(attrs: &[syn::Attribute]) -> bool { @@ -2916,6 +3043,8 @@ fn generate_entity_impl( let mut search_relation_defs = Vec::new(); let mut search_document_chunks = Vec::new(); let mut sortable_columns: Vec<(syn::Ident, String)> = Vec::new(); + let mut order_expression_parameter_arms = Vec::new(); + let mut order_expression_context_checks = Vec::new(); let mut relation_order_match_arms = Vec::new(); let mut order_by_graphql_names = std::collections::BTreeSet::new(); let mut order_by_rust_names = std::collections::BTreeSet::new(); @@ -4149,6 +4278,22 @@ fn generate_entity_impl( } let expression = format!("({})", order_expression.expression); sortable_columns.push((order_field_name.clone(), expression)); + if let Some(provider) = &order_expression.parameter_provider { + order_expression_context_checks.push(quote! { self.#order_field_name.is_some() }); + let parameter_names = order_expression + .parameter_names + .iter() + .map(|name| syn::LitStr::new(name, struct_name.span())) + .collect::>(); + order_expression_parameter_arms.push(quote! { + if self.#order_field_name.is_some() { + let __graphql_orm_parameters = #provider(ctx)?; + #( + values.push(__graphql_orm_parameters.require(#parameter_names)?); + )* + } + }); + } order_by_fields.push(quote! { #[graphql(name = #graphql_name)] pub #order_field_name: Option<::graphql_orm::graphql::orm::OrderDirection>, @@ -4533,6 +4678,27 @@ fn generate_entity_impl( Some(parts.join(", ")) } } + + fn requires_context(&self) -> bool { + false #(|| #order_expression_context_checks)* + } + + fn to_sort_expression_with_context( + &self, + ctx: &::graphql_orm::async_graphql::Context<'_>, + ) -> ::graphql_orm::async_graphql::Result< + Option<::graphql_orm::graphql::orm::SortExpression> + > { + let Some(clause) = self.to_sql_order() else { + return Ok(None); + }; + let mut values = Vec::new(); + #(#order_expression_parameter_arms)* + Ok(Some(::graphql_orm::graphql::orm::SortExpression { + clause, + values, + })) + } } impl ::graphql_orm::graphql::orm::DatabaseFilter for #where_input_name { diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 988b8258..6cec24fd 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -6590,7 +6590,7 @@ pub(crate) fn generate_graphql_operations( if let Some(ref orders) = order_by { for order in orders { - query = query.order_by(order); + query = query.order_by_with_context(order, ctx)?; } } @@ -6982,7 +6982,7 @@ pub(crate) fn generate_graphql_operations( if let Some(ref orders) = order_by { for order in orders { - query = query.order_by(order); + query = query.order_by_with_context(order, ctx)?; } } diff --git a/crates/graphql-orm-macros/src/relations.rs b/crates/graphql-orm-macros/src/relations.rs index f779fd44..0f0bbf36 100644 --- a/crates/graphql-orm-macros/src/relations.rs +++ b/crates/graphql-orm-macros/src/relations.rs @@ -823,7 +823,10 @@ pub(crate) fn generate_graphql_relations( // Use DataLoader whenever the relation key can be batched. #source_binding_multiple - let use_dataloader = #source_supports_dataloader; + let use_dataloader = #source_supports_dataloader + && order_by + .as_ref() + .is_none_or(|order| !order.requires_context()); let loaded = if use_dataloader { use ::graphql_orm::graphql::loaders::RelationLoader; @@ -855,7 +858,7 @@ pub(crate) fn generate_graphql_relations( } if let Some(ref order) = order_by { - query = query.order_by(order); + query = query.order_by_with_context(order, ctx)?; } if query.order_clauses.is_empty() { diff --git a/crates/graphql-orm-storage/AGENTS.md b/crates/graphql-orm-storage/AGENTS.md index 8a41dc10..74e643b2 100644 --- a/crates/graphql-orm-storage/AGENTS.md +++ b/crates/graphql-orm-storage/AGENTS.md @@ -31,13 +31,13 @@ This crate is a reusable storage companion for applications that use `graphql-or ## Current Agent Handoff -- Current crate version is `0.6.1`. -- The workspace resolves `graphql-orm` 0.24.0 and `graphql-orm-backup` 0.7.1 +- Current crate version is `0.6.2`. +- The workspace resolves `graphql-orm` 0.29.0 and `graphql-orm-backup` 0.7.2 through workspace path dependencies and one root `Cargo.lock`. Keep downstream companion packages on one reviewed monorepo revision. - `graphql-orm` owns the optional `agql-auth` integration, pinned at - `agql-auth` 0.16.0 revision - `3bc38cd94794f1e868a9cc3a5551047b95a32105`; this crate does not depend on + `agql-auth` 0.19.0 revision + `1d2e9fe2e1576105212a7b340a11abf8cad0382d`; this crate does not depend on application authorization. - The storage provider boundary is now the streaming `BlobStore` trait. - `ObjectStorage` extends `BlobStore`; custom providers must implement `BlobStore` first. diff --git a/crates/graphql-orm-storage/CHANGELOG.md b/crates/graphql-orm-storage/CHANGELOG.md index 799ceb21..88d9f3b3 100644 --- a/crates/graphql-orm-storage/CHANGELOG.md +++ b/crates/graphql-orm-storage/CHANGELOG.md @@ -10,6 +10,15 @@ supersedes: [] # Changelog +## 0.6.2 - 2026-08-31 + +- Refreshed the package's maintained workspace handoff to the current ORM, + backup, and authorization revisions. Storage APIs, provider behavior, + persisted metadata, and feature defaults are unchanged. +- This patch establishes a new immutable package tag because maintained files + under the package source tree changed after 0.6.1. No data migration is + required. + ## 0.6.1 - 2026-08-13 - Published the maintained configuration catalogue, backup-integration guide, diff --git a/crates/graphql-orm-storage/Cargo.toml b/crates/graphql-orm-storage/Cargo.toml index 6ba6fdb2..f34d8024 100644 --- a/crates/graphql-orm-storage/Cargo.toml +++ b/crates/graphql-orm-storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-storage" -version = "0.6.1" +version = "0.6.2" edition = "2024" license = "MIT" repository = "https://github.com/Dastari/graphql-orm" diff --git a/crates/graphql-orm-storage/MIGRATION.md b/crates/graphql-orm-storage/MIGRATION.md index eb188faf..1736b5b4 100644 --- a/crates/graphql-orm-storage/MIGRATION.md +++ b/crates/graphql-orm-storage/MIGRATION.md @@ -10,6 +10,15 @@ supersedes: [] # Migration Guide +## 0.6.1 to 0.6.2 + +Version 0.6.2 refreshes maintained workspace guidance and establishes a new +immutable package tag for the changed package source tree. The public API, +provider configuration, storage-key layout, persisted metadata, and feature +defaults are unchanged. No data migration is required. Git consumers should +move every selected monorepo package to the same reviewed full revision and +regenerate `Cargo.lock`. + ## 0.6.0 to 0.6.1 Version 0.6.1 publishes the maintained documentation and examples and aligns diff --git a/crates/graphql-orm-storage/README.md b/crates/graphql-orm-storage/README.md index 68e39cfa..efd14019 100644 --- a/crates/graphql-orm-storage/README.md +++ b/crates/graphql-orm-storage/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-storage" kind: reference status: active owner: graphql-orm-storage-maintainers -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -29,13 +29,13 @@ resolved dependency graph. ```toml [dependencies] -graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.1" } +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.2" } ``` For S3-compatible storage without the default local backend: ```toml -graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.1", default-features = false, features = ["s3"] } +graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.2", default-features = false, features = ["s3"] } ``` ## Five-minute local start diff --git a/crates/graphql-orm-storage/docs/backup-integration.md b/crates/graphql-orm-storage/docs/backup-integration.md index 61f1a91e..ceb1df32 100644 --- a/crates/graphql-orm-storage/docs/backup-integration.md +++ b/crates/graphql-orm-storage/docs/backup-integration.md @@ -35,7 +35,7 @@ Those repository keys should not be forced through primary object metadata. ## Adapter Shape -`graphql-orm-backup` 0.7.1 exposes this adapter: +`graphql-orm-backup` 0.7.2 exposes this adapter: ```rust pub struct BlobStoreBackupRepository { diff --git a/crates/graphql-orm-storage/docs/usage.md b/crates/graphql-orm-storage/docs/usage.md index af24f424..48f4f3e2 100644 --- a/crates/graphql-orm-storage/docs/usage.md +++ b/crates/graphql-orm-storage/docs/usage.md @@ -36,7 +36,7 @@ Default local filesystem support: graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", - version = "0.6.1", + version = "0.6.2", } ``` @@ -47,7 +47,7 @@ Provider-specific builds: graphql-orm-storage = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", - version = "0.6.1", + version = "0.6.2", default-features = false, features = ["s3", "azure"], } diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index a6ec766d..9a2dd851 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.28.0" +version = "0.29.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -65,7 +65,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.28.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.29.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 2f58a21f..87d35245 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -132,9 +132,11 @@ README remains project-neutral. resolver and batching path enforces the same bound-value predicate, and the declaration must disable physical foreign-key emission. - **Server-defined ordering:** computed expressions and opt-in relation counts - add direction-only fields to generated order inputs. Relation counts use the - declared key mapping and target entity table, so clients never provide SQL, - identifiers, or aggregate functions. + add direction-only fields to generated order inputs. Named expression binds + can be supplied by an entity-owned function reading GraphQL server context; + clients never provide SQL, identifiers, values, or aggregate functions. + Missing primary-key columns are appended as deterministic pagination + tie-breakers. ## Errors and security boundaries diff --git a/crates/graphql-orm/src/graphql/loaders.rs b/crates/graphql-orm/src/graphql/loaders.rs index 263db98b..a3bd579c 100644 --- a/crates/graphql-orm/src/graphql/loaders.rs +++ b/crates/graphql-orm/src/graphql/loaders.rs @@ -324,9 +324,17 @@ where .iter() .map(|column| SortExpression { clause: format!("{column} ASC"), + values: Vec::new(), }) .collect::>(); sorts.extend(sample.sorts.clone()); + for primary_key in T::PRIMARY_KEYS { + if !sorts.iter().any(|sort| { + crate::graphql::orm::sort_clause_mentions_column(&sort.clause, primary_key) + }) { + sorts.push(SortExpression::unbound(format!("{primary_key} ASC"))); + } + } let sort_sql = sorts .iter() .map(|sort| sort.clause.clone()) diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 94b451c6..24c3e66c 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -195,8 +195,21 @@ pub trait DatabaseFilter { pub trait DatabaseOrderBy { fn to_sql_order(&self) -> Option; + fn requires_context(&self) -> bool { + false + } + fn to_sort_expression(&self) -> Option { - self.to_sql_order().map(|clause| SortExpression { clause }) + self.to_sql_order().map(SortExpression::unbound) + } + + /// Resolve an ordering expression whose bind values come from trusted + /// server context rather than GraphQL input. + fn to_sort_expression_with_context( + &self, + _ctx: &async_graphql::Context<'_>, + ) -> async_graphql::Result> { + Ok(self.to_sort_expression()) } } @@ -456,6 +469,60 @@ impl FilterExpression { #[derive(Clone, Debug, PartialEq)] pub struct SortExpression { pub clause: String, + pub values: Vec, +} + +impl SortExpression { + pub fn unbound(clause: impl Into) -> Self { + Self { + clause: clause.into(), + values: Vec::new(), + } + } +} + +pub(crate) fn sort_clause_mentions_column(clause: &str, column: &str) -> bool { + let expected = column.trim_matches(|character| matches!(character, '"' | '[' | ']')); + clause + .split(|character: char| character.is_whitespace() || character == ',') + .any(|token| { + token + .rsplit('.') + .next() + .unwrap_or(token) + .trim_matches(|character| matches!(character, '"' | '[' | ']')) + == expected + }) +} + +/// Named bind values returned by an entity-owned computed-order provider. +/// +/// GraphQL clients never construct this map. Generated resolvers call the +/// configured server function with the request context, then bind only names +/// declared in the compile-time SQL expression. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct OrderExpressionParameters { + values: std::collections::BTreeMap, +} + +impl OrderExpressionParameters { + pub fn new() -> Self { + Self::default() + } + + pub fn bind(mut self, name: impl Into, value: SqlValue) -> Self { + self.values.insert(name.into(), value); + self + } + + #[doc(hidden)] + pub fn require(&self, name: &str) -> async_graphql::Result { + self.values.get(name).cloned().ok_or_else(|| { + async_graphql::Error::new(format!( + "computed order parameter provider did not supply `{name}`" + )) + }) + } } #[derive(Clone, Debug, PartialEq)] @@ -1682,7 +1749,12 @@ fn render_select_query_inner( &query .sorts .iter() - .map(|sort| sort.clause.clone()) + .map(|sort| { + let rendered = dialect.normalize_sql(&sort.clause, next_index); + next_index += sort.values.len(); + values.extend(sort.values.iter().cloned()); + rendered + }) .collect::>() .join(", "), ); @@ -2580,22 +2652,23 @@ where let mut sorts = if self.order_clauses.is_empty() { vec![SortExpression { clause: entity_default.to_string(), + values: Vec::new(), }] } else { self.order_clauses .iter() .cloned() - .map(|clause| SortExpression { clause }) + .map(SortExpression::unbound) .collect::>() }; for primary_key in ::PRIMARY_KEYS { - if !sorts.iter().any(|sort| { - sort.clause - .split_whitespace() - .any(|part| part.trim_matches('"') == *primary_key) - }) { + if !sorts + .iter() + .any(|sort| sort_clause_mentions_column(&sort.clause, primary_key)) + { sorts.push(SortExpression { clause: format!("{primary_key} ASC"), + values: Vec::new(), }); } } @@ -2883,6 +2956,7 @@ pub struct EntityQuery { pub where_clauses: Vec, pub values: Vec, pub order_clauses: Vec, + order_values: Vec>, pub page: Option, entity_matchers: Vec>, _marker: PhantomData<(T, B)>, @@ -2894,6 +2968,7 @@ impl Clone for EntityQuery { where_clauses: self.where_clauses.clone(), values: self.values.clone(), order_clauses: self.order_clauses.clone(), + order_values: self.order_values.clone(), page: self.page.clone(), entity_matchers: self.entity_matchers.clone(), _marker: PhantomData, @@ -2911,6 +2986,7 @@ where where_clauses: Vec::new(), values: Vec::new(), order_clauses: Vec::new(), + order_values: Vec::new(), page: None, entity_matchers: Vec::new(), _marker: PhantomData, @@ -2964,12 +3040,31 @@ where { if let Some(sort) = order.to_sort_expression() { self.order_clauses.push(sort.clause); + self.order_values.push(sort.values); } self } + /// Apply an order input while resolving any entity-owned bind parameters + /// from the trusted GraphQL server context. + pub fn order_by_with_context( + mut self, + order: &O, + ctx: &async_graphql::Context<'_>, + ) -> async_graphql::Result + where + O: DatabaseOrderBy, + { + if let Some(sort) = order.to_sort_expression_with_context(ctx)? { + self.order_clauses.push(sort.clause); + self.order_values.push(sort.values); + } + Ok(self) + } + pub fn default_order(mut self) -> Self { self.order_clauses.push(T::DEFAULT_SORT.to_string()); + self.order_values.push(Vec::new()); self } @@ -2984,6 +3079,23 @@ where apply_default_limit: bool, ) -> SelectQuery { let page = pagination_config.resolve_page(self.page.as_ref(), apply_default_limit); + let mut sorts = self + .order_clauses + .iter() + .enumerate() + .map(|(index, clause)| SortExpression { + clause: clause.clone(), + values: self.order_values.get(index).cloned().unwrap_or_default(), + }) + .collect::>(); + for primary_key in T::PRIMARY_KEYS { + if !sorts + .iter() + .any(|sort| sort_clause_mentions_column(&sort.clause, primary_key)) + { + sorts.push(SortExpression::unbound(format!("{primary_key} ASC"))); + } + } SelectQuery { table: T::TABLE_NAME, columns: T::column_names() @@ -2991,12 +3103,7 @@ where .map(|column| (*column).to_string()) .collect(), filter: filter_expression_from_raw_parts(&self.where_clauses, &self.values), - sorts: self - .order_clauses - .iter() - .cloned() - .map(|clause| SortExpression { clause }) - .collect(), + sorts, pagination: if self.requires_in_memory_filtering() { None } else if page.limit.is_some() || page.offset > 0 { @@ -4036,6 +4143,7 @@ mod grouped_aggregate_tests { filter: None, sorts: vec![SortExpression { clause: "id ASC".to_string(), + values: Vec::new(), }], pagination: Some(PaginationRequest { limit: Some(10), diff --git a/crates/graphql-orm/tests/computed_order_and_complex_relations.rs b/crates/graphql-orm/tests/computed_order_and_complex_relations.rs index dc8df8c3..d6a2b0f2 100644 --- a/crates/graphql-orm/tests/computed_order_and_complex_relations.rs +++ b/crates/graphql-orm/tests/computed_order_and_complex_relations.rs @@ -4,6 +4,16 @@ use graphql_orm::async_graphql::SimpleObject; use graphql_orm::prelude::*; use graphql_orm::sqlx::Row; +#[derive(Clone, Copy)] +struct DurationAsOf(i64); + +fn duration_order_parameters( + ctx: &graphql_orm::async_graphql::Context<'_>, +) -> graphql_orm::async_graphql::Result { + let as_of = ctx.data::()?.0; + Ok(OrderExpressionParameters::new().bind("as_of", SqlValue::Int(as_of))) +} + #[derive( GraphQLEntity, GraphQLRelations, @@ -24,14 +34,18 @@ use graphql_orm::sqlx::Row; )] #[graphql_orm( compose_complex_object, - order_expression(name = "Duration", expression = "finished_at - started_at") + order_expression( + name = "Duration", + expression = "COALESCE(finished_at, :as_of) - started_at", + parameters = "duration_order_parameters" + ) )] pub struct ComposedParent { #[primary_key] #[sortable] id: String, started_at: i64, - finished_at: i64, + finished_at: Option, #[graphql(skip)] #[relation( target = "ComposedChild", @@ -47,8 +61,11 @@ pub struct ComposedParent { #[graphql_complex_object] impl ComposedParent { #[graphql(name = "Duration")] - async fn duration(&self) -> i64 { - self.finished_at - self.started_at + async fn duration( + &self, + ctx: &graphql_orm::async_graphql::Context<'_>, + ) -> graphql_orm::async_graphql::Result { + Ok(self.finished_at.unwrap_or(ctx.data::()?.0) - self.started_at) } } @@ -87,16 +104,16 @@ schema_roots! { } #[tokio::test] -async fn fixed_expression_ordering_executes_without_client_supplied_sql() -> graphql_orm::Result<()> -{ +async fn contextual_expression_ordering_is_bound_and_paginates_deterministically() +-> graphql_orm::Result<()> { let database = Database::::connect_sqlite("sqlite::memory:").await?; graphql_orm::sqlx::query( - "CREATE TABLE composed_parents (id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL)", + "CREATE TABLE composed_parents (id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, finished_at INTEGER)", ) .execute(database.pool()) .await?; graphql_orm::sqlx::query( - "INSERT INTO composed_parents (id, started_at, finished_at) VALUES ('short', 10, 15), ('long', 10, 30)", + "INSERT INTO composed_parents (id, started_at, finished_at) VALUES ('b-tied-open', 30, NULL), ('long', 10, 30), ('a-tied', 10, 20)", ) .execute(database.pool()) .await?; @@ -107,26 +124,18 @@ async fn fixed_expression_ordering_executes_without_client_supplied_sql() -> gra }; assert_eq!( order.to_sql_order().as_deref(), - Some("(finished_at - started_at) DESC") - ); - - let loaded = EntityQuery::::new() - .order_by(&order) - .fetch_all(&database) - .await?; - assert_eq!( - loaded - .iter() - .map(|parent| parent.id.clone()) - .collect::>(), - vec!["long", "short"] + Some("(COALESCE(finished_at, ?) - started_at) DESC") ); + assert!(order.requires_context()); - let schema = schema_builder(database).finish(); + let schema = schema_builder(database).data(DurationAsOf(40)).finish(); let response = schema .execute( "query { - composedParents(orderBy: [{ Duration: DESC }]) { + composedParents( + orderBy: [{ Duration: DESC }] + page: { limit: 1, offset: 1 } + ) { edges { node { id Duration } } } }", @@ -137,8 +146,28 @@ async fn fixed_expression_ordering_executes_without_client_supplied_sql() -> gra let edges = data["composedParents"]["edges"] .as_array() .expect("computed-order edges"); - assert_eq!(edges[0]["node"]["id"].as_str(), Some("long")); - assert_eq!(edges[0]["node"]["Duration"].as_i64(), Some(20)); + assert_eq!(edges.len(), 1); + assert_eq!(edges[0]["node"]["id"].as_str(), Some("a-tied")); + assert_eq!(edges[0]["node"]["Duration"].as_i64(), Some(10)); + + let ascending = schema + .execute( + "query { + composedParents(orderBy: [{ Duration: ASC }]) { + edges { node { id } } + } + }", + ) + .await; + assert!(ascending.errors.is_empty(), "{:?}", ascending.errors); + let ascending = ascending.data.into_json().expect("GraphQL response JSON"); + let ids = ascending["composedParents"]["edges"] + .as_array() + .expect("ascending computed-order edges") + .iter() + .map(|edge| edge["node"]["id"].as_str().expect("node id")) + .collect::>(); + assert_eq!(ids, ["a-tied", "b-tied-open", "long"]); Ok(()) } @@ -160,7 +189,7 @@ async fn relation_count_ordering_executes_as_a_correlated_server_expression() -> graphql_orm::Result<()> { let database = Database::::connect_sqlite("sqlite::memory:").await?; graphql_orm::sqlx::query( - "CREATE TABLE composed_parents (id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, finished_at INTEGER NOT NULL)", + "CREATE TABLE composed_parents (id TEXT PRIMARY KEY, started_at INTEGER NOT NULL, finished_at INTEGER)", ) .execute(database.pool()) .await?; diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 07806537..3f9a0f89 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.28.0" +version = "0.29.0" dependencies = [ "agql-auth", "async-graphql", @@ -1372,7 +1372,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.28.0" +version = "0.29.0" dependencies = [ "convert_case", "proc-macro2", @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "graphql-orm-storage" -version = "0.6.1" +version = "0.6.2" dependencies = [ "async-trait", "bytes", diff --git a/crates/graphql-orm/tests/query_ir.rs b/crates/graphql-orm/tests/query_ir.rs index 1a7fbb19..ffd29df0 100644 --- a/crates/graphql-orm/tests/query_ir.rs +++ b/crates/graphql-orm/tests/query_ir.rs @@ -21,6 +21,7 @@ fn sample_select() -> SelectQuery { ])), sorts: vec![SortExpression { clause: "name ASC".to_string(), + values: Vec::new(), }], pagination: Some(PaginationRequest { limit: Some(10), @@ -48,6 +49,38 @@ fn postgres_renderer_numbers_placeholders() { assert_eq!(rendered.values.len(), 2); } +#[test] +fn sort_bind_values_follow_filter_values_and_are_backend_normalized() { + let mut query = sample_select(); + query.sorts = vec![SortExpression { + clause: "COALESCE(exit_time, ?) - entry_time DESC".to_string(), + values: vec![SqlValue::Int(1_800)], + }]; + + let sqlite = render_select_query(DatabaseBackend::Sqlite, &query); + assert!( + sqlite + .sql + .contains("ORDER BY COALESCE(exit_time, ?) - entry_time DESC") + ); + assert_eq!( + sqlite.values, + [ + SqlValue::Bool(true), + SqlValue::String("%Al%".to_string()), + SqlValue::Int(1_800), + ] + ); + + let postgres = render_select_query(DatabaseBackend::Postgres, &query); + assert!( + postgres + .sql + .contains("ORDER BY COALESCE(exit_time, $3) - entry_time DESC") + ); + assert_eq!(postgres.values, sqlite.values); +} + #[test] fn placeholder_normalization_skips_quoted_literals() { let rendered = DatabaseBackend::Postgres.normalize_sql("note = '$1?' AND id = ?", 1); @@ -207,6 +240,7 @@ fn mssql_renderer_numbers_placeholders_orders_and_paginates() { ])), sorts: vec![SortExpression { clause: "[JobName] DESC".to_string(), + values: Vec::new(), }], pagination: Some(PaginationRequest { limit: Some(10), diff --git a/docs/reference/graphql-orm/macros-and-attributes.md b/docs/reference/graphql-orm/macros-and-attributes.md index 5e9d243f..598140fe 100644 --- a/docs/reference/graphql-orm/macros-and-attributes.md +++ b/docs/reference/graphql-orm/macros-and-attributes.md @@ -76,7 +76,7 @@ schema/search metadata. | Option | Accepted shape | Defaults and limits | | --- | --- | --- | | `search` | `(index = bool, language = "…", tokenizer = "…", min_token_len = integer, fallback = "enabled" | "disabled")` | defaults are supplied by the runtime; only these keys are accepted | -| `order_expression` | `(name = "GraphQLField", expression = "trusted SQL expression")` | repeatable; the server-fixed expression is parenthesized and the client supplies only `ASC` or `DESC`; comments and statement separators are rejected | +| `order_expression` | `(name = "GraphQLField", expression = "trusted SQL expression", parameters = "server_function_path")` | repeatable; `parameters` is required only when the expression contains `:named` binds; the client supplies only `ASC` or `DESC`; raw placeholders, comments, and statement separators are rejected | | `compose_complex_object` | marker | use with `GraphQLRelations`, `#[graphql(complex)]`, and `#[graphql_complex_object]` on the handwritten inherent impl | | `conditional_index` | `(name = "…", columns = ["…"], unique = bool, predicate_field = "…", predicate_values = ["…"])` | `columns`, `predicate_field`, and nonempty `predicate_values` are required | | `projection` | `(name = "TypeName", fields = [field, …], private = true)` | all three facts are required; public projections are rejected | @@ -129,14 +129,34 @@ owned by the server declaration rather than accepted from a GraphQL request: ```rust,ignore #[graphql_orm(order_expression( name = "Duration", - expression = "finished_at - started_at" + expression = "COALESCE(finished_at, :as_of) - started_at", + parameters = "duration_order_parameters" ))] ``` This adds `Duration: OrderDirection` to the generated order input and lowers it -to `(finished_at - started_at) ASC|DESC`. Expressions are backend-specific and -trusted like `default_sort`; the public input never accepts an expression, -identifier, fragment, or other client-provided SQL. +to a parameterized `(COALESCE(finished_at, ?) - started_at) ASC|DESC`. The +named bind is resolved by an entity-owned synchronous function: + +```rust,ignore +fn duration_order_parameters( + ctx: &async_graphql::Context<'_>, +) -> async_graphql::Result { + let as_of = ctx.data::()?.unix_seconds; + Ok(OrderExpressionParameters::new().bind("as_of", SqlValue::Int(as_of))) +} +``` + +Expressions are backend-specific and trusted like `default_sort`; the public +input never accepts an expression, identifier, fragment, bind value, or other +client-provided SQL. The macro rewrites only declared `:name` tokens outside +SQL literals and identifiers, rejects raw backend placeholders, and fails the +request if the provider omits a declared value. Generated GraphQL resolvers +resolve providers from their server context. Programmatic callers with such an +order use `EntityQuery::order_by_with_context`; parameter-free orders retain +`order_by`. Entity and relation pagination append any missing primary-key +columns as ascending tie-breakers so equal computed values have stable +limit/offset windows. ### Relations and foreign keys diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index b93a79ec..14345d4d 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,15 +18,15 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.28.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.29.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.95.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.2` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.28.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | +| `graphql-orm-macros` | `0.29.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | -| `graphql-orm-storage` | `0.6.1` | `crates/graphql-orm-storage` | `local` | none | +| `graphql-orm-storage` | `0.6.2` | `crates/graphql-orm-storage` | `local` | none | External exact-revision dependency: