From afa966b23fa67447cbb4059b65ca1087bc488f4a Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Fri, 23 Oct 2026 20:32:04 +1100 Subject: [PATCH] feat: filter generated subscriptions by typed primary key --- CHANGELOG.md | 6 + Cargo.lock | 6 +- Cargo.toml | 2 +- MIGRATION.md | 6 +- crates/graphql-orm-macros/Cargo.toml | 2 +- crates/graphql-orm-macros/README.md | 4 + .../fixtures/cross-crate-relations/Cargo.lock | 4 +- .../relationship-capability/Cargo.lock | 4 +- crates/graphql-orm-macros/src/operations.rs | 32 +++- crates/graphql-orm-router/CHANGELOG.md | 6 + crates/graphql-orm-router/Cargo.toml | 2 +- crates/graphql-orm-router/MIGRATION.md | 6 + crates/graphql-orm-router/README.md | 9 +- .../src/federation/wire_tests.rs | 8 +- crates/graphql-orm/Cargo.toml | 4 +- crates/graphql-orm/README.md | 4 + .../fixtures/backend-coexistence/Cargo.lock | 4 +- .../fixtures/federation-end-to-end/Cargo.lock | 6 +- .../migration-api-compatibility/Cargo.lock | 4 +- .../repository-aggregate-consumer/Cargo.lock | 4 +- .../tests/graphql_case_features.rs | 6 +- crates/graphql-orm/tests/graphql_naming.rs | 7 +- .../tests/subscription_integer_keys.rs | 159 ++++++++++++++++++ .../subscription_release_authorization.rs | 117 +++++++++++++ docs/reference/workspace-packages.md | 6 +- 25 files changed, 376 insertions(+), 42 deletions(-) create mode 100644 crates/graphql-orm/tests/subscription_integer_keys.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c542dc23..dc9931e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ for historical context. ## Unreleased +Companion runtime/macros version: **0.61.0**. + +- Generated single-primary-key `…Changed` subscriptions accept an optional typed `key` argument. Omission retains all authorized changes; a key composes with the action filter. Registration and every delivered event retain current authorization and provenance checks. The generated operation catalogue includes the same argument. + +## 0.60.0 (unreleased) + 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. diff --git a/Cargo.lock b/Cargo.lock index 41737a1a..11e3976b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.60.0" +version = "0.61.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -3238,7 +3238,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.6.4" +version = "0.6.5" dependencies = [ "agql-auth", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index da7dc0e5..b5f4a73d 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.60.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.61.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.16.2" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.10.0", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.2" } diff --git a/MIGRATION.md b/MIGRATION.md index cac79580..d46b4be2 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.60.0 to 0.61.0: subscription primary-key selection + +Upgrade runtime/macros together. Generated single-key subscriptions gain an optional `key` argument, using the entity primary key scalar and configured argument casing. Existing operations remain valid. For example, `recordChanged(key: "…", filter: {actions: [UPDATED]})` watches one row. Key selection narrows notifications only: it never grants row, field, scope, assurance, deleted-preimage or propagated-source authority. Refresh schema/catalogue snapshots. Composite-key subscriptions remain unsupported by this change. No data or schema migration is required. + ## 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. @@ -52,7 +56,7 @@ Regenerate derived capability manifests and semantic/router descriptors from the actual finished SDL and semantic catalogue because fingerprints change. Existing resolver, field, row and provider-egress authorization remains independent. GraphQL SDL, stored data and schema ownership are unchanged; no -DDL or data migration is needed. Runtime/macros package versions stay 0.60.0. +DDL or data migration is needed. This companion patch does not itself change runtime/macros; use their paired versions from the selected workspace tag. ## 0.56.0 to 0.57.0: exact SQL Server MONEY reads diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 296004aa..60237b77 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.60.0" +version = "0.61.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 28eff60e..a91c5526 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.61.0 adds an optional typed `key` argument to generated single-primary-key +change subscriptions, preserving current authorization and action filtering. See the +[migration guide](../../MIGRATION.md#0600-to-0610-subscription-primary-key-selection). + 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). 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 4984126a..bdc36d62 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.60.0" +version = "0.61.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.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 f00d37d8..72b81bc8 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.60.0" +version = "0.61.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 643d75ac..6a7ef895 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1851,6 +1851,9 @@ fn generate_graphql_operations_unfiltered( let id_arg_name = apply_graphql_case("id", argument_case); let input_arg_name = apply_graphql_case("input", argument_case); let filter_arg_name = apply_graphql_case("filter", argument_case); + let key_arg_name = apply_graphql_case("key", argument_case); + let key_arg_description = + "Receive changes only for this exact primary key. Omit to watch every authorized row."; let where_arg_description = semantic_argument_description(&where_arg_name); let search_arg_description = semantic_argument_description(&search_arg_name); let order_by_arg_description = semantic_argument_description(&order_by_arg_name); @@ -5061,6 +5064,7 @@ fn generate_graphql_operations_unfiltered( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, #[graphql(name = #filter_arg_name, desc = #filter_arg_description)] _filter: Option<::graphql_orm::graphql::orm::SubscriptionFilterInput>, + #[graphql(name = #key_arg_name, desc = #key_arg_description)] key: Option<#pk_type_ty>, ) -> ::graphql_orm::async_graphql::Result> { use ::graphql_orm::futures::StreamExt; let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; @@ -5091,9 +5095,11 @@ fn generate_graphql_operations_unfiltered( Ok(::graphql_orm::tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |result| { let db = db.clone(); let actions = actions.clone(); + let key = key.clone(); async move { let witness = result.ok()?; let event = witness.event.as_ref(); + if key.as_ref().is_some_and(|key| key != &event.id) { return None; } if ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys) != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_key_values(&event.id)) { return None; } if actions.as_ref().is_some_and(|actions| !actions.contains(&event.action)) { return None; } @@ -5110,6 +5116,9 @@ fn generate_graphql_operations_unfiltered( let preimage = if witness.trusted_delete && event.action == ::graphql_orm::graphql::orm::ChangeAction::Deleted { event.entity.as_ref() } else { None }; let row = ::graphql_orm::graphql::subscriptions::authorize_subscription_row::<#struct_name, #backend_marker>(&db, ctx, &witness.keys, preimage).await? .ok_or_else(|| ::graphql_orm::graphql::errors::OrmPublicError::forbidden().into_graphql_error())?; + if key.is_some() { + ::graphql_orm::graphql::subscriptions::authorize_subscription_keys::<#struct_name, #backend_marker>(&db, ctx, &row).await?; + } ::graphql_orm::graphql::subscriptions::authorize_subscription_fields(&db, ctx, &row, #entity_result_field_name).await?; // Query and field-policy awaits must not outlive current authority. db.ensure_entity_access(Some(ctx), #entity_name_lit, @@ -6265,7 +6274,11 @@ fn generate_graphql_operations_unfiltered( }; let argument_descriptor = |graphql_name: &str, rust_type: proc_macro2::TokenStream| { - let description = semantic_argument_description(graphql_name); + let description = if graphql_name == key_arg_name { + key_arg_description.to_owned() + } else { + semantic_argument_description(graphql_name) + }; quote! { ::graphql_orm::graphql::orm::GraphqlOperationArgumentDescriptor::generated_with_description( #graphql_name, @@ -6548,10 +6561,13 @@ fn generate_graphql_operations_unfiltered( quote! { ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription }, quote! { ::graphql_orm::graphql::orm::GeneratedGraphqlOperationCategory::Subscription }, &subscription_name, - vec![argument_descriptor( - &filter_arg_name, - quote! { Option<::graphql_orm::graphql::orm::SubscriptionFilterInput> }, - )], + vec![ + argument_descriptor( + &filter_arg_name, + quote! { Option<::graphql_orm::graphql::orm::SubscriptionFilterInput> }, + ), + argument_descriptor(&key_arg_name, quote! { Option<#pk_type_ty> }), + ], quote! { #changed_event }, &subscription_operation_authorization, ) @@ -7826,6 +7842,7 @@ fn generate_graphql_operations_unfiltered( &self, ctx: &::graphql_orm::async_graphql::Context<'_>, #[graphql(name = #filter_arg_name, desc = #filter_arg_description)] _filter: Option<::graphql_orm::graphql::orm::SubscriptionFilterInput>, + #[graphql(name = #key_arg_name, desc = #key_arg_description)] key: Option<#pk_type_ty>, ) -> ::graphql_orm::async_graphql::Result> { use ::graphql_orm::futures::StreamExt; let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; @@ -7857,9 +7874,11 @@ fn generate_graphql_operations_unfiltered( Ok(::graphql_orm::tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(move |result| { let db = db.clone(); let actions = actions.clone(); + let key = key.clone(); async move { let witness = result.ok()?; let event = witness.event.as_ref(); + if key.as_ref().is_some_and(|key| key != &event.id) { return None; } if ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys) != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_key_values(&event.id)) { return None; } if actions.as_ref().is_some_and(|actions| !actions.contains(&event.action)) { return None; } @@ -7876,6 +7895,9 @@ fn generate_graphql_operations_unfiltered( let preimage = if witness.trusted_delete && event.action == ::graphql_orm::graphql::orm::ChangeAction::Deleted { event.entity.as_ref() } else { None }; let row = ::graphql_orm::graphql::subscriptions::authorize_subscription_row::<#struct_name, #backend_marker>(&db, ctx, &witness.keys, preimage).await? .ok_or_else(|| ::graphql_orm::graphql::errors::OrmPublicError::forbidden().into_graphql_error())?; + if key.is_some() { + ::graphql_orm::graphql::subscriptions::authorize_subscription_keys::<#struct_name, #backend_marker>(&db, ctx, &row).await?; + } ::graphql_orm::graphql::subscriptions::authorize_subscription_fields(&db, ctx, &row, #entity_result_field_name).await?; // Query and field-policy awaits must not outlive current authority. db.ensure_entity_access(Some(ctx), #entity_name_lit, diff --git a/crates/graphql-orm-router/CHANGELOG.md b/crates/graphql-orm-router/CHANGELOG.md index 2624a0fc..825665c0 100644 --- a/crates/graphql-orm-router/CHANGELOG.md +++ b/crates/graphql-orm-router/CHANGELOG.md @@ -10,6 +10,12 @@ supersedes: [] # Changelog +## 0.6.5 (unreleased) + +- Forward spawned federation wire-test router stderr to hosted CI, so an early + child exit exposes its cause. This changes test diagnostics only; production + routing, authorization, SDL and persisted data are unchanged. + ## 0.6.4 - 2026-10-22 - Refresh the workspace and affected fixture locks to published diff --git a/crates/graphql-orm-router/Cargo.toml b/crates/graphql-orm-router/Cargo.toml index 034dbc3f..cdefea6a 100644 --- a/crates/graphql-orm-router/Cargo.toml +++ b/crates/graphql-orm-router/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-router" -version = "0.6.4" +version = "0.6.5" edition = "2024" rust-version = "1.90" description = "Federated GraphQL router for graphql-orm and project-neutral subgraphs" diff --git a/crates/graphql-orm-router/MIGRATION.md b/crates/graphql-orm-router/MIGRATION.md index ae013de3..1df17caf 100644 --- a/crates/graphql-orm-router/MIGRATION.md +++ b/crates/graphql-orm-router/MIGRATION.md @@ -10,6 +10,12 @@ supersedes: [] # graphql-orm-router migration guide +## 0.6.4 to 0.6.5: test startup diagnostics + +No application, protocol or data migration is required. The package identity +records a test-only diagnostic correction. No executable is distributed by this +source change; binary publication retains its separate approval boundary. + ## 0.6.3 to 0.6.4: TLS certificate distrust patches Refresh the consuming lockfile to `rustls-webpki` 0.103.16 or later in its diff --git a/crates/graphql-orm-router/README.md b/crates/graphql-orm-router/README.md index 805d5f26..d1a67338 100644 --- a/crates/graphql-orm-router/README.md +++ b/crates/graphql-orm-router/README.md @@ -16,6 +16,10 @@ correction. Direct Auth consumers must select the same tag and refresh their locks to one source; see the [workspace migration](../../MIGRATION.md#signed-role-catalogue-transport-bound). +Version `0.6.5` retains test-child startup errors in hosted CI diagnostics. +Production routing and authorization are unchanged; this source update does +not distribute or authorize a new router executable. + Version `0.6.4` selects the published TLS certificate-distrust patches in the workspace lockfile. Rebuild with that lock and refresh downstream locks to retain `rustls-webpki` 0.103.16 and `webpki-roots` / `webpki-root-certs` 1.0.10. @@ -51,7 +55,7 @@ This unpublished package is Git-only: ```toml [dependencies] -graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.4" } +graphql-orm-router = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.6.5" } ``` Version 0.6 makes `AuthenticationProvider::current_time` fallible. The agql @@ -83,6 +87,9 @@ command alone does not enforce those restrictions. Hive's separate `graphiql` feature selects static GraphiQL instead, skipping Laboratory generation and ignoring Laboratory seed configuration. +The in-tree federation wire tests forward spawned router-child stderr to the +test runner, preserving startup diagnostics when a child exits early. + ## Minimal programmatic start For trusted local development, anonymous mode is explicit. Production must diff --git a/crates/graphql-orm-router/src/federation/wire_tests.rs b/crates/graphql-orm-router/src/federation/wire_tests.rs index f328d874..cb1c8f2d 100644 --- a/crates/graphql-orm-router/src/federation/wire_tests.rs +++ b/crates/graphql-orm-router/src/federation/wire_tests.rs @@ -2360,7 +2360,7 @@ impl TestRouter { .env("RUST_TEST_THREADS", "1") .env("RUST_LOG", "error") .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::inherit()) .spawn() .expect("router child should start"); Self { @@ -2424,7 +2424,7 @@ impl TestRouter { .env("RUST_TEST_THREADS", "1") .env("RUST_LOG", "error") .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::inherit()) .spawn() .expect("static router child should start"); Self { @@ -2523,7 +2523,7 @@ impl TestRouter { .env("RUST_TEST_THREADS", "1") .env("RUST_LOG", "error") .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::inherit()) .spawn() .expect("administrative router child should start"); ( @@ -2561,7 +2561,7 @@ impl TestRouter { .env("RUST_TEST_THREADS", "1") .env("RUST_LOG", "error") .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::inherit()) .spawn() .expect("authenticated router child should start"); Self { diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 0b202b44..d75b37f3 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.60.0" +version = "0.61.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.60.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.61.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 a3937d52..cdc427e8 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -10,6 +10,10 @@ supersedes: [] # `graphql-orm` +Version 0.61.0 adds an optional typed `key` argument to generated single-primary-key +change subscriptions, preserving current authorization and action filtering. See the +[migration guide](../../MIGRATION.md#0600-to-0610-subscription-primary-key-selection). + 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). diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 536592c5..326bfa32 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.60.0" +version = "0.61.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.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 2dc0dff6..b7c8a030 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.60.0" +version = "0.61.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -2552,7 +2552,7 @@ dependencies = [ [[package]] name = "graphql-orm-router" -version = "0.6.4" +version = "0.6.5" dependencies = [ "arc-swap", "cynic-parser", 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 b8869510..27dacbc4 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.60.0" +version = "0.61.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.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 3f448d36..e566ea17 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.60.0" +version = "0.61.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.60.0" +version = "0.61.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/graphql_case_features.rs b/crates/graphql-orm/tests/graphql_case_features.rs index e7d62b10..f1e4ea01 100644 --- a/crates/graphql-orm/tests/graphql_case_features.rs +++ b/crates/graphql-orm/tests/graphql_case_features.rs @@ -172,7 +172,7 @@ async fn generated_schema_respects_crate_wide_case_features() { ); assert_has( &sdl, - "caseCollectionChanged(filter: SubscriptionFilterInput): CaseCollectionChangedEvent!", + "caseCollectionChanged(filter: SubscriptionFilterInput, key: String): CaseCollectionChangedEvent!", ); assert_has(&sdl, "\tcoverStoredFileId: UUID"); assert_has(&sdl, "\tSerdeExact: String!"); @@ -204,7 +204,7 @@ async fn generated_schema_respects_crate_wide_case_features() { ); assert_has( &sdl, - "CaseCollectionChanged(Filter: SubscriptionFilterInput): CaseCollectionChangedEvent!", + "CaseCollectionChanged(Filter: SubscriptionFilterInput, Key: String): CaseCollectionChangedEvent!", ); assert_has(&sdl, "\tCoverStoredFileId: UUID"); assert_has(&sdl, "\tSerdeNamedField: String!"); @@ -233,7 +233,7 @@ async fn generated_schema_respects_crate_wide_case_features() { ); assert_has( &sdl, - "CaseCollectionChanged(Filter: SubscriptionFilterInput): CaseCollectionChangedEvent!", + "CaseCollectionChanged(Filter: SubscriptionFilterInput, Key: String): CaseCollectionChangedEvent!", ); assert_has(&sdl, "\tcover_stored_file_id: UUID"); assert_has(&sdl, "\tserde_named_field: String!"); diff --git a/crates/graphql-orm/tests/graphql_naming.rs b/crates/graphql-orm/tests/graphql_naming.rs index 999721e5..b58146b8 100644 --- a/crates/graphql-orm/tests/graphql_naming.rs +++ b/crates/graphql-orm/tests/graphql_naming.rs @@ -91,10 +91,9 @@ async fn generated_graphql_schema_uses_pascal_case_types_and_camel_case_fields() ) ); assert!(compact_sdl.contains("deleteCollection(id:String!):CollectionResult!")); - assert!( - compact_sdl - .contains("collectionChanged(filter:SubscriptionFilterInput):CollectionChangedEvent!") - ); + let subscription_signature = + "collectionChanged(filter:SubscriptionFilterInput,key:String):CollectionChangedEvent!"; + assert!(compact_sdl.contains(subscription_signature)); assert!(sdl.contains("type CollectionResult {")); assert!(sdl.contains("\tsuccess: Boolean!")); diff --git a/crates/graphql-orm/tests/subscription_integer_keys.rs b/crates/graphql-orm/tests/subscription_integer_keys.rs new file mode 100644 index 00000000..3a4715ed --- /dev/null +++ b/crates/graphql-orm/tests/subscription_integer_keys.rs @@ -0,0 +1,159 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::async_graphql::Response; +use graphql_orm::futures::{Stream, StreamExt, future::poll_fn}; +use graphql_orm::prelude::*; +use std::task::Poll; +use tokio::time::{Duration, timeout}; + +#[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[graphql_entity(table = "subscription_integer_records", plural = "IntegerRecords")] +struct IntegerRecord { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "number")] + #[sortable] + id: i32, + label: String, +} + +#[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] +#[graphql_entity( + table = "subscription_integer_audits", + plural = "IntegerAudits", + append_only = true +)] +struct IntegerAudit { + #[primary_key] + #[graphql_orm(auto_generated = false)] + #[filterable(type = "number")] + #[sortable] + id: i32, + label: String, +} + +schema_roots! { + query_custom_ops: [], + generated_mutations: "none", + entities: [IntegerRecord, IntegerAudit], +} + +async fn registered + Unpin>(stream: &mut S) { + poll_fn(|cx| match stream.poll_next_unpin(cx) { + Poll::Pending => Poll::Ready(()), + other => panic!("unexpected registration response: {other:?}"), + }) + .await; +} + +fn assert_identity(response: Response, root: &str, row: &str, expected: i32) { + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json().unwrap(); + assert_eq!(data[root]["id"], expected); + assert_eq!(data[root][row]["id"], expected); + assert_eq!(data[root]["action"], "CREATED"); +} + +#[tokio::test] +async fn normal_and_append_only_integer_keys_filter_exactly_without_changing_omission() { + let db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + for table in [ + "subscription_integer_records", + "subscription_integer_audits", + ] { + sqlx::query(&format!( + "CREATE TABLE {table} (id INTEGER PRIMARY KEY, label TEXT NOT NULL)" + )) + .execute(db.pool()) + .await + .unwrap(); + } + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("integer-key-fixture")) + .finish(); + let sdl = schema.sdl(); + for root in ["integerRecordChanged", "integerAuditChanged"] { + let prefix = format!("{root}("); + let arguments = sdl + .split(prefix.as_str()) + .nth(1) + .and_then(|suffix| suffix.split(')').next()) + .expect("generated subscription arguments"); + assert!(arguments.contains("key: Int"), "{root}: {arguments}"); + assert!(!arguments.contains("key: Int!"), "key must remain optional"); + } + + let mut records = Box::pin(schema.execute_stream( + "subscription { integerRecordChanged { id action integerRecord { id label } } }", + )); + let mut exact_record = Box::pin(schema.execute_stream( + "subscription { integerRecordChanged(key: 1) { id action integerRecord { id label } } }", + )); + let mut audits = Box::pin(schema.execute_stream( + "subscription { integerAuditChanged { id action integerAudit { id label } } }", + )); + let mut exact_audit = Box::pin(schema.execute_stream( + "subscription { integerAuditChanged(key: 1) { id action integerAudit { id label } } }", + )); + registered(&mut records).await; + registered(&mut exact_record).await; + registered(&mut audits).await; + registered(&mut exact_audit).await; + + for id in [2, 1] { + IntegerRecord::insert( + &db, + CreateIntegerRecordInput { + id, + label: format!("record-{id}"), + }, + ) + .await + .unwrap(); + IntegerAudit::insert( + &db, + CreateIntegerAuditInput { + id, + label: format!("audit-{id}"), + }, + ) + .await + .unwrap(); + } + for expected in [2, 1] { + let record = timeout(Duration::from_secs(2), records.next()) + .await + .unwrap() + .unwrap(); + assert_identity(record, "integerRecordChanged", "integerRecord", expected); + let audit = timeout(Duration::from_secs(2), audits.next()) + .await + .unwrap() + .unwrap(); + assert_identity(audit, "integerAuditChanged", "integerAudit", expected); + } + let record = timeout(Duration::from_secs(2), exact_record.next()) + .await + .unwrap() + .unwrap(); + assert_identity(record, "integerRecordChanged", "integerRecord", 1); + let audit = timeout(Duration::from_secs(2), exact_audit.next()) + .await + .unwrap() + .unwrap(); + assert_identity(audit, "integerAuditChanged", "integerAudit", 1); + assert!( + timeout(Duration::from_millis(100), exact_record.next()) + .await + .is_err() + ); + assert!( + timeout(Duration::from_millis(100), exact_audit.next()) + .await + .is_err() + ); +} diff --git a/crates/graphql-orm/tests/subscription_release_authorization.rs b/crates/graphql-orm/tests/subscription_release_authorization.rs index c48f3876..6dec5efa 100644 --- a/crates/graphql-orm/tests/subscription_release_authorization.rs +++ b/crates/graphql-orm/tests/subscription_release_authorization.rs @@ -614,3 +614,120 @@ async fn deleted_preimage_requires_explicit_opt_in_even_when_ordinary_reads_allo assert_eq!(data["secureRecordChanged"]["action"], "CREATED"); } } + +#[tokio::test] +async fn exact_key_and_action_filters_narrow_normal_and_append_only_events() { + for (entity, field) in [ + ("SecureRecord", "secureRecordChanged"), + ("SecureAudit", "secureAuditChanged"), + ] { + let (schema, _) = setup().await; + let mut stream = Box::pin(schema.execute_stream( + Request::new(format!( + "subscription {{ {field}(key: \"wanted\", filter: {{actions: [CREATED]}}) {{ id action }} }}" + )) + .data(AuthSubject::new("alice")), + )); + registered(&mut stream).await; + publish(&schema, entity, "other", "alice").await; + publish(&schema, entity, "wanted", "alice").await; + let response = timeout(Duration::from_secs(2), stream.next()) + .await + .unwrap() + .unwrap(); + assert!(response.errors.is_empty(), "{:?}", response.errors); + let value = response.data.into_json().unwrap(); + assert_eq!(value[field]["id"], "wanted"); + assert_eq!(value[field]["action"], "CREATED"); + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err() + ); + } +} + +#[tokio::test] +async fn exact_key_never_grants_owner_or_revoked_entity_authority() { + for (entity, field) in [ + ("SecureRecord", "secureRecordChanged"), + ("SecureAudit", "secureAuditChanged"), + ] { + for revoke in [false, true] { + let (schema, authority) = setup().await; + let mut stream = Box::pin( + schema.execute_stream( + Request::new(format!( + "subscription {{ {field}(key: \"wanted\") {{ id }} }}" + )) + .data(AuthSubject::new("alice")), + ), + ); + registered(&mut stream).await; + if revoke { + authority.entity_allowed.store(false, Ordering::SeqCst); + } + publish( + &schema, + entity, + "wanted", + if revoke { "alice" } else { "bob" }, + ) + .await; + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err() + ); + } + } +} + +#[tokio::test] +async fn exact_key_requires_current_key_field_authority_for_action_only_payloads() { + for (entity, field) in [ + ("SecureRecord", "secureRecordChanged"), + ("SecureAudit", "secureAuditChanged"), + ] { + let (schema, authority) = setup().await; + let mut stream = Box::pin( + schema.execute_stream( + Request::new(format!( + "subscription {{ {field}(key: \"wanted\") {{ action }} }}" + )) + .data(AuthSubject::new("alice")), + ), + ); + registered(&mut stream).await; + authority.key_allowed.store(false, Ordering::SeqCst); + publish(&schema, entity, "wanted", "alice").await; + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "key argument must not reveal a matching row through an action-only payload" + ); + } +} + +#[tokio::test] +async fn subscription_key_argument_is_in_both_sdl_and_operation_catalogue() { + let (schema, _) = setup().await; + assert!(schema.sdl().contains("key: String")); + use graphql_orm::graphql::orm::GraphqlOperationKind; + let catalog = graphql_orm_operation_catalog(); + let subscriptions = catalog + .operations() + .iter() + .filter(|operation| operation.kind() == GraphqlOperationKind::Subscription) + .collect::>(); + assert_eq!(subscriptions.len(), 2); + for operation in subscriptions { + assert!( + operation + .arguments() + .iter() + .any(|argument| argument.graphql_name() == "key") + ); + } +} diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 9b7f61da..efe9e9c6 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,13 +18,13 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.60.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.61.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.109.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm` (dev-only), `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.16.2` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.10.0` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.60.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.61.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.4.2` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | -| `graphql-orm-router` | `0.6.4` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | +| `graphql-orm-router` | `0.6.5` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | | `graphql-orm-storage` | `0.7.5` | `crates/graphql-orm-storage` | `local` | none |