diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e130ae7..b6b37000 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. @@ -26,6 +28,9 @@ Companion runtime/macros version: **0.59.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/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..ce20030b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,9 +15,9 @@ 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.57.0 to 0.58.0: public host-owned timestamps +## 0.59.0 to 0.60.0: generated query-shape selection -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. +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.58.0 to 0.59.0: composite-primary-key keysets @@ -34,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/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/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-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 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(); 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 single_query_skip = query_shape_skip("single"); + let list_query_skip = query_shape_skip("list"); + let keyset_query_skip = query_shape_skip("keyset"); + let scan_query_skip = query_shape_skip("scan"); + let search_query_skip = query_shape_skip("search"); + let aggregate_query_skip = query_shape_skip("aggregate"); let resolver_auth_mode = resolver_auth_mode_tokens(entity_meta.auth.as_deref(), struct_name.span())?; let authenticated_directive = match entity_meta.auth.as_deref() { @@ -4605,7 +4673,7 @@ pub(crate) fn generate_graphql_operations( let keyset_graphql_method = if keyset_parts.is_some() { quote! { #[doc = #keyset_operation_description] - #[graphql(name = #keyset_query_name, #keyset_list_requires_scopes #authenticated_directive)] + #[graphql(#keyset_query_skip name = #keyset_query_name, #keyset_list_requires_scopes #authenticated_directive)] async fn keyset( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, @@ -4628,7 +4696,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 +4758,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 +6294,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 +6674,42 @@ 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 +6721,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 +6803,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 +6907,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 +7178,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 +7282,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 +9514,38 @@ 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/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", 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..3f91b0c4 --- /dev/null +++ b/crates/graphql-orm/tests/query_shape_selection.rs @@ -0,0 +1,126 @@ +#![cfg(feature = "sqlite")] + +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" +)] +struct ShapeRecord { + #[primary_key] + #[sortable] + #[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(); + 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 + .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) + ); + 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"); +} 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 |