Skip to content

Commit e0b472f

Browse files
rustyconoverclaude
andcommitted
Canonical constraint records for declarative catalogs (vgi.v3)
A declarative Table(constraints=[...]) could not attach: nothing advertised the constraint read profile, there was no ergonomic way to build a record, and records DuckDB rejects at ATTACH were served without complaint. - constraint_record() / ReferencedKey: build a ConstraintInfo from schema path, table, id, kind and column paths. enforcement and validation are required keywords; the worker attests its own facts. - ReadOnlyCatalogInterface.catalog_capabilities() advertises vgi.constraint.records.read.v1 + vgi.expression.duckdb.scalar.v1 when any declared table carries records. The worker already selects against the client's offer after catalog_attach, so attach() overrides cannot drop it. - validate_constraint_records(): the extension's ATTACH rules. Every record must be ENFORCED + ALL_ROWS and owned by its table. Arrow nullability must equal NOT_NULL/PRIMARY_KEY membership, recursing into ordinary structs, and this applies to tables with no records too. Table.__post_init__ runs it, so authors' unit tests catch it, and the worker runs it again when serving. Messages name the table, constraint id and column path. - The legacy not_null/unique/check/primary_key/foreign_key fields are now rejected at Table construction, with an error naming constraint_record(). Their dead validation and serialization helpers are removed. - vgi.catalog re-exports RequiredFilterPaths. - docs/catalog-interface.md: examples use records, and a Constraints section covers the three ATTACH rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent bdbb51f commit e0b472f

10 files changed

Lines changed: 613 additions & 342 deletions

‎docs/catalog-interface.md‎

