diff --git a/CHANGELOG.md b/CHANGELOG.md index 3716d79b..e90356ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-08-13 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -14,6 +14,25 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. +## 0.27.0 - 2026-08-26 + +Companion macros crate: `graphql-orm-macros` **0.27.0**. + +- Added opt-in, compile-time `source_condition` and `target_condition` predicates + for relationships over externally managed polymorphic reference columns. + Generated single, pageable, DataLoader, and nested bulk-preload paths enforce + the same fixed predicate with bound values. +- Qualified generated relation-loader identities by source entity, preventing + equal relationship field names on different parent types from sharing an + incompatible cached result. +- Conditional relationships must set `emit_fk = false`; they describe a + resolver join, not an unconditional physical foreign key. + +Existing relationship declarations and GraphQL SDL are unchanged. No database +or stored-data migration is required. Consumers that add conditional +relationships should regenerate semantic catalogues and dependent capability +fingerprints. + ## 0.26.0 - 2026-08-22 Companion macros crate: `graphql-orm-macros` **0.26.0**. Generated ORM and diff --git a/Cargo.lock b/Cargo.lock index 94a5c79c..1e39c8c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.26.0" +version = "0.27.0" dependencies = [ "agql-auth", "async-graphql", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.26.0" +version = "0.27.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index f377d66e..b0cf7471 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.26.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.27.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.0" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.1", default-features = false } graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.0" } diff --git a/MIGRATION.md b/MIGRATION.md index 6c5e0086..4f3d0583 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-08-13 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -13,6 +13,51 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.26.0 to 0.27.0: conditional polymorphic relationships + +Adopt `graphql-orm` and `graphql-orm-macros` 0.27.0 together from one reviewed +full Git revision. Existing relation declarations need no change. + +Externally managed tables that reuse one reference column for multiple target +types can declare a fixed source discriminator: + +```rust +#[relation( + target = "Document", + from = "reference_id", + to = "id", + source_condition(field = "reference_kind", equals = 1), + emit_fk = false +)] +pub document: Option, +``` + +The inverse collection can constrain the target rows: + +```rust +#[relation( + target = "Activity", + from = "id", + to = "reference_id", + target_condition(column = "reference_kind", equals = 1), + multiple, + emit_fk = false +)] +pub activity: Vec, +``` + +Condition values accept string, integer, float, and boolean literals. A source +condition must name a persisted scalar Rust field with a matching type; target +conditions name a physical target column and use the same trusted, bound-value +SQL path as generated filters. Conditional relationships always require +`emit_fk = false` because the discriminator makes the join conditional rather +than a physical foreign-key contract. + +No database, table, column, constraint, or stored-data migration is required. +After adding conditional relationships, regenerate the semantic catalogue and +any derived capability fingerprints so the new selectable graph is reviewed +and deployed atomically. + ## 0.25.1 to 0.26.0: agql-auth 0.18.0 alignment Git consumers using `auth-agql` must align direct `agql-auth` dependencies to diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 6b018362..583422a4 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.26.0" +version = "0.27.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 81eb877c..86f855b8 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-macros" kind: reference status: active owner: graphql-orm-macros-maintainers -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.27.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.27.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -111,5 +111,12 @@ non-null ordering objects. The macro derives relation resolver signatures and semantic relationship descriptors from the same internal contract so public names, nullability and `Where`/`OrderBy`/`Page` shapes remain byte-equivalent. +Conditional relations accept `source_condition(field = "...", equals = ...)` +or `target_condition(column = "...", equals = ...)` with string, integer, +float, or boolean literals. Source fields are compile-time type checked; target +columns are quoted by the selected backend and all values are bound. These +logical discriminator joins require `emit_fk = false` and are enforced by +single, pageable, DataLoader, and nested bulk-preload paths. + See [core runtime documentation](../graphql-orm/README.md), and the [macro and attribute reference](../../docs/reference/graphql-orm/macros-and-attributes.md). diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index 370c687a..aa5be77a 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -940,6 +940,8 @@ pub(crate) struct FieldMetadata { pub(crate) relation_emit_foreign_key: Option, pub(crate) relation_on_delete: Option, pub(crate) relation_propagate_change: Option, + pub(crate) relation_source_condition: Option, + pub(crate) relation_target_condition: Option, pub(crate) skip_db: bool, /// Skip from generated public GraphQL Create/Update inputs only; field remains in DB, /// the Rust entity, and generated trusted Rust Create/Update input structs. @@ -975,6 +977,20 @@ pub(crate) struct FieldMetadata { pub(crate) write_policy: Option, } +#[derive(Clone)] +pub(crate) enum RelationConditionValue { + String(String), + Integer(i64), + Float(f64), + Boolean(bool), +} + +#[derive(Clone)] +pub(crate) struct RelationConditionMetadata { + pub(crate) member: String, + pub(crate) value: RelationConditionValue, +} + #[derive(Clone)] pub(crate) struct SearchFieldMetadata { pub(crate) weight: String, @@ -1088,6 +1104,8 @@ impl Default for FieldMetadata { relation_emit_foreign_key: None, relation_on_delete: None, relation_propagate_change: None, + relation_source_condition: None, + relation_target_condition: None, skip_db: false, skip_input: false, is_private: false, @@ -1145,6 +1163,67 @@ fn parse_relation_columns(input: ParseStream<'_>) -> syn::Result> { } } +fn parse_relation_condition( + nested: syn::meta::ParseNestedMeta<'_>, + member_name: &str, +) -> syn::Result { + let mut member = None; + let mut condition_value = None; + nested.parse_nested_meta(|condition| { + if condition.path.is_ident(member_name) { + if member.is_some() { + return Err(condition.error(format!( + "duplicate relation condition `{member_name}`" + ))); + } + let value = condition.value()?; + let literal: syn::LitStr = value.parse()?; + let literal = literal.value(); + if literal.trim().is_empty() { + return Err(condition.error(format!( + "relation condition `{member_name}` must not be empty" + ))); + } + member = Some(literal); + } else if condition.path.is_ident("equals") { + if condition_value.is_some() { + return Err(condition.error("duplicate relation condition `equals`")); + } + let value = condition.value()?; + let literal: syn::Lit = value.parse()?; + condition_value = Some(match literal { + syn::Lit::Str(value) => RelationConditionValue::String(value.value()), + syn::Lit::Int(value) => { + RelationConditionValue::Integer(value.base10_parse()?) + } + syn::Lit::Float(value) => RelationConditionValue::Float(value.base10_parse()?), + syn::Lit::Bool(value) => RelationConditionValue::Boolean(value.value), + other => { + return Err(syn::Error::new_spanned( + other, + "relation condition values must be string, integer, float, or boolean literals", + )); + } + }); + } else { + return Err(condition.error("unsupported relation condition option")); + } + Ok(()) + })?; + + Ok(RelationConditionMetadata { + member: member.ok_or_else(|| { + syn::Error::new( + nested.path.span(), + format!("relation condition requires `{member_name}`"), + ) + })?, + value: condition_value.ok_or_else(|| { + syn::Error::new(nested.path.span(), "relation condition requires `equals`") + })?, + }) +} + fn parse_string_array_expr(input: ParseStream<'_>, message: &str) -> syn::Result> { let expr: syn::Expr = input.parse()?; match expr { @@ -1855,7 +1934,7 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result } "relation" => { meta.is_relation = true; - let _ = attr.parse_nested_meta(|nested| { + attr.parse_nested_meta(|nested| { if nested.path.is_ident("target") { let value = nested.value()?; let lit: syn::LitStr = value.parse()?; @@ -1886,11 +1965,25 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result let value = nested.value()?; let lit: syn::LitStr = value.parse()?; meta.relation_propagate_change = Some(lit.value()); + } else if nested.path.is_ident("source_condition") { + if meta.relation_source_condition.is_some() { + return Err(nested.error("duplicate relation source condition")); + } + meta.relation_source_condition = + Some(parse_relation_condition(nested, "field")?); + } else if nested.path.is_ident("target_condition") { + if meta.relation_target_condition.is_some() { + return Err(nested.error("duplicate relation target condition")); + } + meta.relation_target_condition = + Some(parse_relation_condition(nested, "column")?); } else if nested.path.is_ident("multiple") { meta.relation_multiple = true; + } else { + return Err(nested.error("unsupported relation option")); } Ok(()) - }); + })?; } "skip_db" => { meta.skip_db = true; @@ -2696,6 +2789,18 @@ fn generate_entity_impl( if field_meta.is_relation || field_meta.skip_db { if field_meta.is_relation { validate_relation_delete_policy(struct_name, field, &field_meta, &parsed_fields)?; + let emits_foreign_key = field_meta + .relation_emit_foreign_key + .unwrap_or(!field_meta.relation_multiple); + if (field_meta.relation_source_condition.is_some() + || field_meta.relation_target_condition.is_some()) + && emits_foreign_key + { + return Err(syn::Error::new_spanned( + field, + "conditional relations require `emit_fk = false` because they do not describe an unconditional physical foreign key", + )); + } let rust_name = field_name.to_string(); let graphql_name = graphql_field_name( &field_meta, diff --git a/crates/graphql-orm-macros/src/relations.rs b/crates/graphql-orm-macros/src/relations.rs index 54030032..278518de 100644 --- a/crates/graphql-orm-macros/src/relations.rs +++ b/crates/graphql-orm-macros/src/relations.rs @@ -3,8 +3,9 @@ use crate::backend::{ backend_marker_tokens, backend_pool_type_tokens, backend_quote_identifier_path, resolve_backend, }; use crate::entity::{ - collect_parsed_fields, has_graphql_complex, parse_entity_metadata, - relation_change_propagation_tokens, relation_delete_policy_tokens, resolver_auth_mode_tokens, + RelationConditionMetadata, RelationConditionValue, collect_parsed_fields, has_graphql_complex, + parse_entity_metadata, relation_change_propagation_tokens, relation_delete_policy_tokens, + resolver_auth_mode_tokens, }; use crate::naming::graphql_field_name; use crate::relationship_contract::GeneratedRelationshipArgumentContract; @@ -26,6 +27,15 @@ struct RelationDef { on_delete: proc_macro2::TokenStream, propagate_change: proc_macro2::TokenStream, storage_kind: RelationStorageKind, + source_condition: Option, + target_condition: Option, +} + +struct ResolvedSourceCondition { + field_name: syn::Ident, + kind: RelationValueKind, + optional: bool, + value: RelationConditionValue, } #[derive(Copy, Clone)] @@ -136,6 +146,70 @@ fn relation_key_part_kind_tokens(kind: RelationValueKind) -> proc_macro2::TokenS } } +fn condition_value_matches_kind(value: &RelationConditionValue, kind: RelationValueKind) -> bool { + matches!( + (value, kind), + (RelationConditionValue::String(_), RelationValueKind::String) + | (RelationConditionValue::Integer(_), RelationValueKind::Int) + | (RelationConditionValue::Float(_), RelationValueKind::Float) + | (RelationConditionValue::Boolean(_), RelationValueKind::Bool) + ) +} + +fn condition_sql_value_tokens(value: &RelationConditionValue) -> proc_macro2::TokenStream { + match value { + RelationConditionValue::String(value) => { + quote! { ::graphql_orm::graphql::orm::SqlValue::String(#value.to_owned()) } + } + RelationConditionValue::Integer(value) => { + quote! { ::graphql_orm::graphql::orm::SqlValue::Int(#value) } + } + RelationConditionValue::Float(value) => { + quote! { ::graphql_orm::graphql::orm::SqlValue::Float(#value) } + } + RelationConditionValue::Boolean(value) => { + quote! { ::graphql_orm::graphql::orm::SqlValue::Bool(#value) } + } + } +} + +fn source_condition_tokens( + condition: Option<&ResolvedSourceCondition>, + receiver: proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let Some(condition) = condition else { + return quote! { true }; + }; + let field_name = &condition.field_name; + match (&condition.value, condition.kind, condition.optional) { + (RelationConditionValue::String(value), RelationValueKind::String, false) => { + quote! { #receiver.#field_name == #value } + } + (RelationConditionValue::String(value), RelationValueKind::String, true) => { + quote! { #receiver.#field_name.as_deref() == Some(#value) } + } + (RelationConditionValue::Integer(value), RelationValueKind::Int, false) => { + quote! { (#receiver.#field_name as i64) == #value } + } + (RelationConditionValue::Integer(value), RelationValueKind::Int, true) => { + quote! { #receiver.#field_name.is_some_and(|value| (value as i64) == #value) } + } + (RelationConditionValue::Float(value), RelationValueKind::Float, false) => { + quote! { (#receiver.#field_name as f64) == #value } + } + (RelationConditionValue::Float(value), RelationValueKind::Float, true) => { + quote! { #receiver.#field_name.is_some_and(|value| (value as f64) == #value) } + } + (RelationConditionValue::Boolean(value), RelationValueKind::Bool, false) => { + quote! { #receiver.#field_name == #value } + } + (RelationConditionValue::Boolean(value), RelationValueKind::Bool, true) => { + quote! { #receiver.#field_name == Some(#value) } + } + _ => quote! { false }, + } +} + pub(crate) fn generate_graphql_relations( input: &DeriveInput, ) -> syn::Result { @@ -277,6 +351,63 @@ pub(crate) fn generate_graphql_relations( } let source_supports_dataloader = true; let is_multiple = meta.relation_multiple; + let emits_foreign_key = meta.relation_emit_foreign_key.unwrap_or(!is_multiple); + if (meta.relation_source_condition.is_some() || meta.relation_target_condition.is_some()) + && emits_foreign_key + { + return Err(syn::Error::new_spanned( + field, + "conditional relations require `emit_fk = false` because they do not describe an unconditional physical foreign key", + )); + } + let source_condition = if let Some(condition) = &meta.relation_source_condition { + let source_field = parsed_fields + .iter() + .find(|parsed| { + parsed + .field + .ident + .as_ref() + .is_some_and(|ident| ident == condition.member.as_str()) + }) + .ok_or_else(|| { + syn::Error::new_spanned( + field, + format!( + "Relation '{}' source condition references unknown field '{}' on '{}'", + rust_name, condition.member, struct_name + ), + ) + })?; + if source_field.meta.is_relation || source_field.meta.skip_db { + return Err(syn::Error::new_spanned( + &source_field.field, + "relation source conditions require a persisted scalar field", + )); + } + let (kind, optional) = classify_relation_value_type(&source_field.field.ty).ok_or_else( + || { + syn::Error::new_spanned( + &source_field.field.ty, + "relation source condition fields must be String/int/float/bool (optionals allowed)", + ) + }, + )?; + if !condition_value_matches_kind(&condition.value, kind) { + return Err(syn::Error::new_spanned( + field, + "relation source condition value does not match the source field type", + )); + } + Some(ResolvedSourceCondition { + field_name: source_field.field.ident.clone().expect("named field"), + kind, + optional, + value: condition.value.clone(), + }) + } else { + None + }; let on_delete = relation_delete_policy_tokens(meta.relation_on_delete.as_deref(), field.span())?; let propagate_change = relation_change_propagation_tokens( @@ -300,10 +431,12 @@ pub(crate) fn generate_graphql_relations( source_kinds, source_optional, source_supports_dataloader, - emit_foreign_key: meta.relation_emit_foreign_key.unwrap_or(!is_multiple), + emit_foreign_key: emits_foreign_key, on_delete, propagate_change, storage_kind, + source_condition, + target_condition: meta.relation_target_condition.clone(), }); } @@ -355,6 +488,7 @@ pub(crate) fn generate_graphql_relations( .map(|r| -> syn::Result { let field_name = &r.field_name; let graphql_name = &r.graphql_name; + let relation_loader_identity = format!("{struct_name}::{graphql_name}"); let description = &r.description; let fk_columns_sql = r .fk_columns @@ -366,6 +500,7 @@ pub(crate) fn generate_graphql_relations( let source_fields = &r.source_field_idents; let source_kinds = &r.source_kinds; let source_optional = &r.source_optional; + let source_condition = source_condition_tokens(r.source_condition.as_ref(), quote! { self }); let key_part_kind_tokens = source_kinds .iter() .copied() @@ -426,6 +561,54 @@ pub(crate) fn generate_graphql_relations( let edge_type = syn::Ident::new(&edge_type_str, struct_name.span()); let list_relation_complexity = "{ let requested = page.as_ref().and_then(|page| page.limit).unwrap_or(::graphql_orm::graphql::orm::PaginationConfig::DEFAULT_LIMIT); let capped = requested.max(0).min(::graphql_orm::graphql::orm::PaginationConfig::DEFAULT_MAX_LIMIT); 2 + (capped as usize).saturating_mul(child_complexity) }"; let single_relation_complexity = "2 + child_complexity"; + let source_condition_multiple_guard = quote! { + if !(#source_condition) { + let page_info = ::graphql_orm::graphql::pagination::PageInfo { + has_next_page: false, + has_previous_page: false, + start_cursor: None, + end_cursor: None, + total_count: Some(0), + }; + return Ok(#connection_type { edges: Vec::new(), page_info }); + } + }; + let source_condition_single_guard = quote! { + if !(#source_condition) { + return Ok(None); + } + }; + + let (target_condition_filter, fallback_target_predicate, fallback_target_value) = + if let Some(condition) = &r.target_condition { + let target_column_sql = + backend_quote_identifier_path(backend, &condition.member); + let target_value = condition_sql_value_tokens(&condition.value); + let placeholder_index = r.fk_columns.len() + 1; + ( + quote! { + Some::<::graphql_orm::graphql::orm::FilterExpression>( + ::graphql_orm::graphql::orm::FilterExpression::trusted_fragment( + format!("{} = ?", #target_column_sql), + vec![#target_value], + )) + }, + quote! { + Some::(format!( + "{} = {}", + #target_column_sql, + #target_type::__gom_placeholder(#placeholder_index), + )) + }, + quote! { Some::<::graphql_orm::graphql::orm::SqlValue>(#target_value) }, + ) + } else { + ( + quote! { None::<::graphql_orm::graphql::orm::FilterExpression> }, + quote! { None:: }, + quote! { None::<::graphql_orm::graphql::orm::SqlValue> }, + ) + }; let source_binding_multiple = if source_optional.iter().any(|is_option| *is_option) { quote! { @@ -500,7 +683,7 @@ pub(crate) fn generate_graphql_relations( None }; ::graphql_orm::graphql::loaders::CompositeRelationQueryKey { - relation: #graphql_name, + relation: #relation_loader_identity, parent_key: relation_loader_key.clone(), parent_values: relation_sql_values.clone(), fk_columns: vec![#(#fk_columns_sql),*], @@ -514,7 +697,18 @@ pub(crate) fn generate_graphql_relations( page_signature: __gom_relation_pagination .as_ref() .map(|page| format!("limit={:?};offset={}", page.limit, page.offset)), - filter: where_input.as_ref().and_then(|filter| filter.to_filter_expression()), + filter: { + let supplied = where_input + .as_ref() + .and_then(|filter| filter.to_filter_expression()); + match (#target_condition_filter, supplied) { + (Some(fixed), Some(supplied)) => Some( + ::graphql_orm::graphql::orm::FilterExpression::And(vec![fixed, supplied]), + ), + (Some(fixed), None) => Some(fixed), + (None, supplied) => supplied, + } + }, sorts: order_by .as_ref() .and_then(|order| order.to_sort_expression()) @@ -527,7 +721,7 @@ pub(crate) fn generate_graphql_relations( let single_relation_query_key = quote! { ::graphql_orm::graphql::loaders::CompositeRelationQueryKey { - relation: #graphql_name, + relation: #relation_loader_identity, parent_key: relation_loader_key.clone(), parent_values: relation_sql_values.clone(), fk_columns: vec![#(#fk_columns_sql),*], @@ -535,7 +729,7 @@ pub(crate) fn generate_graphql_relations( where_signature: None, order_signature: None, page_signature: None, - filter: None, + filter: #target_condition_filter, sorts: Vec::new(), pagination: None, auth_context: auth_context.clone(), @@ -552,7 +746,13 @@ pub(crate) fn generate_graphql_relations( }) .collect::>(); let fallback_relation_clause = quote! { - vec![#(#fallback_predicate_parts),*].join(" AND ") + { + let mut predicates = vec![#(#fallback_predicate_parts),*]; + if let Some(predicate) = #fallback_target_predicate { + predicates.push(predicate); + } + predicates.join(" AND ") + } }; if r.is_multiple { @@ -586,6 +786,7 @@ pub(crate) fn generate_graphql_relations( use ::graphql_orm::graphql::orm::{DatabaseEntity, DatabaseFilter, DatabaseOrderBy, EntityQuery, SqlValue}; let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; + #source_condition_multiple_guard let db = ctx.data_unchecked::<::graphql_orm::db::Database<#backend_marker>>(); let auth_context = ctx .data_opt::<::graphql_orm::graphql::orm::DbAuthContext>() @@ -641,8 +842,12 @@ pub(crate) fn generate_graphql_relations( }) } else { // Slow path: Use direct query with full SQL support + let mut relation_sql_values = relation_sql_values.clone(); + if let Some(value) = #fallback_target_value { + relation_sql_values.push(value); + } let mut query = EntityQuery::<#target_type, #backend_marker>::new() - .where_values(&#fallback_relation_clause, relation_sql_values.clone()); + .where_values(&#fallback_relation_clause, relation_sql_values); if let Some(ref filter) = where_input { query = query.filter(filter); @@ -732,6 +937,7 @@ pub(crate) fn generate_graphql_relations( use ::graphql_orm::graphql::orm::{DatabaseEntity, EntityQuery, SqlValue}; let _auth_subject = ::graphql_orm::graphql::auth::enforce_resolver_auth(ctx, #resolver_auth_mode)?; + #source_condition_single_guard if self.#field_name.is_some() { #preloaded_single } @@ -760,8 +966,12 @@ pub(crate) fn generate_graphql_relations( .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))? .and_then(|mut result| result.entities.drain(..).next()) } else { + let mut relation_sql_values = relation_sql_values.clone(); + if let Some(value) = #fallback_target_value { + relation_sql_values.push(value); + } EntityQuery::<#target_type, #backend_marker>::new() - .where_values(&#fallback_relation_clause, relation_sql_values.clone()) + .where_values(&#fallback_relation_clause, relation_sql_values) .fetch_one_with_auth(db, auth_context.as_ref()) .await .map_err(|e| ::graphql_orm::async_graphql::Error::new(e.to_string()))? @@ -781,6 +991,22 @@ pub(crate) fn generate_graphql_relations( let graphql_name = &r.graphql_name; let target_type = syn::Ident::new(&r.target_type_str, struct_name.span()); let storage_kind = r.storage_kind; + let source_condition = + source_condition_tokens(r.source_condition.as_ref(), quote! { entity }); + let (bulk_target_column, bulk_target_value) = if let Some(condition) = &r.target_condition + { + let column = backend_quote_identifier_path(backend, &condition.member); + let value = condition_sql_value_tokens(&condition.value); + ( + quote! { Some::<&str>(#column) }, + quote! { Some::<::graphql_orm::graphql::orm::SqlValue>(#value) }, + ) + } else { + ( + quote! { None::<&str> }, + quote! { None::<::graphql_orm::graphql::orm::SqlValue> }, + ) + }; let fk_columns_sql = r .fk_columns .iter() @@ -862,6 +1088,9 @@ pub(crate) fn generate_graphql_relations( let entity_key_pair_expr = quote! { (|| { + if !(#source_condition) { + return None; + } let mut relation_sql_values = Vec::new(); let mut relation_key_parts = Vec::new(); #(#source_value_bindings)* @@ -873,6 +1102,9 @@ pub(crate) fn generate_graphql_relations( }; let entity_key_expr = quote! { (|| { + if !(#source_condition) { + return None; + } let mut relation_key_parts = Vec::new(); #(#source_key_bindings)* Some(::graphql_orm::graphql::loaders::RelationKey::new(relation_key_parts)) @@ -976,11 +1208,11 @@ pub(crate) fn generate_graphql_relations( let mut grouped: #grouped_type = std::collections::HashMap::new(); if !unique_relation_keys.is_empty() { - let bind_values = unique_relation_keys + let mut bind_values = unique_relation_keys .iter() .flat_map(|(_, values)| values.iter().cloned()) .collect::>(); - let relation_predicate = if #relation_key_arity == 1 { + let mut relation_predicate = if #relation_key_arity == 1 { let placeholders = (0..unique_relation_keys.len()) .map(|index| <#target_type>::__gom_placeholder(index + 1)) .collect::>(); @@ -1002,6 +1234,17 @@ pub(crate) fn generate_graphql_relations( .collect::>() .join(" OR ") }; + if let (Some(column), Some(value)) = + (#bulk_target_column, #bulk_target_value) + { + let placeholder = + <#target_type>::__gom_placeholder(bind_values.len() + 1); + relation_predicate = format!( + "({}) AND {} = {}", + relation_predicate, column, placeholder, + ); + bind_values.push(value); + } let relation_key_projections = vec![ #(format!( "{} AS {}", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index fe550540..077554ee 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.26.0" +version = "0.27.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -65,7 +65,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.26.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.27.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 1b0650a9..4763253a 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -3,7 +3,7 @@ title: "graphql-orm" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.27.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -120,6 +120,10 @@ README remains project-neutral. multiple `COUNT`, `MIN`, `MAX`, and `SUM` expressions in the database; `aggregate = true` separately opts a schema into a generated bounded aggregate query root. +- **Conditional relations:** externally managed polymorphic references can use + compile-time source or target discriminator conditions. Every generated + resolver and batching path enforces the same bound-value predicate, and the + declaration must disable physical foreign-key emission. ## Errors and security boundaries diff --git a/crates/graphql-orm/tests/composite_relations_ui.rs b/crates/graphql-orm/tests/composite_relations_ui.rs index 113f691e..f20aed43 100644 --- a/crates/graphql-orm/tests/composite_relations_ui.rs +++ b/crates/graphql-orm/tests/composite_relations_ui.rs @@ -3,4 +3,6 @@ fn invalid_composite_relation_definitions_are_rejected() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/composite_relation_arity_mismatch.rs"); t.compile_fail("tests/ui/composite_relation_unknown_source.rs"); + t.compile_fail("tests/ui/conditional_relation_unknown_source.rs"); + t.compile_fail("tests/ui/conditional_relation_requires_emit_fk_false.rs"); } diff --git a/crates/graphql-orm/tests/conditional_relations.rs b/crates/graphql-orm/tests/conditional_relations.rs new file mode 100644 index 00000000..f94986cf --- /dev/null +++ b/crates/graphql-orm/tests/conditional_relations.rs @@ -0,0 +1,417 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::async_graphql::{Schema, SimpleObject}; +use graphql_orm::prelude::*; +use graphql_orm::sqlx::Row; + +#[derive( + GraphQLEntity, + GraphQLRelations, + GraphQLOperations, + SimpleObject, + serde::Serialize, + serde::Deserialize, + Clone, + Debug, + PartialEq, +)] +#[graphql(rename_fields = "PascalCase")] +#[graphql(complex)] +#[graphql_entity( + backend = "sqlite", + table = "conditional_work_items", + plural = "ConditionalWorkItems", + schema_policy = "external_read_only", + default_sort = "id ASC" +)] +struct ConditionalWorkItem { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + id: i32, + + #[filterable(type = "number")] + #[sortable] + kind: i32, + + #[filterable(type = "number")] + #[sortable] + ref_no: i32, + + #[filterable(type = "string")] + kind_label: String, + + #[filterable(type = "boolean")] + kind_enabled: Option, + + #[filterable(type = "number")] + kind_score: f64, + + /// Related job when this record carries the job discriminator. + #[graphql(skip)] + #[relation( + target = "ConditionalJob", + from = "ref_no", + to = "id", + source_condition(field = "kind", equals = 0), + emit_fk = false + )] + job: Option, + + /// Related request when this record carries the request discriminator. + #[graphql(skip)] + #[relation( + target = "ConditionalRequest", + from = "ref_no", + to = "id", + source_condition(field = "kind", equals = 2), + emit_fk = false + )] + request: Option, + + /// Related job when a string discriminator matches. + #[graphql(skip)] + #[relation( + target = "ConditionalJob", + from = "ref_no", + to = "id", + source_condition(field = "kind_label", equals = "job"), + emit_fk = false + )] + labeled_job: Option, + + /// Related job when an optional boolean discriminator matches. + #[graphql(skip)] + #[relation( + target = "ConditionalJob", + from = "ref_no", + to = "id", + source_condition(field = "kind_enabled", equals = true), + emit_fk = false + )] + enabled_job: Option, + + /// Related job when a floating-point discriminator matches. + #[graphql(skip)] + #[relation( + target = "ConditionalJob", + from = "ref_no", + to = "id", + source_condition(field = "kind_score", equals = 1.5), + emit_fk = false + )] + scored_job: Option, +} + +#[derive( + GraphQLEntity, + GraphQLRelations, + GraphQLOperations, + SimpleObject, + serde::Serialize, + serde::Deserialize, + Clone, + Debug, + PartialEq, +)] +#[graphql(rename_fields = "PascalCase")] +#[graphql(complex)] +#[graphql_entity( + backend = "sqlite", + table = "conditional_jobs", + plural = "ConditionalJobs", + schema_policy = "external_read_only", + default_sort = "id ASC" +)] +struct ConditionalJob { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + id: i32, + + #[filterable(type = "string")] + title: String, + + /// Work records whose fixed target discriminator identifies a job. + #[graphql(skip)] + #[relation( + target = "ConditionalWorkItem", + from = "id", + to = "ref_no", + target_condition(column = "kind", equals = 0), + multiple, + emit_fk = false + )] + work: Vec, +} + +#[derive( + GraphQLEntity, + GraphQLRelations, + GraphQLOperations, + SimpleObject, + serde::Serialize, + serde::Deserialize, + Clone, + Debug, + PartialEq, +)] +#[graphql(rename_fields = "PascalCase")] +#[graphql(complex)] +#[graphql_entity( + backend = "sqlite", + table = "conditional_requests", + plural = "ConditionalRequests", + schema_policy = "external_read_only", + default_sort = "id ASC" +)] +struct ConditionalRequest { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + id: i32, + + #[filterable(type = "string")] + title: String, + + /// Work records whose fixed target discriminator identifies a request. + #[graphql(skip)] + #[relation( + target = "ConditionalWorkItem", + from = "id", + to = "ref_no", + target_condition(column = "kind", equals = 2), + multiple, + emit_fk = false + )] + work: Vec, +} + +impl graphql_orm::graphql::loaders::BatchLoadEntity + for ConditionalWorkItem +{ + fn batch_column() -> &'static str { + "ref_no" + } + + fn batch_key_from_row( + row: &graphql_orm::sqlx::sqlite::SqliteRow, + ) -> Result { + row.try_get::("ref_no") + .map(|value| value.to_string()) + } +} + +macro_rules! impl_batch_by_id { + ($entity:ty) => { + impl graphql_orm::graphql::loaders::BatchLoadEntity + for $entity + { + fn batch_column() -> &'static str { + "id" + } + + fn batch_key_from_row( + row: &graphql_orm::sqlx::sqlite::SqliteRow, + ) -> Result { + row.try_get::("id").map(|value| value.to_string()) + } + } + }; +} + +impl_batch_by_id!(ConditionalJob); +impl_batch_by_id!(ConditionalRequest); + +schema_roots! { + backend: "sqlite", + schema_policy: "external_read_only", + query_custom_ops: [], + entities: [ConditionalWorkItem, ConditionalJob, ConditionalRequest], +} + +type TestSchema = Schema; + +async fn setup_schema() -> Result> { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await?; + sqlx::query( + "CREATE TABLE conditional_work_items ( + id INTEGER PRIMARY KEY, + kind INTEGER NOT NULL, + ref_no INTEGER NOT NULL, + kind_label TEXT NOT NULL, + kind_enabled INTEGER, + kind_score REAL NOT NULL + )", + ) + .execute(&pool) + .await?; + sqlx::query("CREATE TABLE conditional_jobs (id INTEGER PRIMARY KEY, title TEXT NOT NULL)") + .execute(&pool) + .await?; + sqlx::query("CREATE TABLE conditional_requests (id INTEGER PRIMARY KEY, title TEXT NOT NULL)") + .execute(&pool) + .await?; + + sqlx::query("INSERT INTO conditional_jobs (id, title) VALUES (10, 'Job ten')") + .execute(&pool) + .await?; + sqlx::query("INSERT INTO conditional_requests (id, title) VALUES (10, 'Request ten')") + .execute(&pool) + .await?; + for (id, kind, kind_label, kind_enabled, kind_score) in [ + (1, 0, "job", Some(true), 1.5), + (2, 2, "request", Some(false), 2.5), + (3, 9, "other", None, 3.5), + ] { + sqlx::query( + "INSERT INTO conditional_work_items + (id, kind, ref_no, kind_label, kind_enabled, kind_score) + VALUES (?, ?, 10, ?, ?, ?)", + ) + .bind(id) + .bind(kind) + .bind(kind_label) + .bind(kind_enabled) + .bind(kind_score) + .execute(&pool) + .await?; + } + + Ok(schema_builder(graphql_orm::db::Database::new(pool)) + .data("test-user".to_owned()) + .finish()) +} + +#[tokio::test] +async fn source_and_target_conditions_keep_polymorphic_relations_exact_and_batched() +-> Result<(), Box> { + let schema = setup_schema().await?; + graphql_orm::graphql::orm::reset_query_count(); + + let response = schema + .execute( + r#" + query { + conditionalWorkItems(orderBy: [{ Id: ASC }]) { + edges { + node { + Id + Kind + Job { Id Title } + Request { Id Title } + LabeledJob { Id } + EnabledJob { Id } + ScoredJob { Id } + } + } + } + conditionalJobs { + edges { + node { + Id + Work(orderBy: { Id: ASC }, page: { limit: 10 }) { + edges { node { Id Kind } } + } + } + } + } + conditionalRequests { + edges { + node { + Id + Work(orderBy: { Id: ASC }, page: { limit: 10 }) { + edges { node { Id Kind } } + } + } + } + } + } + "#, + ) + .await; + + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json()?; + let work_items = data["conditionalWorkItems"]["edges"] + .as_array() + .expect("work item edges"); + assert!(work_items[0]["node"]["Job"].is_object()); + assert!(work_items[0]["node"]["Request"].is_null()); + assert!(work_items[0]["node"]["LabeledJob"].is_object()); + assert!(work_items[0]["node"]["EnabledJob"].is_object()); + assert!(work_items[0]["node"]["ScoredJob"].is_object()); + assert!(work_items[1]["node"]["Job"].is_null()); + assert!(work_items[1]["node"]["Request"].is_object()); + assert!(work_items[1]["node"]["LabeledJob"].is_null()); + assert!(work_items[1]["node"]["EnabledJob"].is_null()); + assert!(work_items[1]["node"]["ScoredJob"].is_null()); + assert!(work_items[2]["node"]["Job"].is_null()); + assert!(work_items[2]["node"]["Request"].is_null()); + assert!(work_items[2]["node"]["LabeledJob"].is_null()); + assert!(work_items[2]["node"]["EnabledJob"].is_null()); + assert!(work_items[2]["node"]["ScoredJob"].is_null()); + + let job_work = data["conditionalJobs"]["edges"][0]["node"]["Work"]["edges"] + .as_array() + .expect("job work edges"); + assert_eq!(job_work.len(), 1); + assert_eq!(job_work[0]["node"]["Kind"].as_i64(), Some(0)); + let request_work = data["conditionalRequests"]["edges"][0]["node"]["Work"]["edges"] + .as_array() + .expect("request work edges"); + assert_eq!(request_work.len(), 1); + assert_eq!(request_work[0]["node"]["Kind"].as_i64(), Some(2)); + assert!( + graphql_orm::graphql::orm::query_count() <= 12, + "conditional relation expansion issued {} queries", + graphql_orm::graphql::orm::query_count() + ); + + Ok(()) +} + +#[tokio::test] +async fn target_conditions_apply_to_argument_free_nested_bulk_preloads() +-> Result<(), Box> { + let schema = setup_schema().await?; + graphql_orm::graphql::orm::reset_query_count(); + + let response = schema + .execute( + r#" + query { + conditionalJobs { + edges { node { Work { edges { node { Id Kind } } } } } + } + conditionalRequests { + edges { node { Work { edges { node { Id Kind } } } } } + } + } + "#, + ) + .await; + + assert!(response.errors.is_empty(), "{:?}", response.errors); + let data = response.data.into_json()?; + let job_work = data["conditionalJobs"]["edges"][0]["node"]["Work"]["edges"] + .as_array() + .expect("job work edges"); + assert_eq!(job_work.len(), 1); + assert_eq!(job_work[0]["node"]["Kind"].as_i64(), Some(0)); + let request_work = data["conditionalRequests"]["edges"][0]["node"]["Work"]["edges"] + .as_array() + .expect("request work edges"); + assert_eq!(request_work.len(), 1); + assert_eq!(request_work[0]["node"]["Kind"].as_i64(), Some(2)); + assert!( + graphql_orm::graphql::orm::query_count() <= 4, + "conditional bulk preload issued {} queries", + graphql_orm::graphql::orm::query_count() + ); + + Ok(()) +} diff --git a/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.rs b/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.rs new file mode 100644 index 00000000..86f6619c --- /dev/null +++ b/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.rs @@ -0,0 +1,41 @@ +use graphql_orm::prelude::*; + +#[derive(GraphQLEntity, serde::Serialize, serde::Deserialize, Clone, Debug)] +#[graphql_entity(table = "targets", plural = "Targets", default_sort = "id ASC")] +struct Target { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + pub id: i32, + + #[filterable(type = "string")] + pub name: String, +} + +#[derive(GraphQLEntity, serde::Serialize, serde::Deserialize, Clone, Debug)] +#[graphql_entity(table = "records", plural = "Records", default_sort = "id ASC")] +struct Record { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + pub id: i32, + + #[filterable(type = "number")] + pub kind: i32, + + #[filterable(type = "number")] + pub target_id: i32, + + #[filterable(type = "string")] + pub name: String, + + #[relation( + target = "Target", + from = "target_id", + to = "id", + source_condition(field = "kind", equals = 1) + )] + pub target: Option, +} + +fn main() {} diff --git a/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.stderr b/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.stderr new file mode 100644 index 00000000..c7c05605 --- /dev/null +++ b/crates/graphql-orm/tests/ui/conditional_relation_requires_emit_fk_false.stderr @@ -0,0 +1,11 @@ +error: conditional relations require `emit_fk = false` because they do not describe an unconditional physical foreign key + --> tests/ui/conditional_relation_requires_emit_fk_false.rs:32:5 + | +32 | / #[relation( +33 | | target = "Target", +34 | | from = "target_id", +35 | | to = "id", +36 | | source_condition(field = "kind", equals = 1) +37 | | )] +38 | | pub target: Option, + | |______________________________^ diff --git a/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.rs b/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.rs new file mode 100644 index 00000000..6658bda0 --- /dev/null +++ b/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.rs @@ -0,0 +1,41 @@ +use graphql_orm::prelude::*; + +#[derive(GraphQLEntity, serde::Serialize, serde::Deserialize, Clone, Debug)] +#[graphql_entity(table = "targets", plural = "Targets", default_sort = "id ASC")] +struct Target { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + pub id: i32, + + #[filterable(type = "string")] + pub name: String, +} + +#[derive( + GraphQLEntity, GraphQLRelations, serde::Serialize, serde::Deserialize, Clone, Debug, +)] +#[graphql_entity(table = "records", plural = "Records", default_sort = "id ASC")] +struct Record { + #[primary_key] + #[filterable(type = "number")] + #[sortable] + pub id: i32, + + #[filterable(type = "number")] + pub target_id: i32, + + #[filterable(type = "string")] + pub name: String, + + #[relation( + target = "Target", + from = "target_id", + to = "id", + source_condition(field = "missing_kind", equals = 1), + emit_fk = false + )] + pub target: Option, +} + +fn main() {} diff --git a/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.stderr b/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.stderr new file mode 100644 index 00000000..9558bc83 --- /dev/null +++ b/crates/graphql-orm/tests/ui/conditional_relation_unknown_source.stderr @@ -0,0 +1,11 @@ +error: Relation 'target' source condition references unknown field 'missing_kind' on 'Record' + --> tests/ui/conditional_relation_unknown_source.rs:31:5 + | +31 | / #[relation( +32 | | target = "Target", +33 | | from = "target_id", +34 | | to = "id", +... | +37 | | )] +38 | | pub target: Option, + | |______________________________^ diff --git a/docs/reference/graphql-orm/entities-and-relations.md b/docs/reference/graphql-orm/entities-and-relations.md index 011ce14e..7acc557a 100644 --- a/docs/reference/graphql-orm/entities-and-relations.md +++ b/docs/reference/graphql-orm/entities-and-relations.md @@ -3,7 +3,7 @@ title: "Entities And Relations" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-10 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -302,6 +302,54 @@ The macro validates: Target columns are metadata literals used for generated SQL. Invalid target names are caught by the database when the generated query runs. +## Conditional Polymorphic Relations + +Some externally managed schemas reuse one reference column for several target +types and store a discriminator beside it. Declare that shape without inventing +an unconditional foreign key by adding a compile-time source condition: + +```rust +#[graphql(skip)] +#[relation( + target = "Document", + from = "reference_id", + to = "id", + source_condition(field = "reference_kind", equals = 1), + emit_fk = false +)] +pub document: Option, +``` + +When `reference_kind` does not equal the declared value, the generated resolver +returns `None` (or an empty connection) without loading the target. The same +condition is honored by nested bulk preloading. + +Declare the reverse collection with a target condition: + +```rust +#[graphql(skip)] +#[relation( + target = "Activity", + from = "id", + to = "reference_id", + target_condition(column = "reference_kind", equals = 1), + multiple, + emit_fk = false +)] +pub activity: Vec, +``` + +Target conditions are macro-owned SQL predicates with bound values. They are +combined with caller-supplied relationship filters and applied consistently to +single loads, pageable loads, DataLoader batches, and nested bulk-preload +queries. Supported literal types are string, integer, float, and boolean. A +source condition names a persisted Rust field and is compile-time type checked; +a target condition names a physical target column, like `to`. + +Conditional relationships must use `emit_fk = false`. They describe a logical +join whose validity depends on a discriminator and therefore cannot represent +an unconditional database foreign key. + ## Nested Relation Batching Selected relation fields are loaded in batches by relation layer. A query shaped like diff --git a/docs/reference/graphql-orm/macros-and-attributes.md b/docs/reference/graphql-orm/macros-and-attributes.md index 05057d03..5f617c28 100644 --- a/docs/reference/graphql-orm/macros-and-attributes.md +++ b/docs/reference/graphql-orm/macros-and-attributes.md @@ -3,7 +3,7 @@ title: GraphQL ORM macro and attribute reference kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-12 +last_reviewed: 2026-08-26 review_by: 2027-02-01 supersedes: [] --- @@ -142,6 +142,28 @@ changes the relation cardinality. `emit_fk` is a boolean; `on_delete` and `propagate_change` are strings validated for the selected backend/policy. Relations are not ordinary persisted fields. +Polymorphic references can add one fixed discriminator condition: + +```rust,ignore +#[graphql(skip)] +#[relation( + target = "Document", + from = "reference_id", + to = "id", + source_condition(field = "reference_kind", equals = 1), + emit_fk = false +)] +pub document: Option; +``` + +The reverse collection uses +`target_condition(column = "reference_kind", equals = 1)`. Condition values +accept string, integer, float, and boolean literals. `source_condition` names a +persisted scalar Rust field and must match its type; `target_condition` names a +physical target column. Either condition requires `emit_fk = false`. Fixed +conditions are always bound parameters and apply to every resolver and +batch-preload path. + A generated to-many relationship exposes nullable `Where`, `OrderBy`, and `Page` objects. In particular, its ordering contract is one nullable `ChildOrderByInput`, because one relation-loader query accepts one composed diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 0443be0c..f56ef763 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.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.27.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.95.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.27.0` | `crates/graphql-orm-macros` | `sqlite` | none | | `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |