From f31ca9d261daedc3727d80659aca517c355b0f22 Mon Sep 17 00:00:00 2001 From: Jacques Raphanel Date: Thu, 21 May 2026 16:09:50 +0000 Subject: [PATCH 1/5] style: correct all syntax issues --- db2sql/application/dto/dump_request.py | 2 +- db2sql/application/ports/source_reader.py | 2 +- db2sql/application/use_cases/dump_database.py | 12 +- .../use_cases/materialize_views.py | 4 +- .../application/use_cases/migrate_database.py | 12 +- db2sql/domain/__init__.py | 2 +- db2sql/domain/model/column.py | 2 +- db2sql/domain/policy/__init__.py | 2 +- db2sql/infrastructure/config/errors.py | 2 +- db2sql/infrastructure/config/loader.py | 6 +- db2sql/infrastructure/config/schema.py | 8 +- db2sql/infrastructure/logging/__init__.py | 6 +- .../infrastructure/logging/console_logger.py | 6 +- .../output/rotating_file_sink.py | 5 +- db2sql/infrastructure/output/stream_sink.py | 4 +- .../persistence/mssql/reader.py | 12 +- .../persistence/mysql/reader.py | 12 +- .../persistence/oracle/reader.py | 34 ++-- .../persistence/postgres/reader.py | 12 +- .../persistence/query_introspection.py | 4 +- .../persistence/sqlite/reader.py | 12 +- db2sql/infrastructure/plugins/__init__.py | 12 +- db2sql/infrastructure/plugins/registry.py | 8 +- db2sql/infrastructure/writer/mssql/writer.py | 4 +- .../infrastructure/writer/postgres/writer.py | 4 +- db2sql/interface/cli/init_command.py | 38 ++-- db2sql/interface/cli/parser.py | 178 +++++++++++++----- db2sql/interface/cli/runner.py | 16 +- db2sql/interface/cli/validate_command.py | 24 +-- tox.ini | 3 +- 30 files changed, 224 insertions(+), 224 deletions(-) diff --git a/db2sql/application/dto/dump_request.py b/db2sql/application/dto/dump_request.py index 2b82ac5..22ebe6d 100644 --- a/db2sql/application/dto/dump_request.py +++ b/db2sql/application/dto/dump_request.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Dict, FrozenSet, Mapping, Optional, Tuple +from typing import Mapping, Optional, Tuple from db2sql.domain.policy import FilterRules diff --git a/db2sql/application/ports/source_reader.py b/db2sql/application/ports/source_reader.py index 3f63029..aada7e1 100644 --- a/db2sql/application/ports/source_reader.py +++ b/db2sql/application/ports/source_reader.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Iterable, Iterator, List, Protocol, Tuple +from typing import Any, Iterator, List, Protocol, Tuple from db2sql.domain.model import Column, Database, Table diff --git a/db2sql/application/use_cases/dump_database.py b/db2sql/application/use_cases/dump_database.py index 2a2b4c6..3f19964 100644 --- a/db2sql/application/use_cases/dump_database.py +++ b/db2sql/application/use_cases/dump_database.py @@ -39,9 +39,7 @@ def execute(self) -> Database: database = self._reader.collect_metadata() database = filter_database(database, self._request.filter_rules) if self._request.views: - self._logger.info( - f"materializing {len(self._request.views)} view export(s)" - ) + self._logger.info(f"materializing {len(self._request.views)} view export(s)") materialize_views(self._reader, database, self._request.views) self._emit(database) return database @@ -68,9 +66,7 @@ def _emit(self, database: Database) -> None: def _emit_data(self, database: Database) -> None: options = self._request.options - view_options = { - (v.target_schema, v.target_table): v for v in self._request.views - } + view_options = {(v.target_schema, v.target_table): v for v in self._request.views} for schema_name, schema in database.schemas.items(): for table_name, table in schema.tables.items(): view = view_options.get((schema_name, table_name)) @@ -78,9 +74,7 @@ def _emit_data(self, database: Database) -> None: limit = self._resolve_limit(view, options, schema_name, table_name) if limit == 0: continue - self._logger.info( - f"dumping rows from {schema_name}.{table_name} ({fmt.value})" - ) + self._logger.info(f"dumping rows from {schema_name}.{table_name} ({fmt.value})") if table.source_query is not None: rows = self._reader.iter_query_rows(table.source_query, limit=limit) else: diff --git a/db2sql/application/use_cases/materialize_views.py b/db2sql/application/use_cases/materialize_views.py index c6db235..85e48d0 100644 --- a/db2sql/application/use_cases/materialize_views.py +++ b/db2sql/application/use_cases/materialize_views.py @@ -68,8 +68,6 @@ def _apply_primary_key(columns: List[Column], primary_key: Iterable[str]) -> Non for name in pk: column = by_name.get(name) if column is None: - raise ValueError( - f"primary_key references unknown column '{name}' in view export" - ) + raise ValueError(f"primary_key references unknown column '{name}' in view export") column.constraint = "PRIMARY KEY" column.nullable = False diff --git a/db2sql/application/use_cases/migrate_database.py b/db2sql/application/use_cases/migrate_database.py index 6b52f50..3f25590 100644 --- a/db2sql/application/use_cases/migrate_database.py +++ b/db2sql/application/use_cases/migrate_database.py @@ -57,9 +57,7 @@ def execute(self) -> Database: database = self._reader.collect_metadata() database = filter_database(database, self._request.filter_rules) if self._request.views: - self._logger.info( - f"materializing {len(self._request.views)} view export(s)" - ) + self._logger.info(f"materializing {len(self._request.views)} view export(s)") materialize_views(self._reader, database, self._request.views) self._emit_and_load(database) return database @@ -84,18 +82,14 @@ def _emit_and_load(self, database: Database) -> None: def _load_data(self, database: Database) -> None: options = self._request.options - view_options = { - (v.target_schema, v.target_table): v for v in self._request.views - } + view_options = {(v.target_schema, v.target_table): v for v in self._request.views} for schema_name, schema in database.schemas.items(): for table_name, table in schema.tables.items(): view = view_options.get((schema_name, table_name)) limit = self._resolve_limit(view, options, schema_name, table_name) if limit == 0: continue - self._logger.info( - f"bulk-loading rows into {schema_name}.{table_name}" - ) + self._logger.info(f"bulk-loading rows into {schema_name}.{table_name}") if table.source_query is not None: rows = self._reader.iter_query_rows(table.source_query, limit=limit) else: diff --git a/db2sql/domain/__init__.py b/db2sql/domain/__init__.py index 4eb3c2b..2d5e61e 100644 --- a/db2sql/domain/__init__.py +++ b/db2sql/domain/__init__.py @@ -8,7 +8,7 @@ DuplicatedTableError, ) from .model import Column, Database, ForeignKey, Schema, Table -from .policy import FilterRules, filter_database, normalize_identifier, to_snake_case +from .policy import filter_database, FilterRules, normalize_identifier, to_snake_case __all__ = [ "Column", diff --git a/db2sql/domain/model/column.py b/db2sql/domain/model/column.py index f511a30..7172e28 100644 --- a/db2sql/domain/model/column.py +++ b/db2sql/domain/model/column.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Optional from .foreign_key import ForeignKey diff --git a/db2sql/domain/policy/__init__.py b/db2sql/domain/policy/__init__.py index 87b0467..f067f88 100644 --- a/db2sql/domain/policy/__init__.py +++ b/db2sql/domain/policy/__init__.py @@ -1,7 +1,7 @@ """Pure domain policies (identifier normalization, filtering rules).""" from .dependency_order import drop_order, topological_order -from .filter import FilterRules, filter_database +from .filter import filter_database, FilterRules from .identifier import normalize_identifier, to_snake_case __all__ = [ diff --git a/db2sql/infrastructure/config/errors.py b/db2sql/infrastructure/config/errors.py index 99d5e52..5d5dba2 100644 --- a/db2sql/infrastructure/config/errors.py +++ b/db2sql/infrastructure/config/errors.py @@ -2,8 +2,8 @@ from __future__ import annotations -from typing import Union from pathlib import Path +from typing import Union class ConfigError(Exception): diff --git a/db2sql/infrastructure/config/loader.py b/db2sql/infrastructure/config/loader.py index 24e3350..86b8579 100644 --- a/db2sql/infrastructure/config/loader.py +++ b/db2sql/infrastructure/config/loader.py @@ -12,7 +12,11 @@ from db2sql import const -from .errors import ConfigInvalidError, ConfigMissingError, ConfigUnsupportedFileExtensionError +from .errors import ( + ConfigInvalidError, + ConfigMissingError, + ConfigUnsupportedFileExtensionError, +) from .schema import AppConfig PathLike = Union[str, Path] diff --git a/db2sql/infrastructure/config/schema.py b/db2sql/infrastructure/config/schema.py index 1e6ea57..40260a8 100644 --- a/db2sql/infrastructure/config/schema.py +++ b/db2sql/infrastructure/config/schema.py @@ -88,9 +88,7 @@ def _validate_on_existing(cls, value: Any) -> Any: if value is None: return "fail" if value not in ("fail", "drop", "truncate"): - raise ValueError( - "dump.on_existing must be one of 'fail', 'drop', 'truncate'" - ) + raise ValueError("dump.on_existing must be one of 'fail', 'drop', 'truncate'") return value @field_validator( @@ -132,9 +130,7 @@ def _validate_on_existing(cls, value: Any) -> Any: if value is None: return "fail" if value not in ("fail", "drop", "truncate"): - raise ValueError( - "migrate.on_existing must be one of 'fail', 'drop', 'truncate'" - ) + raise ValueError("migrate.on_existing must be one of 'fail', 'drop', 'truncate'") return value diff --git a/db2sql/infrastructure/logging/__init__.py b/db2sql/infrastructure/logging/__init__.py index 6e10ad6..6a5882a 100644 --- a/db2sql/infrastructure/logging/__init__.py +++ b/db2sql/infrastructure/logging/__init__.py @@ -1,7 +1,9 @@ """Logging adapter — ConsoleLogger implements the application Logger port.""" -from .colors import Palette, color_enabled, init_colorama, is_terminal +from .colors import color_enabled, init_colorama, is_terminal, Palette from .console_logger import ( + ConsoleLogger, + InvalidLogLevel, LEVEL_DEBUG, LEVEL_ERROR, LEVEL_NOTICE, @@ -10,8 +12,6 @@ LEVEL_TRACE, LEVEL_VERBOSE, LEVEL_WARNING, - ConsoleLogger, - InvalidLogLevel, ) __all__ = [ diff --git a/db2sql/infrastructure/logging/console_logger.py b/db2sql/infrastructure/logging/console_logger.py index 434b620..97859ce 100644 --- a/db2sql/infrastructure/logging/console_logger.py +++ b/db2sql/infrastructure/logging/console_logger.py @@ -6,7 +6,7 @@ import traceback from typing import IO, Optional -from .colors import RESET, Palette, color_enabled +from .colors import color_enabled, Palette, RESET LEVEL_QUIET = 80 LEVEL_ERROR = 70 @@ -66,7 +66,9 @@ def from_verbosity( raise InvalidLogLevel(level_name) from exc stream: IO[str] if log_file: - stream = open(log_file, "wt", encoding="utf-8") # noqa: SIM115 — owned for process lifetime + stream = open( + log_file, "wt", encoding="utf-8" + ) # noqa: SIM115 — owned for process lifetime else: stream = sys.stdout return cls(level=level, stream=stream) diff --git a/db2sql/infrastructure/output/rotating_file_sink.py b/db2sql/infrastructure/output/rotating_file_sink.py index e069fd6..42accde 100644 --- a/db2sql/infrastructure/output/rotating_file_sink.py +++ b/db2sql/infrastructure/output/rotating_file_sink.py @@ -68,10 +68,7 @@ def boundary(self) -> None: if not self._buffer: return buffered_size = sum(len(s.encode("utf-8")) for s in self._buffer) - if ( - self._current_size > 0 - and self._current_size + buffered_size > self._max_bytes - ): + if self._current_size > 0 and self._current_size + buffered_size > self._max_bytes: self._rotate() self._flush_buffer() diff --git a/db2sql/infrastructure/output/stream_sink.py b/db2sql/infrastructure/output/stream_sink.py index 3952715..1860c58 100644 --- a/db2sql/infrastructure/output/stream_sink.py +++ b/db2sql/infrastructure/output/stream_sink.py @@ -18,7 +18,9 @@ def __init__(self, path: Optional[str] = None) -> None: def __enter__(self) -> "StreamSink": if self._path: - self._stream = open(self._path, "w", encoding="utf-8") # noqa: SIM115 — closed in __exit__ + self._stream = open( + self._path, "w", encoding="utf-8" + ) # noqa: SIM115 — closed in __exit__ self._owns_stream = True else: self._stream = sys.stdout diff --git a/db2sql/infrastructure/persistence/mssql/reader.py b/db2sql/infrastructure/persistence/mssql/reader.py index 6d1f685..b43056a 100644 --- a/db2sql/infrastructure/persistence/mssql/reader.py +++ b/db2sql/infrastructure/persistence/mssql/reader.py @@ -67,9 +67,7 @@ def collect_metadata(self) -> Database: raise SourceReaderError("failed to collect database information") from exception return database - def iter_rows( - self, schema: str, table: Table, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: + def iter_rows(self, schema: str, table: Table, limit: int = -1) -> Iterator[Tuple[Any, ...]]: session = self._ensure_session() columns = ", ".join(f"[{name}]" for name in table.columns) top = f"TOP {limit} " if limit and limit > 0 else "" @@ -81,12 +79,8 @@ def iter_rows( def describe_query(self, query: str) -> List[Column]: return query_introspection.describe_query(self._ensure_session(), query) - def iter_query_rows( - self, query: str, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: - yield from query_introspection.iter_query_rows( - self._ensure_session(), query, limit=limit - ) + def iter_query_rows(self, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: + yield from query_introspection.iter_query_rows(self._ensure_session(), query, limit=limit) def _read_schemas(self, database: Database) -> None: try: diff --git a/db2sql/infrastructure/persistence/mysql/reader.py b/db2sql/infrastructure/persistence/mysql/reader.py index bbabd7d..015ffc2 100644 --- a/db2sql/infrastructure/persistence/mysql/reader.py +++ b/db2sql/infrastructure/persistence/mysql/reader.py @@ -163,9 +163,7 @@ def _read_indexes(self, database: Database) -> None: if table: table.add_index(row.INDEX_NAME, row.COLUMN_NAME) - def iter_rows( - self, schema: str, table: Table, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: + def iter_rows(self, schema: str, table: Table, limit: int = -1) -> Iterator[Tuple[Any, ...]]: session = self._ensure_session() columns = ", ".join(f"`{name}`" for name in table.columns) suffix = f" LIMIT {limit}" if limit and limit > 0 else "" @@ -177,9 +175,5 @@ def iter_rows( def describe_query(self, query: str) -> List[Column]: return query_introspection.describe_query(self._ensure_session(), query) - def iter_query_rows( - self, query: str, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: - yield from query_introspection.iter_query_rows( - self._ensure_session(), query, limit=limit - ) + def iter_query_rows(self, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: + yield from query_introspection.iter_query_rows(self._ensure_session(), query, limit=limit) diff --git a/db2sql/infrastructure/persistence/oracle/reader.py b/db2sql/infrastructure/persistence/oracle/reader.py index 5010676..d408523 100644 --- a/db2sql/infrastructure/persistence/oracle/reader.py +++ b/db2sql/infrastructure/persistence/oracle/reader.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple from sqlalchemy import create_engine, engine, text from sqlalchemy.orm.session import Session, sessionmaker @@ -13,6 +13,7 @@ from db2sql.infrastructure.persistence import query_introspection from db2sql.infrastructure.persistence.errors import SourceReaderError + def _normalize_oracle_type(raw: str) -> str: """Normalize Oracle data-type strings so the postgres emitter can map them. @@ -147,12 +148,9 @@ def collect_metadata(self) -> Database: def _read_schemas(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: - query = ( - "SELECT DISTINCT OWNER FROM ALL_TABLES " - "WHERE OWNER = :owner ORDER BY OWNER" - ) + query = "SELECT DISTINCT OWNER FROM ALL_TABLES " "WHERE OWNER = :owner ORDER BY OWNER" params["owner"] = owner else: query = ( @@ -169,7 +167,7 @@ def _read_schemas(self, database: Database) -> None: def _read_tables(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} clauses = ["t.IOT_NAME IS NULL"] if owner: clauses.append("t.OWNER = :owner") @@ -191,7 +189,7 @@ def _read_tables(self, database: Database) -> None: def _read_columns(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: owner_clause = "c.OWNER = :owner" params["owner"] = owner @@ -233,7 +231,7 @@ def _read_columns(self, database: Database) -> None: def _read_constraints(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: owner_clause = "c.OWNER = :owner" params["owner"] = owner @@ -265,7 +263,7 @@ def _read_constraints(self, database: Database) -> None: def _read_foreign_keys(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: owner_clause = "c.OWNER = :owner" params["owner"] = owner @@ -301,7 +299,7 @@ def _read_foreign_keys(self, database: Database) -> None: def _read_indexes(self, database: Database) -> None: owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: owner_clause = "i.TABLE_OWNER = :owner" params["owner"] = owner @@ -329,7 +327,7 @@ def _read_indexes(self, database: Database) -> None: def _read_identity_columns(self, database: Database) -> None: """Detect 12c+ identity columns. Silently skipped on older Oracle versions.""" owner = self._schema_filter - params: dict = {} + params: Dict[str, Any] = {} if owner: owner_clause = "OWNER = :owner" params["owner"] = owner @@ -354,9 +352,7 @@ def _read_identity_columns(self, database: Database) -> None: if column is not None: column.identity = True - def iter_rows( - self, schema: str, table: Table, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: + def iter_rows(self, schema: str, table: Table, limit: int = -1) -> Iterator[Tuple[Any, ...]]: session = self._ensure_session() columns = ", ".join(f'"{name}"' for name in table.columns) query = f'SELECT {columns} FROM "{schema}"."{table.name}"' @@ -369,9 +365,5 @@ def iter_rows( def describe_query(self, query: str) -> List[Column]: return query_introspection.describe_query(self._ensure_session(), query) - def iter_query_rows( - self, query: str, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: - yield from query_introspection.iter_query_rows( - self._ensure_session(), query, limit=limit - ) + def iter_query_rows(self, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: + yield from query_introspection.iter_query_rows(self._ensure_session(), query, limit=limit) diff --git a/db2sql/infrastructure/persistence/postgres/reader.py b/db2sql/infrastructure/persistence/postgres/reader.py index aba6253..9d3a76f 100644 --- a/db2sql/infrastructure/persistence/postgres/reader.py +++ b/db2sql/infrastructure/persistence/postgres/reader.py @@ -167,9 +167,7 @@ def _read_indexes(self, database: Database) -> None: if table: table.add_index(row.index_name, row.column_name) - def iter_rows( - self, schema: str, table: Table, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: + def iter_rows(self, schema: str, table: Table, limit: int = -1) -> Iterator[Tuple[Any, ...]]: session = self._ensure_session() columns = ", ".join(f'"{name}"' for name in table.columns) suffix = f" LIMIT {limit}" if limit and limit > 0 else "" @@ -181,9 +179,5 @@ def iter_rows( def describe_query(self, query: str) -> List[Column]: return query_introspection.describe_query(self._ensure_session(), query) - def iter_query_rows( - self, query: str, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: - yield from query_introspection.iter_query_rows( - self._ensure_session(), query, limit=limit - ) + def iter_query_rows(self, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: + yield from query_introspection.iter_query_rows(self._ensure_session(), query, limit=limit) diff --git a/db2sql/infrastructure/persistence/query_introspection.py b/db2sql/infrastructure/persistence/query_introspection.py index 83e3eaf..673a49a 100644 --- a/db2sql/infrastructure/persistence/query_introspection.py +++ b/db2sql/infrastructure/persistence/query_introspection.py @@ -63,9 +63,7 @@ def describe_query(session: Session, query: str) -> List[Column]: return columns -def iter_query_rows( - session: Session, query: str, limit: int = -1 -) -> Iterator[Tuple[Any, ...]]: +def iter_query_rows(session: Session, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: """Execute ``query`` and yield rows, stopping after ``limit`` if positive.""" result: Result[Any] = session.execute(text(query)) try: diff --git a/db2sql/infrastructure/persistence/sqlite/reader.py b/db2sql/infrastructure/persistence/sqlite/reader.py index 328f800..3a19fe8 100644 --- a/db2sql/infrastructure/persistence/sqlite/reader.py +++ b/db2sql/infrastructure/persistence/sqlite/reader.py @@ -124,9 +124,7 @@ def _read_foreign_keys(self, database: Database, table_name: str) -> None: continue column.foreign_key = ForeignKey(self._schema, ref_table, ref_col) - def iter_rows( - self, schema: str, table: Table, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: + def iter_rows(self, schema: str, table: Table, limit: int = -1) -> Iterator[Tuple[Any, ...]]: session = self._ensure_session() columns = ", ".join(f'"{name}"' for name in table.columns) suffix = f" LIMIT {limit}" if limit and limit > 0 else "" @@ -139,9 +137,5 @@ def iter_rows( def describe_query(self, query: str) -> List[Column]: return query_introspection.describe_query(self._ensure_session(), query) - def iter_query_rows( - self, query: str, limit: int = -1 - ) -> Iterator[Tuple[Any, ...]]: - yield from query_introspection.iter_query_rows( - self._ensure_session(), query, limit=limit - ) + def iter_query_rows(self, query: str, limit: int = -1) -> Iterator[Tuple[Any, ...]]: + yield from query_introspection.iter_query_rows(self._ensure_session(), query, limit=limit) diff --git a/db2sql/infrastructure/plugins/__init__.py b/db2sql/infrastructure/plugins/__init__.py index 91f4abb..fc6fd36 100644 --- a/db2sql/infrastructure/plugins/__init__.py +++ b/db2sql/infrastructure/plugins/__init__.py @@ -1,21 +1,21 @@ """Plugin discovery for readers, emitters and writers.""" from .registry import ( - EMITTERS_GROUP, - READERS_GROUP, - WRITERS_GROUP, - UnknownEmitterError, - UnknownReaderError, - UnknownWriterError, available_emitters, available_readers, available_writers, + EMITTERS_GROUP, get_source_reader, get_sql_emitter, get_target_writer, + READERS_GROUP, register_emitter, register_reader, register_writer, + UnknownEmitterError, + UnknownReaderError, + UnknownWriterError, + WRITERS_GROUP, ) __all__ = [ diff --git a/db2sql/infrastructure/plugins/registry.py b/db2sql/infrastructure/plugins/registry.py index 7332986..256597d 100644 --- a/db2sql/infrastructure/plugins/registry.py +++ b/db2sql/infrastructure/plugins/registry.py @@ -13,7 +13,7 @@ from __future__ import annotations from importlib import metadata -from typing import Any, Callable, Dict, List +from typing import Any, Callable, cast, Dict, List from db2sql.application.ports import Logger, SourceReader, SqlEmitter, TargetWriter from db2sql.infrastructure.config import AppConfig @@ -97,7 +97,7 @@ def get_source_reader(name: str, config: AppConfig, logger: Logger) -> SourceRea return _manual_readers[name](config, logger) eps = _load_entry_points(READERS_GROUP) if name in eps: - return eps[name](config, logger) + return cast(SourceReader, eps[name](config, logger)) raise UnknownReaderError(name, available_readers()) @@ -106,7 +106,7 @@ def get_sql_emitter(name: str, **kwargs: Any) -> SqlEmitter: return _manual_emitters[name](**kwargs) eps = _load_entry_points(EMITTERS_GROUP) if name in eps: - return eps[name](**kwargs) + return cast(SqlEmitter, eps[name](**kwargs)) raise UnknownEmitterError(name, available_emitters()) @@ -115,5 +115,5 @@ def get_target_writer(name: str, config: AppConfig, logger: Logger) -> TargetWri return _manual_writers[name](config, logger) eps = _load_entry_points(WRITERS_GROUP) if name in eps: - return eps[name](config, logger) + return cast(TargetWriter, eps[name](config, logger)) raise UnknownWriterError(name, available_writers()) diff --git a/db2sql/infrastructure/writer/mssql/writer.py b/db2sql/infrastructure/writer/mssql/writer.py index c41ccd5..3def634 100644 --- a/db2sql/infrastructure/writer/mssql/writer.py +++ b/db2sql/infrastructure/writer/mssql/writer.py @@ -106,9 +106,7 @@ def bulk_load( raw_cursor.executemany(insert_sql, batch) total += len(batch) except Exception as exc: - raise TargetWriterExecutionError( - f"INSERT into {qualified} failed: {exc}" - ) from exc + raise TargetWriterExecutionError(f"INSERT into {qualified} failed: {exc}") from exc self._logger.info(f"loaded {total} row(s) into {schema}.{table.name}") # ---- helpers ---------------------------------------------------------- diff --git a/db2sql/infrastructure/writer/postgres/writer.py b/db2sql/infrastructure/writer/postgres/writer.py index 5618ffd..e1364a7 100644 --- a/db2sql/infrastructure/writer/postgres/writer.py +++ b/db2sql/infrastructure/writer/postgres/writer.py @@ -104,9 +104,7 @@ def bulk_load( try: raw.copy_expert(copy_sql, buffer) except Exception as exc: - raise TargetWriterExecutionError( - f"COPY into {qualified} failed: {exc}" - ) from exc + raise TargetWriterExecutionError(f"COPY into {qualified} failed: {exc}") from exc self._logger.info(f"loaded {count} row(s) into {schema}.{table.name}") # ---- helpers ---------------------------------------------------------- diff --git a/db2sql/interface/cli/init_command.py b/db2sql/interface/cli/init_command.py index 9ffa97d..6d70138 100644 --- a/db2sql/interface/cli/init_command.py +++ b/db2sql/interface/cli/init_command.py @@ -4,7 +4,6 @@ import argparse import json -import os import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple @@ -100,7 +99,7 @@ def _ask_csv_list(prompter: Prompter, message: str) -> List[str]: if not prompter.confirm("Add more entries?", default=False): break # de-duplicate while preserving order - seen: set = set() + seen: set[str] = set() deduped: List[str] = [] for item in accumulated: if item not in seen: @@ -138,9 +137,7 @@ def _ask_server(prompter: Prompter, driver: str) -> Dict[str, Any]: elif driver == "oracle": server["hostname"] = prompter.text("Hostname", validate=_validate_required) port_default = str(_DEFAULT_PORTS.get(driver, 1521)) - server["port"] = int( - prompter.text("Port", default=port_default, validate=_validate_int) - ) + server["port"] = int(prompter.text("Port", default=port_default, validate=_validate_int)) server["username"] = prompter.text("Username", default="") mode = prompter.select( "Identify the database via", @@ -158,9 +155,7 @@ def _ask_server(prompter: Prompter, driver: str) -> Dict[str, Any]: # mssql / mysql / postgres / unknown network driver server["hostname"] = prompter.text("Hostname", validate=_validate_required) port_default = str(_DEFAULT_PORTS.get(driver, 0)) if driver in _DEFAULT_PORTS else "" - server["port"] = int( - prompter.text("Port", default=port_default, validate=_validate_int) - ) + server["port"] = int(prompter.text("Port", default=port_default, validate=_validate_int)) server["dbname"] = prompter.text("Database name", validate=_validate_required) server["username"] = prompter.text("Username", default="") password = _ask_password(prompter) @@ -200,9 +195,7 @@ def _ask_mapping_schemas(prompter: Prompter) -> Dict[str, str]: def _ask_table_overrides(prompter: Prompter) -> Dict[str, Dict[str, Any]]: tables: Dict[str, Dict[str, Any]] = {} while prompter.confirm("Add an override for a specific table?", default=False): - name = prompter.text( - "Table name (bare or schema.table)", validate=_validate_required - ) + name = prompter.text("Table name (bare or schema.table)", validate=_validate_required) override: Dict[str, Any] = {} fmt_choice = prompter.select( f"data_format for '{name}'", @@ -211,9 +204,7 @@ def _ask_table_overrides(prompter: Prompter) -> Dict[str, Dict[str, Any]]: ) if fmt_choice != "(inherit)": override["data_format"] = fmt_choice - limit_raw = prompter.text( - f"limit_records for '{name}' (empty = inherit)", default="" - ) + limit_raw = prompter.text(f"limit_records for '{name}' (empty = inherit)", default="") if limit_raw.strip(): try: override["limit_records"] = int(limit_raw) @@ -229,9 +220,7 @@ def _ask_table_overrides(prompter: Prompter) -> Dict[str, Dict[str, Any]]: return tables -def _ask_dump_section( - prompter: Prompter, target: str -) -> Tuple[Dict[str, Any], DataFormat]: +def _ask_dump_section(prompter: Prompter, target: str) -> Tuple[Dict[str, Any], DataFormat]: dump: Dict[str, Any] = {} if prompter.confirm("Preserve identifier case?", default=False): @@ -283,9 +272,7 @@ def build_config(prompter: Prompter) -> Tuple[AppConfig, OutputFormat]: if not emitters: raise InitAborted() - output_format = prompter.select( - "Output format", choices=["yaml", "json"], default="yaml" - ) + output_format = prompter.select("Output format", choices=["yaml", "json"], default="yaml") driver = prompter.select( "Source database driver", @@ -301,9 +288,7 @@ def build_config(prompter: Prompter) -> Tuple[AppConfig, OutputFormat]: server = _ask_server(prompter, driver) dump, _fmt = _ask_dump_section(prompter, target) - output_file = prompter.text( - "Default SQL output file (empty to write to stdout)", default="" - ) + output_file = prompter.text("Default SQL output file (empty to write to stdout)", default="") data: Dict[str, Any] = { "driver": driver, @@ -327,7 +312,8 @@ def serialize_config(config: AppConfig, output_format: OutputFormat) -> str: payload = config.model_dump(mode="json", exclude_defaults=True) if output_format == "json": return json.dumps(payload, indent=2, sort_keys=False) + "\n" - return yaml.safe_dump(payload, sort_keys=False, default_flow_style=False) + rendered: str = yaml.safe_dump(payload, sort_keys=False, default_flow_style=False) + return rendered def _write_output( @@ -342,9 +328,7 @@ def _write_output( path = Path(output_path) if path.exists() and not force: - if not prompter.confirm( - f"File {path} already exists. Overwrite?", default=False - ): + if not prompter.confirm(f"File {path} already exists. Overwrite?", default=False): raise InitAborted() path.parent.mkdir(parents=True, exist_ok=True) path.write_text(rendered, encoding="utf-8") diff --git a/db2sql/interface/cli/parser.py b/db2sql/interface/cli/parser.py index e36b5d0..4f6eabf 100644 --- a/db2sql/interface/cli/parser.py +++ b/db2sql/interface/cli/parser.py @@ -9,7 +9,12 @@ from db2sql import const from db2sql.application.dto import DataFormat -from db2sql.infrastructure.config import AppConfig, ConfigError, load_config, merge_cli_overrides +from db2sql.infrastructure.config import ( + AppConfig, + ConfigError, + load_config, + merge_cli_overrides, +) from db2sql.infrastructure.plugins import ( available_emitters, available_readers, @@ -44,9 +49,7 @@ def __init__(self, message: str) -> None: class MsDumpToPGArgumentParser(argparse.ArgumentParser): """Builds an :class:`AppConfig` from CLI args + config file + env.""" - def parse_args_with_config( - self, args: Optional[Sequence[str]] = None - ) -> argparse.Namespace: + def parse_args_with_config(self, args: Optional[Sequence[str]] = None) -> argparse.Namespace: options = super().parse_args(args) if "help" in options and options.help: @@ -61,9 +64,8 @@ def parse_args_with_config( # The validate subcommand accepts the config file as a positional arg; # let it win over -C / env / default lookup if provided. config_file = options.config_file - if ( - getattr(options, "command", None) == COMMAND_VALIDATE - and getattr(options, "validate_config_file", None) + if getattr(options, "command", None) == COMMAND_VALIDATE and getattr( + options, "validate_config_file", None ): config_file = options.validate_config_file @@ -144,53 +146,78 @@ def _add_dump_options(parser: argparse.ArgumentParser) -> None: action=OnceArgument, ) parser.add_argument( - "-H", "--host", - metavar="HOSTNAME", dest="hostname", type=str, + "-H", + "--host", + metavar="HOSTNAME", + dest="hostname", + type=str, help=f"Database server host name. [env var: {const.ENV_DB2SQL_HOST}]", default=os.getenv(const.ENV_DB2SQL_HOST), action=OnceArgument, ) parser.add_argument( - "-P", "--port", - metavar="PORT", dest="port", type=int, + "-P", + "--port", + metavar="PORT", + dest="port", + type=int, help=f"Database server port. [env var: {const.ENV_DB2SQL_PORT}]", default=os.getenv(const.ENV_DB2SQL_PORT), action=OnceArgument, ) parser.add_argument( - "-d", "--dbname", - metavar="DBNAME", dest="dbname", type=str, + "-d", + "--dbname", + metavar="DBNAME", + dest="dbname", + type=str, help=f"Database name to connect to. [env var: {const.ENV_DB2SQL_DBNAME}]", default=os.getenv(const.ENV_DB2SQL_DBNAME), action=OnceArgument, ) parser.add_argument( - "-u", "--username", - metavar="USERNAME", dest="username", type=str, + "-u", + "--username", + metavar="USERNAME", + dest="username", + type=str, help=f"Database user name. [env var: {const.ENV_DB2SQL_USER}]", default=os.getenv(const.ENV_DB2SQL_USER), action=OnceArgument, ) parser.add_argument( - "-p", "--password", - metavar="PASSWORD", dest="password", type=str, + "-p", + "--password", + metavar="PASSWORD", + dest="password", + type=str, help=f"Database password. [env var: {const.ENV_DB2SQL_PASSWORD}]", default=os.getenv(const.ENV_DB2SQL_PASSWORD), action=OnceArgument, ) parser.add_argument( - "-W", "--ask-password", - dest="ask_password", action="store_true", default=False, + "-W", + "--ask-password", + dest="ask_password", + action="store_true", + default=False, help="Force password prompt.", ) parser.add_argument( - "-f", "--file", - metavar="PATH", dest="output_file_name", type=str, default=None, + "-f", + "--file", + metavar="PATH", + dest="output_file_name", + type=str, + default=None, help="Output file. If not provided, script is printed to standard output.", ) parser.add_argument( "--split-size", - metavar="SIZE", dest="split_size", type=_parse_size, default=None, + metavar="SIZE", + dest="split_size", + type=_parse_size, + default=None, help=( "Split the dump into multiple files when the current file exceeds " "SIZE. Accepts a byte count or a suffixed value (K/M/G). Requires -f." @@ -210,12 +237,16 @@ def _add_dump_options(parser: argparse.ArgumentParser) -> None: ) parser.add_argument( "--preserve-case", - dest="preserve_case", action=BooleanAction, default=None, + dest="preserve_case", + action=BooleanAction, + default=None, help="Preserve identifier case. When disabled, names are converted to snake_case.", ) parser.add_argument( "--transaction", - dest="dump_use_transaction", action=BooleanAction, default=None, + dest="dump_use_transaction", + action=BooleanAction, + default=None, help=( "Wrap the dump in a transaction (BEGIN/COMMIT). Disable with " "--no-transaction when the SQL is consumed by a tool that manages " @@ -223,55 +254,89 @@ def _add_dump_options(parser: argparse.ArgumentParser) -> None: ), ) parser.add_argument( - "-n", "--max-records", - dest="limit_records", type=int, default=None, + "-n", + "--max-records", + dest="limit_records", + type=int, + default=None, help="Limit the number of rows from each table. -1 means no limit.", ) parser.add_argument( "--data-format", dest="data_format", - choices=[fmt.value for fmt in DataFormat], default=None, + choices=[fmt.value for fmt in DataFormat], + default=None, help="Default output format for table data: copy (faster) or insert.", ) parser.add_argument( - "-i", "--include-schemas", - metavar="NAME", dest="include_schemas", type=str, - action="append", nargs="+", default=None, + "-i", + "--include-schemas", + metavar="NAME", + dest="include_schemas", + type=str, + action="append", + nargs="+", + default=None, help="Schema names to include during export (repeatable, comma separated).", ) parser.add_argument( - "-x", "--exclude-schemas", - metavar="NAME", dest="exclude_schemas", type=str, - action="append", nargs="+", default=None, + "-x", + "--exclude-schemas", + metavar="NAME", + dest="exclude_schemas", + type=str, + action="append", + nargs="+", + default=None, help="Schema names to exclude during export (repeatable, comma separated).", ) parser.add_argument( - "-I", "--include-tables", - metavar="NAME", dest="include_tables", type=str, - action="append", nargs="+", default=None, + "-I", + "--include-tables", + metavar="NAME", + dest="include_tables", + type=str, + action="append", + nargs="+", + default=None, help="Table names to include during export (repeatable, comma separated).", ) parser.add_argument( - "-X", "--exclude-tables", - metavar="NAME", dest="exclude_tables", type=str, - action="append", nargs="+", default=None, + "-X", + "--exclude-tables", + metavar="NAME", + dest="exclude_tables", + type=str, + action="append", + nargs="+", + default=None, help="Table names to exclude during export (repeatable, comma separated).", ) parser.add_argument( - "-C", "--config-file", - metavar="PATH", dest="config_file", type=str, + "-C", + "--config-file", + metavar="PATH", + dest="config_file", + type=str, help=f"Configuration file to use. [env var: {const.ENV_DB2SQL_CONFIG}]", ) parser.add_argument( - "-L", "--log-file", - metavar="PATH", dest="log_file", type=str, + "-L", + "--log-file", + metavar="PATH", + dest="log_file", + type=str, help="Send log output to PATH instead of stdout.", action=OnceArgument, ) parser.add_argument( - "-V", "--verbosity", - metavar="LEVEL", dest="verbosity", default="status", - nargs="?", type=str, + "-V", + "--verbosity", + metavar="LEVEL", + dest="verbosity", + default="status", + nargs="?", + type=str, help=( "Level of detail of the output. Valid options from less verbose to " "more verbose: -Vquiet, -Verror, -Vwarning, -Vnotice, -Vstatus, " @@ -279,7 +344,10 @@ def _add_dump_options(parser: argparse.ArgumentParser) -> None: ), ) parser.add_argument( - "--version", dest="version", action="store_true", default=False, + "--version", + dest="version", + action="store_true", + default=False, help="Output version information and exit.", ) @@ -437,7 +505,9 @@ def _add_migrate_subparser(subparsers: Any) -> None: ) migrate_parser.add_argument( "--transaction", - dest="use_transaction", action=BooleanAction, default=None, + dest="use_transaction", + action=BooleanAction, + default=None, help=( "Wrap the emitted SQL in a transaction (BEGIN/COMMIT). Disable " "with --no-transaction to let the target driver auto-commit each " @@ -458,13 +528,19 @@ def _add_init_subparser(subparsers: Any) -> None: formatter_class=SmartFormatter, ) init_parser.add_argument( - "-o", "--output", - dest="init_output", metavar="PATH", type=str, default=None, + "-o", + "--output", + dest="init_output", + metavar="PATH", + type=str, + default=None, help="Write the generated config to PATH (default: stdout).", ) init_parser.add_argument( "--force", - dest="init_force", action="store_true", default=False, + dest="init_force", + action="store_true", + default=False, help="Overwrite the output file without asking for confirmation.", ) diff --git a/db2sql/interface/cli/runner.py b/db2sql/interface/cli/runner.py index 0ff42c0..c698cab 100644 --- a/db2sql/interface/cli/runner.py +++ b/db2sql/interface/cli/runner.py @@ -16,16 +16,16 @@ to_dump_request, to_migrate_request, ) -from db2sql.infrastructure.logging import ConsoleLogger, Palette, init_colorama +from db2sql.infrastructure.logging import ConsoleLogger, init_colorama, Palette from db2sql.infrastructure.output import ExecutingSink, RotatingFileSink, StreamSink from db2sql.infrastructure.persistence.errors import SourceReaderError from db2sql.infrastructure.plugins import ( + get_source_reader, + get_sql_emitter, + get_target_writer, UnknownEmitterError, UnknownReaderError, UnknownWriterError, - get_sql_emitter, - get_source_reader, - get_target_writer, ) from db2sql.infrastructure.writer import TargetWriterError @@ -38,12 +38,12 @@ ) from .init_command import run_init from .parser import ( + AbortExecution, + build_parser, COMMAND_INIT, COMMAND_MIGRATE, COMMAND_VALIDATE, - AbortExecution, CommandLineError, - build_parser, ) from .validate_command import run_validate @@ -107,9 +107,7 @@ def _execute(self, config: AppConfig) -> None: ) request = to_dump_request(config) if request.split_size is not None and not request.output_file: - raise CommandLineError( - "--split-size requires -f/--file (cannot rotate stdout)." - ) + raise CommandLineError("--split-size requires -f/--file (cannot rotate stdout).") with self._open_dump_sink(request) as sink: use_case = DumpDatabaseUseCase( reader=reader, diff --git a/db2sql/interface/cli/validate_command.py b/db2sql/interface/cli/validate_command.py index fd377b9..8d5f638 100644 --- a/db2sql/interface/cli/validate_command.py +++ b/db2sql/interface/cli/validate_command.py @@ -17,21 +17,20 @@ from __future__ import annotations import argparse -from typing import Dict, List, Tuple +from typing import List from db2sql.application.dto import DumpRequest from db2sql.application.ports import SourceReader -from db2sql.domain.model import Database +from db2sql.domain.model import Database, Table from db2sql.domain.policy import filter_database from db2sql.infrastructure.config import AppConfig, to_dump_request from db2sql.infrastructure.logging import ConsoleLogger, Palette from db2sql.infrastructure.persistence.errors import SourceReaderError from db2sql.infrastructure.plugins import ( - UnknownEmitterError, - UnknownReaderError, available_emitters, available_readers, get_source_reader, + UnknownReaderError, ) from .exit_codes import ERROR_GENERAL, ERROR_INVALID_CONFIGURATION, SUCCESS @@ -75,8 +74,7 @@ def _check_plugin_names(config: AppConfig, logger: ConsoleLogger) -> int | None: emitters = available_emitters() if config.driver not in readers: logger.error( - f"unknown driver {config.driver!r}; known: " - f"{', '.join(sorted(readers)) or '(none)'}" + f"unknown driver {config.driver!r}; known: " f"{', '.join(sorted(readers)) or '(none)'}" ) return ERROR_INVALID_CONFIGURATION if config.target not in emitters: @@ -93,9 +91,7 @@ def _check_plugin_names(config: AppConfig, logger: ConsoleLogger) -> int | None: # --------------------------------------------------------------------------- # -def _run_dry_run( - config: AppConfig, logger: ConsoleLogger, *, with_counts: bool -) -> int: +def _run_dry_run(config: AppConfig, logger: ConsoleLogger, *, with_counts: bool) -> int: request = to_dump_request(config) _print_header(logger, "Source connection") _print_connection(logger, config) @@ -139,9 +135,7 @@ def _print_connection(logger: ConsoleLogger, config: AppConfig) -> None: _print_kv(logger, "options", pretty) -def _print_filter_summary( - logger: ConsoleLogger, raw: Database, filtered: Database -) -> None: +def _print_filter_summary(logger: ConsoleLogger, raw: Database, filtered: Database) -> None: _print_header(logger, "Filtering") total_schemas = len(raw.schemas) total_tables = sum(len(s.tables) for s in raw.schemas.values()) @@ -192,13 +186,11 @@ def _print_plan( count_str = "" if with_counts: count_str = f", count={_count_rows(reader, schema_name, schema.tables[table_name], limit, logger)}" - logger.info( - f" - {table_name} (format={fmt}, {limit_str}{count_str})" - ) + logger.info(f" - {table_name} (format={fmt}, {limit_str}{count_str})") def _count_rows( - reader: SourceReader, schema: str, table: object, limit: int, logger: ConsoleLogger + reader: SourceReader, schema: str, table: Table, limit: int, logger: ConsoleLogger ) -> str: """Return a row count for the table. diff --git a/tox.ini b/tox.ini index e3bc4e1..7795fc6 100644 --- a/tox.ini +++ b/tox.ini @@ -84,7 +84,8 @@ max-line-length = 100 # E203: Whitespace before ':' # E501: Line too long # W503: Line break occurred before a binary operator -ignore = E203,E501,W503 +# E704: Multiple statements on one line (def) — idiomatic for Protocol stubs (`def foo(...): ...`) +ignore = E203,E501,W503,E704 [testenv:docs] basepython = python3 From 5067f2482e7b1c2d2171f1d89f38eca83603a101 Mon Sep 17 00:00:00 2001 From: Jacques Raphanel Date: Thu, 21 May 2026 17:14:39 +0000 Subject: [PATCH 2/5] test: improve functional tests --- .docker/Dockerfile | 2 + .docker/docker-compose.yml | 39 +++- .docker/mysql/init/01-schema.sql | 90 ++++++++ .docker/postgres/init/01-schema.sql | 100 ++++++++- CONTRIBUTING.rst | 47 ++++- Makefile | 12 +- .../persistence/oracle/reader.py | 42 ++-- .../persistence/postgres/reader.py | 36 ++-- tests/functional/conftest.py | 36 ++++ tests/functional/test_mysql_functional.py | 108 ++++++++++ tests/functional/test_postgres_functional.py | 196 ++++++++++++++++++ tox.ini | 8 +- 12 files changed, 658 insertions(+), 58 deletions(-) create mode 100644 .docker/mysql/init/01-schema.sql create mode 100644 tests/functional/test_mysql_functional.py create mode 100644 tests/functional/test_postgres_functional.py diff --git a/.docker/Dockerfile b/.docker/Dockerfile index e4c187d..eafab56 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -21,6 +21,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unixodbc-dev \ && rm -rf /var/lib/apt/lists/* +RUN pip install --no-cache-dir tox + RUN groupadd --gid ${GROUP_ID} dev \ && useradd --uid ${USER_ID} --gid ${GROUP_ID} --create-home --shell /bin/bash dev diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml index aea5667..2ca1c99 100644 --- a/.docker/docker-compose.yml +++ b/.docker/docker-compose.yml @@ -1,3 +1,5 @@ +name: python-db2sql + services: dev: build: @@ -12,6 +14,7 @@ services: - ..:/workspace environment: DEVCONTAINER: "true" + PIPUSER: "0" HOME: /home/dev MSSQL_HOST: mssql MSSQL_PORT: "1433" @@ -28,7 +31,16 @@ services: PG_USER: postgres PG_PASSWORD: ${POSTGRES_PASSWORD:-postgres} PG_DATABASE: db2sqltarget + PG_SOURCE_SCHEMA: apptest + MYSQL_HOST: mysql + MYSQL_PORT: "3306" + MYSQL_USER: root + MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD:-mysqlpw} + MYSQL_DATABASE: db2sqltest init: true + tty: true + stdin_open: true + command: ["sleep", "infinity"] mssql: profiles: ["functional"] @@ -38,7 +50,7 @@ services: MSSQL_SA_PASSWORD: ${MSSQL_SA_PASSWORD:-Db2sqlTest!Strong} MSSQL_PID: Developer ports: - - "${MSSQL_PORT:-1433}:1433" + - "${MSSQL_PORT:-11433}:1433" volumes: - mssql-data:/var/opt/mssql healthcheck: @@ -82,7 +94,7 @@ services: APP_USER: ${ORACLE_APP_USER:-apptest} APP_USER_PASSWORD: ${ORACLE_APP_PASSWORD:-apptestpw} ports: - - "${ORACLE_PORT:-1521}:1521" + - "${ORACLE_PORT:-11521}:1521" volumes: - ./oracle/init:/container-entrypoint-initdb.d:ro - oracle-data:/opt/oracle/oradata @@ -93,6 +105,26 @@ services: retries: 60 start_period: 60s + mysql: + profiles: ["functional"] + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-mysqlpw} + MYSQL_DATABASE: db2sqltest + ports: + - "${MYSQL_PORT:-13306}:3306" + volumes: + - ./mysql/init:/docker-entrypoint-initdb.d:ro + - mysql-data:/var/lib/mysql + healthcheck: + test: + - CMD-SHELL + - mysqladmin ping -h 127.0.0.1 -uroot -p"$$MYSQL_ROOT_PASSWORD" --silent + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + postgres: profiles: ["functional"] image: postgres:16-alpine @@ -101,7 +133,7 @@ services: POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_DB: db2sqltarget ports: - - "${POSTGRES_PORT:-5432}:5432" + - "${PG_PORT:-15432}:5432" volumes: - ./postgres/init:/docker-entrypoint-initdb.d:ro - postgres-data:/var/lib/postgresql/data @@ -113,5 +145,6 @@ services: volumes: mssql-data: + mysql-data: oracle-data: postgres-data: diff --git a/.docker/mysql/init/01-schema.sql b/.docker/mysql/init/01-schema.sql new file mode 100644 index 0000000..1472caf --- /dev/null +++ b/.docker/mysql/init/01-schema.sql @@ -0,0 +1,90 @@ +-- Functional-test fixture for MySQL. +-- +-- The MySQLSourceReader uses the connection's database as the "schema" +-- (MySQL has no notion of schema separate from database), so all fixtures +-- live in the ``db2sqltest`` database created from MYSQL_DATABASE. +-- +-- Coverage: every MySQL source type referenced by +-- ``PostgresSqlEmitter.DEFAULT_TYPE_MAP`` that is native to MySQL, plus the +-- standard relational fixture (author/book + foreign key + secondary index). + +CREATE DATABASE IF NOT EXISTS db2sqltest; +USE db2sqltest; + +DROP TABLE IF EXISTS book; +DROP TABLE IF EXISTS author; +DROP TABLE IF EXISTS type_matrix; + +-- Type-coverage table ------------------------------------------------------- +CREATE TABLE type_matrix ( + id INT NOT NULL AUTO_INCREMENT, + c_bit BIT(1) NULL, + c_tinyint TINYINT NULL, + c_smallint SMALLINT NULL, + c_mediumint MEDIUMINT NULL, + c_int INT NULL, + c_bigint BIGINT NULL, + c_decimal DECIMAL(12,4) NULL, + c_numeric NUMERIC(18,6) NULL, + c_float FLOAT NULL, + c_double DOUBLE NULL, + c_char CHAR(8) NULL, + c_varchar VARCHAR(64) NULL, + c_text TEXT NULL, + c_mediumtext MEDIUMTEXT NULL, + c_longtext LONGTEXT NULL, + c_binary BINARY(8) NULL, + c_varbinary VARBINARY(64) NULL, + c_blob BLOB NULL, + c_date DATE NULL, + c_time TIME NULL, + c_datetime DATETIME NULL, + c_timestamp TIMESTAMP NULL DEFAULT NULL, + c_json JSON NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Relational mini-fixture (parallels mssql/oracle/postgres fixtures) +CREATE TABLE author ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(120) NOT NULL, + birth_year INT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE book ( + id INT NOT NULL AUTO_INCREMENT, + author_id INT NOT NULL, + title VARCHAR(200) NOT NULL, + PRIMARY KEY (id), + CONSTRAINT fk_book_author FOREIGN KEY (author_id) REFERENCES author (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE INDEX idx_book_title ON book (title); + +-- 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, + c_decimal, c_numeric, c_float, c_double, + c_char, c_varchar, c_text, c_mediumtext, c_longtext, + c_binary, c_varbinary, c_blob, + c_date, c_time, c_datetime, c_timestamp, c_json) +VALUES + (b'1', 7, 32100, 8388600, 2147483640, 9000000000000000000, + 1234.5678, 1234567.890123, 1.5, 3.141592653589793, + 'fixed', 'with accent é', 'long text payload', 'medium text', 'long text', + UNHEX('DEADBEEFCAFEBABE'), UNHEX('01020304'), UNHEX('05060708'), + '2024-01-31', '14:30:00', '2024-01-31 14:30:00', + '2024-01-31 14:30:00', JSON_OBJECT('k', 'v')), + (NULL, NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL, + NULL, NULL, NULL, + NULL, NULL, NULL, NULL, NULL); + +INSERT INTO author (name, birth_year) VALUES ('Alice', 1980); +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'); diff --git a/.docker/postgres/init/01-schema.sql b/.docker/postgres/init/01-schema.sql index 675b049..853dc2e 100644 --- a/.docker/postgres/init/01-schema.sql +++ b/.docker/postgres/init/01-schema.sql @@ -1,11 +1,93 @@ --- Target PostgreSQL database used by functional tests to validate a generated --- dump. The dump is supposed to be self-sufficient (CREATE SCHEMA + DDL + --- COPY/INSERT), so this init script only ensures the database exists and is --- otherwise empty. +-- Functional-test fixture for PostgreSQL. +-- +-- The "db2sqltarget" database (created from POSTGRES_DB by the entrypoint) is +-- used both as: +-- * a TARGET database for dumps produced by db2sql (apply step) — left +-- untouched outside the apptest schema below; +-- * a SOURCE database whose ``apptest`` schema mirrors the mssql/oracle +-- fixtures (type_matrix + author/book) so the PostgreSQL reader can be +-- exercised by the functional suite. +-- +-- The reader filters out pg_catalog/information_schema/pg_toast, so the +-- ``apptest`` schema is the only one surfaced by collect_metadata(). +-- Note: types covered are the PG-native ones referenced by +-- ``PostgresSqlEmitter.DEFAULT_TYPE_MAP`` (no MSSQL/Oracle-only types). --- The database "db2sqltarget" is created from POSTGRES_DB by the entrypoint, --- so nothing else is strictly required here. We keep the file so the volume --- mount /docker-entrypoint-initdb.d is non-empty and to host any future --- preconditioning (extensions, roles, etc.). +CREATE SCHEMA IF NOT EXISTS apptest; -SELECT 'postgres target ready' AS status; +DROP TABLE IF EXISTS apptest.book; +DROP TABLE IF EXISTS apptest.author; +DROP TABLE IF EXISTS apptest.type_matrix; + +-- Type-coverage table ------------------------------------------------------- +CREATE TABLE apptest.type_matrix ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + c_boolean BOOLEAN, + c_smallint SMALLINT, + c_integer INTEGER, + c_bigint BIGINT, + c_real REAL, + c_double DOUBLE PRECISION, + c_numeric NUMERIC(18, 6), + c_decimal DECIMAL(12, 4), + c_char CHAR(8), + c_varchar VARCHAR(64), + c_text TEXT, + c_bytea BYTEA, + c_date DATE, + c_time TIME, + c_timestamp TIMESTAMP, + c_timestamptz TIMESTAMP WITH TIME ZONE, + c_uuid UUID, + c_json JSON, + c_jsonb JSONB, + c_xml XML +); + +-- Relational mini-fixture (parallels mssql/oracle/sqlite fixtures) +CREATE TABLE apptest.author ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name VARCHAR(120) NOT NULL, + birth_year INTEGER +); + +CREATE TABLE apptest.book ( + id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + author_id INTEGER NOT NULL, + title VARCHAR(200) NOT NULL, + CONSTRAINT fk_book_author FOREIGN KEY (author_id) REFERENCES apptest.author (id) +); + +CREATE INDEX idx_book_title ON apptest.book (title); + +-- Representative payload (one populated row + one all-NULL row) +INSERT INTO apptest.type_matrix + (c_boolean, c_smallint, c_integer, c_bigint, + c_real, c_double, c_numeric, c_decimal, + c_char, c_varchar, c_text, c_bytea, + c_date, c_time, c_timestamp, c_timestamptz, + c_uuid, c_json, c_jsonb, c_xml) +VALUES + (TRUE, 32100, 2147483640, 9000000000000000000, + 1.5, 3.141592653589793, 1234567.890123, 1234.5678, + 'fixed', 'with accent é', 'long text payload', '\xDEADBEEFCAFEBABE', + DATE '2024-01-31', TIME '14:30:00', + TIMESTAMP '2024-01-31 14:30:00', + TIMESTAMP WITH TIME ZONE '2024-01-31 14:30:00+01', + '11111111-2222-3333-4444-555555555555', + '{"k": "v"}', '{"k": "v"}', + XMLPARSE(DOCUMENT 'v')), + (NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL, + NULL, NULL, NULL, NULL); + +INSERT INTO apptest.author (name, birth_year) VALUES ('Alice', 1980); +INSERT INTO apptest.author (name, birth_year) VALUES ('Bob', NULL); + +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'); + +SELECT 'postgres apptest fixture loaded' AS status; diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index dd8f9ff..6880702 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -229,7 +229,7 @@ You need to install ``tox`` using one of the following approach: Running integration tests ------------------------- -Integration tests run against a local symbol store. +Integration tests run against a local symbol store. To run these tests: @@ -239,6 +239,51 @@ To run these tests: tox -e cli +Running functional tests +------------------------ + +Functional tests exercise the source readers (and emitters) against real +database servers. A docker compose stack under ``.docker/`` brings up the +required services (MSSQL, MySQL, PostgreSQL, and optionally Oracle) and +applies the per-DB init scripts in ``.docker//init/``. + +The ``functional`` docker compose profile covers MSSQL, MySQL and Postgres +(lightweight images). Oracle lives in its own ``oracle`` profile because the +image is ~10 GB and takes 3–5 min to boot. + +Make targets: + +.. code-block:: bash + + # full stack (mssql + mysql + postgres + oracle), then run all functional + # tests inside the dev container + make stack-up + make test-functional + + # lightweight subset — no Oracle (fast iteration) + make stack-up-light + make test-functional-light + + # Oracle-only (heavy, on-demand) + make stack-up-oracle + make test-oracle + + # tear down (keep volumes / wipe volumes) + make stack-down + make stack-reset + +The connection parameters consumed by the test fixtures are injected into +the dev container by ``.docker/docker-compose.yml`` (``MSSQL_*``, ``MYSQL_*``, +``PG_*``, ``ORACLE_*``). When a server is unreachable or its env vars are +missing, the corresponding fixture skips the test with a clear message +instead of failing — so the suite stays green when run outside the stack. + +Adding a new functional fixture: drop an init script under +``.docker//init/`` (executed automatically by the official images) and +mirror the existing tests in ``tests/functional/`` (one test module per +source DB, ``pytestmark = pytest.mark.functional``). + + Releases -------- diff --git a/Makefile b/Makefile index bb4955f..c472a8f 100644 --- a/Makefile +++ b/Makefile @@ -44,14 +44,16 @@ docker-clean: ## Remove the docker dev image and associated resources # `oracle` profile because the image is ~10 GB and takes 3-5 min to boot. # `stack-up` keeps the historical "everything" behaviour for local dev. -stack-up: ## Start the full functional DB stack (mssql, postgres, oracle) and wait until healthy - $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional --profile oracle up -d --wait +stack-up: ## Start the full functional DB stack (mssql, mysql, postgres, oracle) and wait until healthy + $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional --profile oracle up -d --wait mssql mysql postgres oracle + $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional run --rm mssql-init -stack-up-light: ## Start only the lightweight functional DB stack (mssql, postgres) — no Oracle - $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional up -d --wait +stack-up-light: ## Start only the lightweight functional DB stack (mssql, mysql, postgres) — no Oracle + $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional up -d --wait mssql mysql postgres + $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile functional run --rm mssql-init stack-up-oracle: ## Start only the Oracle container (heavy, ~3-5 min boot) — no mssql/postgres - $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile oracle up -d --wait + $(DOCKER_ENV) $(DOCKER_COMPOSE) --profile oracle up -d --wait oracle stack-down: ## Stop the functional DB stack (keep volumes) $(DOCKER_COMPOSE) --profile functional --profile oracle down diff --git a/db2sql/infrastructure/persistence/oracle/reader.py b/db2sql/infrastructure/persistence/oracle/reader.py index d408523..0c45736 100644 --- a/db2sql/infrastructure/persistence/oracle/reader.py +++ b/db2sql/infrastructure/persistence/oracle/reader.py @@ -163,7 +163,7 @@ def _read_schemas(self, database: Database) -> None: except Exception as exc: raise SourceReaderError(f"Error connecting to database {exc}") from exc for row in rows: - database.add_schema(Schema(row.OWNER)) + database.add_schema(Schema(row.owner)) def _read_tables(self, database: Database) -> None: owner = self._schema_filter @@ -185,7 +185,7 @@ def _read_tables(self, database: Database) -> None: params, ) for row in rows: - database.add_table(row.OWNER, Table(row.TABLE_NAME)) + database.add_table(row.owner, Table(row.table_name)) def _read_columns(self, database: Database) -> None: owner = self._schema_filter @@ -207,25 +207,25 @@ def _read_columns(self, database: Database) -> None: params, ) for row in rows: - table = database.get_table(row.OWNER, row.TABLE_NAME) + table = database.get_table(row.owner, row.table_name) if table is None: continue - default = row.DATA_DEFAULT + default = row.data_default if isinstance(default, str): default = default.strip().rstrip(";").strip() or None - data_type = (row.DATA_TYPE or "").lower() - char_length = row.CHAR_LENGTH if "char" in data_type else -1 + data_type = (row.data_type or "").lower() + char_length = row.char_length if "char" in data_type else -1 if not char_length: char_length = -1 table.add_column( Column( - name=row.COLUMN_NAME, - type=_normalize_oracle_type(row.DATA_TYPE), + name=row.column_name, + type=_normalize_oracle_type(row.data_type), default=default, - nullable=row.NULLABLE == "Y", + nullable=row.nullable == "Y", char_length=char_length, - precision=row.DATA_PRECISION, - scale=row.DATA_SCALE, + precision=row.data_precision, + scale=row.data_scale, ) ) @@ -254,12 +254,12 @@ def _read_constraints(self, database: Database) -> None: params, ) for row in rows: - table = database.get_table(row.OWNER, row.TABLE_NAME) + table = database.get_table(row.owner, row.table_name) if table is None: continue - column = table.get_column(row.COLUMN_NAME) + column = table.get_column(row.column_name) if column is not None: - column.constraint = row.CONSTRAINT_TYPE + column.constraint = row.constraint_type def _read_foreign_keys(self, database: Database) -> None: owner = self._schema_filter @@ -289,13 +289,13 @@ def _read_foreign_keys(self, database: Database) -> None: params, ) for row in rows: - table = database.get_table(row.OWNER, row.TABLE_NAME) + table = database.get_table(row.owner, row.table_name) if table is None: continue - column = table.get_column(row.COLUMN_NAME) + 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) + column.foreign_key = ForeignKey(row.ref_owner, row.ref_table, row.ref_column) def _read_indexes(self, database: Database) -> None: owner = self._schema_filter @@ -320,9 +320,9 @@ def _read_indexes(self, database: Database) -> None: params, ) for row in rows: - table = database.get_table(row.TABLE_OWNER, row.TABLE_NAME) + table = database.get_table(row.table_owner, row.table_name) if table is not None: - table.add_index(row.INDEX_NAME, row.COLUMN_NAME) + table.add_index(row.index_name, row.column_name) def _read_identity_columns(self, database: Database) -> None: """Detect 12c+ identity columns. Silently skipped on older Oracle versions.""" @@ -345,10 +345,10 @@ def _read_identity_columns(self, database: Database) -> None: except Exception: return for row in rows: - table = database.get_table(row.OWNER, row.TABLE_NAME) + table = database.get_table(row.owner, row.table_name) if table is None: continue - column = table.get_column(row.COLUMN_NAME) + column = table.get_column(row.column_name) if column is not None: column.identity = True diff --git a/db2sql/infrastructure/persistence/postgres/reader.py b/db2sql/infrastructure/persistence/postgres/reader.py index 9d3a76f..3672c23 100644 --- a/db2sql/infrastructure/persistence/postgres/reader.py +++ b/db2sql/infrastructure/persistence/postgres/reader.py @@ -69,9 +69,9 @@ def _read_schemas_and_tables(self, database: Database) -> None: ) ) for row in rows: - if row.TABLE_SCHEMA not in database.schemas: - database.add_schema(Schema(row.TABLE_SCHEMA)) - database.add_table(row.TABLE_SCHEMA, Table(row.TABLE_NAME)) + if row.table_schema not in database.schemas: + database.add_schema(Schema(row.table_schema)) + database.add_table(row.table_schema, Table(row.table_name)) def _read_columns(self, database: Database) -> None: rows = self._ensure_session().execute( @@ -85,19 +85,19 @@ def _read_columns(self, database: Database) -> None: ) ) for row in rows: - table = database.get_table(row.TABLE_SCHEMA, row.TABLE_NAME) + table = database.get_table(row.table_schema, row.table_name) if table is None: continue column = Column( - name=row.COLUMN_NAME, - type=row.DATA_TYPE, - default=row.COLUMN_DEFAULT, - nullable=row.IS_NULLABLE == "YES", - char_length=row.CHARACTER_MAXIMUM_LENGTH or -1, - precision=row.NUMERIC_PRECISION, - scale=row.NUMERIC_SCALE, + name=row.column_name, + type=row.data_type, + default=row.column_default, + nullable=row.is_nullable == "YES", + char_length=row.character_maximum_length or -1, + precision=row.numeric_precision, + scale=row.numeric_scale, ) - if row.IS_IDENTITY == "YES": + if row.is_identity == "YES": column.identity = True table.add_column(column) @@ -114,11 +114,11 @@ def _read_constraints(self, database: Database) -> None: ) ) for row in rows: - table = database.get_table(row.TABLE_SCHEMA, row.TABLE_NAME) + table = database.get_table(row.table_schema, row.table_name) if table: - column = table.get_column(row.COLUMN_NAME) + column = table.get_column(row.column_name) if column: - column.constraint = row.CONSTRAINT_TYPE + column.constraint = row.constraint_type def _read_foreign_keys(self, database: Database) -> None: rows = self._ensure_session().execute( @@ -138,13 +138,13 @@ def _read_foreign_keys(self, database: Database) -> None: ) ) for row in rows: - table = database.get_table(row.TABLE_SCHEMA, row.TABLE_NAME) + table = database.get_table(row.table_schema, row.table_name) if table is None: continue - column = table.get_column(row.COLUMN_NAME) + 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) + column.foreign_key = ForeignKey(row.ref_schema, row.ref_table, row.ref_column) def _read_indexes(self, database: Database) -> None: rows = self._ensure_session().execute( diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index 8baaf78..a39956e 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -83,3 +83,39 @@ def oracle_config() -> AppConfig: options={"service_name": env["ORACLE_SERVICE"], "owner": env["ORACLE_USER"]}, ), ) + + +@pytest.fixture(scope="session") +def postgres_config() -> AppConfig: + env = _require_env( + "PG_HOST", "PG_PORT", "PG_USER", "PG_PASSWORD", "PG_DATABASE" + ) + _check_tcp(env["PG_HOST"], int(env["PG_PORT"])) + return AppConfig( + driver="postgres", + server=ServerConfig( + hostname=env["PG_HOST"], + port=int(env["PG_PORT"]), + username=env["PG_USER"], + password=env["PG_PASSWORD"], + dbname=env["PG_DATABASE"], + ), + ) + + +@pytest.fixture(scope="session") +def mysql_config() -> AppConfig: + env = _require_env( + "MYSQL_HOST", "MYSQL_PORT", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE" + ) + _check_tcp(env["MYSQL_HOST"], int(env["MYSQL_PORT"])) + return AppConfig( + driver="mysql", + server=ServerConfig( + hostname=env["MYSQL_HOST"], + port=int(env["MYSQL_PORT"]), + username=env["MYSQL_USER"], + password=env["MYSQL_PASSWORD"], + dbname=env["MYSQL_DATABASE"], + ), + ) diff --git a/tests/functional/test_mysql_functional.py b/tests/functional/test_mysql_functional.py new file mode 100644 index 0000000..0cf34b6 --- /dev/null +++ b/tests/functional/test_mysql_functional.py @@ -0,0 +1,108 @@ +"""Functional tests for the MySQL source reader and the postgres emitter. + +Run via ``make test-functional`` after ``make stack-up`` (or ``stack-up-light``). + +The fixture comes from ``.docker/mysql/init/01-schema.sql``: a ``db2sqltest`` +database with a ``type_matrix`` table (covering MySQL-native source types +referenced by ``PostgresSqlEmitter.DEFAULT_TYPE_MAP``) plus the standard +author/book relational fixture. + +MySQL has no notion of "schema" separate from "database", so the reader +exposes the database name as a single schema entry. +""" + +from __future__ import annotations + +import pytest + +from db2sql.infrastructure.config import AppConfig +from db2sql.infrastructure.emit.postgres.emitter import PostgresSqlEmitter +from db2sql.infrastructure.persistence.mysql.reader import MySQLSourceReader + +pytestmark = pytest.mark.functional + + +# Expected source-column → mysql-reported-type for every column in +# .docker/mysql/init/01-schema.sql:type_matrix. +# MySQL's INFORMATION_SCHEMA.COLUMNS.DATA_TYPE returns the canonical +# lowercase name without precision (e.g. "varchar" not "varchar(64)"), so +# we match those 1:1 against the keys of PostgresSqlEmitter.DEFAULT_TYPE_MAP. +EXPECTED_TYPES = { + "c_bit": "bit", + "c_tinyint": "tinyint", + "c_smallint": "smallint", + "c_mediumint": "mediumint", + "c_int": "int", + "c_bigint": "bigint", + "c_decimal": "decimal", + "c_numeric": "decimal", # MySQL aliases NUMERIC → DECIMAL + "c_float": "float", + "c_double": "double", + "c_char": "char", + "c_varchar": "varchar", + "c_text": "text", + "c_mediumtext": "mediumtext", + "c_longtext": "longtext", + "c_binary": "binary", + "c_varbinary": "varbinary", + "c_blob": "blob", + "c_date": "date", + "c_time": "time", + "c_datetime": "datetime", + "c_timestamp": "timestamp", + "c_json": "json", +} + + +@pytest.fixture(scope="module") +def mysql_metadata(mysql_config: AppConfig, null_logger): + pytest.importorskip("pymysql") + reader = MySQLSourceReader(mysql_config, null_logger) + return reader.collect_metadata() + + +def test_mysql_schema_and_tables(mysql_metadata) -> None: + # The reader uses the database name as the schema key. + assert "db2sqltest" in mysql_metadata.schemas + schema = mysql_metadata.schemas["db2sqltest"] + assert {"type_matrix", "author", "book"}.issubset(schema.tables.keys()) + + +def test_mysql_type_matrix_columns_present(mysql_metadata) -> None: + table = mysql_metadata.schemas["db2sqltest"].get_table("type_matrix") + assert table is not None + for column_name in EXPECTED_TYPES: + assert column_name in table.columns, f"column {column_name} missing" + + +def test_mysql_default_type_mapping(mysql_metadata) -> None: + table = mysql_metadata.schemas["db2sqltest"].get_table("type_matrix") + assert table is not None + emitter = PostgresSqlEmitter() + for column_name, expected_source in EXPECTED_TYPES.items(): + column = table.columns[column_name] + assert column.type.lower() == expected_source, ( + f"{column_name}: reader returned {column.type!r}, expected {expected_source!r}" + ) + expected_target = PostgresSqlEmitter.DEFAULT_TYPE_MAP[expected_source] + rendered = emitter.column_definition(column) + assert expected_target.split("(")[0] in rendered, ( + f"{column_name}: rendered={rendered!r}, expected target type {expected_target!r}" + ) + + +def test_mysql_identity_and_pk(mysql_metadata) -> None: + table = mysql_metadata.schemas["db2sqltest"].get_table("type_matrix") + assert table is not None + pk_id = table.columns["id"] + assert pk_id.identity is True, "AUTO_INCREMENT should mark the column as identity" + assert pk_id.constraint == "PRIMARY KEY" + + +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") + assert any("title" in cols for cols in book.indexes.values()) diff --git a/tests/functional/test_postgres_functional.py b/tests/functional/test_postgres_functional.py new file mode 100644 index 0000000..943265a --- /dev/null +++ b/tests/functional/test_postgres_functional.py @@ -0,0 +1,196 @@ +"""Functional tests for the PostgreSQL source reader. + +Covers two pipelines: + +1. ``PostgresSourceReader`` → ``PostgresSqlEmitter`` (the canonical export + target — types should round-trip through ``DEFAULT_TYPE_MAP`` unchanged). +2. ``PostgresSourceReader`` → ``MssqlSqlEmitter`` (the pg → mssql migration + path: PG-native types must map to a non-empty MSSQL target type via + ``MssqlSqlEmitter.DEFAULT_TYPE_MAP``). + +Run via ``make test-functional`` after ``make stack-up`` (or ``stack-up-light``). + +The fixture comes from ``.docker/postgres/init/01-schema.sql`` which creates +an ``apptest`` schema with ``type_matrix``/``author``/``book`` tables. +""" + +from __future__ import annotations + +import pytest + +from db2sql.infrastructure.config import AppConfig +from db2sql.infrastructure.emit.mssql.emitter import MssqlSqlEmitter +from db2sql.infrastructure.emit.postgres.emitter import PostgresSqlEmitter +from db2sql.infrastructure.persistence.postgres.reader import PostgresSourceReader + +pytestmark = pytest.mark.functional + + +# Expected source-column → pg-reported-type for every column in +# .docker/postgres/init/01-schema.sql:apptest.type_matrix. PG's +# INFORMATION_SCHEMA.COLUMNS.DATA_TYPE reports the canonical lowercase name +# (e.g. "timestamp with time zone", "double precision"); we match those 1:1 +# against the keys of PostgresSqlEmitter.DEFAULT_TYPE_MAP. +EXPECTED_TYPES = { + "c_boolean": "boolean", + "c_smallint": "smallint", + "c_integer": "integer", + "c_bigint": "bigint", + "c_real": "real", + "c_double": "double precision", + "c_numeric": "numeric", + "c_decimal": "numeric", + "c_char": "character", + "c_varchar": "character varying", + "c_text": "text", + "c_bytea": "bytea", + "c_date": "date", + "c_time": "time without time zone", + "c_timestamp": "timestamp without time zone", + "c_timestamptz": "timestamp with time zone", + "c_uuid": "uuid", + "c_json": "json", + "c_jsonb": "jsonb", + "c_xml": "xml", +} + + +@pytest.fixture(scope="module") +def postgres_metadata(postgres_config: AppConfig, null_logger): + pytest.importorskip("psycopg2") + reader = PostgresSourceReader(postgres_config, null_logger) + return reader.collect_metadata() + + +def test_postgres_schema_and_tables(postgres_metadata) -> None: + assert "apptest" in postgres_metadata.schemas + schema = postgres_metadata.schemas["apptest"] + assert {"type_matrix", "author", "book"}.issubset(schema.tables.keys()) + + +def test_postgres_type_matrix_columns_present(postgres_metadata) -> None: + table = postgres_metadata.schemas["apptest"].get_table("type_matrix") + assert table is not None + for column_name in EXPECTED_TYPES: + assert column_name in table.columns, f"column {column_name} missing" + + +def test_postgres_default_type_mapping(postgres_metadata) -> None: + """Each PG-reported source type maps to a non-empty PG target column.""" + table = postgres_metadata.schemas["apptest"].get_table("type_matrix") + assert table is not None + emitter = PostgresSqlEmitter() + for column_name, expected_source in EXPECTED_TYPES.items(): + column = table.columns[column_name] + assert column.type.lower() == expected_source, ( + f"{column_name}: reader returned {column.type!r}, expected {expected_source!r}" + ) + rendered = emitter.column_definition(column) + assert rendered.strip(), f"{column_name}: emitter produced empty definition" + # The column name must always appear; the rendered type must not be + # empty after the name token. + tokens = rendered.split() + assert len(tokens) >= 2, f"{column_name}: rendered={rendered!r}" + + +def test_postgres_identity_and_pk(postgres_metadata) -> None: + table = postgres_metadata.schemas["apptest"].get_table("type_matrix") + assert table is not None + pk_id = table.columns["id"] + assert pk_id.identity is True + assert pk_id.constraint == "PRIMARY KEY" + + +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") + assert any("title" in cols for cols in book.indexes.values()) + + +# --------------------------------------------------------------------------- +# pg → mssql: same source metadata, but rendered through the MSSQL emitter. +# --------------------------------------------------------------------------- + +# Subset of EXPECTED_TYPES whose PG name is a direct key of +# MssqlSqlEmitter.DEFAULT_TYPE_MAP. Multi-word PG types ("double precision", +# "timestamp with time zone") and "character"/"character varying" are not in +# the MSSQL map verbatim, so we cover those via the no-empty-render check in +# ``test_pg_to_mssql_default_type_mapping``. +PG_TO_MSSQL_EXPECTED = { + "c_boolean": "bit", + "c_smallint": "smallint", + "c_bigint": "bigint", + "c_real": "real", + "c_numeric": "numeric", + "c_decimal": "numeric", + "c_text": "nvarchar(max)", + "c_bytea": "varbinary(max)", + "c_date": "date", + "c_uuid": "uniqueidentifier", + "c_json": "nvarchar(max)", + "c_jsonb": "nvarchar(max)", + "c_xml": "xml", +} + + +def test_pg_to_mssql_default_type_mapping(postgres_metadata) -> None: + """Each PG source column renders to a non-empty MSSQL definition. + + For columns whose PG type appears verbatim in ``MssqlSqlEmitter.DEFAULT_TYPE_MAP`` + we additionally assert the expected target type is present in the output. + """ + table = postgres_metadata.schemas["apptest"].get_table("type_matrix") + assert table is not None + emitter = MssqlSqlEmitter() + for column_name in EXPECTED_TYPES: + column = table.columns[column_name] + rendered = emitter.column_definition(column) + assert rendered.strip(), f"{column_name}: mssql emitter produced empty definition" + tokens = rendered.split() + assert len(tokens) >= 2, f"{column_name}: rendered={rendered!r}" + expected_target = PG_TO_MSSQL_EXPECTED.get(column_name) + if expected_target is not None: + assert expected_target.split("(")[0] in rendered, ( + f"{column_name}: rendered={rendered!r}, " + f"expected target type {expected_target!r}" + ) + + +def test_pg_to_mssql_identity_renders_as_identity(postgres_metadata) -> None: + """PG ``GENERATED ALWAYS AS IDENTITY`` must become ``IDENTITY(1,1)`` for MSSQL.""" + table = postgres_metadata.schemas["apptest"].get_table("type_matrix") + assert table is not None + pk_id = table.columns["id"] + rendered = MssqlSqlEmitter().column_definition(pk_id) + assert "IDENTITY(1,1)" in rendered, rendered + + +def test_pg_to_mssql_table_emits_qualified_names(postgres_metadata) -> None: + """Smoke-test the full table emission: schema-qualified, bracket-quoted.""" + import io + + class _Sink: + def __init__(self) -> None: + self.buf = io.StringIO() + + def write(self, data: str) -> None: + self.buf.write(data) + + def boundary(self) -> None: + pass + + def value(self) -> str: + return self.buf.getvalue() + + sink = _Sink() + emitter = MssqlSqlEmitter() + emitter.emit_tables(postgres_metadata, sink) + out = sink.value() + assert "[apptest].[type_matrix]" in out + assert "[apptest].[author]" in out + assert "[apptest].[book]" in out + # IDENTITY rendered on the PK column + assert "IDENTITY(1,1)" in out diff --git a/tox.ini b/tox.ini index 7795fc6..e6623c9 100644 --- a/tox.ini +++ b/tox.ini @@ -139,11 +139,14 @@ envdir = {toxworkdir}/functional deps = -r{toxinidir}/requirements.txt pytest pymssql + pymysql + cryptography oracledb psycopg2-binary -e {toxinidir} passenv = MSSQL_* + MYSQL_* ORACLE_* PG_* commands = @@ -163,16 +166,19 @@ commands = pytest -m oracle {posargs} {toxinidir}/tests/functional [testenv:functional-light] -# Functional tests excluding the Oracle subset: needs only MSSQL + Postgres. +# Functional tests excluding the Oracle subset: needs MSSQL + MySQL + Postgres. basepython = python3 envdir = {toxworkdir}/functional-light deps = -r{toxinidir}/requirements.txt pytest pymssql + pymysql + cryptography psycopg2-binary -e {toxinidir} passenv = MSSQL_* + MYSQL_* PG_* commands = pytest -m "functional and not oracle" {posargs} {toxinidir}/tests/functional From b940ccd04d93c538979416920ca87a89e7cd0fe6 Mon Sep 17 00:00:00 2001 From: Jacques Raphanel Date: Thu, 21 May 2026 17:24:22 +0000 Subject: [PATCH 3/5] fix: tests failed due to provider logic --- .../persistence/test_oracle_reader.py | 238 +++++++++--------- .../persistence/test_postgres_reader.py | 118 ++++----- 2 files changed, 178 insertions(+), 178 deletions(-) diff --git a/tests/unit/infrastructure/persistence/test_oracle_reader.py b/tests/unit/infrastructure/persistence/test_oracle_reader.py index cfe1ca2..12ef905 100644 --- a/tests/unit/infrastructure/persistence/test_oracle_reader.py +++ b/tests/unit/infrastructure/persistence/test_oracle_reader.py @@ -28,61 +28,61 @@ def _build_reader(**server_kwargs) -> OracleSourceReader: def _full_plan() -> FakeSession: session = FakeSession() - session.add("FROM ALL_TABLES", [FakeRow(OWNER="HR", TABLE_NAME="EMP")]) + session.add("FROM ALL_TABLES", [FakeRow(owner="HR", table_name="EMP")]) session.add( "FROM ALL_TABLES t", - [FakeRow(OWNER="HR", TABLE_NAME="EMP")], + [FakeRow(owner="HR", table_name="EMP")], ) session.add( "FROM ALL_TAB_COLUMNS", [ FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="ID", - DATA_DEFAULT=None, - NULLABLE="N", - DATA_TYPE="NUMBER", - DATA_LENGTH=22, - CHAR_LENGTH=0, - DATA_PRECISION=10, - DATA_SCALE=0, + owner="HR", + table_name="EMP", + column_name="ID", + data_default=None, + nullable="N", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, ), FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="NAME", - DATA_DEFAULT="'unknown' ", - NULLABLE="Y", - DATA_TYPE="VARCHAR2", - DATA_LENGTH=100, - CHAR_LENGTH=100, - DATA_PRECISION=None, - DATA_SCALE=None, + owner="HR", + table_name="EMP", + column_name="NAME", + data_default="'unknown' ", + nullable="Y", + data_type="VARCHAR2", + data_length=100, + char_length=100, + data_precision=None, + data_scale=None, ), FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="HIRED_AT", - DATA_DEFAULT=None, - NULLABLE="Y", - DATA_TYPE="DATE", - DATA_LENGTH=7, - CHAR_LENGTH=0, - DATA_PRECISION=None, - DATA_SCALE=None, + owner="HR", + table_name="EMP", + column_name="HIRED_AT", + data_default=None, + nullable="Y", + data_type="DATE", + data_length=7, + char_length=0, + data_precision=None, + data_scale=None, ), FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="DEPT_ID", - DATA_DEFAULT=None, - NULLABLE="Y", - DATA_TYPE="NUMBER", - DATA_LENGTH=22, - CHAR_LENGTH=0, - DATA_PRECISION=10, - DATA_SCALE=0, + owner="HR", + table_name="EMP", + column_name="DEPT_ID", + data_default=None, + nullable="Y", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, ), ], ) @@ -90,34 +90,34 @@ def _full_plan() -> FakeSession: "c.CONSTRAINT_TYPE = 'R'", [ FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="DEPT_ID", - POSITION=1, - REF_OWNER="HR", - REF_TABLE="DEPT", - REF_COLUMN="ID", + owner="HR", + table_name="EMP", + column_name="DEPT_ID", + position=1, + ref_owner="HR", + ref_table="DEPT", + ref_column="ID", ) ], ) session.add( "c.CONSTRAINT_TYPE IN ('P', 'U')", - [FakeRow(OWNER="HR", TABLE_NAME="EMP", COLUMN_NAME="ID", CONSTRAINT_TYPE="PRIMARY KEY")], + [FakeRow(owner="HR", table_name="EMP", column_name="ID", constraint_type="PRIMARY KEY")], ) session.add( "FROM ALL_INDEXES", [ FakeRow( - TABLE_OWNER="HR", - TABLE_NAME="EMP", - INDEX_NAME="IDX_EMP_NAME", - COLUMN_NAME="NAME", + table_owner="HR", + table_name="EMP", + index_name="IDX_EMP_NAME", + column_name="NAME", ) ], ) session.add( "FROM ALL_TAB_IDENTITY_COLS", - [FakeRow(OWNER="HR", TABLE_NAME="EMP", COLUMN_NAME="ID")], + [FakeRow(owner="HR", table_name="EMP", column_name="ID")], ) return session @@ -186,8 +186,8 @@ def test_collect_metadata_builds_full_database() -> None: def test_owner_option_constrains_schema_filter() -> None: reader = _build_reader(options={"owner": "hr"}) session = FakeSession() - session.add("FROM ALL_TABLES", [FakeRow(OWNER="HR", TABLE_NAME="EMP")]) - session.add("FROM ALL_TABLES t", [FakeRow(OWNER="HR", TABLE_NAME="EMP")]) + session.add("FROM ALL_TABLES", [FakeRow(owner="HR", table_name="EMP")]) + session.add("FROM ALL_TABLES t", [FakeRow(owner="HR", table_name="EMP")]) session.add("FROM ALL_TAB_COLUMNS", []) session.add("c.CONSTRAINT_TYPE = 'R'", []) session.add("c.CONSTRAINT_TYPE IN ('P', 'U')", []) @@ -219,7 +219,7 @@ def execute(self, *_args, **_kwargs): def test_iter_rows_uses_oracle_fetch_first_for_limit() -> None: reader = _build_reader() session = FakeSession() - session.add("FETCH FIRST", [FakeRow(ID=1, NAME="Alice")]) + session.add("FETCH FIRST", [FakeRow(id=1, name="Alice")]) install_fake_session(reader, session) from db2sql.domain.model import Column, Table @@ -239,22 +239,22 @@ def test_identity_columns_query_is_optional() -> None: """Older Oracle versions lack ALL_TAB_IDENTITY_COLS — the reader must tolerate it.""" reader = _build_reader() session = FakeSession() - session.add("FROM ALL_TABLES", [FakeRow(OWNER="HR", TABLE_NAME="EMP")]) - session.add("FROM ALL_TABLES t", [FakeRow(OWNER="HR", TABLE_NAME="EMP")]) + session.add("FROM ALL_TABLES", [FakeRow(owner="HR", table_name="EMP")]) + session.add("FROM ALL_TABLES t", [FakeRow(owner="HR", table_name="EMP")]) session.add( "FROM ALL_TAB_COLUMNS", [ FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="ID", - DATA_DEFAULT=None, - NULLABLE="N", - DATA_TYPE="NUMBER", - DATA_LENGTH=22, - CHAR_LENGTH=0, - DATA_PRECISION=10, - DATA_SCALE=0, + owner="HR", + table_name="EMP", + column_name="ID", + data_default=None, + nullable="N", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, ) ], ) @@ -315,77 +315,77 @@ def test_collect_metadata_skips_rows_for_missing_columns_and_tables() -> None: 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="EMP")]) + session.add("DISTINCT OWNER FROM ALL_TABLES", [_FakeRow(owner="HR")]) + session.add("FROM ALL_TABLES t", [_FakeRow(owner="HR", table_name="EMP")]) session.add( "FROM ALL_TAB_COLUMNS", [ _FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="ID", - DATA_DEFAULT=None, - NULLABLE="N", - DATA_TYPE="NUMBER", - DATA_LENGTH=22, - CHAR_LENGTH=0, - DATA_PRECISION=10, - DATA_SCALE=0, + owner="HR", + table_name="EMP", + column_name="ID", + data_default=None, + nullable="N", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, ), # Char column with 0 length → exercises the "if not char_length" branch _FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="MEMO", - DATA_DEFAULT=None, - NULLABLE="Y", - DATA_TYPE="CHAR", - DATA_LENGTH=10, - CHAR_LENGTH=0, - DATA_PRECISION=None, - DATA_SCALE=None, + owner="HR", + table_name="EMP", + column_name="MEMO", + data_default=None, + nullable="Y", + data_type="CHAR", + data_length=10, + char_length=0, + data_precision=None, + data_scale=None, ), # Column for an unknown table — must be ignored _FakeRow( - OWNER="HR", - TABLE_NAME="GHOST", - COLUMN_NAME="X", - DATA_DEFAULT=None, - NULLABLE="Y", - DATA_TYPE="NUMBER", - DATA_LENGTH=22, - CHAR_LENGTH=0, - DATA_PRECISION=10, - DATA_SCALE=0, + owner="HR", + table_name="GHOST", + column_name="X", + data_default=None, + nullable="Y", + data_type="NUMBER", + data_length=22, + char_length=0, + data_precision=10, + data_scale=0, ), ], ) # Constraint referencing a non-existent column — must be skipped session.add( "c.CONSTRAINT_TYPE IN ('P', 'U')", - [_FakeRow(OWNER="HR", TABLE_NAME="EMP", COLUMN_NAME="MISSING", CONSTRAINT_TYPE="UNIQUE")], + [_FakeRow(owner="HR", table_name="EMP", column_name="MISSING", constraint_type="UNIQUE")], ) # FK referencing a non-existent column AND a non-existent table session.add( "c.CONSTRAINT_TYPE = 'R'", [ _FakeRow( - OWNER="HR", - TABLE_NAME="EMP", - COLUMN_NAME="MISSING", - POSITION=1, - REF_OWNER="HR", - REF_TABLE="DEPT", - REF_COLUMN="ID", + owner="HR", + table_name="EMP", + column_name="MISSING", + position=1, + ref_owner="HR", + ref_table="DEPT", + ref_column="ID", ), _FakeRow( - OWNER="HR", - TABLE_NAME="GHOST", - COLUMN_NAME="X", - POSITION=1, - REF_OWNER="HR", - REF_TABLE="DEPT", - REF_COLUMN="ID", + owner="HR", + table_name="GHOST", + column_name="X", + position=1, + ref_owner="HR", + ref_table="DEPT", + ref_column="ID", ), ], ) @@ -393,7 +393,7 @@ def test_collect_metadata_skips_rows_for_missing_columns_and_tables() -> None: # Identity row pointing to an unknown column session.add( "FROM ALL_TAB_IDENTITY_COLS", - [_FakeRow(OWNER="HR", TABLE_NAME="EMP", COLUMN_NAME="MISSING")], + [_FakeRow(owner="HR", table_name="EMP", column_name="MISSING")], ) install_fake_session(reader, session) db = reader.collect_metadata() @@ -410,7 +410,7 @@ def test_collect_metadata_wraps_unexpected_exception_post_schemas() -> None: reader = _build_reader() session = _FakeSession() - session.add("DISTINCT OWNER FROM ALL_TABLES", [_FakeRow(OWNER="HR")]) + session.add("DISTINCT OWNER FROM ALL_TABLES", [_FakeRow(owner="HR")]) original_execute = session.execute diff --git a/tests/unit/infrastructure/persistence/test_postgres_reader.py b/tests/unit/infrastructure/persistence/test_postgres_reader.py index 93f487f..e10f53c 100644 --- a/tests/unit/infrastructure/persistence/test_postgres_reader.py +++ b/tests/unit/infrastructure/persistence/test_postgres_reader.py @@ -31,50 +31,50 @@ def _populated_session() -> FakeSession: session.add( "FROM INFORMATION_SCHEMA.TABLES", [ - FakeRow(TABLE_SCHEMA="public", TABLE_NAME="author"), - FakeRow(TABLE_SCHEMA="public", TABLE_NAME="book"), - FakeRow(TABLE_SCHEMA="other", TABLE_NAME="thing"), + FakeRow(table_schema="public", table_name="author"), + FakeRow(table_schema="public", table_name="book"), + FakeRow(table_schema="other", table_name="thing"), ], ) session.add( "FROM INFORMATION_SCHEMA.COLUMNS", [ FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="author", - COLUMN_NAME="id", - COLUMN_DEFAULT=None, - IS_NULLABLE="NO", - DATA_TYPE="integer", - CHARACTER_MAXIMUM_LENGTH=None, - NUMERIC_PRECISION=32, - NUMERIC_SCALE=0, - IS_IDENTITY="YES", + table_schema="public", + table_name="author", + column_name="id", + column_default=None, + is_nullable="NO", + data_type="integer", + character_maximum_length=None, + numeric_precision=32, + numeric_scale=0, + is_identity="YES", ), FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="book", - COLUMN_NAME="author_id", - COLUMN_DEFAULT="0", - IS_NULLABLE="NO", - DATA_TYPE="integer", - CHARACTER_MAXIMUM_LENGTH=None, - NUMERIC_PRECISION=32, - NUMERIC_SCALE=0, - IS_IDENTITY="NO", + table_schema="public", + table_name="book", + column_name="author_id", + column_default="0", + is_nullable="NO", + data_type="integer", + character_maximum_length=None, + numeric_precision=32, + numeric_scale=0, + is_identity="NO", ), # Column for a table the reader never collected FakeRow( - TABLE_SCHEMA="ghost", - TABLE_NAME="x", - COLUMN_NAME="dropped", - COLUMN_DEFAULT=None, - IS_NULLABLE="YES", - DATA_TYPE="text", - CHARACTER_MAXIMUM_LENGTH=None, - NUMERIC_PRECISION=None, - NUMERIC_SCALE=None, - IS_IDENTITY="NO", + table_schema="ghost", + table_name="x", + column_name="dropped", + column_default=None, + is_nullable="YES", + data_type="text", + character_maximum_length=None, + numeric_precision=None, + numeric_scale=None, + is_identity="NO", ), ], ) @@ -82,17 +82,17 @@ def _populated_session() -> FakeSession: "INFORMATION_SCHEMA.TABLE_CONSTRAINTS", [ FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="author", - COLUMN_NAME="id", - CONSTRAINT_TYPE="PRIMARY KEY", + table_schema="public", + table_name="author", + column_name="id", + constraint_type="PRIMARY KEY", ), # missing column → silently ignored FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="author", - COLUMN_NAME="missing", - CONSTRAINT_TYPE="UNIQUE", + table_schema="public", + table_name="author", + column_name="missing", + constraint_type="UNIQUE", ), ], ) @@ -100,30 +100,30 @@ def _populated_session() -> FakeSession: "REFERENTIAL_CONSTRAINTS", [ FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="book", - COLUMN_NAME="author_id", - REF_SCHEMA="public", - REF_TABLE="author", - REF_COLUMN="id", + table_schema="public", + table_name="book", + column_name="author_id", + ref_schema="public", + ref_table="author", + ref_column="id", ), # column missing → ignored FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="book", - COLUMN_NAME="zzz", - REF_SCHEMA="public", - REF_TABLE="author", - REF_COLUMN="id", + table_schema="public", + table_name="book", + column_name="zzz", + ref_schema="public", + ref_table="author", + ref_column="id", ), # table missing FakeRow( - TABLE_SCHEMA="public", - TABLE_NAME="phantom", - COLUMN_NAME="x", - REF_SCHEMA="public", - REF_TABLE="author", - REF_COLUMN="id", + table_schema="public", + table_name="phantom", + column_name="x", + ref_schema="public", + ref_table="author", + ref_column="id", ), ], ) From ef5c9157e41314305dafe0abc70bf43f2ffa1324 Mon Sep 17 00:00:00 2001 From: Jacques Raphanel Date: Thu, 21 May 2026 17:33:00 +0000 Subject: [PATCH 4/5] ci: failed to run functional tests --- .github/workflows/ci.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd64281..7de7977 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,7 @@ jobs: run: sphinx-build -b html -W --keep-going docs docs/_build/html functional: - name: Functional tests (MSSQL + Postgres, no Oracle) + name: Functional tests (MSSQL + MySQL + Postgres, no Oracle) runs-on: ubuntu-latest # Light stack (~2-3 min boot). Runs on every push to main, and on PRs # carrying the `run-functional` label. Oracle is on-demand only — see @@ -98,6 +98,12 @@ jobs: POSTGRES_PASSWORD: postgres PG_PASSWORD: postgres PG_DATABASE: db2sqltarget + MYSQL_HOST: localhost + MYSQL_PORT: "3306" + MYSQL_USER: root + MYSQL_ROOT_PASSWORD: mysqlpw + MYSQL_PASSWORD: mysqlpw + MYSQL_DATABASE: db2sqltest steps: - uses: actions/checkout@v4 @@ -112,8 +118,11 @@ jobs: - name: Install package and test dependencies run: pip install -e ".[all]" pytest - - name: Start docker stack (mssql + postgres) and wait until healthy - run: docker compose -f .docker/docker-compose.yml --profile functional up -d --wait + - name: Start docker stack (mssql + mysql + postgres) and wait until healthy + run: docker compose -f .docker/docker-compose.yml --profile functional up -d --wait mssql mysql postgres + + - name: Apply MSSQL init schema + run: docker compose -f .docker/docker-compose.yml --profile functional run --rm mssql-init - name: Run functional tests (excluding Oracle) run: pytest -m "functional and not oracle" tests/functional -v From 0c9119f53be78a05bd07f3f63c1aced93ce03297 Mon Sep 17 00:00:00 2001 From: Jacques Raphanel Date: Thu, 21 May 2026 17:33:11 +0000 Subject: [PATCH 5/5] ci: syntax issue --- .github/workflows/release-binaries.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index ec949be..318f30d 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -39,19 +39,16 @@ jobs: - label: windows-x86_64 os: windows-latest python: "3.12" - shell: pwsh archive-glob: "installer/dist/db2sql-*-windows-*.zip" container: "" - label: linux-x86_64 os: ubuntu-latest python: "3.12" - shell: bash archive-glob: "installer/dist/db2sql-*-linux-*.tar.gz" container: "python:3.12-bookworm" - label: macos-arm64 os: macos-14 # Apple Silicon runner python: "3.12" - shell: bash archive-glob: "installer/dist/db2sql-*-macos-*.tar.gz" container: "" @@ -79,7 +76,6 @@ jobs: rm -rf /var/lib/apt/lists/* - name: Show toolchain info - shell: ${{ matrix.shell }} run: | python --version python -c "import platform; print('platform:', platform.platform()); print('machine:', platform.machine())" @@ -89,14 +85,12 @@ jobs: run: ldd --version | head -1 - name: Install build dependencies - shell: ${{ matrix.shell }} run: | python -m pip install --upgrade pip wheel python -m pip install -e ".[all]" python -m pip install "pyinstaller>=6" - name: Build standalone binary - shell: ${{ matrix.shell }} run: python installer/build.py --archive - name: Upload build artifact