Lines changed: 75 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -122,9 +122,8 @@ Information about a table:
122122
| `name` | `str` | Table name |
123123
| `schema_path` | `SchemaPath` | Parent schema path |
124124
| `columns` | `SerializedSchema` | Column definitions as Arrow schema bytes |
125-
| `not_null_constraints` | `list[int]` | Column indices with NOT NULL |
126-
| `unique_constraints` | `list[list[int]]` | Column index groups for UNIQUE |
127-
| `check_constraints` | `list[str]` | SQL check expressions |
125+
| `constraints` | `list[bytes]` | Canonical `ConstraintInfo` records (see [Constraints](#constraints)) |
126+
| `definition_revision` | `bytes \| None` | Opaque table-definition revision |
128127
| `comment` | `str \| None` | Optional description |
129128
| `tags` | `dict[str, str]` | Key-value metadata |
130129

@@ -509,7 +508,7 @@ For most use cases, the declarative catalog API provides a simpler way to define
509508

510509
```python
511510
from vgi import Worker, TableFunctionGenerator
512-
from vgi.catalog import Catalog, Schema, Table, View
511+
from vgi.catalog import Catalog, Schema, Table, View, constraint_record
513512
```
514513

515514
### Function-Backed Tables (Recommended)
@@ -527,7 +526,7 @@ class UsersFunction(TableFunctionGenerator):
527526
"""Generate user data."""
528527

529528
FIXED_SCHEMA: ClassVar[pa.Schema] = pa.schema([
530-
("id", pa.int64()),
529+
pa.field("id", pa.int64(), nullable=False), # NOT NULL: see constraints below
531530
("name", pa.string()),
532531
("active", pa.bool_()),
533532
])
@@ -545,8 +544,16 @@ class UsersFunction(TableFunctionGenerator):
545544
users_table = Table(
546545
name="users",
547546
function=UsersFunction, # Schema derived from FIXED_SCHEMA
548-
not_null=["id"], # Constraint column names validated
549-
unique=[["id"]],
547+
constraints=( # Paths and nullability validated at definition time
548+
constraint_record(
549+
["main"], "users", "users.id.not_null", "NOT_NULL", [["id"]],
550+
enforcement="ENFORCED", validation="ALL_ROWS",
551+
),
552+
constraint_record(
553+
["main"], "users", "users.id.unique", "UNIQUE", [["id"]],
554+
enforcement="ENFORCED", validation="ALL_ROWS",
555+
),
556+
),
550557
comment="User accounts",
551558
)
552559
```
@@ -593,7 +600,7 @@ if __name__ == "__main__":
593600
| Feature | Description |
594601
|---------|-------------|
595602
| **No schema duplication** | Function-backed tables derive schema automatically |
596-
| **Constraint validation** | `not_null`, `unique` column names validated at definition time |
603+
| **Constraint validation** | Constraint paths, trust and Arrow nullability validated at definition time |
597604
| **Automatic scan handling** | Function-backed tables don't need `table_scan_function_get()` |
598605
| **Type safety** | Frozen dataclasses with runtime validation |
599606

@@ -606,16 +613,60 @@ For tables not backed by functions, provide the schema explicitly:
606613
config_table = Table(
607614
name="config",
608615
columns=pa.schema([
609-
("key", pa.string()),
616+
pa.field("key", pa.string(), nullable=False),
610617
("value", pa.string()),
611618
]),
612-
not_null=["key"],
613-
unique=[["key"]],
619+
constraints=(
620+
constraint_record(
621+
["main"], "config", "config.key.pk", "PRIMARY_KEY", [["key"]],
622+
enforcement="ENFORCED", validation="ALL_ROWS",
623+
),
624+
),
614625
)
615626
```
616627

617628
**Note:** Tables with explicit columns require the worker to implement `table_scan_function_get()` to tell DuckDB how to scan the data.
618629

630+
### Constraints
631+
632+
A table states its constraints as canonical records built with
633+
`constraint_record()`. Each record is the worker's own attestation — the SDK
634+
never derives one — so `enforcement` and `validation` are always explicit:
635+
636+
```python
637+
from vgi.catalog import ReferencedKey, constraint_record
638+
639+
constraint_record(
640+
["main"], "orders", "orders.customer.fk", "FOREIGN_KEY", [["customer_id"]],
641+
enforcement="ENFORCED", validation="ALL_ROWS",
642+
references=ReferencedKey(
643+
schema_path=["main"], table="customers", columns=[["id"]], constraint_id="customers.pk",
644+
),
645+
)
646+
constraint_record(
647+
["main"], "orders", "orders.amount.check", "CHECK", [],
648+
enforcement="ENFORCED", validation="ALL_ROWS", check_sql="amount >= 0",
649+
)
650+
```
651+
652+
DuckDB checks three things when it attaches the catalog, and `Table` checks the
653+
same at definition time (the worker checks again when serving each table):
654+
655+
- It reads only `ENFORCED` + `ALL_ROWS` records; state one only when every row
656+
the table can ever return satisfies it.
657+
- Every Arrow field — descending through ordinary structs, but not into
658+
extension or logical-type storage — is non-nullable exactly when a
659+
`NOT_NULL` or `PRIMARY_KEY` record names its path. This applies to tables
660+
with no constraints too: a non-nullable field there is an error.
661+
- The records are readable only under the negotiated
662+
`vgi.constraint.records.read.v1` profile (plus `vgi.expression.duckdb.scalar.v1`
663+
for CHECK). `ReadOnlyCatalogInterface` advertises both automatically when any
664+
declared table carries records; a catalog that serves tables some other way
665+
overrides `catalog_capabilities()`.
666+
667+
The legacy `not_null`, `unique`, `check`, `primary_key` and `foreign_key`
668+
fields are rejected; replace them with records.
669+
619670
### Validation
620671

621672
Declarative catalogs include comprehensive validation:
@@ -627,10 +678,21 @@ Table(name="bad") # ValueError: must specify either 'columns' or 'function'
627678
# Error: invalid constraint column
628679
Table(
629680
name="users",
630-
columns=pa.schema([("id", pa.int64())]),
631-
not_null=["nonexistent"], # ValueError: column 'nonexistent' not found
681+
columns=pa.schema([pa.field("id", pa.int64(), nullable=False)]),
682+
constraints=(
683+
constraint_record(
684+
["main"], "users", "nn", "NOT_NULL", [["nonexistent"]],
685+
enforcement="ENFORCED", validation="ALL_ROWS",
686+
), # ValueError: column path ['nonexistent'] is missing
687+
),
632688
)
633689

690+
# Error: Arrow nullability must match NOT_NULL/PRIMARY_KEY records exactly
691+
Table(
692+
name="users",
693+
columns=pa.schema([pa.field("id", pa.int64(), nullable=False)]),
694+
) # ValueError: ... the field is non-nullable but no ... record names it
695+
634696
# Error: default_schema not in schemas
635697
Catalog(
636698
name="myapp",

‎tests/catalog/test_constraint_public_contract.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,29 @@ def test_discovery_never_hides_constraints_and_mutation_requires_revision() -> N
170170
Worker._table_for_capabilities(table_info(definition_revision=None), PROFILES)
171171

172172

173-
@pytest.mark.parametrize("trusted", [False, True])
174-
def test_discovery_rejects_nullability_that_does_not_match_trust(trusted: bool) -> None:
175-
info = INFO if trusted else replace(INFO, enforcement=ConstraintEnforcement.METADATA_ONLY)
176-
wrong = pa.schema([pa.field("a.b", pa.int64(), nullable=trusted)])
173+
def test_discovery_rejects_nullability_that_does_not_match_records() -> None:
174+
wrong = pa.schema([pa.field("a.b", pa.int64(), nullable=True)])
177175
with pytest.raises(ValueError, match="nullability"):
178176
Worker._table_for_capabilities(
179-
table_info(columns=wrong.serialize().to_pybytes(), constraints=[info.serialize_to_bytes()]), PROFILES
177+
table_info(columns=wrong.serialize().to_pybytes(), constraints=[INFO.serialize_to_bytes()]), PROFILES
180178
)
181179

182180

181+
@pytest.mark.parametrize(
182+
"state",
183+
[
184+
{"enforcement": ConstraintEnforcement.METADATA_ONLY},
185+
{"validation": ConstraintValidation.FUTURE_WRITES},
186+
{"validation": ConstraintValidation.UNVALIDATED},
187+
],
188+
)
189+
def test_discovery_rejects_records_duckdb_will_not_read(state: dict[str, Any]) -> None:
190+
"""The extension fails ATTACH unless every record is ENFORCED + ALL_ROWS."""
191+
info = replace(INFO, **state)
192+
with pytest.raises(ValueError, match="accepts only ENFORCED \\+ ALL_ROWS"):
193+
Worker._table_for_capabilities(table_info(constraints=[info.serialize_to_bytes()]), PROFILES)
194+
195+
183196
def test_create_checks_negotiation_and_current_support_before_provider(monkeypatch: pytest.MonkeyPatch) -> None:
184197
for selected, available in [(CatalogCapabilities(), PROFILES), (PROFILES, CatalogCapabilities())]:
185198
instance, _, calls = worker(monkeypatch, selected, available)

0 commit comments

Comments
 (0)