From b644452a66b0ce4ab8d9f4aa9d4808d37244e038 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 03:07:29 +1100 Subject: [PATCH 1/8] Select generated query shapes for deliberate legacy coexistence --- CHANGELOG.md | 4 +- Cargo.lock | 4 +- Cargo.toml | 2 +- MIGRATION.md | 4 + crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 4 + crates/graphql-orm-macros/src/entity.rs | 21 +++++ crates/graphql-orm-macros/src/operations.rs | 80 +++++++++++++++++-- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 4 + .../tests/query_shape_selection.rs | 59 ++++++++++++++ .../graphql-orm/macros-and-attributes.md | 10 +++ docs/reference/workspace-packages.md | 4 +- 13 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 crates/graphql-orm/tests/query_shape_selection.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e130ae7..75e79c21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ for historical context. ## Unreleased -Companion runtime/macros version: **0.59.0**. +Companion runtime/macros version: **0.60.0**. + +- Add explicit `query_shapes = "list,keyset,scan"` selection on generated entities. Omitted query shapes disappear from both the GraphQL root and operation catalogue, allowing a host to retain a differently shaped legacy singleton during migration. Default generation remains unchanged; repository APIs, relations, mutations and subscriptions are unaffected. - Extend explicit `timestamp = "host"` to public GraphQLEntity fields and ordinary String timestamps (including optional forms). Caller-supplied source timestamps stay unchanged instead of receiving the ORM mutation clock. Existing automatic timestamp behavior remains the default. Policies, hooks and field restrictions still apply. diff --git a/Cargo.lock b/Cargo.lock index bf0e13a0..c5ab0e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 0cc96316..350601f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.59.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.60.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.1" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.1" } diff --git a/MIGRATION.md b/MIGRATION.md index 7ace34b1..668202ce 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,6 +15,10 @@ workspace release tag for every chosen workspace package and record its full commit from the release manifest. Neither the runtime nor macros crate is published to crates.io. +## 0.59.0 to 0.60.0: generated query-shape selection + +Upgrade runtime/macros together. `#[graphql_entity(query_shapes = "list,keyset,scan")]` exposes only those generated query shapes. Supported selectors are `single`, `list`, `keyset`, `scan`, `search` and `aggregate`. The list must be nonempty and contain no duplicates; selected optional capabilities must already be configured. An omitted selector retains all existing generated queries. Disabled shapes are absent from both SDL and operation/semantic metadata; repository methods and entity relations remain available. Mutation and subscription exposure stay independent. This permits a recorded legacy root to coexist while its generated same-name query is deliberately omitted; it grants no authorization and does not make the legacy root an accepted permanent exception. No data or schema migration is required. + ## 0.57.0 to 0.58.0: public host-owned timestamps Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index d284b97e..296004aa 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.59.0" +version = "0.60.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 ce6965f4..28eff60e 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -10,6 +10,10 @@ supersedes: [] # `graphql-orm-macros` +Version 0.60.0 adds explicit generated query-shape selection for controlled +coexistence with recorded legacy roots. Defaults are unchanged; see the +[migration guide](../../MIGRATION.md#0590-to-0600-generated-query-shape-selection). + Version 0.58.0 extends explicit host-owned timestamps to public entities and String values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index 59f6488a..5a93a1d8 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -27,6 +27,7 @@ pub(crate) struct EntityMetadata { /// Explicit AI execution policy by generated mutation category. pub(crate) ai_mutations: std::collections::BTreeMap, pub(crate) keyset: Option, + pub(crate) query_shapes: Option>, pub(crate) backup_enabled: Option, pub(crate) backup_export_order: Option, pub(crate) backup_restore_order: Option, @@ -269,6 +270,26 @@ pub(crate) fn parse_entity_metadata(attrs: &[syn::Attribute]) -> syn::Result, @@ -4628,7 +4640,7 @@ pub(crate) fn generate_graphql_operations( } /// Bounded forward authorization scan; continuation does not promise another visible row. - #[graphql(name = #scan_query_name, #keyset_list_requires_scopes #authenticated_directive)] + #[graphql(#scan_query_skip name = #scan_query_name, #keyset_list_requires_scopes #authenticated_directive)] async fn authorized_scan( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -4690,7 +4702,7 @@ pub(crate) fn generate_graphql_operations( let search_query_method = if has_search { quote! { #[doc = #search_operation_description] - #[graphql(name = #search_query_name, #search_requires_scopes #authenticated_directive)] + #[graphql(#search_query_skip name = #search_query_name, #search_requires_scopes #authenticated_directive)] async fn search( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -6226,7 +6238,7 @@ pub(crate) fn generate_graphql_operations( let aggregate_query_method = if entity_meta.aggregate { quote! { #[doc = #aggregate_operation_description] - #[graphql(name = #aggregate_query_name, #aggregate_requires_scopes #authenticated_directive)] + #[graphql(#aggregate_query_skip name = #aggregate_query_name, #aggregate_requires_scopes #authenticated_directive)] async fn aggregate( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -6606,6 +6618,33 @@ pub(crate) fn generate_graphql_operations( &federation_keys, &federation_scope_enforcements, ); + if let Some(shapes) = &entity_meta.query_shapes { + for shape in shapes { + let available = match shape.as_str() { + "keyset" | "scan" => keyset_parts.is_some(), + "search" => has_search, + "aggregate" => entity_meta.aggregate, + _ => true, + }; + if !available { + return Err(syn::Error::new_spanned(struct_name, format!("query_shapes selects unavailable {shape} capability"))); + } + } + } + let disabled_query_fields = [ + ("single", single_query_name.as_str()), + ("list", list_query_name.as_str()), + ("keyset", keyset_query_name.as_str()), + ("scan", scan_query_name.as_str()), + ("search", search_query_name.as_str()), + ("aggregate", aggregate_query_name.as_str()), + ].into_iter().filter(|(shape, _)| !query_shape_enabled(shape)).map(|(_, name)| name).collect::>(); + let query_shape_metadata_filter = if disabled_query_fields.is_empty() { quote! {} } else { + quote! { + operations.retain(|operation| !(operation.kind() == ::graphql_orm::graphql::orm::GraphqlOperationKind::Query + && [#(#disabled_query_fields),*].contains(&operation.field_name()))); + } + }; let operation_metadata_impl = quote! { impl ::graphql_orm::graphql::orm::GraphqlOperationMetadata for #struct_name { fn generated_graphql_operations( @@ -6617,6 +6656,7 @@ pub(crate) fn generate_graphql_operations( let mut operations = vec![ #(#operation_descriptors),* ]; + #query_shape_metadata_filter operations.sort_by(|left, right| { ( left.kind(), @@ -6698,7 +6738,7 @@ pub(crate) fn generate_graphql_operations( #aggregate_query_method #[doc = #list_operation_description] - #[graphql(name = #list_query_name, #list_requires_scopes #authenticated_directive)] + #[graphql(#list_query_skip name = #list_query_name, #list_requires_scopes #authenticated_directive)] async fn list( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -6802,7 +6842,7 @@ pub(crate) fn generate_graphql_operations( #keyset_graphql_method #[doc = #single_operation_description] - #[graphql(name = #single_query_name, #single_read_requires_scopes #authenticated_directive)] + #[graphql(#single_query_skip name = #single_query_name, #single_read_requires_scopes #authenticated_directive)] async fn get_by_id( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -7073,7 +7113,7 @@ pub(crate) fn generate_graphql_operations( /// Get a list of #plural_name with optional filtering, sorting, and pagination #[doc = #list_operation_description] - #[graphql(name = #list_query_name, #list_requires_scopes #authenticated_directive)] + #[graphql(#list_query_skip name = #list_query_name, #list_requires_scopes #authenticated_directive)] async fn list( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -7177,7 +7217,7 @@ pub(crate) fn generate_graphql_operations( /// Get a single #struct_name_str by ID #[doc = #single_operation_description] - #[graphql(name = #single_query_name, #single_read_requires_scopes #authenticated_directive)] + #[graphql(#single_query_skip name = #single_query_name, #single_read_requires_scopes #authenticated_directive)] async fn get_by_id( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -9409,3 +9449,27 @@ mod composite_keyset_tests { ); } } + +#[cfg(test)] +mod query_shape_tests { + use super::generate_graphql_operations; + + fn entity(shapes: &str) -> syn::DeriveInput { + let shapes = syn::LitStr::new(shapes, proc_macro2::Span::call_site()); + syn::parse_quote! { + #[graphql_entity(table = "query_shape_fixture", query_shapes = #shapes)] + struct QueryShapeFixture { + #[primary_key] + id: String, + } + } + } + + #[test] + fn malformed_or_unavailable_query_selections_fail_closed() { + assert!(generate_graphql_operations(&entity("list")).is_ok()); + for shapes in ["", "unknown", "single,single", "keyset", "scan", "search", "aggregate"] { + assert!(generate_graphql_operations(&entity(shapes)).is_err(), "{shapes}"); + } + } +} diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index e586774d..0b202b44 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -73,7 +73,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.59.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.60.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 14d1ebe8..a3937d52 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -10,6 +10,10 @@ supersedes: [] # `graphql-orm` +Version 0.60.0 adds explicit generated query-shape selection for controlled +coexistence with recorded legacy roots. Defaults are unchanged; see the +[migration guide](../../MIGRATION.md#0590-to-0600-generated-query-shape-selection). + Version 0.58.0 extends explicit host-owned timestamps to public entities and String values without replacing source timestamps with the mutation clock. See the [migration guide](../../MIGRATION.md#0570-to-0580-public-host-owned-timestamps). diff --git a/crates/graphql-orm/tests/query_shape_selection.rs b/crates/graphql-orm/tests/query_shape_selection.rs new file mode 100644 index 00000000..26348b83 --- /dev/null +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -0,0 +1,59 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; +use graphql_orm::graphql::orm::{GraphqlOperationKind, GraphqlOperationMetadata}; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity(table = "shape_records", plural = "ShapeRecords", query_shapes = "list")] +struct ShapeRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + title: String, +} + +#[derive(Default)] +struct LegacyQueries; + +#[graphql_orm_custom_operations(kind = "query", authorization = true)] +#[graphql_orm::async_graphql::Object] +impl LegacyQueries { + /// Synthetic legacy shape whose argument differs from the generated singleton. + async fn shape_record(&self, legacy_value: String) -> String { + format!("legacy:{legacy_value}") + } +} + +schema_roots! { + query_custom_ops: [], + entities: [ShapeRecord], + described_query_types: [LegacyQueries], +} + +#[tokio::test] +async fn selected_generated_list_coexists_with_a_differently_shaped_legacy_singleton() { + let database = Database::::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE shape_records (id TEXT PRIMARY KEY, title TEXT NOT NULL)").execute(database.pool()).await.unwrap(); + sqlx::query("INSERT INTO shape_records (id, title) VALUES ('one', 'First')").execute(database.pool()).await.unwrap(); + let schema = schema_builder(database).data("test-user".to_string()).finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("shapeRecord(legacyValue: String!): String!")); + assert!(!sdl.contains("shapeRecord(id:")); + assert!(sdl.contains("shapeRecords(")); + assert!(sdl.contains("shapeRecordChanged(")); + let response = schema.execute("{ shapeRecord(legacyValue: \"old\") shapeRecords { edges { node { id title } } } }").await; + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + assert_eq!(data["shapeRecord"], "legacy:old"); + assert_eq!(data["shapeRecords"]["edges"][0]["node"]["id"], "one"); +} + +#[test] +fn generated_catalogue_contains_only_selected_query_shapes() { + let operations = ShapeRecord::generated_graphql_operations(); + let queries = operations.iter().filter(|operation| operation.kind() == GraphqlOperationKind::Query).map(|operation| operation.field_name()).collect::>(); + assert_eq!(queries, ["shapeRecords"]); + assert!(operations.iter().any(|operation| operation.kind() == GraphqlOperationKind::Subscription)); + assert!(operations.iter().any(|operation| operation.kind() == GraphqlOperationKind::Mutation)); + assert!(graphql_orm_operation_catalog().exposed_operations().any(|operation| operation.field_name() == "shapeRecord")); +} diff --git a/docs/reference/graphql-orm/macros-and-attributes.md b/docs/reference/graphql-orm/macros-and-attributes.md index f693d17a..e906c2be 100644 --- a/docs/reference/graphql-orm/macros-and-attributes.md +++ b/docs/reference/graphql-orm/macros-and-attributes.md @@ -554,6 +554,16 @@ Enable at most one in each group: - [Federation entities, keys, and operation roots](federation.md) - [Macro crate README](../../../crates/graphql-orm-macros/README.md) +## Generated query shapes + +`#[graphql_entity(query_shapes = "list,keyset,scan")]` selects generated query +shapes for the entity. The available selectors are `single`, `list`, `keyset`, +`scan`, `search`, and `aggregate`; optional capabilities must be configured. +Omission preserves all current generated queries. Disabled shapes are absent +from both SDL and operation metadata. This selection does not alter repository +APIs, relations, mutations, subscriptions or authorization. See the +[migration guide](../../../MIGRATION.md#0590-to-0600-generated-query-shape-selection). + ## Host-owned timestamps `#[graphql_orm(timestamp = "host")]` accepts ordinary `i64`, `String` and optional diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index e88bd6eb..c78000ac 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.59.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.60.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.1` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.59.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.60.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.1` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.6.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | From cec1a25ca6ea448f17a8f4aa92d4a397a31778b9 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 03:23:50 +1100 Subject: [PATCH 2/8] Format query shape selection with Rust 1.97.1 --- crates/graphql-orm-macros/src/operations.rs | 46 ++++++++++++--- .../tests/query_shape_selection.rs | 56 +++++++++++++++---- 2 files changed, 83 insertions(+), 19 deletions(-) diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 09e6d5fe..a314b39c 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1248,9 +1248,19 @@ pub(crate) fn generate_graphql_operations( let struct_name_str = struct_name.to_string(); let entity_meta = parse_entity_metadata(&input.attrs)?; let operation_authorizations = parse_operation_authorizations(&input.attrs)?; - let query_shape_enabled = |shape: &str| entity_meta.query_shapes.as_ref() - .is_none_or(|shapes| shapes.iter().any(|selected| selected == shape)); - let query_shape_skip = |shape: &str| if query_shape_enabled(shape) { quote! {} } else { quote! { skip, } }; + let query_shape_enabled = |shape: &str| { + entity_meta + .query_shapes + .as_ref() + .is_none_or(|shapes| shapes.iter().any(|selected| selected == shape)) + }; + let query_shape_skip = |shape: &str| { + if query_shape_enabled(shape) { + quote! {} + } else { + quote! { skip, } + } + }; let single_query_skip = query_shape_skip("single"); let list_query_skip = query_shape_skip("list"); let keyset_query_skip = query_shape_skip("keyset"); @@ -6627,7 +6637,10 @@ pub(crate) fn generate_graphql_operations( _ => true, }; if !available { - return Err(syn::Error::new_spanned(struct_name, format!("query_shapes selects unavailable {shape} capability"))); + return Err(syn::Error::new_spanned( + struct_name, + format!("query_shapes selects unavailable {shape} capability"), + )); } } } @@ -6638,8 +6651,14 @@ pub(crate) fn generate_graphql_operations( ("scan", scan_query_name.as_str()), ("search", search_query_name.as_str()), ("aggregate", aggregate_query_name.as_str()), - ].into_iter().filter(|(shape, _)| !query_shape_enabled(shape)).map(|(_, name)| name).collect::>(); - let query_shape_metadata_filter = if disabled_query_fields.is_empty() { quote! {} } else { + ] + .into_iter() + .filter(|(shape, _)| !query_shape_enabled(shape)) + .map(|(_, name)| name) + .collect::>(); + let query_shape_metadata_filter = if disabled_query_fields.is_empty() { + quote! {} + } else { quote! { operations.retain(|operation| !(operation.kind() == ::graphql_orm::graphql::orm::GraphqlOperationKind::Query && [#(#disabled_query_fields),*].contains(&operation.field_name()))); @@ -9468,8 +9487,19 @@ mod query_shape_tests { #[test] fn malformed_or_unavailable_query_selections_fail_closed() { assert!(generate_graphql_operations(&entity("list")).is_ok()); - for shapes in ["", "unknown", "single,single", "keyset", "scan", "search", "aggregate"] { - assert!(generate_graphql_operations(&entity(shapes)).is_err(), "{shapes}"); + for shapes in [ + "", + "unknown", + "single,single", + "keyset", + "scan", + "search", + "aggregate", + ] { + assert!( + generate_graphql_operations(&entity(shapes)).is_err(), + "{shapes}" + ); } } } diff --git a/crates/graphql-orm/tests/query_shape_selection.rs b/crates/graphql-orm/tests/query_shape_selection.rs index 26348b83..ce66c810 100644 --- a/crates/graphql-orm/tests/query_shape_selection.rs +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -1,10 +1,14 @@ #![cfg(feature = "sqlite")] -use graphql_orm::prelude::*; use graphql_orm::graphql::orm::{GraphqlOperationKind, GraphqlOperationMetadata}; +use graphql_orm::prelude::*; #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] -#[graphql_entity(table = "shape_records", plural = "ShapeRecords", query_shapes = "list")] +#[graphql_entity( + table = "shape_records", + plural = "ShapeRecords", + query_shapes = "list" +)] struct ShapeRecord { #[primary_key] #[graphql_orm(auto_generated = false)] @@ -32,16 +36,30 @@ schema_roots! { #[tokio::test] async fn selected_generated_list_coexists_with_a_differently_shaped_legacy_singleton() { - let database = Database::::connect_sqlite("sqlite::memory:").await.unwrap(); - sqlx::query("CREATE TABLE shape_records (id TEXT PRIMARY KEY, title TEXT NOT NULL)").execute(database.pool()).await.unwrap(); - sqlx::query("INSERT INTO shape_records (id, title) VALUES ('one', 'First')").execute(database.pool()).await.unwrap(); - let schema = schema_builder(database).data("test-user".to_string()).finish(); + let database = Database::::connect_sqlite("sqlite::memory:") + .await + .unwrap(); + sqlx::query("CREATE TABLE shape_records (id TEXT PRIMARY KEY, title TEXT NOT NULL)") + .execute(database.pool()) + .await + .unwrap(); + sqlx::query("INSERT INTO shape_records (id, title) VALUES ('one', 'First')") + .execute(database.pool()) + .await + .unwrap(); + let schema = schema_builder(database) + .data("test-user".to_string()) + .finish(); let sdl = schema.sdl(); assert!(sdl.contains("shapeRecord(legacyValue: String!): String!")); assert!(!sdl.contains("shapeRecord(id:")); assert!(sdl.contains("shapeRecords(")); assert!(sdl.contains("shapeRecordChanged(")); - let response = schema.execute("{ shapeRecord(legacyValue: \"old\") shapeRecords { edges { node { id title } } } }").await; + let response = schema + .execute( + "{ shapeRecord(legacyValue: \"old\") shapeRecords { edges { node { id title } } } }", + ) + .await; assert!(response.errors.is_empty(), "{:?}", response.errors); let data = response.data.into_json().unwrap(); assert_eq!(data["shapeRecord"], "legacy:old"); @@ -51,9 +69,25 @@ async fn selected_generated_list_coexists_with_a_differently_shaped_legacy_singl #[test] fn generated_catalogue_contains_only_selected_query_shapes() { let operations = ShapeRecord::generated_graphql_operations(); - let queries = operations.iter().filter(|operation| operation.kind() == GraphqlOperationKind::Query).map(|operation| operation.field_name()).collect::>(); + let queries = operations + .iter() + .filter(|operation| operation.kind() == GraphqlOperationKind::Query) + .map(|operation| operation.field_name()) + .collect::>(); assert_eq!(queries, ["shapeRecords"]); - assert!(operations.iter().any(|operation| operation.kind() == GraphqlOperationKind::Subscription)); - assert!(operations.iter().any(|operation| operation.kind() == GraphqlOperationKind::Mutation)); - assert!(graphql_orm_operation_catalog().exposed_operations().any(|operation| operation.field_name() == "shapeRecord")); + assert!( + operations + .iter() + .any(|operation| operation.kind() == GraphqlOperationKind::Subscription) + ); + assert!( + operations + .iter() + .any(|operation| operation.kind() == GraphqlOperationKind::Mutation) + ); + assert!( + graphql_orm_operation_catalog() + .exposed_operations() + .any(|operation| operation.field_name() == "shapeRecord") + ); } From 375ad6e153c970fe52464499803d0c07d69abe27 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 05:15:31 +1100 Subject: [PATCH 3/8] fix: provide sortable public fields for generated order inputs --- crates/graphql-orm/tests/query_shape_selection.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/graphql-orm/tests/query_shape_selection.rs b/crates/graphql-orm/tests/query_shape_selection.rs index ce66c810..0ad586cb 100644 --- a/crates/graphql-orm/tests/query_shape_selection.rs +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -11,6 +11,7 @@ use graphql_orm::prelude::*; )] struct ShapeRecord { #[primary_key] + #[sortable] #[graphql_orm(auto_generated = false)] id: String, title: String, From b7be40da1f76bead6f144708d83c6618307063b2 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 12:45:53 +1100 Subject: [PATCH 4/8] fix(macros): omit disabled query methods before GraphQL expansion --- CHANGELOG.md | 3 ++ crates/graphql-orm-macros/src/operations.rs | 35 +++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e79c21..b6b37000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ Companion runtime/macros version: **0.60.0**. - Generate static keyset reads and authorized scans for entities with composite primary keys. Configured order must end with the complete primary-key tuple in declaration order, with no duplicate order fields. Every component is retained in cursor ordering; existing authorization, limits and read-only schema posture remain in force. +- Omit unselected query resolver methods entirely, including their GraphQL-only + argument attributes, so list-only generated roots compile beside legacy roots. + ## 0.57.0 (2026-10-22) - Reject private configured cursor fields despite explicit read/order surface diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index a314b39c..0a67ac12 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1243,6 +1243,41 @@ fn generated_resolver_schema_signature( pub(crate) fn generate_graphql_operations( input: &DeriveInput, +) -> syn::Result { + let output = generate_graphql_operations_unfiltered(input)?; + let metadata = parse_entity_metadata(&input.attrs)?; + let Some(shapes) = metadata.query_shapes else { + return Ok(output); + }; + // Object's skipped methods retain ordinary Rust signatures. Removing a + // disabled method also removes its GraphQL-only parameter attributes. + let queries_name = format!("{}Queries", input.ident); + let mut generated: syn::File = syn::parse2(output)?; + for item in &mut generated.items { + let syn::Item::Impl(implementation) = item else { continue; }; + let syn::Type::Path(target) = implementation.self_ty.as_ref() else { continue; }; + if !target.path.segments.last().is_some_and(|segment| segment.ident == queries_name) { + continue; + } + implementation.items.retain(|item| { + let syn::ImplItem::Fn(method) = item else { return true; }; + let shape = match method.sig.ident.to_string().as_str() { + "get_by_id" => "single", + "list" => "list", + "keyset" => "keyset", + "authorized_scan" => "scan", + "search" => "search", + "aggregate" => "aggregate", + _ => return true, + }; + shapes.iter().any(|selected| selected == shape) + }); + } + Ok(quote! { #generated }) +} + +fn generate_graphql_operations_unfiltered( + input: &DeriveInput, ) -> syn::Result { let struct_name = &input.ident; let struct_name_str = struct_name.to_string(); From b275a4795b99ace08318b30c80027be9064420ca Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 13:07:14 +1100 Subject: [PATCH 5/8] style: clarify selected-query AST traversal --- crates/graphql-orm-macros/src/operations.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 0a67ac12..643d75ac 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1254,13 +1254,24 @@ pub(crate) fn generate_graphql_operations( let queries_name = format!("{}Queries", input.ident); let mut generated: syn::File = syn::parse2(output)?; for item in &mut generated.items { - let syn::Item::Impl(implementation) = item else { continue; }; - let syn::Type::Path(target) = implementation.self_ty.as_ref() else { continue; }; - if !target.path.segments.last().is_some_and(|segment| segment.ident == queries_name) { + let syn::Item::Impl(implementation) = item else { + continue; + }; + let syn::Type::Path(target) = implementation.self_ty.as_ref() else { + continue; + }; + if !target + .path + .segments + .last() + .is_some_and(|segment| segment.ident == queries_name) + { continue; } implementation.items.retain(|item| { - let syn::ImplItem::Fn(method) = item else { return true; }; + let syn::ImplItem::Fn(method) = item else { + return true; + }; let shape = match method.sig.ident.to_string().as_str() { "get_by_id" => "single", "list" => "list", From f8545123bcb30601f191ff8f92aa012d18a4136e Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 15:40:17 +1100 Subject: [PATCH 6/8] test: inspect query shapes through canonical schema metadata --- .../tests/query_shape_selection.rs | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/crates/graphql-orm/tests/query_shape_selection.rs b/crates/graphql-orm/tests/query_shape_selection.rs index 0ad586cb..5122f9aa 100644 --- a/crates/graphql-orm/tests/query_shape_selection.rs +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -52,8 +52,22 @@ async fn selected_generated_list_coexists_with_a_differently_shaped_legacy_singl .data("test-user".to_string()) .finish(); let sdl = schema.sdl(); - assert!(sdl.contains("shapeRecord(legacyValue: String!): String!")); - assert!(!sdl.contains("shapeRecord(id:")); + let document = graphql_orm::async_graphql::parser::parse_schema(&sdl).unwrap(); + let query_name = ::type_name(); + let query_fields = document.definitions.iter().find_map(|definition| { + use graphql_orm::async_graphql::parser::types::{TypeKind, TypeSystemDefinition}; + let TypeSystemDefinition::Type(definition) = definition else { return None; }; + if definition.node.name.node.as_str() != query_name.as_ref() { return None; } + let TypeKind::Object(object) = &definition.node.kind else { return None; }; + Some(&object.fields) + }).expect("composed query root"); + let singleton = query_fields.iter().find(|field| field.node.name.node.as_str() == "shapeRecord") + .expect("legacy singleton remains composed"); + assert_eq!(singleton.node.ty.node.to_string(), "String!"); + assert_eq!(singleton.node.arguments.len(), 1); + let argument = &singleton.node.arguments[0].node; + assert_eq!(argument.name.node.as_str(), "legacyValue"); + assert_eq!(argument.ty.node.to_string(), "String!"); assert!(sdl.contains("shapeRecords(")); assert!(sdl.contains("shapeRecordChanged(")); let response = schema @@ -86,9 +100,10 @@ fn generated_catalogue_contains_only_selected_query_shapes() { .iter() .any(|operation| operation.kind() == GraphqlOperationKind::Mutation) ); - assert!( - graphql_orm_operation_catalog() - .exposed_operations() - .any(|operation| operation.field_name() == "shapeRecord") - ); + let legacy = graphql_orm_semantic_catalog().operations.iter() + .find(|operation| operation.field_name == "shapeRecord") + .expect("described legacy operation is present in the semantic catalogue"); + assert_eq!(legacy.source, graphql_orm::graphql::orm::GraphqlSemanticOperationSource::Custom); + assert_eq!(legacy.arguments.len(), 1); + assert_eq!(legacy.arguments[0].graphql_name, "legacyValue"); } From 5d255270d889fc5a6f385bda122c97926f5f0e36 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 15:41:04 +1100 Subject: [PATCH 7/8] style(tests): format query metadata assertions --- .../tests/query_shape_selection.rs | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/graphql-orm/tests/query_shape_selection.rs b/crates/graphql-orm/tests/query_shape_selection.rs index 5122f9aa..3f91b0c4 100644 --- a/crates/graphql-orm/tests/query_shape_selection.rs +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -54,14 +54,26 @@ async fn selected_generated_list_coexists_with_a_differently_shaped_legacy_singl let sdl = schema.sdl(); let document = graphql_orm::async_graphql::parser::parse_schema(&sdl).unwrap(); let query_name = ::type_name(); - let query_fields = document.definitions.iter().find_map(|definition| { - use graphql_orm::async_graphql::parser::types::{TypeKind, TypeSystemDefinition}; - let TypeSystemDefinition::Type(definition) = definition else { return None; }; - if definition.node.name.node.as_str() != query_name.as_ref() { return None; } - let TypeKind::Object(object) = &definition.node.kind else { return None; }; - Some(&object.fields) - }).expect("composed query root"); - let singleton = query_fields.iter().find(|field| field.node.name.node.as_str() == "shapeRecord") + let query_fields = document + .definitions + .iter() + .find_map(|definition| { + use graphql_orm::async_graphql::parser::types::{TypeKind, TypeSystemDefinition}; + let TypeSystemDefinition::Type(definition) = definition else { + return None; + }; + if definition.node.name.node.as_str() != query_name.as_ref() { + return None; + } + let TypeKind::Object(object) = &definition.node.kind else { + return None; + }; + Some(&object.fields) + }) + .expect("composed query root"); + let singleton = query_fields + .iter() + .find(|field| field.node.name.node.as_str() == "shapeRecord") .expect("legacy singleton remains composed"); assert_eq!(singleton.node.ty.node.to_string(), "String!"); assert_eq!(singleton.node.arguments.len(), 1); @@ -100,10 +112,15 @@ fn generated_catalogue_contains_only_selected_query_shapes() { .iter() .any(|operation| operation.kind() == GraphqlOperationKind::Mutation) ); - let legacy = graphql_orm_semantic_catalog().operations.iter() + let legacy = graphql_orm_semantic_catalog() + .operations + .iter() .find(|operation| operation.field_name == "shapeRecord") .expect("described legacy operation is present in the semantic catalogue"); - assert_eq!(legacy.source, graphql_orm::graphql::orm::GraphqlSemanticOperationSource::Custom); + assert_eq!( + legacy.source, + graphql_orm::graphql::orm::GraphqlSemanticOperationSource::Custom + ); assert_eq!(legacy.arguments.len(), 1); assert_eq!(legacy.arguments[0].graphql_name, "legacyValue"); } From 7c15a537a32b956c3828f630f4fe09cb4359bf93 Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 18:40:46 +1100 Subject: [PATCH 8/8] chore: align query shapes with merged 0.60 release identity --- MIGRATION.md | 8 ++++---- .../fixtures/cross-crate-relations/Cargo.lock | 4 ++-- .../fixtures/relationship-capability/Cargo.lock | 4 ++-- .../tests/fixtures/backend-coexistence/Cargo.lock | 4 ++-- .../tests/fixtures/federation-end-to-end/Cargo.lock | 4 ++-- .../tests/fixtures/migration-api-compatibility/Cargo.lock | 4 ++-- .../fixtures/repository-aggregate-consumer/Cargo.lock | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 668202ce..ce20030b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -19,10 +19,6 @@ published to crates.io. Upgrade runtime/macros together. `#[graphql_entity(query_shapes = "list,keyset,scan")]` exposes only those generated query shapes. Supported selectors are `single`, `list`, `keyset`, `scan`, `search` and `aggregate`. The list must be nonempty and contain no duplicates; selected optional capabilities must already be configured. An omitted selector retains all existing generated queries. Disabled shapes are absent from both SDL and operation/semantic metadata; repository methods and entity relations remain available. Mutation and subscription exposure stay independent. This permits a recorded legacy root to coexist while its generated same-name query is deliberately omitted; it grants no authorization and does not make the legacy root an accepted permanent exception. No data or schema migration is required. -## 0.57.0 to 0.58.0: public host-owned timestamps - -Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. - ## 0.58.0 to 0.59.0: composite-primary-key keysets Explicit `read = true` or `order = true` never overrides a private configured @@ -38,6 +34,10 @@ Ordering, fingerprints and cursor bytes remain unchanged for authorized reads. Upgrade runtime/macros together. Composite-key entities may now declare `keyset = "rank desc, partition_key asc, item_no asc"` when `(partition_key, item_no)` is the primary key in declaration order. Every primary-key member must appear as the final unique tiebreaker tuple; order fields may not repeat. The generated keyset and scan read surfaces use the existing bounded, authorized cursor implementation. Existing valid single-key orders/cursors are unchanged. No generated GraphQL writes or subscriptions are enabled for composite-key entities by this change. No data or schema migration is required, and an externally managed source stays externally managed. +## 0.57.0 to 0.58.0: public host-owned timestamps + +Upgrade runtime/macros together. Public GraphQLEntity and RepositoryEntity timestamp fields may explicitly use `#[graphql_orm(timestamp = "host")]` on ordinary `i64`, `String`, or optional forms whose Rust or physical name is `created_at` or `updated_at`. For an external source timestamp mapped to physical `updated_at`, opt in on the mapped field to prevent automatic epoch assignment. Values are bound unchanged; String content is caller-owned and is not parsed or normalized as a date. Existing automatic behavior remains unchanged without the annotation. The annotation does not change stored type/default metadata; conventional Rust names still need `default = false` to suppress their implicit default when appropriate. Schema-only, generated/version/key/relation fields and date/JSON/boolean adapters remain rejected. No data or schema migration is required merely to opt in; preserve field/row authorization and distinguish source observation time from collection and mutation clocks. + ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads Upgrade `graphql-orm` and `graphql-orm-macros` together to 0.57.0 using one diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index e29956ae..d2b44258 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock index 5713ab16..5c541cdf 100644 --- a/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/relationship-capability/Cargo.lock @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index ba2cf541..0c601af5 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1368,7 +1368,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index 03dcba9e..b8dca129 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index dd6f87c3..7acdd701 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 6d4572ed..a790f8dd 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1025,7 +1025,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.59.0" +version = "0.60.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.59.0" +version = "0.60.0" dependencies = [ "convert_case", "proc-macro2",