Skip to content

Commit bf05ae4

Browse files
flyersworderclaude
andcommitted
feat: add table relationship metadata for join guidance (v0.2.5)
- Add Relationship dataclass (from_, to, type) to semantic.base - Add get_relationships() to SemanticSource protocol - Implement in YamlSource (parsed from YAML), DbtSource and CubeSource (empty, ready for native join metadata parsing) - Include relationships in to_system_prompt() so agent knows join paths - Update semantic_source.yml fixture with relationships section - 6 new tests (177 total) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0765400 commit bf05ae4

10 files changed

Lines changed: 166 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [0.2.5] - 2026-03-29
6+
7+
### Added
8+
9+
- **Table relationship metadata**: `Relationship` dataclass and `get_relationships()` on `SemanticSource` protocol for declaring join paths between tables (from/to column + relationship type)
10+
- **Relationships in system prompt**: `to_system_prompt()` includes join paths so the agent knows how to combine tables correctly
11+
- **YamlSource relationships**: Parsed from `relationships` section in semantic YAML files
12+
- DbtSource and CubeSource return empty relationships (ready for future parsing of native join metadata)
13+
514
## [0.2.4] - 2026-03-29
615

716
### Added

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "agentic-data-contracts"
3-
version = "0.2.4"
3+
version = "0.2.5"
44
description = "YAML-first data contract governance for AI agents"
55
readme = "README.md"
66
requires-python = ">=3.12"

src/agentic_data_contracts/core/contract.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,17 @@ def to_system_prompt(self, semantic_source: SemanticSource | None = None) -> str
171171
)
172172
sections.append(line)
173173

174+
# Table relationships
175+
if semantic_source is not None:
176+
rels = semantic_source.get_relationships()
177+
if rels:
178+
sections.append(
179+
"\n### Table Relationships\n"
180+
"Use these join paths when combining tables:"
181+
)
182+
for r in rels:
183+
sections.append(f"- {r.from_} \u2192 {r.to} ({r.type})")
184+
174185
# Resource limits
175186
res = self.schema.resources
176187
if res:

src/agentic_data_contracts/semantic/base.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,20 @@ class MetricDefinition:
2020
filters: list[str] = field(default_factory=list)
2121

2222

23+
@dataclass
24+
class Relationship:
25+
from_: str # "schema.table.column"
26+
to: str # "schema.table.column"
27+
type: str = "many_to_one" # many_to_one | one_to_one | many_to_many
28+
29+
2330
@runtime_checkable
2431
class SemanticSource(Protocol):
2532
def get_metrics(self) -> list[MetricDefinition]: ...
2633
def get_metric(self, name: str) -> MetricDefinition | None: ...
2734
def get_table_schema(self, schema: str, table: str) -> TableSchema | None: ...
2835
def search_metrics(self, query: str) -> list[MetricDefinition]: ...
36+
def get_relationships(self) -> list[Relationship]: ...
2937

3038

3139
def fuzzy_search_metrics(

src/agentic_data_contracts/semantic/cube.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import yaml
88

99
from agentic_data_contracts.adapters.base import Column, TableSchema
10-
from agentic_data_contracts.semantic.base import MetricDefinition, fuzzy_search_metrics
10+
from agentic_data_contracts.semantic.base import (
11+
MetricDefinition,
12+
Relationship,
13+
fuzzy_search_metrics,
14+
)
1115

1216

1317
class CubeSource:
@@ -54,5 +58,8 @@ def get_metric(self, name: str) -> MetricDefinition | None:
5458
def search_metrics(self, query: str) -> list[MetricDefinition]:
5559
return fuzzy_search_metrics(self._metrics, self.get_metric, query)
5660

61+
def get_relationships(self) -> list[Relationship]:
62+
return [] # TODO: parse from Cube joins config
63+
5764
def get_table_schema(self, schema: str, table: str) -> TableSchema | None:
5865
return self._tables.get(f"{schema}.{table}")

src/agentic_data_contracts/semantic/dbt.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
from typing import Any
88

99
from agentic_data_contracts.adapters.base import Column, TableSchema
10-
from agentic_data_contracts.semantic.base import MetricDefinition, fuzzy_search_metrics
10+
from agentic_data_contracts.semantic.base import (
11+
MetricDefinition,
12+
Relationship,
13+
fuzzy_search_metrics,
14+
)
1115

1216

1317
class DbtSource:
@@ -77,5 +81,8 @@ def get_metric(self, name: str) -> MetricDefinition | None:
7781
def search_metrics(self, query: str) -> list[MetricDefinition]:
7882
return fuzzy_search_metrics(self._metrics, self.get_metric, query)
7983

84+
def get_relationships(self) -> list[Relationship]:
85+
return [] # TODO: parse from dbt manifest relationships/refs
86+
8087
def get_table_schema(self, schema: str, table: str) -> TableSchema | None:
8188
return self._tables.get(f"{schema}.{table}")

src/agentic_data_contracts/semantic/yaml_source.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
import yaml
88

99
from agentic_data_contracts.adapters.base import Column, TableSchema
10-
from agentic_data_contracts.semantic.base import MetricDefinition, fuzzy_search_metrics
10+
from agentic_data_contracts.semantic.base import (
11+
MetricDefinition,
12+
Relationship,
13+
fuzzy_search_metrics,
14+
)
1115

1216

1317
class YamlSource:
@@ -38,6 +42,14 @@ def __init__(self, path: str | Path) -> None:
3842
for c in t.get("columns", [])
3943
]
4044
)
45+
self._relationships = [
46+
Relationship(
47+
from_=r["from"],
48+
to=r["to"],
49+
type=r.get("type", "many_to_one"),
50+
)
51+
for r in raw.get("relationships", [])
52+
]
4153

