Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
---
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
47 changes: 46 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
---
Expand All @@ -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<Document>,
```

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<Activity>,
```

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
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
13 changes: 10 additions & 3 deletions crates/graphql-orm-macros/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
---
Expand All @@ -16,13 +16,13 @@ macro/runtime versions aligned:

```toml
[dependencies]
graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.26.0", default-features = false, features = ["sqlite"] }
graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", 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 = "<reviewed-full-40-character-commit-sha>", version = "0.26.0", default-features = false, features = ["sqlite"] }
graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.27.0", default-features = false, features = ["sqlite"] }
```

The direct dependency still requires a compatible `graphql-orm` runtime in the
Expand Down Expand Up @@ -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).
109 changes: 107 additions & 2 deletions crates/graphql-orm-macros/src/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,8 @@ pub(crate) struct FieldMetadata {
pub(crate) relation_emit_foreign_key: Option<bool>,
pub(crate) relation_on_delete: Option<String>,
pub(crate) relation_propagate_change: Option<String>,
pub(crate) relation_source_condition: Option<RelationConditionMetadata>,
pub(crate) relation_target_condition: Option<RelationConditionMetadata>,
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.
Expand Down Expand Up @@ -975,6 +977,20 @@ pub(crate) struct FieldMetadata {
pub(crate) write_policy: Option<String>,
}

#[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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1145,6 +1163,67 @@ fn parse_relation_columns(input: ParseStream<'_>) -> syn::Result<Vec<String>> {
}
}

fn parse_relation_condition(
nested: syn::meta::ParseNestedMeta<'_>,
member_name: &str,
) -> syn::Result<RelationConditionMetadata> {
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<Vec<String>> {
let expr: syn::Expr = input.parse()?;
match expr {
Expand Down Expand Up @@ -1855,7 +1934,7 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result<FieldMetadata>
}
"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()?;
Expand Down Expand Up @@ -1886,11 +1965,25 @@ pub(crate) fn parse_field_metadata(field: &Field) -> syn::Result<FieldMetadata>
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;
Expand Down Expand Up @@ -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,
Expand Down
Loading