From 5be2c808637a3c298af44dfd2dd61ce88717ca69 Mon Sep 17 00:00:00 2001 From: Jeremy McCormick Date: Wed, 29 Jul 2026 17:08:55 -0500 Subject: [PATCH 1/2] Use felis ticket branch --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 00067c0..bcceb14 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,6 @@ cassandra-driver lsst-utils @ git+https://github.com/lsst/utils@main lsst-resources[s3] @ git+https://github.com/lsst/resources@main lsst-sphgeom @ git+https://github.com/lsst/sphgeom@main -lsst-felis @ git+https://github.com/lsst/felis@main +lsst-felis @ git+https://github.com/lsst/felis@tickets/DM-51789 lsst-pex-config @ git+https://github.com/lsst/pex_config@main lsst-sdm-schemas @ git+https://github.com/lsst/sdm_schemas@main From 777b59b1404138f05ea47112ea11343b2ad20eeb Mon Sep 17 00:00:00 2001 From: Jeremy McCormick Date: Wed, 29 Jul 2026 21:31:20 -0500 Subject: [PATCH 2/2] Add support for looking up columns by name when building schema model --- python/lsst/dax/apdb/schema_model.py | 111 ++++-- tests/test_schema_model.py | 517 +++++++++++++++++++++++++++ 2 files changed, 607 insertions(+), 21 deletions(-) create mode 100644 tests/test_schema_model.py diff --git a/python/lsst/dax/apdb/schema_model.py b/python/lsst/dax/apdb/schema_model.py index af980a6..41495af 100644 --- a/python/lsst/dax/apdb/schema_model.py +++ b/python/lsst/dax/apdb/schema_model.py @@ -66,6 +66,42 @@ def _make_iterable(obj: str | Iterable[str]) -> Iterable[str]: yield from obj +@dataclasses.dataclass +class _ColumnLookup: + """Lookup object for resolving columns by name or ID.""" + + columns_by_id: Mapping[str, Column] + columns_by_table_name: Mapping[tuple[str, str], Column] + + def find_table_column(self, table_name: str, column_ref: str) -> Column: + """Find a table-local column using name-first, ID-second lookup. + + Parameters + ---------- + table_name : `str` + Name of the table that owns the column reference. + column_ref : `str` + Column reference within that table. This can be either a column + name (new style) or a column ID (legacy style). + + Returns + ------- + column : `Column` + Resolved converted column. + + Raises + ------ + KeyError + Raised if ``column_ref`` cannot be resolved by either + ``(table_name, column_ref)`` in ``columns_by_table_name`` or + ``column_ref`` in ``columns_by_id``. + """ + column = self.columns_by_table_name.get((table_name, column_ref)) + if column is not None: + return column + return self.columns_by_id[column_ref] + + _data_type_size: Mapping[DataTypes, int] = { felis.datamodel.DataType.boolean: 1, felis.datamodel.DataType.byte: 1, @@ -224,15 +260,22 @@ class Index: """Additional annotations for this index.""" @classmethod - def from_felis(cls, dm_index: felis.datamodel.Index, columns: Mapping[str, Column]) -> Index: + def from_felis( + cls, + dm_index: felis.datamodel.Index, + dm_table: felis.datamodel.Table, + lookup: _ColumnLookup, + ) -> Index: """Convert Felis index definition into instance of this class. Parameters ---------- dm_index : `felis.datamodel.Index` Felis index definition. - columns : `~collections.abc.Mapping` [`str`, `Column`] - Mapping of column ID to `Column` instance. + dm_table : `felis.datamodel.Table` + Felis table containing this index. + lookup : `_ColumnLookup` + Lookup object for resolving columns. Returns ------- @@ -242,7 +285,7 @@ def from_felis(cls, dm_index: felis.datamodel.Index, columns: Mapping[str, Colum return cls( name=dm_index.name, id=dm_index.id, - columns=[columns[c] for c in (dm_index.columns or [])], + columns=[lookup.find_table_column(dm_table.name, c) for c in (dm_index.columns or [])], expressions=dm_index.expressions or [], description=dm_index.description, annotations=_strip_keys(dict(dm_index), ["name", "id", "columns", "expressions", "description"]), @@ -274,15 +317,22 @@ class Constraint: """Additional annotations for this constraint.""" @classmethod - def from_felis(cls, dm_constr: felis.datamodel.Constraint, columns: Mapping[str, Column]) -> Constraint: + def from_felis( + cls, + dm_constr: felis.datamodel.Constraint, + dm_table: felis.datamodel.Table, + lookup: _ColumnLookup, + ) -> Constraint: """Convert Felis constraint definition into instance of this class. Parameters ---------- - dm_const : `felis.datamodel.Constraint` + dm_constr : `felis.datamodel.Constraint` Felis constraint definition. - columns : `~collections.abc.Mapping` [`str`, `Column`] - Mapping of column ID to `Column` instance. + dm_table : `felis.datamodel.Table` + Felis table containing this constraint. + lookup : `_ColumnLookup` + Lookup object for resolving columns. Returns ------- @@ -293,7 +343,7 @@ def from_felis(cls, dm_constr: felis.datamodel.Constraint, columns: Mapping[str, return UniqueConstraint( name=dm_constr.name, id=dm_constr.id, - columns=[columns[c] for c in dm_constr.columns], + columns=[lookup.find_table_column(dm_table.name, c) for c in dm_constr.columns], deferrable=dm_constr.deferrable, initially=dm_constr.initially, description=dm_constr.description, @@ -303,11 +353,24 @@ def from_felis(cls, dm_constr: felis.datamodel.Constraint, columns: Mapping[str, ), ) elif isinstance(dm_constr, felis.datamodel.ForeignKeyConstraint): + source_columns = [lookup.find_table_column(dm_table.name, c) for c in dm_constr.columns] + if dm_constr.reference is not None: + # New encoding: `reference` provides table and column names. + # Resolve referenced columns via (table_name, column_name). + referenced_columns = [ + lookup.columns_by_table_name[(dm_constr.reference.table, column_name)] + for column_name in dm_constr.reference.columns + ] + else: + # Legacy encoding: `referencedColumns` stores column IDs. + # Resolve referenced columns directly via ID lookup. + referenced_columns = [lookup.columns_by_id[c] for c in dm_constr.referenced_columns or []] + return ForeignKeyConstraint( name=dm_constr.name, id=dm_constr.id, - columns=[columns[c] for c in dm_constr.columns], - referenced_columns=[columns[c] for c in dm_constr.referenced_columns], + columns=source_columns, + referenced_columns=referenced_columns, deferrable=dm_constr.deferrable, initially=dm_constr.initially, description=dm_constr.description, @@ -320,6 +383,7 @@ def from_felis(cls, dm_constr: felis.datamodel.Constraint, columns: Mapping[str, "columns", "deferrable", "initially", + "reference", "referenced_columns", "description", ], @@ -428,28 +492,30 @@ def __post_init__(self) -> None: column.table = self @classmethod - def from_felis(cls, dm_table: felis.datamodel.Table, columns: Mapping[str, Column]) -> Table: + def from_felis(cls, dm_table: felis.datamodel.Table, lookup: _ColumnLookup) -> Table: """Convert Felis table definition into instance of this class. Parameters ---------- dm_table : `felis.datamodel.Table` Felis table definition. - columns : `~collections.abc.Mapping` [`str`, `Column`] - Mapping of column ID to `Column` instance. + lookup : `_ColumnLookup` + Lookup object for resolving columns. Returns ------- table : `Table` Converted table definition. """ - table_columns = [columns[c.id] for c in dm_table.columns] + table_columns = [lookup.columns_by_id[c.id] for c in dm_table.columns] if dm_table.primary_key: - pk_columns = [columns[c] for c in _make_iterable(dm_table.primary_key)] + pk_columns = [ + lookup.find_table_column(dm_table.name, c) for c in _make_iterable(dm_table.primary_key) + ] else: pk_columns = [] - constraints = [Constraint.from_felis(constr, columns) for constr in dm_table.constraints] - indices = [Index.from_felis(dm_idx, columns) for dm_idx in dm_table.indexes] + constraints = [Constraint.from_felis(constr, dm_table, lookup) for constr in dm_table.constraints] + indices = [Index.from_felis(dm_idx, dm_table, lookup) for dm_idx in dm_table.indexes] table = cls( name=dm_table.name, id=dm_table.id, @@ -503,13 +569,16 @@ def from_felis(cls, dm_schema: felis.datamodel.Schema) -> Schema: Converted schema definition. """ # Convert all columns first. - columns: MutableMapping[str, Column] = {} + columns_by_id: MutableMapping[str, Column] = {} + columns_by_table_name: MutableMapping[tuple[str, str], Column] = {} for dm_table in dm_schema.tables: for dm_column in dm_table.columns: column = Column.from_felis(dm_column) - columns[column.id] = column + columns_by_id[column.id] = column + columns_by_table_name[(dm_table.name, column.name)] = column - tables = [Table.from_felis(dm_table, columns) for dm_table in dm_schema.tables] + lookup = _ColumnLookup(columns_by_id=columns_by_id, columns_by_table_name=columns_by_table_name) + tables = [Table.from_felis(dm_table, lookup) for dm_table in dm_schema.tables] version: felis.datamodel.SchemaVersion | None if isinstance(dm_schema.version, str): diff --git a/tests/test_schema_model.py b/tests/test_schema_model.py new file mode 100644 index 0000000..6d1d223 --- /dev/null +++ b/tests/test_schema_model.py @@ -0,0 +1,517 @@ +# This file is part of dax_apdb. +# +# Developed for the LSST Data Management System. +# This product includes software developed by the LSST Project +# (http://www.lsst.org). +# See the COPYRIGHT file at the top-level directory of this distribution +# for details of code ownership. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Unit tests for schema_model conversion from Felis schema objects.""" + +import unittest +from typing import Any + +import felis.datamodel +from pydantic import ValidationError + +from lsst.dax.apdb import schema_model + + +class SchemaModelFromFelisTestCase(unittest.TestCase): + """Test conversion from Felis schema models to dax_apdb schema models.""" + + def _make_schema(self, schema_data: dict[str, Any]) -> felis.datamodel.Schema: + """Build a Felis schema with deterministic IDs for tests.""" + return felis.datamodel.Schema.model_validate(schema_data, context={"id_generation": True}) + + def test_index_column_lookup_by_name(self) -> None: + """Index column references by name should resolve during conversion.""" + dm_schema = self._make_schema( + { + "name": "TestSchema", + "tables": [ + { + "name": "DiaObject", + "columns": [ + {"name": "diaObjectId", "datatype": "long", "nullable": False}, + {"name": "validityStartMjdTai", "datatype": "double", "nullable": False}, + ], + "primaryKey": ["diaObjectId", "validityStartMjdTai"], + "indexes": [ + { + "name": "IDX_DiaObject_validityStartMjdTai", + "columns": ["validityStartMjdTai"], + } + ], + } + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + table = apdb_schema.tables[0] + + self.assertEqual(table.indexes[0].columns[0].name, "validityStartMjdTai") + self.assertEqual([col.name for col in table.primary_key], ["diaObjectId", "validityStartMjdTai"]) + + def test_schema_model_conversion(self) -> None: + """Conversion produces correct instances of all schema_model target + classes. + """ + dm_schema = self._make_schema( + { + "name": "AllTargetsSchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "code", "datatype": "string", "length": 32, "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "uq_parent_code", + "@type": "Unique", + "columns": ["code"], + }, + { + "name": "chk_parent_id", + "@type": "Check", + "expression": "id > 0", + }, + ], + "indexes": [{"name": "idx_parent_code", "columns": ["code"]}], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["id"]}, + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + self.assertIsInstance(apdb_schema, schema_model.Schema) + self.assertEqual(apdb_schema.name, "AllTargetsSchema") + self.assertEqual(len(apdb_schema.tables), 2) + self.assertEqual([table.name for table in apdb_schema.tables], ["Parent", "Child"]) + + parent_table, child_table = apdb_schema.tables + self.assertIsInstance(parent_table, schema_model.Table) + self.assertIsInstance(child_table, schema_model.Table) + + self.assertEqual([col.name for col in parent_table.columns], ["id", "code"]) + self.assertEqual([col.name for col in child_table.columns], ["id", "parentId"]) + for column in [*parent_table.columns, *child_table.columns]: + self.assertIsInstance(column, schema_model.Column) + self.assertIsNotNone(column.table) + + self.assertEqual([col.name for col in parent_table.primary_key], ["id"]) + self.assertEqual([col.name for col in child_table.primary_key], ["id"]) + + self.assertEqual(len(parent_table.indexes), 1) + parent_index = parent_table.indexes[0] + self.assertIsInstance(parent_index, schema_model.Index) + self.assertEqual(parent_index.name, "idx_parent_code") + self.assertEqual([col.name for col in parent_index.columns], ["code"]) + self.assertEqual(child_table.indexes, []) + + self.assertEqual(len(parent_table.constraints), 2) + self.assertTrue(all(isinstance(c, schema_model.Constraint) for c in parent_table.constraints)) + parent_constraints = {constraint.name: constraint for constraint in parent_table.constraints} + + self.assertIn("uq_parent_code", parent_constraints) + unique = parent_constraints["uq_parent_code"] + assert isinstance(unique, schema_model.UniqueConstraint) + self.assertEqual([col.name for col in unique.columns], ["code"]) + + self.assertIn("chk_parent_id", parent_constraints) + check = parent_constraints["chk_parent_id"] + assert isinstance(check, schema_model.CheckConstraint) + self.assertEqual(check.expression, "id > 0") + + self.assertEqual(len(child_table.constraints), 1) + fk = child_table.constraints[0] + assert isinstance(fk, schema_model.ForeignKeyConstraint) + self.assertEqual(fk.name, "fk_child_parent") + self.assertEqual([col.name for col in fk.columns], ["parentId"]) + self.assertEqual([col.name for col in fk.referenced_columns], ["id"]) + self.assertIs(fk.referenced_table, parent_table) + + def test_foreign_key_legacy_referenced_columns(self) -> None: + """Legacy ForeignKey.referencedColumns should still resolve.""" + dm_schema = self._make_schema( + { + "name": "TestSchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + }, + { + "name": "Child", + "columns": [ + {"name": "childId", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["childId"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "referencedColumns": ["#Parent.id"], + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + child_table = next(table for table in apdb_schema.tables if table.name == "Child") + fk = child_table.constraints[0] + assert isinstance(fk, schema_model.ForeignKeyConstraint) + + self.assertEqual([col.name for col in fk.columns], ["parentId"]) + self.assertEqual([col.name for col in fk.referenced_columns], ["id"]) + + def test_foreign_key_reference_style(self) -> None: + """Name-based ForeignKey.reference should resolve during conversion.""" + dm_schema = self._make_schema( + { + "name": "TestSchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + }, + { + "name": "Child", + "columns": [ + {"name": "childId", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["childId"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["id"]}, + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + child_table = next(table for table in apdb_schema.tables if table.name == "Child") + fk = child_table.constraints[0] + assert isinstance(fk, schema_model.ForeignKeyConstraint) + + self.assertEqual([col.name for col in fk.columns], ["parentId"]) + self.assertEqual([col.name for col in fk.referenced_columns], ["id"]) + + def test_column_table_backrefs(self) -> None: + """Converted columns should point back to their owning table.""" + dm_schema = self._make_schema( + { + "name": "BackrefSchema", + "tables": [ + { + "name": "Parent", + "columns": [{"name": "id", "datatype": "long", "nullable": False}], + "primaryKey": ["id"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["id"]}, + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + for table in apdb_schema.tables: + for column in table.columns: + self.assertIs(column.table, table) + + def test_foreign_key_referenced_column_identity(self) -> None: + """FK referenced columns should be exact objects from parent table + columns. + """ + dm_schema = self._make_schema( + { + "name": "IdentitySchema", + "tables": [ + { + "name": "Parent", + "columns": [{"name": "id", "datatype": "long", "nullable": False}], + "primaryKey": ["id"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["id"]}, + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + parent_table = next(table for table in apdb_schema.tables if table.name == "Parent") + child_table = next(table for table in apdb_schema.tables if table.name == "Child") + fk = child_table.constraints[0] + assert isinstance(fk, schema_model.ForeignKeyConstraint) + + self.assertIs(fk.referenced_columns[0], parent_table.columns[0]) + + def test_foreign_key_multicolumn_order_preserved(self) -> None: + """Source and referenced FK column order should match Felis + definition. + """ + dm_schema = self._make_schema( + { + "name": "OrderSchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id1", "datatype": "long", "nullable": False}, + {"name": "id2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id1", "id2"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "p1", "datatype": "long", "nullable": False}, + {"name": "p2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["p2", "p1"], + "reference": {"table": "Parent", "columns": ["id2", "id1"]}, + } + ], + }, + ], + } + ) + + apdb_schema = schema_model.Schema.from_felis(dm_schema) + child_table = next(table for table in apdb_schema.tables if table.name == "Child") + fk = child_table.constraints[0] + assert isinstance(fk, schema_model.ForeignKeyConstraint) + + self.assertEqual([col.name for col in fk.columns], ["p2", "p1"]) + self.assertEqual([col.name for col in fk.referenced_columns], ["id2", "id1"]) + + def test_foreign_key_styles_equivalent(self) -> None: + """Legacy and reference FK encodings should resolve to same column + names. + """ + legacy_schema = self._make_schema( + { + "name": "LegacySchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id1", "datatype": "long", "nullable": False}, + {"name": "id2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id1", "id2"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "p1", "datatype": "long", "nullable": False}, + {"name": "p2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["p2", "p1"], + "referencedColumns": ["#Parent.id2", "#Parent.id1"], + } + ], + }, + ], + } + ) + + reference_schema = self._make_schema( + { + "name": "ReferenceSchema", + "tables": [ + { + "name": "Parent", + "columns": [ + {"name": "id1", "datatype": "long", "nullable": False}, + {"name": "id2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id1", "id2"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "p1", "datatype": "long", "nullable": False}, + {"name": "p2", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["p2", "p1"], + "reference": {"table": "Parent", "columns": ["id2", "id1"]}, + } + ], + }, + ], + } + ) + + legacy_apdb_schema = schema_model.Schema.from_felis(legacy_schema) + reference_apdb_schema = schema_model.Schema.from_felis(reference_schema) + legacy_child = next(table for table in legacy_apdb_schema.tables if table.name == "Child") + reference_child = next(table for table in reference_apdb_schema.tables if table.name == "Child") + legacy_fk = legacy_child.constraints[0] + reference_fk = reference_child.constraints[0] + assert isinstance(legacy_fk, schema_model.ForeignKeyConstraint) + assert isinstance(reference_fk, schema_model.ForeignKeyConstraint) + + self.assertEqual([col.name for col in legacy_fk.columns], [col.name for col in reference_fk.columns]) + self.assertEqual( + [col.name for col in legacy_fk.referenced_columns], + [col.name for col in reference_fk.referenced_columns], + ) + + def test_foreign_key_missing_referenced_table_raises(self) -> None: + """Missing referenced table should fail Felis schema validation.""" + with self.assertRaises(ValidationError): + self._make_schema( + { + "name": "MissingTableSchema", + "tables": [ + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["id"]}, + } + ], + } + ], + } + ) + + def test_foreign_key_missing_referenced_column_raises(self) -> None: + """Missing referenced column should fail Felis schema validation.""" + with self.assertRaises(ValidationError): + self._make_schema( + { + "name": "MissingColumnSchema", + "tables": [ + { + "name": "Parent", + "columns": [{"name": "id", "datatype": "long", "nullable": False}], + "primaryKey": ["id"], + }, + { + "name": "Child", + "columns": [ + {"name": "id", "datatype": "long", "nullable": False}, + {"name": "parentId", "datatype": "long", "nullable": False}, + ], + "primaryKey": ["id"], + "constraints": [ + { + "name": "fk_child_parent", + "@type": "ForeignKey", + "columns": ["parentId"], + "reference": {"table": "Parent", "columns": ["missing"]}, + } + ], + }, + ], + } + ) + + +if __name__ == "__main__": + unittest.main()