4254
def get_metrics(self) -> list[MetricDefinition]:
4355
return list(self._metrics)
@@ -51,5 +63,8 @@ def get_metric(self, name: str) -> MetricDefinition | None:
5163
def search_metrics(self, query: str) -> list[MetricDefinition]:
5264
return fuzzy_search_metrics(self._metrics, self.get_metric, query)
5365

66+
def get_relationships(self) -> list[Relationship]:
67+
return list(self._relationships)
68+
5469
def get_table_schema(self, schema: str, table: str) -> TableSchema | None:
5570
return self._tables.get(f"{schema}.{table}")

tests/fixtures/semantic_source.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,24 @@ tables:
2929
- name: status
3030
type: VARCHAR
3131
description: "Order status: pending, completed, cancelled"
32+
- name: customer_id
33+
type: INTEGER
34+
description: "FK to customers"
35+
36+
- schema: analytics
37+
table: customers
38+
columns:
39+
- name: id
40+
type: INTEGER
41+
description: "Primary key"
42+
- name: name
43+
type: VARCHAR
44+
description: "Customer name"
45+
- name: region
46+
type: VARCHAR
47+
description: "Geographic region"
48+
49+
relationships:
50+
- from: analytics.orders.customer_id
51+
to: analytics.customers.id
52+
type: many_to_one
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Tests for table relationship metadata."""
2+
3+
from pathlib import Path
4+
5+
from agentic_data_contracts.core.contract import DataContract
6+
from agentic_data_contracts.core.schema import (
7+
AllowedTable,
8+
DataContractSchema,
9+
SemanticConfig,
10+
)
11+
from agentic_data_contracts.semantic.cube import CubeSource
12+
from agentic_data_contracts.semantic.dbt import DbtSource
13+
from agentic_data_contracts.semantic.yaml_source import YamlSource
14+
15+
16+
def test_yaml_source_loads_relationships(fixtures_dir: Path) -> None:
17+
source = YamlSource(fixtures_dir / "semantic_source.yml")
18+
rels = source.get_relationships()
19+
assert len(rels) == 1
20+
assert rels[0].from_ == "analytics.orders.customer_id"
21+
assert rels[0].to == "analytics.customers.id"
22+
assert rels[0].type == "many_to_one"
23+
24+
25+
def test_yaml_source_no_relationships(tmp_path: Path) -> None:
26+
(tmp_path / "empty.yml").write_text("metrics: []")
27+
source = YamlSource(tmp_path / "empty.yml")
28+
assert source.get_relationships() == []
29+
30+
31+
def test_dbt_source_returns_empty_relationships(
32+
fixtures_dir: Path,
33+
) -> None:
34+
source = DbtSource(fixtures_dir / "sample_dbt_manifest.json")
35+
assert source.get_relationships() == []
36+
37+
38+
def test_cube_source_returns_empty_relationships(
39+
fixtures_dir: Path,
40+
) -> None:
41+
source = CubeSource(fixtures_dir / "sample_cube_schema.yml")
42+
assert source.get_relationships() == []
43+
44+
45+
def test_system_prompt_includes_relationships(
46+
fixtures_dir: Path,
47+
) -> None:
48+
source = YamlSource(fixtures_dir / "semantic_source.yml")
49+
schema = DataContractSchema(
50+
name="test",
51+
semantic=SemanticConfig(
52+
allowed_tables=[
53+
AllowedTable.model_validate(
54+
{"schema": "analytics", "tables": ["orders", "customers"]}
55+
),
56+
],
57+
),
58+
)
59+
dc = DataContract(schema)
60+
prompt = dc.to_system_prompt(semantic_source=source)
61+
assert "Table Relationships" in prompt
62+
assert "analytics.orders.customer_id" in prompt
63+
assert "analytics.customers.id" in prompt
64+
assert "many_to_one" in prompt
65+
66+
67+
def test_system_prompt_no_relationships_when_empty(
68+
fixtures_dir: Path,
69+
) -> None:
70+
source = DbtSource(fixtures_dir / "sample_dbt_manifest.json")
71+
schema = DataContractSchema(
72+
name="test",
73+
semantic=SemanticConfig(
74+
allowed_tables=[
75+
AllowedTable.model_validate(
76+
{"schema": "analytics", "tables": ["orders"]}
77+
),
78+
],
79+
),
80+
)
81+
dc = DataContract(schema)
82+
prompt = dc.to_system_prompt(semantic_source=source)
83+
assert "Table Relationships" not in prompt

tests/test_semantic/test_yaml_source.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def test_get_metric_not_found(source: YamlSource) -> None:
3939
def test_get_table_schema(source: YamlSource) -> None:
4040
schema = source.get_table_schema("analytics", "orders")
4141
assert schema is not None
42-
assert len(schema.columns) == 4
42+
assert len(schema.columns) == 5
4343
col_names = [c.name for c in schema.columns]
4444
assert "id" in col_names
4545
assert "amount" in col_names

0 commit comments

Comments
 (0)