diff --git a/.docker/mssql/init/01-schema.sql b/.docker/mssql/init/01-schema.sql index 84d6040..a1ffbe4 100644 --- a/.docker/mssql/init/01-schema.sql +++ b/.docker/mssql/init/01-schema.sql @@ -100,6 +100,25 @@ GO CREATE INDEX idx_book_title ON apptest.book (title); GO +-- Composite primary key + composite foreign key: the reader must keep the two +-- columns in one constraint, otherwise each half points at a non-unique key. +CREATE TABLE apptest.assembly ( + id INT NOT NULL, + cetat CHAR(1) NOT NULL, + label NVARCHAR(50), + CONSTRAINT pk_assembly PRIMARY KEY (id, cetat) +); +GO + +CREATE TABLE apptest.assembly_vote ( + id INT IDENTITY(1,1) PRIMARY KEY, + assembly_id INT NOT NULL, + cetat CHAR(1) NOT NULL, + CONSTRAINT fk_vote_assembly FOREIGN KEY (assembly_id, cetat) + REFERENCES apptest.assembly (id, cetat) +); +GO + -- Representative payload INSERT INTO apptest.type_matrix (c_bit, c_tinyint, c_smallint, c_int, c_bigint, @@ -132,4 +151,7 @@ GO INSERT INTO apptest.book (author_id, title) VALUES (1, N'First'); INSERT INTO apptest.book (author_id, title) VALUES (1, N'Second''s ride'); INSERT INTO apptest.book (author_id, title) VALUES (2, N'Bob book'); + +INSERT INTO apptest.assembly (id, cetat, label) VALUES (1, 'O', N'AG 2024'); +INSERT INTO apptest.assembly_vote (assembly_id, cetat) VALUES (1, 'O'); GO diff --git a/.docker/mysql/init/01-schema.sql b/.docker/mysql/init/01-schema.sql index 1472caf..dffef00 100644 --- a/.docker/mysql/init/01-schema.sql +++ b/.docker/mysql/init/01-schema.sql @@ -62,6 +62,24 @@ CREATE TABLE book ( CREATE INDEX idx_book_title ON book (title); +-- Composite primary key + composite foreign key: the reader must keep the two +-- columns in one constraint, otherwise each half points at a non-unique key. +CREATE TABLE assembly ( + id INT NOT NULL, + cetat CHAR(1) NOT NULL, + label VARCHAR(50), + PRIMARY KEY (id, cetat) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE assembly_vote ( + id INT NOT NULL AUTO_INCREMENT, + assembly_id INT NOT NULL, + cetat CHAR(1) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_vote_assembly FOREIGN KEY (assembly_id, cetat) + REFERENCES assembly (id, cetat) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Representative payload (one populated row + one all-NULL row) INSERT INTO type_matrix (c_bit, c_tinyint, c_smallint, c_mediumint, c_int, c_bigint, @@ -88,3 +106,6 @@ INSERT INTO author (name, birth_year) VALUES ('Bob', NULL); INSERT INTO book (author_id, title) VALUES (1, 'First'); INSERT INTO book (author_id, title) VALUES (1, 'Second\'s ride'); INSERT INTO book (author_id, title) VALUES (2, 'Bob book'); + +INSERT INTO assembly (id, cetat, label) VALUES (1, 'O', 'AG 2024'); +INSERT INTO assembly_vote (assembly_id, cetat) VALUES (1, 'O'); diff --git a/.docker/oracle/init/01-schema.sql b/.docker/oracle/init/01-schema.sql index 9c444e4..4e000ff 100644 --- a/.docker/oracle/init/01-schema.sql +++ b/.docker/oracle/init/01-schema.sql @@ -70,6 +70,26 @@ CREATE TABLE book ( CREATE INDEX idx_book_title ON book (title); +-- --------------------------------------------------------------------------- +-- Composite primary key + composite foreign key. The reader has to keep the +-- two columns in one constraint: emitted separately, each half would point at +-- a non-unique key and the target would reject the DDL. +-- --------------------------------------------------------------------------- +CREATE TABLE assembly ( + id NUMBER NOT NULL, + cetat CHAR(1) NOT NULL, + label VARCHAR2(50), + CONSTRAINT pk_assembly PRIMARY KEY (id, cetat) +); + +CREATE TABLE assembly_vote ( + id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + assembly_id NUMBER NOT NULL, + cetat CHAR(1) NOT NULL, + CONSTRAINT fk_vote_assembly FOREIGN KEY (assembly_id, cetat) + REFERENCES assembly (id, cetat) +); + -- --------------------------------------------------------------------------- -- Representative payload (one populated row + one all-NULL row) -- --------------------------------------------------------------------------- @@ -114,4 +134,7 @@ INSERT INTO book (author_id, title) VALUES (1, 'First'); INSERT INTO book (author_id, title) VALUES (1, 'Second''s ride'); INSERT INTO book (author_id, title) VALUES (2, 'Bob book'); +INSERT INTO assembly (id, cetat, label) VALUES (1, 'O', 'AG 2024'); +INSERT INTO assembly_vote (assembly_id, cetat) VALUES (1, 'O'); + COMMIT; diff --git a/.docker/postgres/init/01-schema.sql b/.docker/postgres/init/01-schema.sql index 853dc2e..85ea7f4 100644 --- a/.docker/postgres/init/01-schema.sql +++ b/.docker/postgres/init/01-schema.sql @@ -60,6 +60,23 @@ CREATE TABLE apptest.book ( CREATE INDEX idx_book_title ON apptest.book (title); +-- Composite primary key + composite foreign key: the reader must keep the two +-- columns in one constraint, otherwise each half points at a non-unique key. +CREATE TABLE apptest.assembly ( + id INTEGER NOT NULL, + cetat CHAR(1) NOT NULL, + label VARCHAR(50), + CONSTRAINT pk_assembly PRIMARY KEY (id, cetat) +); + +CREATE TABLE apptest.assembly_vote ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + assembly_id INTEGER NOT NULL, + cetat CHAR(1) NOT NULL, + CONSTRAINT fk_vote_assembly FOREIGN KEY (assembly_id, cetat) + REFERENCES apptest.assembly (id, cetat) +); + -- Representative payload (one populated row + one all-NULL row) INSERT INTO apptest.type_matrix (c_boolean, c_smallint, c_integer, c_bigint, @@ -90,4 +107,7 @@ INSERT INTO apptest.book (author_id, title) VALUES (1, 'First'); INSERT INTO apptest.book (author_id, title) VALUES (1, 'Second''s ride'); INSERT INTO apptest.book (author_id, title) VALUES (2, 'Bob book'); +INSERT INTO apptest.assembly (id, cetat, label) VALUES (1, 'O', 'AG 2024'); +INSERT INTO apptest.assembly_vote (assembly_id, cetat) VALUES (1, 'O'); + SELECT 'postgres apptest fixture loaded' AS status; diff --git a/db2sql/domain/model/column.py b/db2sql/domain/model/column.py index 7172e28..2444ea2 100644 --- a/db2sql/domain/model/column.py +++ b/db2sql/domain/model/column.py @@ -5,8 +5,6 @@ from dataclasses import dataclass from typing import Optional -from .foreign_key import ForeignKey - @dataclass class Column: @@ -22,7 +20,6 @@ class Column: computed_definition: Optional[str] = None identity: bool = False constraint: Optional[str] = None - foreign_key: Optional[ForeignKey] = None @property def is_primary_key(self) -> bool: diff --git a/db2sql/domain/model/foreign_key.py b/db2sql/domain/model/foreign_key.py index f66f4a0..c605eca 100644 --- a/db2sql/domain/model/foreign_key.py +++ b/db2sql/domain/model/foreign_key.py @@ -3,12 +3,30 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Optional, Tuple @dataclass(frozen=True) class ForeignKey: - """Reference to another column. Immutable value object.""" + """One constraint: local columns referencing a key of another table. + + ``schema`` and ``table`` name the referenced table. ``columns`` and + ``ref_columns`` are parallel: the n-th local column references the n-th + referenced column. Composite keys are a single ForeignKey, not one per + column — splitting them produces DDL the target rejects, since each half + would point at a non-unique key. + """ schema: str table: str - column: str + columns: Tuple[str, ...] + ref_columns: Tuple[str, ...] + name: Optional[str] = None + + def __post_init__(self) -> None: + if not self.columns: + raise ValueError("foreign key has no columns") + if len(self.columns) != len(self.ref_columns): + raise ValueError( + f"foreign key column count mismatch: {self.columns} -> {self.ref_columns}" + ) diff --git a/db2sql/domain/model/table.py b/db2sql/domain/model/table.py index f91a728..48deb37 100644 --- a/db2sql/domain/model/table.py +++ b/db2sql/domain/model/table.py @@ -8,6 +8,7 @@ from db2sql.domain.errors import DuplicatedColumnError from .column import Column +from .foreign_key import ForeignKey @dataclass @@ -17,6 +18,7 @@ class Table: name: str columns: Dict[str, Column] = field(default_factory=dict) indexes: Dict[str, List[str]] = field(default_factory=dict) + foreign_keys: List[ForeignKey] = field(default_factory=list) source_query: Optional[str] = None def add_column(self, column: Column) -> None: @@ -30,5 +32,8 @@ def get_column(self, name: str) -> Optional[Column]: def add_index(self, index_name: str, column_name: str) -> None: self.indexes.setdefault(index_name, []).append(column_name) + def add_foreign_key(self, foreign_key: ForeignKey) -> None: + self.foreign_keys.append(foreign_key) + def primary_key_columns(self) -> List[str]: return [name for name, column in self.columns.items() if column.is_primary_key] diff --git a/db2sql/domain/policy/dependency_order.py b/db2sql/domain/policy/dependency_order.py index eab3a1a..cd58c50 100644 --- a/db2sql/domain/policy/dependency_order.py +++ b/db2sql/domain/policy/dependency_order.py @@ -2,7 +2,7 @@ Used to emit ``DROP TABLE`` statements in an order that respects referential integrity without falling back to ``CASCADE``. The order is computed from the -foreign keys carried on each :class:`~db2sql.domain.model.Column`. +foreign keys carried on each :class:`~db2sql.domain.model.Table`. """ from __future__ import annotations @@ -37,10 +37,7 @@ def topological_order(database: Database) -> List[TableKey]: for schema_name, schema in database.schemas.items(): for table_name, table in schema.tables.items(): child = (schema_name, table_name) - for column in table.columns.values(): - fk = column.foreign_key - if fk is None: - continue + for fk in table.foreign_keys: parent = (fk.schema, fk.table) if parent == child or parent not in known: continue diff --git a/db2sql/infrastructure/emit/mssql/emitter.py b/db2sql/infrastructure/emit/mssql/emitter.py index ae46210..949aeb4 100644 --- a/db2sql/infrastructure/emit/mssql/emitter.py +++ b/db2sql/infrastructure/emit/mssql/emitter.py @@ -314,10 +314,7 @@ def emit_foreign_keys(self, database: Database, sink: OutputSink) -> None: for schema in database.schemas.values(): for table in schema.tables.values(): qualified = self.table_name(schema, table) - for column in table.columns.values(): - fk = column.foreign_key - if not fk: - continue + for fk in table.foreign_keys: ref_schema = database.schemas.get(fk.schema) if ref_schema is None: continue @@ -325,11 +322,13 @@ def emit_foreign_keys(self, database: Database, sink: OutputSink) -> None: if ref_table is None: continue ref_qualified = self.table_name(ref_schema, ref_table) + cols = ", ".join(self.quote_identifier(c) for c in fk.columns) + ref_cols = ", ".join(self.quote_identifier(c) for c in fk.ref_columns) sink.write( f"ALTER TABLE {qualified} " - f"ADD FOREIGN KEY ({self.quote_identifier(column.name)}) " + f"ADD FOREIGN KEY ({cols}) " f"REFERENCES {ref_qualified} " - f"({self.quote_identifier(fk.column)});\n" + f"({ref_cols});\n" ) sink.boundary() sink.write("\n") diff --git a/db2sql/infrastructure/emit/postgres/emitter.py b/db2sql/infrastructure/emit/postgres/emitter.py index bb66f9a..b72c2ca 100644 --- a/db2sql/infrastructure/emit/postgres/emitter.py +++ b/db2sql/infrastructure/emit/postgres/emitter.py @@ -272,10 +272,7 @@ def emit_foreign_keys(self, database: Database, sink: OutputSink) -> None: for schema in database.schemas.values(): for table in schema.tables.values(): qualified = self.table_name(schema, table) - for column in table.columns.values(): - fk = column.foreign_key - if not fk: - continue + for fk in table.foreign_keys: ref_schema = database.schemas.get(fk.schema) if ref_schema is None: continue @@ -283,11 +280,13 @@ def emit_foreign_keys(self, database: Database, sink: OutputSink) -> None: if ref_table is None: continue ref_qualified = self.table_name(ref_schema, ref_table) + cols = ", ".join(self.quote_identifier(c) for c in fk.columns) + ref_cols = ", ".join(self.quote_identifier(c) for c in fk.ref_columns) sink.write( f"ALTER TABLE {qualified} " - f"ADD FOREIGN KEY ({self.quote_identifier(column.name)}) " + f"ADD FOREIGN KEY ({cols}) " f"REFERENCES {ref_qualified} " - f"({self.quote_identifier(fk.column)});\n" + f"({ref_cols});\n" ) sink.boundary() sink.write("\n") diff --git a/db2sql/infrastructure/persistence/foreign_keys.py b/db2sql/infrastructure/persistence/foreign_keys.py new file mode 100644 index 0000000..9d906fe --- /dev/null +++ b/db2sql/infrastructure/persistence/foreign_keys.py @@ -0,0 +1,62 @@ +"""Turn per-column catalog rows into one foreign key per constraint. + +Every catalog reports foreign keys one row per column, so a composite key +arrives as several rows sharing a constraint name. Each reader collects those +rows in position order and hands them here. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Iterable, List, Optional, Tuple + +from db2sql.domain.model import Database, ForeignKey + + +@dataclass(frozen=True) +class ForeignKeyColumn: + """A single ``(local column -> referenced column)`` row from a catalog. + + ``key`` groups rows belonging to the same constraint; it is usually the + constraint name, but any value the catalog provides will do (SQLite, for + instance, only numbers its foreign keys). + """ + + schema: str + table: str + key: str + column: str + ref_schema: str + ref_table: str + ref_column: str + name: Optional[str] = None + + +def attach_foreign_keys(database: Database, rows: Iterable[ForeignKeyColumn]) -> None: + """Group ``rows`` by constraint and attach the result to their tables. + + Rows must already be in the constraint's column order — local and + referenced columns are paired positionally. A constraint is skipped when + its table or any of its columns is absent from ``database`` (excluded + schema, filtered table). + """ + grouped: Dict[Tuple[str, str, str], List[ForeignKeyColumn]] = {} + for row in rows: + grouped.setdefault((row.schema, row.table, row.key), []).append(row) + + for (schema, table_name, _), members in grouped.items(): + table = database.get_table(schema, table_name) + if table is None: + continue + if any(table.get_column(member.column) is None for member in members): + continue + first = members[0] + table.add_foreign_key( + ForeignKey( + schema=first.ref_schema, + table=first.ref_table, + columns=tuple(member.column for member in members), + ref_columns=tuple(member.ref_column for member in members), + name=first.name, + ) + ) diff --git a/db2sql/infrastructure/persistence/mssql/reader.py b/db2sql/infrastructure/persistence/mssql/reader.py index eca95c0..4ca82b0 100644 --- a/db2sql/infrastructure/persistence/mssql/reader.py +++ b/db2sql/infrastructure/persistence/mssql/reader.py @@ -8,10 +8,14 @@ from sqlalchemy.orm.session import Session, sessionmaker from db2sql.application.ports import Logger -from db2sql.domain.model import Column, Database, ForeignKey, Schema, Table +from db2sql.domain.model import Column, Database, Schema, Table from db2sql.infrastructure.config import AppConfig from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError +from db2sql.infrastructure.persistence.foreign_keys import ( + attach_foreign_keys, + ForeignKeyColumn, +) from db2sql.infrastructure.url import build_url, redact_url @@ -221,19 +225,25 @@ def _read_foreign_keys(self, database: Database) -> None: AND KCU2.CONSTRAINT_NAME = RC.UNIQUE_CONSTRAINT_NAME WHERE KCU1.ORDINAL_POSITION = KCU2.ORDINAL_POSITION AND KCU1.TABLE_SCHEMA not in ('sys', 'guest', 'information_schema') -ORDER BY CONSTRAINT_SCHEMA, CONSTRAINT_NAME +ORDER BY CONSTRAINT_SCHEMA, CONSTRAINT_NAME, KCU1.ORDINAL_POSITION """)) - for row in r: - table = database.get_table(row.TABLE_SCHEMA, row.TABLE_NAME) - if table: - column = table.get_column(row.COLUMN_NAME) - if column: - column.foreign_key = ForeignKey( - row.UNIQUE_TABLE_SCHEMA, - row.UNIQUE_TABLE_NAME, - row.UNIQUE_COLUMN_NAME, - ) + attach_foreign_keys( + database, + ( + ForeignKeyColumn( + schema=row.TABLE_SCHEMA, + table=row.TABLE_NAME, + key=row.CONSTRAINT_NAME, + column=row.COLUMN_NAME, + ref_schema=row.UNIQUE_TABLE_SCHEMA, + ref_table=row.UNIQUE_TABLE_NAME, + ref_column=row.UNIQUE_COLUMN_NAME, + name=row.CONSTRAINT_NAME, + ) + for row in r + ), + ) def _read_indexes(self, database: Database) -> None: r: engine.Result[Any] = self._ensure_session().execute(text(""" diff --git a/db2sql/infrastructure/persistence/mysql/reader.py b/db2sql/infrastructure/persistence/mysql/reader.py index c621f51..c90fa4f 100644 --- a/db2sql/infrastructure/persistence/mysql/reader.py +++ b/db2sql/infrastructure/persistence/mysql/reader.py @@ -8,10 +8,14 @@ from sqlalchemy.orm.session import Session, sessionmaker from db2sql.application.ports import Logger -from db2sql.domain.model import Column, Database, ForeignKey, Schema, Table +from db2sql.domain.model import Column, Database, Schema, Table from db2sql.infrastructure.config import AppConfig from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError +from db2sql.infrastructure.persistence.foreign_keys import ( + attach_foreign_keys, + ForeignKeyColumn, +) from db2sql.infrastructure.url import build_url, database_from_url, redact_url @@ -126,24 +130,30 @@ def _read_constraints(self, database: Database) -> None: def _read_foreign_keys(self, database: Database) -> None: rows = self._ensure_session().execute( text( - "SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME " + "SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, " + " REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME " "FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE " - "WHERE TABLE_SCHEMA = :schema AND REFERENCED_TABLE_NAME IS NOT NULL" + "WHERE TABLE_SCHEMA = :schema AND REFERENCED_TABLE_NAME IS NOT NULL " + "ORDER BY TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION" ), {"schema": self._database_name}, ) - for row in rows: - table = database.get_table(self._database_name, row.TABLE_NAME) - if table is None: - continue - column = table.get_column(row.COLUMN_NAME) - if column is None: - continue - column.foreign_key = ForeignKey( - self._database_name, - row.REFERENCED_TABLE_NAME, - row.REFERENCED_COLUMN_NAME, - ) + attach_foreign_keys( + database, + ( + ForeignKeyColumn( + schema=self._database_name, + table=row.TABLE_NAME, + key=row.CONSTRAINT_NAME, + column=row.COLUMN_NAME, + ref_schema=self._database_name, + ref_table=row.REFERENCED_TABLE_NAME, + ref_column=row.REFERENCED_COLUMN_NAME, + name=row.CONSTRAINT_NAME, + ) + for row in rows + ), + ) def _read_indexes(self, database: Database) -> None: rows = self._ensure_session().execute( diff --git a/db2sql/infrastructure/persistence/oracle/reader.py b/db2sql/infrastructure/persistence/oracle/reader.py index cd29c58..fc54b05 100644 --- a/db2sql/infrastructure/persistence/oracle/reader.py +++ b/db2sql/infrastructure/persistence/oracle/reader.py @@ -8,10 +8,14 @@ from sqlalchemy.orm.session import Session, sessionmaker from db2sql.application.ports import Logger -from db2sql.domain.model import Column, Database, ForeignKey, Schema, Table +from db2sql.domain.model import Column, Database, Schema, Table from db2sql.infrastructure.config import AppConfig from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError +from db2sql.infrastructure.persistence.foreign_keys import ( + attach_foreign_keys, + ForeignKeyColumn, +) from db2sql.infrastructure.url import build_url, redact_url @@ -267,7 +271,8 @@ def _read_foreign_keys(self, database: Database) -> None: owner_clause = self._excluded_schemas_sql("c.OWNER") rows = self._ensure_session().execute( text( - "SELECT cc.OWNER, cc.TABLE_NAME, cc.COLUMN_NAME, cc.POSITION, " + "SELECT cc.OWNER, cc.TABLE_NAME, cc.CONSTRAINT_NAME, cc.COLUMN_NAME, " + " cc.POSITION, " " rc.OWNER AS REF_OWNER, rc.TABLE_NAME AS REF_TABLE, " " rc.COLUMN_NAME AS REF_COLUMN " "FROM ALL_CONSTRAINTS c " @@ -284,14 +289,22 @@ def _read_foreign_keys(self, database: Database) -> None: ), params, ) - for row in rows: - table = database.get_table(row.owner, row.table_name) - if table is None: - continue - column = table.get_column(row.column_name) - if column is None: - continue - column.foreign_key = ForeignKey(row.ref_owner, row.ref_table, row.ref_column) + attach_foreign_keys( + database, + ( + ForeignKeyColumn( + schema=row.owner, + table=row.table_name, + key=row.constraint_name, + column=row.column_name, + ref_schema=row.ref_owner, + ref_table=row.ref_table, + ref_column=row.ref_column, + name=row.constraint_name, + ) + for row in rows + ), + ) def _read_indexes(self, database: Database) -> None: owner = self._schema_filter diff --git a/db2sql/infrastructure/persistence/postgres/reader.py b/db2sql/infrastructure/persistence/postgres/reader.py index 8f393d7..1c48b53 100644 --- a/db2sql/infrastructure/persistence/postgres/reader.py +++ b/db2sql/infrastructure/persistence/postgres/reader.py @@ -8,10 +8,14 @@ from sqlalchemy.orm.session import Session, sessionmaker from db2sql.application.ports import Logger -from db2sql.domain.model import Column, Database, ForeignKey, Schema, Table +from db2sql.domain.model import Column, Database, Schema, Table from db2sql.infrastructure.config import AppConfig from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError +from db2sql.infrastructure.persistence.foreign_keys import ( + attach_foreign_keys, + ForeignKeyColumn, +) from db2sql.infrastructure.url import build_url, redact_url _SYSTEM_SCHEMAS = ("pg_catalog", "information_schema", "pg_toast") @@ -117,6 +121,7 @@ def _read_foreign_keys(self, database: Database) -> None: rows = self._ensure_session().execute( text( "SELECT k1.TABLE_SCHEMA, k1.TABLE_NAME, k1.COLUMN_NAME, " + " k1.CONSTRAINT_NAME, " " k2.TABLE_SCHEMA AS REF_SCHEMA, k2.TABLE_NAME AS REF_TABLE, " " k2.COLUMN_NAME AS REF_COLUMN " "FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc " @@ -127,17 +132,27 @@ def _read_foreign_keys(self, database: Database) -> None: " ON k2.CONSTRAINT_NAME = rc.UNIQUE_CONSTRAINT_NAME " " AND k2.CONSTRAINT_SCHEMA = rc.UNIQUE_CONSTRAINT_SCHEMA " " AND k1.ORDINAL_POSITION = k2.ORDINAL_POSITION " - f"WHERE k1.TABLE_SCHEMA NOT IN {_SYSTEM_SCHEMAS}" + f"WHERE k1.TABLE_SCHEMA NOT IN {_SYSTEM_SCHEMAS} " + "ORDER BY k1.TABLE_SCHEMA, k1.TABLE_NAME, k1.CONSTRAINT_NAME, " + " k1.ORDINAL_POSITION" ) ) - for row in rows: - table = database.get_table(row.table_schema, row.table_name) - if table is None: - continue - column = table.get_column(row.column_name) - if column is None: - continue - column.foreign_key = ForeignKey(row.ref_schema, row.ref_table, row.ref_column) + attach_foreign_keys( + database, + ( + ForeignKeyColumn( + schema=row.table_schema, + table=row.table_name, + key=row.constraint_name, + column=row.column_name, + ref_schema=row.ref_schema, + ref_table=row.ref_table, + ref_column=row.ref_column, + name=row.constraint_name, + ) + for row in rows + ), + ) def _read_indexes(self, database: Database) -> None: rows = self._ensure_session().execute( diff --git a/db2sql/infrastructure/persistence/sqlite/reader.py b/db2sql/infrastructure/persistence/sqlite/reader.py index 01d7840..b0ac153 100644 --- a/db2sql/infrastructure/persistence/sqlite/reader.py +++ b/db2sql/infrastructure/persistence/sqlite/reader.py @@ -8,10 +8,14 @@ from sqlalchemy.orm.session import Session, sessionmaker from db2sql.application.ports import Logger -from db2sql.domain.model import Column, Database, ForeignKey, Schema, Table +from db2sql.domain.model import Column, Database, Schema, Table from db2sql.infrastructure.config import AppConfig from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError +from db2sql.infrastructure.persistence.foreign_keys import ( + attach_foreign_keys, + ForeignKeyColumn, +) from db2sql.infrastructure.url import build_url, redact_url _DEFAULT_SCHEMA = "public" @@ -118,15 +122,23 @@ def _read_indexes(self, database: Database, table_name: str) -> None: def _read_foreign_keys(self, database: Database, table_name: str) -> None: session = self._ensure_session() rows = session.execute(text(f'PRAGMA foreign_key_list("{table_name}")')).fetchall() - table = database.get_table(self._schema, table_name) - if table is None: - return - for row in rows: - _, _, ref_table, src_col, ref_col, *_ = row - column = table.get_column(src_col) - if column is None: - continue - column.foreign_key = ForeignKey(self._schema, ref_table, ref_col) + # PRAGMA numbers each constraint in `id`; a composite key is several + # rows sharing that id, `seq` giving the column order. + attach_foreign_keys( + database, + ( + ForeignKeyColumn( + schema=self._schema, + table=table_name, + key=str(row[0]), + column=row[3], + ref_schema=self._schema, + ref_table=row[2], + ref_column=row[4], + ) + for row in sorted(rows, key=lambda row: (row[0], row[1])) + ), + ) def iter_rows( # pylint: disable=unused-argument self, schema: str, table: Table, limit: int = -1 diff --git a/tests/cli/test_cli_sqlite.py b/tests/cli/test_cli_sqlite.py index 2912484..f00dd52 100644 --- a/tests/cli/test_cli_sqlite.py +++ b/tests/cli/test_cli_sqlite.py @@ -2,6 +2,7 @@ from __future__ import annotations +import sqlite3 import sys from pathlib import Path @@ -26,6 +27,10 @@ def test_cli_main_end_to_end(sample_db: Path, tmp_path: Path, monkeypatch) -> No contents = output_file.read_text() assert "BEGIN;" in contents and "COMMIT;" in contents assert 'COPY "public"."book"' in contents + assert ( + 'ALTER TABLE "public"."book" ADD FOREIGN KEY ("author_id") ' + 'REFERENCES "public"."author" ("id");' in contents + ) def test_cli_no_transaction_omits_begin_and_commit( @@ -164,3 +169,45 @@ def test_cli_dump_command_accepts_options_before_and_after_the_verb( ) assert rc == 0 assert 'COPY "public"."book"' in output_file.read_text() + + +def test_cli_emits_composite_foreign_key_as_one_statement( + tmp_path: Path, monkeypatch +) -> None: + """A two-column FK must stay one constraint. + + Emitted column by column, each half would reference a non-unique key and + the target refuses the statement ("no unique constraint matching given + keys for referenced table"). + """ + db_path = tmp_path / "composite.db" + conn = sqlite3.connect(db_path) + conn.executescript( + """ + CREATE TABLE convoque ( + id INTEGER NOT NULL, + state TEXT NOT NULL, + name TEXT, + PRIMARY KEY (id, state) + ); + CREATE TABLE vote ( + id INTEGER PRIMARY KEY, + convoque_id INTEGER NOT NULL, + state TEXT NOT NULL, + FOREIGN KEY (convoque_id, state) REFERENCES convoque(id, state) + ); + """ + ) + conn.commit() + conn.close() + + output_file = tmp_path / "dump.sql" + monkeypatch.setattr(sys, "argv", ["db2sql"]) + rc = Cli().run( + ["--driver", "sqlite", "-d", str(db_path), "--preserve-case", "-f", str(output_file)] + ) + assert rc == 0 + contents = output_file.read_text() + assert contents.count("ADD FOREIGN KEY") == 1 + assert 'ADD FOREIGN KEY ("convoque_id", "state")' in contents + assert 'REFERENCES "public"."convoque" ("id", "state")' in contents diff --git a/tests/functional/test_mssql_functional.py b/tests/functional/test_mssql_functional.py index 734dfec..bf9292a 100644 --- a/tests/functional/test_mssql_functional.py +++ b/tests/functional/test_mssql_functional.py @@ -119,12 +119,21 @@ def test_mssql_computed_column_detected(mssql_metadata) -> None: def test_mssql_foreign_key_and_index(mssql_metadata) -> None: book = mssql_metadata.schemas["apptest"].get_table("book") assert book is not None - assert book.columns["author_id"].foreign_key is not None - fk = book.columns["author_id"].foreign_key - assert (fk.schema, fk.table, fk.column) == ("apptest", "author", "id") + (fk,) = book.foreign_keys + assert (fk.schema, fk.table) == ("apptest", "author") + assert (fk.columns, fk.ref_columns) == (("author_id",), ("id",)) assert any("title" in cols for cols in book.indexes.values()) +def test_mssql_composite_foreign_key_stays_one_constraint(mssql_metadata) -> None: + vote = mssql_metadata.schemas["apptest"].get_table("assembly_vote") + assert vote is not None + (fk,) = vote.foreign_keys + assert (fk.schema, fk.table) == ("apptest", "assembly") + assert fk.columns == ("assembly_id", "cetat") + assert fk.ref_columns == ("id", "cetat") + + # --------------------------------------------------------------------------- # # mssql → pg: DEFAULT-value translation through PostgresSqlEmitter # # --------------------------------------------------------------------------- # diff --git a/tests/functional/test_mysql_functional.py b/tests/functional/test_mysql_functional.py index 0cf34b6..8330c8c 100644 --- a/tests/functional/test_mysql_functional.py +++ b/tests/functional/test_mysql_functional.py @@ -102,7 +102,16 @@ def test_mysql_identity_and_pk(mysql_metadata) -> None: def test_mysql_foreign_key_and_index(mysql_metadata) -> None: book = mysql_metadata.schemas["db2sqltest"].get_table("book") assert book is not None - fk = book.columns["author_id"].foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("db2sqltest", "author", "id") + (fk,) = book.foreign_keys + assert (fk.schema, fk.table) == ("db2sqltest", "author") + assert (fk.columns, fk.ref_columns) == (("author_id",), ("id",)) assert any("title" in cols for cols in book.indexes.values()) + + +def test_mysql_composite_foreign_key_stays_one_constraint(mysql_metadata) -> None: + vote = mysql_metadata.schemas["db2sqltest"].get_table("assembly_vote") + assert vote is not None + (fk,) = vote.foreign_keys + assert (fk.schema, fk.table) == ("db2sqltest", "assembly") + assert fk.columns == ("assembly_id", "cetat") + assert fk.ref_columns == ("id", "cetat") diff --git a/tests/functional/test_oracle_functional.py b/tests/functional/test_oracle_functional.py index 054b1cc..c7dae25 100644 --- a/tests/functional/test_oracle_functional.py +++ b/tests/functional/test_oracle_functional.py @@ -55,6 +55,7 @@ def test_oracle_schema_and_tables(oracle_metadata) -> None: assert "APPTEST" in oracle_metadata.schemas schema = oracle_metadata.schemas["APPTEST"] assert {"TYPE_MATRIX", "TYPE_LONG", "AUTHOR", "BOOK"}.issubset(schema.tables.keys()) + assert {"ASSEMBLY", "ASSEMBLY_VOTE"}.issubset(schema.tables.keys()) def test_oracle_type_matrix_columns_present(oracle_metadata) -> None: @@ -97,7 +98,16 @@ def test_oracle_identity_and_pk(oracle_metadata) -> None: def test_oracle_foreign_key_and_index(oracle_metadata) -> None: book = oracle_metadata.schemas["APPTEST"].get_table("BOOK") assert book is not None - fk = book.columns["AUTHOR_ID"].foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("APPTEST", "AUTHOR", "ID") + (fk,) = book.foreign_keys + assert (fk.schema, fk.table) == ("APPTEST", "AUTHOR") + assert (fk.columns, fk.ref_columns) == (("AUTHOR_ID",), ("ID",)) assert any("TITLE" in cols for cols in book.indexes.values()) + + +def test_oracle_composite_foreign_key_stays_one_constraint(oracle_metadata) -> None: + vote = oracle_metadata.schemas["APPTEST"].get_table("ASSEMBLY_VOTE") + assert vote is not None + (fk,) = vote.foreign_keys + assert (fk.schema, fk.table) == ("APPTEST", "ASSEMBLY") + assert fk.columns == ("ASSEMBLY_ID", "CETAT") + assert fk.ref_columns == ("ID", "CETAT") diff --git a/tests/functional/test_postgres_functional.py b/tests/functional/test_postgres_functional.py index 943265a..ab57e4e 100644 --- a/tests/functional/test_postgres_functional.py +++ b/tests/functional/test_postgres_functional.py @@ -104,12 +104,21 @@ def test_postgres_identity_and_pk(postgres_metadata) -> None: def test_postgres_foreign_key_and_index(postgres_metadata) -> None: book = postgres_metadata.schemas["apptest"].get_table("book") assert book is not None - fk = book.columns["author_id"].foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("apptest", "author", "id") + (fk,) = book.foreign_keys + assert (fk.schema, fk.table) == ("apptest", "author") + assert (fk.columns, fk.ref_columns) == (("author_id",), ("id",)) assert any("title" in cols for cols in book.indexes.values()) +def test_postgres_composite_foreign_key_stays_one_constraint(postgres_metadata) -> None: + vote = postgres_metadata.schemas["apptest"].get_table("assembly_vote") + assert vote is not None + (fk,) = vote.foreign_keys + assert (fk.schema, fk.table) == ("apptest", "assembly") + assert fk.columns == ("assembly_id", "cetat") + assert fk.ref_columns == ("id", "cetat") + + # --------------------------------------------------------------------------- # pg → mssql: same source metadata, but rendered through the MSSQL emitter. # --------------------------------------------------------------------------- diff --git a/tests/unit/domain/model/test_column.py b/tests/unit/domain/model/test_column.py index 12f313e..b31fa2b 100644 --- a/tests/unit/domain/model/test_column.py +++ b/tests/unit/domain/model/test_column.py @@ -2,7 +2,7 @@ from __future__ import annotations -from db2sql.domain.model import Column, ForeignKey +from db2sql.domain.model import Column def test_column_defaults() -> None: @@ -15,7 +15,6 @@ def test_column_defaults() -> None: assert col.computed_definition is None assert col.identity is False assert col.constraint is None - assert col.foreign_key is None assert col.is_primary_key is False @@ -27,10 +26,3 @@ def test_column_is_primary_key_when_constraint_matches() -> None: def test_column_unique_constraint_not_primary_key() -> None: col = Column(name="email", type="text", constraint="UNIQUE") assert col.is_primary_key is False - - -def test_column_foreign_key_assignment() -> None: - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("public", "author", "id") - assert col.foreign_key is not None - assert col.foreign_key.column == "id" diff --git a/tests/unit/domain/model/test_foreign_key.py b/tests/unit/domain/model/test_foreign_key.py index 6adbeb8..0c4a362 100644 --- a/tests/unit/domain/model/test_foreign_key.py +++ b/tests/unit/domain/model/test_foreign_key.py @@ -9,17 +9,37 @@ from db2sql.domain.model import ForeignKey +def _fk(columns: tuple = ("author_id",), ref_columns: tuple = ("id",)) -> ForeignKey: + return ForeignKey(schema="s", table="t", columns=columns, ref_columns=ref_columns) + + def test_foreign_key_is_frozen() -> None: - fk = ForeignKey(schema="s", table="t", column="c") + fk = _fk() with pytest.raises(dataclasses.FrozenInstanceError): fk.schema = "other" # type: ignore[misc] def test_equality_is_structural() -> None: - assert ForeignKey("s", "t", "c") == ForeignKey("s", "t", "c") - assert ForeignKey("s", "t", "c") != ForeignKey("s", "t", "d") + assert _fk() == _fk() + assert _fk() != _fk(ref_columns=("other",)) def test_foreign_key_is_hashable() -> None: - fk = ForeignKey("s", "t", "c") + fk = _fk() assert {fk: 1}[fk] == 1 + + +def test_composite_key_keeps_column_order() -> None: + fk = _fk(columns=("idagconvoque", "cetat"), ref_columns=("idagconvoque", "cetat")) + assert fk.columns == ("idagconvoque", "cetat") + assert fk.ref_columns == ("idagconvoque", "cetat") + + +def test_column_count_mismatch_is_rejected() -> None: + with pytest.raises(ValueError): + _fk(columns=("a", "b"), ref_columns=("id",)) + + +def test_empty_key_is_rejected() -> None: + with pytest.raises(ValueError): + _fk(columns=(), ref_columns=()) diff --git a/tests/unit/domain/policy/test_dependency_order.py b/tests/unit/domain/policy/test_dependency_order.py index e65d7dc..9a44f25 100644 --- a/tests/unit/domain/policy/test_dependency_order.py +++ b/tests/unit/domain/policy/test_dependency_order.py @@ -8,9 +8,14 @@ def _table_with_fk(name: str, ref_schema: str, ref_table: str) -> Table: table = Table(name=name) - table.add_column( - Column(name=f"{ref_table}_id", type="int", - foreign_key=ForeignKey(schema=ref_schema, table=ref_table, column="id")) + table.add_column(Column(name=f"{ref_table}_id", type="int")) + table.add_foreign_key( + ForeignKey( + schema=ref_schema, + table=ref_table, + columns=(f"{ref_table}_id",), + ref_columns=("id",), + ) ) return table diff --git a/tests/unit/infrastructure/emit/test_mssql_emitter.py b/tests/unit/infrastructure/emit/test_mssql_emitter.py index c374bf6..c8b349f 100644 --- a/tests/unit/infrastructure/emit/test_mssql_emitter.py +++ b/tests/unit/infrastructure/emit/test_mssql_emitter.py @@ -225,9 +225,8 @@ def test_emit_foreign_keys_with_valid_reference(self) -> None: emitter = MssqlSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("public", "author", "id") - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_foreign_keys(db, sink) @@ -239,14 +238,29 @@ def test_emit_foreign_keys_skips_dangling_refs(self) -> None: emitter = MssqlSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("missing", "author", "id") - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("missing", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_foreign_keys(db, sink) assert "ALTER TABLE" not in sink.text + def test_emit_foreign_keys_keeps_composite_key_in_one_statement(self) -> None: + emitter = MssqlSqlEmitter(preserve_case=True) + db = self._db() + vote = Table(name="vote") + vote.add_column(Column(name="author_id", type="int")) + vote.add_column(Column(name="state", type="char")) + vote.add_foreign_key( + ForeignKey("public", "author", ("author_id", "state"), ("id", "state")) + ) + db.schemas["public"].add_table(vote) + sink = _Sink() + emitter.emit_foreign_keys(db, sink) + assert sink.text.count("ALTER TABLE") == 1 + assert "ADD FOREIGN KEY ([author_id], [state])" in sink.text + assert "REFERENCES [public].[author] ([id], [state])" in sink.text + def test_emit_indexes(self) -> None: emitter = MssqlSqlEmitter(preserve_case=True) db = self._db() @@ -259,8 +273,8 @@ def test_emit_drops_uses_sql_server_2016_syntax_in_reverse_order(self) -> None: emitter = MssqlSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - book.add_column(Column(name="author_id", type="int", - foreign_key=ForeignKey("public", "author", "id"))) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_drops(db, sink) @@ -273,8 +287,8 @@ def test_emit_truncates_emits_per_table_in_reverse_order(self) -> None: emitter = MssqlSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - book.add_column(Column(name="author_id", type="int", - foreign_key=ForeignKey("public", "author", "id"))) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_truncates(db, sink) diff --git a/tests/unit/infrastructure/emit/test_postgres_emitter.py b/tests/unit/infrastructure/emit/test_postgres_emitter.py index b7db1f7..e3eccbf 100644 --- a/tests/unit/infrastructure/emit/test_postgres_emitter.py +++ b/tests/unit/infrastructure/emit/test_postgres_emitter.py @@ -183,9 +183,8 @@ def test_emit_foreign_keys_skips_dangling_refs(self) -> None: db = self._db() # Add a book table referencing a missing target schema book = Table(name="book") - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("missing", "author", "id") - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("missing", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() @@ -196,15 +195,32 @@ def test_emit_foreign_keys_with_valid_reference(self) -> None: emitter = PostgresSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("public", "author", "id") - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_foreign_keys(db, sink) assert 'ALTER TABLE "public"."book"' in sink.text assert 'REFERENCES "public"."author" ("id")' in sink.text + def test_emit_foreign_keys_keeps_composite_key_in_one_statement(self) -> None: + emitter = PostgresSqlEmitter(preserve_case=True) + db = self._db() + vote = Table(name="vote") + vote.add_column(Column(name="author_id", type="int")) + vote.add_column(Column(name="state", type="char")) + vote.add_foreign_key( + ForeignKey("public", "author", ("author_id", "state"), ("id", "state")) + ) + db.schemas["public"].add_table(vote) + sink = _Sink() + emitter.emit_foreign_keys(db, sink) + # One statement per constraint: split per column, each half would point + # at a non-unique key and the target would reject it. + assert sink.text.count("ALTER TABLE") == 1 + assert 'ADD FOREIGN KEY ("author_id", "state")' in sink.text + assert 'REFERENCES "public"."author" ("id", "state")' in sink.text + def test_emit_indexes(self) -> None: emitter = PostgresSqlEmitter(preserve_case=True) db = self._db() @@ -228,9 +244,8 @@ def test_emit_drops_emits_in_reverse_dependency_order(self) -> None: emitter = PostgresSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - col = Column(name="author_id", type="int", - foreign_key=ForeignKey("public", "author", "id")) - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() @@ -264,8 +279,8 @@ def test_emit_truncates_uses_single_comma_separated_statement(self) -> None: emitter = PostgresSqlEmitter(preserve_case=True) db = self._db() book = Table(name="book") - book.add_column(Column(name="author_id", type="int", - foreign_key=ForeignKey("public", "author", "id"))) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "author", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_truncates(db, sink) @@ -298,9 +313,8 @@ def test_emit_foreign_keys_skips_when_ref_table_missing(self) -> None: db = self._db() # schema exists but the referenced table does not book = Table(name="book") - col = Column(name="author_id", type="int") - col.foreign_key = ForeignKey("public", "no_such_table", "id") - book.add_column(col) + book.add_column(Column(name="author_id", type="int")) + book.add_foreign_key(ForeignKey("public", "no_such_table", ("author_id",), ("id",))) db.schemas["public"].add_table(book) sink = _Sink() emitter.emit_foreign_keys(db, sink) diff --git a/tests/unit/infrastructure/persistence/test_mssql_reader.py b/tests/unit/infrastructure/persistence/test_mssql_reader.py index ee50d0d..2670e57 100644 --- a/tests/unit/infrastructure/persistence/test_mssql_reader.py +++ b/tests/unit/infrastructure/persistence/test_mssql_reader.py @@ -64,6 +64,28 @@ def _populated_session() -> FakeSession: NUMERIC_PRECISION=10, NUMERIC_SCALE=0, ), + FakeRow( + TABLE_SCHEMA="dbo", + TABLE_NAME="Customer", + COLUMN_NAME="State", + COLUMN_DEFAULT=None, + IS_NULLABLE="NO", + DATA_TYPE="char", + CHARACTER_MAXIMUM_LENGTH=1, + NUMERIC_PRECISION=None, + NUMERIC_SCALE=None, + ), + FakeRow( + TABLE_SCHEMA="dbo", + TABLE_NAME="Order", + COLUMN_NAME="State", + COLUMN_DEFAULT=None, + IS_NULLABLE="NO", + DATA_TYPE="char", + CHARACTER_MAXIMUM_LENGTH=1, + NUMERIC_PRECISION=None, + NUMERIC_SCALE=None, + ), # Belongs to a table we never collected — must be ignored FakeRow( TABLE_SCHEMA="dbo", @@ -139,14 +161,35 @@ def _populated_session() -> FakeSession: FakeRow( TABLE_SCHEMA="dbo", TABLE_NAME="Order", + CONSTRAINT_NAME="FK_Order_Customer", COLUMN_NAME="CustomerId", UNIQUE_TABLE_SCHEMA="dbo", UNIQUE_TABLE_NAME="Customer", UNIQUE_COLUMN_NAME="Id", ), + # Composite constraint: two rows, one per column, same name FakeRow( TABLE_SCHEMA="dbo", TABLE_NAME="Order", + CONSTRAINT_NAME="FK_Order_Customer_State", + COLUMN_NAME="CustomerId", + UNIQUE_TABLE_SCHEMA="dbo", + UNIQUE_TABLE_NAME="Customer", + UNIQUE_COLUMN_NAME="Id", + ), + FakeRow( + TABLE_SCHEMA="dbo", + TABLE_NAME="Order", + CONSTRAINT_NAME="FK_Order_Customer_State", + COLUMN_NAME="State", + UNIQUE_TABLE_SCHEMA="dbo", + UNIQUE_TABLE_NAME="Customer", + UNIQUE_COLUMN_NAME="State", + ), + FakeRow( + TABLE_SCHEMA="dbo", + TABLE_NAME="Order", + CONSTRAINT_NAME="FK_Order_Missing", COLUMN_NAME="Missing", UNIQUE_TABLE_SCHEMA="dbo", UNIQUE_TABLE_NAME="Customer", @@ -155,6 +198,7 @@ def _populated_session() -> FakeSession: FakeRow( TABLE_SCHEMA="dbo", TABLE_NAME="Phantom", + CONSTRAINT_NAME="FK_Phantom", COLUMN_NAME="X", UNIQUE_TABLE_SCHEMA="dbo", UNIQUE_TABLE_NAME="Customer", @@ -212,9 +256,13 @@ def test_collect_metadata_collects_everything() -> None: assert customer.columns["Id"].constraint == "PRIMARY KEY" assert customer.columns["Id"].computed_definition == "([Id]+1)" order = db.schemas["dbo"].tables["Order"] - fk = order.columns["CustomerId"].foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("dbo", "Customer", "Id") + simple, composite = order.foreign_keys + assert (simple.schema, simple.table) == ("dbo", "Customer") + assert (simple.columns, simple.ref_columns) == (("CustomerId",), ("Id",)) + assert (composite.columns, composite.ref_columns) == ( + ("CustomerId", "State"), + ("Id", "State"), + ) assert order.indexes == {"idx_order_cust": ["CustomerId"]} diff --git a/tests/unit/infrastructure/persistence/test_mysql_reader.py b/tests/unit/infrastructure/persistence/test_mysql_reader.py index 533a99d..5177470 100644 --- a/tests/unit/infrastructure/persistence/test_mysql_reader.py +++ b/tests/unit/infrastructure/persistence/test_mysql_reader.py @@ -78,6 +78,28 @@ def _full_plan() -> FakeSession: NUMERIC_SCALE=0, EXTRA="", ), + FakeRow( + TABLE_NAME="author", + COLUMN_NAME="state", + COLUMN_DEFAULT=None, + IS_NULLABLE="NO", + DATA_TYPE="char", + CHARACTER_MAXIMUM_LENGTH=1, + NUMERIC_PRECISION=None, + NUMERIC_SCALE=None, + EXTRA="", + ), + FakeRow( + TABLE_NAME="book", + COLUMN_NAME="state", + COLUMN_DEFAULT=None, + IS_NULLABLE="NO", + DATA_TYPE="char", + CHARACTER_MAXIMUM_LENGTH=1, + NUMERIC_PRECISION=None, + NUMERIC_SCALE=None, + EXTRA="", + ), # Belongs to a missing table — should be ignored gracefully FakeRow( TABLE_NAME="ghost", @@ -104,18 +126,36 @@ def _full_plan() -> FakeSession: "REFERENCED_TABLE_NAME IS NOT NULL", [ FakeRow( + CONSTRAINT_NAME="fk_book_author", TABLE_NAME="book", COLUMN_NAME="author_id", REFERENCED_TABLE_NAME="author", REFERENCED_COLUMN_NAME="id", ), + # Composite constraint: two rows, one per column, same name FakeRow( + CONSTRAINT_NAME="fk_book_author_state", + TABLE_NAME="book", + COLUMN_NAME="author_id", + REFERENCED_TABLE_NAME="author", + REFERENCED_COLUMN_NAME="id", + ), + FakeRow( + CONSTRAINT_NAME="fk_book_author_state", + TABLE_NAME="book", + COLUMN_NAME="state", + REFERENCED_TABLE_NAME="author", + REFERENCED_COLUMN_NAME="state", + ), + FakeRow( + CONSTRAINT_NAME="fk_book_missing", TABLE_NAME="book", COLUMN_NAME="missing_col", REFERENCED_TABLE_NAME="author", REFERENCED_COLUMN_NAME="id", ), FakeRow( + CONSTRAINT_NAME="fk_ghost", TABLE_NAME="ghost", COLUMN_NAME="x", REFERENCED_TABLE_NAME="author", @@ -173,11 +213,13 @@ def test_collect_metadata_populates_schema_tables_columns_indexes_fks() -> None: assert author.columns["name"].nullable is False book = schema.tables["book"] - fk = book.columns["author_id"].foreign_key - assert fk is not None - assert fk.schema == "main" - assert fk.table == "author" - assert fk.column == "id" + simple, composite = book.foreign_keys + assert (simple.schema, simple.table) == ("main", "author") + assert (simple.columns, simple.ref_columns) == (("author_id",), ("id",)) + assert (composite.columns, composite.ref_columns) == ( + ("author_id", "state"), + ("id", "state"), + ) assert book.indexes == {"idx_book_author": ["author_id"]} diff --git a/tests/unit/infrastructure/persistence/test_oracle_reader.py b/tests/unit/infrastructure/persistence/test_oracle_reader.py index 12ef905..e706be2 100644 --- a/tests/unit/infrastructure/persistence/test_oracle_reader.py +++ b/tests/unit/infrastructure/persistence/test_oracle_reader.py @@ -92,6 +92,7 @@ def _full_plan() -> FakeSession: FakeRow( owner="HR", table_name="EMP", + constraint_name="FK_EMP_DEPT", column_name="DEPT_ID", position=1, ref_owner="HR", @@ -174,15 +175,72 @@ def test_collect_metadata_builds_full_database() -> None: # Oracle DATE includes time component → normalized to timestamp. assert hired_col.type == "timestamp" - dept_col = table.get_column("DEPT_ID") - assert dept_col is not None - fk = dept_col.foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("HR", "DEPT", "ID") + assert table.get_column("DEPT_ID") is not None + (fk,) = table.foreign_keys + assert (fk.schema, fk.table) == ("HR", "DEPT") + assert (fk.columns, fk.ref_columns) == (("DEPT_ID",), ("ID",)) + assert fk.name == "FK_EMP_DEPT" assert table.indexes.get("IDX_EMP_NAME") == ["NAME"] +def test_composite_foreign_key_is_collected_as_one_constraint() -> None: + """Two rows sharing a constraint name make one two-column FK, in POSITION order.""" + reader = _build_reader() + session = FakeSession() + session.add("DISTINCT OWNER FROM ALL_TABLES", [FakeRow(owner="HR")]) + session.add("FROM ALL_TABLES t", [FakeRow(owner="HR", table_name="VOTE")]) + session.add( + "FROM ALL_TAB_COLUMNS", + [ + FakeRow( + owner="HR", + table_name="VOTE", + column_name=name, + data_default=None, + nullable="N", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, + ) + for name in ("CONVOQUE_ID", "STATE") + ], + ) + session.add( + "c.CONSTRAINT_TYPE = 'R'", + [ + FakeRow( + owner="HR", + table_name="VOTE", + constraint_name="FK_VOTE_CONVOQUE", + column_name="CONVOQUE_ID", + position=1, + ref_owner="HR", + ref_table="CONVOQUE", + ref_column="ID", + ), + FakeRow( + owner="HR", + table_name="VOTE", + constraint_name="FK_VOTE_CONVOQUE", + column_name="STATE", + position=2, + ref_owner="HR", + ref_table="CONVOQUE", + ref_column="STATE", + ), + ], + ) + install_fake_session(reader, session) + + vote = reader.collect_metadata().schemas["HR"].tables["VOTE"] + (fk,) = vote.foreign_keys + assert fk.columns == ("CONVOQUE_ID", "STATE") + assert fk.ref_columns == ("ID", "STATE") + + def test_owner_option_constrains_schema_filter() -> None: reader = _build_reader(options={"owner": "hr"}) session = FakeSession() @@ -372,6 +430,7 @@ def test_collect_metadata_skips_rows_for_missing_columns_and_tables() -> None: _FakeRow( owner="HR", table_name="EMP", + constraint_name="FK_EMP_MISSING", column_name="MISSING", position=1, ref_owner="HR", @@ -381,6 +440,7 @@ def test_collect_metadata_skips_rows_for_missing_columns_and_tables() -> None: _FakeRow( owner="HR", table_name="GHOST", + constraint_name="FK_GHOST", column_name="X", position=1, ref_owner="HR", @@ -402,6 +462,8 @@ def test_collect_metadata_skips_rows_for_missing_columns_and_tables() -> None: assert emp.columns["MEMO"].char_length == -1 # The MISSING column was never added assert "MISSING" not in emp.columns + # Both FKs name a column or table that does not exist — neither is kept + assert emp.foreign_keys == [] def test_collect_metadata_wraps_unexpected_exception_post_schemas() -> None: diff --git a/tests/unit/infrastructure/persistence/test_postgres_reader.py b/tests/unit/infrastructure/persistence/test_postgres_reader.py index e10f53c..ae0027f 100644 --- a/tests/unit/infrastructure/persistence/test_postgres_reader.py +++ b/tests/unit/infrastructure/persistence/test_postgres_reader.py @@ -63,6 +63,30 @@ def _populated_session() -> FakeSession: numeric_scale=0, is_identity="NO", ), + FakeRow( + table_schema="public", + table_name="author", + column_name="state", + column_default=None, + is_nullable="NO", + data_type="character", + character_maximum_length=1, + numeric_precision=None, + numeric_scale=None, + is_identity="NO", + ), + FakeRow( + table_schema="public", + table_name="book", + column_name="state", + column_default=None, + is_nullable="NO", + data_type="character", + character_maximum_length=1, + numeric_precision=None, + numeric_scale=None, + is_identity="NO", + ), # Column for a table the reader never collected FakeRow( table_schema="ghost", @@ -102,15 +126,36 @@ def _populated_session() -> FakeSession: FakeRow( table_schema="public", table_name="book", + constraint_name="book_author_id_fkey", + column_name="author_id", + ref_schema="public", + ref_table="author", + ref_column="id", + ), + # Composite constraint: two rows, one per column, same name + FakeRow( + table_schema="public", + table_name="book", + constraint_name="book_author_state_fkey", column_name="author_id", ref_schema="public", ref_table="author", ref_column="id", ), + FakeRow( + table_schema="public", + table_name="book", + constraint_name="book_author_state_fkey", + column_name="state", + ref_schema="public", + ref_table="author", + ref_column="state", + ), # column missing → ignored FakeRow( table_schema="public", table_name="book", + constraint_name="book_zzz_fkey", column_name="zzz", ref_schema="public", ref_table="author", @@ -120,6 +165,7 @@ def _populated_session() -> FakeSession: FakeRow( table_schema="public", table_name="phantom", + constraint_name="phantom_x_fkey", column_name="x", ref_schema="public", ref_table="author", @@ -174,9 +220,13 @@ def test_collect_metadata_populates_all_layers() -> None: assert public.tables["author"].columns["id"].identity is True assert public.tables["author"].columns["id"].constraint == "PRIMARY KEY" - fk = public.tables["book"].columns["author_id"].foreign_key - assert fk is not None - assert (fk.schema, fk.table, fk.column) == ("public", "author", "id") + simple, composite = public.tables["book"].foreign_keys + assert (simple.schema, simple.table) == ("public", "author") + assert (simple.columns, simple.ref_columns) == (("author_id",), ("id",)) + assert (composite.columns, composite.ref_columns) == ( + ("author_id", "state"), + ("id", "state"), + ) assert public.tables["book"].indexes == {"idx_book_author": ["author_id"]} diff --git a/tests/unit/infrastructure/persistence/test_sqlite_reader.py b/tests/unit/infrastructure/persistence/test_sqlite_reader.py index a8e366b..fd4c790 100644 --- a/tests/unit/infrastructure/persistence/test_sqlite_reader.py +++ b/tests/unit/infrastructure/persistence/test_sqlite_reader.py @@ -168,9 +168,57 @@ def _execute(query, _params=None): db = reader.collect_metadata() # The dangling FK source column is silently ignored table = db.schemas["public"].tables["t"] - assert table.columns["id"].foreign_key is None + assert table.foreign_keys == [] + +def test_collect_metadata_groups_simple_and_composite_foreign_keys() -> None: + """PRAGMA lists one row per column; `id` says which constraint they belong to.""" + reader = SQLiteSourceReader(_config(dbname=":memory:"), _Logger()) + session = FakeSession() + + def _execute(query, _params=None): + query = str(query) + if "FROM sqlite_master" in query: + return FakeResult([("vote",)]) + if "table_info" in query: + return FakeResult( + [ + (0, "id", "INTEGER", 1, None, 1), + (1, "convoque_id", "INTEGER", 1, None, 0), + (2, "state", "TEXT", 1, None, 0), + ] + ) + if "index_list" in query: + return FakeResult([]) + if "foreign_key_list" in query: + # id, seq, ref_table, src_col, ref_col, ... + # Constraint 1 comes back with its columns out of order on purpose. + return FakeResult( + [ + (0, 0, "author", "convoque_id", "id", "NO ACTION", "NO ACTION", "NONE"), + (1, 1, "convoque", "state", "state", "NO ACTION", "NO ACTION", "NONE"), + (1, 0, "convoque", "convoque_id", "id", "NO ACTION", "NO ACTION", "NONE"), + ] + ) + return FakeResult([]) + + session.execute = _execute # type: ignore[assignment] + install_fake_session(reader, session) + + vote = reader.collect_metadata().schemas["public"].tables["vote"] + simple, composite = vote.foreign_keys + assert (simple.table, simple.columns, simple.ref_columns) == ( + "author", + ("convoque_id",), + ("id",), + ) + assert (composite.table, composite.columns, composite.ref_columns) == ( + "convoque", + ("convoque_id", "state"), + ("id", "state"), + ) + def test_iter_rows_quotes_columns_and_table() -> None: reader = SQLiteSourceReader(_config(dbname=":memory:"), _Logger()) session = FakeSession()