Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .docker/mssql/init/01-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
21 changes: 21 additions & 0 deletions .docker/mysql/init/01-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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');
23 changes: 23 additions & 0 deletions .docker/oracle/init/01-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-- ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
20 changes: 20 additions & 0 deletions .docker/postgres/init/01-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
3 changes: 0 additions & 3 deletions db2sql/domain/model/column.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
from dataclasses import dataclass
from typing import Optional

from .foreign_key import ForeignKey


@dataclass
class Column:
Expand All @@ -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:
Expand Down
22 changes: 20 additions & 2 deletions db2sql/domain/model/foreign_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
5 changes: 5 additions & 0 deletions db2sql/domain/model/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from db2sql.domain.errors import DuplicatedColumnError

from .column import Column
from .foreign_key import ForeignKey


@dataclass
Expand All @@ -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:
Expand All @@ -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]
7 changes: 2 additions & 5 deletions db2sql/domain/policy/dependency_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 5 additions & 6 deletions db2sql/infrastructure/emit/mssql/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,22 +314,21 @@ 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
ref_table = ref_schema.get_table(fk.table)
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")
Expand Down
11 changes: 5 additions & 6 deletions db2sql/infrastructure/emit/postgres/emitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,22 +272,21 @@ 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
ref_table = ref_schema.get_table(fk.table)
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")
Expand Down
62 changes: 62 additions & 0 deletions db2sql/infrastructure/persistence/foreign_keys.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
Loading
Loading