diff --git a/CHANGELOG.md b/CHANGELOG.md index e90356ab..689612a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -14,6 +14,25 @@ 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 + +Companion macros crate: `graphql-orm-macros` **0.28.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. +- 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. +- Added opt-in `graphql_complex_object` composition so handwritten complex + fields and generated, batched `GraphQLRelations` resolvers share one + async-graphql complex-object implementation. + +No database or stored-data migration is required. Adding an order expression +or relation aggregate changes the entity's GraphQL order-input SDL; adding +complex-object composition preserves the existing handwritten and relation +field SDL. + ## 0.27.0 - 2026-08-26 Companion macros crate: `graphql-orm-macros` **0.27.0**. diff --git a/Cargo.lock b/Cargo.lock index 0f6d35c3..51ae259e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.27.0" +version = "0.28.0" dependencies = [ "agql-auth", "async-graphql", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.27.0" +version = "0.28.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 2bf1c9ac..35457d3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.27.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.28.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-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.0" } diff --git a/MIGRATION.md b/MIGRATION.md index 4f3d0583..af509864 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -13,6 +13,51 @@ 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 + +Computed fields can join generated ordering without a handwritten query: + +```rust +#[graphql_orm(order_expression( + name = "Duration", + expression = "finished_at - started_at" +))] +``` + +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. + +A readable relation can expose a server-generated count order without a +projection: + +```rust +#[relation( + target = "StaffAssignment", + from = "id", + to = "policy_id", + multiple, + order_aggregate(name = "AssignedStaffCount", aggregate = "count") +)] +``` + +Requests again supply only `ASC` or `DESC`. The macro obtains the target table +from its entity type and generates the correlated count from the declared key +mapping. The initial contract supports `count` on unconditional, readable +relations; conditional relations fail compilation when combined with +`order_aggregate`. + +For a relation entity that already has a handwritten `#[ComplexObject]` impl, +add `#[graphql_orm(compose_complex_object)]` to the entity and replace that +impl attribute with `#[graphql_complex_object]`. Handwritten methods remain +unchanged; generated relations retain their batching and arguments. Do not +leave both complex-object attributes on the impl. + +There is no table, column, constraint, or stored-data migration. Regenerate +and review GraphQL SDL and semantic/capability fingerprints when adding a new +ordering field. Review the query plan for correlated relation counts and add +indexes on the target key columns used by frequently sorted relations. + ## 0.26.0 to 0.27.0: conditional polymorphic relationships Adopt `graphql-orm` and `graphql-orm-macros` 0.27.0 together from one reviewed diff --git a/README.md b/README.md index 973e0275..c2c43597 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.26.0. Replace the placeholder below with the final +`graphql-orm` version is 0.28.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.26.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } ``` Choose exactly the backend support needed by each service. Cargo can unify diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 583422a4..f1d1d410 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.27.0" +version = "0.28.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 86f855b8..6e6314d7 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-macros" kind: reference status: active owner: graphql-orm-macros-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.27.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.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.27.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -37,6 +37,7 @@ not connect to a database, run migrations, host GraphQL, or authorize requests. | `GraphQLSchemaEntity` | schema metadata only | | `RepositoryEntity` | typed repository CRUD and private projections with no GraphQL surface | | `GraphQLRelations` | batched single/composite-key relation resolvers | +| `graphql_complex_object` | handwritten complex fields composed with generated relations | | `GraphQLOperations` | generated GraphQL root operation types and operation metadata | | `schema_roots!` | query/mutation/subscription roots, schema builders, metadata, and resolved catalog | | `graphql_orm_custom_operations` | semantic metadata emitted beside a handwritten root impl | @@ -118,5 +119,17 @@ columns are quoted by the selected backend and all values are bound. These logical discriminator joins require `emit_fk = false` and are enforced by 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 +`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 +`graphql_orm(compose_complex_object)` and apply `graphql_complex_object` to the +handwritten impl so generated relations are flattened into the same +`ComplexObject` surface. + See [core runtime documentation](../graphql-orm/README.md), and the [macro and attribute reference](../../docs/reference/graphql-orm/macros-and-attributes.md). diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index aa5be77a..602e3879 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -15,6 +15,7 @@ pub(crate) struct EntityMetadata { pub(crate) description: Option, pub(crate) classification: String, pub(crate) default_sort: Option, + pub(crate) order_expressions: Vec, pub(crate) schema_policy: Option, pub(crate) auth: Option, pub(crate) schema_only: bool, @@ -42,6 +43,13 @@ pub(crate) struct EntityMetadata { pub(crate) serde_rename_all: Option, pub(crate) graphql_rename_fields: Option, pub(crate) rls: Option, + pub(crate) compose_complex_object: bool, +} + +#[derive(Clone)] +pub(crate) struct OrderExpressionMetadata { + pub(crate) name: String, + pub(crate) expression: String, } #[derive(Clone, Copy)] @@ -401,7 +409,9 @@ pub(crate) fn parse_entity_metadata(attrs: &[syn::Attribute]) -> syn::Result syn::Result syn::Result syn::Result<()> { + let mut chars = value.chars(); + let valid_start = chars + .next() + .is_some_and(|character| character == '_' || character.is_ascii_alphabetic()); + if !valid_start + || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) + || value.starts_with("__") + { + return Err(syn::Error::new( + span, + "order_expression name must be a non-reserved GraphQL name", + )); + } + Ok(()) +} + +fn validate_order_expression(value: &str, span: proc_macro2::Span) -> syn::Result<()> { + if value.is_empty() + || value.trim() != value + || value.len() > 4096 + || value.chars().any(char::is_control) + || value.contains(';') + || value.contains("--") + || value.contains("/*") + || value.contains("*/") + { + return Err(syn::Error::new( + span, + "order_expression must be one trimmed, non-empty SQL expression without comments, statement separators, or control characters", + )); + } + Ok(()) +} + +#[cfg(test)] +mod order_expression_tests { + use super::{validate_graphql_order_name, validate_order_expression}; + + #[test] + fn accepts_one_server_defined_expression() { + validate_graphql_order_name("Duration", proc_macro2::Span::call_site()).unwrap(); + validate_order_expression( + "COALESCE(finished_at, started_at) - started_at", + proc_macro2::Span::call_site(), + ) + .unwrap(); + } + + #[test] + fn rejects_reserved_names_and_multi_statement_sql() { + assert!(validate_graphql_order_name("__duration", proc_macro2::Span::call_site()).is_err()); + assert!( + validate_order_expression( + "finished_at; DROP TABLE jobs", + proc_macro2::Span::call_site() + ) + .is_err() + ); + assert!( + validate_order_expression( + "finished_at /* request-controlled */", + proc_macro2::Span::call_site() + ) + .is_err() + ); + } +} + pub(crate) fn has_repository_entity_attribute(attrs: &[syn::Attribute]) -> bool { attrs .iter() @@ -942,6 +1057,7 @@ pub(crate) struct FieldMetadata { pub(crate) relation_propagate_change: Option, pub(crate) relation_source_condition: Option, pub(crate) relation_target_condition: Option, + pub(crate) relation_order_aggregate: Option, pub(crate) skip_db: bool, /// Skip from generated public GraphQL Create/Update inputs only; field remains in DB, /// the Rust entity, and generated trusted Rust Create/Update input structs. @@ -991,6 +1107,12 @@ pub(crate) struct RelationConditionMetadata { pub(crate) value: RelationConditionValue, } +#[derive(Clone)] +pub(crate) struct RelationOrderAggregateMetadata { + pub(crate) name: String, + pub(crate) aggregate: String, +} + #[derive(Clone)] pub(crate) struct SearchFieldMetadata { pub(crate) weight: String, @@ -1106,6 +1228,7 @@ impl Default for FieldMetadata { relation_propagate_change: None, relation_source_condition: None, relation_target_condition: None, + relation_order_aggregate: None, skip_db: false, skip_input: false, is_private: false, @@ -1977,6 +2100,50 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result } meta.relation_target_condition = Some(parse_relation_condition(nested, "column")?); + } else if nested.path.is_ident("order_aggregate") { + if meta.relation_order_aggregate.is_some() { + return Err(nested.error("duplicate relation order_aggregate")); + } + let mut name = None; + let mut aggregate = None; + nested.parse_nested_meta(|option| { + if option.path.is_ident("name") { + let value = option.value()?; + let literal: syn::LitStr = value.parse()?; + validate_graphql_order_name(&literal.value(), literal.span())?; + name = Some(literal.value()); + } else if option.path.is_ident("aggregate") { + let value = option.value()?; + let literal: syn::LitStr = value.parse()?; + let value = literal.value(); + if value != "count" { + return Err(syn::Error::new( + literal.span(), + "relation order_aggregate currently supports only aggregate = \"count\"", + )); + } + aggregate = Some(value); + } else { + return Err(option.error( + "unsupported relation order_aggregate option; expected name or aggregate", + )); + } + Ok(()) + })?; + meta.relation_order_aggregate = Some(RelationOrderAggregateMetadata { + name: name.ok_or_else(|| { + syn::Error::new( + nested.path.span(), + "relation order_aggregate requires name = \"GraphQLField\"", + ) + })?, + aggregate: aggregate.ok_or_else(|| { + syn::Error::new( + nested.path.span(), + "relation order_aggregate requires aggregate = \"count\"", + ) + })?, + }); } else if nested.path.is_ident("multiple") { meta.relation_multiple = true; } else { @@ -2749,6 +2916,9 @@ 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 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(); let mut object_field_methods = Vec::new(); let mut repository_field_policy_defs = Vec::new(); let mut semantic_field_defs = Vec::new(); @@ -3017,6 +3187,86 @@ fn generate_entity_impl( search_fields: #search_fields_tokens, } }); + + if let Some(order_aggregate) = &field_meta.relation_order_aggregate { + if field_meta.relation_source_condition.is_some() + || field_meta.relation_target_condition.is_some() + { + return Err(syn::Error::new_spanned( + field, + "relation order_aggregate is not supported on conditional relations", + )); + } + if !field_meta.read || field_meta.is_private { + return Err(syn::Error::new_spanned( + field, + "relation order_aggregate requires a readable public relation", + )); + } + debug_assert_eq!(order_aggregate.aggregate, "count"); + if !order_by_graphql_names.insert(order_aggregate.name.clone()) { + return Err(syn::Error::new_spanned( + field, + format!( + "duplicate generated ordering field `{}`", + order_aggregate.name + ), + )); + } + let aggregate_name = &order_aggregate.name; + let order_field_name = + rust_ident_from_graphql_name(aggregate_name, field.span()); + if !order_by_rust_names.insert(order_field_name.to_string()) { + return Err(syn::Error::new_spanned( + field, + format!( + "ordering field `{aggregate_name}` conflicts with another generated Rust order-input field" + ), + )); + } + let target_type = syn::Ident::new(&target_type, field.span()); + let relation_alias = "__graphql_orm_order_relation"; + let predicates = source_columns + .iter() + .zip(target_columns.iter()) + .map(|(source_column, target_column)| { + let source = parsed_fields + .iter() + .find(|parsed| { + parsed + .field + .ident + .as_ref() + .is_some_and(|ident| ident == source_column) + }) + .expect("relation source columns were validated above"); + let source_physical = + source.meta.db_column.as_deref().unwrap_or(source_column); + format!( + "{relation_alias}.{} = {table_name}.{}", + backend_quote_identifier_path(backend, target_column), + backend_quote_identifier_path(backend, source_physical), + ) + }) + .collect::>() + .join(" AND "); + let expression_format = format!( + "(SELECT COUNT(*) FROM {{}} AS {relation_alias} WHERE {predicates}) {{}}" + ); + relation_order_match_arms.push(quote! { + if let Some(dir) = &self.#order_field_name { + parts.push(format!( + #expression_format, + <#target_type as ::graphql_orm::graphql::orm::DatabaseEntity>::TABLE_NAME, + dir.to_sql(), + )); + } + }); + order_by_fields.push(quote! { + #[graphql(name = #aggregate_name)] + pub #order_field_name: Option<::graphql_orm::graphql::orm::OrderDirection>, + }); + } } // Initialize relation fields to empty @@ -3783,6 +4033,20 @@ fn generate_entity_impl( // Generate OrderByInput field for sortable fields if field_meta.sortable && field_meta.order { let order_field_name = rust_ident_from_graphql_name(&graphql_name, field_name.span()); + if !order_by_graphql_names.insert(graphql_name.clone()) { + return Err(syn::Error::new_spanned( + field, + format!("duplicate generated ordering field `{graphql_name}`"), + )); + } + if !order_by_rust_names.insert(order_field_name.to_string()) { + return Err(syn::Error::new_spanned( + field, + format!( + "ordering field `{graphql_name}` conflicts with another generated Rust order-input field" + ), + )); + } sortable_columns.push((order_field_name.clone(), db_col_sql.clone())); order_by_fields.push(quote! { #[graphql(name = #graphql_name)] @@ -3863,6 +4127,34 @@ fn generate_entity_impl( from_row_fields.push(row_assignment); } + for order_expression in &entity_meta.order_expressions { + if !order_by_graphql_names.insert(order_expression.name.clone()) { + return Err(syn::Error::new( + struct_name.span(), + format!( + "duplicate generated ordering field `{}`", + order_expression.name + ), + )); + } + let graphql_name = &order_expression.name; + let order_field_name = rust_ident_from_graphql_name(graphql_name, struct_name.span()); + if !order_by_rust_names.insert(order_field_name.to_string()) { + return Err(syn::Error::new( + struct_name.span(), + format!( + "ordering field `{graphql_name}` conflicts with another generated Rust order-input field" + ), + )); + } + let expression = format!("({})", order_expression.expression); + sortable_columns.push((order_field_name.clone(), expression)); + order_by_fields.push(quote! { + #[graphql(name = #graphql_name)] + pub #order_field_name: Option<::graphql_orm::graphql::orm::OrderDirection>, + }); + } + let default_primary_key = if backend == BackendKind::Mssql { backend_quote_identifier_path(backend, "id") } else { @@ -4234,6 +4526,7 @@ fn generate_entity_impl( fn to_sql_order(&self) -> Option { let mut parts: Vec = Vec::new(); #(#order_by_match_arms)* + #(#relation_order_match_arms)* if parts.is_empty() { None } else { diff --git a/crates/graphql-orm-macros/src/lib.rs b/crates/graphql-orm-macros/src/lib.rs index 97c889c6..f4f91b21 100644 --- a/crates/graphql-orm-macros/src/lib.rs +++ b/crates/graphql-orm-macros/src/lib.rs @@ -467,6 +467,68 @@ pub fn derive_graphql_relations(input: TokenStream) -> TokenStream { } } +#[proc_macro_attribute] +/// Compose handwritten complex fields with resolvers emitted by `GraphQLRelations`. +/// +/// Apply this to the handwritten inherent `impl` in place of +/// `async_graphql::ComplexObject`, and mark the entity with +/// `#[graphql_orm(compose_complex_object)]`. +pub fn graphql_complex_object(args: TokenStream, input: TokenStream) -> TokenStream { + let args = proc_macro2::TokenStream::from(args); + let mut item_impl = parse_macro_input!(input as syn::ItemImpl); + if item_impl.trait_.is_some() { + return syn::Error::new_spanned( + &item_impl, + "graphql_complex_object requires an inherent impl", + ) + .to_compile_error() + .into(); + } + if !item_impl.generics.params.is_empty() { + return syn::Error::new_spanned( + &item_impl.generics, + "graphql_complex_object does not support generic entity impls", + ) + .to_compile_error() + .into(); + } + if item_impl.items.iter().any(|item| { + matches!(item, syn::ImplItem::Fn(method) if method.sig.ident == "__graphql_orm_generated_relations") + }) { + return syn::Error::new_spanned( + &item_impl, + "graphql_complex_object reserves __graphql_orm_generated_relations", + ) + .to_compile_error() + .into(); + } + + let self_ty = item_impl.self_ty.clone(); + item_impl.items.push(syn::parse_quote! { + #[graphql(flatten)] + async fn __graphql_orm_generated_relations( + &self, + ) -> <#self_ty as ::graphql_orm::graphql::orm::GeneratedRelationsObject>::Object<'_> { + <#self_ty as ::graphql_orm::graphql::orm::GeneratedRelationsObject> + ::generated_relations_object(self) + } + }); + + let attribute = if args.is_empty() { + quote::quote! { #[::graphql_orm::async_graphql::ComplexObject] } + } else { + quote::quote! { #[::graphql_orm::async_graphql::ComplexObject(#args)] } + }; + quote::quote! { + #[allow(unused_imports)] + use ::graphql_orm::async_graphql::OutputType as _; + + #attribute + #item_impl + } + .into() +} + #[proc_macro_derive( GraphQLOperations, attributes( diff --git a/crates/graphql-orm-macros/src/relations.rs b/crates/graphql-orm-macros/src/relations.rs index 278518de..f779fd44 100644 --- a/crates/graphql-orm-macros/src/relations.rs +++ b/crates/graphql-orm-macros/src/relations.rs @@ -214,6 +214,7 @@ pub(crate) fn generate_graphql_relations( input: &DeriveInput, ) -> syn::Result { let struct_name = &input.ident; + let entity_visibility = &input.vis; let entity_meta = parse_entity_metadata(&input.attrs)?; let resolver_auth_mode = resolver_auth_mode_tokens(entity_meta.auth.as_deref(), struct_name.span())?; @@ -1292,7 +1293,38 @@ pub(crate) fn generate_graphql_relations( let has_relations = !relations.is_empty(); let complex_object_impl = if has_relations { - if legacy_graphql_complex { + if legacy_graphql_complex && entity_meta.compose_complex_object { + let relation_object = syn::Ident::new( + &format!("{struct_name}GeneratedRelations"), + struct_name.span(), + ); + let relation_object_name = relation_object.to_string(); + quote! { + #[doc(hidden)] + #entity_visibility struct #relation_object<'a>(&'a #struct_name); + + impl<'a> ::std::ops::Deref for #relation_object<'a> { + type Target = #struct_name; + + fn deref(&self) -> &Self::Target { + self.0 + } + } + + #[::graphql_orm::async_graphql::Object(name = #relation_object_name)] + impl<'a> #relation_object<'a> { + #(#relation_resolvers)* + } + + impl ::graphql_orm::graphql::orm::GeneratedRelationsObject for #struct_name { + type Object<'a> = #relation_object<'a> where Self: 'a; + + fn generated_relations_object(&self) -> Self::Object<'_> { + #relation_object(self) + } + } + } + } else if legacy_graphql_complex { quote! { #[::graphql_orm::async_graphql::ComplexObject] impl #struct_name { diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 077554ee..a6ec766d 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.27.0" +version = "0.28.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.27.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.28.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 4763253a..2f58a21f 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -3,7 +3,7 @@ title: "graphql-orm" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.27.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.28.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -50,6 +50,13 @@ are independent groups; enable at most one feature in each group. In a multi-backend dependency graph, select `backend = "sqlite" | "postgres" | "mssql"` on each entity and in `schema_roots!`. +Companion capabilities remain separate packages rather than core features. +Depending on `graphql-orm` does not compile or link `graphql-orm-ai`, +`graphql-orm-storage`, `graphql-orm-backup`, or `graphql-orm-router`; add only +the companion crates an application uses. For the core crate itself, set +`default-features = false` and enable one database backend plus only the +optional bridges needed by the application. + Generated update, delete, predicate-write, and upsert helpers keep the authoritative preimage, row/field policy, input transformation, hooks, and DML on one pinned transaction. Predicate writes materialize the authorized primary @@ -124,6 +131,10 @@ README remains project-neutral. compile-time source or target discriminator conditions. Every generated 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. ## Errors and security boundaries diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 09104b20..94b451c6 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -200,6 +200,18 @@ pub trait DatabaseOrderBy { } } +/// Internal bridge used by the generated complex-object composition macro. +/// +/// This is public only because macro expansion occurs in the consuming crate. +#[doc(hidden)] +pub trait GeneratedRelationsObject { + type Object<'a>: crate::async_graphql::ObjectType + where + Self: 'a; + + fn generated_relations_object(&self) -> Self::Object<'_>; +} + /// Backend-neutral, policy-aware query builder for macro-generated repository entities. /// /// Unlike the legacy pool-bound compatibility builders, this type is bound to a diff --git a/crates/graphql-orm/src/prelude.rs b/crates/graphql-orm/src/prelude.rs index 40ad195e..52b74577 100644 --- a/crates/graphql-orm/src/prelude.rs +++ b/crates/graphql-orm/src/prelude.rs @@ -25,6 +25,6 @@ pub use crate::graphql::structural_auth::{ }; pub use crate::{ GraphQLEntity, GraphQLOperations, GraphQLRelations, GraphQLSchemaEntity, GraphQLSemanticObject, - RepositoryEntity, backend_selected_graphql_entity, graphql_orm_custom_operations, - mutation_result, schema_roots, + RepositoryEntity, backend_selected_graphql_entity, graphql_complex_object, + graphql_orm_custom_operations, mutation_result, schema_roots, }; diff --git a/crates/graphql-orm/tests/computed_order_and_complex_relations.rs b/crates/graphql-orm/tests/computed_order_and_complex_relations.rs new file mode 100644 index 00000000..dc8df8c3 --- /dev/null +++ b/crates/graphql-orm/tests/computed_order_and_complex_relations.rs @@ -0,0 +1,225 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::async_graphql::SimpleObject; +use graphql_orm::prelude::*; +use graphql_orm::sqlx::Row; + +#[derive( + GraphQLEntity, + GraphQLRelations, + GraphQLOperations, + SimpleObject, + Clone, + Debug, + serde::Serialize, + serde::Deserialize, +)] +#[graphql(complex)] +#[graphql_entity( + backend = "sqlite", + table = "composed_parents", + plural = "ComposedParents", + default_sort = "id ASC", + auth = "none" +)] +#[graphql_orm( + compose_complex_object, + order_expression(name = "Duration", expression = "finished_at - started_at") +)] +pub struct ComposedParent { + #[primary_key] + #[sortable] + id: String, + started_at: i64, + finished_at: i64, + #[graphql(skip)] + #[relation( + target = "ComposedChild", + from = "id", + to = "parent_id", + multiple, + emit_fk = false, + order_aggregate(name = "ChildCount", aggregate = "count") + )] + children: Vec, +} + +#[graphql_complex_object] +impl ComposedParent { + #[graphql(name = "Duration")] + async fn duration(&self) -> i64 { + self.finished_at - self.started_at + } +} + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + backend = "sqlite", + table = "composed_children", + plural = "ComposedChildren", + default_sort = "id ASC", + auth = "none" +)] +pub struct ComposedChild { + #[primary_key] + #[sortable] + id: String, + #[filterable(type = "string")] + parent_id: String, +} + +impl BatchLoadEntity for ComposedChild { + fn batch_column() -> &'static str { + "parent_id" + } + + fn batch_key_from_row(row: &graphql_orm::DbRow) -> Result { + row.try_get("parent_id") + } +} + +schema_roots! { + backend: "sqlite", + schema_policy: "managed", + auth: "none", + query_custom_ops: [], + entities: [ComposedParent, ComposedChild], +} + +#[tokio::test] +async fn fixed_expression_ordering_executes_without_client_supplied_sql() -> 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)", + ) + .execute(database.pool()) + .await?; + graphql_orm::sqlx::query( + "INSERT INTO composed_parents (id, started_at, finished_at) VALUES ('short', 10, 15), ('long', 10, 30)", + ) + .execute(database.pool()) + .await?; + + let order = ComposedParentOrderByInput { + duration: Some(OrderDirection::Desc), + ..Default::default() + }; + 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"] + ); + + let schema = schema_builder(database).finish(); + let response = schema + .execute( + "query { + composedParents(orderBy: [{ Duration: DESC }]) { + edges { node { id Duration } } + } + }", + ) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("GraphQL response JSON"); + 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)); + Ok(()) +} + +#[tokio::test] +async fn generated_relations_flatten_into_handwritten_complex_objects() -> graphql_orm::Result<()> { + let database = Database::::connect_sqlite("sqlite::memory:").await?; + let sdl = schema_builder(database).finish().sdl(); + + assert!(sdl.contains("Duration: Int!")); + assert!(sdl.contains("children(")); + assert!(sdl.contains("Duration: OrderDirection")); + assert!(sdl.contains("ChildCount: OrderDirection")); + assert!(!sdl.contains("type ComposedParentGeneratedRelations")); + Ok(()) +} + +#[tokio::test] +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)", + ) + .execute(database.pool()) + .await?; + graphql_orm::sqlx::query( + "CREATE TABLE composed_children (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL)", + ) + .execute(database.pool()) + .await?; + graphql_orm::sqlx::query( + "INSERT INTO composed_parents (id, started_at, finished_at) VALUES ('none', 0, 0), ('one', 0, 0), ('two', 0, 0)", + ) + .execute(database.pool()) + .await?; + graphql_orm::sqlx::query( + "INSERT INTO composed_children (id, parent_id) VALUES ('c1', 'one'), ('c2', 'two'), ('c3', 'two')", + ) + .execute(database.pool()) + .await?; + + let order = ComposedParentOrderByInput { + child_count: Some(OrderDirection::Desc), + ..Default::default() + }; + assert_eq!( + order.to_sql_order().as_deref(), + Some( + "(SELECT COUNT(*) FROM composed_children AS __graphql_orm_order_relation WHERE __graphql_orm_order_relation.parent_id = composed_parents.id) DESC" + ) + ); + + let loaded = EntityQuery::::new() + .order_by(&order) + .fetch_all(&database) + .await?; + assert_eq!( + loaded + .iter() + .map(|parent| parent.id.as_str()) + .collect::>(), + vec!["two", "one", "none"] + ); + + let schema = schema_builder(database).finish(); + let response = schema + .execute( + "query { + composedParents(orderBy: [{ ChildCount: DESC }]) { + edges { node { id } } + } + }", + ) + .await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().expect("GraphQL response JSON"); + let edges = data["composedParents"]["edges"] + .as_array() + .expect("relation-count edges"); + assert_eq!(edges[0]["node"]["id"].as_str(), Some("two")); + assert_eq!(edges[1]["node"]["id"].as_str(), Some("one")); + assert_eq!(edges[2]["node"]["id"].as_str(), Some("none")); + Ok(()) +} diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 13a9a935..07806537 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.26.0" +version = "0.28.0" dependencies = [ "agql-auth", "async-graphql", @@ -1329,7 +1329,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.95.0" +version = "0.95.10" dependencies = [ "agql-auth", "async-graphql", @@ -1357,7 +1357,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai-tool-profiles" -version = "0.10.0" +version = "0.10.2" dependencies = [ "async-graphql", "async-graphql-parser", @@ -1372,7 +1372,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.26.0" +version = "0.28.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/docs/reference/graphql-orm/entities-and-relations.md b/docs/reference/graphql-orm/entities-and-relations.md index 7acc557a..1e89b7e9 100644 --- a/docs/reference/graphql-orm/entities-and-relations.md +++ b/docs/reference/graphql-orm/entities-and-relations.md @@ -3,7 +3,7 @@ title: "Entities And Relations" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -255,6 +255,13 @@ pub struct Post { } ``` +If the entity also defines handwritten `ComplexObject` fields, add +`#[graphql_orm(compose_complex_object)]` to the entity and replace the +handwritten impl's `#[ComplexObject]` attribute with +`#[graphql_complex_object]`. The wrapper flattens the generated relation object +into the same complex field surface, preserving generated batching and +filter/order/page arguments without a second conflicting trait impl. + Single-column relation syntax remains: ```rust @@ -263,6 +270,27 @@ Single-column relation syntax remains: pub posts: Vec, ``` +To make a related-row count server-sortable without adding a projection, +declare an optional count order on a readable relation: + +```rust +#[graphql(skip)] +#[relation( + target = "StaffAssignment", + from = "id", + to = "policy_id", + multiple, + order_aggregate(name = "AssignedStaffCount", aggregate = "count") +)] +pub staff_assignments: Vec, +``` + +This adds `AssignedStaffCount: OrderDirection` to the parent order input. The +generated SQL is a correlated `COUNT(*)` using the relation key mapping and +the target entity's table; neither SQL nor identifiers are accepted from the +request. Index the target key columns used by frequently sorted relations. +Only `count` is currently accepted, and conditional relations cannot opt in. + Related fields can be copied into the parent search document: ```rust diff --git a/docs/reference/graphql-orm/macros-and-attributes.md b/docs/reference/graphql-orm/macros-and-attributes.md index 5f617c28..5e9d243f 100644 --- a/docs/reference/graphql-orm/macros-and-attributes.md +++ b/docs/reference/graphql-orm/macros-and-attributes.md @@ -3,7 +3,7 @@ title: GraphQL ORM macro and attribute reference kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-26 +last_reviewed: 2026-08-31 review_by: 2027-02-01 supersedes: [] --- @@ -24,6 +24,7 @@ does not. | `GraphQLSchemaEntity` | schema/entity metadata for validation and planning | you need GraphQL or repository operations | | `RepositoryEntity` | typed repository CRUD, filters, ordering, projections, and Rust write inputs | you need generated `async-graphql` types or resolvers | | `GraphQLRelations` | relation resolver/loading implementations | the struct has no relation fields | +| `graphql_complex_object` | handwritten `ComplexObject` fields composed with generated relation fields | the entity does not opt into complex-object composition | | `GraphQLOperations` | generated query, mutation, and subscription operation types plus discovery descriptors | the entity uses `#[repository_entity(...)]` | | `schema_roots!` | `QueryRoot`, `MutationRoot`, `SubscriptionRoot`, `AppSchema`, schema builders, schema metadata helpers, and operation/semantic catalogs | no generated operation types participate in the schema | | `graphql_orm_custom_operations` | canonical semantic metadata beside one handwritten `async-graphql` root impl | the impl is not actually composed into the finished schema | @@ -75,6 +76,8 @@ 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 | +| `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 | | `operation_authorization` | described below | consumed by `GraphQLOperations` | @@ -120,6 +123,21 @@ Validation metadata accepted inside `graphql_orm` is `min`, `max`, `max_length`, `one_of = ["…"]`, and `gte_field`, `gt_field`, `lte_field`, `lt_field` (field-name strings). +Entity-level expression ordering is intended for computed fields whose SQL is +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" +))] +``` + +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. + ### Relations and foreign keys Use a relation field plus `GraphQLRelations`: @@ -142,6 +160,52 @@ changes the relation cardinality. `emit_fk` is a boolean; `on_delete` and `propagate_change` are strings validated for the selected backend/policy. Relations are not ordinary persisted fields. +An unconditional readable relation can add a correlated count to its parent +entity's generated order input: + +```rust,ignore +#[relation( + target = "StaffAssignment", + from = "id", + to = "policy_id", + multiple, + order_aggregate(name = "AssignedStaffCount", aggregate = "count") +)] +pub staff_assignments: Vec; +``` + +`name` is the GraphQL order-input field and `aggregate` currently accepts only +`"count"`. The request supplies only `OrderDirection`; the macro obtains the +target table through `DatabaseEntity` and constructs the correlated aggregate +from `from`/`to`. The relation must remain public and readable. Combining +`order_aggregate` with `source_condition` or `target_condition` is rejected so +conditional predicates continue to use their bound-value resolver path. + +When an entity already has handwritten complex fields, opt into one composed +`ComplexObject` implementation instead of applying async-graphql's attribute +directly: + +```rust,ignore +#[derive(GraphQLEntity, GraphQLRelations, SimpleObject, Clone)] +#[graphql(complex)] +#[graphql_orm(compose_complex_object)] +struct Job { + // persisted and relation fields +} + +#[graphql_complex_object] +impl Job { + async fn duration(&self) -> i64 { + self.finished_at - self.started_at + } +} +``` + +`graphql_complex_object` delegates schema and resolution for generated +relations through an internal flattened object, so handwritten fields and +batched relation resolvers share the one `async_graphql::ComplexObject` trait +implementation required by `SimpleObject`. + Polymorphic references can add one fixed discriminator condition: ```rust,ignore diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index b95893b4..b93a79ec 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.27.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.28.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.27.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.28.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 |