From 616e6a01e29bbb1fda29519d6d8f850eaf6126ba Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 24 Oct 2026 05:28:19 +1100 Subject: [PATCH 1/3] feat: generate composite and read-only Changed subscriptions --- CHANGELOG.md | 26 + MIGRATION.md | 55 +- crates/graphql-orm-macros/README.md | 9 + crates/graphql-orm-macros/src/operations.rs | 438 ++++++---- crates/graphql-orm-macros/src/schema_roots.rs | 19 +- crates/graphql-orm/README.md | 12 + .../graphql-orm/src/graphql/subscriptions.rs | 87 +- crates/graphql-orm/src/lib.rs | 4 +- .../tests/composite_readonly_subscriptions.rs | 789 ++++++++++++++++++ .../tests/federation_entity_keys_mssql.rs | 2 +- crates/graphql-orm/tests/read_projections.rs | 4 +- .../tests/resolver_operation_metadata.rs | 12 +- .../resolver_operation_metadata_mssql.rs | 8 +- .../tests/ui/composite_mutations_sqlx_free.rs | 2 +- 14 files changed, 1272 insertions(+), 195 deletions(-) create mode 100644 crates/graphql-orm/tests/composite_readonly_subscriptions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e24d542..897e096d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,32 @@ for historical context. ## Unreleased +Companion runtime/macros version: **0.64.0**. + +- Revalidate the complete current release contract after successful selected + snapshot serialization; source, target, key or field revocation suppresses + the prepared event without a frame or private diagnostics. + +- Prepare selected Changed snapshots before stream release. Discard late scalar + refusals and private policy diagnostics without emitting even an error frame, + and preserve clean delivery of subsequent authorized events. + +- Seed cycle detection with the same complete typed target identity and origin + encoding used by propagated events, including composite and append-only sources. + +- Generate real composite-key and read-only projection subscriptions with typed + complete-key filters and current row/field/provenance authorization. +- Emit complete typed composite direct witnesses only after committed generated + repository mutations; preserve trusted rollback and propagation fences. +- Expose generated subscriptions under read-only schema policy while retaining + empty mutation roots and rejecting arbitrary extra subscriptions. +- Read-only and composite `GraphQLOperations` entities require both serde derives + for generated Changed observer events, matching writable scalar-key entities. +- Keep private repository sources closed to GraphQL notification release pending + a separate explicit projection-authorization contract. + +## 0.63.0 (unreleased) + Companion runtime/macros version: **0.63.0**. - Seed propagated-event cycle detection with the complete typed target identity and origin so cycles cannot re-emit the direct source. diff --git a/MIGRATION.md b/MIGRATION.md index 0e4a0576..ed251cf7 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -15,13 +15,46 @@ 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.61.0 to 0.62.0: runtime keyset ordering - -Upgrade runtime and macros together. A generated `…Keyset` query now accepts `orderBy: [EntityKeysetOrderByInput!]`. Each entry selects exactly one readable, persisted sortable scalar, for example `[{title: ASC}, {rank: DESC}]`. Primary-key fields are accepted. Computed, relation, spatial and decimal orders are absent from this input; ordinary list ordering retains its separate input type. A complete readable scalar primary key is required. Entities with private or unsupported primary keys retain their configured keyset surface without registering an unusable runtime order input. - -Each dynamic order appends missing primary-key columns in ascending order. NULL values sort last in either direction; backward windows reverse comparison and null placement internally, then return canonical order. Duplicate fields, empty entries and empty lists fail before SELECT. Every order field, including an implicit key component, requires current field policy even when omitted from the payload. - -Dynamic cursors bind the entity, exact ordered columns, directions and null placement. A cursor for a different order or the configured default is rejected. Omit `orderBy` to retain the configured order and existing cursor bytes. Repository callers may use `keyset_page_ordered` or `keyset_connection_page_ordered`; existing methods stay unchanged. Every composite primary-key component participates in configured and runtime validation. No schema or data migration is required. +## 0.63.0 to 0.64.0: composite and read-only Changed + +A successful selected snapshot is released only after a second current source, +target, key and field authorization check. Hosts whose serialization hooks await +or revoke authority do not release the prepared value under stale permissions. + +Changed snapshot and scalar nullability are preserved. Generated delivery +prepares the selected snapshot before yielding a stream item. A late scalar +refusal or policy error discards the whole event and its private diagnostics, +including nullable failures, without emitting an error frame. Subsequent allowed +events remain clean. Internal observer DTOs are unchanged. + +Upgrade runtime and macros together. Composite-primary-key entities now expose +real generated `…Changed` subscriptions with an optional complete `EntityKeyInput`. +Their event includes a stable opaque `id` and typed `key`. All primary-key fields +must be authorized before either identity is released. Composite repository +mutations queue typed trusted witnesses and propagation only after commit; +rollback never delivers these notifications. Existing raw composite observers +receive added change-kind and provenance fields and must update exhaustive +struct patterns for this pre-1.0 capability change. + +Read-only public entities accept the same generated trusted propagation channel. +Entities whose `GraphQLOperations` derive now generates Changed events need +`serde::Serialize` and `serde::Deserialize` for the observer payload. This includes +read-only projections and composite entities with repository mutations, matching +the existing writable scalar-key Changed contract. Add both derives to affected +fixtures and consumers when upgrading. +No insert/update/delete capability or schema management is added. Under a read-only +`schema_roots!` policy the mutation root stays empty while generated subscriptions +and their operation-catalogue entries remain present. Arbitrary extra subscriptions +retain their read-only-schema rejection. Raw or deserialized observer events do not +enter generated delivery. + +The full target key, current source/target rows, selected scalar fields, key fields, +relation tuples and per-hop provenance are checked for each release. Omitted keys +retain entity-wide authorized delivery. This implements the public composite/read-only +slice of issue #191. Private repository source fields remain non-subscribable; +private-source projection authorization and the complete dependent AI notification +graph remain unfinished, so draft #189 and issue #184 are not completed by this change. +No data/schema migration is required. ## 0.62.0 to 0.63.0: propagation relation tuples @@ -44,6 +77,14 @@ and read-only trusted projection subscriptions remain separate unfinished work; this change does not complete #191, #184 or draft #189. No data/schema migration or downstream pin change is included. +## 0.61.0 to 0.62.0: runtime keyset ordering + +Upgrade runtime and macros together. A generated `…Keyset` query now accepts `orderBy: [EntityKeysetOrderByInput!]`. Each entry selects exactly one readable, persisted sortable scalar, for example `[{title: ASC}, {rank: DESC}]`. Primary-key fields are accepted. Computed, relation, spatial and decimal orders are absent from this input; ordinary list ordering retains its separate input type. A complete readable scalar primary key is required. Entities with private or unsupported primary keys retain their configured keyset surface without registering an unusable runtime order input. + +Each dynamic order appends missing primary-key columns in ascending order. NULL values sort last in either direction; backward windows reverse comparison and null placement internally, then return canonical order. Duplicate fields, empty entries and empty lists fail before SELECT. Every order field, including an implicit key component, requires current field policy even when omitted from the payload. + +Dynamic cursors bind the entity, exact ordered columns, directions and null placement. A cursor for a different order or the configured default is rejected. Omit `orderBy` to retain the configured order and existing cursor bytes. Repository callers may use `keyset_page_ordered` or `keyset_connection_page_ordered`; existing methods stay unchanged. Every composite primary-key component participates in configured and runtime validation. No schema or data migration is required. + ## 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. diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index 8a5c15e0..f4481b8f 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -15,6 +15,15 @@ The scalar-only input, complete primary-key suffix, order-bound cursor and curre field authorization are described in the [migration guide](../../MIGRATION.md#0610-to-0620-runtime-keyset-ordering). +Generated Changed delivery prepares the selected snapshot before yielding a +stream item. Late scalar refusals and policy errors, including nullable fields, +discard the entire event and private diagnostics without an error frame. A +later authorized event starts with clean response errors. + +Version 0.64.0 generates complete composite-key and read-only projection change +subscriptions independently of mutation exposure. See the +[migration guide](../../MIGRATION.md#0630-to-0640-composite-and-read-only-changed). + Version 0.63.0 resolves generated upward propagation using complete relation from/to tuples and revalidates the current mapping at release. See the [migration guide](../../MIGRATION.md#0620-to-0630-propagation-relation-tuples). diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index bc5f5896..7c8b51ba 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -1936,18 +1936,22 @@ fn generate_graphql_operations_unfiltered( syn::Ident::new(&format!("Delete{}Result", plural_name), struct_name.span()); let upsert_result_type_str = format!("Upsert{}Result", struct_name); let delete_many_result_type_str = format!("Delete{}Result", plural_name); + let subscription_key_arg_name = apply_graphql_case("key", argument_case); let key_type = syn::Ident::new(&format!("{}Key", struct_name), struct_name.span()); + let key_input_name = format!("{}KeyInput", struct_name); let key_type_definition = if has_composite_primary_key { let fields = primary_key_fields .iter() .map(|field| { let field_name = &field.field_name; let field_type = &field.field_type; - quote! { pub #field_name: #field_type, } + let graphql_name = &field.graphql_name; + quote! { #[graphql(name = #graphql_name)] pub #field_name: #field_type, } }) .collect::>(); quote! { - #[derive(Clone, Debug, PartialEq)] + #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, ::graphql_orm::async_graphql::SimpleObject, ::graphql_orm::async_graphql::InputObject)] + #[graphql(input_name = #key_input_name)] pub struct #key_type { #(#fields)* } @@ -2124,7 +2128,20 @@ fn generate_graphql_operations_unfiltered( bind_values } }; - let subscription_pk_bind = pk_bind_value_for_entity.clone(); + let subscription_key_binds = primary_key_fields + .iter() + .map(|field| { + let name = &field.field_name; + push_create_input_sql_value_tokens( + backend, + struct_name, + quote! { entity.#name.clone() }, + &field.field_type, + &field.meta, + quote! { ::graphql_orm::sqlx::Error }, + ) + }) + .collect::>(); let subscription_key_visibility = if crate::entity::has_repository_entity_attribute(&input.attrs) { quote! {} @@ -2134,9 +2151,45 @@ fn generate_graphql_operations_unfiltered( let subscription_key_method = quote! { #[doc(hidden)] #subscription_key_visibility fn __gom_subscription_key_values(entity: &Self) -> Vec<::graphql_orm::graphql::orm::SqlValue> { - vec![#subscription_pk_bind] + let mut bind_values = Vec::new(); + #(#subscription_key_binds)* + bind_values } }; + let subscription_identity_type = if has_composite_primary_key { + quote! { String } + } else { + quote! { #pk_type } + }; + let subscription_extra_key_field = if has_composite_primary_key { + quote! { pub key: #key_type, } + } else { + quote! {} + }; + let subscription_key_initializer = if has_composite_primary_key { + quote! { key: id.clone(), } + } else { + quote! {} + }; + let propagated_event_id = if has_composite_primary_key { + quote! { ::graphql_orm::graphql::orm::composite_key_id(&Self::__gom_key_values(&id)) } + } else { + quote! { id.clone() } + }; + let subscription_event_key = if has_composite_primary_key { + quote! { event.key } + } else { + quote! { event.id } + }; + let propagation_entity_key = if has_composite_primary_key { + let components = primary_key_fields.iter().map(|field| { + let name = &field.field_name; + quote! { #name: entity.#name.clone() } + }); + quote! { #key_type { #(#components),* } } + } else { + quote! { entity.#pk_field.clone() } + }; // Repository-only observers keep their existing redacted event contract; // these entities have no generated GraphQL subscription channel. let subscription_witness_emission = |publish: proc_macro2::TokenStream| { @@ -2173,8 +2226,8 @@ fn generate_graphql_operations_unfiltered( }; let propagated_subscription_emitter = quote! { #[doc(hidden)] - pub(crate) fn __gom_propagation_key(entity: &Self) -> #pk_type_ty { - entity.#pk_field.clone() + pub(crate) fn __gom_propagation_key(entity: &Self) -> #key_type { + #propagation_entity_key } #[doc(hidden)] @@ -2182,7 +2235,7 @@ fn generate_graphql_operations_unfiltered( pub(crate) fn __gom_queue_propagated_changed_event( hook_ctx: &mut ::graphql_orm::graphql::orm::MutationContext<'_, #backend_marker>, action: ::graphql_orm::graphql::orm::ChangeAction, - id: #pk_type, + id: #key_type, parent_entity: Option<&Self>, source_entity: &str, source_id: &str, @@ -2192,7 +2245,8 @@ fn generate_graphql_operations_unfiltered( let event = #changed_event { action, change_kind: ::graphql_orm::graphql::orm::ChangeKind::Propagated, - id, + id: #propagated_event_id, + #subscription_key_initializer source_entity: Some(source_entity.to_string()), source_id: Some(source_id.to_string()), path, @@ -3582,18 +3636,6 @@ fn generate_graphql_operations_unfiltered( } } - #[derive(Default)] - pub struct #subscriptions_struct; - - #[::graphql_orm::async_graphql::Subscription] - impl #subscriptions_struct { - #[graphql(visible = false)] - async fn __gom_composite_key_subscriptions_unavailable( - &self, - ) -> ::graphql_orm::futures::stream::Empty { - ::graphql_orm::futures::stream::empty() - } - } } } else { quote! {} @@ -5257,8 +5299,8 @@ 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> { + #[graphql(name = #subscription_key_arg_name, desc = "Exact complete primary key to observe")] key: Option<#key_type>, + ) -> ::graphql_orm::async_graphql::Result>> { use ::graphql_orm::futures::StreamExt; let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; #subscription_scope_enforcement @@ -5292,11 +5334,11 @@ fn generate_graphql_operations_unfiltered( 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 key.as_ref().is_some_and(|key| ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_key_values(key)) != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys)) { 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; } - let authorized: ::graphql_orm::async_graphql::Result<#struct_name> = async { + let authorize_current = || async { let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; #subscription_scope_enforcement ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; @@ -5321,18 +5363,26 @@ fn generate_graphql_operations_unfiltered( let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; #subscription_scope_enforcement ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; - Ok(row) - }.await; - match authorized { - Ok(row) => Some(#changed_event { - action: event.action, - change_kind: event.change_kind, - id: event.id.clone(), - source_entity: event.source_entity.clone(), - source_id: event.source_id.clone(), - path: event.path.clone(), - entity: Some(row), - }), + Ok::<#struct_name, ::graphql_orm::async_graphql::Error>(row) + }; + match authorize_current().await { + Ok(row) => { + let prepared = ::graphql_orm::graphql::subscriptions::ChangedPayload::prepare(#changed_event { + action: event.action, + change_kind: event.change_kind, + id: event.id.clone(), + source_entity: event.source_entity.clone(), + source_id: event.source_id.clone(), + path: event.path.clone(), + entity: Some(row), + }, ctx).await?; + // Successful serialization can itself await or revoke authority. + // Recheck the complete current release contract before yielding. + match authorize_current().await { + Ok(_) => Some(prepared), + Err(error) => { let _ = ::graphql_orm::graphql::loaders::relation_authorization_error(error); None } + } + }, Err(error) => { let _ = ::graphql_orm::graphql::loaders::relation_authorization_error(error); None } } } @@ -5538,13 +5588,6 @@ fn generate_graphql_operations_unfiltered( #[derive(Clone, Debug, Default)] pub struct #update_input { #(#update_input_fields)* } - #[derive(Clone, Debug)] - pub struct #changed_event { - pub action: ::graphql_orm::graphql::orm::ChangeAction, - pub key: #key_type, - pub entity: Option<#struct_name>, - } - impl #struct_name { #[doc(hidden)] fn __gom_runtime_error(message: impl Into) -> ::graphql_orm::sqlx::Error { @@ -5627,11 +5670,19 @@ fn generate_graphql_operations_unfiltered( ) -> ::graphql_orm::Result<()> { if let Some(entity) = entity { #search_write_maintenance - hook_ctx.queue_event(#changed_event { - action, - key: Self::__gom_entity_key(entity), + let id = Self::__gom_entity_key(entity); + let source_id = ::graphql_orm::graphql::orm::composite_key_id(&Self::__gom_key_values(&id)); + let event = #changed_event { + action, change_kind: ::graphql_orm::graphql::orm::ChangeKind::Direct, + id: source_id.clone(), key: id, + source_entity: None, source_id: None, path: Vec::new(), entity: Some(entity.clone()), - }); + }; + hook_ctx.queue_event(event.clone()); + #queue_subscription_witness + #propagation_visited_seed + Self::__gom_propagate_changed_event(hook_ctx, action, entity, #entity_name_lit, + &source_id, Vec::new(), &mut visited, &[]).await?; } Ok(()) } @@ -6765,7 +6816,10 @@ fn generate_graphql_operations_unfiltered( &filter_arg_name, quote! { Option<::graphql_orm::graphql::orm::SubscriptionFilterInput> }, ), - argument_descriptor(&key_arg_name, quote! { Option<#pk_type_ty> }), + argument_descriptor( + &apply_graphql_case("key", argument_case), + quote! { Option<#key_type> }, + ), ], quote! { #changed_event }, &subscription_operation_authorization, @@ -6832,6 +6886,8 @@ fn generate_graphql_operations_unfiltered( &delete_many_operation_authorization, )); operation_descriptors.push(subscription_descriptor()); + } else { + operation_descriptors.push(subscription_descriptor()); } // Federation entity keys are resolved here because the generated @@ -6954,6 +7010,174 @@ fn generate_graphql_operations_unfiltered( } }; + let subscription_release_key_field = if has_composite_primary_key { + quote! { key: event.key.clone(), } + } else { + quote! {} + }; + let generated_subscription_surface = quote! { + /// Generated subscriptions for #struct_name + #[derive(Default)] + pub struct #subscriptions_struct; + + #[::graphql_orm::async_graphql::Subscription] + impl #subscriptions_struct { + /// Subscribe to #struct_name_str changes + #[doc = #subscription_operation_description] + #[graphql(name = #subscription_name, #subscription_requires_scopes #authenticated_directive)] + async fn on_changed( + &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 = #subscription_key_arg_name, desc = "Exact complete primary key to observe")] key: Option<#key_type>, + ) -> ::graphql_orm::async_graphql::Result>> { + use ::graphql_orm::futures::StreamExt; + let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; + #subscription_scope_enforcement + ::graphql_orm::graphql::assurance::enforce_resolver_assurance( + ctx, + ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription, + )?; + let db = ctx.data::<::graphql_orm::db::Database<#backend_marker>>().map_err(|_| { + ::graphql_orm::async_graphql::Error::new( + "graphql-orm Database runtime not registered; build the schema with schema_builder(database) or add Database to schema data", + ) + })?; + db.ensure_entity_access( + Some(ctx), + #entity_name_lit, + <#struct_name as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, + ::graphql_orm::graphql::orm::EntityAccessKind::Read, + ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlSubscription, + ).await?; + + if _filter.as_ref().is_some_and(|filter| filter.dummy.is_some()) { + return Err(::graphql_orm::graphql::errors::OrmPublicError::new(::graphql_orm::graphql::errors::OrmErrorCode::InvalidInput).into_graphql_error()); + } + ::graphql_orm::graphql::subscriptions::validate_subscription_selection::<#struct_name, #backend_marker>(ctx, #entity_result_field_name)?; + let actions = _filter.and_then(|filter| filter.actions); + let rx = db.ensure_event_sender::<::graphql_orm::graphql::subscriptions::SubscriptionEvent<#changed_event, #backend_marker>>().subscribe(); + let db = db.clone(); + 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| ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_key_values(key)) != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys)) { 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(&#subscription_event_key)) { return None; } + if actions.as_ref().is_some_and(|actions| !actions.contains(&event.action)) { return None; } + let authorize_current = || async { + let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; + #subscription_scope_enforcement + ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; + witness.authorize_provenance(&db, ctx, event.source_entity.as_deref(), event.source_id.as_deref(), &event.path, event.change_kind == ::graphql_orm::graphql::orm::ChangeKind::Propagated).await?; + if witness.trusted_delete + && event.entity.as_ref().is_some_and(|entity| + ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_subscription_key_values(entity)) + != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys) + ) { return Err(::graphql_orm::graphql::errors::OrmPublicError::forbidden().into_graphql_error()); } + 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, + <#struct_name as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, + ::graphql_orm::graphql::orm::EntityAccessKind::Read, + ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlSubscription).await?; + let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; + #subscription_scope_enforcement + ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; + Ok::<#struct_name, ::graphql_orm::async_graphql::Error>(row) + }; + match authorize_current().await { + Ok(row) => { + let prepared = ::graphql_orm::graphql::subscriptions::ChangedPayload::prepare(#changed_event { + action: event.action, + change_kind: event.change_kind, + id: event.id.clone(), + #subscription_release_key_field + source_entity: event.source_entity.clone(), + source_id: event.source_id.clone(), + path: event.path.clone(), + entity: Some(row), + }, ctx).await?; + // Successful serialization can itself await or revoke authority. + // Recheck the complete current release contract before yielding. + match authorize_current().await { + Ok(_) => Some(prepared), + Err(error) => { let _ = ::graphql_orm::graphql::loaders::relation_authorization_error(error); None } + } + }, + Err(error) => { let _ = ::graphql_orm::graphql::loaders::relation_authorization_error(error); None } + } + } + })) + } + } + + }; + let generated_propagation_driver = quote! { + #[doc(hidden)] + #[allow(clippy::too_many_arguments)] + pub(crate) fn __gom_propagate_changed_event<'a>( + hook_ctx: &'a mut ::graphql_orm::graphql::orm::MutationContext<'_, #backend_marker>, + action: ::graphql_orm::graphql::orm::ChangeAction, + entity: &'a Self, + source_entity: &'a str, + source_id: &'a str, + path: Vec, + visited: &'a mut ::std::collections::HashSet, + provenance: &'a [::graphql_orm::graphql::subscriptions::SubscriptionEventSource<#backend_marker>], + ) -> ::graphql_orm::futures::future::BoxFuture<'a, ::graphql_orm::Result<()>> { + Box::pin(async move { + if provenance.len() >= ::graphql_orm::graphql::subscriptions::MAX_SUBSCRIPTION_PROVENANCE_HOPS + || visited.len() >= ::graphql_orm::graphql::subscriptions::MAX_SUBSCRIPTION_PROPAGATION_EVENTS { return Ok(()); } + #(#propagation_blocks)* + Ok(()) + }) + } + + }; + let readonly_composite_changed = if (has_composite_primary_key + || schema_policy_read_only + || !backend_writable) + && !entity_meta.append_only + { + quote! { + #[derive(Debug, Clone, ::graphql_orm::async_graphql::SimpleObject, serde::Serialize, serde::Deserialize)] + #[graphql(name = #changed_event_str, rename_fields = #field_case_rule)] + pub struct #changed_event { + pub action: ::graphql_orm::graphql::orm::ChangeAction, + #[graphql(name = #change_kind_field_name)] + pub change_kind: ::graphql_orm::graphql::orm::ChangeKind, + pub id: #subscription_identity_type, + #subscription_extra_key_field + #[graphql(name = #source_entity_field_name)] + pub source_entity: Option, + #[graphql(name = #source_id_field_name)] + pub source_id: Option, + pub path: Vec, + #[graphql(name = #entity_result_field_name)] + pub entity: Option<#struct_name>, + } + #generated_subscription_surface + impl #struct_name { + #subscription_key_method + #propagated_subscription_emitter + #generated_propagation_driver + } + } + } else { + quote! {} + }; + if !backend_writable || schema_policy_read_only || has_composite_primary_key @@ -7198,6 +7422,7 @@ fn generate_graphql_operations_unfiltered( #retention_surface #composite_mutation_surface #composite_write_stubs + #readonly_composite_changed }); } @@ -8028,102 +8253,7 @@ fn generate_graphql_operations_unfiltered( // Subscription Struct // ============================================================================ - /// Generated subscriptions for #struct_name - #[derive(Default)] - pub struct #subscriptions_struct; - - #[::graphql_orm::async_graphql::Subscription] - impl #subscriptions_struct { - /// Subscribe to #struct_name_str changes - #[doc = #subscription_operation_description] - #[graphql(name = #subscription_name, #subscription_requires_scopes #authenticated_directive)] - async fn on_changed( - &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)?; - #subscription_scope_enforcement - ::graphql_orm::graphql::assurance::enforce_resolver_assurance( - ctx, - ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription, - )?; - let db = ctx.data::<::graphql_orm::db::Database<#backend_marker>>().map_err(|_| { - ::graphql_orm::async_graphql::Error::new( - "graphql-orm Database runtime not registered; build the schema with schema_builder(database) or add Database to schema data", - ) - })?; - db.ensure_entity_access( - Some(ctx), - #entity_name_lit, - <#struct_name as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, - ::graphql_orm::graphql::orm::EntityAccessKind::Read, - ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlSubscription, - ).await?; - - if _filter.as_ref().is_some_and(|filter| filter.dummy.is_some()) { - return Err(::graphql_orm::graphql::errors::OrmPublicError::new(::graphql_orm::graphql::errors::OrmErrorCode::InvalidInput).into_graphql_error()); - } - ::graphql_orm::graphql::subscriptions::validate_subscription_selection::<#struct_name, #backend_marker>(ctx, #entity_result_field_name)?; - let actions = _filter.and_then(|filter| filter.actions); - let rx = db.ensure_event_sender::<::graphql_orm::graphql::subscriptions::SubscriptionEvent<#changed_event, #backend_marker>>().subscribe(); - let db = db.clone(); - 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; } - let authorized: ::graphql_orm::async_graphql::Result<#struct_name> = async { - let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; - #subscription_scope_enforcement - ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; - witness.authorize_provenance(&db, ctx, event.source_entity.as_deref(), event.source_id.as_deref(), &event.path, event.change_kind == ::graphql_orm::graphql::orm::ChangeKind::Propagated).await?; - if witness.trusted_delete - && event.entity.as_ref().is_some_and(|entity| - ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&#struct_name::__gom_subscription_key_values(entity)) - != ::graphql_orm::graphql::loaders::RelationKey::from_sql_values(&witness.keys) - ) { return Err(::graphql_orm::graphql::errors::OrmPublicError::forbidden().into_graphql_error()); } - 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, - <#struct_name as ::graphql_orm::graphql::orm::Entity>::metadata().read_policy, - ::graphql_orm::graphql::orm::EntityAccessKind::Read, - ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlSubscription).await?; - let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; - #subscription_scope_enforcement - ::graphql_orm::graphql::assurance::enforce_resolver_assurance(ctx, ::graphql_orm::graphql::orm::GraphqlOperationKind::Subscription)?; - Ok(row) - }.await; - match authorized { - Ok(row) => Some(#changed_event { - action: event.action, - change_kind: event.change_kind, - id: event.id.clone(), - source_entity: event.source_entity.clone(), - source_id: event.source_id.clone(), - path: event.path.clone(), - entity: Some(row), - }), - Err(error) => { let _ = ::graphql_orm::graphql::loaders::relation_authorization_error(error); None } - } - } - })) - } - } + #generated_subscription_surface // ============================================================================ // Repository Trait Implementation @@ -8164,25 +8294,7 @@ fn generate_graphql_operations_unfiltered( #subscription_key_method #propagated_subscription_emitter - #[doc(hidden)] - #[allow(clippy::too_many_arguments)] - pub(crate) fn __gom_propagate_changed_event<'a>( - hook_ctx: &'a mut ::graphql_orm::graphql::orm::MutationContext<'_, #backend_marker>, - action: ::graphql_orm::graphql::orm::ChangeAction, - entity: &'a Self, - source_entity: &'a str, - source_id: &'a str, - path: Vec, - visited: &'a mut ::std::collections::HashSet, - provenance: &'a [::graphql_orm::graphql::subscriptions::SubscriptionEventSource<#backend_marker>], - ) -> ::graphql_orm::futures::future::BoxFuture<'a, ::graphql_orm::Result<()>> { - Box::pin(async move { - if provenance.len() >= ::graphql_orm::graphql::subscriptions::MAX_SUBSCRIPTION_PROVENANCE_HOPS - || visited.len() >= ::graphql_orm::graphql::subscriptions::MAX_SUBSCRIPTION_PROPAGATION_EVENTS { return Ok(()); } - #(#propagation_blocks)* - Ok(()) - }) - } + #generated_propagation_driver #[doc(hidden)] async fn __gom_queue_changed_event( diff --git a/crates/graphql-orm-macros/src/schema_roots.rs b/crates/graphql-orm-macros/src/schema_roots.rs index 44405ae7..5034da5a 100644 --- a/crates/graphql-orm-macros/src/schema_roots.rs +++ b/crates/graphql-orm-macros/src/schema_roots.rs @@ -134,7 +134,7 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { .iter() .map(|entity| { let mutations_exposed = generated_mutation_is_exposed(entity); - let subscriptions_exposed = backend_writable && !schema_policy_read_only; + let subscriptions_exposed = true; quote! { ( <#entity as ::graphql_orm::graphql::orm::GraphqlOperationMetadata> @@ -333,12 +333,25 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { }) .collect(); + let (readonly_subscription_root, readonly_subscription_value) = if subscription_types + .is_empty() + { + ( + quote! { pub type SubscriptionRoot = ::graphql_orm::async_graphql::EmptySubscription; }, + quote! { ::graphql_orm::async_graphql::EmptySubscription }, + ) + } else { + ( + emit_chunked_merged_subscription("Subscription", None, &subscription_types), + quote! { SubscriptionRoot::default() }, + ) + }; return quote! { #query_root #operation_catalog_helper pub type MutationRoot = ::graphql_orm::async_graphql::EmptyMutation; - pub type SubscriptionRoot = ::graphql_orm::async_graphql::EmptySubscription; + #readonly_subscription_root pub type AppSchema = ::graphql_orm::async_graphql::Schema; pub const GRAPHQL_ORM_SCHEMA_POLICY: Option<::graphql_orm::graphql::orm::SchemaPolicy> = #schema_policy_const; @@ -359,7 +372,7 @@ pub(crate) fn expand(input: TokenStream) -> TokenStream { let builder = ::graphql_orm::async_graphql::Schema::build( QueryRoot::default(), ::graphql_orm::async_graphql::EmptyMutation, - ::graphql_orm::async_graphql::EmptySubscription, + #readonly_subscription_value, ) .data(database.clone()); let builder = limits.apply(builder); diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index d683a8d0..18f66b1d 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -15,6 +15,18 @@ The scalar-only input, complete primary-key suffix, order-bound cursor and curre field authorization are described in the [migration guide](../../MIGRATION.md#0610-to-0620-runtime-keyset-ordering). +Generated Changed delivery prepares the selected snapshot before yielding a +stream item. Late scalar refusals and policy errors, including nullable fields, +discard the entire event and private diagnostics without an error frame. A +later authorized event starts with clean response errors. After successful +serialization, delivery rechecks current source, target, key and selected-field +authority before yielding the prepared value. + +Version 0.64.0 generates complete composite-key and read-only projection change +subscriptions independently of mutation exposure. Read-only and composite +`GraphQLOperations` entities derive both serde traits for their observer events. See the +[migration guide](../../MIGRATION.md#0630-to-0640-composite-and-read-only-changed). + Version 0.63.0 resolves generated upward propagation using complete relation from/to tuples and revalidates the current mapping at release. See the [migration guide](../../MIGRATION.md#0620-to-0630-propagation-relation-tuples). diff --git a/crates/graphql-orm/src/graphql/subscriptions.rs b/crates/graphql-orm/src/graphql/subscriptions.rs index afbed8a1..6021f344 100644 --- a/crates/graphql-orm/src/graphql/subscriptions.rs +++ b/crates/graphql-orm/src/graphql/subscriptions.rs @@ -10,6 +10,71 @@ use async_graphql::Context; use futures::future::BoxFuture; use std::sync::Arc; +/// A selected Changed payload prepared before the stream releases an item. +/// +/// A nullable scalar guard can fail after authorization preflight. Resolve the +/// selected snapshot inside the generated filter, discard every failed payload +/// and its private diagnostics, and yield only a completed value. The GraphQL +/// event identity and internal observer DTO remain unchanged. +#[doc(hidden)] +pub struct ChangedPayload { + value: async_graphql::Value, + type_name: std::borrow::Cow<'static, str>, + marker: std::marker::PhantomData T>, +} + +impl ChangedPayload { + /// Prepare one selected event; failures never become subscription frames. + pub async fn prepare(event: T, ctx: &Context<'_>) -> Option { + let errors_before = ctx.query_env.errors.lock().unwrap().len(); + let type_name = event.introspection_type_name(); + let selection = ctx.with_selection_set(&ctx.item.node.selection_set); + let resolved = event.resolve(&selection, ctx.item).await; + let field_failed = { + let mut errors = ctx.query_env.errors.lock().unwrap(); + let failed = errors.len() > errors_before; + if failed { + errors.truncate(errors_before); + } + failed + }; + if field_failed { + return None; + } + resolved.ok().map(|value| Self { + value, + type_name, + marker: std::marker::PhantomData, + }) + } +} + +impl async_graphql::OutputType for ChangedPayload { + fn type_name() -> std::borrow::Cow<'static, str> { + T::type_name() + } + + fn qualified_type_name() -> String { + T::qualified_type_name() + } + + fn introspection_type_name(&self) -> std::borrow::Cow<'static, str> { + self.type_name.clone() + } + + fn create_type_info(registry: &mut async_graphql::registry::Registry) -> String { + T::create_type_info(registry) + } + + async fn resolve( + &self, + _ctx: &async_graphql::ContextSelectionSet<'_>, + _field: &async_graphql::Positioned, + ) -> async_graphql::ServerResult { + Ok(self.value.clone()) + } +} + /// Maximum number of relationship hops retained and checked for an event. pub const MAX_SUBSCRIPTION_PROVENANCE_HOPS: usize = 16; /// Maximum propagated targets examined for one originating change. @@ -57,11 +122,15 @@ impl SubscriptionEventSource { where T: Entity + FromSqlRow + Clone + Send + Sync + 'static, { - let id = crate::graphql::loaders::RelationKey::from_sql_values(&keys) - .parts() - .first() - .cloned() - .unwrap_or_default(); + let id = if keys.len() > 1 { + crate::graphql::orm::composite_key_id(&keys) + } else { + crate::graphql::loaders::RelationKey::from_sql_values(&keys) + .parts() + .first() + .cloned() + .unwrap_or_default() + }; Self { entity: T::entity_name(), id, @@ -519,7 +588,9 @@ pub fn validate_subscription_selection( for selected in crate::graphql::loaders::relation_selected_fields(ctx, &ctx.item.node.selection_set.node) { - if selected.node.name.node.as_str().eq_ignore_ascii_case("id") { + if selected.node.name.node.as_str().eq_ignore_ascii_case("id") + || selected.node.name.node.as_str().eq_ignore_ascii_case("key") + { for key in T::PRIMARY_KEYS { let available = T::columns() .iter() @@ -571,7 +642,9 @@ where crate::graphql::loaders::relation_selected_fields(ctx, &ctx.item.node.selection_set.node) { let field_ctx = ctx.with_field(selected); - if selected.node.name.node.as_str().eq_ignore_ascii_case("id") { + if selected.node.name.node.as_str().eq_ignore_ascii_case("id") + || selected.node.name.node.as_str().eq_ignore_ascii_case("key") + { authorize_subscription_keys::(db, &field_ctx, row).await?; } else if selected.node.name.node.as_str() == entity_field { for scalar in crate::graphql::loaders::relation_selected_fields( diff --git a/crates/graphql-orm/src/lib.rs b/crates/graphql-orm/src/lib.rs index f220024e..62311211 100644 --- a/crates/graphql-orm/src/lib.rs +++ b/crates/graphql-orm/src/lib.rs @@ -497,7 +497,7 @@ pub mod types; /// ``` /// use graphql_orm::prelude::*; /// -/// #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] +/// #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] /// #[graphql_entity( /// backend = "sqlite", /// table = "composite_write_records", @@ -523,7 +523,7 @@ pub mod types; /// ```compile_fail /// use graphql_orm::prelude::*; /// -/// #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] +/// #[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] /// #[graphql_entity( /// backend = "sqlite", /// table = "composite_write_records", diff --git a/crates/graphql-orm/tests/composite_readonly_subscriptions.rs b/crates/graphql-orm/tests/composite_readonly_subscriptions.rs new file mode 100644 index 00000000..07be8ed2 --- /dev/null +++ b/crates/graphql-orm/tests/composite_readonly_subscriptions.rs @@ -0,0 +1,789 @@ +#![cfg(feature = "sqlite")] +use graphql_orm::async_graphql::{Request, Response}; +use graphql_orm::futures::{Stream, StreamExt, future::poll_fn}; +use graphql_orm::prelude::*; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use std::task::Poll; +use tokio::time::{Duration, timeout}; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "composite_events", + plural = "CompositeEvents", + repository_mutations = true +)] +struct CompositeEventRow { + #[primary_key] + #[sortable] + tenant: String, + #[primary_key] + code: String, + label: String, + #[graphql(skip)] + #[relation(target = "ReadonlyEventProjection", from = ["tenant", "code"], to = ["tenant", "code"], propagate_change = "up", emit_fk = false)] + projection: Option, +} +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[graphql_entity( + table = "composite_events", + plural = "ReadonlyEventProjections", + schema_policy = "external_read_only" +)] +struct ReadonlyEventProjection { + #[primary_key] + #[sortable] + tenant: String, + #[primary_key] + code: String, + #[graphql_orm(read_policy = "label.read")] + label: Option, +} +schema_roots! { query_custom_ops: [], entities: [CompositeEventRow, ReadonlyEventProjection], generated_mutations: "none", } + +#[derive(Clone)] +struct Policy { + source: Arc, + target: Arc, + key: Arc, +} +impl EntityPolicy for Policy { + fn can_access_entity<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a Database, + _: &'static str, + _: Option<&'static str>, + _: EntityAccessKind, + _: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} +impl RowPolicy for Policy { + fn can_read_row<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a Database, + entity: &'static str, + _: Option<&'static str>, + _: EntityAccessSurface, + _: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + Ok(if entity == "CompositeEventRow" { + self.source.load(Ordering::SeqCst) + } else { + self.target.load(Ordering::SeqCst) + }) + }) + } + fn can_write_row<'a>( + &'a self, + _: Option<&'a async_graphql::Context<'_>>, + _: &'a Database, + _: &'static str, + _: Option<&'static str>, + _: EntityAccessSurface, + _: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} +impl FieldPolicy for Policy { + fn can_read_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + field: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(field != "code" || self.key.load(Ordering::SeqCst)) }) + } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} +async fn setup() -> (Database, Policy) { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + sqlx::query("CREATE TABLE composite_events (tenant TEXT NOT NULL, code TEXT NOT NULL, label TEXT NOT NULL, PRIMARY KEY(tenant,code))").execute(db.pool()).await.unwrap(); + let policy = Policy { + source: Arc::new(AtomicBool::new(true)), + target: Arc::new(AtomicBool::new(true)), + key: Arc::new(AtomicBool::new(true)), + }; + db.set_entity_policy(policy.clone()); + db.set_row_policy(policy.clone()); + db.set_field_policy(policy.clone()); + (db, policy) +} +fn input(code: &str) -> CreateCompositeEventRowInput { + CreateCompositeEventRowInput { + tenant: "same-tenant".into(), + code: code.into(), + label: code.into(), + } +} +async fn registered + Unpin>(stream: &mut S) { + poll_fn(|cx| match stream.poll_next_unpin(cx) { + Poll::Pending => Poll::Ready(()), + other => panic!("unexpected initial event {other:?}"), + }) + .await; +} +const DIRECT: &str = "subscription { compositeEventRowChanged(key: {tenant: \"same-tenant\", code: \"one\"}) { id key {tenant code} changeKind compositeEventRow {label} } }"; +const PROJECTED: &str = "subscription { readonlyEventProjectionChanged(key: {tenant: \"same-tenant\", code: \"one\"}) { id key {tenant code} sourceId changeKind readonlyEventProjection {label} } }"; +#[tokio::test] +async fn complete_key_filter_and_readonly_projection_share_only_the_exact_identity() { + let (db, _) = setup().await; + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut direct = Box::pin(schema.execute_stream(DIRECT)); + let mut projection = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut direct).await; + registered(&mut projection).await; + CompositeEventRow::insert(&db, input("two")).await.unwrap(); + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + let a = timeout(Duration::from_secs(2), direct.next()) + .await + .unwrap() + .unwrap(); + let b = timeout(Duration::from_secs(2), projection.next()) + .await + .unwrap() + .unwrap(); + assert!(a.errors.is_empty(), "{:?}", a.errors); + assert!(b.errors.is_empty(), "{:?}", b.errors); + let a = a.data.into_json().unwrap(); + let b = b.data.into_json().unwrap(); + assert_eq!(a["compositeEventRowChanged"]["key"]["code"], "one"); + assert_eq!(b["readonlyEventProjectionChanged"]["key"]["code"], "one"); + assert_eq!( + a["compositeEventRowChanged"]["id"], + b["readonlyEventProjectionChanged"]["sourceId"] + ); + assert_eq!( + b["readonlyEventProjectionChanged"]["changeKind"], + "PROPAGATED" + ); + assert!( + timeout(Duration::from_millis(100), direct.next()) + .await + .is_err() + ); + assert!( + timeout(Duration::from_millis(100), projection.next()) + .await + .is_err() + ); +} +#[tokio::test] +async fn projected_release_revalidates_source_target_and_all_key_fields() { + for denied in ["source", "target", "key"] { + let (db, policy) = setup().await; + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut stream).await; + match denied { + "source" => policy.source.store(false, Ordering::SeqCst), + "target" => policy.target.store(false, Ordering::SeqCst), + _ => policy.key.store(false, Ordering::SeqCst), + } + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "released {denied}" + ); + } +} +#[tokio::test] +async fn rollback_does_not_emit_composite_or_readonly_projection_events() { + let (db, _) = setup().await; + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut stream).await; + let result: Result<(), TransactionError> = db + .transaction(TransactionMode::Default, |tx| { + Box::pin(async move { + tx.insert::(input("one")).await?; + Err(OrmPublicError::forbidden()) + }) + }) + .await; + assert!(result.is_err()); + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err() + ); +} +#[tokio::test] +async fn deleted_composite_source_preimages_do_not_release_direct_or_readonly_events() { + let (db, _) = setup().await; + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut direct = Box::pin(schema.execute_stream(DIRECT)); + let mut projection = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut direct).await; + registered(&mut projection).await; + CompositeEventRow::delete_by_key( + &db, + &CompositeEventRowKey { + tenant: "same-tenant".into(), + code: "one".into(), + }, + ) + .await + .unwrap(); + assert!( + timeout(Duration::from_millis(100), direct.next()) + .await + .is_err(), + "deleted direct preimage lacks current row authority" + ); + assert!( + timeout(Duration::from_millis(100), projection.next()) + .await + .is_err(), + "deleted source cannot establish current projection provenance" + ); + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + for (response, field, kind) in [ + ( + timeout(Duration::from_secs(2), direct.next()) + .await + .unwrap() + .unwrap(), + "compositeEventRowChanged", + "DIRECT", + ), + ( + timeout(Duration::from_secs(2), projection.next()) + .await + .unwrap() + .unwrap(), + "readonlyEventProjectionChanged", + "PROPAGATED", + ), + ] { + assert!(response.errors.is_empty(), "{:?}", response.errors); + let value = response.data.into_json().unwrap(); + assert_eq!(value[field]["key"]["tenant"], "same-tenant"); + assert_eq!(value[field]["key"]["code"], "one"); + assert_eq!(value[field]["changeKind"], kind); + } +} + +#[tokio::test] +async fn generated_catalogue_matches_readonly_subscription_sdl_without_mutations() { + let (db, _) = setup().await; + let schema = schema_builder(db).data(AuthSubject::new("tester")).finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("readonlyEventProjectionChanged")); + assert!(sdl.contains("ReadonlyEventProjectionKeyInput")); + assert!(!sdl.contains("createReadonlyEventProjection")); + assert!(!sdl.contains("type MutationRoot")); + let catalog = graphql_orm_operation_catalog(); + assert!( + catalog + .operations() + .iter() + .any(|op| op.field_name() == "readonlyEventProjectionChanged") + ); + assert!( + !catalog + .operations() + .iter() + .any(|op| op.kind() == GraphqlOperationKind::Mutation) + ); + let mut invalid = Box::pin(schema.execute_stream(Request::new( + "subscription { readonlyEventProjectionChanged(key: {tenant: \"same-tenant\"}) { id } }", + ))); + let invalid = timeout(Duration::from_secs(2), invalid.next()) + .await + .unwrap() + .unwrap(); + assert!(!invalid.errors.is_empty()); +} + +#[tokio::test] +async fn raw_readonly_observer_events_cannot_enter_generated_delivery() { + let (db, _) = setup().await; + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut stream).await; + db.emit_event(ReadonlyEventProjectionChangedEvent { + id: "untrusted".into(), + key: ReadonlyEventProjectionKey { + tenant: "same-tenant".into(), + code: "one".into(), + }, + action: ChangeAction::Updated, + change_kind: ChangeKind::Direct, + source_entity: None, + source_id: None, + path: vec![], + entity: None, + }); + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + 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["readonlyEventProjectionChanged"]["changeKind"], + "PROPAGATED" + ); + assert_ne!(value["readonlyEventProjectionChanged"]["id"], "untrusted"); +} + +mod readonly_root { + use super::{ + ReadonlyEventProjection, ReadonlyEventProjectionQueries, + ReadonlyEventProjectionSubscriptions, + }; + use graphql_orm::prelude::*; + schema_roots! { schema_policy: "external_read_only", query_custom_ops: [], entities: [ReadonlyEventProjection], } + #[tokio::test] + async fn schema_readonly_policy_keeps_generated_subscription_and_empty_mutation() { + let (db, _) = super::setup().await; + let schema = schema_builder(db).finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("readonlyEventProjectionChanged")); + assert!(!sdl.contains("createReadonlyEventProjection")); + assert!( + graphql_orm_operation_catalog() + .operations() + .iter() + .any(|operation| operation.kind() == GraphqlOperationKind::Subscription) + ); + assert!( + !graphql_orm_operation_catalog() + .operations() + .iter() + .any(|operation| operation.kind() == GraphqlOperationKind::Mutation) + ); + } +} + +#[derive(Clone)] +struct RevokeDuringSerialization(Arc, Arc); +impl async_graphql::extensions::ExtensionFactory for RevokeDuringSerialization { + fn create(&self) -> Arc { + Arc::new(self.clone()) + } +} +#[async_graphql::async_trait::async_trait] +impl async_graphql::extensions::Extension for RevokeDuringSerialization { + async fn resolve( + &self, + ctx: &async_graphql::extensions::ExtensionContext<'_>, + info: async_graphql::extensions::ResolveInfo<'_>, + next: async_graphql::extensions::NextResolve<'_>, + ) -> async_graphql::ServerResult> { + if info.parent_type == "ReadonlyEventProjection" + && info.name == "label" + && self.1.fetch_add(1, Ordering::SeqCst) == 0 + { + self.0.store(true, Ordering::SeqCst); + } + next.run(ctx, info).await + } +} +struct DenyLabelAfterPreflight(Arc, Arc, bool); +impl FieldPolicy for DenyLabelAfterPreflight { + fn can_read_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + entity: &'static str, + field: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { + let selected = entity == "ReadonlyEventProjection" && field == "label"; + if selected { + self.1.fetch_add(1, Ordering::SeqCst); + } + let denied = selected && self.0.load(Ordering::SeqCst); + if denied && self.2 { + return Err(async_graphql::Error::new("PRIVATE_POLICY_DIAGNOSTIC")); + } + Ok(!denied) + }) + } + fn can_read_repository_field<'a>( + &'a self, + _: Option>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } + fn can_write_field<'a>( + &'a self, + _: &'a async_graphql::Context<'_>, + _: &'a Database, + _: &'static str, + _: &'static str, + _: Option<&'static str>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + _: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} +#[tokio::test] +async fn late_nullable_scalar_refusal_emits_no_frame_and_does_not_poison_the_next_event() { + for policy_error in [false, true] { + let (mut db, _) = setup().await; + let revoked = Arc::new(AtomicBool::new(false)); + let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + db.set_field_policy(DenyLabelAfterPreflight( + revoked.clone(), + checks.clone(), + policy_error, + )); + let schema = schema_builder(db.clone()) + .extension(RevokeDuringSerialization( + revoked.clone(), + serializations.clone(), + )) + .data(AuthSubject::new("tester")) + .finish(); + assert!( + schema.sdl().contains("label: String\n"), + "exercise a nullable scalar" + ); + let query = "subscription { readonlyEventProjectionChanged { id key {tenant code} sourceId changeKind readonlyEventProjection {label} } }"; + let mut stream = Box::pin(schema.execute_stream(query)); + registered(&mut stream).await; + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "late refusal must not release even an error frame" + ); + assert!( + checks.load(Ordering::SeqCst) >= 2, + "preflight and serialization both checked the field" + ); + assert_eq!( + serializations.load(Ordering::SeqCst), + 1, + "revocation must occur in real GraphQL serialization, after preflight" + ); + revoked.store(false, Ordering::SeqCst); + CompositeEventRow::insert(&db, input("two")).await.unwrap(); + let response = timeout(Duration::from_secs(2), stream.next()) + .await + .unwrap() + .unwrap(); + assert!( + response.errors.is_empty(), + "private diagnostics from a discarded event escaped: {:?}", + response.errors + ); + let value = response.data.into_json().unwrap(); + assert_eq!( + value["readonlyEventProjectionChanged"]["key"]["code"], + "two" + ); + assert_eq!( + value["readonlyEventProjectionChanged"]["readonlyEventProjection"]["label"], + "two" + ); + assert_eq!(serializations.load(Ordering::SeqCst), 2); + } +} + +#[derive(Clone)] +struct RevokeAfterSuccessfulSerialization { + authority: Arc, + revoked_value: bool, + serializations: Arc, +} +impl async_graphql::extensions::ExtensionFactory for RevokeAfterSuccessfulSerialization { + fn create(&self) -> Arc { + Arc::new(self.clone()) + } +} +#[async_graphql::async_trait::async_trait] +impl async_graphql::extensions::Extension for RevokeAfterSuccessfulSerialization { + async fn resolve( + &self, + ctx: &async_graphql::extensions::ExtensionContext<'_>, + info: async_graphql::extensions::ResolveInfo<'_>, + next: async_graphql::extensions::NextResolve<'_>, + ) -> async_graphql::ServerResult> { + let revoke = info.parent_type == "ReadonlyEventProjection" + && info.name == "label" + && self.serializations.fetch_add(1, Ordering::SeqCst) == 0; + let value = next.run(ctx, info).await; + if revoke { + assert!( + value.is_ok(), + "the selected scalar must serialize successfully before revocation" + ); + self.authority.store(self.revoked_value, Ordering::SeqCst); + } + value + } +} + +#[tokio::test] +async fn successful_serialization_cannot_outlive_source_target_key_or_field_authority() { + for denied in ["source", "target", "key", "field", "field_error"] { + let (mut db, policy) = setup().await; + let field_revoked = Arc::new(AtomicBool::new(false)); + let field_checks = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (authority, revoked_value) = match denied { + "source" => (policy.source.clone(), false), + "target" => (policy.target.clone(), false), + "key" => (policy.key.clone(), false), + _ => { + db.set_field_policy(DenyLabelAfterPreflight( + field_revoked.clone(), + field_checks.clone(), + denied == "field_error", + )); + (field_revoked, true) + } + }; + let serializations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let schema = schema_builder(db.clone()) + .extension(RevokeAfterSuccessfulSerialization { + authority: authority.clone(), + revoked_value, + serializations: serializations.clone(), + }) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(PROJECTED)); + registered(&mut stream).await; + CompositeEventRow::insert(&db, input("one")).await.unwrap(); + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "successful serialization released a frame after {denied} authority was revoked" + ); + assert_eq!( + serializations.load(Ordering::SeqCst), + 1, + "exercise real successful scalar serialization" + ); + if denied.starts_with("field") { + assert!( + field_checks.load(Ordering::SeqCst) >= 3, + "check field authority before, during, and after successful serialization" + ); + } + authority.store(!revoked_value, Ordering::SeqCst); + CompositeEventRow::update_by_key( + &db, + &CompositeEventRowKey { + tenant: "same-tenant".into(), + code: "one".into(), + }, + UpdateCompositeEventRowInput { + label: Some("after".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + let response = timeout(Duration::from_secs(2), stream.next()) + .await + .unwrap() + .unwrap(); + assert!( + response.errors.is_empty(), + "discarded private failure poisoned the next event: {:?}", + response.errors + ); + let value = response.data.into_json().unwrap(); + assert_eq!( + value["readonlyEventProjectionChanged"]["key"]["code"], + "one" + ); + assert_eq!( + value["readonlyEventProjectionChanged"]["readonlyEventProjection"]["label"], + "after" + ); + assert_eq!(serializations.load(Ordering::SeqCst), 2); + } +} + +mod composite_cycles { + use super::{Duration, StreamExt, registered, timeout}; + use graphql_orm::prelude::*; + + #[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize, + )] + #[graphql_entity( + table = "composite_propagation_nodes", + plural = "CompositePropagationNodes", + repository_mutations = true + )] + struct CompositePropagationNode { + #[primary_key] + #[sortable] + tenant: String, + #[primary_key] + code: String, + parent_tenant: Option, + parent_code: Option, + label: String, + #[graphql(skip)] + #[relation(target = "CompositePropagationNode", from = ["parent_tenant", "parent_code"], to = ["tenant", "code"], propagate_change = "up", emit_fk = false)] + parent: Option>, + } + schema_roots! { query_custom_ops: [], entities: [CompositePropagationNode], generated_mutations: "none", } + + async fn setup() -> Database { + let mut db = Database::connect_sqlite("sqlite::memory:").await.unwrap(); + let policy = super::Policy { + source: std::sync::Arc::new(super::AtomicBool::new(true)), + target: std::sync::Arc::new(super::AtomicBool::new(true)), + key: std::sync::Arc::new(super::AtomicBool::new(true)), + }; + db.set_entity_policy(policy.clone()); + db.set_row_policy(policy.clone()); + db.set_field_policy(policy); + sqlx::query("CREATE TABLE composite_propagation_nodes (tenant TEXT NOT NULL, code TEXT NOT NULL, parent_tenant TEXT, parent_code TEXT, label TEXT NOT NULL, PRIMARY KEY(tenant,code))").execute(db.pool()).await.unwrap(); + db + } + async fn seed(db: &Database, code: &str, parent: Option<&str>) { + sqlx::query("INSERT INTO composite_propagation_nodes (tenant,code,parent_tenant,parent_code,label) VALUES (?,?,?,?,?)") + .bind("shared-tenant").bind(code).bind(parent.map(|_| "shared-tenant")) + .bind(parent).bind("before").execute(db.pool()).await.unwrap(); + } + async fn update(db: &Database, code: &str) { + CompositePropagationNode::update_by_key( + db, + &CompositePropagationNodeKey { + tenant: "shared-tenant".into(), + code: code.into(), + }, + UpdateCompositePropagationNodeInput { + label: Some("after".into()), + ..Default::default() + }, + ) + .await + .unwrap() + .expect("source row"); + } + const QUERY: &str = + "subscription { compositePropagationNodeChanged { key {tenant code} path changeKind } }"; + + #[tokio::test] + async fn composite_cycle_does_not_reemit_the_direct_source() { + let db = setup().await; + seed(&db, "one", Some("two")).await; + seed(&db, "two", Some("one")).await; + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(QUERY)); + registered(&mut stream).await; + update(&db, "one").await; + for (code, kind, depth) in [("one", "DIRECT", 0), ("two", "PROPAGATED", 1)] { + 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(); + let event = &value["compositePropagationNodeChanged"]; + assert_eq!(event["key"]["tenant"], "shared-tenant"); + assert_eq!(event["key"]["code"], code); + assert_eq!(event["changeKind"], kind); + assert_eq!(event["path"].as_array().unwrap().len(), depth); + } + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "cycle re-emitted an already notified complete key" + ); + } + + #[tokio::test] + async fn composite_chain_stops_at_sixteen_authorized_hops() { + let db = setup().await; + for index in 0..40 { + let parent = (index > 0).then(|| format!("node-{}", index - 1)); + seed(&db, &format!("node-{index}"), parent.as_deref()).await; + } + let schema = schema_builder(db.clone()) + .data(AuthSubject::new("tester")) + .finish(); + let mut stream = Box::pin(schema.execute_stream(QUERY)); + registered(&mut stream).await; + update(&db, "node-39").await; + for depth in 0..=16 { + 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(); + let event = &value["compositePropagationNodeChanged"]; + assert_eq!(event["key"]["tenant"], "shared-tenant"); + assert_eq!(event["key"]["code"], format!("node-{}", 39 - depth)); + assert_eq!(event["path"].as_array().unwrap().len(), depth); + } + assert!( + timeout(Duration::from_millis(100), stream.next()) + .await + .is_err(), + "composite propagation exceeded its hop bound" + ); + } +} diff --git a/crates/graphql-orm/tests/federation_entity_keys_mssql.rs b/crates/graphql-orm/tests/federation_entity_keys_mssql.rs index 69c572b3..1594b7ef 100644 --- a/crates/graphql-orm/tests/federation_entity_keys_mssql.rs +++ b/crates/graphql-orm/tests/federation_entity_keys_mssql.rs @@ -8,7 +8,7 @@ use graphql_orm::async_graphql::{EmptyMutation, EmptySubscription, SDLExportOptions, Schema}; use graphql_orm::prelude::*; -#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug)] +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Serialize, serde::Deserialize)] #[graphql_entity( backend = "mssql", table = "dbo.FederationZones", diff --git a/crates/graphql-orm/tests/read_projections.rs b/crates/graphql-orm/tests/read_projections.rs index b69d0f7f..516128ca 100644 --- a/crates/graphql-orm/tests/read_projections.rs +++ b/crates/graphql-orm/tests/read_projections.rs @@ -61,7 +61,9 @@ schema_roots! { entities: [ProjectionCertificate], } -#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq)] +#[derive( + GraphQLEntity, GraphQLOperations, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize, +)] #[graphql_entity( table = "projection_probe", plural = "ProjectionProbes", diff --git a/crates/graphql-orm/tests/resolver_operation_metadata.rs b/crates/graphql-orm/tests/resolver_operation_metadata.rs index 2380480c..1491099a 100644 --- a/crates/graphql-orm/tests/resolver_operation_metadata.rs +++ b/crates/graphql-orm/tests/resolver_operation_metadata.rs @@ -590,17 +590,17 @@ fn read_only_composite_and_append_only_profiles_report_only_actual_resolvers() { use root_read_only_surface::RootReadOnlyMetadataRecord; let read_only = ReadOnlyMetadataRecord::generated_graphql_operations(); - assert_eq!(read_only.len(), 2); + assert_eq!(read_only.len(), 3); assert!( read_only .iter() - .all(|operation| operation.kind() == GraphqlOperationKind::Query) + .all(|operation| operation.kind() != GraphqlOperationKind::Mutation) ); assert_eq!( read_only_surface::graphql_orm_operation_catalog() .exposed_operations() .count(), - 2 + 3 ); let root_read_only = RootReadOnlyMetadataRecord::generated_graphql_operations(); @@ -610,13 +610,13 @@ fn read_only_composite_and_append_only_profiles_report_only_actual_resolvers() { .any(|operation| { operation.kind() == GraphqlOperationKind::Subscription }) ); let root_read_only_catalog = root_read_only_surface::graphql_orm_operation_catalog(); - assert_eq!(root_read_only_catalog.exposed_operations().count(), 2); + assert_eq!(root_read_only_catalog.exposed_operations().count(), 3); assert!(root_read_only_catalog.operations().iter().all(|operation| { - operation.kind() == GraphqlOperationKind::Query || !operation.is_exposed() + operation.kind() != GraphqlOperationKind::Mutation || !operation.is_exposed() })); let composite = CompositeMetadataRecord::generated_graphql_operations(); - assert_eq!(composite.len(), 2); + assert_eq!(composite.len(), 3); let single = operation(composite, GeneratedGraphqlOperationCategory::SingleRead); assert_eq!( single diff --git a/crates/graphql-orm/tests/resolver_operation_metadata_mssql.rs b/crates/graphql-orm/tests/resolver_operation_metadata_mssql.rs index 8d1b4ada..d0949833 100644 --- a/crates/graphql-orm/tests/resolver_operation_metadata_mssql.rs +++ b/crates/graphql-orm/tests/resolver_operation_metadata_mssql.rs @@ -27,15 +27,15 @@ schema_roots! { } #[test] -fn mssql_metadata_contains_only_read_resolvers() { +fn mssql_metadata_contains_queries_and_trusted_subscriptions_without_mutations() { let generated = ResolverMetadataRecord::generated_graphql_operations(); - assert_eq!(generated.len(), 2); + assert_eq!(generated.len(), 3); assert!(generated.iter().all(|operation| { - operation.backend() == "mssql" && operation.kind() == GraphqlOperationKind::Query + operation.backend() == "mssql" && operation.kind() != GraphqlOperationKind::Mutation })); let catalog = graphql_orm_operation_catalog(); - assert_eq!(catalog.exposed_operations().count(), 2); + assert_eq!(catalog.exposed_operations().count(), 3); assert!( catalog .operations() diff --git a/crates/graphql-orm/tests/ui/composite_mutations_sqlx_free.rs b/crates/graphql-orm/tests/ui/composite_mutations_sqlx_free.rs index 8cf2e7f7..de2b3631 100644 --- a/crates/graphql-orm/tests/ui/composite_mutations_sqlx_free.rs +++ b/crates/graphql-orm/tests/ui/composite_mutations_sqlx_free.rs @@ -1,6 +1,6 @@ use graphql_orm::prelude::*; -#[derive(GraphQLEntity, GraphQLOperations, serde::Serialize, Clone, Debug)] +#[derive(GraphQLEntity, GraphQLOperations, serde::Serialize, serde::Deserialize, Clone, Debug)] #[graphql_entity( backend = "sqlite", table = "private_grants", From aa6be6b993960a3b50f7efa60a186867308af04a Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 24 Oct 2026 06:39:31 +1100 Subject: [PATCH 2/3] chore: version generated Changed after relation tuples --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/graphql-orm-macros/Cargo.toml | 2 +- .../fixtures/cross-crate-relations/Cargo.lock | 4 ++-- .../fixtures/relationship-capability/Cargo.lock | 4 ++-- crates/graphql-orm/Cargo.toml | 4 ++-- .../graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock | 4 ++-- .../tests/fixtures/federation-end-to-end/Cargo.lock | 4 ++-- .../tests/fixtures/migration-api-compatibility/Cargo.lock | 4 ++-- .../tests/fixtures/repository-aggregate-consumer/Cargo.lock | 4 ++-- docs/reference/workspace-packages.md | 4 ++-- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b997ace..3d7f60d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3113,7 +3113,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.63.0" +version = "0.64.0" dependencies = [ "agql-auth", "async-graphql", @@ -3217,7 +3217,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 10f2daee..ce4f7096 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.63.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.64.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/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 424b73b7..bfbae188 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.63.0" +version = "0.64.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/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index 9785350a..d3728286 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.63.0" +version = "0.64.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1076,7 +1076,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.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 9f8bc3f9..44a72e46 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.63.0" +version = "0.64.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1152,7 +1152,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index cc9ec317..36126e60 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.63.0" +version = "0.64.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.63.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.64.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/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index bcb87353..5f0f8869 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.63.0" +version = "0.64.0" dependencies = [ "agql-auth", "async-graphql", @@ -1442,7 +1442,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.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 34a7db40..b40753d5 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.63.0" +version = "0.64.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2532,7 +2532,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.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 4c348bdd..8e9eb013 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.63.0" +version = "0.64.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1052,7 +1052,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.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 26826cae..22ccfd49 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.63.0" +version = "0.64.0" dependencies = [ "async-graphql", "async-trait", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.63.0" +version = "0.64.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 1365cf2d..29d2df41 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.63.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.64.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.63.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.64.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.5` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | From 88017cb3204505a76f4b088838e3efd9ab5dd8cb Mon Sep 17 00:00:00 2001 From: Toby Martin Date: Sat, 24 Oct 2026 07:37:34 +1100 Subject: [PATCH 3/3] test: remove redundant composite Changed struct update --- crates/graphql-orm/tests/composite_readonly_subscriptions.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/graphql-orm/tests/composite_readonly_subscriptions.rs b/crates/graphql-orm/tests/composite_readonly_subscriptions.rs index 07be8ed2..3c244a77 100644 --- a/crates/graphql-orm/tests/composite_readonly_subscriptions.rs +++ b/crates/graphql-orm/tests/composite_readonly_subscriptions.rs @@ -631,7 +631,6 @@ async fn successful_serialization_cannot_outlive_source_target_key_or_field_auth }, UpdateCompositeEventRowInput { label: Some("after".into()), - ..Default::default() }, ) .await