Skip to content

Commit ff0a98e

Browse files
committed
fix(#1470): two-hop ref display resolution (display_field that is itself a ref)
build_display_join_plan resolved one hop only — when a target's display_field is itself a to-one ref, the FK display column projected the nested ref's raw UUID. Now chains a second LEFT JOIN to the nested target's display_field so {rel}__display carries the two-hop human name. One-hop unchanged; degrades to the prior column when the nested target lacks a display_field. Closes the two-hop "harder related case" from #1471. Plan-string unit tests assert the exact chained SQL; full -m "not e2e" suite green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9e9ebe commit ff0a98e

9 files changed

Lines changed: 98 additions & 9 deletions

File tree

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,4 +374,4 @@ Example: `examples/ops_dashboard` has working `bar_chart` (FK `group_by: system`
374374
- **KG re-seeding**: `ensure_seeded()` checks a version key; bump it in `seed.py` when TOML data changes.
375375

376376
---
377-
**Version**: 0.86.21 | **Python**: 3.12+ | **Status**: Production Ready
377+
**Version**: 0.86.22 | **Python**: 3.12+ | **Status**: Production Ready

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## [0.86.22] - 2026-06-25
13+
14+
### Fixed
15+
- **#1470/#1471 two-hop: ref columns whose target `display_field` is itself a ref now resolve to a human string, not the nested FK's UUID.** `build_display_join_plan` resolved one hop only — when an entity's `display_field` points at another ref (e.g. `Manuscript.display_field: student → User`), the FK display column projected the nested ref's raw UUID. It now detects that case and chains a second `LEFT JOIN` to the nested target's `display_field`, so the `{rel}__display` column carries the two-hop name. One-hop (scalar `display_field`) behaviour is unchanged; degrades to the prior column when the nested target has no `display_field`. This closes the "harder related case" noted on #1471. (Follow-on: a PG fixture exercising the chained join at runtime — currently the join SQL is asserted at the generation level + composes via the proven one-hop path.)
16+
1217
## [0.86.21] - 2026-06-25
1318

1419
### Fixed

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# DAZZLE Development Roadmap
22

33
**Last Updated**: 2026-06-16
4-
**Current Version**: v0.86.21
4+
**Current Version**: v0.86.22
55

66
For past releases, see [CHANGELOG.md](CHANGELOG.md).
77

homebrew/dazzle.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ class Dazzle < Formula
1010

1111
desc "DSL-first application framework with LLM-assisted development"
1212
homepage "https://github.com/manwithacat/dazzle"
13-
version "0.86.21"
13+
version "0.86.22"
1414
license "MIT"
1515

16-
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.86.21.tar.gz"
16+
url "https://github.com/manwithacat/dazzle/archive/refs/tags/v0.86.22.tar.gz"
1717
sha256 "PLACEHOLDER_SOURCE_SHA256"
1818

1919
# pydantic-core requires Rust to build from source, so use pre-built wheels

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "dazzle-dsl"
7-
version = "0.86.21"
7+
version = "0.86.22"
88
description = "DAZZLE — declarative SaaS framework with built-in compliance (SOC 2, ISO 27001), provable RBAC, and graph features"
99
readme = "README.md"
1010
requires-python = ">=3.12"

src/dazzle/http/runtime/relation_loader.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,35 @@ def build_display_join_plan(
476476
alias = f"_fkd_{relation_name}"
477477
fk_col = quote_identifier(relation.foreign_key_field)
478478
target = quote_identifier(relation.to_entity)
479-
display_col = quote_identifier(display_field)
480479
joins.append(f'LEFT JOIN {target} AS "{alias}" ON "{alias}".id = {base}.{fk_col}')
481-
extra_cols.append(f'"{alias}".{display_col} AS "{relation_name}__display"')
480+
481+
# #1471 two-hop: if the target's own display_field is itself a to-one
482+
# ref (e.g. Manuscript.display_field: student → User), chain a second
483+
# LEFT JOIN so the column resolves to the nested target's display name
484+
# instead of the nested FK's raw UUID. Falls back to the one-hop column
485+
# when the display_field isn't a ref or the nested target has no
486+
# display_field (degrades to the prior behaviour, no regression).
487+
nested = self.registry.get_relation(relation.to_entity, display_field)
488+
nested_display = (
489+
self.registry.display_fields.get(nested.to_entity)
490+
if nested and nested.is_to_one
491+
else None
492+
)
493+
if nested and nested.is_to_one and nested_display:
494+
alias2 = f"_fkd2_{relation_name}"
495+
nested_fk = quote_identifier(nested.foreign_key_field)
496+
nested_target = quote_identifier(nested.to_entity)
497+
joins.append(
498+
f'LEFT JOIN {nested_target} AS "{alias2}" '
499+
f'ON "{alias2}".id = "{alias}".{nested_fk}'
500+
)
501+
extra_cols.append(
502+
f'"{alias2}".{quote_identifier(nested_display)} AS "{relation_name}__display"'
503+
)
504+
else:
505+
extra_cols.append(
506+
f'"{alias}".{quote_identifier(display_field)} AS "{relation_name}__display"'
507+
)
482508
return joins, extra_cols, fallback
483509

484510
def apply_display_joins_to_rows(

src/dazzle/mcp/semantics_kb/core.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
[meta]
55
category = "Core Constructs"
6-
version = "0.86.21"
6+
version = "0.86.22"
77

88
[concepts.entity]
99
category = "Core Construct"

tests/unit/test_fk_display_join.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,64 @@ def _registry_with(
4444
return registry
4545

4646

47+
def _two_hop_registry() -> RelationRegistry:
48+
"""Order → customer (Customer); Customer.display_field = rep → SalesRep;
49+
SalesRep.display_field = name. So Order.customer should two-hop resolve to
50+
SalesRep.name, not the nested rep UUID (#1471 two-hop)."""
51+
registry = RelationRegistry()
52+
registry.register(
53+
"Order",
54+
RelationInfo(
55+
name="customer",
56+
from_entity="Order",
57+
to_entity="Customer",
58+
kind="many_to_one",
59+
foreign_key_field="customer_id",
60+
),
61+
)
62+
registry.register(
63+
"Customer",
64+
RelationInfo(
65+
name="rep",
66+
from_entity="Customer",
67+
to_entity="SalesRep",
68+
kind="many_to_one",
69+
foreign_key_field="rep_id",
70+
),
71+
)
72+
registry.display_fields["Customer"] = "rep" # display_field is itself a ref
73+
registry.display_fields["SalesRep"] = "name"
74+
return registry
75+
76+
77+
class TestTwoHopDisplayJoin:
78+
def test_chains_second_join_when_display_field_is_ref(self) -> None:
79+
loader = RelationLoader(registry=_two_hop_registry(), entities=[])
80+
joins, extras, fallback = loader.build_display_join_plan("Order", ["customer"])
81+
82+
assert fallback == []
83+
assert len(joins) == 2 # base join + nested join
84+
# base: Customer joined on Order.customer_id
85+
assert any(
86+
'"Customer"' in j and '"customer_id"' in j and '"_fkd_customer"' in j for j in joins
87+
)
88+
# nested: SalesRep joined on _fkd_customer.rep_id
89+
assert any(
90+
'"SalesRep"' in j and '"_fkd2_customer"' in j and '"_fkd_customer"."rep_id"' in j
91+
for j in joins
92+
)
93+
# the display column resolves to SalesRep.name (two hops), not the UUID
94+
assert extras[0] == '"_fkd2_customer"."name" AS "customer__display"'
95+
96+
def test_one_hop_unaffected_by_two_hop_logic(self) -> None:
97+
# display_field is a plain scalar → still a single join + direct column.
98+
registry = _registry_with("Order", "customer", "Customer", "customer_id", "name")
99+
loader = RelationLoader(registry=registry, entities=[])
100+
joins, extras, _ = loader.build_display_join_plan("Order", ["customer"])
101+
assert len(joins) == 1
102+
assert extras[0] == '"_fkd_customer"."name" AS "customer__display"'
103+
104+
47105
class TestBuildDisplayJoinPlan:
48106
def test_emits_join_and_alias_column_for_registered_display(self) -> None:
49107
registry = _registry_with("Order", "customer", "Customer", "customer_id", "name")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)