Skip to content
Closed
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
45 changes: 45 additions & 0 deletions fglatch/registry/_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from fgmetric._typing_extensions import is_list
from fgmetric._typing_extensions import is_optional
from fgmetric._typing_extensions import unpack_optional
from latch.registry.record import Record
from latch.registry.table import Table
from latch.registry.types import Column
from latch.registry.utils import to_python_type
Expand Down Expand Up @@ -285,6 +286,9 @@ def _compare_unwrapped(
if is_list(model_type):
return _compare_list(field_name, column_name, model_type, column_type)

if _looks_like_latch_record_model(model_type):
return _compare_link(field_name, column_name, model_type, column_type)

if model_type is column_type:
return None

Expand Down Expand Up @@ -422,3 +426,44 @@ def _compare_list(
return _compare_unwrapped(
f"{field_name}[*]", f"{column_name}[*]", model_args[0], column_args[0]
)


def _looks_like_latch_record_model(t: TypeAnnotation) -> bool:
"""
Structural check for a `LatchRecordModel` subclass.

Identifies a Pydantic `BaseModel` subclass that declares the `id` and `name` fields
`LatchRecordModel` mandates. The check is structural (no `issubclass(t, LatchRecordModel)`)
so this module doesn't need to import `_record_model` — which imports back from here,
and would otherwise produce a circular import that has to be papered over with a lazy
function-local import.
"""
return (
isinstance(t, type) and issubclass(t, BaseModel) and {"id", "name"} <= t.model_fields.keys()
)


def _compare_link(
field_name: str,
column_name: str,
model_type: TypeAnnotation,
column_type: TypeAnnotation,
) -> SchemaMismatch | None:
"""
Confirm a `LatchRecordModel` model field maps to a Registry `link` column.

Per spec, the link's target table is not checked — any `LatchRecordModel` subclass
matches any `link` column. That keeps the check tolerant of models that represent
only a subset of a larger linked table. The SDK's `to_python_type` maps link columns
to `Record`, so a column is a link iff `column_type is Record`.
"""
if column_type is Record:
return None

return SchemaMismatch(
kind=SchemaMismatchKind.TYPE_MISMATCH,
model_field=field_name,
column_name=column_name,
model_type=model_type,
column_type=column_type,
)
88 changes: 88 additions & 0 deletions tests/registry/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import cast

import pytest
from latch.registry.record import Record
from latch.registry.table import Table
from latch.registry.types import Column
from latch.registry.types import EmptyCell
Expand Down Expand Up @@ -34,6 +35,18 @@ def _column(py_type: Any, primitive: _BasicPrimitive, allow_empty: bool = False)
return Column(key="<placeholder>", type=column_type, upstream_type=upstream_type)


def _link_column(experiment_id: str, allow_empty: bool = False) -> Column:
"""Build a Registry link Column pointing at the given table's experimentId."""
column_type: Any = Record
if allow_empty:
column_type = Union[column_type, EmptyCell]
upstream_type: DBType = {
"type": {"primitive": "link", "experimentId": experiment_id},
"allowEmpty": allow_empty,
}
return Column(key="<placeholder>", type=column_type, upstream_type=upstream_type)


def _array_column(
element_py_type: type,
element_primitive: _BasicPrimitive,
Expand Down Expand Up @@ -786,3 +799,78 @@ class Model(LatchRecordModel):
table = _table(mocker, {"xs": _array_column(str, "string", allow_empty=True)})

assert _validate_table_schema(Model, table, allow_extra_columns=True) == []


# Link tests.


class _LinkedRecord(LatchRecordModel):
"""A LatchRecordModel subclass used as a link target in tests."""

title: str


def test_link_field_happy_with_typed_target(mocker: MockerFixture) -> None:
"""A LatchRecordModel subclass on the model ↔ link column validates cleanly."""

class Model(LatchRecordModel):
parent: _LinkedRecord

table = _table(mocker, {"parent": _link_column("9999")})

assert _validate_table_schema(Model, table, allow_extra_columns=True) == []


def test_link_field_happy_with_bare_base(mocker: MockerFixture) -> None:
"""A bare `LatchRecordModel` annotation ↔ link column validates cleanly."""

class Model(LatchRecordModel):
parent: LatchRecordModel

table = _table(mocker, {"parent": _link_column("9999")})

assert _validate_table_schema(Model, table, allow_extra_columns=True) == []


def test_link_field_type_mismatch_when_column_not_link(mocker: MockerFixture) -> None:
"""Model declares a record subclass but column is a string primitive → `TYPE_MISMATCH`."""

class Model(LatchRecordModel):
parent: _LinkedRecord

table = _table(mocker, {"parent": _column(str, "string")})

mismatches = _validate_table_schema(Model, table, allow_extra_columns=True)

assert len(mismatches) == 1
assert mismatches[0].kind is SchemaMismatchKind.TYPE_MISMATCH


def test_nullable_link_field_happy(mocker: MockerFixture) -> None:
"""`RecordSubclass | None` ↔ nullable link column validates cleanly."""

class Model(LatchRecordModel):
parent: _LinkedRecord | None = None

table = _table(mocker, {"parent": _link_column("9999", allow_empty=True)})

assert _validate_table_schema(Model, table, allow_extra_columns=True) == []


def test_unrelated_basemodel_does_not_match_link(mocker: MockerFixture) -> None:
"""A BaseModel without id/name doesn't get flagged as a record link."""
from pydantic import BaseModel

class NotARecord(BaseModel):
x: int

class Model(LatchRecordModel):
thing: NotARecord

table = _table(mocker, {"thing": _link_column("9999")})

mismatches = _validate_table_schema(Model, table, allow_extra_columns=True)

# Treated as a primitive type comparison → TYPE_MISMATCH against Record.
assert len(mismatches) == 1
assert mismatches[0].kind is SchemaMismatchKind.TYPE_MISMATCH