diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ce44767e..01932f94 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,8 +68,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -231,8 +231,8 @@ jobs: fail-fast: false matrix: include: - - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: false } - - { python-version: '3.15.0-rc.1', postgres-version: '18', freethreaded: true } + - { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: false } + - { python-version: '3.15.0-rc.2', postgres-version: '18', freethreaded: true } services: postgres: @@ -279,8 +279,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -322,7 +322,7 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup + python -m pytest tests/ --tb=short -n 8 --dist=loadgroup test: name: Python ${{ matrix.python-version }} / postgres ${{ matrix.postgres-version }} @@ -391,8 +391,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -435,14 +435,14 @@ jobs: if: matrix.postgres-version != '18' run: | export PYTHONPATH=src:tests - python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup + python -m pytest tests/ --tb=short -n 8 --dist=loadgroup - name: Run tests with coverage (only for postgres 18) if: matrix.postgres-version == '18' run: | export PYTHONPATH=src:tests pip install codecov - python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup --cov=src --cov-report=xml + python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src --cov-report=xml - name: Upload coverage to Codecov if: matrix.postgres-version == '18' @@ -490,8 +490,8 @@ jobs: - name: Install activerecord and testsuite run: | - pip install rhosocial-activerecord==1.0.0.dev29 - pip install rhosocial-activerecord-testsuite==1.0.0.dev16 + pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/parameter-inline" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -522,4 +522,4 @@ jobs: - name: Run tests run: | export PYTHONPATH=src:tests - python -m pytest tests/ --tb=short --scenario-parallel -n 2 --dist=loadgroup + python -m pytest tests/ --tb=short -n 8 --dist=loadgroup diff --git a/changelog.d/55.added.md b/changelog.d/55.added.md new file mode 100644 index 00000000..851351bc --- /dev/null +++ b/changelog.d/55.added.md @@ -0,0 +1 @@ +Add parallel testing infrastructure and PostgreSQL backend improvements \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 248ee365..ea713912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rhosocial-activerecord-postgres" -version = "1.0.0.dev16" +version = "1.0.0.dev17" description = "postgres backend implementation for rhosocial-activerecord, providing a robust and optimized postgres database support." readme = "README.md" license = { file = "LICENSE" } @@ -79,7 +79,7 @@ rhosocial = ["**/*.pyi", "**/*.py"] [tool.pytest.ini_options] minversion = "7.0" -addopts = "-ra -q" +addopts = "-ra -q -p rhosocial.activerecord.testsuite.conftest" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = [ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/README.md b/src/rhosocial/activerecord/backend/impl/postgres/README.md index 7342669b..356af4d1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/README.md +++ b/src/rhosocial/activerecord/backend/impl/postgres/README.md @@ -30,7 +30,7 @@ python -m rhosocial.activerecord.backend.impl.postgres [OPTIONAL_FLAGS] [YOUR_SQ | `--database` | `POSTGRES_DATABASE` env var or _None_ | Database name to connect to. | | `--user` | `POSTGRES_USER` env var or `postgres` | Database user. | | `--password` | `POSTGRES_PASSWORD` env var or _empty string_ | Database password. | -| `--use-async` | _False_ | Use the asynchronous backend. | +| `--async` | _False_ | Use the asynchronous backend. | | `--output` | `table` (if rich available) or `json` (fallback) | Output format. Choices are `table` (rich formatted), `json`, `csv`, `tsv`. | | `--log-level` | `INFO` | Set the logging level (e.g., `DEBUG`, `INFO`). | | `--rich-ascii` | _False_ | Use ASCII characters for table borders. | diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py index 9c6f720e..18b53f69 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__init__.py @@ -67,6 +67,8 @@ PostgresUUIDType, PostgresVarBitType, PostgresVectorType, + PostgresHalfvecType, + PostgresSparsevecType, PostgresXID8Type, PostgresXIDType, PostgresXMLType, @@ -598,6 +600,8 @@ "PostgresUUIDType", "PostgresVarBitType", "PostgresVectorType", + "PostgresHalfvecType", + "PostgresSparsevecType", "PostgresXID8Type", "PostgresXIDType", "PostgresXMLType", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py index 1c1761e1..24151fec 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/__main__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/__main__.py @@ -23,18 +23,18 @@ def _build_parser(): return parser -def parse_args(): +def parse_args(argv=None): """Parse command-line arguments. Provided for test compatibility. In production, main() is used instead. """ parser = _build_parser() - return parser.parse_args() + return parser.parse_args(argv) -def main(): +def main(argv=None): parser = _build_parser() - args = parser.parse_args() + args = parser.parse_args(argv) if args.command is None: cmd_list = ", ".join(f"'{c}'" for c in COMMAND_NAMES[:-1]) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py index e1c93541..6d5c7ab3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/adapters/base.py @@ -18,8 +18,12 @@ class PostgresListAdapter(BaseSQLTypeAdapter): """ Adapts Python list to PostgreSQL array types. - This adapter does not perform any conversion - psycopg handles - Python lists natively for PostgreSQL array types. + This adapter passes Python lists through to psycopg for native array + column support. On read, if the database returns a string (e.g. when + a list was stored in a TEXT column via psycopg array serialization), + it parses the value back using psycopg's own array loader — the exact + mirror of the ``ListDumper`` used on write — and falls back to JSON + for the portable ``["a","b"]`` representation. """ def __init__(self): @@ -27,12 +31,46 @@ def __init__(self): self._register_type(list, list) def _do_to_database(self, value: list, target_type: Type, options: Optional[Dict[str, Any]] = None) -> Any: - # psycopg handles list natively for PostgreSQL arrays return value + @staticmethod + def _parse_array_literal(value: str) -> list: + """Parse a PostgreSQL array literal using psycopg's array loader. + + ``psycopg.types.array._load_text`` is the exact reverse of the + ``ListDumper`` that psycopg uses to serialize a Python list on + write, so the round-trip is symmetric and handles quoting, + backslash escaping, ``NULL`` elements and nested arrays correctly. + """ + from psycopg.types.array import _load_text + + class _TextElementLoader: + def load(self, data): + return data.decode("utf-8") + + try: + return _load_text(value.encode("utf-8"), _TextElementLoader()) + except Exception: + raise ValueError(f"Not a PostgreSQL array literal: {value!r}") from None + def _do_from_database(self, value: Any, target_type: Type, options: Optional[Dict[str, Any]] = None) -> list: if isinstance(value, list): return value + if isinstance(value, str): + stripped = value.strip() + # Try JSON first (more portable format) + if stripped.startswith("["): + try: + return json.loads(stripped) + except (json.JSONDecodeError, ValueError): + pass + # Try PostgreSQL array literal + if stripped.startswith("{"): + try: + return self._parse_array_literal(stripped) + except (ValueError, IndexError): + pass + raise TypeError(f"Cannot convert str to list: {value!r}") raise TypeError(f"Cannot convert {type(value).__name__} to list") def to_database_batch( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py index 7aba7eb1..cd99f36d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/async_backend.py @@ -644,7 +644,7 @@ async def _handle_auto_commit(self) -> None: # Check if we're not in an active transaction if not self.in_transaction: # For PostgreSQL, if autocommit is disabled, we need to commit explicitly - if not getattr(self.config, "autocommit", False): + if not self.config.autocommit: await self._connection.commit() self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)") except Exception as e: @@ -658,7 +658,7 @@ async def _handle_auto_commit_if_needed(self) -> None: PostgreSQL respects the autocommit setting, but we also need to handle explicit commits. """ if not self.in_transaction and self._connection: - if not getattr(self.config, "autocommit", False): + if not self.config.autocommit: await self._connection.commit() self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py index eb4366e2..d14bbe9d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/base.py @@ -82,7 +82,7 @@ def threadsafety(self) -> int: def requires_manual_commit(self) -> bool: """Check if manual commit is required for this database.""" - return not getattr(self.config, "autocommit", False) + return not self.config.autocommit def _register_postgres_adapters(self): """Register PostgreSQL-specific type adapters. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py index b23d26fa..e1ee833a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/backend/sync.py @@ -645,7 +645,7 @@ def _handle_auto_commit(self) -> None: # Check if we're not in an active transaction if not self.in_transaction: # For PostgreSQL, if autocommit is disabled, we need to commit explicitly - if not getattr(self.config, "autocommit", False): + if not self.config.autocommit: self._connection.commit() self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)") except Exception as e: @@ -659,7 +659,7 @@ def _handle_auto_commit_if_needed(self) -> None: PostgreSQL respects the autocommit setting, but we also need to handle explicit commits. """ if not self.in_transaction and self._connection: - if not getattr(self.config, "autocommit", False): + if not self.config.autocommit: self._connection.commit() self.log(logging.DEBUG, "Auto-committed operation (not in active transaction)") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py index 06dada96..e3d3518d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/connection.py @@ -37,8 +37,15 @@ def add_connection_args(parser): help="Database password (env: POSTGRES_PASSWORD)", ) parser.add_argument( - "--use-async", + "--ssl", + choices=["auto", "require", "verify-ca", "verify-full", "disabled"], + default="auto", + help="SSL mode (env: POSTGRES_SSL, default: auto)", + ) + parser.add_argument( + "--async", action="store_true", + dest="is_async", help="Use asynchronous backend", ) parser.add_argument( @@ -118,12 +125,24 @@ def resolve_connection_config_from_args(args): return resolver.resolve({}) # Fallback to explicit connection parameters + # SSL parameter mapping (simplified for CLI unification) + ssl_param = getattr(args, "ssl", None) + sslmode_map = { + "auto": "prefer", + "require": "require", + "verify-ca": "verify-ca", + "verify-full": "verify-full", + "disabled": "disable", + } + sslmode = sslmode_map.get(ssl_param, "prefer") + return PostgresConnectionConfig( host=args.host or "localhost", port=args.port or 5432, database=args.database, username=args.user, password=args.password, + sslmode=sslmode, ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py index 09895572..d5d42a91 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/introspect.py @@ -128,7 +128,7 @@ def handle(args): config = resolve_connection_config_from_args(args) - if args.use_async: + if args.is_async: backend = AsyncPostgresBackend(connection_config=config) asyncio.run(_handle_introspect_async(args, backend, provider)) else: @@ -280,7 +280,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider introspector = backend.introspector if args.type == "tables": - tables = await introspector.list_tables_async( + tables = await introspector.list_tables( schema=args.schema, include_system=args.include_system ) @@ -288,7 +288,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider provider.display_results(data, title="Tables") elif args.type == "views": - views = await introspector.list_views_async(schema=args.schema) + views = await introspector.list_views(schema=args.schema) data = _serialize_for_output(views) provider.display_results(data, title="Views") @@ -296,7 +296,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider if not args.name: print("Error: Table name is required for 'table' introspection", file=sys.stderr) sys.exit(1) - info = await introspector.get_table_info_async(args.name, schema=args.schema) + info = await introspector.get_table_info(args.name, schema=args.schema) if info: provider.display_results(_serialize_for_output(info.columns), title=f"Columns of {args.name}") if info.indexes: @@ -314,7 +314,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider if not args.name: print("Error: Table name is required for 'columns' introspection", file=sys.stderr) sys.exit(1) - columns = await introspector.list_columns_async(args.name, schema=args.schema) + columns = await introspector.list_columns(args.name, schema=args.schema) data = _serialize_for_output(columns) provider.display_results(data, title=f"Columns of {args.name}") @@ -322,7 +322,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider if not args.name: print("Error: Table name is required for 'indexes' introspection", file=sys.stderr) sys.exit(1) - indexes = await introspector.list_indexes_async(args.name, schema=args.schema) + indexes = await introspector.list_indexes(args.name, schema=args.schema) data = _serialize_for_output(indexes) provider.display_results(data, title=f"Indexes of {args.name}") @@ -330,12 +330,12 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider if not args.name: print("Error: Table name is required for 'foreign-keys' introspection", file=sys.stderr) sys.exit(1) - fks = await introspector.list_foreign_keys_async(args.name, schema=args.schema) + fks = await introspector.list_foreign_keys(args.name, schema=args.schema) data = _serialize_for_output(fks) provider.display_results(data, title=f"Foreign Keys of {args.name}") elif args.type == "triggers": - triggers = await introspector.list_triggers_async( + triggers = await introspector.list_triggers( table_name=args.name, schema=args.schema ) @@ -343,7 +343,7 @@ async def _handle_introspect_async(args, backend: AsyncPostgresBackend, provider provider.display_results(data, title="Triggers") elif args.type == "database": - info = await introspector.get_database_info_async() + info = await introspector.get_database_info() data = _serialize_for_output(info) provider.display_results([data], title="Database Info") diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/query.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/query.py index 9307efaf..42e4aab3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/query.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/query.py @@ -139,7 +139,7 @@ def handle(args): config = resolve_connection_config_from_args(args) kwargs = {"use_ascii": args.rich_ascii} - if args.use_async: + if args.is_async: backend = AsyncPostgresBackend(connection_config=config) asyncio.run(_execute_query_async(sql_source, backend, provider, **kwargs)) else: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/cli/status.py b/src/rhosocial/activerecord/backend/impl/postgres/cli/status.py index 0e7573e8..40bf8de0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/cli/status.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/cli/status.py @@ -110,7 +110,7 @@ def handle(args): config = resolve_connection_config_from_args(args) - if args.use_async: + if args.is_async: backend = AsyncPostgresBackend(connection_config=config) asyncio.run(_handle_status_async(args, backend, provider)) else: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/config.py b/src/rhosocial/activerecord/backend/impl/postgres/config.py index 251b3306..8d7340b7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/config.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/config.py @@ -47,6 +47,11 @@ class PostgresConnectionMixin: service: Optional[str] = None gssencmode: Optional[str] = None channel_binding: Optional[str] = None + # Transaction behavior. The psycopg connection always runs with driver-level + # autocommit enabled and transactions are managed explicitly by the framework. + # When False (default), the backend issues an explicit COMMIT after statements + # executed outside a managed transaction; when True, those commits are skipped. + autocommit: bool = False # Schema namespace support search_path: Optional[str] = None # Set connection's search_path (passed directly to psycopg) default_schema: Optional[str] = None # Default schema when Model doesn't specify __schema_name__ diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 08cc6723..3a8ca700 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,14 +6,13 @@ based on the PostgreSQL version provided at initialization. """ -from typing import Any, Dict, Tuple, Optional, TYPE_CHECKING +from typing import Tuple, Optional, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression.collation import CollateExpression from .function_versions import FunctionSupportInfo, FunctionVersionRequirement from rhosocial.activerecord.backend.dialect.base import SQLDialectBase -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.dialect.mixins import ( SQLXMLMixin, CollationMixin, @@ -46,7 +45,6 @@ ConstraintMixin, PartitionMixin, # New Mixins - IdentifierMixin, PredicateMixin, ExpressionMixin, DateTimeMixin, @@ -188,9 +186,16 @@ # Introspection capability mixin PostgresIntrospectionCapabilityMixin, PostgresAlterColumnModifierMixin, + # Newly extracted mixins + PostgresDateTimeMixin, + PostgresDQLMixin, + PostgresJSONMixin, + PostgresGeneratedColumnMixin, + PostgresExpressionMixin, + PostgresFunctionMixin, ) -from .collation import validate_postgres_collation_name +from .reserved_words import POSTGRESQL_RESERVED_WORDS # PostgreSQL-specific imports from .protocols import ( @@ -285,32 +290,16 @@ PostgresLockingSupport, ) -if TYPE_CHECKING: - from rhosocial.activerecord.backend.expression.statements import ( - CreateTableExpression, - CreateViewExpression, - DropViewExpression, - TruncateExpression, - CreateMaterializedViewExpression, - DropMaterializedViewExpression, - RefreshMaterializedViewExpression, - ExplainExpression, - AddTableConstraint, - AddIndex, - DropIndex as DropIndexAction, # noqa: F401 - TableConstraint, - ) - from rhosocial.activerecord.backend.expression.transaction import ( - BeginTransactionExpression, - CommitTransactionExpression, - RollbackTransactionExpression, - SavepointExpression, - ReleaseSavepointExpression, - SetTransactionExpression, - ) class PostgresDialect( SQLDialectBase, + # PG-specific mixins (before global mixins to override) + PostgresDateTimeMixin, + PostgresDQLMixin, + PostgresJSONMixin, + PostgresGeneratedColumnMixin, + PostgresExpressionMixin, + PostgresFunctionMixin, # Per-feature mixins PostgresCTEMixin, PostgresWindowMixin, @@ -368,7 +357,7 @@ class PostgresDialect( PostgresExtensionMixin, PostgresMaterializedViewMixin, PostgresAlterColumnModifierMixin, # Before TableMixin/ConstraintMixin to override format_*_action - PostgresTableMixin, # Before TableMixin to override supports_table_like_syntax + PostgresTableMixin, # Before TableMixin to override supports_create_table_like TableMixin, ConstraintMixin, PostgresPartitionMixin, @@ -437,7 +426,6 @@ class PostgresDialect( PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, # New Mixins - IdentifierMixin, PredicateMixin, ExpressionMixin, DateTimeMixin, @@ -603,6 +591,13 @@ class PostgresDialect( Use introspect_and_adapt() to detect installed extensions automatically. """ + # PostgreSQL function version support: function_name -> (min_version, max_version) + # Function version requirements are defined in function_versions.py, + # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) + # and assembled into POSTGRES_FUNCTION_VERSIONS. + from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV + _POSTGRES_FUNCTION_VERSIONS = _FV + def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ Initialize PostgreSQL dialect with specific version. @@ -615,6 +610,7 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ super().__init__() + self._reserved_words = POSTGRESQL_RESERVED_WORDS if version is not None: self.version = version @@ -647,1194 +643,3 @@ def create_schema_differ(self): ) return PostgresSchemaDiffer() - - def format_datetime_diff_expression(self, expr: "Any") -> Tuple[str, Tuple]: - start_sql, start_params = expr.start.to_sql() - end_sql, end_params = expr.end.to_sql() - seconds_sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))" - factors = { - "second": "1", - "minute": "60", - "hour": "3600", - "day": "86400", - "week": "604800", - } - if expr.unit.value in factors: - sql = f"({seconds_sql} / {factors[expr.unit.value]})" - params = end_params + start_params - elif expr.unit.value == "month": - sql = ( - f"((EXTRACT(YEAR FROM {end_sql}) - " - f"EXTRACT(YEAR FROM {start_sql})) * 12 + " - f"(EXTRACT(MONTH FROM {end_sql}) - " - f"EXTRACT(MONTH FROM {start_sql})))" - ) - params = end_params + start_params + end_params + start_params - else: - sql = ( - f"(EXTRACT(YEAR FROM {end_sql}) - " - f"EXTRACT(YEAR FROM {start_sql}))" - ) - params = end_params + start_params - return self._apply_value_expression_modifiers(sql, params, expr) - - def validate_collation_name(self, expr: "CollateExpression") -> str: - """Validate PostgreSQL collation names and return their SQL representation.""" - schema = expr.collation_options.get("schema") - unsupported = set(expr.collation_options) - {"schema"} - if unsupported: - options = ", ".join(sorted(unsupported)) - raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") - validate_postgres_collation_name(expr.collation_name, getattr(self, "version", None)) - if schema is not None: - return f"{self.format_identifier(str(schema))}.{self.format_identifier(expr.collation_name)}" - return self.format_identifier(expr.collation_name) - - def format_explain_statement(self, explain_expr: "ExplainExpression") -> tuple: - """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). - - PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` - - Supported options: - - ``ANALYZE`` - - ``FORMAT { TEXT | XML | JSON | YAML }`` - - ``QUERY PLAN`` type — silently omitted (plain EXPLAIN is equivalent). - - Args: - explain_expr: ExplainExpression instance - - Returns: - Tuple of (SQL string, params tuple) - - """ - from rhosocial.activerecord.backend.expression.statements import ExplainType - - statement_sql, statement_params = explain_expr.statement.to_sql() - options = explain_expr.options - if options is None: - return f"EXPLAIN {statement_sql}", statement_params - - opts: list = [] - - if options.analyze: - opts.append("ANALYZE") - - if options.format is not None: - fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) - opts.append(f"FORMAT {fmt_name.upper()}") - elif options.type is not None and options.type == ExplainType.QUERY_PLAN: - # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent - pass - - if opts: - return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params - return f"EXPLAIN {statement_sql}", statement_params - - def supports_for_update(self) -> bool: - """Whether FOR UPDATE clause is supported in SELECT statements. - - PostgreSQL supports FOR UPDATE since early versions. The clause locks - selected rows preventing other transactions from modifying them. - PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and - FOR UPDATE SKIP LOCKED (since 9.5). - """ - return True - - def supports_json_arrow_operators(self) -> bool: - """PostgreSQL supports -> and ->> operators for JSON/JSONB access.""" - return True - - def supports_lock_strength(self, strength) -> bool: - """ - Check if a specific lock strength is supported. - - PostgreSQL lock strength support by version: - - FOR UPDATE: All versions - - FOR NO KEY UPDATE: PostgreSQL 9.0+ - - FOR SHARE: PostgreSQL 9.0+ - - FOR KEY SHARE: PostgreSQL 9.3+ - - Args: - strength: The LockStrength enum value to check - - Returns: - True if the lock strength is supported, False otherwise - - """ - from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength - - if strength == LockStrength.UPDATE: - return True # All PostgreSQL versions support FOR UPDATE - elif strength == LockStrength.NO_KEY_UPDATE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.SHARE: - return self.version >= (9, 0, 0) - elif strength == LockStrength.KEY_SHARE: - return self.version >= (9, 3, 0) - return False - - # region ILIKE Support - - def format_ilike_expression(self, column: Any, pattern: str, negate: bool = False) -> Tuple[str, Tuple]: - """Format ILIKE expression for PostgreSQL. - - Args: - column: Column name string or expression with ``to_sql()``. - pattern: Right-hand-side pattern (replaced with ``%s`` placeholder). - negate: If ``True``, produces ``NOT ILIKE`` instead of ``ILIKE``. - - Returns: - Tuple of (SQL string, (pattern,) params tuple) - - """ - if isinstance(column, str): - col_sql = self.format_identifier(column) - else: - col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) - - if negate: - sql = f"{col_sql} NOT ILIKE %s" - else: - sql = f"{col_sql} ILIKE %s" - - return sql, (pattern,) - - # endregion - - # region Truncate Support - - def format_truncate_statement(self, expr: "TruncateExpression") -> Tuple[str, tuple]: - """Format TRUNCATE statement for PostgreSQL. - - - ``expr.table_name`` — target table. - - ``expr.restart_identity`` — add ``RESTART IDENTITY`` (PG 8.4+). - - ``expr.cascade`` — add ``CASCADE``. - - Args: - expr: TruncateExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - parts = ["TRUNCATE TABLE"] - parts.append(self.format_identifier(expr.table_name)) - - if expr.restart_identity and self.supports_truncate_restart_identity(): - parts.append("RESTART IDENTITY") - - if expr.cascade: - parts.append("CASCADE") - - return " ".join(parts), () - - # endregion - - # region Custom Implementations for PostgreSQL-specific behavior - def format_identifier(self, identifier: str) -> str: - """ - Format identifier using PostgreSQL's double quote quoting mechanism. - - Args: - identifier: Raw identifier string - - Returns: - Quoted identifier with escaped internal quotes - - """ - # Escape any internal double quotes by doubling them - escaped = identifier.replace('"', '""') - return f'"{escaped}"' - - def format_column(self, name: str, table: Optional[str] = None, - alias: Optional[str] = None, - schema_name: Optional[str] = None) -> Tuple[str, Tuple]: - """Format column reference for PostgreSQL. - - PostgreSQL rules for column references: - - When the table has an alias (used in FROM/JOIN), column references - must use the alias, not the schema-qualified name. For example, - when FROM clause has ``public.users AS "users"``, the column - reference must be ``"users"."id"``, not ``"public"."users"."id"``. - - When no alias is present but schema is specified, use the - full three-segment form: ``"schema"."table"."column"``. - - Otherwise use the standard two-segment or single-segment form. - """ - if table: - # In PostgreSQL, when a table has an alias, column references - # must use the alias — schema_name is irrelevant in this context. - if schema_name and not alias: - col_sql = ( - f"{self.format_identifier(schema_name)}." - f"{self.format_identifier(table)}." - f"{self.format_identifier(name)}" - ) - else: - col_sql = f"{self.format_identifier(table)}.{self.format_identifier(name)}" - else: - col_sql = self.format_identifier(name) - - if alias: - col_sql = f"{col_sql} AS {self.format_identifier(alias)}" - - return col_sql, () - - def format_on_conflict_clause(self, expr) -> Tuple[str, tuple]: - """Format ON CONFLICT clause for PostgreSQL. - - Overrides the base implementation to handle EXCLUDED pseudo-table - references without quoting, as EXCLUDED is a special PostgreSQL - keyword in ON CONFLICT context and must not be double-quoted. - - - ``expr.conflict_target`` — optional list of column names / expressions. - - ``expr.do_nothing`` — ``DO NOTHING``. - - ``expr.update_assignments`` — dict of ``{column: expression}`` for ``DO UPDATE SET``. - - ``expr.update_where`` — optional WHERE predicate for the update. - - Args: - expr: OnConflictClause expression instance - - Returns: - Tuple of (SQL string, params tuple) - - """ - from rhosocial.activerecord.backend.expression import bases - from rhosocial.activerecord.backend.expression.core import Column - - all_params = [] - parts = ["ON CONFLICT"] - - # Add conflict target if specified - if expr.conflict_target: - target_parts = [] - for target in expr.conflict_target: - if isinstance(target, str): - target_parts.append(self.format_identifier(target)) - elif hasattr(target, 'to_sql'): - target_sql, target_params = target.to_sql() - target_parts.append(target_sql) - all_params.extend(target_params) - else: - target_parts.append(self.format_identifier(str(target))) - if target_parts: - parts.append(f"({', '.join(target_parts)})") - - # Add DO NOTHING or DO UPDATE - if expr.do_nothing: - parts.append("DO NOTHING") - elif expr.update_assignments: - update_parts = [] - for col, expr_val in expr.update_assignments.items(): - if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': - # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. - # It must NOT be double-quoted, only the column name should be quoted. - val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - elif isinstance(expr_val, bases.BaseExpression): - val_sql, val_params = expr_val.to_sql() - update_parts.append(f"{self.format_identifier(col)} = {val_sql}") - all_params.extend(val_params) - else: - update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") - all_params.append(expr_val) - - parts.append(f"DO UPDATE SET {', '.join(update_parts)}") - - # Add WHERE clause if specified - if expr.update_where: - where_sql, where_params = expr.update_where.to_sql() - parts.append(f"WHERE {where_sql}") - all_params.extend(where_params) - else: - parts.append("DO NOTHING") - - return " ".join(parts), tuple(all_params) - - # region View Support - - def format_create_view_statement(self, expr: "CreateViewExpression") -> Tuple[str, tuple]: - """Format CREATE VIEW statement for PostgreSQL. - - - ``expr.temporary`` — add ``TEMPORARY``. - - ``expr.replace`` — add ``OR REPLACE``. - - ``expr.view_name`` — view name (identifier). - - ``expr.column_aliases`` — optional list of column aliases. - - ``expr.query`` — source SELECT expression. - - ``expr.options.check_option`` — ``WITH {LOCAL|CASCADED} CHECK OPTION``. - - Args: - expr: CreateViewExpression instance - - Returns: - Tuple of (SQL string, params tuple) - - """ - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - if expr.replace: - parts.append("OR REPLACE") - - parts.append("VIEW") - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.options and expr.options.check_option: - check_option = expr.options.check_option.value - parts.append(f"WITH {check_option} CHECK OPTION") - - return " ".join(parts), query_params - - def format_drop_view_statement(self, expr: "DropViewExpression") -> Tuple[str, tuple]: - """Format DROP VIEW statement for PostgreSQL. - - - ``expr.if_exists`` — add ``IF EXISTS``. - - ``expr.view_name`` — view name (identifier). - - ``expr.cascade`` — add ``CASCADE``. - - Args: - expr: DropViewExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - parts = ["DROP VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_create_materialized_view_statement(self, expr: "CreateMaterializedViewExpression") -> Tuple[str, tuple]: - """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. - - - ``expr.view_name`` — view name (identifier). - - ``expr.column_aliases`` — optional list of column aliases. - - ``expr.tablespace`` — optional tablespace. - - ``expr.storage_options`` — optional dict of storage parameters (``WITH (… )``). - - ``expr.query`` — source SELECT expression. - - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. - - Args: - expr: CreateMaterializedViewExpression instance - - Returns: - Tuple of (SQL string, params tuple) - - """ - parts = ["CREATE MATERIALIZED VIEW"] - parts.append(self.format_identifier(expr.view_name)) - - if expr.column_aliases: - cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) - parts.append(f"({cols})") - - if expr.tablespace and self.supports_materialized_view_tablespace(): - parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") - - if expr.storage_options and self.supports_materialized_view_storage_options(): - storage_parts = [] - for key, value in expr.storage_options.items(): - storage_parts.append(f"{key.upper()} = {value}") - parts.append(f"WITH ({', '.join(storage_parts)})") - - query_sql, query_params = expr.query.to_sql() - parts.append(f"AS {query_sql}") - - if expr.with_data: - parts.append("WITH DATA") - else: - parts.append("WITH NO DATA") - - return " ".join(parts), query_params - - def format_drop_materialized_view_statement(self, expr: "DropMaterializedViewExpression") -> Tuple[str, tuple]: - """Format DROP MATERIALIZED VIEW statement for PostgreSQL. - - - ``expr.if_exists`` — add ``IF EXISTS``. - - ``expr.view_name`` — view name (identifier). - - ``expr.cascade`` — add ``CASCADE``. - - Args: - expr: DropMaterializedViewExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - parts = ["DROP MATERIALIZED VIEW"] - if expr.if_exists: - parts.append("IF EXISTS") - parts.append(self.format_identifier(expr.view_name)) - if expr.cascade: - parts.append("CASCADE") - return " ".join(parts), () - - def format_refresh_materialized_view_statement( - self, expr: "RefreshMaterializedViewExpression" - ) -> Tuple[str, tuple]: - """Format REFRESH MATERIALIZED VIEW statement for PostgreSQL. - - - ``expr.concurrent`` — add ``CONCURRENTLY`` (PG 9.4+). - - ``expr.view_name`` — view name (identifier). - - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. - - Args: - expr: RefreshMaterializedViewExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - parts = ["REFRESH MATERIALIZED VIEW"] - if expr.concurrent and self.supports_materialized_view_concurrent_refresh(): - parts.append("CONCURRENTLY") - parts.append(self.format_identifier(expr.view_name)) - if expr.with_data is not None: - parts.append("WITH DATA" if expr.with_data else "WITH NO DATA") - return " ".join(parts), () - - # endregion - - # region Index Support - - def format_add_index_action(self, action: "AddIndex") -> Tuple[str, tuple]: - """PostgreSQL does not support ALTER TABLE ADD INDEX. - - Raises UnsupportedFeatureError — use CREATE INDEX instead. - """ - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE ADD INDEX", - suggestion="Use CREATE INDEX to create an index on the table.", - ) - - def format_drop_index_action(self, action: "DropIndex") -> Tuple[str, tuple]: # noqa: F821 - """PostgreSQL does not support ALTER TABLE DROP INDEX. - - Raises UnsupportedFeatureError — use DROP INDEX statement instead. - """ - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - - raise UnsupportedFeatureError( - self.name, - "ALTER TABLE DROP INDEX", - suggestion="Use DROP INDEX statement to remove an index.", - ) - - # endregion - - # region Table Support - - # Constraint capability overrides - - def format_create_table_statement(self, expr: "CreateTableExpression") -> Tuple[str, tuple]: - """ - Format CREATE TABLE statement for PostgreSQL. - - This formatter handles PostgreSQL-specific CREATE TABLE behavior before - delegating ordinary table generation to the base dialect: - - * PostgreSQL LIKE syntax with INCLUDING/EXCLUDING options. - * Declarative partitioning validation for ``partition`` expressions. - - PostgreSQL LIKE syntax supports INCLUDING/EXCLUDING options to control what - gets copied: DEFAULTS, CONSTRAINTS, INDEXES, IDENTITY, GENERATED, ALL, - COMMENTS, STORAGE, COMPRESSION. - - The behavior is controlled by the `dialect_options` parameter in - CreateTableExpression: - - 1. When `dialect_options` contains 'like_table' key: - - LIKE syntax takes highest priority - - All other parameters (columns, indexes, constraints, etc.) are IGNORED - - Only temporary and if_not_exists flags are considered - - Additional 'like_options' key controls INCLUDING/EXCLUDING behavior - - 2. When `dialect_options` does NOT contain 'like_table': - - Applies PostgreSQL partition validation when `partition` is present - - Falls back to base class implementation for SQL generation - - Standard CREATE TABLE and CREATE TABLE ... PARTITION BY statements - are generated by the base formatter - - Partition validation follows PostgreSQL declarative partitioning rules: - - - RANGE and LIST partitioned tables require PostgreSQL 10+. - - HASH partitioned tables require PostgreSQL 11+. - - MySQL-specific partition methods such as KEY and RANGE COLUMNS are rejected. - - The API is `partition=PartitionClause(...)`; PartitionClause is the - standard expression type for partition specification. - - The 'like_options' key supports two formats: - - a) Dictionary format (recommended): - { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - - b) List format (for backwards compatibility): - ['DEFAULTS', 'CONSTRAINTS'] # Defaults to INCLUDING - or - [('INCLUDING', 'DEFAULTS'), ('EXCLUDING', 'INDEXES')] - - Usage Examples: - # Basic LIKE syntax - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], # Ignored when like_table is present - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users") - - # LIKE with INCLUDING options (dictionary format - recommended) - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[...], # Will be ignored - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, - # INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) - - # LIKE with schema-qualified source table - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} - ) - # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") - - # LIKE with TEMPORARY and IF NOT EXISTS - CreateTableExpression( - dialect=postgres_dialect, - table_name="temp_users", - columns=[], - temporary=True, - if_not_exists=True, - dialect_options={'like_table': 'users'} - ) - # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") - - # LIKE with INCLUDING ALL - CreateTableExpression( - dialect=postgres_dialect, - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } - ) - # Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) - - # PostgreSQL declarative partitioned table - CreateTableExpression( - dialect=postgres_dialect, - table="events", - columns=[ - ColumnDefinition("id", "BIGINT NOT NULL"), - ColumnDefinition("created_at", "TIMESTAMP NOT NULL"), - ], - partition=PartitionClause( - dialect=postgres_dialect, - method="RANGE", - keys=[Column(postgres_dialect, "created_at")], - ), - ) - # Generates: CREATE TABLE "events" (...) PARTITION BY RANGE ("created_at") - # PostgreSQL dialect validates version support before delegating to - # the base formatter. - - Args: - expr: CreateTableExpression instance - - Returns: - Tuple of (SQL string, parameters tuple) - - """ - # Check for LIKE syntax in dialect_options (highest priority) - if "like_table" in expr.dialect_options: - like_table = expr.dialect_options["like_table"] - like_options = expr.dialect_options.get("like_options", []) - - parts = ["CREATE"] - - if expr.temporary: - parts.append("TEMPORARY") - - parts.append("TABLE") - - if expr.if_not_exists: - parts.append("IF NOT EXISTS") - - parts.append(self.format_identifier(expr.table_name)) - - # Build LIKE clause with options - like_parts = [] - - # Handle schema-qualified table name: ('schema', 'table') - if isinstance(like_table, tuple): - schema, table = like_table - like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" - else: - like_table_str = self.format_identifier(like_table) - - like_parts.append(f"LIKE {like_table_str}") - - # Add INCLUDING/EXCLUDING options - # Format: dictionary with 'including' and 'excluding' keys - # Example: {'including': ['DEFAULTS', 'CONSTRAINTS'], 'excluding': ['INDEXES']} - if isinstance(like_options, dict): - # Handle dictionary format - including = like_options.get("including", []) - excluding = like_options.get("excluding", []) - - for option in including: - like_parts.append(f"INCLUDING {option.upper()}") - - for option in excluding: - like_parts.append(f"EXCLUDING {option.upper()}") - elif isinstance(like_options, list): - # Handle list format for backwards compatibility - for option in like_options: - if isinstance(option, tuple): - action, feature = option - like_parts.append(f"{action.upper()} {feature.upper()}") - else: - # Default to INCLUDING if just feature name provided - like_parts.append(f"INCLUDING {option.upper()}") - - parts.append(f"({', '.join(like_parts)})") - - return " ".join(parts), () - - if getattr(expr, "partition", None) is not None: - # Validate through the PartitionClause → format_partition_clause chain. - expr.partition.to_sql() - - # Otherwise, delegate to base implementation - return super().format_create_table_statement(expr) - - # endregion - - # region Type Casting Support (PostgreSQL-specific) - def format_cast_expression( - self, expr_sql: str, target_type: str, expr_params: tuple, alias: Optional[str] = None - ) -> Tuple[str, Tuple]: - """Format type cast expression using PostgreSQL :: syntax. - - PostgreSQL supports both standard CAST(expr AS type) syntax and the - PostgreSQL-specific expr::type syntax. This method uses the more - concise :: syntax which is idiomatic in PostgreSQL. - - Args: - expr_sql: SQL expression string to be cast - target_type: Target PostgreSQL type name (e.g., 'integer', 'varchar(100)') - expr_params: Parameters tuple for the expression - alias: Optional alias for the result - - Returns: - Tuple of (SQL string, parameters) - - Example: - >>> dialect.format_cast_expression('price', 'numeric', ()) - # Returns: ('price::numeric', ()) - >>> dialect.format_cast_expression('amount', 'money', ()) - # Returns: ('amount::money', ()) - >>> dialect.format_cast_expression('value', 'integer', (), 'int_val') - # Returns: ('value::integer AS "int_val"', ()) - - Note: - For chained type conversions, each ::type is appended: - >>> col.cast('money').cast('numeric').cast('float8') - # Generates: col::money::numeric::float8 - - """ - sql = f"{expr_sql}::{target_type}" - if alias: - sql = f"{sql} AS {self.format_identifier(alias)}" - return sql, expr_params - - # endregion - - # region Operator Formatting (PostgreSQL-specific) - - def format_binary_operator( - self, op: str, left_sql: str, right_sql: str, left_params: tuple, right_params: tuple - ) -> Tuple[str, Tuple]: - """Format binary operator with psycopg placeholder escaping. - - psycopg uses %s as parameter placeholder. When the SQL operator itself - contains % (e.g., pg_trgm similarity operator), it must be escaped as %% - to prevent psycopg from interpreting it as a placeholder prefix. - """ - # Escape % in operators for psycopg compatibility - escaped_op = op.replace('%', '%%') if '%' in op else op - sql = f"{left_sql} {escaped_op} {right_sql}" - return sql, left_params + right_params - - # endregion - - # region Constraint DDL Support (PostgreSQL-specific) - - def format_add_table_constraint_action( - self, action: "AddTableConstraint", - ) -> Tuple[str, tuple]: - """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. - - Extends the base class implementation with: - - EXCLUDE constraint support (PG-specific constraint type) - - NOT VALID suffix (PG-specific: skip validation of existing rows) - """ - from rhosocial.activerecord.backend.expression.statements import ( - TableConstraintType, ConstraintValidation, - ) - - # Handle EXCLUDE constraint (PG-specific, not in base class) - if action.constraint.constraint_type == TableConstraintType.EXCLUDE: - parts = [] - exclude_sql, params = self._format_exclude_constraint(action.constraint) - parts.append(exclude_sql) - - # NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - parts.append("NOT VALID") - - return f"ADD {' '.join(parts)}", tuple(params) - - # Use base class for standard formatting (includes DEFERRABLE) - sql, params = super().format_add_table_constraint_action(action) - - # PostgreSQL NOT VALID suffix - if action.constraint.dialect_options: - validation = action.constraint.dialect_options.get('validation') - if validation == ConstraintValidation.NOVALIDATE: - sql += " NOT VALID" - - return sql, params - - def _format_exclude_constraint( - self, constraint: "TableConstraint", - ) -> Tuple[str, tuple]: - """Format EXCLUDE constraint (PostgreSQL-specific). - - EXCLUDE constraints use the dialect_options dict to specify: - - 'exclude_elements': List of (expression, operator) tuples - e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) - - 'using': The index access method (default 'gist') - e.g., 'gist', 'btree', 'spgist' - - 'where': Optional predicate for partial exclusion constraints - - Example: - TableConstraint( - constraint_type=TableConstraintType.EXCLUDE, - name='exclude_range_overlap', - dialect_options={ - 'exclude_elements': [('range', '&&')], - 'using': 'gist', - } - ) - # Generates: EXCLUDE USING gist (range WITH &&) - - """ - params: list = [] - - if constraint.name: - parts = ["CONSTRAINT", self.format_identifier(constraint.name)] - else: - parts = [] - - # USING clause - validate index access method. - valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) - using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" - if using not in valid_using: - raise ValueError( - f"Invalid index access method '{using}': must be one of {valid_using}" - ) - parts.append(f"EXCLUDE USING {using}") - - # Elements: (expression, operator) pairs - validate operators. - valid_ops = frozenset({ - "=", "<", "<=", ">", ">=", "<>", - "&&", "@>", "<@", "<<", ">>", "&<", "&>", - "~=", "@@", "?|", "?&", "is", "is not", - }) - exclude_elements = [] - if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: - for expr, op in constraint.dialect_options["exclude_elements"]: - if op not in valid_ops: - raise ValueError( - f"Invalid exclude operator '{op}': must be one of {valid_ops}" - ) - if isinstance(expr, str): - exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") - else: - expr_sql, expr_params = expr.to_sql() - params.extend(expr_params) - exclude_elements.append(f"{expr_sql} WITH {op}") - - if exclude_elements: - parts.append(f"({', '.join(exclude_elements)})") - - # WHERE clause for partial exclusion constraint - if constraint.dialect_options and 'where' in constraint.dialect_options: - where_expr = constraint.dialect_options['where'] - where_sql, where_params = where_expr.to_sql() - params.extend(where_params) - parts.append(f"WHERE ({where_sql})") - - # DEFERRABLE / NOT DEFERRABLE - if constraint.deferrable is True: - if constraint.initially_deferred is True: - parts.append("DEFERRABLE INITIALLY DEFERRED") - elif constraint.initially_deferred is False: - parts.append("DEFERRABLE INITIALLY IMMEDIATE") - else: - parts.append("DEFERRABLE") - elif constraint.deferrable is False: - parts.append("NOT DEFERRABLE") - - return ' '.join(parts), tuple(params) - - # endregion - - # region Transaction Control Support - - # PostgreSQL function version support: function_name -> (min_version, max_version) - # min_version: minimum supported version (inclusive), None = all versions - # Function version requirements are defined in function_versions.py, - # categorized by topic (JSON Path, Range, hstore, pgvector, PostGIS, etc.) - # and assembled into POSTGRES_FUNCTION_VERSIONS. - from .function_versions import POSTGRES_FUNCTION_VERSIONS as _FV - _POSTGRES_FUNCTION_VERSIONS = _FV - - def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: - """Return supported SQL functions with detailed support information. - - This method combines: - 1. Core functions from rhosocial.activerecord.backend.expression.functions - 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions - - Each function is mapped to a FunctionSupportInfo indicating: - - Whether the function is supported - - If not, the reason why (PG version, extension status, etc.) - - Returns: - Dict mapping function names to FunctionSupportInfo. - - """ - from .function_versions import FunctionSupportInfo - from rhosocial.activerecord.backend.expression.functions import ( - __all__ as core_functions, - ) - from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions - - expression_constructors = { - "xmlagg", - "xmlattributes", - "xmlcomment", - "xmlconcat", - "xmlelement", - "xmlexists", - "xmlforest", - "xmlparse", - "xmlpi", - "xmlquery", - "xmlroot", - "xmlserialize", - "xmltable", - } - result: Dict[str, FunctionSupportInfo] = {} - for func_name in core_functions: - if func_name not in expression_constructors: - result[func_name] = self._check_function_support(func_name) - - postgres_funcs = getattr(postgres_functions, "__all__", []) - for func_name in postgres_funcs: - if func_name not in result: - result[func_name] = self._check_function_support(func_name) - - return result - - def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": - """Check function support status and return detailed information. - - Args: - func_name: Name of the function to check - - Returns: - FunctionSupportInfo with support status and reason if unsupported - - """ - from .function_versions import FunctionSupportInfo - - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return FunctionSupportInfo(supported=True) - - # Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_low") - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return FunctionSupportInfo(supported=False, reason="pg_version_too_high") - - # Check extension requirements - if requirement.extension is not None: - if not hasattr(self, "_extensions"): - return FunctionSupportInfo(supported=False, reason="extension_not_probed") - - if requirement.ext_feature is not None: - if not self.check_extension_feature(requirement.extension, requirement.ext_feature): - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - else: - if not self.is_extension_installed(requirement.extension): - return FunctionSupportInfo(supported=False, reason="extension_not_installed") - if requirement.min_ext_version is not None: - installed = self.get_extension_version(requirement.extension) - if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: - return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") - - return FunctionSupportInfo(supported=True) - - def _is_postgres_function_supported(self, func_name: str) -> bool: - """Check if a PostgreSQL function is supported based on version and extensions. - - Checks: - 1. PostgreSQL server version (for built-in and version-gated functions) - 2. Extension installation and version (for extension-provided functions) - - For extension functions, requires _extensions to have been populated - via introspect_and_adapt(). If _extensions is not available, - extension functions return False (cannot confirm availability). - - Args: - func_name: Name of the PostgreSQL function - - Returns: - True if supported, False otherwise - - """ - requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) - if requirement is None: - return True # Unregistered functions default to supported - - # Step 1: Check PostgreSQL server version - if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: - return False - if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: - return False - - # Step 2: Check extension requirements - if requirement.extension is not None: - return self._check_extension_requirement(requirement) - - return True - - def _check_extension_requirement( - self, requirement: "FunctionVersionRequirement" - ) -> bool: - """Check extension requirements based on introspect_and_adapt() results. - - Requires _extensions to have been populated via introspect_and_adapt(). - If _extensions is not available, returns False (cannot confirm - extension availability without probing the database). - """ - ext_name = requirement.extension - - # _extensions is populated by introspect_and_adapt() - # If the attribute doesn't exist, introspect_and_adapt() hasn't been called - if not hasattr(self, "_extensions"): - return False - - # Use feature-level check if ext_feature is specified - if requirement.ext_feature is not None: - return self.check_extension_feature(ext_name, requirement.ext_feature) - - # Check extension installed - if not self.is_extension_installed(ext_name): - return False - - # Check extension version - if requirement.min_ext_version is not None: - installed = self.get_extension_version(ext_name) - if installed is None: - return False - return self._compare_versions(installed, requirement.min_ext_version) >= 0 - - return True - - def format_begin_transaction( - self, expr: "BeginTransactionExpression" - ) -> Tuple[str, tuple]: - """Format BEGIN TRANSACTION statement for PostgreSQL. - - PostgreSQL syntax: - BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] - [ { READ WRITE | READ ONLY } ] - [ { NOT DEFERRABLE | DEFERRABLE } ] - - DEFERRABLE is only meaningful for SERIALIZABLE isolation level. - """ - params = expr.get_params() - parts = ["BEGIN"] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - parts.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - parts.append("READ ONLY") - elif mode_name == "READ_WRITE": - parts.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None and isolation: - isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) - if isolation_name == "SERIALIZABLE": - parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - return " ".join(parts), () - - def format_commit_transaction( - self, expr: "CommitTransactionExpression" - ) -> Tuple[str, tuple]: - """Format COMMIT TRANSACTION statement for PostgreSQL. - - Always returns ``COMMIT``; ``expr`` is ignored. - - Args: - expr: CommitTransactionExpression instance (unused) - - Returns: - Tuple of ("COMMIT", ()) - - """ - return "COMMIT", () - - def format_rollback_transaction( - self, expr: "RollbackTransactionExpression" - ) -> Tuple[str, tuple]: - """Format ROLLBACK TRANSACTION statement for PostgreSQL. - - Supports ROLLBACK [ TO SAVEPOINT savepoint_name ]. - """ - params = expr.get_params() - savepoint = params.get("savepoint") - if savepoint: - return f"ROLLBACK TO SAVEPOINT {self.format_identifier(savepoint)}", () - return "ROLLBACK", () - - def format_savepoint( - self, expr: "SavepointExpression" - ) -> Tuple[str, tuple]: - """Format SAVEPOINT statement for PostgreSQL. - - ``expr.get_params()["name"]`` — savepoint name (identifier). - - Args: - expr: SavepointExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - params = expr.get_params() - name = params.get("name", "") - return f"SAVEPOINT {self.format_identifier(name)}", () - - def format_release_savepoint( - self, expr: "ReleaseSavepointExpression" - ) -> Tuple[str, tuple]: - """Format RELEASE SAVEPOINT statement for PostgreSQL. - - ``expr.get_params()["name"]`` — savepoint name (identifier). - - Args: - expr: ReleaseSavepointExpression instance - - Returns: - Tuple of (SQL string, empty params tuple) - - """ - params = expr.get_params() - name = params.get("name", "") - return f"RELEASE SAVEPOINT {self.format_identifier(name)}", () - - def format_set_transaction( - self, expr: "SetTransactionExpression" - ) -> Tuple[str, tuple]: - """Format SET TRANSACTION statement for PostgreSQL. - - PostgreSQL supports setting transaction characteristics for the current - transaction or for subsequent transactions. - - Syntax: - SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] - SET SESSION CHARACTERISTICS AS TRANSACTION { ... } - """ - params = expr.get_params() - parts = [] - - if params.get("session"): - parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") - else: - parts.append("SET TRANSACTION") - - options = [] - - isolation = params.get("isolation_level") - if isolation: - level_str = self.get_isolation_level_name(isolation) - options.append(f"ISOLATION LEVEL {level_str}") - - mode = params.get("mode") - if mode: - mode_name = mode.name if hasattr(mode, "name") else str(mode) - if mode_name == "READ_ONLY": - options.append("READ ONLY") - elif mode_name == "READ_WRITE": - options.append("READ WRITE") - - deferrable = params.get("deferrable") - if deferrable is not None: - options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") - - if options: - parts.append(" ".join(options)) - - return " ".join(parts), () - - # endregion diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh index e9f2d368..b8351c98 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/cli_commands.sh @@ -173,15 +173,15 @@ run_named_expression() { echo "" echo "--- List named expressions (module may not exist) ---" - $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expression examples found)" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions 2>/dev/null || echo "(No named expression examples found)" echo "" echo "--- List with dialect version ---" - $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions --dialect-version 15.0.0 2>/dev/null || echo "(No named expression examples found)" + $PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions --dialect-version 15.0.0 2>/dev/null || echo "(No named expression examples found)" echo "" echo "--- Describe expression ---" - $PYTHON_CMD named-expression --describe rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.some_expression 2>/dev/null || echo "(No named expression examples found)" + $PYTHON_CMD named-expression --describe rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.get_order 2>/dev/null || echo "(No named expression examples found)" } # Command: named-procedure @@ -193,15 +193,15 @@ run_named_procedure() { echo "" echo "--- List named procedures (module may not exist) ---" - $PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures 2>/dev/null || echo "(No named procedure examples found)" + $PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.order_workflow 2>/dev/null || echo "(No named procedure examples found)" echo "" echo "--- Describe procedure ---" - $PYTHON_CMD named-procedure --describe rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure 2>/dev/null || echo "(No named procedure examples found)" + $PYTHON_CMD named-procedure --describe rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.order_workflow.OrderProcessingProcedure 2>/dev/null || echo "(No named procedure examples found)" echo "" echo "--- Dry run procedure ---" - $PYTHON_CMD named-procedure rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.SomeProcedure --dry-run 2>/dev/null || echo "(No named procedure examples found)" + $PYTHON_CMD named-procedure rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.order_workflow.OrderProcessingProcedure --dry-run 2>/dev/null || echo "(No named procedure examples found)" } # Command: named-procedure-graph diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh index ff246bd9..46eb1b02 100755 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_expression.sh @@ -17,5 +17,5 @@ echo "==========================================" echo "PostgreSQL CLI - named-expression command examples" echo "==========================================" -$PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions 2>/dev/null || echo "(No named expressions found)" +$PYTHON_CMD named-expression --list rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions 2>/dev/null || echo "(No named expressions found)" $PYTHON_CMD named-connection --list rhosocial.activerecord.backend.impl.postgres.examples.named_connections \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_procedure.sh b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_procedure.sh index fdc97289..c9abc740 100755 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_procedure.sh +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/cli/bash/named_procedure.sh @@ -17,5 +17,5 @@ echo "==========================================" echo "PostgreSQL CLI - named-procedure command examples" echo "==========================================" -$PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures 2>/dev/null || echo "(No named procedures found)" +$PYTHON_CMD named-procedure --list rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.order_workflow 2>/dev/null || echo "(No named procedures found)" $PYTHON_CMD named-connection --list rhosocial.activerecord.backend.impl.postgres.examples.named_connections \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py index 0cb8edd7..5fd1e5a0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/create_table.py @@ -41,51 +41,55 @@ dialect = backend.dialect # Drop if exists for clean setup -drop = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +drop = DropTableExpression(dialect=dialect, table='products', if_exists=True) sql, params = drop.to_sql() backend.execute(sql, params) # ============================================================ # SECTION: Business Logic (the pattern to learn) # ============================================================ +# Column data types are built with dialect.parse_type() (DataType instance). columns = [ ColumnDefinition( name='id', - data_type='SERIAL', + data_type=dialect.parse_type('SERIAL'), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name='name', - data_type='VARCHAR(200)', + data_type=dialect.parse_type('VARCHAR(200)'), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL), ], ), ColumnDefinition( name='price', - data_type='NUMERIC(10,2)', + data_type=dialect.parse_type('NUMERIC(10,2)'), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL), ], ), ColumnDefinition( name='category', - data_type='VARCHAR(100)', + data_type=dialect.parse_type('VARCHAR(100)'), ), ColumnDefinition( name='is_active', - data_type='BOOLEAN', + data_type=dialect.parse_type('BOOLEAN'), constraints=[ ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True), ], ), ColumnDefinition( name='created_at', - data_type='TIMESTAMP', + data_type=dialect.parse_type('TIMESTAMP'), constraints=[ - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=current_timestamp(dialect)), + ColumnConstraint( + ColumnConstraintType.DEFAULT, + default_value=current_timestamp(dialect), + ), ], ), ] @@ -99,7 +103,7 @@ create_expr = CreateTableExpression( dialect=dialect, - table_name='products', + table='products', columns=columns, indexes=indexes, if_not_exists=True, @@ -124,7 +128,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_table = DropTableExpression(dialect=dialect, table_name='products', if_exists=True) +drop_table = DropTableExpression(dialect=dialect, table='products', if_exists=True) sql, params = drop_table.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py index 1f43e5d4..e20bffb5 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/extensions/pgvector.py @@ -6,8 +6,9 @@ 1. Check if vector extension is available 2. CREATE EXTENSION and create table with vector column 3. Insert vector data -4. Execute cosine similarity search -5. Create HNSW index for fast vector search +4. Cosine similarity search (via the vector_search helper) +5. L2 distance search (via the vector_search helper) +6. Create HNSW index (via the create_vector_index helper) """ # ============================================================ @@ -34,7 +35,7 @@ # Clean up for demo from rhosocial.activerecord.backend.expression import DropTableExpression -drop_expr = DropTableExpression(dialect=dialect, table_name="documents", if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table="documents", if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) @@ -49,11 +50,6 @@ ColumnDefinition, ColumnConstraint, ColumnConstraintType, - CreateIndexExpression, -) -from rhosocial.activerecord.backend.expression.core import Literal, Subquery -from rhosocial.activerecord.backend.expression.operators import ( - BinaryExpression, ) from rhosocial.activerecord.backend.expression.statements.dml import ( InsertExpression, @@ -61,17 +57,13 @@ from rhosocial.activerecord.backend.expression.statements import ( ValuesSource, ) -from rhosocial.activerecord.backend.expression import ( - Column, - QueryExpression, - TableExpression, -) -from rhosocial.activerecord.backend.expression.query_parts import ( - OrderByClause, - LimitOffsetClause, -) +from rhosocial.activerecord.backend.expression.core import Literal from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.backend.impl.postgres.functions import ( + vector_search, + create_vector_index, +) # Check if vector extension is available available = dialect.is_extension_available("vector") @@ -96,30 +88,31 @@ if installed: # Example 1: Create table with vector column + # Column data types are built with dialect.parse_type() (DataType instance). columns = [ ColumnDefinition( name="id", - data_type="SERIAL", + data_type=dialect.parse_type("SERIAL"), constraints=[ ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( name="content", - data_type="TEXT", + data_type=dialect.parse_type("TEXT"), constraints=[ ColumnConstraint(ColumnConstraintType.NOT_NULL), ], ), ColumnDefinition( name="embedding", - data_type="VECTOR(3)", + data_type=dialect.parse_type("VECTOR(3)"), ), ] create_expr = CreateTableExpression( dialect=dialect, - table_name="documents", + table="documents", columns=columns, if_not_exists=True, ) @@ -129,7 +122,7 @@ backend.execute(sql, params) # Example 2: Insert vector data - # Use Literal with .cast("vector") for type-safe vector insertion + # Use Literal with .cast("vector") for type-safe vector insertion. insert_expr = InsertExpression( dialect=dialect, into="documents", @@ -159,108 +152,66 @@ sql, params = insert_expr.to_sql() print("\n--- INSERT vector data ---") print(f"SQL: {sql}") - print(f"Params: {params}") backend.execute(sql, params) - # Example 3: Cosine similarity search - # Find documents most similar to "kitten" represented as [1.0, 0.55, 0.18] - # pgvector <=> operator is cosine distance; cosine_similarity = 1 - cosine_distance - query_vector_cosine = Literal(dialect, "[1.0, 0.55, 0.18]").cast("vector") - cosine_dist = BinaryExpression( - dialect, "<=>", - Column(dialect, "embedding"), - query_vector_cosine, - ) - - # cosine_similarity = 1 - (embedding <=> query_vector) - # Wrap cosine_dist with Subquery to ensure parentheses for operator precedence: - # without parentheses, PostgreSQL parses "1 - embedding <=> ..." as "(1 - embedding) <=> ..." - # because subtraction (-) has higher precedence than the custom <=> operator. - # Wrap the outer expression with Subquery to add alias since BinaryExpression lacks AliasableMixin. - cosine_sim = Subquery( + # Example 3: Cosine similarity search (query similar to "cat") + opts = ExecutionOptions(stmt_type=StatementType.DQL) + query = vector_search( dialect, - BinaryExpression( - dialect, "-", - Literal(dialect, 1), - Subquery(dialect, cosine_dist), - ), - ).as_("cosine_similarity") - - query = QueryExpression( - dialect=dialect, - select=[Column(dialect, "content"), cosine_sim], - from_=TableExpression(dialect, "documents"), - order_by=OrderByClause(dialect, [cosine_dist]), - limit_offset=LimitOffsetClause(dialect, limit=3), + "documents", + [1.0, 0.55, 0.18], + metric="cosine", + top_k=3, + columns=["content"], + include_similarity=True, ) sql, params = query.to_sql() print("\n--- Cosine similarity search ---") print("Query vector: [1.0, 0.55, 0.18] (similar to 'cat')") print(f"SQL: {sql}") print(f"Params: {params}") - opts = ExecutionOptions(stmt_type=StatementType.DQL) result = backend.execute(sql, params, options=opts) print(f"Results: {result.data}") - # Example 4: L2 distance search - # pgvector <-> operator is L2 (Euclidean) distance - query_vector_l2 = Literal(dialect, "[0.1, 0.2, 0.9]").cast("vector") - l2_dist = BinaryExpression( - dialect, "<->", - Column(dialect, "embedding"), - query_vector_l2, - ) - - # Wrap with Subquery to add alias since BinaryExpression lacks AliasableMixin - l2_dist_aliased = Subquery(dialect, l2_dist).as_("l2_distance") - - # ORDER BY needs the raw expression (without alias wrapper) - l2_dist_order = BinaryExpression( - dialect, "<->", - Column(dialect, "embedding"), - Literal(dialect, "[0.1, 0.2, 0.9]").cast("vector"), - ) - - query = QueryExpression( - dialect=dialect, - select=[Column(dialect, "content"), l2_dist_aliased], - from_=TableExpression(dialect, "documents"), - order_by=OrderByClause(dialect, [l2_dist_order]), - limit_offset=LimitOffsetClause(dialect, limit=3), + # Example 4: L2 distance search (query vector equal to "car" row) + query = vector_search( + dialect, + "documents", + [0.1, 0.2, 0.9], + metric="l2", + top_k=3, + columns=["content"], + include_distance=True, ) sql, params = query.to_sql() print("\n--- L2 distance search ---") print("Query vector: [0.1, 0.2, 0.9] (similar to 'car')") print(f"SQL: {sql}") - print(f"Params: {params}") result = backend.execute(sql, params, options=opts) print(f"Results: {result.data}") # Example 5: Create HNSW index for fast vector search - # HNSW index provides approximate nearest neighbor search - # Use dialect_options={"opclasses": {"embedding": "vector_cosine_ops"}} - # to specify the operator class for the vector column. - create_idx = CreateIndexExpression( - dialect=dialect, - index_name="idx_documents_embedding", - table_name="documents", - columns=["embedding"], - index_type="HNSW", - if_not_exists=True, - dialect_options={"opclasses": {"embedding": "vector_cosine_ops"}}, + # Metric 'cosine' maps to the vector_cosine_ops operator class. + index_expr = create_vector_index( + dialect, + "documents", + metric="cosine", + index_type="hnsw", + m=16, + ef_construction=64, ) - sql, params = create_idx.to_sql() + sql, params = index_expr.to_sql() print("\n--- CREATE HNSW INDEX ---") print(f"SQL: {sql}") backend.execute(sql, params) - print("HNSW index created: idx_documents_embedding") + print("HNSW index created") - # Note: HNSW index options can be set via: - # SET hnsw.ef_search = 100; -- query-time parameter - # The index creation supports operator classes: - # vector_cosine_ops (default for <=>) - # vector_l2_ops (for <->) - # vector_ip_ops (for <#> inner product) + # Note: HNSW query-time parameter can be set via: + # SET hnsw.ef_search = 100; + # Supported operator classes (metric -> opclass): + # cosine -> vector_cosine_ops (<=>) + # l2 -> vector_l2_ops (<->) + # ip -> vector_ip_ops (<#> inner product) else: print("\nSkipping execution - vector extension not available on this server") @@ -269,7 +220,7 @@ # ============================================================ # SECTION: Teardown (necessary for execution, reference only) # ============================================================ -drop_expr = DropTableExpression(dialect=dialect, table_name="documents", if_exists=True) +drop_expr = DropTableExpression(dialect=dialect, table="documents", if_exists=True) sql, params = drop_expr.to_sql() backend.execute(sql, params) backend.disconnect() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/__init__.py new file mode 100644 index 00000000..79aedea2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/__init__.py +"""Named query examples for PostgreSQL backend.""" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py new file mode 100644 index 00000000..f597f210 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py @@ -0,0 +1,189 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_expressions/order_expressions.py +""" +Order-related named query examples. + +This file demonstrates how to define named queries (Named Query) for encapsulating +reusable SQL query logic. Named queries are backend features, independent of +ActiveRecord models. + +PostgreSQL notes +---------------- +Unlike the SQLite examples, this module does NOT create any tables or data at +import time. Importing this module must stay side-effect free (the CLI imports +it for --list/--describe without a live database). Use ``prepare_orders_demo`` +to create the schema and seed data, then the CLI named-expression / +named-procedure commands to execute the queries. +""" + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + Column, + Literal, + QueryExpression, + TableExpression, +) + + +def get_order(dialect, order_id: int): + """Get order details by ID.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "status"), Column(dialect, "user_id")], + from_=TableExpression(dialect, "orders"), + where=Column(dialect, "id") == Literal(dialect, order_id), + ) + + +def check_inventory(dialect, order_id: int): + """Check available inventory for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "available")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def reserve_inventory(dialect, order_id: int): + """Reserve inventory for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "available")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def send_notification(dialect, user_id: int, type: str): + """Send notification to a user.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "notifications"), + where=Column(dialect, "user_id") == Literal(dialect, user_id), + ) + + +def process_payment(dialect, order_id: int, amount: float): + """Process payment for an order.""" + return QueryExpression( + dialect, + select=[Column(dialect, "status"), Column(dialect, "transaction_id")], + from_=TableExpression(dialect, "payments"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def release_inventory(dialect, order_id: int): + """Release reserved inventory.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def create_order_record(dialect, order_id: int, user_id: int, amount: float): + """Create an order record.""" + return QueryExpression( + dialect, + select=[Column(dialect, "id"), Column(dialect, "created_at")], + from_=TableExpression(dialect, "order_records"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +def confirm_inventory(dialect, order_id: int): + """Confirm inventory (final confirmation).""" + return QueryExpression( + dialect, + select=[Column(dialect, "id")], + from_=TableExpression(dialect, "inventory"), + where=Column(dialect, "order_id") == Literal(dialect, order_id), + ) + + +# ============================================================ +# SECTION: Setup (necessary for execution, reference only) +# ============================================================ +# The setup below is import-safe: it only prepares the schema when this module +# is run directly, so importing it for --list/--describe never touches a database. + +_PREPARE_STATEMENTS = [ + "DROP TABLE IF EXISTS order_records", + "DROP TABLE IF EXISTS payments", + "DROP TABLE IF EXISTS notifications", + "DROP TABLE IF EXISTS inventory", + "DROP TABLE IF EXISTS orders", + ( + "CREATE TABLE orders (" + " id SERIAL PRIMARY KEY," + " status VARCHAR(20) DEFAULT 'pending'," + " user_id INTEGER NOT NULL)" + ), + "CREATE TABLE inventory (id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL, available INTEGER DEFAULT 0)", + "CREATE TABLE notifications (id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, type VARCHAR(20))", + ( + "CREATE TABLE payments (" + " id SERIAL PRIMARY KEY," + " order_id INTEGER NOT NULL," + " status VARCHAR(20)," + " transaction_id VARCHAR(40))" + ), + ( + "CREATE TABLE order_records (" + " id SERIAL PRIMARY KEY," + " order_id INTEGER NOT NULL," + " created_at VARCHAR(30))" + ), + "INSERT INTO orders (id, status, user_id) VALUES (1, 'pending', 100)", + "INSERT INTO inventory (id, order_id, available) VALUES (1, 1, 10)", +] + + +def prepare_orders_demo(backend) -> None: + """Create the demo schema and seed data. + + Drops any existing demo tables first so the demo is idempotent. + + Args: + backend: Connected PostgresBackend instance. + """ + for statement in _PREPARE_STATEMENTS: + backend.execute(statement) + + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +if __name__ == "__main__": + import os + + from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "127.0.0.1"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + database=os.getenv("POSTGRES_DATABASE", "postgres"), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + dialect = backend.dialect + + prepare_orders_demo(backend) + + print("=== Named Query Examples ===\n") + query = get_order(dialect, order_id=1) + sql, params = query.to_sql() + print(f"get_order SQL: {sql}") + print(f"Params: {params}\n") + + result = backend.execute(sql, params) + print(f"Execution result: {result.data}\n") + + backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py index a37101fd..09dae565 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_migrations/expressions.py @@ -26,12 +26,13 @@ def create_users_table(dialect): table="users", columns=[ ColumnDefinition( + dialect, "id", - PostgresSerialType(), + PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("name", PostgresCharacterVaryingType(255)), - ColumnDefinition("email", PostgresCharacterVaryingType(255)), + ColumnDefinition(dialect, "name", PostgresCharacterVaryingType(dialect, length=255)), + ColumnDefinition(dialect, "email", PostgresCharacterVaryingType(dialect, length=255)), ], ) @@ -48,12 +49,13 @@ def create_posts_table(dialect): table="posts", columns=[ ColumnDefinition( + dialect, "id", - PostgresSerialType(), + PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("title", PostgresCharacterVaryingType(255)), - ColumnDefinition("user_id", PostgresCharacterVaryingType(255)), + ColumnDefinition(dialect, "title", PostgresCharacterVaryingType(dialect, length=255)), + ColumnDefinition(dialect, "user_id", PostgresCharacterVaryingType(dialect, length=255)), ], ) @@ -74,11 +76,12 @@ def create_custom_table(dialect, table_name: str = "custom_table"): table=table_name, columns=[ ColumnDefinition( + dialect, "id", - PostgresSerialType(), + PostgresSerialType(dialect), constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)], ), - ColumnDefinition("value", PostgresCharacterVaryingType(255)), + ColumnDefinition(dialect, "value", PostgresCharacterVaryingType(dialect, length=255)), ], ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/__init__.py new file mode 100644 index 00000000..4bea42f2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/__init__.py @@ -0,0 +1,2 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/__init__.py +"""Named procedure examples for PostgreSQL backend.""" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/order_workflow.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/order_workflow.py new file mode 100644 index 00000000..20db15c2 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/order_workflow.py @@ -0,0 +1,174 @@ +# src/rhosocial/activerecord/backend/impl/postgres/examples/named_procedures/order_workflow.py +""" +Order processing workflow example - demonstrates Named Procedure flowchart capabilities. + +This procedure includes: +- Conditional branching (inventory check) +- Parallel execution (inventory reservation + notification) +- Conditional rollback (payment failure) + +Named Procedure is a backend feature, independent of ActiveRecord models. + +PostgreSQL notes +---------------- +This module is import-safe: no database connection is made at import time. +Prepare the schema and seed data with +``rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.prepare_orders_demo`` +before running the procedure. +""" + +# ============================================================ +# SECTION: Business Logic (the pattern to learn) +# ============================================================ +from rhosocial.activerecord.backend.named_expression import ( # noqa: E402 + Procedure, + ProcedureContext, + ParallelStep, +) + + +class OrderProcessingProcedure(Procedure): + """Complete order processing workflow. + + Flow: + 1. Query order details + 2. Check inventory (abort if insufficient) + 3. Parallel: reserve inventory + send notification + 4. Process payment (rollback inventory on failure) + 5. Create order record + 6. Final inventory confirmation + """ + + order_id: int + user_id: int + amount: float = 0.0 + + def run(self, ctx: ProcedureContext) -> None: + ctx.log(f"Starting order processing: {self.order_id}", "INFO") + + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.get_order", + params={"order_id": self.order_id}, + bind="order", + ) + + order = ctx.scalar("order", "status") + if order is None: + ctx.log(f"Order {self.order_id} not found", "ERROR") + ctx.abort("OrderProcessingProcedure", f"Order {self.order_id} not found") + + ctx.log(f"Order status: {order}", "DEBUG") + + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.check_inventory", + params={"order_id": self.order_id}, + bind="inventory", + ) + + available = ctx.scalar("inventory", "available") + if not available or available < 1: + ctx.log("Insufficient inventory, aborting", "WARNING") + ctx.abort("OrderProcessingProcedure", "Insufficient inventory") + + ctx.parallel( + ParallelStep( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.reserve_inventory", + params={"order_id": self.order_id}, + bind="reserved", + ), + ParallelStep( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.send_notification", + params={"user_id": self.user_id, "type": "order_started"}, + ), + max_concurrency=1, + ) + + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.process_payment", + params={"order_id": self.order_id, "amount": self.amount}, + bind="payment", + ) + + payment_status = ctx.scalar("payment", "status") + if payment_status != "success": + ctx.log(f"Payment failed: {payment_status}, rolling back inventory", "ERROR") + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.release_inventory", + params={"order_id": self.order_id}, + output=True, + ) + ctx.abort("OrderProcessingProcedure", f"Payment failed: {payment_status}") + + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.create_order_record", + params={ + "order_id": self.order_id, + "user_id": self.user_id, + "amount": self.amount, + }, + bind="order_record", + output=True, + ) + + ctx.execute( + "rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions.confirm_inventory", + params={"order_id": self.order_id}, + output=True, + ) + + ctx.log(f"Order {self.order_id} processing complete", "INFO") + + +# Demo: Generate static diagram +if __name__ == "__main__": + print("=== Order Processing Procedure ===\n") + print("Static Diagram (Flowchart):") + print(OrderProcessingProcedure.static_diagram("flowchart")) + print("\nStatic Diagram (Sequence):") + print(OrderProcessingProcedure.static_diagram("sequence")) + +# ============================================================ +# SECTION: Execution (run the expression) +# ============================================================ +from rhosocial.activerecord.backend.named_expression import ProcedureRunner, TransactionMode # noqa: E402 + +if __name__ == "__main__": + import os + + from rhosocial.activerecord.backend.impl.postgres import PostgresBackend, PostgresConnectionConfig + from rhosocial.activerecord.backend.impl.postgres.examples.named_expressions.order_expressions import ( + prepare_orders_demo, + ) + + config = PostgresConnectionConfig( + host=os.getenv("POSTGRES_HOST", "127.0.0.1"), + port=int(os.getenv("POSTGRES_PORT", "5432")), + username=os.getenv("POSTGRES_USER", "postgres"), + password=os.getenv("POSTGRES_PASSWORD", ""), + database=os.getenv("POSTGRES_DATABASE", "postgres"), + ) + backend = PostgresBackend(connection_config=config) + backend.connect() + + prepare_orders_demo(backend) + + runner = ProcedureRunner( + "rhosocial.activerecord.backend.impl.postgres.examples.named_procedures.order_workflow.OrderProcessingProcedure" + ).load() + + result = runner.run( + backend, + user_params={"order_id": 1, "user_id": 100, "amount": 99.99}, + transaction_mode=TransactionMode.AUTO, + ) + + print(f"Procedure completed. Aborted: {result.aborted}") + if result.aborted: + print(f"Abort reason: {result.abort_reason}") + for log in result.logs: + print(f"[{log.level}] {log.message}") + + # ============================================================ + # SECTION: Teardown (necessary for execution, reference only) + # ============================================================ + backend.disconnect() \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py index 2c7241df..14f4b8b9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/pgq/recursive_cte_graph_traversal.py @@ -57,7 +57,7 @@ from rhosocial.activerecord.backend.expression.query_parts import ( WhereClause, OrderByClause, - JoinExpression, + JoinClause, ) from rhosocial.activerecord.backend.options import ExecutionOptions from rhosocial.activerecord.backend.schema import StatementType @@ -164,7 +164,7 @@ ) # --- Recursive query: follow edges one hop, depth+1, bound depth<4 --- -recursive_join = JoinExpression( +recursive_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, 'traversal', alias='t'), right_table=TableExpression(dialect, 'follows', alias='f'), @@ -243,7 +243,7 @@ print("=" * 72) # --- Base query: direct sources sending to target account ACC-001 --- -aml_base_join = JoinExpression( +aml_base_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, 'transactions', alias='tx'), right_table=TableExpression(dialect, 'accounts', alias='a'), @@ -265,7 +265,7 @@ ) # --- Recursive query: trace upstream along the transaction chain --- -aml_recursive_join = JoinExpression( +aml_recursive_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, 'fund_trace', alias='tr'), right_table=TableExpression(dialect, 'transactions', alias='tx'), @@ -374,7 +374,7 @@ 1. SetOperationExpression(operation='UNION ALL') combines base + recursive queries 2. CTEExpression(name=..., query=..., columns=...) defines the CTE column signature 3. WithQueryExpression(recursive=True) generates WITH RECURSIVE -4. Recursive query joins the CTE reference with physical tables via chained JoinExpression +4. Recursive query joins the CTE reference with physical tables via chained JoinClause 5. depth field simulates the hop count of quantified path patterns 6. WHERE depth BETWEEN x AND y simulates {x,y} range constraint 7. Use cases: social recommendations, fund tracing, knowledge graph traversal diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py index 5b0583c7..3ee9ea7d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/array_type_diff.py @@ -47,9 +47,9 @@ # One-dimensional array column scores_v1_table = CreateTableExpression( dialect=dialect, table="scores_v1", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("values", ArrayType(IntegerType(), dimensions=1)), + ColumnDefinition(dialect, "values", ArrayType(dialect, element_type=IntegerType(dialect), dimensions=1)), ] ) sql, params = scores_v1_table.to_sql() @@ -58,9 +58,9 @@ # Two-dimensional array column scores_v2_table = CreateTableExpression( dialect=dialect, table="scores_v2", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("values", ArrayType(IntegerType(), dimensions=2)), + ColumnDefinition(dialect, "values", ArrayType(dialect, element_type=IntegerType(dialect), dimensions=2)), ] ) sql, params = scores_v2_table.to_sql() @@ -102,7 +102,7 @@ # V1: 1-d array col1d = ColumnInfo( name="values", table_name="t", ordinal_position=1, - data_type="INTEGER[]", parsed_data_type=ArrayType(IntegerType(), dimensions=1), + data_type="INTEGER[]", parsed_data_type=ArrayType(element_type=IntegerType(), dimensions=1), nullable=ColumnNullable.NULLABLE, ) snap1d = snapshot.__class__( @@ -115,7 +115,7 @@ # V2: 2-d array col2d = ColumnInfo( name="values", table_name="t", ordinal_position=1, - data_type="INTEGER[][]", parsed_data_type=ArrayType(IntegerType(), dimensions=2), + data_type="INTEGER[][]", parsed_data_type=ArrayType(element_type=IntegerType(), dimensions=2), nullable=ColumnNullable.NULLABLE, ) snap2d = snapshot.__class__( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py index 1015e988..86b9f629 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/column_order_irrelevant.py @@ -46,10 +46,10 @@ demo_table = CreateTableExpression( dialect=dialect, table="demo", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), - ColumnDefinition("email", VarCharType(200)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=200)), ] ) sql, params = demo_table.to_sql() @@ -71,7 +71,7 @@ # Add a column between name and email — shifts ordinal positions # but PostgresSchemaDiffer ignores ordinal_position. add_age = AlterTableExpression(dialect, "demo", [ - AddColumn(dialect, ColumnDefinition("age", IntegerType())) + AddColumn(dialect, ColumnDefinition(dialect, "age", IntegerType(dialect))) ]) sql, params = add_age.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py index eb2cc5d2..1c8598eb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/index_type_diff.py @@ -46,10 +46,10 @@ demo_table = CreateTableExpression( dialect=dialect, table="demo", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(200)), - ColumnDefinition("tags", ArrayType(TextType(), dimensions=1)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=200)), + ColumnDefinition(dialect, "tags", ArrayType(dialect, element_type=TextType(dialect), dimensions=1)), ] ) sql, params = demo_table.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py index 7075b33e..b1d11b9d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/postgres10_identity.py @@ -52,9 +52,9 @@ # PostgreSQL 9-compatible: SERIAL shorthand pg9_table = CreateTableExpression( dialect=dialect, table="pg9_style", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] ) sql, params = pg9_table.to_sql() @@ -66,10 +66,10 @@ try: pg10_table = CreateTableExpression( dialect=dialect, table="pg10_style", columns=[ - ColumnDefinition("id", IntegerType(), + ColumnDefinition(dialect, "id", IntegerType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)], dialect_options={"identity": "ALWAYS"}), - ColumnDefinition("name", VarCharType(100)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), ] ) sql, params = pg10_table.to_sql() diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py index fb41ddd0..c138e480 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/serialization_roundtrip.py @@ -46,10 +46,10 @@ users_table = CreateTableExpression( dialect=dialect, table="users", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), - ColumnDefinition("email", VarCharType(200)), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100)), + ColumnDefinition(dialect, "email", VarCharType(dialect, length=200)), ] ) sql, params = users_table.to_sql() @@ -75,7 +75,7 @@ # Modify the database add_phone = AlterTableExpression(dialect, "users", [ - AddColumn(dialect, ColumnDefinition("phone", VarCharType(20))) + AddColumn(dialect, ColumnDefinition(dialect, "phone", VarCharType(dialect, length=20))) ]) sql, params = add_phone.to_sql() backend.execute(sql, params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py index e9665e0b..e6cc2703 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/schema_diff/table_changes.py @@ -58,9 +58,9 @@ users_table = CreateTableExpression( dialect=dialect, table="users", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100), + ColumnDefinition(dialect, "name", VarCharType(dialect, length=100), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.NOT_NULL)]), ] ) @@ -69,10 +69,10 @@ orders_table = CreateTableExpression( dialect=dialect, table="orders", columns=[ - ColumnDefinition("id", PostgresSerialType(), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect), constraints=[ColumnConstraint(constraint_type=ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType()), - ColumnDefinition("amount", DecimalType(10, 2)), + ColumnDefinition(dialect, "user_id", IntegerType(dialect)), + ColumnDefinition(dialect, "amount", DecimalType(dialect, precision=10, scale=2)), ], table_constraints=[ TableConstraint( diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 14200826..fbae29cd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -19,6 +19,7 @@ AdvisoryLockType, ) from .locking import LockStrength +from .ilike import ILIKEExpression from .ddl import ( PostgresVacuumExpression, PostgresAnalyzeExpression, @@ -93,6 +94,8 @@ PostgresUUIDType, PostgresVarBitType, PostgresVectorType, + PostgresHalfvecType, + PostgresSparsevecType, PostgresXID8Type, PostgresXIDType, PostgresXMLType, @@ -108,6 +111,8 @@ "AdvisoryLockType", # locking "LockStrength", + # ilike + "ILIKEExpression", # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", @@ -179,6 +184,8 @@ "PostgresUUIDType", "PostgresVarBitType", "PostgresVectorType", + "PostgresHalfvecType", + "PostgresSparsevecType", "PostgresXID8Type", "PostgresXIDType", "PostgresXMLType", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py index d42f20b0..d49cd207 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/advisory/lock.py @@ -85,15 +85,10 @@ def __init__( self.shared = shared self.session = session - def to_sql(self) -> SQLQueryAndParams: - """ - Generate SQL for acquiring advisory lock. - - Returns: - Tuple of (SQL string, parameters tuple) - - """ - return self.dialect.format_advisory_lock(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_advisory_lock" class PostgresAdvisoryUnlockExpression(BaseExpression): @@ -133,15 +128,10 @@ def __init__( self.key = key self.shared = shared - def to_sql(self) -> SQLQueryAndParams: - """ - Generate SQL for releasing advisory lock. - - Returns: - Tuple of (SQL string, parameters tuple) - - """ - return self.dialect.format_advisory_unlock(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_advisory_unlock" class PostgresAdvisoryUnlockAllExpression(BaseExpression): @@ -157,15 +147,10 @@ def __init__(self, dialect: "SQLDialectBase"): """Initialize advisory unlock all expression.""" super().__init__(dialect) - def to_sql(self) -> SQLQueryAndParams: - """ - Generate SQL for releasing all advisory locks. - - Returns: - Tuple of (SQL string, parameters tuple) - - """ - return self.dialect.format_advisory_unlock_all(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_advisory_unlock_all" class PostgresTryAdvisoryLockExpression(BaseExpression): @@ -211,12 +196,7 @@ def __init__( self.shared = shared self.session = session - def to_sql(self) -> SQLQueryAndParams: - """ - Generate SQL for non-blocking advisory lock acquisition. - - Returns: - Tuple of (SQL string, parameters tuple) - - """ - return self.dialect.format_try_advisory_lock(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_try_advisory_lock" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py index 1c4be6a0..2f45fd40 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/__init__.py @@ -85,8 +85,17 @@ PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, PostgresCreateRangeTypeExpression, + EnumTypeNameExpression, + EnumValuesExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, ) from .extension import PostgresCreateExtensionExpression, PostgresDropExtensionExpression +from .multirange import ( + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) from .pg_partman import ( PostgresPgPartmanCreateParentExpression, PostgresPgPartmanRunMaintenanceExpression, @@ -166,6 +175,14 @@ "PostgresAlterEnumTypeAddValueExpression", "PostgresAlterEnumTypeRenameValueExpression", "PostgresCreateRangeTypeExpression", + "EnumTypeNameExpression", + "EnumValuesExpression", + "CreateEnumTypeExpression", + "DropEnumTypeExpression", + "AlterEnumAddValueExpression", + # multirange + "CreateMultirangeTypeExpression", + "MultirangeAggFunctionExpression", # extension "PostgresCreateExtensionExpression", "PostgresDropExtensionExpression", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py index 91b80782..3b0999e3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/cluster.py @@ -59,11 +59,7 @@ def __init__( self.using_index = using_index self.verbose = verbose - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CLUSTER statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_cluster_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_cluster_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py index 5427436c..85c2e009 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/collation.py @@ -64,14 +64,10 @@ def __init__( self.if_not_exists = if_not_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CREATE COLLATION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_collation_ddl_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_collation_ddl_statement" class PostgresDropCollationExpression(BaseExpression): @@ -103,11 +99,7 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP COLLATION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_collation_ddl_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_collation_ddl_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py index d3301a74..f1025d50 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/comment.py @@ -83,11 +83,7 @@ def __init__( self.schema = schema self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate COMMENT ON SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_comment_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_comment_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py index 5c155ced..cf2f462b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/domain.py @@ -73,14 +73,10 @@ def __init__( self.constraints = constraints or [] self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate CREATE DOMAIN SQL statement. - - Returns: - Tuple of (SQL string, params tuple). - - """ - return self.dialect.format_create_domain_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_domain_statement" class PostgresAlterDomainExpression(BaseExpression): @@ -119,14 +115,10 @@ def __init__( self.new_name = new_name self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the ALTER DOMAIN SQL statement. - - Returns: - Tuple of (SQL string, params tuple). - - """ - return self.dialect.format_postgres_alter_domain_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_postgres_alter_domain_statement" class PostgresDropDomainExpression(BaseExpression): @@ -158,11 +150,7 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the DROP DOMAIN SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_domain_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_domain_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py index fa8fbc8c..0639a43b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/extension.py @@ -78,14 +78,10 @@ def __init__( self.cascade = cascade self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE EXTENSION SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_extension(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_extension" class PostgresDropExtensionExpression(BaseExpression): @@ -134,11 +130,7 @@ def __init__( self.restrict = restrict self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate DROP EXTENSION SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_extension(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_extension" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py index 66558f59..4231b97d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/foreign_table.py @@ -58,14 +58,10 @@ def __init__( self.if_not_exists = if_not_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CREATE FOREIGN TABLE statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_foreign_table_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_foreign_table_statement" class PostgresDropForeignTableExpression(BaseExpression): @@ -97,11 +93,7 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP FOREIGN TABLE statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_foreign_table_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_foreign_table_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py index 24118b79..9a5be396 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/index.py @@ -140,8 +140,10 @@ def __init__( self.nowait = nowait self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - return self.dialect.format_alter_index_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_index_statement" class PostgresReindexExpression(BaseExpression): @@ -211,11 +213,7 @@ def __init__( self.verbose = verbose self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate REINDEX SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_reindex_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_reindex_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py new file mode 100644 index 00000000..b9bd702b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py @@ -0,0 +1,104 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/multirange.py +"""PostgreSQL multirange type DDL expressions. + +Version Requirements: +- CREATE TYPE ... AS MULTIRANGE: PostgreSQL 14+ +- multirange_agg(): PostgreSQL 14+ +""" + +from typing import Any, Dict, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class CreateMultirangeTypeExpression(BaseExpression): + """Expression for CREATE TYPE ... AS MULTIRANGE statement. + + PostgreSQL automatically creates multirange types when you create + a range type. This expression is for explicit creation if needed. + + Attributes: + name: Multirange type name. + range_type: Associated range type. + schema: Optional schema name. + + Example: + >>> expr = CreateMultirangeTypeExpression( + ... dialect, name="my_multirange", range_type="my_range", + ... ) + >>> sql, params = expr.to_sql() + >>> sql + 'CREATE TYPE my_multirange AS MULTIRANGE (my_range)' + + Note: + Requires PostgreSQL 14+. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + range_type: str, + schema: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.range_type = range_type + self.schema = schema + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_multirange_type_statement" + + +class MultirangeAggFunctionExpression(BaseExpression): + """Expression for multirange_agg aggregate function call. + + Aggregates multiple ranges into a multirange. + + Attributes: + range_column: Range column to aggregate. + table_name: Table name. + where_clause: Optional WHERE clause. + schema: Optional schema name. + + Example: + >>> expr = MultirangeAggFunctionExpression( + ... dialect, range_column="period", table_name="events", + ... ) + >>> sql, params = expr.to_sql() + >>> sql + 'SELECT multirange_agg(period) FROM events' + + Note: + Requires PostgreSQL 14+. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + range_column: str, + table_name: str, + where_clause: Optional[str] = None, + schema: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.range_column = range_column + self.table_name = table_name + self.where_clause = where_clause + self.schema = schema + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_multirange_agg_function" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py index 489280c7..f904d52d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/mv.py @@ -96,11 +96,7 @@ def concurrently(self): """Alias for concurrent (backward compatibility).""" return self.concurrent - def to_sql(self) -> Tuple[str, tuple]: - """Generate REFRESH MATERIALIZED VIEW SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_refresh_materialized_view_pg_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_refresh_materialized_view_pg_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py index 8999a8b8..c71501fe 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/partition.py @@ -83,15 +83,10 @@ def __init__( ) self.value = value - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL for a partition bound value. - - Delegates to the dialect's format_partition_value() method. - - Returns: - Tuple of (SQL string, empty params tuple). - """ - return self.dialect.format_partition_value(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_value" class PostgresCreatePartitionExpression(BaseExpression): @@ -159,14 +154,10 @@ def __init__( self.if_not_exists = if_not_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE TABLE PARTITION SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_partition_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_partition_statement" class PostgresDetachPartitionExpression(BaseExpression): @@ -216,14 +207,10 @@ def __init__( self.finalize = finalize self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate DETACH PARTITION SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_detach_partition_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_detach_partition_statement" class PostgresAttachPartitionExpression(BaseExpression): @@ -280,17 +267,10 @@ def __init__( self.concurrently = concurrently self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate ATTACH PARTITION SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - Raises: - ValueError: If concurrently is used on PostgreSQL < 14. - - """ - return self.dialect.format_attach_partition_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_attach_partition_statement" class PostgresPartitionMetadataExpression(BaseExpression): @@ -319,6 +299,7 @@ def __init__( self.include_partitions = include_partitions self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate PostgreSQL partition metadata query SQL.""" - return self.dialect.format_partition_metadata_query(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_partition_metadata_query" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py index 0e08ae26..2026e3f3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/pg_partman.py @@ -82,13 +82,10 @@ def __init__( self.jobmon = jobmon self.schema = schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL for pg_partman create_parent(). - - Returns: - Tuple of (SELECT function_call SQL, params tuple). - """ - return self.dialect.format_pg_partman_create_parent(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pg_partman_create_parent" class PostgresPgPartmanRunMaintenanceExpression(BaseExpression): @@ -115,13 +112,10 @@ def __init__( self.parent_table = parent_table self.schema = schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL for pg_partman run_maintenance(). - - Returns: - Tuple of (SELECT function_call SQL, params tuple). - """ - return self.dialect.format_pg_partman_run_maintenance(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pg_partman_run_maintenance" class PostgresPgPartmanUpdateConfigExpression(BaseExpression): @@ -167,16 +161,10 @@ def __init__( self.retention_keep_index = retention_keep_index self.schema = schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL for updating pg_partman part_config. - - Returns: - Tuple of (UPDATE SQL, params tuple). - - Raises: - ValueError: If no config options are specified. - """ - return self.dialect.format_pg_partman_update_config(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pg_partman_update_config" class PostgresPgPartmanDeleteConfigExpression(BaseExpression): @@ -202,10 +190,7 @@ def __init__( self.parent_table = parent_table self.schema = schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL for deleting a pg_partman part_config row. - - Returns: - Tuple of (DELETE SQL, params tuple). - """ - return self.dialect.format_pg_partman_delete_config(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_pg_partman_delete_config" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py index 42b907e2..2feabfbd 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/policy.py @@ -126,14 +126,10 @@ def __init__( self.using = using self.with_check = with_check - def to_sql(self) -> "SQLQueryAndParams": - """Generate CREATE POLICY SQL statement. - - Returns: - Tuple of (SQL string, params tuple). - - """ - return self.dialect.format_create_policy_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_policy_statement" class PostgresAlterPolicyExpression(BaseExpression): @@ -192,14 +188,10 @@ def mode(self) -> AlterPolicyMode: return AlterPolicyMode.RENAME return AlterPolicyMode.REPLACE - def to_sql(self) -> "SQLQueryAndParams": - """Generate ALTER POLICY SQL statement. - - Returns: - Tuple of (SQL string, params tuple). - - """ - return self.dialect.format_alter_policy_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_policy_statement" class PostgresDropPolicyExpression(BaseExpression): @@ -239,11 +231,7 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "SQLQueryAndParams": - """Generate DROP POLICY SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_policy_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_policy_statement" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py index 3ce661a1..a6a8024f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/publication.py @@ -61,14 +61,10 @@ def __init__( self.options = options or [] self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CREATE PUBLICATION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_publication_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_publication_statement" class PostgresDropPublicationExpression(BaseExpression): @@ -94,14 +90,10 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP PUBLICATION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_publication_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_publication_statement" class PostgresCreateSubscriptionExpression(BaseExpression): @@ -132,14 +124,10 @@ def __init__( self.options = options or [] self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CREATE SUBSCRIPTION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_subscription_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_subscription_statement" class PostgresDropSubscriptionExpression(BaseExpression): @@ -164,11 +152,7 @@ def __init__( self.if_exists = if_exists self.cascade = cascade - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP SUBSCRIPTION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_subscription_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_subscription_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py index 92b36c8c..133fd743 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/rls_config.py @@ -83,14 +83,10 @@ def __init__( self.mode = mode self.always = always - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the ALTER TABLE ... ROW LEVEL SECURITY statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_alter_table_rls_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_table_rls_statement" class PostgresForceRlsExpression(BaseExpression): @@ -120,11 +116,7 @@ def __init__( self.schema = schema self.force = force - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the FORCE/NO FORCE ROW LEVEL SECURITY statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_force_rls_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_force_rls_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py index 00d41ec1..ca7cf395 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/routine.py @@ -82,14 +82,10 @@ def __init__( self.strict = strict self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the CREATE FUNCTION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_function_ddl_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_function_ddl_statement" class PostgresDropFunctionExpression(BaseExpression): @@ -125,14 +121,10 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP FUNCTION statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_function_ddl_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_function_ddl_statement" class PostgresCreateAggregateExpression(BaseExpression): @@ -169,14 +161,10 @@ def __init__( self.initcond = initcond self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Return the CREATE AGGREGATE statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_aggregate_ddl_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_aggregate_ddl_statement" class PostgresDropAggregateExpression(BaseExpression): @@ -212,11 +200,7 @@ def __init__( self.cascade = cascade self.restrict = restrict - def to_sql(self) -> "Tuple[str, tuple]": - """Return the DROP AGGREGATE statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_aggregate_ddl_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_aggregate_ddl_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py index 9c625ad4..afdd2cd9 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/statistics.py @@ -85,14 +85,10 @@ def __init__( self.if_not_exists = if_not_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE STATISTICS SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_statistics_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_statistics_statement" class PostgresDropStatisticsExpression(BaseExpression): @@ -133,11 +129,7 @@ def __init__( self.if_exists = if_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate DROP STATISTICS SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_statistics_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_statistics_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py index a763afdd..67142b67 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/table_settings.py @@ -80,11 +80,7 @@ def __init__( self.access_method = access_method self.dialect_options = dialect_options or {} - def to_sql(self) -> "Tuple[str, tuple]": - """Generate the ALTER TABLE ... SET statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_alter_table_settings_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_table_settings_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py index 1bf99059..728cd154 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/type.py @@ -32,6 +32,12 @@ "PostgresAlterEnumTypeRenameValueExpression", # Range type "PostgresCreateRangeTypeExpression", + # Format-signature-compliant enum expressions + "EnumTypeNameExpression", + "EnumValuesExpression", + "CreateEnumTypeExpression", + "DropEnumTypeExpression", + "AlterEnumAddValueExpression", ] @@ -91,14 +97,10 @@ def __init__( self.if_not_exists = if_not_exists self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE TYPE AS ENUM SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_create_enum_type(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_enum_type" class PostgresDropEnumTypeExpression(BaseExpression): @@ -151,14 +153,10 @@ def __init__( self.cascade = cascade self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate DROP TYPE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_drop_enum_type(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_enum_type" class PostgresAlterEnumAddValueExpression(BaseExpression): @@ -215,14 +213,10 @@ def __init__( self.after = after self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate ALTER TYPE ADD VALUE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_alter_enum_add_value(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_enum_add_value" class PostgresAlterEnumTypeAddValueExpression(BaseExpression): @@ -265,14 +259,10 @@ def __init__( self.before = before self.after = after - def to_sql(self) -> Tuple[str, tuple]: - """Generate ALTER TYPE ADD VALUE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_alter_enum_type_add_value(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_enum_type_add_value" class PostgresAlterEnumTypeRenameValueExpression(BaseExpression): @@ -312,14 +302,10 @@ def __init__( self.new_value = new_value self.schema = schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate ALTER TYPE RENAME VALUE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_alter_enum_type_rename_value(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_enum_type_rename_value" class PostgresCreateRangeTypeExpression(BaseExpression): @@ -390,11 +376,204 @@ def __init__( self.subtype_diff = subtype_diff self.if_not_exists = if_not_exists - def to_sql(self) -> Tuple[str, tuple]: - """Generate CREATE TYPE AS RANGE SQL statement. + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_range_type" + + +# ============================================================================= +# Format-signature-compliant enum expressions +# ============================================================================= + + +class EnumTypeNameExpression(BaseExpression): + """Expression for formatting an enum type name with optional schema. + + Attributes: + name: Enum type name. + schema: Optional schema name. + + Example: + >>> expr = EnumTypeNameExpression(dialect, name="status", schema="app") + >>> sql, params = expr.to_sql() + >>> sql + 'app.status' + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_enum_type_name" + + +class EnumValuesExpression(BaseExpression): + """Expression for formatting a list of enum values for SQL. + + Attributes: + values: List of enum value strings. + + Example: + >>> expr = EnumValuesExpression(dialect, values=["a", "b", "c"]) + >>> sql, params = expr.to_sql() + >>> sql + "'a', 'b', 'c'" + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + values: List[str], + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.values = values + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_enum_values" + + +class CreateEnumTypeExpression(BaseExpression): + """Expression for CREATE TYPE ... AS ENUM statement. + + Attributes: + name: Enum type name. + values: List of allowed values. + schema: Optional schema name. + if_not_exists: Add IF NOT EXISTS clause. + + Example: + >>> expr = CreateEnumTypeExpression( + ... dialect, name="status", values=["active", "inactive"], + ... ) + >>> sql, params = expr.to_sql() + >>> sql + "CREATE TYPE status AS ENUM ('active', 'inactive')" - Returns: - Tuple of (SQL string, empty params tuple). + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + values: List[str], + schema: Optional[str] = None, + if_not_exists: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.values = values + self.schema = schema + self.if_not_exists = if_not_exists + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_create_enum_type_raw" + + +class DropEnumTypeExpression(BaseExpression): + """Expression for DROP TYPE statement. + + Attributes: + name: Enum type name. + schema: Optional schema name. + if_exists: Add IF EXISTS clause. + cascade: Add CASCADE clause. + + Example: + >>> expr = DropEnumTypeExpression(dialect, name="status") + >>> sql, params = expr.to_sql() + >>> sql + "DROP TYPE status" + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + schema: Optional[str] = None, + if_exists: bool = False, + cascade: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.name = name + self.schema = schema + self.if_exists = if_exists + self.cascade = cascade + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_drop_enum_type_raw" + + +class AlterEnumAddValueExpression(BaseExpression): + """Expression for ALTER TYPE ... ADD VALUE statement. + + Attributes: + type_name: Enum type name. + new_value: New value to add. + schema: Optional schema name. + before: Add before this existing value. + after: Add after this existing value. + + Example: + >>> expr = AlterEnumAddValueExpression( + ... dialect, type_name="status", new_value="archived", + ... ) + >>> sql, params = expr.to_sql() + >>> sql + "ALTER TYPE status ADD VALUE 'archived'" + + """ + + def __init__( + self, + dialect: "SQLDialectBase", + type_name: str, + new_value: str, + schema: Optional[str] = None, + before: Optional[str] = None, + after: Optional[str] = None, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.type_name = type_name + self.new_value = new_value + self.schema = schema + self.before = before + self.after = after + self.dialect_options = dialect_options or {} - """ - return self.dialect.format_create_range_type(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_alter_enum_add_value_raw" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py index 18062612..0a28b660 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ddl/vacuum.py @@ -101,14 +101,10 @@ def __init__( self.columns = columns self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate VACUUM SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_vacuum_statement(self) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_vacuum_statement" class PostgresAnalyzeExpression(BaseExpression): @@ -164,11 +160,7 @@ def __init__( self.columns = columns self.dialect_options = dialect_options or {} - def to_sql(self) -> Tuple[str, tuple]: - """Generate ANALYZE SQL statement. - - Returns: - Tuple of (SQL string, empty params tuple). - - """ - return self.dialect.format_analyze_statement(self) \ No newline at end of file + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_analyze_statement" \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py new file mode 100644 index 00000000..753496ef --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py @@ -0,0 +1,51 @@ +# src/rhosocial/activerecord/backend/impl/postgres/expression/ilike.py +"""PostgreSQL ILIKE expression class.""" + +from typing import Any, Dict, Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.bases import BaseExpression + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +class ILIKEExpression(BaseExpression): + """PostgreSQL ILIKE expression for case-insensitive pattern matching. + + Attributes: + column: Column name or expression with ``to_sql()``. + pattern: Pattern string (replaced with ``%s`` placeholder). + negate: If True, produces ``NOT ILIKE`` instead of ``ILIKE``. + + Example: + >>> expr = ILIKEExpression(dialect, column="name", pattern="%foo%") + >>> sql, params = expr.to_sql() + >>> sql + '"name" ILIKE %s' + >>> params + ('%foo%',) + + Note: + Requires PostgreSQL backend. The ``ILIKE`` operator is + PostgreSQL-specific and not portable to other databases. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + column: Any, + pattern: str, + negate: bool = False, + *, + dialect_options: Optional[Dict[str, Any]] = None, + ): + super().__init__(dialect) + self.column = column + self.pattern = pattern + self.negate = negate + self.dialect_options = dialect_options or {} + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_ilike_expression" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index 53d43944..c2b99f89 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import Optional, Set +from typing import Any, Dict, Optional, Set, TYPE_CHECKING from rhosocial.activerecord.backend.expression.types import ( ArrayType, @@ -26,14 +26,18 @@ VarCharType, ) +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + # --------------------------------------------------------------------------- # Character varying alias # --------------------------------------------------------------------------- -class PostgresCharacterVaryingType(VarCharType, backend="postgres"): +class PostgresCharacterVaryingType(VarCharType): """PostgreSQL ``CHARACTER VARYING(n)`` — alias for ``VARCHAR(n)``.""" + name = "postgres_character_varying" @classmethod def synonyms(cls) -> Set[str]: return {'VarCharType'} @@ -43,9 +47,10 @@ def synonyms(cls) -> Set[str]: # Binary data: BYTEA # --------------------------------------------------------------------------- -class PostgresByteaType(BlobType, backend="postgres"): +class PostgresByteaType(BlobType): """PostgreSQL ``BYTEA`` — variable-length binary string.""" + name = "postgres_bytea" @classmethod def synonyms(cls) -> Set[str]: return {'BlobType'} @@ -55,25 +60,28 @@ def synonyms(cls) -> Set[str]: # Serial (auto-increment) types # --------------------------------------------------------------------------- -class PostgresSmallSerialType(DataType, backend="postgres"): +class PostgresSmallSerialType(DataType): """PostgreSQL ``SMALLSERIAL`` — auto-incrementing SMALLINT (2 bytes).""" + name = "postgres_smallserial" @classmethod def synonyms(cls) -> Set[str]: return {'SmallSerialType', 'SmallIntType'} -class PostgresSerialType(DataType, backend="postgres"): +class PostgresSerialType(DataType): """PostgreSQL ``SERIAL`` — auto-incrementing INTEGER (4 bytes).""" + name = "postgres_serial" @classmethod def synonyms(cls) -> Set[str]: return {'SerialType', 'IntegerType'} -class PostgresBigSerialType(DataType, backend="postgres"): +class PostgresBigSerialType(DataType): """PostgreSQL ``BIGSERIAL`` — auto-incrementing BIGINT (8 bytes).""" + name = "postgres_bigserial" @classmethod def synonyms(cls) -> Set[str]: return {'BigSerialType', 'BigIntType'} @@ -83,289 +91,324 @@ def synonyms(cls) -> Set[str]: # UUID # --------------------------------------------------------------------------- -class PostgresUUIDType(DataType, backend="postgres"): +class PostgresUUIDType(DataType): """PostgreSQL ``UUID`` — universally unique identifier.""" - + name = "postgres_uuid" # --------------------------------------------------------------------------- # XML # --------------------------------------------------------------------------- -class PostgresXMLType(DataType, backend="postgres"): +class PostgresXMLType(DataType): """PostgreSQL ``XML`` — XML data type.""" - + name = "postgres_xml" # --------------------------------------------------------------------------- # Text search # --------------------------------------------------------------------------- -class PostgresTSVectorType(DataType, backend="postgres"): +class PostgresTSVectorType(DataType): """PostgreSQL ``TSVECTOR`` — text search document.""" - -class PostgresTSQueryType(DataType, backend="postgres"): + name = "postgres_tsvector" +class PostgresTSQueryType(DataType): """PostgreSQL ``TSQUERY`` — text search query.""" - + name = "postgres_tsquery" # --------------------------------------------------------------------------- # JSON path # --------------------------------------------------------------------------- -class PostgresJsonPathType(DataType, backend="postgres"): +class PostgresJsonPathType(DataType): """PostgreSQL ``JSONPATH`` — SQL/JSON path expression (PG 12+).""" - + name = "postgres_jsonpath" # --------------------------------------------------------------------------- # Bit string types # --------------------------------------------------------------------------- -class PostgresBitType(DataType, backend="postgres"): +class PostgresBitType(DataType): """PostgreSQL ``BIT(n)`` — fixed-length bit string.""" + name = "postgres_bit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None): - super().__init__() + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) self.n = n - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.n == other.n - - def __hash__(self) -> int: - return hash((type(self), self.n)) + def _type_params(self) -> tuple: + return (self.n,) -class PostgresVarBitType(DataType, backend="postgres"): +class PostgresVarBitType(DataType): """PostgreSQL ``VARBIT(n)`` — variable-length bit string.""" + name = "postgres_varbit" n: Optional[int] = None - def __init__(self, n: Optional[int] = None): - super().__init__() + def __init__(self, n: Optional[int] = None, dialect: Optional["SQLDialectBase"] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) self.n = n - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.n == other.n - - def __hash__(self) -> int: - return hash((type(self), self.n)) + def _type_params(self) -> tuple: + return (self.n,) # --------------------------------------------------------------------------- # Network address types # --------------------------------------------------------------------------- -class PostgresInetType(DataType, backend="postgres"): +class PostgresInetType(DataType): """PostgreSQL ``INET`` — IPv4 or IPv6 address.""" - -class PostgresCidrType(DataType, backend="postgres"): + name = "postgres_inet" +class PostgresCidrType(DataType): """PostgreSQL ``CIDR`` — IPv4 or IPv6 network.""" - -class PostgresMacAddrType(DataType, backend="postgres"): + name = "postgres_cidr" +class PostgresMacAddrType(DataType): """PostgreSQL ``MACADDR`` — MAC address (EUI-48).""" - -class PostgresMacAddr8Type(DataType, backend="postgres"): + name = "postgres_macaddr" +class PostgresMacAddr8Type(DataType): """PostgreSQL ``MACADDR8`` — MAC address (EUI-64, PG 10+).""" - + name = "postgres_macaddr8" # --------------------------------------------------------------------------- # Geometric types # --------------------------------------------------------------------------- -class PostgresPointType(DataType, backend="postgres"): +class PostgresPointType(DataType): """PostgreSQL ``POINT`` — geometric point (x, y).""" - -class PostgresLineType(DataType, backend="postgres"): + name = "postgres_point" +class PostgresLineType(DataType): """PostgreSQL ``LINE`` — infinite line.""" - -class PostgresLineSegmentType(DataType, backend="postgres"): + name = "postgres_line" +class PostgresLineSegmentType(DataType): """PostgreSQL ``LSEG`` — line segment.""" - -class PostgresBoxType(DataType, backend="postgres"): + name = "postgres_line_segment" +class PostgresBoxType(DataType): """PostgreSQL ``BOX`` — rectangular box.""" - -class PostgresPathType(DataType, backend="postgres"): + name = "postgres_box" +class PostgresPathType(DataType): """PostgreSQL ``PATH`` — open or closed geometric path.""" - -class PostgresPolygonType(DataType, backend="postgres"): + name = "postgres_path" +class PostgresPolygonType(DataType): """PostgreSQL ``POLYGON`` — closed geometric polygon.""" - -class PostgresCircleType(DataType, backend="postgres"): + name = "postgres_polygon" +class PostgresCircleType(DataType): """PostgreSQL ``CIRCLE`` — circle (center + radius).""" - + name = "postgres_circle" # --------------------------------------------------------------------------- # Monetary type # --------------------------------------------------------------------------- -class PostgresMoneyType(DataType, backend="postgres"): +class PostgresMoneyType(DataType): """PostgreSQL ``MONEY`` — currency amount.""" - + name = "postgres_money" # --------------------------------------------------------------------------- # Range types # --------------------------------------------------------------------------- -class PostgresInt4RangeType(DataType, backend="postgres"): +class PostgresInt4RangeType(DataType): """PostgreSQL ``INT4RANGE`` — range of integer.""" - -class PostgresInt8RangeType(DataType, backend="postgres"): + name = "postgres_int4range" +class PostgresInt8RangeType(DataType): """PostgreSQL ``INT8RANGE`` — range of bigint.""" - -class PostgresNumRangeType(DataType, backend="postgres"): + name = "postgres_int8range" +class PostgresNumRangeType(DataType): """PostgreSQL ``NUMRANGE`` — range of numeric.""" - -class PostgresTsRangeType(DataType, backend="postgres"): + name = "postgres_numrange" +class PostgresTsRangeType(DataType): """PostgreSQL ``TSRANGE`` — range of timestamp without time zone.""" - -class PostgresTsTzRangeType(DataType, backend="postgres"): + name = "postgres_tsrange" +class PostgresTsTzRangeType(DataType): """PostgreSQL ``TSTZRANGE`` — range of timestamp with time zone.""" - -class PostgresDateRangeType(DataType, backend="postgres"): + name = "postgres_tstzrange" +class PostgresDateRangeType(DataType): """PostgreSQL ``DATERANGE`` — range of date.""" - + name = "postgres_daterange" # --------------------------------------------------------------------------- # Multirange types (PG 14+) # --------------------------------------------------------------------------- -class PostgresInt4MultirangeType(DataType, backend="postgres"): +class PostgresInt4MultirangeType(DataType): """PostgreSQL ``INT4MULTIRANGE`` — multirange of integer (PG 14+).""" - -class PostgresInt8MultirangeType(DataType, backend="postgres"): + name = "postgres_int4multirange" +class PostgresInt8MultirangeType(DataType): """PostgreSQL ``INT8MULTIRANGE`` — multirange of bigint (PG 14+).""" - -class PostgresNumMultirangeType(DataType, backend="postgres"): + name = "postgres_int8multirange" +class PostgresNumMultirangeType(DataType): """PostgreSQL ``NUMMULTIRANGE`` — multirange of numeric (PG 14+).""" - -class PostgresTsMultirangeType(DataType, backend="postgres"): + name = "postgres_nummultirange" +class PostgresTsMultirangeType(DataType): """PostgreSQL ``TSMULTIRANGE`` — multirange of timestamp (PG 14+).""" - -class PostgresTsTzMultirangeType(DataType, backend="postgres"): + name = "postgres_tsmultirange" +class PostgresTsTzMultirangeType(DataType): """PostgreSQL ``TSTZMULTIRANGE`` — multirange of timestamptz (PG 14+).""" - -class PostgresDateMultirangeType(DataType, backend="postgres"): + name = "postgres_tstzmultirange" +class PostgresDateMultirangeType(DataType): """PostgreSQL ``DATEMULTIRANGE`` — multirange of date (PG 14+).""" - + name = "postgres_datemultirange" # --------------------------------------------------------------------------- # Object identifier types # --------------------------------------------------------------------------- -class PostgresOIDType(DataType, backend="postgres"): +class PostgresOIDType(DataType): """PostgreSQL ``OID`` — object identifier.""" - -class PostgresRegClassType(DataType, backend="postgres"): + name = "postgres_oid" +class PostgresRegClassType(DataType): """PostgreSQL ``REGCLASS`` — relation name (OID alias).""" - -class PostgresRegTypeType(DataType, backend="postgres"): + name = "postgres_regclass" +class PostgresRegTypeType(DataType): """PostgreSQL ``REGTYPE`` — type name (OID alias).""" - -class PostgresXIDType(DataType, backend="postgres"): + name = "postgres_regtype" +class PostgresXIDType(DataType): """PostgreSQL ``XID`` — transaction ID.""" - -class PostgresXID8Type(DataType, backend="postgres"): + name = "postgres_xid" +class PostgresXID8Type(DataType): """PostgreSQL ``XID8`` — 64-bit transaction ID (PG 13+).""" - -class PostgresCIDType(DataType, backend="postgres"): + name = "postgres_xid8" +class PostgresCIDType(DataType): """PostgreSQL ``CID`` — command ID.""" - -class PostgresTIDType(DataType, backend="postgres"): + name = "postgres_cid" +class PostgresTIDType(DataType): """PostgreSQL ``TID`` — tuple ID (page, tuple).""" - + name = "postgres_tid" # --------------------------------------------------------------------------- # pg_lsn type # --------------------------------------------------------------------------- -class PostgresPgLSNType(DataType, backend="postgres"): +class PostgresPgLSNType(DataType): """PostgreSQL ``PG_LSN`` — WAL log sequence number.""" - + name = "postgres_pg_lsn" # --------------------------------------------------------------------------- # Extension-provided types (minimal DataType wrappers) # --------------------------------------------------------------------------- -class PostgresHstoreType(DataType, backend="postgres"): +class PostgresHstoreType(DataType): """PostgreSQL ``HSTORE`` — key-value store (hstore extension).""" - -class PostgresGeometryType(DataType, backend="postgres"): + name = "postgres_hstore" +class PostgresGeometryType(DataType): """PostGIS ``GEOMETRY`` — generic spatial geometry (PostGIS extension). For production use, subclass with SRID support as needed. """ - -class PostgresGeographyType(DataType, backend="postgres"): + name = "postgres_geometry" +class PostgresGeographyType(DataType): """PostGIS ``GEOGRAPHY`` — geodetic spatial type (PostGIS extension).""" - -class PostgresCitextType(DataType, backend="postgres"): + name = "postgres_geography" +class PostgresCitextType(DataType): """PostgreSQL ``CITEXT`` — case-insensitive text (citext extension).""" - -class PostgresCubeType(DataType, backend="postgres"): + name = "postgres_citext" +class PostgresCubeType(DataType): """PostgreSQL ``CUBE`` — multi-dimensional cube (cube extension).""" - -class PostgresLtreeType(DataType, backend="postgres"): + name = "postgres_cube" +class PostgresLtreeType(DataType): """PostgreSQL ``LTREE`` — label tree (ltree extension).""" - -class PostgresRasterType(DataType, backend="postgres"): + name = "postgres_ltree" +class PostgresRasterType(DataType): """PostgreSQL ``RASTER`` — raster (PostGIS raster extension).""" - -class PostgresVectorType(DataType, backend="postgres"): + name = "postgres_raster" +class PostgresVectorType(DataType): """pgvector ``VECTOR(n)`` — vector embedding (pgvector extension). Args: dim: Number of dimensions. """ + name = "postgres_vector" dim: int - def __init__(self, dim: int): - super().__init__() + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) self.dim = dim - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - return self.dim == other.dim + def _type_params(self) -> tuple: + return (self.dim,) + + +class PostgresHalfvecType(DataType): + """pgvector ``HALFVEC(n)`` — half-precision vector (pgvector 0.5.0+). + + Stores each component as a half-precision float, halving memory compared + to ``VECTOR``. Requires the pgvector extension. + + Args: + dim: Number of dimensions. + """ + + name = "postgres_halfvec" + dim: int + + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) + self.dim = dim + + def _type_params(self) -> tuple: + return (self.dim,) + + +class PostgresSparsevecType(DataType): + """pgvector ``SPARSEVEC(n)`` — sparse vector (pgvector 0.7.0+). + + Stores only non-zero components as ``{idx:value,...}/dim``, suitable for + high-dimensional sparse embeddings. Requires the pgvector extension. + + Args: + dim: Number of dimensions. + """ + + name = "postgres_sparsevec" + dim: int + + def __init__(self, dim: int, dialect: Optional["SQLDialectBase"] = None, + dialect_options: Optional[Dict[str, Any]] = None): + super().__init__(dialect, dialect_options=dialect_options) + self.dim = dim - def __hash__(self) -> int: - return hash((type(self), self.dim)) + def _type_params(self) -> tuple: + return (self.dim,) # --------------------------------------------------------------------------- @@ -382,6 +425,7 @@ class PostgresArrayType(ArrayType): ``dimensions``. """ + name = "postgres_array" def is_equivalent(self, other: "DataType") -> bool: if not isinstance(other, ArrayType): return False diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py index da294485..3c3dfbc8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/__init__.py @@ -287,10 +287,17 @@ vector_l2_distance, vector_cosine_distance, vector_inner_product, + vector_distance, vector_cosine_similarity, vector_literal, ) +# pgvector similarity-search helpers +from .vector_search import ( + vector_search, + create_vector_index, +) + # PostGIS functions from .postgis import ( st_make_point, @@ -714,8 +721,12 @@ "vector_l2_distance", "vector_cosine_distance", "vector_inner_product", + "vector_distance", "vector_cosine_similarity", "vector_literal", + # pgvector similarity-search helpers + "vector_search", + "create_vector_index", # PostGIS functions - Construction "st_make_point", "st_geom_from_text", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py index 8d56b2de..d834337c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py @@ -752,7 +752,7 @@ def hstore_to_array_operator( BinaryExpression for %%hstore """ return BinaryExpression( - dialect, "%%", + dialect, "%", core.Literal(dialect, ""), _convert_to_expression(dialect, hstore_expr), ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py index 4bbb2df1..00e88d31 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/pgvector.py @@ -13,6 +13,10 @@ - <=> : Cosine distance - <#> : Inner product (negative) +NOTE: The operator factories treat plain string arguments as literal values, +NOT column references. Pass ``Column(dialect, name)`` (or use ``vector_distance``) +to reference a vector column. + The vector type requires the pgvector extension: CREATE EXTENSION IF NOT EXISTS vector; """ @@ -27,6 +31,13 @@ from rhosocial.activerecord.backend.dialect import SQLDialectBase +_METRIC_OPERATORS = { + "cosine": "<=>", + "l2": "<->", + "ip": "<#>", +} + + def _convert_to_expression( dialect: "SQLDialectBase", expr: Union[PostgresVector, List[float], str, "bases.BaseExpression"], @@ -71,7 +82,8 @@ def vector_l2_distance( Args: dialect: The SQL dialect instance - left: Left operand (column name, vector, or expression) + left: Left operand (vector, string literal, or expression); plain + strings are literal values, use ``Column(dialect, name)`` for columns right: Right operand (vector literal, or expression) Returns: @@ -96,7 +108,8 @@ def vector_cosine_distance( Args: dialect: The SQL dialect instance - left: Left operand (column name, vector, or expression) + left: Left operand (vector, string literal, or expression); plain + strings are literal values, use ``Column(dialect, name)`` for columns right: Right operand (vector literal, or expression) Returns: @@ -119,7 +132,8 @@ def vector_inner_product( Args: dialect: The SQL dialect instance - left: Left operand (column name, vector, or expression) + left: Left operand (vector, string literal, or expression); plain + strings are literal values, use ``Column(dialect, name)`` for columns right: Right operand (vector literal, or expression) Returns: @@ -130,6 +144,41 @@ def vector_inner_product( return BinaryArithmeticExpression(dialect, "<#>", left_expr, right_expr) +def vector_distance( + dialect: "SQLDialectBase", + column: Union[str, "bases.BaseExpression"], + query_vector: Union[PostgresVector, List[float], str, "bases.BaseExpression"], + metric: str = "cosine", +) -> BinaryArithmeticExpression: + """Distance operator for a vector column, dispatching by metric. + + Unlike the raw operator factories, a plain string ``column`` is wrapped in + ``Column`` (a column reference), so passing a column name is safe. + + Args: + dialect: The SQL dialect instance + column: Vector column name (string) or column expression + query_vector: The query vector (``PostgresVector``, ``List[float]``, + str, or expression) + metric: Distance metric - 'cosine' | 'l2' | 'ip' + + Returns: + BinaryArithmeticExpression for the requested distance + """ + if metric not in _METRIC_OPERATORS: + raise ValueError( + f"Unsupported vector metric '{metric}'; expected one of " + f"{tuple(_METRIC_OPERATORS)}" + ) + column_expr = ( + core.Column(dialect, column) if isinstance(column, str) else column + ) + return BinaryArithmeticExpression( + dialect, _METRIC_OPERATORS[metric], + column_expr, _convert_to_expression(dialect, query_vector), + ) + + # === Similarity === def vector_cosine_similarity( @@ -187,6 +236,7 @@ def vector_literal( "vector_l2_distance", "vector_cosine_distance", "vector_inner_product", + "vector_distance", "vector_cosine_similarity", "vector_literal", ] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py new file mode 100644 index 00000000..44e856b1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py @@ -0,0 +1,216 @@ +# src/rhosocial/activerecord/backend/impl/postgres/functions/vector_search.py +""" +PostgreSQL pgvector similarity-search helpers. + +High-level helpers built on top of the pgvector operator factories in +``functions/pgvector.py``: + +- ``vector_search``: assemble a complete similarity-search + ``QueryExpression`` (SELECT distance/similarity, WHERE filters, + ORDER BY distance, LIMIT top_k). +- ``create_vector_index``: assemble a ``CreateIndexExpression`` for + HNSW/IVFFlat with metric-to-operator-class mapping. + +They target the RAG knowledge-base pattern: + index(chunk/tenant/meta/vector) + search(query_vector, top_k, filters) + +Requires the pgvector extension: CREATE EXTENSION IF NOT EXISTS vector; +""" + +from typing import List, Optional, Union, Dict, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression import ( + Column, + Literal, + QueryExpression, + TableExpression, +) +from rhosocial.activerecord.backend.expression.core import ( + BaseExpression, + WildcardExpression, +) +from rhosocial.activerecord.backend.expression.operators import BinaryArithmeticExpression +from rhosocial.activerecord.backend.expression.query_parts import ( + LimitOffsetClause, + OrderByClause, +) +from rhosocial.activerecord.backend.expression.query_parts import ( + SQLPredicate, + WhereClause, +) +from rhosocial.activerecord.backend.expression.statements.ddl_index import ( + CreateIndexExpression, +) + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.dialect import SQLDialectBase + +from .pgvector import ( + vector_cosine_distance, + vector_inner_product, + vector_l2_distance, +) + +__all__ = ["vector_search", "create_vector_index"] + +VECTOR_METRICS = ("cosine", "l2", "ip") + +_METRIC_DISTANCE_FN: Dict[str, object] = { + "cosine": vector_cosine_distance, + "l2": vector_l2_distance, + "ip": vector_inner_product, +} + +_METRIC_DISTANCE_ALIAS = { + "cosine": "cosine_distance", + "l2": "l2_distance", + "ip": "inner_product", +} + +_METRIC_SIMILARITY_ALIAS = { + "cosine": "cosine_similarity", + "l2": "l2_similarity", +} + +_METRIC_OPCLASS = { + "cosine": "vector_cosine_ops", + "l2": "vector_l2_ops", + "ip": "vector_ip_ops", +} + + +def _validate_metric(metric: str) -> None: + if metric not in VECTOR_METRICS: + raise ValueError( + f"Unsupported vector metric '{metric}'; expected one of {VECTOR_METRICS}" + ) + + +def vector_search( + dialect: "SQLDialectBase", + table: str, + query_vector, + metric: str = "cosine", + top_k: int = 10, + *, + vector_column: str = "embedding", + columns: Optional[List[Union[str, BaseExpression]]] = None, + where: Optional[Union[SQLPredicate, WhereClause]] = None, + include_distance: bool = True, + include_similarity: bool = False, +) -> QueryExpression: + """Build a pgvector similarity-search query. + + Args: + dialect: The SQL dialect instance + table: Table name to search + query_vector: Query vector (``PostgresVector``, ``List[float]``, str, + or expression) + metric: Distance metric - 'cosine' | 'l2' | 'ip' + top_k: Number of nearest neighbors to return (LIMIT) + vector_column: Vector column name (default 'embedding') + columns: Select list; strings become columns, expressions pass through. + Defaults to ``SELECT *``. + where: Optional filter predicate (e.g. tenant/permission scoping) + include_distance: Include an aliased distance column in SELECT + include_similarity: Include ``1 - distance`` for cosine/l2 in SELECT + + Returns: + A ready-to-execute ``QueryExpression``. + """ + _validate_metric(metric) + if top_k <= 0: + raise ValueError("top_k must be positive") + + vector_col = Column(dialect, vector_column) + distance_expr = _METRIC_DISTANCE_FN[metric](dialect, vector_col, query_vector) + + if columns is None: + select: List[BaseExpression] = [WildcardExpression(dialect)] + else: + select = [ + Column(dialect, c) if isinstance(c, str) else c for c in columns + ] + + if include_distance: + select.append(distance_expr.as_(_METRIC_DISTANCE_ALIAS[metric])) + if include_similarity and metric in _METRIC_SIMILARITY_ALIAS: + similarity = BinaryArithmeticExpression( + dialect, "-", Literal(dialect, 1), distance_expr + ) + select.append(similarity.as_(_METRIC_SIMILARITY_ALIAS[metric])) + + return QueryExpression( + dialect=dialect, + select=select, + from_=TableExpression(dialect, table), + where=where, + order_by=OrderByClause(dialect, [distance_expr]), + limit_offset=LimitOffsetClause(dialect, limit=top_k), + ) + + +def create_vector_index( + dialect: "SQLDialectBase", + table_name: str, + column_name: str = "embedding", + metric: str = "cosine", + index_type: str = "hnsw", + *, + index_name: Optional[str] = None, + m: Optional[int] = None, + ef_construction: Optional[int] = None, + lists: Optional[int] = None, + if_not_exists: bool = True, +) -> CreateIndexExpression: + """Build a pgvector index creation expression. + + Args: + dialect: The SQL dialect instance + table_name: Table name + column_name: Vector column name (default 'embedding') + metric: Distance metric - 'cosine' | 'l2' | 'ip' (maps to opclass) + index_type: Index type - 'hnsw' | 'ivfflat' + index_name: Optional index name (auto-generated if not provided) + m: HNSW max connections per layer + ef_construction: HNSW ef_construction + lists: IVFFlat number of lists + if_not_exists: Add IF NOT EXISTS + + Returns: + A ready-to-execute ``CreateIndexExpression``. + + Note: + IVFFlat requires the table to contain data before the index can be + created (HNSW has no such requirement). Create the index after + inserting rows when using ``index_type='ivfflat'``. + """ + _validate_metric(metric) + index_type_l = index_type.lower() + if index_type_l not in ("hnsw", "ivfflat"): + raise ValueError(f"Unsupported vector index type '{index_type}'; " + "expected 'hnsw' or 'ivfflat'") + + idx_name = index_name or f"idx_{table_name}_{column_name}_{index_type_l}" + with_options: Dict[str, int] = {} + if index_type_l == "hnsw": + if m is not None: + with_options["m"] = m + if ef_construction is not None: + with_options["ef_construction"] = ef_construction + else: + if lists is not None: + with_options["lists"] = lists + + return CreateIndexExpression( + dialect=dialect, + index_name=idx_name, + table_name=table_name, + columns=[column_name], + index_type=index_type_l.upper(), + if_not_exists=if_not_exists, + dialect_options={ + "opclasses": {column_name: _METRIC_OPCLASS[metric]}, + "with": with_options, + }, + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py index 3ba187d1..ea4371bb 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/introspector.py @@ -71,7 +71,22 @@ class PostgreSQLIntrospectorMixin(IntrospectorMixin): """ def _get_default_schema(self) -> str: - """Return 'public' as PostgreSQL default schema.""" + """Return the actual schema from backend config or search_path. + + Priority: + 1. config.default_schema + 2. First entry of config.search_path (e.g. 'broker, public' → 'broker') + 3. Fallback to 'public' + """ + config = getattr(self._backend, "config", None) if hasattr(self, "_backend") else None + if config is not None: + if hasattr(config, "default_schema") and getattr(config, "default_schema"): + return str(getattr(config, "default_schema")) + if hasattr(config, "search_path") and getattr(config, "search_path"): + sp = str(getattr(config, "search_path")) + first = sp.split(",")[0].strip().strip('"').strip("'") + if first: + return first return "public" def _get_database_name(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index a43bfe77..a5742759 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -14,6 +14,12 @@ from .extension import PostgresExtensionMixin from .materialized_view import PostgresMaterializedViewMixin from .table import PostgresTableMixin +from .datetime import PostgresDateTimeMixin +from .dql import PostgresDQLMixin +from .json import PostgresJSONMixin +from .generated_column import PostgresGeneratedColumnMixin +from .expression import PostgresExpressionMixin +from .function import PostgresFunctionMixin # DDL mixins from .ddl.partition import PostgresPartitionMixin @@ -119,6 +125,12 @@ "PostgresExtensionMixin", "PostgresMaterializedViewMixin", "PostgresTableMixin", + "PostgresDateTimeMixin", + "PostgresDQLMixin", + "PostgresJSONMixin", + "PostgresGeneratedColumnMixin", + "PostgresExpressionMixin", + "PostgresFunctionMixin", # DDL mixins "PostgresPartitionMixin", "PostgresIndexMixin", diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py index 0ce2a6b2..bbd40943 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/collation.py @@ -10,3 +10,18 @@ class PostgresCollationMixin: def supports_collate_expression(self) -> bool: return True + + def validate_collation_name(self, expr) -> str: + """Validate PostgreSQL collation names and return their SQL representation.""" + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + from ..collation import validate_postgres_collation_name + + schema = expr.collation_options.get("schema") + unsupported = set(expr.collation_options) - {"schema"} + if unsupported: + options = ", ".join(sorted(unsupported)) + raise UnsupportedFeatureError(self.name, f"COLLATE options: {options}") + validate_postgres_collation_name(expr.collation_name, getattr(self, "version", None)) + if schema is not None: + return f"{self.format_identifier(str(schema))}.{self.format_identifier(expr.collation_name)}" + return self.format_identifier(expr.collation_name) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py new file mode 100644 index 00000000..9b5dd4c7 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py @@ -0,0 +1,36 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py +"""PostgreSQL datetime formatting mixin.""" + + +class PostgresDateTimeMixin: + """PostgreSQL datetime diff expression formatting.""" + + def format_datetime_diff_expression(self, expr: "Any") -> tuple: + start_sql, start_params = expr.start.to_sql() + end_sql, end_params = expr.end.to_sql() + seconds_sql = f"EXTRACT(EPOCH FROM ({end_sql} - {start_sql}))" + factors = { + "second": "1", + "minute": "60", + "hour": "3600", + "day": "86400", + "week": "604800", + } + if expr.unit.value in factors: + sql = f"({seconds_sql} / {factors[expr.unit.value]})" + params = end_params + start_params + elif expr.unit.value == "month": + sql = ( + f"((EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql})) * 12 + " + f"(EXTRACT(MONTH FROM {end_sql}) - " + f"EXTRACT(MONTH FROM {start_sql})))" + ) + params = end_params + start_params + end_params + start_params + else: + sql = ( + f"(EXTRACT(YEAR FROM {end_sql}) - " + f"EXTRACT(YEAR FROM {start_sql}))" + ) + params = end_params + start_params + return self.apply_alias(sql, params, expr) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py index 6c5923de..ee2550ff 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/column.py @@ -97,3 +97,38 @@ def format_alter_column_action(self, action) -> Tuple[str, tuple]: else: modified = f"{sql} USING ({using_sql})" return modified, tuple(params) + tuple(using_params) + + def format_column(self, expr) -> Tuple[str, tuple]: + """Format column reference for PostgreSQL. + + PostgreSQL rules for column references: + - When the table has an alias (used in FROM/JOIN), column references + must use the alias, not the schema-qualified name. For example, + when FROM clause has ``public.users AS "users"``, the column + reference must be ``"users"."id"``, not ``"public"."users"."id"``. + - When no alias is present but schema is specified, use the + full three-segment form: ``"schema"."table"."column"``. + - Otherwise use the standard two-segment or single-segment form. + """ + name = expr.name + table = expr.table + alias = expr.alias + schema_name = expr.schema_name + if table: + # In PostgreSQL, when a table has an alias, column references + # must use the alias — schema_name is irrelevant in this context. + if schema_name and not alias: + col_sql = ( + f"{self.format_identifier(schema_name, expr.schema_need_quote)}." + f"{self.format_identifier(table, expr.table_need_quote)}." + f"{self.format_identifier(name, expr.name_need_quote)}" + ) + else: + col_sql = f"{self.format_identifier(table, expr.table_need_quote)}.{self.format_identifier(name, expr.name_need_quote)}" + else: + col_sql = self.format_identifier(name, expr.name_need_quote) + + if alias: + col_sql = f"{col_sql} AS {self.format_identifier(alias, expr.alias_need_quote)}" + + return col_sql, () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py index 213e74f5..d71b6a99 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/constraint.py @@ -6,14 +6,16 @@ SQL standard constraint capability defaults are defined in the core ConstraintMixin (rhosocial.activerecord.backend.dialect.mixins). - -Note: The format_add_table_constraint_action method is defined directly -in PostgresDialect rather than in this mixin, because Python's C3 MRO -places SQLDialectBase before mixins in the inheritance chain, which -prevents mixin methods from overriding base class methods of the same name. -This mixin provides capability detection defaults only. """ +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + AddTableConstraint, + TableConstraint, + ) + class PostgresConstraintMixin: """PostgreSQL-proprietary constraint capability defaults. @@ -49,3 +51,124 @@ def supports_drop_constraint_if_exists(self) -> bool: ``DROP CONSTRAINT`` since 9.6. """ return True + + def format_add_table_constraint_action( + self, action: "AddTableConstraint", + ) -> Tuple[str, tuple]: + """Format ADD CONSTRAINT action with PostgreSQL-specific extensions. + + Extends the base class implementation with: + - EXCLUDE constraint support (PG-specific constraint type) + - NOT VALID suffix (PG-specific: skip validation of existing rows) + """ + from rhosocial.activerecord.backend.expression.statements import ( + TableConstraintType, ConstraintValidation, + ) + + # Handle EXCLUDE constraint (PG-specific, not in base class) + if action.constraint.constraint_type == TableConstraintType.EXCLUDE: + parts = [] + exclude_sql, params = self._format_exclude_constraint(action.constraint) + parts.append(exclude_sql) + + # NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + parts.append("NOT VALID") + + return f"ADD {' '.join(parts)}", tuple(params) + + # Use base class for standard formatting (includes DEFERRABLE) + sql, params = super().format_add_table_constraint_action(action) + + # PostgreSQL NOT VALID suffix + if action.constraint.dialect_options: + validation = action.constraint.dialect_options.get('validation') + if validation == ConstraintValidation.NOVALIDATE: + sql += " NOT VALID" + + return sql, params + + def _format_exclude_constraint( + self, constraint: "TableConstraint", + ) -> Tuple[str, tuple]: + """Format EXCLUDE constraint (PostgreSQL-specific). + + EXCLUDE constraints use the dialect_options dict to specify: + - 'exclude_elements': List of (expression, operator) tuples + e.g., [('range', '&&')] for EXCLUDE USING gist (range WITH &&) + - 'using': The index access method (default 'gist') + e.g., 'gist', 'btree', 'spgist' + - 'where': Optional predicate for partial exclusion constraints + + Example: + TableConstraint( + constraint_type=TableConstraintType.EXCLUDE, + name='exclude_range_overlap', + dialect_options={ + 'exclude_elements': [('range', '&&')], + 'using': 'gist', + } + ) + # Generates: EXCLUDE USING gist (range WITH &&) + + """ + params: list = [] + + if constraint.name: + parts = ["CONSTRAINT", self.format_identifier(constraint.name)] + else: + parts = [] + + # USING clause - validate index access method. + valid_using = frozenset({"gist", "btree", "spgist", "hash", "gin", "brin"}) + using = constraint.dialect_options.get("using", "gist") if constraint.dialect_options else "gist" + if using not in valid_using: + raise ValueError( + f"Invalid index access method '{using}': must be one of {valid_using}" + ) + parts.append(f"EXCLUDE USING {using}") + + # Elements: (expression, operator) pairs - validate operators. + valid_ops = frozenset({ + "=", "<", "<=", ">", ">=", "<>", + "&&", "@>", "<@", "<<", ">>", "&<", "&>", + "~=", "@@", "?|", "?&", "is", "is not", + }) + exclude_elements = [] + if constraint.dialect_options and "exclude_elements" in constraint.dialect_options: + for expr, op in constraint.dialect_options["exclude_elements"]: + if op not in valid_ops: + raise ValueError( + f"Invalid exclude operator '{op}': must be one of {valid_ops}" + ) + if isinstance(expr, str): + exclude_elements.append(f"{self.format_identifier(expr)} WITH {op}") + else: + expr_sql, expr_params = expr.to_sql() + params.extend(expr_params) + exclude_elements.append(f"{expr_sql} WITH {op}") + + if exclude_elements: + parts.append(f"({', '.join(exclude_elements)})") + + # WHERE clause for partial exclusion constraint + if constraint.dialect_options and 'where' in constraint.dialect_options: + where_expr = constraint.dialect_options['where'] + where_sql, where_params = where_expr.to_sql() + params.extend(where_params) + parts.append(f"WHERE ({where_sql})") + + # DEFERRABLE / NOT DEFERRABLE + if constraint.deferrable is True: + if constraint.initially_deferred is True: + parts.append("DEFERRABLE INITIALLY DEFERRED") + elif constraint.initially_deferred is False: + parts.append("DEFERRABLE INITIALLY IMMEDIATE") + else: + parts.append("DEFERRABLE") + elif constraint.deferrable is False: + parts.append("NOT DEFERRABLE") + + return ' '.join(parts), tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py index 3e576e0b..fe60a722 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/index.py @@ -18,6 +18,9 @@ CreateIndexExpression, DropIndexExpression, ) + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) class PostgresIndexMixin: @@ -236,25 +239,23 @@ def supports_fulltext_query_expansion(self) -> bool: return False def format_fulltext_match( - self, columns: List[str], search_term: str, mode: Optional[str] = None + self, expr: "FulltextMatchExpression" ) -> Tuple[str, Tuple]: """Format tsvector @@ tsquery expression for PostgreSQL full-text search. Produces: to_tsvector('config', col1 || ' ' || col2) @@ tsquery_func(search_term) Args: - columns: Columns to include in the tsvector expression - search_term: Search term to convert to tsquery - mode: Search mode: - - None / 'NATURAL LANGUAGE': plainto_tsquery (plain text) - - 'BOOLEAN': to_tsquery (operators &, |, !) - - 'PHRASE': phraseto_tsquery (phrase matching) + expr: FulltextMatchExpression node carrying columns, search_term, and mode. Returns: Tuple of (SQL string, parameters tuple) """ config = self._FT_DEFAULT_CONFIG ph = self.get_parameter_placeholder() + columns = expr.columns + search_term = expr.search_term + mode = expr.mode if len(columns) == 1: tsvector_expr = f"to_tsvector('{config}', {self.format_identifier(columns[0])})" @@ -669,3 +670,33 @@ def format_create_index_pg_statement( parts.append(f"WHERE {where_clause}") return (" ".join(parts), tuple(all_params)) + + def format_add_index_action(self, action) -> Tuple[str, tuple]: + """PostgreSQL does not support ALTER TABLE ADD INDEX. + + Raises UnsupportedFeatureError — use CREATE INDEX instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE ADD INDEX", + suggestion="Use CREATE INDEX to create an index on the table.", + ) + + def format_drop_index_action(self, action) -> Tuple[str, tuple]: + """PostgreSQL does not support ALTER TABLE DROP INDEX. + + Raises UnsupportedFeatureError — use DROP INDEX statement instead. + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + raise UnsupportedFeatureError( + self.name, + "ALTER TABLE DROP INDEX", + suggestion="Use DROP INDEX statement to remove an index.", + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py index 56c6eb40..bae51098 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py @@ -94,3 +94,31 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: sql_parts.append("SKIP LOCKED") return " ".join(sql_parts), tuple(all_params) + + def supports_lock_strength(self, strength) -> bool: + """Check if a specific lock strength is supported. + + PostgreSQL lock strength support by version: + - FOR UPDATE: All versions + - FOR NO KEY UPDATE: PostgreSQL 9.0+ + - FOR SHARE: PostgreSQL 9.0+ + - FOR KEY SHARE: PostgreSQL 9.3+ + + Args: + strength: The LockStrength enum value to check + + Returns: + True if the lock strength is supported, False otherwise + + """ + from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + + if strength == LockStrength.UPDATE: + return True # All PostgreSQL versions support FOR UPDATE + elif strength == LockStrength.NO_KEY_UPDATE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.SHARE: + return self.version >= (9, 0, 0) + elif strength == LockStrength.KEY_SHARE: + return self.version >= (9, 3, 0) + return False diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dql.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dql.py new file mode 100644 index 00000000..9154ad29 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dql.py @@ -0,0 +1,16 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/dql.py +"""PostgreSQL DQL feature support mixin.""" + + +class PostgresDQLMixin: + """PostgreSQL DQL feature support.""" + + def supports_for_update(self) -> bool: + """Whether FOR UPDATE clause is supported in SELECT statements. + + PostgreSQL supports FOR UPDATE since early versions. The clause locks + selected rows preventing other transactions from modifying them. + PostgreSQL also supports FOR UPDATE OF, FOR UPDATE NOWAIT, and + FOR UPDATE SKIP LOCKED (since 9.5). + """ + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py index 65236cad..3f30f887 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py @@ -15,3 +15,36 @@ def supports_explain_format(self, format_type: str) -> bool: format_type_upper = format_type.upper() supported_formats = ["TEXT", "XML", "JSON", "YAML"] return format_type_upper in supported_formats + + def format_explain_statement(self, explain_expr) -> tuple: + """Build the PostgreSQL EXPLAIN SQL string and return (sql, params). + + PostgreSQL syntax: ``EXPLAIN [ ( option [, ...] ) ] statement`` + + Supported options: + - ``ANALYZE`` + - ``FORMAT { TEXT | XML | JSON | YAML }`` + - ``QUERY PLAN`` type — silently omitted (plain EXPLAIN is equivalent). + """ + from rhosocial.activerecord.backend.expression.statements import ExplainType + + statement_sql, statement_params = explain_expr.statement.to_sql() + options = explain_expr.options + if options is None: + return f"EXPLAIN {statement_sql}", statement_params + + opts: list = [] + + if options.analyze: + opts.append("ANALYZE") + + if options.format is not None: + fmt_name = options.format.name if hasattr(options.format, "name") else str(options.format) + opts.append(f"FORMAT {fmt_name.upper()}") + elif options.type is not None and options.type == ExplainType.QUERY_PLAN: + # PostgreSQL has no QUERY PLAN keyword; plain EXPLAIN is equivalent + pass + + if opts: + return "EXPLAIN (" + ", ".join(opts) + ") " + statement_sql, statement_params + return f"EXPLAIN {statement_sql}", statement_params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py new file mode 100644 index 00000000..b9c8173c --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py @@ -0,0 +1,55 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/expression.py +"""PostgreSQL expression formatting mixin.""" + +from typing import Tuple + + +class PostgresExpressionMixin: + """PostgreSQL-specific expression formatting.""" + + def format_cast_expression(self, expr) -> Tuple[str, tuple]: + """Format type cast expression using PostgreSQL :: syntax. + + PostgreSQL supports both standard CAST(expr AS type) syntax and the + PostgreSQL-specific expr::type syntax. This method uses the more + concise :: syntax which is idiomatic in PostgreSQL. + + ``expr.expression`` renders through its own ``to_sql()``; the cast + target is the node's ``target_type`` construction parameter. + + Args: + expr: :class:`~...expression.core.CastExpression` instance. + + Returns: + Tuple of (SQL string, parameters) + + Example: + >>> Column(dialect, 'price').cast('numeric').to_sql() + # Returns: ('"price"::numeric', ()) + + Note: + For chained type conversions, each ::type is appended: + >>> col.cast('money').cast('numeric').cast('float8') + # Generates: "col"::money::numeric::float8 + + """ + expr_sql, params = expr.expression.to_sql() + sql = f"{expr_sql}::{expr.target_type}" + if expr.alias: + sql = f"{sql} AS {self.format_identifier(expr.alias)}" + return sql, params + + def format_binary_operator(self, expr) -> Tuple[str, tuple]: + """Format binary operator with psycopg placeholder escaping. + + psycopg uses %s as parameter placeholder. When the SQL operator itself + contains % (e.g., pg_trgm similarity operator), it must be escaped as %% + to prevent psycopg from interpreting it as a placeholder prefix. + """ + left_sql, left_params = expr.left.to_sql() + right_sql, right_params = expr.right.to_sql() + op = expr.op + # Escape % in operators for psycopg compatibility + escaped_op = op.replace('%', '%%') if '%' in op else op + sql = f"{left_sql} {escaped_op} {right_sql}" + return sql, left_params + right_params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pgvector.py index 08dd6985..7b33b05f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/extensions/pgvector.py @@ -43,6 +43,7 @@ def format_create_vector_index_statement( ef_construction: Optional[int] = None, lists: Optional[int] = None, schema: Optional[str] = None, + opclass: str = "vector_cosine_ops", ) -> Tuple[str, tuple]: """Format CREATE INDEX statement for vector column. @@ -55,6 +56,9 @@ def format_create_vector_index_statement( ef_construction: HNSW ef_construction parameter lists: IVFFlat number of lists schema: Optional schema name + opclass: pgvector operator class for the index; defaults to + ``vector_cosine_ops``. Use ``vector_l2_ops`` or ``vector_ip_ops`` + for L2/inner-product distance indexes. Returns: Tuple of (SQL statement, parameters) @@ -70,13 +74,13 @@ def format_create_vector_index_statement( with_clause = f" WITH ({', '.join(with_clauses)})" if with_clauses else "" sql = ( f"CREATE INDEX {index_name} ON {full_table} " - f"USING hnsw ({column_name} vector_cosine_ops){with_clause}" + f"USING hnsw ({column_name} {opclass}){with_clause}" ) else: # ivfflat lists_clause = f" WITH (lists = {lists})" if lists else "" sql = ( f"CREATE INDEX {index_name} ON {full_table} " - f"USING ivfflat ({column_name} vector_cosine_ops){lists_clause}" + f"USING ivfflat ({column_name} {opclass}){lists_clause}" ) return (sql, ()) @@ -88,6 +92,7 @@ def format_create_hnsw_index_statement( index_name: Optional[str] = None, m: Optional[int] = None, ef_construction: Optional[int] = None, + opclass: str = "vector_cosine_ops", ) -> str: """Format CREATE INDEX statement with HNSW index for vector column. @@ -97,6 +102,8 @@ def format_create_hnsw_index_statement( index_name: Optional index name (auto-generated if not provided) m: HNSW M parameter (max connections per layer) ef_construction: HNSW ef_construction parameter + opclass: pgvector operator class for the index; defaults to + ``vector_cosine_ops``. Returns: SQL CREATE INDEX statement @@ -110,5 +117,5 @@ def format_create_hnsw_index_statement( with_clause = f" WITH ({', '.join(with_clauses)})" if with_clauses else "" return ( f"CREATE INDEX {idx_name} ON {table_name} " - f"USING hnsw ({column_name} vector_cosine_ops){with_clause}" + f"USING hnsw ({column_name} {opclass}){with_clause}" ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/function.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/function.py new file mode 100644 index 00000000..a565b481 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/function.py @@ -0,0 +1,171 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/function.py +"""PostgreSQL function support detection mixin.""" + +from typing import Dict, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.impl.postgres.function_versions import ( + FunctionSupportInfo, + FunctionVersionRequirement, + ) + + +class PostgresFunctionMixin: + """PostgreSQL function support detection.""" + + def supports_functions(self) -> Dict[str, "FunctionSupportInfo"]: + """Return supported SQL functions with detailed support information. + + This method combines: + 1. Core functions from rhosocial.activerecord.backend.expression.functions + 2. PostgreSQL-specific functions from rhosocial.activerecord.backend.impl.postgres.functions + + Each function is mapped to a FunctionSupportInfo indicating: + - Whether the function is supported + - If not, the reason why (PG version, extension status, etc.) + + Returns: + Dict mapping function names to FunctionSupportInfo. + + """ + from ..function_versions import FunctionSupportInfo + from rhosocial.activerecord.backend.expression.functions import ( + __all__ as core_functions, + ) + from rhosocial.activerecord.backend.impl.postgres import functions as postgres_functions + + expression_constructors = { + "xmlagg", + "xmlattributes", + "xmlcomment", + "xmlconcat", + "xmlelement", + "xmlexists", + "xmlforest", + "xmlparse", + "xmlpi", + "xmlquery", + "xmlroot", + "xmlserialize", + "xmltable", + } + result: Dict[str, FunctionSupportInfo] = {} + for func_name in core_functions: + if func_name not in expression_constructors: + result[func_name] = self._check_function_support(func_name) + + postgres_funcs = getattr(postgres_functions, "__all__", []) + for func_name in postgres_funcs: + if func_name not in result: + result[func_name] = self._check_function_support(func_name) + + return result + + def _check_function_support(self, func_name: str) -> "FunctionSupportInfo": + """Check function support status and return detailed information. + + Args: + func_name: Name of the function to check + + Returns: + FunctionSupportInfo with support status and reason if unsupported + + """ + from ..function_versions import FunctionSupportInfo + + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return FunctionSupportInfo(supported=True) + + # Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_low") + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return FunctionSupportInfo(supported=False, reason="pg_version_too_high") + + # Check extension requirements + if requirement.extension is not None: + if not hasattr(self, "_extensions"): + return FunctionSupportInfo(supported=False, reason="extension_not_probed") + + if requirement.ext_feature is not None: + if not self.check_extension_feature(requirement.extension, requirement.ext_feature): + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + else: + if not self.is_extension_installed(requirement.extension): + return FunctionSupportInfo(supported=False, reason="extension_not_installed") + if requirement.min_ext_version is not None: + installed = self.get_extension_version(requirement.extension) + if installed is None or self._compare_versions(installed, requirement.min_ext_version) < 0: + return FunctionSupportInfo(supported=False, reason="extension_version_insufficient") + + return FunctionSupportInfo(supported=True) + + def _is_postgres_function_supported(self, func_name: str) -> bool: + """Check if a PostgreSQL function is supported based on version and extensions. + + Checks: + 1. PostgreSQL server version (for built-in and version-gated functions) + 2. Extension installation and version (for extension-provided functions) + + For extension functions, requires _extensions to have been populated + via introspect_and_adapt(). If _extensions is not available, + extension functions return False (cannot confirm availability). + + Args: + func_name: Name of the PostgreSQL function + + Returns: + True if supported, False otherwise + + """ + requirement = self._POSTGRES_FUNCTION_VERSIONS.get(func_name) + if requirement is None: + return True # Unregistered functions default to supported + + # Step 1: Check PostgreSQL server version + if requirement.min_pg_version is not None and self.version < requirement.min_pg_version: + return False + if requirement.max_pg_version is not None and self.version > requirement.max_pg_version: + return False + + # Step 2: Check extension requirements + if requirement.extension is not None: + return self._check_extension_requirement(requirement) + + return True + + def _check_extension_requirement( + self, requirement: "FunctionVersionRequirement" + ) -> bool: + """Check extension requirements based on introspect_and_adapt() results. + + Requires _extensions to have been populated via introspect_and_adapt(). + If _extensions is not available, returns False (cannot confirm + extension availability without probing the database). + """ + ext_name = requirement.extension + + # _extensions is populated by introspect_and_adapt() + # If the attribute doesn't exist, introspect_and_adapt() hasn't been called + if not hasattr(self, "_extensions"): + return False + + # Use feature-level check if ext_feature is specified + if requirement.ext_feature is not None: + return self.check_extension_feature(ext_name, requirement.ext_feature) + + # Check extension installed + if not self.is_extension_installed(ext_name): + return False + + # Check extension version + if requirement.min_ext_version is not None: + installed = self.get_extension_version(ext_name) + if installed is None: + return False + return self._compare_versions(installed, requirement.min_ext_version) >= 0 + + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/generated_column.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/generated_column.py new file mode 100644 index 00000000..cd32328b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/generated_column.py @@ -0,0 +1,27 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/generated_column.py +"""PostgreSQL generated column feature support mixin.""" + + +class PostgresGeneratedColumnMixin: + """PostgreSQL generated column feature support.""" + + def supports_auto_increment(self) -> bool: + """Whether AUTO_INCREMENT/IDENTITY column attributes are supported. + + PostgreSQL supports SERIAL and GENERATED ... AS IDENTITY. + """ + return True + + def supports_stored_generated_columns(self) -> bool: + """Whether STORED generated columns are supported. + + PostgreSQL generated columns are always STORED. + """ + return self.supports_generated_columns() + + def supports_virtual_generated_columns(self) -> bool: + """Whether VIRTUAL generated columns are supported. + + PostgreSQL has no VIRTUAL generated columns. + """ + return False diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py index ce9033a6..5d5e0e79 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py @@ -1,6 +1,13 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/ilike.py """PostgreSQL ilike feature support implementation.""" +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( + ILIKEExpression, + ) + class PostgresILIKEMixin: """PostgreSQL ilike override implementation. @@ -10,3 +17,42 @@ class PostgresILIKEMixin: def supports_ilike(self) -> bool: return True + + def format_ilike_expression(self, column, pattern: str = "", negate: bool = False) -> tuple: + """Format ILIKE expression for PostgreSQL. + + Accepts either raw parameters or an ILIKEExpression object. + + Args: + column: Column name string, expression with ``to_sql()``, + or an :class:`ILIKEExpression` instance. + pattern: Right-hand-side pattern (replaced with ``%s`` placeholder). + Ignored when *column* is an ``ILIKEExpression``. + negate: If ``True``, produces ``NOT ILIKE`` instead of ``ILIKE``. + Ignored when *column* is an ``ILIKEExpression``. + + Returns: + Tuple of (SQL string, (pattern,) params tuple) + + """ + from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( + ILIKEExpression, + ) + + if isinstance(column, ILIKEExpression): + expr = column + column = expr.column + pattern = expr.pattern + negate = expr.negate + + if isinstance(column, str): + col_sql = self.format_identifier(column) + else: + col_sql, col_params = column.to_sql() if hasattr(column, "to_sql") else (str(column), ()) + + if negate: + sql = f"{col_sql} NOT ILIKE %s" + else: + sql = f"{col_sql} ILIKE %s" + + return sql, (pattern,) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index 1df9610e..f489329b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -107,11 +107,22 @@ def get_supported_introspection_scopes(self) -> List[IntrospectionScope]: def _get_default_schema(self) -> str: """Get default schema name. - PostgreSQL's default schema is 'public'. - - Returns: - The default schema name 'public'. + PostgreSQL's default schema is 'public', but may be overridden by + the connection config's `default_schema` or the first entry of + `search_path`. Returns 'public' if neither is set. """ + config = getattr(self, "_config", None) or getattr(self, "_backend", None) + cfg = config.config if hasattr(config, "config") else config + if cfg is not None: + default_schema = getattr(cfg, "default_schema", None) + if default_schema: + return str(default_schema) + search_path = getattr(cfg, "search_path", None) + if search_path: + # search_path 形如 "broker", public → 取第一个有效 schema + first = str(search_path).split(",")[0].strip().strip('"').strip("'") + if first: + return first return "public" # ========== Query Formatting Methods ========== diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py new file mode 100644 index 00000000..b44b38c9 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py @@ -0,0 +1,10 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/json.py +"""PostgreSQL JSON feature support mixin.""" + + +class PostgresJSONMixin: + """PostgreSQL JSON feature support.""" + + def supports_json_arrow_operators(self) -> bool: + """PostgreSQL supports -> and ->> operators for JSON/JSONB access.""" + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py index 53562079..899c3eff 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py @@ -12,6 +12,49 @@ def supports_materialized_view_concurrent_refresh(self) -> bool: """CONCURRENTLY is supported since PostgreSQL 9.4.""" return self.version >= (9, 4, 0) + def format_create_materialized_view_statement(self, expr) -> tuple: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + + - ``expr.view_name`` — view name (identifier). + - ``expr.column_aliases`` — optional list of column aliases. + - ``expr.tablespace`` — optional tablespace. + - ``expr.storage_options`` — optional dict of storage parameters (``WITH (… )``). + - ``expr.query`` — source SELECT expression. + - ``expr.with_data`` — ``WITH DATA`` / ``WITH NO DATA``. + + Args: + expr: CreateMaterializedViewExpression instance + + Returns: + Tuple of (SQL string, params tuple) + + """ + parts = ["CREATE MATERIALIZED VIEW"] + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + if expr.tablespace and self.supports_materialized_view_tablespace(): + parts.append(f"TABLESPACE {self.format_identifier(expr.tablespace)}") + + if expr.storage_options and self.supports_materialized_view_storage_options(): + storage_parts = [] + for key, value in expr.storage_options.items(): + storage_parts.append(f"{key.upper()} = {value}") + parts.append(f"WITH ({', '.join(storage_parts)})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.with_data: + parts.append("WITH DATA") + else: + parts.append("WITH NO DATA") + + return " ".join(parts), query_params + def format_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py index 8273b0c4..e94b21fc 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/schema.py @@ -8,6 +8,10 @@ class PostgresSchemaMixin: All features are native, using version number for detection. """ + def supports_schema(self) -> bool: + """PostgreSQL models named schema namespaces natively.""" + return True + def supports_create_schema(self) -> bool: return True @@ -22,3 +26,6 @@ def supports_schema_if_exists(self) -> bool: def supports_schema_cascade(self) -> bool: return True + + def supports_schema_authorization(self) -> bool: + return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py index 83bebc5d..409e9cc3 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py @@ -36,21 +36,87 @@ def supports_table_tablespace(self) -> bool: """TABLESPACE specification is supported in all versions.""" return True - def supports_table_like_syntax(self) -> bool: + def supports_create_table_like(self) -> bool: """PostgreSQL supports CREATE TABLE (LIKE ...) with INCLUDING/EXCLUDING options.""" return True def format_create_table_statement(self, expr) -> Tuple[str, tuple]: - """Render CREATE TABLE, injecting the UNLOGGED qualifier. - - The core ``TableMixin.format_create_table_statement`` does not know - about PostgreSQL's ``UNLOGGED`` table classes (WAL-avoiding tables). - Callers opt in via ``dialect_options={"unlogged": True}`` on the - ``CreateTableExpression``; the qualifier is version-gated at 9.5+. - ``UNLOGGED`` and ``TEMPORARY`` are mutually exclusive qualifiers in - PostgreSQL's grammar; TEMPORARY wins when both are requested. + """Render CREATE TABLE for PostgreSQL. + + Handles PostgreSQL-specific CREATE TABLE behavior: + * PostgreSQL LIKE syntax with INCLUDING/EXCLUDING options. + * Declarative partitioning validation for ``partition`` expressions. + * UNLOGGED table qualifier. + + The LIKE syntax is controlled by ``dialect_options['like_table']``. + When present, all other parameters (columns, indexes, constraints) + are ignored. The ``like_options`` key controls INCLUDING/EXCLUDING + behavior (dict or list format). + + Partition validation follows PostgreSQL declarative partitioning rules: + - RANGE and LIST require PostgreSQL 10+. + - HASH requires PostgreSQL 11+. + - MySQL-specific methods (KEY, RANGE COLUMNS) are rejected. """ + # Check for LIKE syntax in dialect_options (highest priority) + if "like_table" in expr.dialect_options: + like_table = expr.dialect_options["like_table"] + like_options = expr.dialect_options.get("like_options", []) + + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + parts.append("TABLE") + + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + + parts.append(self.format_identifier(expr.table_name)) + + # Build LIKE clause with options + like_parts = [] + + # Handle schema-qualified table name: ('schema', 'table') + if isinstance(like_table, tuple): + schema, table = like_table + like_table_str = f"{self.format_identifier(schema)}.{self.format_identifier(table)}" + else: + like_table_str = self.format_identifier(like_table) + + like_parts.append(f"LIKE {like_table_str}") + + # Add INCLUDING/EXCLUDING options + if isinstance(like_options, dict): + including = like_options.get("including", []) + excluding = like_options.get("excluding", []) + + for option in including: + like_parts.append(f"INCLUDING {option.upper()}") + + for option in excluding: + like_parts.append(f"EXCLUDING {option.upper()}") + elif isinstance(like_options, list): + for option in like_options: + if isinstance(option, tuple): + action, feature = option + like_parts.append(f"{action.upper()} {feature.upper()}") + else: + like_parts.append(f"INCLUDING {option.upper()}") + + parts.append(f"({', '.join(like_parts)})") + + return " ".join(parts), () + + if getattr(expr, "partition", None) is not None: + # Validate through the PartitionClause -> format_partition_clause chain. + expr.partition.to_sql() + + # Delegate to base implementation (TableMixin) sql, params = super().format_create_table_statement(expr) + + # Apply UNLOGGED qualifier if requested if not (getattr(expr, "dialect_options", None) or {}).get("unlogged_table"): return sql, params if getattr(expr, "temporary", False): @@ -83,7 +149,7 @@ def format_create_table_like(self, expr) -> Tuple[str, tuple]: def format_column_definition(self, col_def) -> Tuple[str, tuple]: from rhosocial.activerecord.backend.dialect.base import SQLDialectBase all_params: List[Any] = [] - type_sql, _ = col_def.data_type.to_sql(self) + type_sql, _ = col_def.data_type.to_sql() if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): raise ValueError( f"Invalid data type '{type_sql}': " @@ -97,7 +163,11 @@ def format_column_definition(self, col_def) -> Tuple[str, tuple]: col_sql += f" GENERATED {identity.upper()} AS IDENTITY" else: raise ValueError(f"Invalid identity option '{identity}': must be 'ALWAYS' or 'BY DEFAULT'") + auto_identity_added = False for constraint in col_def.constraints: + if not auto_identity_added and getattr(constraint, 'is_auto_increment', False): + col_sql += " GENERATED BY DEFAULT AS IDENTITY" + auto_identity_added = True suffix, params = self.format_column_constraint(constraint) col_sql += suffix all_params.extend(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py index aa7eafb2..4b24681e 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py @@ -1,6 +1,14 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/transaction.py """PostgreSQL transaction feature support implementation.""" +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + SetTransactionExpression, + ) + class PostgresTransactionMixin: """PostgreSQL transaction override implementation. @@ -22,3 +30,83 @@ def supports_deferrable_transaction(self) -> bool: def supports_savepoint(self) -> bool: return True + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION statement for PostgreSQL. + + PostgreSQL syntax: + BEGIN [ ISOLATION LEVEL { READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE } ] + [ { READ WRITE | READ ONLY } ] + [ { NOT DEFERRABLE | DEFERRABLE } ] + + DEFERRABLE is only meaningful for SERIALIZABLE isolation level. + """ + params = expr.get_params() + parts = ["BEGIN"] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + parts.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + parts.append("READ ONLY") + elif mode_name == "READ_WRITE": + parts.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None and isolation: + isolation_name = isolation.name if hasattr(isolation, "name") else str(isolation) + if isolation_name == "SERIALIZABLE": + parts.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + return " ".join(parts), () + + def format_set_transaction( + self, expr: "SetTransactionExpression" + ) -> Tuple[str, tuple]: + """Format SET TRANSACTION statement for PostgreSQL. + + PostgreSQL supports setting transaction characteristics for the current + transaction or for subsequent transactions. + + Syntax: + SET TRANSACTION { ISOLATION LEVEL { ... } | { READ WRITE | READ ONLY } | [ NOT ] DEFERRABLE } [, ...] + SET SESSION CHARACTERISTICS AS TRANSACTION { ... } + """ + params = expr.get_params() + parts = [] + + if params.get("session"): + parts.append("SET SESSION CHARACTERISTICS AS TRANSACTION") + else: + parts.append("SET TRANSACTION") + + options = [] + + isolation = params.get("isolation_level") + if isolation: + level_str = self.get_isolation_level_name(isolation) + options.append(f"ISOLATION LEVEL {level_str}") + + mode = params.get("mode") + if mode: + mode_name = mode.name if hasattr(mode, "name") else str(mode) + if mode_name == "READ_ONLY": + options.append("READ ONLY") + elif mode_name == "READ_WRITE": + options.append("READ WRITE") + + deferrable = params.get("deferrable") + if deferrable is not None: + options.append("DEFERRABLE" if deferrable else "NOT DEFERRABLE") + + if options: + parts.append(" ".join(options)) + + return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py index 3e260464..d8394b9d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -1,6 +1,8 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py """PostgreSQL truncate feature support implementation.""" +from typing import Tuple + class PostgresTruncateMixin: """PostgreSQL truncate override implementation. @@ -13,3 +15,21 @@ def supports_truncate_restart_identity(self) -> bool: def supports_truncate_cascade(self) -> bool: return True + + def format_truncate_statement(self, expr) -> Tuple[str, tuple]: + """Format TRUNCATE statement for PostgreSQL. + + - ``expr.table_name`` — target table. + - ``expr.restart_identity`` — add ``RESTART IDENTITY`` (PG 8.4+). + - ``expr.cascade`` — add ``CASCADE``. + """ + parts = ["TRUNCATE TABLE"] + parts.append(self.format_identifier(expr.table_name)) + + if expr.restart_identity and self.supports_truncate_restart_identity(): + parts.append("RESTART IDENTITY") + + if expr.cascade: + parts.append("CASCADE") + + return " ".join(parts), () diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py index 73c155f6..aafe9250 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/data_type_formatting.py @@ -37,58 +37,7 @@ ) from ...expression.types import ( PostgresArrayType, - PostgresBigSerialType, - PostgresBitType, - PostgresBoxType, - PostgresByteaType, - PostgresCIDType, - PostgresCircleType, - PostgresDateMultirangeType, - PostgresDateRangeType, - PostgresGeographyType, - PostgresGeometryType, - PostgresHstoreType, - PostgresInetType, - PostgresInt4MultirangeType, - PostgresInt4RangeType, - PostgresInt8MultirangeType, - PostgresInt8RangeType, - PostgresJsonPathType, - PostgresLineSegmentType, - PostgresLineType, - PostgresMacAddr8Type, - PostgresMacAddrType, - PostgresMoneyType, - PostgresNumMultirangeType, - PostgresNumRangeType, - PostgresOIDType, - PostgresPathType, - PostgresPgLSNType, - PostgresPointType, - PostgresPolygonType, - PostgresRegClassType, - PostgresRegTypeType, - PostgresSerialType, - PostgresSmallSerialType, - PostgresTIDType, - PostgresTSQueryType, - PostgresTSVectorType, - PostgresTsMultirangeType, - PostgresTsRangeType, - PostgresTsTzMultirangeType, - PostgresTsTzRangeType, - PostgresUUIDType, - PostgresVarBitType, - PostgresVectorType, - PostgresXID8Type, - PostgresXIDType, - PostgresXMLType, - PostgresCidrType, PostgresCharacterVaryingType, - PostgresCitextType, - PostgresCubeType, - PostgresLtreeType, - PostgresRasterType, ) if TYPE_CHECKING: @@ -109,347 +58,616 @@ class PostgresTypeFormatSupportMixin(DDLTypeMixin, DDLTypeSupport): # --- PostgreSQL-specific formatters --- - @DDLTypeMixin.handles(PostgresByteaType) - def format_data_type_bytea(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_enum(self, data_type) -> Tuple[str, tuple]: + """Render a PostgresEnumType reference for DDL column definitions. + + PostgreSQL ENUM types are referenced by name in column definitions; + the CREATE TYPE ... AS ENUM is a separate DDL operation. + """ + return data_type.name, () + + def format_data_type_postgres_bytea(self, data_type) -> Tuple[str, tuple]: return "BYTEA", () - @DDLTypeMixin.handles(PostgresSmallSerialType) - def format_data_type_small_serial(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_smallserial(self, data_type) -> Tuple[str, tuple]: return "SMALLSERIAL", () - @DDLTypeMixin.handles(PostgresSerialType) - def format_data_type_serial(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_serial(self, data_type) -> Tuple[str, tuple]: return "SERIAL", () - @DDLTypeMixin.handles(PostgresBigSerialType) - def format_data_type_big_serial(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_bigserial(self, data_type) -> Tuple[str, tuple]: return "BIGSERIAL", () - @DDLTypeMixin.handles(PostgresUUIDType) - def format_data_type_uuid(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_uuid(self, data_type) -> Tuple[str, tuple]: return "UUID", () - @DDLTypeMixin.handles(PostgresXMLType) - def format_data_type_xml(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_xml(self, data_type) -> Tuple[str, tuple]: return "XML", () - @DDLTypeMixin.handles(PostgresCharacterVaryingType) - def format_data_type_character_varying(self, data_type: PostgresCharacterVaryingType) -> Tuple[str, tuple]: + def format_data_type_postgres_character_varying(self, data_type: PostgresCharacterVaryingType) -> Tuple[str, tuple]: if data_type.length is not None: return f"CHARACTER VARYING({data_type.length})", () return "CHARACTER VARYING", () - @DDLTypeMixin.handles(PostgresTSVectorType) - def format_data_type_ts_vector(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tsvector(self, data_type) -> Tuple[str, tuple]: return "TSVECTOR", () - @DDLTypeMixin.handles(PostgresTSQueryType) - def format_data_type_ts_query(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tsquery(self, data_type) -> Tuple[str, tuple]: return "TSQUERY", () - @DDLTypeMixin.handles(PostgresJsonPathType) - def format_data_type_json_path(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_jsonpath(self, data_type) -> Tuple[str, tuple]: return "JSONPATH", () - @DDLTypeMixin.handles(PostgresBitType) - def format_data_type_bit(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_bit(self, data_type) -> Tuple[str, tuple]: if data_type.n is not None: return f"BIT({data_type.n})", () return "BIT", () - @DDLTypeMixin.handles(PostgresVarBitType) - def format_data_type_var_bit(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_varbit(self, data_type) -> Tuple[str, tuple]: if data_type.n is not None: return f"VARBIT({data_type.n})", () return "VARBIT", () - @DDLTypeMixin.handles(PostgresInetType) - def format_data_type_inet(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_inet(self, data_type) -> Tuple[str, tuple]: return "INET", () - @DDLTypeMixin.handles(PostgresCidrType) - def format_data_type_cidr(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_cidr(self, data_type) -> Tuple[str, tuple]: return "CIDR", () - @DDLTypeMixin.handles(PostgresMacAddrType) - def format_data_type_mac_addr(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_macaddr(self, data_type) -> Tuple[str, tuple]: return "MACADDR", () - @DDLTypeMixin.handles(PostgresMacAddr8Type) - def format_data_type_mac_addr8(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_macaddr8(self, data_type) -> Tuple[str, tuple]: return "MACADDR8", () - @DDLTypeMixin.handles(PostgresPointType) - def format_data_type_point(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_point(self, data_type) -> Tuple[str, tuple]: return "POINT", () - @DDLTypeMixin.handles(PostgresLineType) - def format_data_type_line(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_line(self, data_type) -> Tuple[str, tuple]: return "LINE", () - @DDLTypeMixin.handles(PostgresLineSegmentType) - def format_data_type_line_segment(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_line_segment(self, data_type) -> Tuple[str, tuple]: return "LSEG", () - @DDLTypeMixin.handles(PostgresBoxType) - def format_data_type_box(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_box(self, data_type) -> Tuple[str, tuple]: return "BOX", () - @DDLTypeMixin.handles(PostgresPathType) - def format_data_type_path(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_path(self, data_type) -> Tuple[str, tuple]: return "PATH", () - @DDLTypeMixin.handles(PostgresPolygonType) - def format_data_type_polygon(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_polygon(self, data_type) -> Tuple[str, tuple]: return "POLYGON", () - @DDLTypeMixin.handles(PostgresCircleType) - def format_data_type_circle(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_circle(self, data_type) -> Tuple[str, tuple]: return "CIRCLE", () - @DDLTypeMixin.handles(PostgresMoneyType) - def format_data_type_money(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_money(self, data_type) -> Tuple[str, tuple]: return "MONEY", () - @DDLTypeMixin.handles(PostgresInt4RangeType) - def format_data_type_int4_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_int4range(self, data_type) -> Tuple[str, tuple]: return "INT4RANGE", () - @DDLTypeMixin.handles(PostgresInt8RangeType) - def format_data_type_int8_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_int8range(self, data_type) -> Tuple[str, tuple]: return "INT8RANGE", () - @DDLTypeMixin.handles(PostgresNumRangeType) - def format_data_type_num_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_numrange(self, data_type) -> Tuple[str, tuple]: return "NUMRANGE", () - @DDLTypeMixin.handles(PostgresTsRangeType) - def format_data_type_ts_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tsrange(self, data_type) -> Tuple[str, tuple]: return "TSRANGE", () - @DDLTypeMixin.handles(PostgresTsTzRangeType) - def format_data_type_ts_tz_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tstzrange(self, data_type) -> Tuple[str, tuple]: return "TSTZRANGE", () - @DDLTypeMixin.handles(PostgresDateRangeType) - def format_data_type_date_range(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_daterange(self, data_type) -> Tuple[str, tuple]: return "DATERANGE", () - @DDLTypeMixin.handles(PostgresInt4MultirangeType) - def format_data_type_int4_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_int4multirange(self, data_type) -> Tuple[str, tuple]: return "INT4MULTIRANGE", () - @DDLTypeMixin.handles(PostgresInt8MultirangeType) - def format_data_type_int8_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_int8multirange(self, data_type) -> Tuple[str, tuple]: return "INT8MULTIRANGE", () - @DDLTypeMixin.handles(PostgresNumMultirangeType) - def format_data_type_num_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_nummultirange(self, data_type) -> Tuple[str, tuple]: return "NUMMULTIRANGE", () - @DDLTypeMixin.handles(PostgresTsMultirangeType) - def format_data_type_ts_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tsmultirange(self, data_type) -> Tuple[str, tuple]: return "TSMULTIRANGE", () - @DDLTypeMixin.handles(PostgresTsTzMultirangeType) - def format_data_type_ts_tz_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tstzmultirange(self, data_type) -> Tuple[str, tuple]: return "TSTZMULTIRANGE", () - @DDLTypeMixin.handles(PostgresDateMultirangeType) - def format_data_type_date_multirange(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_datemultirange(self, data_type) -> Tuple[str, tuple]: return "DATEMULTIRANGE", () - @DDLTypeMixin.handles(PostgresOIDType) - def format_data_type_oid(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_oid(self, data_type) -> Tuple[str, tuple]: return "OID", () - @DDLTypeMixin.handles(PostgresRegClassType) - def format_data_type_reg_class(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_regclass(self, data_type) -> Tuple[str, tuple]: return "REGCLASS", () - @DDLTypeMixin.handles(PostgresRegTypeType) - def format_data_type_reg_type(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_regtype(self, data_type) -> Tuple[str, tuple]: return "REGTYPE", () - @DDLTypeMixin.handles(PostgresXIDType) - def format_data_type_xid(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_xid(self, data_type) -> Tuple[str, tuple]: return "XID", () - @DDLTypeMixin.handles(PostgresXID8Type) - def format_data_type_xid8(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_xid8(self, data_type) -> Tuple[str, tuple]: return "XID8", () - @DDLTypeMixin.handles(PostgresCIDType) - def format_data_type_cid(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_cid(self, data_type) -> Tuple[str, tuple]: return "CID", () - @DDLTypeMixin.handles(PostgresTIDType) - def format_data_type_tid(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_tid(self, data_type) -> Tuple[str, tuple]: return "TID", () - @DDLTypeMixin.handles(PostgresPgLSNType) - def format_data_type_pg_lsn(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_pg_lsn(self, data_type) -> Tuple[str, tuple]: return "PG_LSN", () - @DDLTypeMixin.handles(PostgresHstoreType) - def format_data_type_hstore(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_hstore(self, data_type) -> Tuple[str, tuple]: return "HSTORE", () - @DDLTypeMixin.handles(PostgresGeometryType) - def format_data_type_geometry(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_geometry(self, data_type) -> Tuple[str, tuple]: return "GEOMETRY", () - @DDLTypeMixin.handles(PostgresGeographyType) - def format_data_type_geography(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_geography(self, data_type) -> Tuple[str, tuple]: return "GEOGRAPHY", () - @DDLTypeMixin.handles(PostgresVectorType) - def format_data_type_vector(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_vector(self, data_type) -> Tuple[str, tuple]: return f"VECTOR({data_type.dim})", () - @DDLTypeMixin.handles(PostgresCitextType) - def format_data_type_citext(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_halfvec(self, data_type) -> Tuple[str, tuple]: + return f"HALFVEC({data_type.dim})", () + + def format_data_type_postgres_sparsevec(self, data_type) -> Tuple[str, tuple]: + return f"SPARSEVEC({data_type.dim})", () + + def format_data_type_postgres_citext(self, data_type) -> Tuple[str, tuple]: return "CITEXT", () - @DDLTypeMixin.handles(PostgresCubeType) - def format_data_type_cube(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_cube(self, data_type) -> Tuple[str, tuple]: return "CUBE", () - @DDLTypeMixin.handles(PostgresLtreeType) - def format_data_type_ltree(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_ltree(self, data_type) -> Tuple[str, tuple]: return "LTREE", () - @DDLTypeMixin.handles(PostgresRasterType) - def format_data_type_raster(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_raster(self, data_type) -> Tuple[str, tuple]: return "RASTER", () - @DDLTypeMixin.handles(ArrayType) - def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: - elem = data_type.element_type - elem_cls = type(elem) - if elem_cls not in self._type_formatters: - raise TypeError( - f"ArrayType element type {elem_cls.__name__!r} is not supported " - f"by {type(self).__name__}. Use a PostgreSQL-specific DataType " - f"as the element type (e.g. PostgresIntegerType)." - ) - element_sql, _ = self.format_data_type(elem) + def format_data_type_postgres_array(self, data_type: ArrayType) -> Tuple[str, tuple]: + element_sql, _ = self.format_data_type(data_type.element_type) return element_sql + "[]" * data_type.dimensions, () # --- Core formatters (PostgreSQL specialized) --- - @DDLTypeMixin.handles(TinyIntType) - def format_data_type_tiny_int(self, data_type: TinyIntType) -> Tuple[str, tuple]: + def format_data_type_tinyint(self, data_type: TinyIntType) -> Tuple[str, tuple]: return "SMALLINT", () - @DDLTypeMixin.handles(SmallIntType) - def format_data_type_small_int(self, data_type: SmallIntType) -> Tuple[str, tuple]: + def format_data_type_smallint(self, data_type: SmallIntType) -> Tuple[str, tuple]: return "SMALLINT", () - @DDLTypeMixin.handles(IntType) def format_data_type_int(self, data_type: IntType) -> Tuple[str, tuple]: return "INTEGER", () - @DDLTypeMixin.handles(IntegerType) def format_data_type_integer(self, data_type: IntegerType) -> Tuple[str, tuple]: return "INTEGER", () - @DDLTypeMixin.handles(BigIntType) - def format_data_type_big_int(self, data_type: BigIntType) -> Tuple[str, tuple]: + def format_data_type_bigint(self, data_type: BigIntType) -> Tuple[str, tuple]: return "BIGINT", () - @DDLTypeMixin.handles(FloatType) def format_data_type_float(self, data_type: FloatType) -> Tuple[str, tuple]: if data_type.precision is not None: + if not (1 <= data_type.precision <= 53): + raise ValueError( + f"FLOAT precision must be between 1 and 53 " + f"(PostgreSQL limit), got {data_type.precision}" + ) return f"FLOAT({data_type.precision})", () return "REAL", () - @DDLTypeMixin.handles(RealType) def format_data_type_real(self, data_type: RealType) -> Tuple[str, tuple]: return "REAL", () - @DDLTypeMixin.handles(DoubleType) def format_data_type_double(self, data_type: DoubleType) -> Tuple[str, tuple]: return "DOUBLE PRECISION", () - @DDLTypeMixin.handles(DecimalType) def format_data_type_decimal(self, data_type: DecimalType) -> Tuple[str, tuple]: + if data_type.precision is not None: + if not (1 <= data_type.precision <= 1000): + raise ValueError( + f"DECIMAL precision must be between 1 and 1000 " + f"(PostgreSQL limit), got {data_type.precision}" + ) + if data_type.scale is not None: + if not (0 <= data_type.scale <= data_type.precision if data_type.precision else True): + raise ValueError( + f"DECIMAL scale must be between 0 and precision " + f"({data_type.precision}), got {data_type.scale}" + ) if data_type.precision is not None and data_type.scale is not None: return f"DECIMAL({data_type.precision},{data_type.scale})", () if data_type.precision is not None: return f"DECIMAL({data_type.precision})", () return "DECIMAL", () - @DDLTypeMixin.handles(CharType) def format_data_type_char(self, data_type: CharType) -> Tuple[str, tuple]: if data_type.length is not None: return f"CHAR({data_type.length})", () return "CHAR", () - @DDLTypeMixin.handles(VarCharType) - def format_data_type_var_char(self, data_type: VarCharType) -> Tuple[str, tuple]: + def format_data_type_varchar(self, data_type: VarCharType) -> Tuple[str, tuple]: if data_type.length is not None: return f"VARCHAR({data_type.length})", () return "VARCHAR", () - @DDLTypeMixin.handles(TextType) def format_data_type_text(self, data_type: TextType) -> Tuple[str, tuple]: return "TEXT", () - @DDLTypeMixin.handles(BooleanType) def format_data_type_boolean(self, data_type: BooleanType) -> Tuple[str, tuple]: return "BOOLEAN", () - @DDLTypeMixin.handles(BlobType) def format_data_type_blob(self, data_type: BlobType) -> Tuple[str, tuple]: return "BYTEA", () - @DDLTypeMixin.handles(DateType) def format_data_type_date(self, data_type: DateType) -> Tuple[str, tuple]: return "DATE", () - @DDLTypeMixin.handles(TimeType) def format_data_type_time(self, data_type: TimeType) -> Tuple[str, tuple]: if data_type.precision is not None: + if not (0 <= data_type.precision <= 6): + raise ValueError( + f"TIME precision must be between 0 and 6 " + f"(PostgreSQL limit), got {data_type.precision}" + ) return f"TIME({data_type.precision})", () return "TIME", () - @DDLTypeMixin.handles(TimeTzType) - def format_data_type_time_tz(self, data_type: TimeTzType) -> Tuple[str, tuple]: + def format_data_type_timetz(self, data_type: TimeTzType) -> Tuple[str, tuple]: if data_type.precision is not None: + if not (0 <= data_type.precision <= 6): + raise ValueError( + f"TIME precision must be between 0 and 6 " + f"(PostgreSQL limit), got {data_type.precision}" + ) return f"TIME({data_type.precision}) WITH TIME ZONE", () return "TIME WITH TIME ZONE", () - @DDLTypeMixin.handles(DateTimeType) - def format_data_type_date_time(self, data_type: DateTimeType) -> Tuple[str, tuple]: + def format_data_type_datetime(self, data_type: DateTimeType) -> Tuple[str, tuple]: return "TIMESTAMP", () - @DDLTypeMixin.handles(TimestampType) def format_data_type_timestamp(self, data_type: TimestampType) -> Tuple[str, tuple]: if data_type.precision is not None: + if not (0 <= data_type.precision <= 6): + raise ValueError( + f"TIMESTAMP precision must be between 0 and 6 " + f"(PostgreSQL limit), got {data_type.precision}" + ) return f"TIMESTAMP({data_type.precision})", () return "TIMESTAMP", () - @DDLTypeMixin.handles(TimestampTzType) - def format_data_type_timestamp_tz(self, data_type: TimestampTzType) -> Tuple[str, tuple]: + def format_data_type_timestamptz(self, data_type: TimestampTzType) -> Tuple[str, tuple]: if data_type.precision is not None: + if not (0 <= data_type.precision <= 6): + raise ValueError( + f"TIMESTAMP precision must be between 0 and 6 " + f"(PostgreSQL limit), got {data_type.precision}" + ) return f"TIMESTAMP({data_type.precision}) WITH TIME ZONE", () return "TIMESTAMP WITH TIME ZONE", () - @DDLTypeMixin.handles(IntervalType) def format_data_type_interval(self, data_type: IntervalType) -> Tuple[str, tuple]: if data_type.fields is not None: return f"INTERVAL {data_type.fields}", () return "INTERVAL", () - @DDLTypeMixin.handles(JsonType) def format_data_type_json(self, data_type: JsonType) -> Tuple[str, tuple]: return "JSON", () - @DDLTypeMixin.handles(JsonBType) - def format_data_type_json_b(self, data_type: JsonBType) -> Tuple[str, tuple]: + def format_data_type_jsonb(self, data_type: JsonBType) -> Tuple[str, tuple]: return "JSONB", () + # ------------------------------------------------------------------ + # supports_data_type_ — per-type capability queries + # ------------------------------------------------------------------ + + def supports_data_type_postgres_enum(self) -> bool: + return True + + def supports_data_type_postgres_bytea(self) -> bool: + return True + + def supports_data_type_postgres_smallserial(self) -> bool: + return True + + def supports_data_type_postgres_serial(self) -> bool: + return True + + def supports_data_type_postgres_bigserial(self) -> bool: + return True + + def supports_data_type_postgres_uuid(self) -> bool: + return True + + def supports_data_type_postgres_xml(self) -> bool: + return True + + def supports_data_type_postgres_character_varying(self) -> bool: + return True + + def supports_data_type_postgres_tsvector(self) -> bool: + return True + + def supports_data_type_postgres_tsquery(self) -> bool: + return True + + def supports_data_type_postgres_jsonpath(self) -> bool: + return True + + def supports_data_type_postgres_bit(self) -> bool: + return True + + def supports_data_type_postgres_varbit(self) -> bool: + return True + + def supports_data_type_postgres_inet(self) -> bool: + return True + + def supports_data_type_postgres_cidr(self) -> bool: + return True + + def supports_data_type_postgres_macaddr(self) -> bool: + return True + + def supports_data_type_postgres_macaddr8(self) -> bool: + return True + + def supports_data_type_postgres_point(self) -> bool: + return True + + def supports_data_type_postgres_line(self) -> bool: + return True + + def supports_data_type_postgres_line_segment(self) -> bool: + return True + + def supports_data_type_postgres_box(self) -> bool: + return True + + def supports_data_type_postgres_path(self) -> bool: + return True + + def supports_data_type_postgres_polygon(self) -> bool: + return True + + def supports_data_type_postgres_circle(self) -> bool: + return True + + def supports_data_type_postgres_money(self) -> bool: + return True + + def supports_data_type_postgres_int4range(self) -> bool: + return True + + def supports_data_type_postgres_int8range(self) -> bool: + return True + + def supports_data_type_postgres_numrange(self) -> bool: + return True + + def supports_data_type_postgres_tsrange(self) -> bool: + return True + + def supports_data_type_postgres_tstzrange(self) -> bool: + return True + + def supports_data_type_postgres_daterange(self) -> bool: + return True + + def supports_data_type_postgres_int4multirange(self) -> bool: + return True + + def supports_data_type_postgres_int8multirange(self) -> bool: + return True + + def supports_data_type_postgres_nummultirange(self) -> bool: + return True + + def supports_data_type_postgres_tsmultirange(self) -> bool: + return True + + def supports_data_type_postgres_tstzmultirange(self) -> bool: + return True + + def supports_data_type_postgres_datemultirange(self) -> bool: + return True + + def supports_data_type_postgres_oid(self) -> bool: + return True + + def supports_data_type_postgres_regclass(self) -> bool: + return True + + def supports_data_type_postgres_regtype(self) -> bool: + return True + + def supports_data_type_postgres_xid(self) -> bool: + return True + + def supports_data_type_postgres_xid8(self) -> bool: + return True + + def supports_data_type_postgres_cid(self) -> bool: + return True + + def supports_data_type_postgres_tid(self) -> bool: + return True + + def supports_data_type_postgres_pg_lsn(self) -> bool: + return True + + def supports_data_type_postgres_hstore(self) -> bool: + return True + + def supports_data_type_postgres_geometry(self) -> bool: + return True + + def supports_data_type_postgres_geography(self) -> bool: + return True + + def supports_data_type_postgres_vector(self) -> bool: + return True + + def supports_data_type_postgres_halfvec(self) -> bool: + return True + + def supports_data_type_postgres_sparsevec(self) -> bool: + return True + + def supports_data_type_postgres_citext(self) -> bool: + return True + + def supports_data_type_postgres_cube(self) -> bool: + return True + + def supports_data_type_postgres_ltree(self) -> bool: + return True + + def supports_data_type_postgres_raster(self) -> bool: + return True + + def supports_data_type_postgres_array(self) -> bool: + return True + + # --- Core type support --- + + def supports_data_type_tinyint(self) -> bool: + return True + + def supports_data_type_smallint(self) -> bool: + return True + + def supports_data_type_int(self) -> bool: + return True + + def supports_data_type_integer(self) -> bool: + return True + + def supports_data_type_bigint(self) -> bool: + return True + + def supports_data_type_float(self) -> bool: + return True + + def supports_data_type_real(self) -> bool: + return True + + def supports_data_type_double(self) -> bool: + return True + + def supports_data_type_decimal(self) -> bool: + return True + + def supports_data_type_char(self) -> bool: + return True + + def supports_data_type_varchar(self) -> bool: + return True + + def supports_data_type_text(self) -> bool: + return True + + def supports_data_type_boolean(self) -> bool: + return True + + def supports_data_type_blob(self) -> bool: + return True + + def supports_data_type_date(self) -> bool: + return True + + def supports_data_type_time(self) -> bool: + return True + + def supports_data_type_timetz(self) -> bool: + return True + + def supports_data_type_datetime(self) -> bool: + return True + + def supports_data_type_timestamp(self) -> bool: + return True + + def supports_data_type_timestamptz(self) -> bool: + return True + + def supports_data_type_interval(self) -> bool: + return True + + def supports_data_type_json(self) -> bool: + return True + + def supports_data_type_jsonb(self) -> bool: + return True + + def supports_data_type_uuid(self) -> bool: + return True + + def supports_data_type_custom(self) -> bool: + return True + + def supports_data_type_array(self) -> bool: + return True + + # ------------------------------------------------------------------ + # Supported types advertisement + # ------------------------------------------------------------------ + + def supports_data_types(self) -> dict: + """Mapping ``{: concrete type class}`` of every type this + dialect renders. + + The formatters a dialect defines **are** the set of types it + supports (naming-convention dispatch has no registry), so this + derives the mapping from the ``format_data_type_`` methods + and checks ``supports_data_type_()`` for each. + """ + result = {} + for member_name in dir(type(self)): + match = re.match(r"^format_data_type_([a-z][a-z0-9_]*)$", member_name) + if not match: + continue + name = match.group(1) + supports = getattr(self, f"supports_data_type_{name}", None) + if supports is not None and supports(): + cls = self._type_class_for(name) + if cls is not None: + result[name] = cls + return result + + def suggested_data_types(self) -> dict: + """Cross-backend type-consistency suggestion map. + + PostgreSQL has native BYTEA for binary data (mapped via ``blob``), + and all core types it supports are rendered through their own + ``format_data_type_`` formatters. There are no core types + that PostgreSQL cannot handle natively and honestly suggest an + alternative for. + + Returns an empty dict — PostgreSQL covers every core type it + renders; suggestions are not needed. + """ + return {} + + def format_data_type_uuid(self, data_type) -> Tuple[str, tuple]: + return "UUID", () + + def format_data_type_custom(self, data_type) -> Tuple[str, tuple]: + return data_type.raw, () + + def format_data_type_array(self, data_type: ArrayType) -> Tuple[str, tuple]: + element_sql, _ = self.format_data_type(data_type.element_type) + return element_sql + "[]" * data_type.dimensions, () + # ------------------------------------------------------------------ # DDLTypeSupport — parsing # ------------------------------------------------------------------ @@ -538,7 +756,7 @@ def parse_type(self, raw: str) -> DataType: base_raw = stripped[:kw_match.start()] element_type = self.parse_type(base_raw) # PG normalises all array declarations to 1-D internally - return PostgresArrayType(element_type, dimensions=1) + return PostgresArrayType(element_type=element_type, dimensions=1, dialect=self) # Check for array bracket suffix: e.g. "INTEGER[]", "INTEGER[][]" remaining = stripped @@ -554,7 +772,7 @@ def parse_type(self, raw: str) -> DataType: if remaining != stripped: element_type = self.parse_type(remaining) # PG normalises all array declarations to 1-D internally - return PostgresArrayType(element_type, dimensions=1) + return PostgresArrayType(element_type=element_type, dimensions=1, dialect=self) # ---- Standard type dispatch ---- @@ -566,93 +784,93 @@ def parse_type(self, raw: str) -> DataType: PostgresSmallSerialType, ) if upper.startswith("BIGSERIAL") or upper.startswith("SERIAL8"): - return PostgresBigSerialType() + return PostgresBigSerialType(self) if upper.startswith("SMALLSERIAL") or upper.startswith("SERIAL2"): - return PostgresSmallSerialType() - return PostgresSerialType() + return PostgresSmallSerialType(self) + return PostgresSerialType(self) # Integer family if self._PG_INTEGER_TYPES.match(upper): if upper.startswith("SMALLINT") or upper.startswith("INT2"): - return SmallIntType() + return SmallIntType(self) if upper.startswith("BIGINT") or upper.startswith("INT8"): - return BigIntType() - return IntegerType() + return BigIntType(self) + return IntegerType(self) # Float family if self._PG_FLOAT_TYPES.match(upper): if upper.startswith("DOUBLE"): - return DoubleType() + return DoubleType(dialect=self) if upper.startswith("REAL") or upper.startswith("FLOAT4"): - return RealType() + return RealType(dialect=self) nums = re.findall(r"\d+", stripped) precision = int(nums[0]) if nums else None - return FloatType(precision) + return FloatType(dialect=self, precision=precision) # Decimal family if self._PG_DECIMAL_TYPES.match(upper): nums = re.findall(r"\d+", stripped) if len(nums) >= 2: - return DecimalType(int(nums[0]), int(nums[1])) + return DecimalType(dialect=self, precision=int(nums[0]), scale=int(nums[1])) if len(nums) == 1: - return DecimalType(int(nums[0])) - return DecimalType() + return DecimalType(dialect=self, precision=int(nums[0])) + return DecimalType(dialect=self) # String family if self._PG_STRING_TYPES.match(upper): length_match = re.search(r"\((\d+)\)", stripped) length = int(length_match.group(1)) if length_match else None if upper.startswith("VARCHAR") or upper.startswith("CHARACTER VARYING"): - return VarCharType(length) + return VarCharType(dialect=self, length=length) if upper.startswith("CHAR") or upper.startswith("CHARACTER"): - return CharType(length) - return TextType() + return CharType(dialect=self, length=length) + return TextType(dialect=self) # Binary if self._PG_BINARY_TYPES.match(upper): from ...expression.types import PostgresByteaType - return PostgresByteaType() + return PostgresByteaType(self) # Date/time if self._PG_DATE_TYPES.match(upper): if upper.startswith("DATETIME"): - return DateTimeType() + return DateTimeType(dialect=self) if upper.startswith("DATE"): - return DateType() + return DateType(dialect=self) if upper.startswith("TIMESTAMP"): nums = re.findall(r"\d+", stripped) precision = int(nums[0]) if nums else None if "WITH TIME ZONE" in upper or upper.startswith("TIMESTAMPTZ"): - return TimestampTzType(precision) - return TimestampType(precision) + return TimestampTzType(dialect=self, precision=precision) + return TimestampType(dialect=self, precision=precision) if upper.startswith("TIME"): nums = re.findall(r"\d+", stripped) precision = int(nums[0]) if nums else None if "WITH TIME ZONE" in upper or upper.startswith("TIMETZ"): - return TimeTzType(precision) - return TimeType(precision) + return TimeTzType(dialect=self, precision=precision) + return TimeType(dialect=self, precision=precision) if upper.startswith("INTERVAL"): fields_match = re.search(r"INTERVAL\s+(.*)", upper) fields = fields_match.group(1).strip() if fields_match else None - return IntervalType(fields) + return IntervalType(dialect=self, fields=fields) # JSON if self._PG_JSON_TYPES.match(upper): if upper.startswith("JSONB"): - return JsonBType() + return JsonBType(dialect=self) if upper.startswith("JSONPATH"): from ...expression.types import PostgresJsonPathType - return PostgresJsonPathType() - return JsonType() + return PostgresJsonPathType(dialect=self) + return JsonType(dialect=self) # UUID if self._PG_UUID_TYPES.match(upper): from ...expression.types import PostgresUUIDType - return PostgresUUIDType() + return PostgresUUIDType(dialect=self) # Boolean if upper.startswith("BOOLEAN") or upper.startswith("BOOL"): - return BooleanType() + return BooleanType(dialect=self) # Bit string if self._PG_BIT_TYPES.match(upper): @@ -660,9 +878,9 @@ def parse_type(self, raw: str) -> DataType: n = int(nums[0]) if nums else None if upper.startswith("BIT"): from ...expression.types import PostgresBitType - return PostgresBitType(n) + return PostgresBitType(n=n, dialect=self) from ...expression.types import PostgresVarBitType - return PostgresVarBitType(n) + return PostgresVarBitType(n=n, dialect=self) # Network address if self._PG_NET_TYPES.match(upper): @@ -673,12 +891,12 @@ def parse_type(self, raw: str) -> DataType: PostgresMacAddrType, ) if upper.startswith("CIDR"): - return PostgresCidrType() + return PostgresCidrType(self) if upper.startswith("MACADDR8"): - return PostgresMacAddr8Type() + return PostgresMacAddr8Type(self) if upper.startswith("MACADDR"): - return PostgresMacAddrType() - return PostgresInetType() + return PostgresMacAddrType(self) + return PostgresInetType(self) # Geometric if self._PG_GEOM_TYPES.match(upper): @@ -703,7 +921,7 @@ def parse_type(self, raw: str) -> DataType: for name, cls in geom_map.items(): if upper.startswith(name): return cls() - return PostgresPointType() + return PostgresPointType(self) # Range types if self._PG_RANGE_TYPES.match(upper): @@ -726,7 +944,7 @@ def parse_type(self, raw: str) -> DataType: for name, cls in range_map.items(): if upper.startswith(name): return cls() - return PostgresInt4RangeType() + return PostgresInt4RangeType(self) # Multirange types if self._PG_MULTIRANGE_TYPES.match(upper): @@ -749,7 +967,7 @@ def parse_type(self, raw: str) -> DataType: for name, cls in mr_map.items(): if upper.startswith(name): return cls() - return PostgresInt4MultirangeType() + return PostgresInt4MultirangeType(self) # OID types if self._PG_OID_TYPES.match(upper): @@ -774,7 +992,7 @@ def parse_type(self, raw: str) -> DataType: for name, cls in oid_map.items(): if upper.startswith(name): return cls() - return PostgresOIDType() + return PostgresOIDType(self) # Text search if self._PG_TS_TYPES.match(upper): @@ -783,8 +1001,8 @@ def parse_type(self, raw: str) -> DataType: PostgresTSVectorType, ) if upper.startswith("TSVECTOR"): - return PostgresTSVectorType() - return PostgresTSQueryType() + return PostgresTSVectorType(self) + return PostgresTSQueryType(self) # Miscellaneous if self._PG_MISC_TYPES.match(upper): @@ -797,25 +1015,42 @@ def parse_type(self, raw: str) -> DataType: PostgresXMLType, ) if upper.startswith("MONEY"): - return PostgresMoneyType() + return PostgresMoneyType(self) if upper.startswith("XML"): - return PostgresXMLType() + return PostgresXMLType(self) if upper.startswith("PG_LSN"): - return PostgresPgLSNType() + return PostgresPgLSNType(self) if upper.startswith("HSTORE"): - return PostgresHstoreType() + return PostgresHstoreType(self) if upper.startswith("GEOGRAPHY"): - return PostgresGeographyType() + return PostgresGeographyType(self) if upper.startswith("GEOMETRY"): - return PostgresGeometryType() - - # Vector type (pgvector) - if upper.startswith("VECTOR"): + return PostgresGeometryType(self) + + # Vector types (pgvector) + vector_match = re.match(r"^(VECTOR|HALFVEC|SPARSEVEC)\b", upper) + if vector_match: + if ( + hasattr(self, "_extensions") + and "vector" in self._extensions + and not self._extensions["vector"].installed + ): + raise RuntimeError( + "Cannot use pgvector types: the 'vector' (pgvector) extension " + "is not installed. Run: CREATE EXTENSION IF NOT EXISTS vector;" + ) nums = re.findall(r"\d+", stripped) dim = int(nums[0]) if nums else 0 + vector_kind = vector_match.group(1) + if vector_kind == "HALFVEC": + from ...expression.types import PostgresHalfvecType + return PostgresHalfvecType(dim=dim, dialect=self) + if vector_kind == "SPARSEVEC": + from ...expression.types import PostgresSparsevecType + return PostgresSparsevecType(dim=dim, dialect=self) from ...expression.types import PostgresVectorType - return PostgresVectorType(dim) + return PostgresVectorType(dim=dim, dialect=self) # Fallback from rhosocial.activerecord.backend.expression.types import CustomType - return CustomType(stripped) \ No newline at end of file + return CustomType(dialect=self, raw=stripped) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py index af5381c0..40e6ab7b 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/enum.py @@ -5,13 +5,18 @@ enumerated type operations. """ -from typing import Optional, List, Tuple, TYPE_CHECKING, Union +from typing import Optional, List, Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.impl.postgres.expression.ddl.type import ( PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, PostgresAlterEnumAddValueExpression, + EnumTypeNameExpression, + EnumValuesExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, ) @@ -36,6 +41,18 @@ def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: return f"{schema}.{name}" return name + def format_enum_type_name_expression(self, expr: "EnumTypeNameExpression") -> Tuple[str, tuple]: + """Format enum type name from expression object. + + Args: + expr: :class:`EnumTypeNameExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return (self.format_enum_type_name(expr.name, expr.schema), ()) + def format_enum_values(self, values: List[str]) -> str: """Format enum values list for SQL. @@ -48,6 +65,18 @@ def format_enum_values(self, values: List[str]) -> str: """ return ", ".join(f"'{v}'" for v in values) + def format_enum_values_expression(self, expr: "EnumValuesExpression") -> Tuple[str, tuple]: + """Format enum values list from expression object. + + Args: + expr: :class:`EnumValuesExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return (self.format_enum_values(expr.values), ()) + def format_create_enum_type_raw( self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False ) -> str: @@ -68,6 +97,24 @@ def format_create_enum_type_raw( exists_clause = "IF NOT EXISTS " if if_not_exists else "" return f"CREATE TYPE {exists_clause}{full_name} AS ENUM ({values_str})" + def format_create_enum_type_raw_expression(self, expr: "CreateEnumTypeExpression") -> Tuple[str, tuple]: + """Format CREATE TYPE ... AS ENUM from expression object. + + Args: + expr: :class:`CreateEnumTypeExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + sql = self.format_create_enum_type_raw( + expr.name, + expr.values, + expr.schema, + expr.if_not_exists, + ) + return (sql, ()) + def format_drop_enum_type_raw( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False ) -> str: @@ -88,6 +135,24 @@ def format_drop_enum_type_raw( cascade_clause = " CASCADE" if cascade else "" return f"DROP TYPE {exists_clause}{full_name}{cascade_clause}" + def format_drop_enum_type_raw_expression(self, expr: "DropEnumTypeExpression") -> Tuple[str, tuple]: + """Format DROP TYPE from expression object. + + Args: + expr: :class:`DropEnumTypeExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + sql = self.format_drop_enum_type_raw( + expr.name, + expr.schema, + expr.if_exists, + expr.cascade, + ) + return (sql, ()) + def format_alter_enum_add_value_raw( self, type_name: str, new_value: str, schema: Optional[str] = None, before: Optional[str] = None, after: Optional[str] = None @@ -113,6 +178,25 @@ def format_alter_enum_add_value_raw( sql += f" AFTER '{after}'" return sql + def format_alter_enum_add_value_raw_expression(self, expr: "AlterEnumAddValueExpression") -> Tuple[str, tuple]: + """Format ALTER TYPE ADD VALUE from expression object. + + Args: + expr: :class:`AlterEnumAddValueExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + sql = self.format_alter_enum_add_value_raw( + expr.type_name, + expr.new_value, + expr.schema, + expr.before, + expr.after, + ) + return (sql, ()) + # ========================================================================= # Convenience methods for EnumTypeSupport protocol # ========================================================================= @@ -188,135 +272,84 @@ def alter_enum_add_value( def format_create_enum_type( self, - expr_or_name: Union["PostgresCreateEnumTypeExpression", str], - values: Optional[List[str]] = None, - schema: Optional[str] = None, - if_not_exists: bool = False, - ) -> Union[str, Tuple[str, tuple]]: - """Format CREATE TYPE for either the legacy or expression-based API. - - **Expression mode** — if ``expr_or_name`` has a ``values`` attribute, - its ``.name``, ``.values``, ``.schema``, and ``.if_not_exists`` are - used and the result is wrapped in ``(sql, ())``. - - **Legacy mode** — ``expr_or_name`` is treated as a type name string; - ``values``, ``schema``, and ``if_not_exists`` must be supplied as - keyword arguments. + expr: "PostgresCreateEnumTypeExpression", + ) -> Tuple[str, tuple]: + """Format CREATE TYPE ... AS ENUM from its expression node. Args: - expr_or_name: Expression instance or type name string. - values: List of enum values (required in legacy mode). - schema: Optional schema name. - if_not_exists: Add ``IF NOT EXISTS``. + expr: :class:`PostgresCreateEnumTypeExpression` instance. Returns: - Tuple of (SQL string, params tuple) in expression mode, - plain SQL string in legacy mode. - - Raises: - TypeError: If ``values`` is ``None`` in legacy mode. + Tuple of (SQL string, params tuple). """ - if hasattr(expr_or_name, "values"): - sql = self.format_create_enum_type_raw( - expr_or_name.name, - expr_or_name.values, - expr_or_name.schema, - expr_or_name.if_not_exists, - ) - return (sql, ()) - - if values is None: - raise TypeError("values must be provided when formatting enum type by name") - - return self.format_create_enum_type_raw(expr_or_name, values, schema, if_not_exists) + sql = self.format_create_enum_type_raw( + expr.name, + expr.values, + expr.schema, + expr.if_not_exists, + ) + return (sql, ()) def format_drop_enum_type( self, - expr_or_name: Union["PostgresDropEnumTypeExpression", str], - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - ) -> Union[str, Tuple[str, tuple]]: - """Format DROP TYPE for either the legacy or expression-based API. - - **Expression mode** — if ``expr_or_name`` has an ``if_exists`` attribute, - its ``.name``, ``.schema``, ``.if_exists``, and ``.cascade`` are used - and the result is wrapped in ``(sql, ())``. - - **Legacy mode** — ``expr_or_name`` is treated as a type name string; - ``schema``, ``if_exists``, and ``cascade`` must be supplied as keyword - arguments. + expr: "PostgresDropEnumTypeExpression", + ) -> Tuple[str, tuple]: + """Format DROP TYPE from its expression node. Args: - expr_or_name: Expression instance or type name string. - schema: Optional schema name. - if_exists: Add ``IF EXISTS``. - cascade: Add ``CASCADE``. + expr: :class:`PostgresDropEnumTypeExpression` instance. Returns: - Tuple of (SQL string, params tuple) in expression mode, - plain SQL string in legacy mode. + Tuple of (SQL string, params tuple). """ - if hasattr(expr_or_name, "if_exists"): - sql = self.format_drop_enum_type_raw( - expr_or_name.name, - expr_or_name.schema, - expr_or_name.if_exists, - expr_or_name.cascade, - ) - return (sql, ()) - - return self.format_drop_enum_type_raw(expr_or_name, schema, if_exists, cascade) + sql = self.format_drop_enum_type_raw( + expr.name, + expr.schema, + expr.if_exists, + expr.cascade, + ) + return (sql, ()) def format_alter_enum_add_value( self, - expr_or_type_name: Union["PostgresAlterEnumAddValueExpression", str], - new_value: Optional[str] = None, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - ) -> Union[str, Tuple[str, tuple]]: - """Format ALTER TYPE ADD VALUE for either the legacy or expression-based API. + expr: "PostgresAlterEnumAddValueExpression", + ) -> Tuple[str, tuple]: + """Format ALTER TYPE ADD VALUE from its expression node. + + Args: + expr: :class:`PostgresAlterEnumAddValueExpression` instance. + + Returns: + Tuple of (SQL string, params tuple). + + """ + sql = self.format_alter_enum_add_value_raw( + expr.type_name, + expr.new_value, + expr.schema, + expr.before, + expr.after, + ) + return (sql, ()) - **Expression mode** — if ``expr_or_type_name`` has a ``new_value`` attribute, - its ``.type_name``, ``.new_value``, ``.schema``, ``.before``, and ``.after`` - are used and the result is wrapped in ``(sql, ())``. + def format_enum_type_expression(self, expr) -> Tuple[str, tuple]: + """Format a :class:`PostgresEnumType` type reference expression. - **Legacy mode** — ``expr_or_type_name`` is treated as a type name string; - ``new_value``, ``schema``, ``before``, and ``after`` must be supplied as - keyword arguments. + The reference is schema-qualified when the expression carries a + schema: ``"schema"."name"`` / ``"name"``. Args: - expr_or_type_name: Expression instance or type name string. - new_value: New enum value (required in legacy mode). - schema: Optional schema name. - before: Insert before this existing value. - after: Insert after this existing value. + expr: :class:`PostgresEnumType` instance. Returns: - Tuple of (SQL string, params tuple) in expression mode, - plain SQL string in legacy mode. - - Raises: - TypeError: If ``new_value`` is ``None`` in legacy mode. + Tuple of (SQL string, empty params tuple). """ - if hasattr(expr_or_type_name, "new_value"): - sql = self.format_alter_enum_add_value_raw( - expr_or_type_name.type_name, - expr_or_type_name.new_value, - expr_or_type_name.schema, - expr_or_type_name.before, - expr_or_type_name.after, - ) - return (sql, ()) - - if new_value is None: - raise TypeError("new_value must be provided when formatting enum value by name") - - return self.format_alter_enum_add_value_raw(expr_or_type_name, new_value, schema, before, after) + if expr.schema: + return (f"{expr.schema}.{expr.name}", ()) + return (expr.name, ()) def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: """Format ALTER TYPE ADD VALUE statement from expression object. diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py index 08b35287..4074e3c7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/jsonb_enhanced.py @@ -5,7 +5,7 @@ Implements the PostgresJSONBEnhancedSupport protocol. """ -from typing import TYPE_CHECKING +from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: from rhosocial.activerecord.backend.expression import JSONExpression @@ -18,7 +18,7 @@ class PostgresJSONBEnhancedMixin: JSON and JSONB features beyond the standard JSONSupport protocol. """ - def format_json_expression(self, expr: "JSONExpression"): + def format_json_expression(self, expr: "JSONExpression") -> Tuple[str, tuple]: from rhosocial.activerecord.backend.expression import bases if isinstance(expr.column, bases.BaseExpression): @@ -34,10 +34,6 @@ def format_json_expression(self, expr: "JSONExpression"): sql = value_sql params = col_params + (expr.path,) - if expr.cast_types: - for target_type in expr.cast_types: - sql, params = self.format_cast_expression(sql, target_type, params, None) - if expr.alias: sql = f"{sql} AS {self.format_identifier(expr.alias)}" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py index d11537e6..73fac075 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/types/multirange.py @@ -14,7 +14,13 @@ - format_multirange_agg_function: Complete SELECT query template """ -from typing import Optional, Tuple +from typing import Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.impl.postgres.expression.ddl.multirange import ( + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, + ) class MultirangeMixin: @@ -90,3 +96,39 @@ def format_multirange_agg_function( if where_clause: sql += f" WHERE {where_clause}" return (sql, ()) + + # ========================================================================= + # Expression-based format methods + # ========================================================================= + + def format_create_multirange_type_statement_expression( + self, expr: "CreateMultirangeTypeExpression" + ) -> Tuple[str, tuple]: + """Format CREATE TYPE ... AS MULTIRANGE from expression object. + + Args: + expr: :class:`CreateMultirangeTypeExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.format_create_multirange_type_statement( + expr.name, expr.range_type, expr.schema + ) + + def format_multirange_agg_function_expression( + self, expr: "MultirangeAggFunctionExpression" + ) -> Tuple[str, tuple]: + """Format multirange_agg aggregate function call from expression object. + + Args: + expr: :class:`MultirangeAggFunctionExpression` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + return self.format_multirange_agg_function( + expr.range_column, expr.table_name, expr.where_clause, expr.schema + ) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py index 31612d0e..ef74295f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -17,3 +17,74 @@ def supports_on_conflict_clause(self) -> bool: def supports_multiple_on_conflict_clauses(self) -> bool: """PostgreSQL grammar allows only a single ON CONFLICT clause per INSERT.""" return False + + def format_on_conflict_clause(self, expr) -> tuple: + """Format ON CONFLICT clause for PostgreSQL. + + Overrides the base implementation to handle EXCLUDED pseudo-table + references without quoting, as EXCLUDED is a special PostgreSQL + keyword in ON CONFLICT context and must not be double-quoted. + + - ``expr.conflict_target`` — optional list of column names / expressions. + - ``expr.do_nothing`` — ``DO NOTHING``. + - ``expr.update_assignments`` — dict of ``{column: expression}`` for ``DO UPDATE SET``. + - ``expr.update_where`` — optional WHERE predicate for the update. + + Args: + expr: OnConflictClause expression instance + + Returns: + Tuple of (SQL string, params tuple) + + """ + from rhosocial.activerecord.backend.expression import bases + from rhosocial.activerecord.backend.expression.core import Column + + all_params = [] + parts = ["ON CONFLICT"] + + # Add conflict target if specified + if expr.conflict_target: + target_parts = [] + for target in expr.conflict_target: + if isinstance(target, str): + target_parts.append(self.format_identifier(target)) + elif hasattr(target, 'to_sql'): + target_sql, target_params = target.to_sql() + target_parts.append(target_sql) + all_params.extend(target_params) + else: + target_parts.append(self.format_identifier(str(target))) + if target_parts: + parts.append(f"({', '.join(target_parts)})") + + # Add DO NOTHING or DO UPDATE + if expr.do_nothing: + parts.append("DO NOTHING") + elif expr.update_assignments: + update_parts = [] + for col, expr_val in expr.update_assignments.items(): + if isinstance(expr_val, Column) and getattr(expr_val, 'table', None) == 'EXCLUDED': + # EXCLUDED is a special pseudo-table in PostgreSQL ON CONFLICT. + # It must NOT be double-quoted, only the column name should be quoted. + val_sql = f'EXCLUDED.{self.format_identifier(expr_val.name)}' + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + elif isinstance(expr_val, bases.BaseExpression): + val_sql, val_params = expr_val.to_sql() + update_parts.append(f"{self.format_identifier(col)} = {val_sql}") + all_params.extend(val_params) + else: + update_parts.append(f"{self.format_identifier(col)} = {self.get_parameter_placeholder()}") + all_params.append(expr_val) + + parts.append(f"DO UPDATE SET {', '.join(update_parts)}") + + # Add WHERE clause if specified + if expr.update_where: + where_sql, where_params = expr.update_where.to_sql() + parts.append(f"WHERE {where_sql}") + all_params.extend(where_params) + else: + parts.append("DO NOTHING") + + return " ".join(parts), tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py index 14dcd2a0..0e8f83d0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py @@ -31,3 +31,44 @@ def supports_materialized_view_tablespace(self) -> bool: def supports_materialized_view_storage_options(self) -> bool: return True + + def format_create_view_statement(self, expr) -> tuple: + """Format CREATE VIEW statement for PostgreSQL. + + - ``expr.temporary`` — add ``TEMPORARY``. + - ``expr.replace`` — add ``OR REPLACE``. + - ``expr.view_name`` — view name (identifier). + - ``expr.column_aliases`` — optional list of column aliases. + - ``expr.query`` — source SELECT expression. + - ``expr.options.check_option`` — ``WITH {LOCAL|CASCADED} CHECK OPTION``. + + Args: + expr: CreateViewExpression instance + + Returns: + Tuple of (SQL string, params tuple) + + """ + parts = ["CREATE"] + + if expr.temporary: + parts.append("TEMPORARY") + + if expr.replace: + parts.append("OR REPLACE") + + parts.append("VIEW") + parts.append(self.format_identifier(expr.view_name)) + + if expr.column_aliases: + cols = ", ".join(self.format_identifier(c) for c in expr.column_aliases) + parts.append(f"({cols})") + + query_sql, query_params = expr.query.to_sql() + parts.append(f"AS {query_sql}") + + if expr.options and expr.options.check_option: + check_option = expr.options.check_option.value + parts.append(f"WITH {check_option} CHECK OPTION") + + return " ".join(parts), query_params diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py index 470f9a55..ec2752c6 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py @@ -11,3 +11,5 @@ class PostgresExplainSupport(Protocol): def supports_explain_analyze(self)-> bool: ... def supports_explain_format(self, format_type: str)-> bool: ... + + def format_explain_statement(self, explain_expr) -> tuple: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pgvector.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pgvector.py index f208adf7..53146cee 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pgvector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/extensions/pgvector.py @@ -60,6 +60,7 @@ def format_create_vector_index_statement( ef_construction: Optional[int] = None, lists: Optional[int] = None, schema: Optional[str] = None, + opclass: str = "vector_cosine_ops", ) -> Tuple[str, tuple]: """Format CREATE INDEX statement for vector column.""" ... @@ -71,6 +72,7 @@ def format_create_hnsw_index_statement( index_name: Optional[str] = None, m: Optional[int] = None, ef_construction: Optional[int] = None, + opclass: str = "vector_cosine_ops", ) -> str: """Format CREATE INDEX statement with HNSW index for vector column.""" ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py index 0356671e..31723b24 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/schema.py @@ -8,6 +8,8 @@ class PostgresSchemaSupport(Protocol): """PostgreSQL schema feature support protocol.""" + def supports_schema(self)-> bool: ... + def supports_create_schema(self)-> bool: ... def supports_drop_schema(self)-> bool: ... @@ -17,3 +19,5 @@ def supports_schema_if_not_exists(self)-> bool: ... def supports_schema_if_exists(self)-> bool: ... def supports_schema_cascade(self)-> bool: ... + + def supports_schema_authorization(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py index 9ff7b6a0..5d65ea23 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -46,7 +46,7 @@ def supports_unlogged_table(self) -> bool: """ ... - def supports_table_like_syntax(self) -> bool: + def supports_create_table_like(self) -> bool: """Whether CREATE TABLE (LIKE ...) syntax is supported. PostgreSQL supports CREATE TABLE (LIKE other_table) with diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py index ee45d091..ae44b85c 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py @@ -11,3 +11,5 @@ class PostgresTruncateSupport(Protocol): def supports_truncate_restart_identity(self)-> bool: ... def supports_truncate_cascade(self)-> bool: ... + + def format_truncate_statement(self, expr) -> tuple: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py index 03645bc8..19f44b14 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/enum.py @@ -4,7 +4,7 @@ This module defines the protocol for PostgreSQL-specific ENUM type management. """ -from typing import Protocol, runtime_checkable, List, Optional, Tuple, Union, TYPE_CHECKING +from typing import Protocol, runtime_checkable, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ...expression.ddl.type import ( @@ -108,21 +108,15 @@ def format_enum_values(self, values: List[str]) -> str: def format_create_enum_type( self, - expr_or_name: Union["PostgresCreateEnumTypeExpression", str], - values: Optional[List[str]] = None, - schema: Optional[str] = None, - if_not_exists: bool = False, - ) -> Union[str, Tuple[str, tuple]]: - """Format CREATE TYPE for either the legacy or expression-based API. + expr: "PostgresCreateEnumTypeExpression", + ) -> Tuple[str, tuple]: + """Format CREATE TYPE from its expression node. Args: - expr_or_name: PostgresCreateEnumTypeExpression instance or type name string - values: List of allowed values (required when expr_or_name is a string) - schema: Optional schema name - if_not_exists: Add IF NOT EXISTS clause + expr: PostgresCreateEnumTypeExpression instance Returns: - SQL statement string or tuple of (SQL string, params tuple) + Tuple of (SQL string, params tuple) """ ... @@ -144,21 +138,15 @@ def format_create_enum_type_raw( def format_drop_enum_type( self, - expr_or_name: Union["PostgresDropEnumTypeExpression", str], - schema: Optional[str] = None, - if_exists: bool = False, - cascade: bool = False, - ) -> Union[str, Tuple[str, tuple]]: - """Format DROP TYPE for either the legacy or expression-based API. + expr: "PostgresDropEnumTypeExpression", + ) -> Tuple[str, tuple]: + """Format DROP TYPE from its expression node. Args: - expr_or_name: PostgresDropEnumTypeExpression instance or type name string - schema: Optional schema name - if_exists: Add IF EXISTS clause - cascade: Add CASCADE clause + expr: PostgresDropEnumTypeExpression instance Returns: - SQL statement string or tuple of (SQL string, params tuple) + Tuple of (SQL string, params tuple) """ ... @@ -180,23 +168,26 @@ def format_drop_enum_type_raw( def format_alter_enum_add_value( self, - expr_or_type_name: Union["PostgresAlterEnumAddValueExpression", str], - new_value: Optional[str] = None, - schema: Optional[str] = None, - before: Optional[str] = None, - after: Optional[str] = None, - ) -> Union[str, Tuple[str, tuple]]: - """Format ALTER TYPE ADD VALUE for either the legacy or expression-based API. + expr: "PostgresAlterEnumAddValueExpression", + ) -> Tuple[str, tuple]: + """Format ALTER TYPE ADD VALUE from its expression node. Args: - expr_or_type_name: PostgresAlterEnumAddValueExpression instance or type name string - new_value: New value to add (required when expr_or_type_name is a string) - schema: Optional schema name - before: Add before this value - after: Add after this value + expr: PostgresAlterEnumAddValueExpression instance + + Returns: + Tuple of (SQL string, params tuple) + """ + ... + + def format_enum_type_expression(self, expr) -> Tuple[str, tuple]: + """Format a PostgresEnumType type reference expression. + + Args: + expr: PostgresEnumType instance Returns: - SQL statement string or tuple of (SQL string, params tuple) + Tuple of (SQL string, params tuple) """ ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/reserved_words.py b/src/rhosocial/activerecord/backend/impl/postgres/reserved_words.py new file mode 100644 index 00000000..35686517 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/reserved_words.py @@ -0,0 +1,24 @@ +# src/rhosocial/activerecord/backend/impl/postgresql/reserved_words.py +""" +PostgreSQL reserved words list. + +Source: PostgreSQL 16 Documentation (https://www.postgresql.org/docs/16/sql-keywords-appendix.html) +""" + +POSTGRESQL_RESERVED_WORDS = frozenset({ + "all", "analyse", "analyze", "and", "any", "array", "as", "asc", + "asymmetric", "both", "case", "cast", "check", "collate", "column", + "constraint", "create", "cross", "current_catalog", "current_date", + "current_role", "current_schema", "current_time", "current_timestamp", + "current_user", "default", "deferrable", "desc", "distinct", "do", + "else", "end", "except", "false", "fetch", "for", "foreign", "from", + "full", "grant", "group", "having", "ilike", "in", "initially", + "inner", "intersect", "into", "is", "isnull", "join", "lateral", + "leading", "left", "like", "limit", "localtime", "localtimestamp", + "natural", "not", "notnull", "null", "offset", "on", "only", "or", + "order", "outer", "overlaps", "placing", "primary", "references", + "returning", "right", "select", "session_user", "similar", "some", + "symmetric", "table", "then", "to", "trailing", "true", "union", + "unique", "user", "using", "variadic", "verbose", "when", "where", + "window", "with", +}) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py index e4c98cea..c4aa4b6d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/types/enum.py @@ -113,23 +113,10 @@ def schema(self) -> Optional[str]: """Schema name, if any.""" return self._schema - def to_sql(self) -> Tuple[str, tuple]: - """Generate SQL type reference for use in column definitions. - - Returns: - Tuple of (SQL string, empty params tuple) - - Examples: - >>> status_enum.to_sql() - ('video_status', ()) - - >>> # With schema - >>> status_enum.to_sql() - ('app.video_status', ()) - """ - if self._schema: - return (f"{self._schema}.{self._name}", ()) - return (self._name, ()) + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_enum_type_expression" def validate_value(self, value: str) -> bool: """Check if a value is valid for this enum. @@ -167,7 +154,9 @@ def from_python_enum( return cls(dialect=dialect, name=type_name, values=values, schema=schema) def __str__(self) -> str: - return self.to_sql()[0] + if self._schema: + return f"{self._schema}.{self._name}" + return self._name def __repr__(self) -> str: return ( diff --git a/tests/conftest.py b/tests/conftest.py index 34dcd084..4d68a27f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -105,12 +105,18 @@ def pytest_addoption(parser): default=False, help='Scenario parallel mode: distribute scenarios across workers, keep each scenario on one worker.', ) - parser.addoption( - '--scenarios', - default=None, - help='Comma-separated list of scenario names to run (e.g., --scenarios=postgres_16,postgres_17). ' - 'Compatible with pytest -k "" as a native alternative.', - ) + # The generic --scenarios option is registered by the testsuite conftest + # (loaded via ``addopts``); register it here only when it is not already + # present so the two conftests can coexist. + try: + parser.addoption( + '--scenarios', + default=None, + help='Comma-separated list of scenario names to run (e.g., --scenarios=postgres_16,postgres_17). ' + 'Compatible with pytest -k "" as a native alternative.', + ) + except ValueError: + pass def pytest_configure(config): diff --git a/tests/providers/basic.py b/tests/providers/basic.py index 964849be..bcd7be81 100644 --- a/tests/providers/basic.py +++ b/tests/providers/basic.py @@ -350,6 +350,11 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = N else: raise ValueError("No scenarios registered") config_dict = SCENARIO_MAP[scenario_name] + from .pooling import resolve_database_name + + pooled_db = resolve_database_name(scenario_name) + if pooled_db: + config_dict = {**config_dict, "database": pooled_db} return { 'backend_module': 'rhosocial.activerecord.backend.impl.postgres', 'backend_class_name': backend_class_name, diff --git a/tests/providers/fixtures/basic.py b/tests/providers/fixtures/basic.py index aaea9129..fec5d58f 100644 --- a/tests/providers/fixtures/basic.py +++ b/tests/providers/fixtures/basic.py @@ -57,21 +57,21 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("is_active", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "username", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "balance", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "is_active", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], ) @@ -86,33 +86,33 @@ def create_type_cases_table(dialect, table_name: str = "type_cases") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresUUIDType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("username", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tiny_int", SmallIntType()), - ColumnDefinition("small_int", SmallIntType()), - ColumnDefinition("big_int", BigIntType()), - ColumnDefinition("float_val", FloatType()), - ColumnDefinition("double_val", DoubleType()), - ColumnDefinition("decimal_val", DecimalType(precision=10, scale=4)), - ColumnDefinition("char_val", VarCharType(255)), - ColumnDefinition("varchar_val", VarCharType(255)), - ColumnDefinition("text_val", TextType()), - ColumnDefinition("date_val", DateType()), - ColumnDefinition("time_val", TimeTzType()), - ColumnDefinition("timestamp_val", TimestampTzType()), - ColumnDefinition("blob_val", PostgresByteaType()), - ColumnDefinition("json_val", JsonBType()), - ColumnDefinition("array_val", ArrayType(IntegerType())), - ColumnDefinition("is_active", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition(dialect, "id", PostgresUUIDType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "username", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "tiny_int", SmallIntType(dialect=dialect)), + ColumnDefinition(dialect, "small_int", SmallIntType(dialect=dialect)), + ColumnDefinition(dialect, "big_int", BigIntType(dialect=dialect)), + ColumnDefinition(dialect, "float_val", FloatType(dialect=dialect)), + ColumnDefinition(dialect, "double_val", DoubleType(dialect=dialect)), + ColumnDefinition(dialect, "decimal_val", DecimalType(precision=10, scale=4, dialect=dialect)), + ColumnDefinition(dialect, "char_val", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "varchar_val", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "text_val", TextType(dialect=dialect)), + ColumnDefinition(dialect, "date_val", DateType(dialect=dialect)), + ColumnDefinition(dialect, "time_val", TimeTzType(dialect=dialect)), + ColumnDefinition(dialect, "timestamp_val", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "blob_val", PostgresByteaType(dialect=dialect)), + ColumnDefinition(dialect, "json_val", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "array_val", ArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), + ColumnDefinition(dialect, "is_active", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["id"]), ], ) @@ -128,25 +128,25 @@ def create_type_tests_table(dialect, table_name: str = "type_tests") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresUUIDType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("string_field", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("int_field", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("float_field", FloatType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("decimal_field", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("bool_field", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("datetime_field", TimestampTzType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("json_field", JsonBType()), - ColumnDefinition("nullable_field", VarCharType(255)), + ColumnDefinition(dialect, "id", PostgresUUIDType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "string_field", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "int_field", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "float_field", FloatType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "decimal_field", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "bool_field", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "datetime_field", TimestampTzType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "json_field", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "nullable_field", VarCharType(length=255, dialect=dialect)), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["id"]), ], ) @@ -162,24 +162,24 @@ def create_validated_field_users_table(dialect, table_name: str = "validated_fie table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("credit_score", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=300)]), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="active")]), - ColumnDefinition("is_active", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "username", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "balance", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "credit_score", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=300)]), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="active")]), + ColumnDefinition(dialect, "is_active", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), ], ) @@ -194,13 +194,13 @@ def create_validated_users_table(dialect, table_name: str = "validated_users") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "username", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect)), ], ) @@ -215,17 +215,17 @@ def create_pydantic_validated_models_table(dialect, table_name: str = "pydantic_ table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("code", VarCharType(32)), - ColumnDefinition("quantity", IntegerType()), - ColumnDefinition("step_count", IntegerType()), - ColumnDefinition("price", DecimalType(precision=10, scale=2)), - ColumnDefinition("start_at", TimestampTzType()), - ColumnDefinition("end_at", TimestampTzType()), - ColumnDefinition("status", VarCharType(32)), - ColumnDefinition("normalized_name", VarCharType(50)), - ColumnDefinition("created_token", VarCharType(255)), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "code", VarCharType(length=32, dialect=dialect)), + ColumnDefinition(dialect, "quantity", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "step_count", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect)), + ColumnDefinition(dialect, "start_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "end_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "status", VarCharType(length=32, dialect=dialect)), + ColumnDefinition(dialect, "normalized_name", VarCharType(length=50, dialect=dialect)), + ColumnDefinition(dialect, "created_token", VarCharType(length=255, dialect=dialect)), ], ) @@ -240,14 +240,14 @@ def create_bulk_users_table(dialect, table_name: str = "bulk_users") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="")]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="")]), ], ) @@ -262,21 +262,21 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("author", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("published_at", TimestampTzType()), - ColumnDefinition("published", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "author", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "published_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "published", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -292,24 +292,24 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("post_ref", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("author", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("text", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampTzType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("updated_at", TimestampTzType()), - ColumnDefinition("approved", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "post_ref", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "author", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "text", TextType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "approved", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), ], table_constraints=[ - ForeignKeyConstraint(columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["post_ref"], foreign_key_table="posts", foreign_key_columns=["id"], on_delete=_CASCADE), - ForeignKeyConstraint(columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["author"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -325,13 +325,13 @@ def create_column_mapping_items_table(dialect, table_name: str = "column_mapping table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("item_total", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("remarks", IntegerType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "item_total", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "remarks", IntegerType(dialect=dialect)), ], ) @@ -346,14 +346,14 @@ def create_mixed_annotation_items_table(dialect, table_name: str = "mixed_annota table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tags", ArrayType(TextType())), - ColumnDefinition("meta", TextType()), - ColumnDefinition("description", TextType()), - ColumnDefinition("status", TextType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "tags", ArrayType(element_type=TextType(dialect=dialect), dialect=dialect)), + ColumnDefinition(dialect, "meta", TextType(dialect=dialect)), + ColumnDefinition(dialect, "description", TextType(dialect=dialect)), + ColumnDefinition(dialect, "status", TextType(dialect=dialect)), ], ) @@ -368,17 +368,17 @@ def create_type_adapter_tests_table(dialect, table_name: str = "type_adapter_tes table=table_name, if_not_exists=True, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("optional_name", VarCharType(255)), - ColumnDefinition("optional_age", IntegerType()), - ColumnDefinition("last_login", TimestampTzType()), - ColumnDefinition("is_premium", BooleanType()), - ColumnDefinition("unsupported_union", VarCharType(255)), - ColumnDefinition("custom_bool", VarCharType(10)), - ColumnDefinition("optional_custom_bool", VarCharType(10)), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "optional_name", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "optional_age", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "last_login", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "is_premium", BooleanType(dialect=dialect)), + ColumnDefinition(dialect, "unsupported_union", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "custom_bool", VarCharType(length=10, dialect=dialect)), + ColumnDefinition(dialect, "optional_custom_bool", VarCharType(length=10, dialect=dialect)), ], ) @@ -393,18 +393,18 @@ def create_composite_pk_order_items_table(dialect, table_name: str = "order_item table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("order_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "order_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "product_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "quantity", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["order_id", "product_id"]), ], ) @@ -420,18 +420,18 @@ def create_store_inventory_table(dialect, table_name: str = "store_inventory") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("store_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("batch_id", VarCharType(64), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("stock", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition(dialect, "store_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "product_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "batch_id", VarCharType(length=64, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "stock", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), ], table_constraints=[ - TableConstraint(constraint_type=TableConstraintType.PRIMARY_KEY, + TableConstraint(dialect, constraint_type=TableConstraintType.PRIMARY_KEY, columns=["store_id", "product_id", "batch_id"]), ], ) @@ -447,12 +447,12 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("total", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TextType()), - ColumnDefinition("updated_at", TextType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "total", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TextType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TextType(dialect=dialect)), ], ) @@ -467,14 +467,14 @@ def create_product_table(dialect, table_name: str = "product") -> CreateTableExp table=table_name, if_not_exists=True, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("price", FloatType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", TextType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "price", FloatType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "quantity", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], ) diff --git a/tests/providers/fixtures/events.py b/tests/providers/fixtures/events.py index 39960fb5..3e942487 100644 --- a/tests/providers/fixtures/events.py +++ b/tests/providers/fixtures/events.py @@ -35,19 +35,19 @@ def create_event_tests_table(dialect, table_name: str = "event_tests") -> Create table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), - ColumnDefinition("revision", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + ColumnDefinition(dialect, "revision", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], ) @@ -62,16 +62,16 @@ def create_event_tracking_models_table(dialect, table_name: str = "event_trackin table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("view_count", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0)]), - ColumnDefinition("last_viewed_at", TimestampTzType(), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "view_count", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0)]), + ColumnDefinition(dialect, "last_viewed_at", TimestampTzType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) diff --git a/tests/providers/fixtures/mixins.py b/tests/providers/fixtures/mixins.py index b7c8f832..9b706da8 100644 --- a/tests/providers/fixtures/mixins.py +++ b/tests/providers/fixtures/mixins.py @@ -37,21 +37,21 @@ def create_combined_articles_table(dialect, table_name: str = "combined_articles table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="draft")]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), - ColumnDefinition("version", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("deleted_at", TimestampTzType(), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="draft")]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "version", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "deleted_at", TimestampTzType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) @@ -66,15 +66,15 @@ def create_tasks_table(dialect, table_name: str = "tasks") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("is_completed", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), - ColumnDefinition("deleted_at", TimestampTzType(), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "is_completed", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition(dialect, "deleted_at", TimestampTzType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), ], ) @@ -89,13 +89,13 @@ def create_timestamped_posts_table(dialect, table_name: str = "timestamped_posts table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], ) @@ -110,16 +110,16 @@ def create_versioned_products_table(dialect, table_name: str = "versioned_produc table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("price", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("version", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "version", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), ], ) diff --git a/tests/providers/fixtures/query.py b/tests/providers/fixtures/query.py index 86e279fc..2f0fbdec 100644 --- a/tests/providers/fixtures/query.py +++ b/tests/providers/fixtures/query.py @@ -42,21 +42,21 @@ def create_users_table(dialect, table_name: str = "users") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("balance", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("is_active", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "username", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "balance", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "is_active", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], ) @@ -71,21 +71,21 @@ def create_posts_table(dialect, table_name: str = "posts") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("title", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType()), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="published")]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "title", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="published")]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -101,23 +101,23 @@ def create_comments_table(dialect, table_name: str = "comments") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("post_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("content", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("is_hidden", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=False)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "post_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "content", TextType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "is_hidden", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=False)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), - ForeignKeyConstraint(columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["post_id"], foreign_key_table="posts", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -133,23 +133,23 @@ def create_orders_table(dialect, table_name: str = "orders") -> CreateTableExpre table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("order_number", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "order_number", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), ], ) @@ -164,25 +164,25 @@ def create_order_items_table(dialect, table_name: str = "order_items") -> Create table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("order_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("unit_price", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("subtotal", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "order_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "product_name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "quantity", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "unit_price", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "subtotal", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="orders", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -198,17 +198,17 @@ def create_profiles_table(dialect, table_name: str = "profiles") -> CreateTableE table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("bio", TextType()), - ColumnDefinition("avatar_url", VarCharType(512)), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "bio", TextType(dialect=dialect)), + ColumnDefinition(dialect, "avatar_url", VarCharType(length=512, dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"]), ], ) @@ -223,22 +223,22 @@ def create_json_users_table(dialect, table_name: str = "json_users") -> CreateTa table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("username", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("email", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("age", IntegerType()), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), - ColumnDefinition("settings", JsonBType()), - ColumnDefinition("tags", JsonBType()), - ColumnDefinition("profile", JsonBType()), - ColumnDefinition("roles", JsonBType()), - ColumnDefinition("scores", JsonBType()), - ColumnDefinition("subscription", JsonBType()), - ColumnDefinition("preferences", JsonBType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "username", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "email", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "age", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "settings", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "tags", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "profile", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "roles", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "scores", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "subscription", JsonBType(dialect=dialect)), + ColumnDefinition(dialect, "preferences", JsonBType(dialect=dialect)), ], ) @@ -253,20 +253,20 @@ def create_nodes_table(dialect, table_name: str = "nodes") -> CreateTableExpress table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("parent_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NULL)]), - ColumnDefinition("value", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "parent_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NULL)]), + ColumnDefinition(dialect, "value", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["parent_id"], foreign_key_table="nodes", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -282,12 +282,12 @@ def create_searchable_items_table(dialect, table_name: str = "searchable_items") table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(255)), - ColumnDefinition("tags", TextType()), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "tags", TextType(dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], ) @@ -302,34 +302,34 @@ def create_extended_orders_table(dialect, table_name: str = "extended_orders") - table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("order_number", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("total_amount", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=0.00)]), - ColumnDefinition("status", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="pending")]), - ColumnDefinition("priority", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="medium")]), - ColumnDefinition("region", VarCharType(50), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value="default")]), - ColumnDefinition("category", VarCharType(255)), - ColumnDefinition("product", VarCharType(255)), - ColumnDefinition("department", VarCharType(255)), - ColumnDefinition("year", VarCharType(10)), - ColumnDefinition("quarter", VarCharType(10)), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "order_number", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "total_amount", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=0.00)]), + ColumnDefinition(dialect, "status", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="pending")]), + ColumnDefinition(dialect, "priority", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="medium")]), + ColumnDefinition(dialect, "region", VarCharType(length=50, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value="default")]), + ColumnDefinition(dialect, "category", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "product", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "department", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "year", VarCharType(length=10, dialect=dialect)), + ColumnDefinition(dialect, "quarter", VarCharType(length=10, dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["user_id"], foreign_key_table="users", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) @@ -345,24 +345,24 @@ def create_extended_order_items_table(dialect, table_name: str = "extended_order table=table_name, if_not_exists=False, columns=[ - ColumnDefinition("id", PostgresSerialType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("order_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("product_name", VarCharType(255), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("quantity", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=1)]), - ColumnDefinition("price", DecimalType(precision=10, scale=2), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("category", VarCharType(255)), - ColumnDefinition("region", VarCharType(50)), - ColumnDefinition("created_at", TimestampTzType()), - ColumnDefinition("updated_at", TimestampTzType()), + ColumnDefinition(dialect, "id", PostgresSerialType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "order_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "product_name", VarCharType(length=255, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "quantity", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=1)]), + ColumnDefinition(dialect, "price", DecimalType(precision=10, scale=2, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "category", VarCharType(length=255, dialect=dialect)), + ColumnDefinition(dialect, "region", VarCharType(length=50, dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampTzType(dialect=dialect)), + ColumnDefinition(dialect, "updated_at", TimestampTzType(dialect=dialect)), ], table_constraints=[ - ForeignKeyConstraint(columns=["order_id"], foreign_key_table="extended_orders", foreign_key_columns=["id"], + ForeignKeyConstraint(dialect, columns=["order_id"], foreign_key_table="extended_orders", foreign_key_columns=["id"], on_delete=_CASCADE), ], ) diff --git a/tests/providers/partition.py b/tests/providers/partition.py index 64a73d68..dd6bc575 100644 --- a/tests/providers/partition.py +++ b/tests/providers/partition.py @@ -252,11 +252,11 @@ def _create_parent_sql(self, dialect): dialect=dialect, table=self.TABLE_NAME, columns=[ - ColumnDefinition("id", BigIntType(), dialect_options={"identity": "BY DEFAULT"}), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tenant_id", IntegerType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType()), - ColumnDefinition("amount", DecimalType(12, 2)), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), dialect_options={"identity": "BY DEFAULT"}), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "tenant_id", IntegerType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), + ColumnDefinition(dialect, "amount", DecimalType(precision=12, scale=2, dialect=dialect)), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/providers/pooling.py b/tests/providers/pooling.py new file mode 100644 index 00000000..40186779 --- /dev/null +++ b/tests/providers/pooling.py @@ -0,0 +1,132 @@ +# tests/providers/pooling.py +"""Database pooling helpers for the PostgreSQL test providers. + +Under parallel (pytest-xdist) runs with a positive pool size the providers +reuse a per-worker pooled database ``{database}_{index}`` on the scenario's +PostgreSQL server instead of the shared scenario ``database`` schema, so +scenario variants of the same test can run concurrently on different workers +without conflicting. The pool name prefix is derived from the scenario's +configured ``database`` (the YAML ``database`` field), so e.g. +``database: test_db`` produces pooled databases ``test_db_0``, ``test_db_1``, +... Serial runs (no ``-n``) keep the previous behaviour: the provider connects +to the scenario's configured ``database``. + +The scenario name selects the server (host/port); the pool index selects the +database name. The two are deliberately unrelated. +""" + +import psycopg + +from rhosocial.activerecord.testsuite.core.pool import ( + pooled_database_name, + register_base_database, + register_pool_reset_handler, +) + +from .scenarios import SCENARIO_MAP, get_scenario_raw + +# Derive each scenario's pooled-database base name from its configured +# ``database`` (YAML ``database`` field). Registered at import time so any +# caller of pooled_database_name() / resolve_database_name() resolves names +# consistent with the scenario configuration. +for _scenario_name, _scenario_config in SCENARIO_MAP.items(): + register_base_database(_scenario_name, _scenario_config["database"]) + + +def resolve_database_name(scenario_name: str): + """ + Return the pooled database name (e.g. ``test_db_3``) used by a test for + the given scenario, or ``None`` when pooling is inactive (callers then fall + back to the scenario's configured database). + """ + return pooled_database_name(scenario_name) + + +def _escape_identifier(name: str) -> str: + """Escape a PostgreSQL identifier for use inside double quotes.""" + return name.replace('"', '""') + + +def _connect_maintenance(config) -> psycopg.Connection: + """Connect to a maintenance database (``postgres`` or the configured one).""" + for dbname in ("postgres", config.database): + try: + return psycopg.connect( + host=config.host, + port=config.port, + dbname=dbname, + user=config.username, + password=config.password, + connect_timeout=10, + autocommit=True, + ) + except psycopg.Error: + continue + return None + + +def _reset_postgres_database(scenario_name: str, db_name: str) -> None: + """Ensure the pooled database exists and is empty on the scenario's server. + + Connects to the server selected by ``scenario_name``, creates the pooled + ``db_name`` database if missing, and drops all leftover non-extension + tables so the test starts from a clean state. Extension-owned tables are + preserved (e.g. PostGIS metadata). Errors are swallowed: a failed reset + must not hide the underlying test failure. + """ + if scenario_name not in SCENARIO_MAP: + return + _, config = get_scenario_raw(scenario_name) + try: + admin_conn = _connect_maintenance(config) + if admin_conn is None: + return + try: + with admin_conn.cursor() as cursor: + cursor.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + if cursor.fetchone() is None: + cursor.execute(f'CREATE DATABASE "{_escape_identifier(db_name)}"') + finally: + admin_conn.close() + except Exception: + return + + try: + conn = psycopg.connect( + host=config.host, + port=config.port, + dbname=db_name, + user=config.username, + password=config.password, + connect_timeout=10, + autocommit=True, + ) + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT n.nspname, c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p', 'm') + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + AND NOT EXISTS ( + SELECT 1 FROM pg_depend d + JOIN pg_extension e ON d.refobjid = e.oid + WHERE d.classid = 'pg_class'::regclass + AND d.objid = c.oid + AND d.deptype = 'e' + ) + """ + ) + for schema, table in cursor.fetchall(): + cursor.execute( + f'DROP TABLE IF EXISTS "{_escape_identifier(schema)}"."{_escape_identifier(table)}" CASCADE' + ) + finally: + conn.close() + except Exception: + pass + + +register_pool_reset_handler(_reset_postgres_database) diff --git a/tests/providers/query.py b/tests/providers/query.py index 209121c0..1e18c2a2 100644 --- a/tests/providers/query.py +++ b/tests/providers/query.py @@ -124,12 +124,83 @@ def _select_model_class(base_cls, py312_cls, py311_cls, py310_cls, model_name: s class QueryProviderBase: def __init__(self): - self._scenario_db_files = {} + self._active_backends = [] self._created_tables: Set[str] = set() + self._created_schemas: Set[str] = set() + self._mixed_schema_provisioned = False + + def supports_schema(self) -> bool: + """PostgreSQL models named schema namespaces natively.""" + return True def get_test_scenarios(self) -> List[str]: return list(get_enabled_scenarios().keys()) + def _provision_cross_schema(self, backend_instance) -> None: + """Create the two cross-schema fixture namespaces, tables and seeds.""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SCHEMA_A, + SCHEMA_B, + ) + + opts = ExecutionOptions(stmt_type=StatementType.DDL) + statements = [] + for schema in (SCHEMA_A, SCHEMA_B): + statements.append(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE') + statements.append(f'CREATE SCHEMA "{schema}"') + statements.append( + f'CREATE TABLE "{SCHEMA_A}".customers ' + f'(id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL)' + ) + statements.append( + f'CREATE TABLE "{SCHEMA_B}".orders ' + f'(id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL, amount INTEGER NOT NULL)' + ) + for sql in statements: + backend_instance.execute(sql, options=opts) + self._created_schemas.update((SCHEMA_A, SCHEMA_B)) + + def _provision_mixed_schema_sync(self, backend_instance) -> None: + """Provision an identical orders table in SCHEMA_A (default stays untouched).""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SCHEMA_A, + ) + + opts = ExecutionOptions(stmt_type=StatementType.DDL) + statements = [ + f'DROP SCHEMA IF EXISTS "{SCHEMA_A}" CASCADE', + f'CREATE SCHEMA "{SCHEMA_A}"', + f'CREATE TABLE "{SCHEMA_A}".orders (' + "id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, " + "order_number VARCHAR(255) NOT NULL, " + "total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, " + "status VARCHAR(50) NOT NULL DEFAULT 'pending', " + "created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE)", + ] + for sql in statements: + backend_instance.execute(sql, options=opts) + self._mixed_schema_provisioned = True + + async def _provision_mixed_schema_async(self, backend_instance) -> None: + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SCHEMA_A, + ) + + opts = ExecutionOptions(stmt_type=StatementType.DDL) + statements = [ + f'DROP SCHEMA IF EXISTS "{SCHEMA_A}" CASCADE', + f'CREATE SCHEMA "{SCHEMA_A}"', + f'CREATE TABLE "{SCHEMA_A}".orders (' + "id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, " + "order_number VARCHAR(255) NOT NULL, " + "total_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, " + "status VARCHAR(50) NOT NULL DEFAULT 'pending', " + "created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE)", + ] + for sql in statements: + await backend_instance.execute(sql, options=opts) + self._mixed_schema_provisioned = True + def _track_backend(self, backend_instance, collection): if backend_instance not in collection: collection.append(backend_instance) @@ -278,6 +349,40 @@ def setup_order_item_model(self, scenario_name: str) -> Type[ActiveRecord]: self._created_tables.add("order_items") return CompositeOrderItemBase + def setup_schema_fixtures(self, scenario_name: str): + """Two models in two distinct schemas (see testsuite schema_models).""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SchemaCustomer, + SchemaOrder, + ) + + backend_class, config = get_scenario(scenario_name) + SchemaCustomer.configure(config, backend_class) + SchemaOrder.configure(config, backend_class) + shared_backend = SchemaCustomer.__backend__ + self._track_backend(shared_backend, self._active_backends) + self._provision_cross_schema(shared_backend) + return SchemaCustomer, SchemaOrder + + def setup_mixed_schema_fixtures(self, scenario_name: str): + """(User, Order, MixedSchemaOrder): default users/orders plus orders in SCHEMA_A.""" + from rhosocial.activerecord.testsuite.feature.query.cross_schema.mixed_schema_models import ( + MixedSchemaOrder, + ) + from rhosocial.activerecord.testsuite.feature.query.fixtures.models import Order, User + + user_model = self._setup_model(User, scenario_name, "users") + shared_backend = user_model.__backend__ + order_model = self._setup_model(Order, scenario_name, "orders", shared_backend) + + backend_class, config = get_scenario(scenario_name) + MixedSchemaOrder.__connection_config__ = config + MixedSchemaOrder.__backend_class__ = backend_class + MixedSchemaOrder.__backend__ = shared_backend + self._track_backend(shared_backend, self._active_backends) + self._provision_mixed_schema_sync(shared_backend) + return user_model, order_model, MixedSchemaOrder + def _get_schema_sql_for_fixture_type(self, fixture_type: str) -> dict: schemas = {} if fixture_type == 'order': @@ -306,6 +411,11 @@ def get_worker_connection_params(self, scenario_name: str, fixture_type: str = ' else: raise ValueError("No scenarios registered") config_dict = SCENARIO_MAP[scenario_name] + from .pooling import resolve_database_name + + pooled_db = resolve_database_name(scenario_name) + if pooled_db: + config_dict = {**config_dict, "database": pooled_db} return { 'backend_module': 'rhosocial.activerecord.backend.impl.postgres', 'backend_class_name': backend_class_name, @@ -331,8 +441,24 @@ def cleanup_after_test(self, scenario_name: str): backend_instance.disconnect() except Exception: pass + for schema_name in list(self._created_schemas): + try: + backend_instance.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE') + except Exception: + pass + if self._mixed_schema_provisioned: + try: + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SCHEMA_A, + ) + + backend_instance.execute(f'DROP SCHEMA IF EXISTS "{SCHEMA_A}" CASCADE') + except Exception: + pass + self._mixed_schema_provisioned = False self._active_backends.clear() self._created_tables.clear() + self._created_schemas.clear() class QueryAsyncProvider(QueryProviderBase, IQueryAsyncProvider): @@ -476,6 +602,34 @@ async def setup_order_item_model(self, scenario_name: str) -> Type[AsyncActiveRe self._created_tables.add("order_items") return AsyncCompositeOrderItemBase + async def setup_schema_fixtures(self, scenario_name: str): + """Two async models in two distinct schemas.""" + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + AsyncSchemaCustomer, + AsyncSchemaOrder, + ) + + from rhosocial.activerecord.backend.impl.postgres import ( + AsyncPostgresBackend, + PostgresBackend, + ) + + _, config = get_scenario(scenario_name) + + # Provision DDL through a short-lived synchronous connection. + ddl_backend = PostgresBackend(connection_config=config) + ddl_backend.connect() + try: + self._provision_cross_schema(ddl_backend) + finally: + ddl_backend.disconnect() + + await AsyncSchemaCustomer.configure(config, AsyncPostgresBackend) + await AsyncSchemaOrder.configure(config, AsyncPostgresBackend) + shared_backend = AsyncSchemaCustomer.__backend__ + self._track_backend(shared_backend, self._active_async_backends) + return AsyncSchemaCustomer, AsyncSchemaOrder + async def cleanup_after_test(self, scenario_name: str): for backend_instance in self._active_async_backends: try: @@ -489,5 +643,46 @@ async def cleanup_after_test(self, scenario_name: str): await backend_instance.disconnect() except Exception: pass + for schema_name in list(self._created_schemas): + try: + await backend_instance.execute( + f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE') + except Exception: + pass + if self._mixed_schema_provisioned: + try: + from rhosocial.activerecord.testsuite.feature.query.fixtures.schema_models import ( + SCHEMA_A, + ) + + await backend_instance.execute(f'DROP SCHEMA IF EXISTS "{SCHEMA_A}" CASCADE') + except Exception: + pass + self._mixed_schema_provisioned = False self._active_async_backends.clear() self._created_tables.clear() + self._created_schemas.clear() + + async def setup_mixed_schema_fixtures(self, scenario_name: str): + """(AsyncUser, AsyncOrder, AsyncMixedSchemaOrder) with orders also in SCHEMA_A.""" + from rhosocial.activerecord.backend.impl.postgres import AsyncPostgresBackend + from rhosocial.activerecord.testsuite.feature.query.cross_schema.mixed_schema_models import ( + AsyncMixedSchemaOrder, + ) + from rhosocial.activerecord.testsuite.feature.query.fixtures.async_models import ( + AsyncOrder, + AsyncUser, + ) + + _, config = get_scenario(scenario_name) + user_model = await self._setup_async_model(AsyncUser, scenario_name, "users") + shared_backend = user_model.__backend__ + self._track_backend(shared_backend, self._active_async_backends) + order_model = await self._setup_async_model(AsyncOrder, scenario_name, "orders", shared_backend) + + AsyncMixedSchemaOrder.__connection_config__ = config + AsyncMixedSchemaOrder.__backend_class__ = AsyncPostgresBackend + AsyncMixedSchemaOrder.__backend__ = shared_backend + self._track_backend(shared_backend, self._active_async_backends) + await self._provision_mixed_schema_async(shared_backend) + return AsyncUser, order_model, AsyncMixedSchemaOrder diff --git a/tests/providers/registry.py b/tests/providers/registry.py index 32738870..a7d424c0 100644 --- a/tests/providers/registry.py +++ b/tests/providers/registry.py @@ -19,6 +19,10 @@ from .query_benchmark import QueryBenchmarkProvider from .transaction_benchmark import TransactionBenchmarkProvider +# Importing the pooling module registers the PostgreSQL-specific pool reset +# handler (a side effect of the import), mirroring MySQL's tests/providers/pooling.py. +from . import pooling # noqa: F401 + # Create a single, global instance of the ProviderRegistry for this backend. provider_registry = ProviderRegistry() diff --git a/tests/providers/scenarios.py b/tests/providers/scenarios.py index bdedbd04..2d1a04c4 100644 --- a/tests/providers/scenarios.py +++ b/tests/providers/scenarios.py @@ -2,9 +2,11 @@ """postgres backend test scenario configuration mapping table""" import os +from dataclasses import replace from typing import Dict, Any, Tuple, Type from rhosocial.activerecord.backend.impl.postgres import PostgresBackend from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.testsuite.core.pool import pooled_database_name # Scenario name -> configuration dictionary mapping table (postgres only) SCENARIO_MAP: Dict[str, Dict[str, Any]] = {} @@ -15,10 +17,12 @@ def register_scenario(name: str, config: Dict[str, Any]): SCENARIO_MAP[name] = config -def get_scenario(name: str) -> Tuple[Type[PostgresBackend], PostgresConnectionConfig]: - """ - Retrieves the backend class and a connection configuration object for a given - scenario name. This is called by the provider to set up the database for a test. +def get_scenario_raw(name: str) -> Tuple[Type[PostgresBackend], PostgresConnectionConfig]: + """Return the backend class and connection config for a scenario. + + The config uses the scenario's configured ``database``; no pool override is + applied. Used by the pool reset handler, which must reach the server + regardless of whether a pooled database exists yet. """ if name not in SCENARIO_MAP: # If the specified scenario is not found, use the first available one as a fallback @@ -32,6 +36,22 @@ def get_scenario(name: str) -> Tuple[Type[PostgresBackend], PostgresConnectionCo return PostgresBackend, config +def get_scenario(name: str) -> Tuple[Type[PostgresBackend], PostgresConnectionConfig]: + """ + Retrieves the backend class and a connection configuration object for a given + scenario name. This is called by the provider to set up the database for a test. + + Under an active database pool the config's ``database`` is replaced by the + worker's pooled database name (``{database}_{index}``) so concurrent workers + never share a schema. Serial runs keep the scenario's configured database. + """ + backend_class, config = get_scenario_raw(name) + pooled_db = pooled_database_name(name) + if pooled_db: + config = replace(config, database=pooled_db) + return backend_class, config + + def get_enabled_scenarios() -> Dict[str, Any]: """ Returns the map of all currently enabled scenarios. The testsuite's conftest diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py index cf90c588..ba63d63b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/conftest.py @@ -73,7 +73,14 @@ def get_scenario_config(name: str) -> Dict[str, Any]: name = next(iter(SCENARIO_MAP)) else: raise ValueError("No scenarios registered or enabled") - return SCENARIO_MAP[name] + config_dict = SCENARIO_MAP[name].copy() + # Apply the pooled database name so parallel workers never share a schema. + from providers.pooling import resolve_database_name + + pooled_db = resolve_database_name(name) + if pooled_db: + config_dict["database"] = pooled_db + return config_dict # --- Provider Logic for Fixtures --- diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py index 5fee2aa0..38b0a734 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_alter_table_if_exists.py @@ -40,7 +40,7 @@ class TestPostgresAddColumnIfNotExists: def test_if_not_exists_renders_qualifier(self, dialect): action = AddColumn( dialect, - ColumnDefinition("content", TextType()), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), if_not_exists=True, ) sql, params = action.to_sql() @@ -48,7 +48,7 @@ def test_if_not_exists_renders_qualifier(self, dialect): assert params == () def test_none_renders_plain_form(self, dialect): - action = AddColumn(dialect, ColumnDefinition("content", TextType())) + action = AddColumn(dialect, ColumnDefinition(dialect, "content", TextType(dialect=dialect))) sql, params = action.to_sql() assert 'ADD COLUMN "content" TEXT' == sql assert "IF NOT EXISTS" not in sql @@ -57,7 +57,7 @@ def test_none_renders_plain_form(self, dialect): def test_inside_alter_table(self, dialect): action = AddColumn( dialect, - ColumnDefinition("content", TextType()), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), if_not_exists=True, ) expr = AlterTableExpression( @@ -224,7 +224,7 @@ def test_unlogged_renders_qualifier(self, dialect): expr = CreateTableExpression( dialect, table="audit", - columns=[ColumnDefinition("id", TextType())], + columns=[ColumnDefinition(dialect, "id", TextType(dialect=dialect))], dialect_options={"unlogged_table": True}, ) sql, params = expr.to_sql() @@ -239,7 +239,7 @@ def test_plain_omits_qualifier(self, dialect): expr = CreateTableExpression( dialect, table="audit", - columns=[ColumnDefinition("id", TextType())], + columns=[ColumnDefinition(dialect, "id", TextType(dialect=dialect))], ) sql, params = expr.to_sql() assert not sql.startswith("CREATE UNLOGGED") @@ -253,7 +253,7 @@ def test_temporary_wins_over_unlogged(self, dialect): expr = CreateTableExpression( dialect, table="audit", - columns=[ColumnDefinition("id", TextType())], + columns=[ColumnDefinition(dialect, "id", TextType(dialect=dialect))], temporary=True, dialect_options={"unlogged_table": True}, ) @@ -273,7 +273,7 @@ def test_version_gate_94(self): expr = CreateTableExpression( low, table="audit", - columns=[ColumnDefinition("id", TextType())], + columns=[ColumnDefinition(low, "id", TextType(dialect=low))], dialect_options={"unlogged_table": True}, ) with pytest.raises(UnsupportedFeatureError): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_schema_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_schema_support.py new file mode 100644 index 00000000..b8c4a03e --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_schema_support.py @@ -0,0 +1,66 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_schema_support.py +"""Tests for the SchemaSupport capability declared on the PostgreSQL dialect. + +PostgreSQL models named schema namespaces natively (database -> schema -> table), +so the dialect must report ``supports_schema()`` as True together with the full +set of granular schema DDL capabilities. +""" +from rhosocial.activerecord.backend.dialect.protocols import SchemaSupport +from rhosocial.activerecord.backend.expression.statements.ddl_schema import ( + CreateSchemaExpression, + DropSchemaExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +class TestSchemaCapability: + """Umbrella flag and granular schema DDL capability bits.""" + + def _dialect(self) -> PostgresDialect: + return PostgresDialect() + + def test_supports_schema_is_true(self): + assert self._dialect().supports_schema() is True + + def test_implements_schema_support_protocol(self): + assert isinstance(self._dialect(), SchemaSupport) + + def test_granular_schema_ddl_capabilities(self): + d = self._dialect() + assert d.supports_create_schema() is True + assert d.supports_drop_schema() is True + assert d.supports_schema_if_not_exists() is True + assert d.supports_schema_if_exists() is True + assert d.supports_schema_cascade() is True + + def test_schema_authorization_capability(self): + assert self._dialect().supports_schema_authorization() is True + + +class TestSchemaDDLFormatting: + """CREATE/DROP SCHEMA rendering through the standard core formatters.""" + + def _dialect(self) -> PostgresDialect: + return PostgresDialect() + + def test_create_schema(self): + sql, params = CreateSchemaExpression(self._dialect(), "app").to_sql() + assert sql == 'CREATE SCHEMA "app"' + assert params == () + + def test_create_schema_if_not_exists(self): + sql, _ = CreateSchemaExpression(self._dialect(), "app", if_not_exists=True).to_sql() + assert sql == 'CREATE SCHEMA IF NOT EXISTS "app"' + + def test_create_schema_authorization(self): + sql, _ = CreateSchemaExpression(self._dialect(), "app", authorization="app_user").to_sql() + assert sql == 'CREATE SCHEMA "app" AUTHORIZATION "app_user"' + + def test_drop_schema(self): + sql, params = DropSchemaExpression(self._dialect(), "app").to_sql() + assert sql == 'DROP SCHEMA "app"' + assert params == () + + def test_drop_schema_if_exists_cascade(self): + sql, _ = DropSchemaExpression(self._dialect(), "app", if_exists=True, cascade=True).to_sql() + assert sql == 'DROP SCHEMA IF EXISTS "app" CASCADE' diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/examples_compile_test.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/examples_compile_test.py new file mode 100644 index 00000000..837afc53 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/examples_compile_test.py @@ -0,0 +1,36 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/examples_compile_test.py +"""Guard against syntax/API drift in the runnable examples. + +Every script under ``examples/`` must at least compile. This test runs +without a database and fails fast if an example introduces a syntax error. +(API drift that only manifests at runtime is caught by ``scripts/run_examples.py`` +against a live database in CI.) +""" +import py_compile +from pathlib import Path + +import pytest + +EXAMPLES_ROOT = ( + Path(__file__).resolve().parents[5] + / "src" / "rhosocial" / "activerecord" / "backend" / "impl" / "postgres" + / "examples" +) + + +def _collect_examples(): + if not EXAMPLES_ROOT.exists(): + return [] + return sorted(EXAMPLES_ROOT.rglob("*.py")) + + +def test_all_examples_compile(): + examples = _collect_examples() + assert examples, f"no examples found under {EXAMPLES_ROOT}" + failures = [] + for path in examples: + try: + py_compile.compile(str(path), doraise=True) + except py_compile.PyCompileError as exc: # pragma: no cover + failures.append(str(exc)) + assert not failures, "example compile failures:\n" + "\n".join(failures) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py new file mode 100644 index 00000000..dcfbee34 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py @@ -0,0 +1,186 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py +"""SQL-snapshot tests for format-signature-compliant expression classes. + +These tests build expressions with a bare ``PostgresDialect`` (no DB +connection) and assert on the generated SQL, so they run without a server. +""" +import pytest + +from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( + ILIKEExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.type import ( + EnumTypeNameExpression, + EnumValuesExpression, + CreateEnumTypeExpression, + DropEnumTypeExpression, + AlterEnumAddValueExpression, +) +from rhosocial.activerecord.backend.impl.postgres.expression.ddl.multirange import ( + CreateMultirangeTypeExpression, + MultirangeAggFunctionExpression, +) + + +class TestILIKEExpression: + """Tests for ILIKEExpression.""" + + def test_basic_ilike(self, postgres_dialect): + expr = ILIKEExpression(postgres_dialect, column="name", pattern="%foo%") + sql, params = expr.to_sql() + assert sql == '"name" ILIKE %s' + assert params == ('%foo%',) + + def test_negated_ilike(self, postgres_dialect): + expr = ILIKEExpression(postgres_dialect, column="name", pattern="%bar%", negate=True) + sql, params = expr.to_sql() + assert sql == '"name" NOT ILIKE %s' + assert params == ('%bar%',) + + def test_ilike_dispatches_to_postgres_format(self, postgres_dialect): + """Verify ILIKEExpression.to_sql() dispatches to format_ilike_expression.""" + expr = ILIKEExpression(postgres_dialect, column="col", pattern="pat") + sql, params = expr.to_sql() + assert "ILIKE" in sql + assert params == ("pat",) + + def test_ilike_generates_native_ilike_not_lower(self, postgres_dialect): + """Verify generated SQL uses native ILIKE, not LOWER() LIKE LOWER().""" + expr = ILIKEExpression(postgres_dialect, column="c", pattern="%x%") + sql, _ = expr.to_sql() + assert "LOWER" not in sql.upper() + assert "ILIKE" in sql + + +class TestEnumTypeNameExpression: + """Tests for EnumTypeNameExpression.""" + + def test_name_without_schema(self, postgres_dialect): + expr = EnumTypeNameExpression(postgres_dialect, name="status") + sql, params = expr.to_sql() + assert sql == "status" + assert params == () + + def test_name_with_schema(self, postgres_dialect): + expr = EnumTypeNameExpression(postgres_dialect, name="status", schema="app") + sql, params = expr.to_sql() + assert sql == "app.status" + assert params == () + + +class TestEnumValuesExpression: + """Tests for EnumValuesExpression.""" + + def test_single_value(self, postgres_dialect): + expr = EnumValuesExpression(postgres_dialect, values=["active"]) + sql, params = expr.to_sql() + assert sql == "'active'" + assert params == () + + def test_multiple_values(self, postgres_dialect): + expr = EnumValuesExpression(postgres_dialect, values=["a", "b", "c"]) + sql, params = expr.to_sql() + assert sql == "'a', 'b', 'c'" + assert params == () + + +class TestCreateEnumTypeExpression: + """Tests for CreateEnumTypeExpression.""" + + def test_basic_create(self, postgres_dialect): + expr = CreateEnumTypeExpression( + postgres_dialect, name="status", values=["active", "inactive"], + ) + sql, params = expr.to_sql() + assert sql == "CREATE TYPE status AS ENUM ('active', 'inactive')" + assert params == () + + def test_create_with_schema_and_if_not_exists(self, postgres_dialect): + expr = CreateEnumTypeExpression( + postgres_dialect, name="priority", values=["low", "high"], + schema="app", if_not_exists=True, + ) + sql, params = expr.to_sql() + assert sql == "CREATE TYPE IF NOT EXISTS app.priority AS ENUM ('low', 'high')" + assert params == () + + +class TestDropEnumTypeExpression: + """Tests for DropEnumTypeExpression.""" + + def test_basic_drop(self, postgres_dialect): + expr = DropEnumTypeExpression(postgres_dialect, name="status") + sql, params = expr.to_sql() + assert sql == "DROP TYPE status" + assert params == () + + def test_drop_with_if_exists_and_cascade(self, postgres_dialect): + expr = DropEnumTypeExpression( + postgres_dialect, name="status", schema="app", + if_exists=True, cascade=True, + ) + sql, params = expr.to_sql() + assert sql == "DROP TYPE IF EXISTS app.status CASCADE" + assert params == () + + +class TestAlterEnumAddValueExpression: + """Tests for AlterEnumAddValueExpression.""" + + def test_basic_add_value(self, postgres_dialect): + expr = AlterEnumAddValueExpression( + postgres_dialect, type_name="status", new_value="archived", + ) + sql, params = expr.to_sql() + assert sql == "ALTER TYPE status ADD VALUE 'archived'" + assert params == () + + def test_add_value_with_before_after(self, postgres_dialect): + expr = AlterEnumAddValueExpression( + postgres_dialect, type_name="status", new_value="pending", + schema="app", before="active", + ) + sql, params = expr.to_sql() + assert sql == "ALTER TYPE app.status ADD VALUE 'pending' BEFORE 'active'" + assert params == () + + +class TestCreateMultirangeTypeExpression: + """Tests for CreateMultirangeTypeExpression.""" + + def test_basic_create(self, postgres_dialect): + expr = CreateMultirangeTypeExpression( + postgres_dialect, name="my_multirange", range_type="my_range", + ) + sql, params = expr.to_sql() + assert sql == "CREATE TYPE my_multirange AS MULTIRANGE (my_range)" + assert params == () + + def test_create_with_schema(self, postgres_dialect): + expr = CreateMultirangeTypeExpression( + postgres_dialect, name="mr", range_type="int4range", schema="app", + ) + sql, params = expr.to_sql() + assert sql == "CREATE TYPE app.mr AS MULTIRANGE (int4range)" + assert params == () + + +class TestMultirangeAggFunctionExpression: + """Tests for MultirangeAggFunctionExpression.""" + + def test_basic_agg(self, postgres_dialect): + expr = MultirangeAggFunctionExpression( + postgres_dialect, range_column="period", table_name="events", + ) + sql, params = expr.to_sql() + assert sql == "SELECT multirange_agg(period) FROM events" + assert params == () + + def test_agg_with_where_and_schema(self, postgres_dialect): + expr = MultirangeAggFunctionExpression( + postgres_dialect, range_column="period", table_name="events", + where_clause="status = 'active'", schema="public", + ) + sql, params = expr.to_sql() + assert sql == "SELECT multirange_agg(period) FROM public.events WHERE status = 'active'" + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py new file mode 100644 index 00000000..54f7fbd4 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py @@ -0,0 +1,198 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/expression/vector_search_test.py +"""SQL-snapshot tests for the pgvector similarity-search helpers. + +These tests build expressions with a bare ``PostgresDialect`` (no DB +connection) and assert on the generated SQL, so they run without a server. +""" +import pytest + +from rhosocial.activerecord.backend.expression import Column, Literal, predicates +from rhosocial.activerecord.backend.impl.postgres.functions import ( + create_vector_index, + vector_distance, + vector_search, +) + + +class TestVectorSearch: + """Tests for the vector_search helper.""" + + def test_default_select_star(self, postgres_dialect): + expr = vector_search(postgres_dialect, "documents", [1.0, 0.5, 0.2]) + sql, params = expr.to_sql() + assert "SELECT *" in sql + assert 'AS "cosine_distance"' in sql + assert "ORDER BY" in sql and "<=>" in sql + assert "LIMIT %s" in sql + assert params[-1] == 10 + + def test_columns_and_similarity(self, postgres_dialect): + expr = vector_search( + postgres_dialect, + "documents", + [1.0, 0.55, 0.18], + columns=["content", "tenant"], + include_similarity=True, + top_k=3, + ) + sql, params = expr.to_sql() + assert "SELECT \"content\", \"tenant\"" in sql + assert 'AS "cosine_distance"' in sql + assert 'AS "cosine_similarity"' in sql + assert "LIMIT %s" in sql and params[-1] == 3 + + def test_where_filter(self, postgres_dialect): + tenant_pred = predicates.ComparisonPredicate( + postgres_dialect, "=", Column(postgres_dialect, "tenant"), + Literal(postgres_dialect, "acme"), + ) + expr = vector_search( + postgres_dialect, "documents", [1.0, 0.5, 0.2], + where=tenant_pred, + ) + sql, params = expr.to_sql() + assert 'WHERE "tenant" = %s' in sql + assert "acme" in params + + def test_l2_metric(self, postgres_dialect): + expr = vector_search( + postgres_dialect, "documents", [0.1, 0.2, 0.9], + metric="l2", columns=["content"], + ) + sql, params = expr.to_sql() + assert 'AS "l2_distance"' in sql + assert "<->" in sql + + def test_ip_metric(self, postgres_dialect): + expr = vector_search( + postgres_dialect, "documents", [0.1, 0.2, 0.9], + metric="ip", columns=["content"], + ) + sql, params = expr.to_sql() + assert 'AS "inner_product"' in sql + assert "<#>" in sql + + def test_ip_metric_with_similarity_requested(self, postgres_dialect): + """ip metric has no 1-distance similarity alias, so none is added.""" + expr = vector_search( + postgres_dialect, "documents", [0.1, 0.2, 0.9], + metric="ip", columns=["content"], include_similarity=True, + ) + sql, params = expr.to_sql() + assert 'AS "inner_product"' in sql + assert "similarity" not in sql + + def test_include_distance_false(self, postgres_dialect): + expr = vector_search( + postgres_dialect, "documents", [0.1, 0.2, 0.9], + columns=["content"], include_distance=False, + ) + sql, params = expr.to_sql() + assert "cosine_distance" not in sql + + def test_l2_similarity_alias(self, postgres_dialect): + expr = vector_search( + postgres_dialect, "documents", [0.1, 0.2, 0.9], + metric="l2", include_similarity=True, + ) + sql, params = expr.to_sql() + assert 'AS "l2_similarity"' in sql + + def test_invalid_metric(self, postgres_dialect): + with pytest.raises(ValueError, match="Unsupported vector metric"): + vector_search(postgres_dialect, "documents", [1.0], metric="hamming") + + def test_non_positive_top_k(self, postgres_dialect): + with pytest.raises(ValueError, match="top_k must be positive"): + vector_search(postgres_dialect, "documents", [1.0], top_k=0) + + +class TestVectorDistance: + """Tests for the vector_distance column-name convenience.""" + + def test_column_name_wrapped_as_column(self, postgres_dialect): + expr = vector_distance(postgres_dialect, "embedding", [1.0, 0.5, 0.2]) + sql, params = expr.to_sql() + # Column name must render as identifier, not a string literal parameter. + assert 'AS' not in sql + assert '"embedding" <=> %s::vector(3)' in sql + assert "embedding" not in params + + def test_l2_column(self, postgres_dialect): + expr = vector_distance(postgres_dialect, "embedding", [1.0], metric="l2") + sql, params = expr.to_sql() + assert '"embedding" <-> %s::vector(1)' in sql + + def test_invalid_metric(self, postgres_dialect): + with pytest.raises(ValueError, match="Unsupported vector metric"): + vector_distance(postgres_dialect, "embedding", [1.0], metric="bad") + + +class TestCreateVectorIndex: + """Tests for the create_vector_index helper.""" + + def test_hnsw_default_cosine(self, postgres_dialect): + expr = create_vector_index(postgres_dialect, "documents", "embedding") + sql, params = expr.to_sql() + assert "CREATE INDEX IF NOT EXISTS" in sql + assert 'USING HNSW ("embedding" vector_cosine_ops)' in sql + + def test_hnsw_with_options(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + index_type="hnsw", m=16, ef_construction=64, + ) + sql, params = expr.to_sql() + assert "WITH (m = 16, ef_construction = 64)" in sql + + def test_ivfflat_l2(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + metric="l2", index_type="ivfflat", lists=4, + ) + sql, params = expr.to_sql() + assert 'USING IVFFLAT ("embedding" vector_l2_ops)' in sql + assert "WITH (lists = 4)" in sql + + def test_ivfflat_without_lists(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + metric="l2", index_type="ivfflat", + ) + sql, params = expr.to_sql() + assert 'USING IVFFLAT ("embedding" vector_l2_ops)' in sql + assert "WITH" not in sql + + def test_custom_index_name(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + index_name="doc_emb_hnsw", + ) + sql, params = expr.to_sql() + assert "doc_emb_hnsw" in sql + + def test_auto_index_name(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + index_type="hnsw", + ) + sql, params = expr.to_sql() + assert "idx_documents_embedding_hnsw" in sql + + def test_ip_opclass(self, postgres_dialect): + expr = create_vector_index( + postgres_dialect, "documents", "embedding", + metric="ip", index_type="hnsw", + ) + sql, params = expr.to_sql() + assert 'vector_ip_ops' in sql + + def test_invalid_metric(self, postgres_dialect): + with pytest.raises(ValueError, match="Unsupported vector metric"): + create_vector_index(postgres_dialect, "documents", "embedding", + metric="hamming") + + def test_invalid_index_type(self, postgres_dialect): + with pytest.raises(ValueError, match="Unsupported vector index type"): + create_vector_index(postgres_dialect, "documents", "embedding", + index_type="btree") diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_default_schema.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_default_schema.py new file mode 100644 index 00000000..54e20116 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_default_schema.py @@ -0,0 +1,84 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/introspection/test_introspection_default_schema.py +"""Offline tests for ``_get_default_schema`` config-aware resolution. + +Both introspection stacks gained config-aware default-schema resolution: + +1. ``PostgreSQLIntrospectorMixin._get_default_schema`` (introspector) reads + ``self._backend.config``. +2. ``PostgresIntrospectionCapabilityMixin._get_default_schema`` (dialect SQL + generation) reads ``self._config`` or ``self._backend``. + +Priority: config.default_schema > first entry of config.search_path > 'public'. +""" +from rhosocial.activerecord.backend.impl.postgres import PostgresBackend +from rhosocial.activerecord.backend.impl.postgres.config import PostgresConnectionConfig +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.introspection import ( + SyncPostgreSQLIntrospector, +) + + +def make_config(**overrides) -> PostgresConnectionConfig: + params = dict( + host="localhost", + port=5432, + database="test", + username="postgres", + password="", + ) + params.update(overrides) + return PostgresConnectionConfig(**params) + + +def make_introspector(config: PostgresConnectionConfig) -> SyncPostgreSQLIntrospector: + backend = PostgresBackend(connection_config=config) + return SyncPostgreSQLIntrospector(backend, executor=object()) + + +class TestIntrospectorDefaultSchema: + """PostgreSQLIntrospectorMixin._get_default_schema (self._backend.config).""" + + def test_returns_public_when_not_configured(self): + introspector = make_introspector(make_config()) + assert introspector._get_default_schema() == "public" + + def test_returns_default_schema_from_config(self): + introspector = make_introspector(make_config(default_schema="broker")) + assert introspector._get_default_schema() == "broker" + + def test_returns_first_entry_of_search_path(self): + introspector = make_introspector(make_config(search_path="broker, public")) + assert introspector._get_default_schema() == "broker" + + def test_strips_quotes_from_search_path_entry(self): + introspector = make_introspector(make_config(search_path='"broker", public')) + assert introspector._get_default_schema() == "broker" + + def test_default_schema_takes_priority_over_search_path(self): + introspector = make_introspector( + make_config(default_schema="app", search_path="broker, public") + ) + assert introspector._get_default_schema() == "app" + + +class TestDialectMixinDefaultSchema: + """PostgresIntrospectionCapabilityMixin._get_default_schema (dialect).""" + + def test_returns_public_when_not_configured(self): + assert PostgresDialect()._get_default_schema() == "public" + + def test_returns_default_schema_from_config(self): + dialect = PostgresDialect() + dialect._config = make_config(default_schema="broker") + assert dialect._get_default_schema() == "broker" + + def test_returns_first_entry_of_search_path(self): + dialect = PostgresDialect() + dialect._config = make_config(search_path="broker, public") + assert dialect._get_default_schema() == "broker" + + def test_returns_schema_from_backend(self): + dialect = PostgresDialect() + backend = PostgresBackend(connection_config=make_config(search_path="warehouse")) + dialect._backend = backend + assert dialect._get_default_schema() == "warehouse" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py index b0509aff..2badd88a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom_integration.py @@ -61,14 +61,14 @@ def bloom_env(postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="email", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="email", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -165,13 +165,13 @@ def test_bloom_index_with_options(self, bloom_env): # Create temporary table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type=TextType()), + ColumnDefinition(dialect, name="code", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -238,14 +238,14 @@ async def async_bloom_env(async_postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="email", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="email", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -342,13 +342,13 @@ async def test_async_bloom_index_with_options(self, async_bloom_env): columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="code", data_type=TextType()), + ColumnDefinition(dialect, name="code", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py index a6fd3461..aef65b2c 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin_integration.py @@ -59,15 +59,15 @@ def btree_gin_env(postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="value", data_type=IntegerType()), - ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="value", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=TextType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -214,15 +214,15 @@ async def async_btree_gin_env(async_postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="value", data_type=IntegerType()), - ColumnDefinition(name="tags", data_type=PostgresArrayType(TextType())), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="value", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=TextType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py index 8e6fc040..6b66c2d5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist_integration.py @@ -61,15 +61,15 @@ def btree_gist_env(postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="created_at", data_type=TimestampType()), - ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="created_at", data_type=TimestampType(dialect=dialect)), + ColumnDefinition(dialect, name="value", data_type=IntegerType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -225,15 +225,15 @@ async def async_btree_gist_env(async_postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="created_at", data_type=TimestampType()), - ColumnDefinition(name="value", data_type=IntegerType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="created_at", data_type=TimestampType(dialect=dialect)), + ColumnDefinition(dialect, name="value", data_type=IntegerType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py index 19277faf..ed508c4a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_citext_integration.py @@ -54,13 +54,13 @@ def _setup_citext_table(backend, dialect, table): """Create and populate the basic citext test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="name", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -88,17 +88,17 @@ def _setup_citext_unique_table(backend, dialect, table): """Create and populate the citext unique constraint test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( - name="email", - data_type=PostgresCitextType(), + dialect, name="email", + data_type=PostgresCitextType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE), ], ), ] @@ -128,13 +128,13 @@ def _setup_citext_like_table(backend, dialect, table): """Create and populate the citext LIKE test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="name", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -165,13 +165,13 @@ def _setup_citext_users_table(backend, dialect, table): """Create and populate the citext users test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="username", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -199,14 +199,14 @@ def _setup_citext_roles_table(backend, dialect, table): """Create and populate the citext roles test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=PostgresCitextType()), - ColumnDefinition(name="role", data_type=TextType()), + ColumnDefinition(dialect, name="username", data_type=PostgresCitextType(dialect=dialect)), + ColumnDefinition(dialect, name="role", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -245,13 +245,13 @@ async def _async_setup_citext_table(backend, dialect, table): """Async: create and populate the basic citext test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="name", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -279,17 +279,17 @@ async def _async_setup_citext_unique_table(backend, dialect, table): """Async: create and populate the citext unique constraint test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), ColumnDefinition( - name="email", - data_type=PostgresCitextType(), + dialect, name="email", + data_type=PostgresCitextType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.UNIQUE), + ColumnConstraint(dialect, ColumnConstraintType.UNIQUE), ], ), ] @@ -319,13 +319,13 @@ async def _async_setup_citext_like_table(backend, dialect, table): """Async: create and populate the citext LIKE test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="name", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -356,13 +356,13 @@ async def _async_setup_citext_users_table(backend, dialect, table): """Async: create and populate the citext users test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=PostgresCitextType()), + ColumnDefinition(dialect, name="username", data_type=PostgresCitextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -390,14 +390,14 @@ async def _async_setup_citext_roles_table(backend, dialect, table): """Async: create and populate the citext roles test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=PostgresCitextType()), - ColumnDefinition(name="role", data_type=TextType()), + ColumnDefinition(dialect, name="username", data_type=PostgresCitextType(dialect=dialect)), + ColumnDefinition(dialect, name="role", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py index d2966b0d..1998ebbb 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_cube_integration.py @@ -66,13 +66,13 @@ def cube_env(postgres_backend_single): table="test_cubes", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) @@ -101,13 +101,13 @@ def cube_env(postgres_backend_single): table="test_cube_dist", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) @@ -136,13 +136,13 @@ def cube_env(postgres_backend_single): table="test_cube_idx", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) @@ -352,13 +352,13 @@ async def async_cube_env(async_postgres_backend_single): table="test_cubes_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) @@ -387,13 +387,13 @@ async def async_cube_env(async_postgres_backend_single): table="test_cube_dist_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) @@ -422,13 +422,13 @@ async def async_cube_env(async_postgres_backend_single): table="test_cube_idx_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="c", data_type=PostgresCubeType()), + ColumnDefinition(dialect, name="c", data_type=PostgresCubeType(dialect=dialect)), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py index 90298a2c..b6a0a2f4 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_earthdistance_integration.py @@ -55,13 +55,13 @@ def earthdistance_env(postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type=PostgresPointType()), + ColumnDefinition(dialect, name="loc", data_type=PostgresPointType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -167,13 +167,13 @@ async def async_earthdistance_env(async_postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="loc", data_type=PostgresPointType()), + ColumnDefinition(dialect, name="loc", data_type=PostgresPointType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py index 64be7549..e8add2f1 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_fuzzystrmatch_integration.py @@ -54,13 +54,13 @@ def _setup_lev_table(backend, dialect, table): """Create and populate the levenshtein test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type=TextType()), + ColumnDefinition(dialect, name="word", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -91,13 +91,13 @@ def _setup_diff_table(backend, dialect, table): """Create and populate the difference test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -138,13 +138,13 @@ async def _async_setup_lev_table(backend, dialect, table): """Async: create and populate the levenshtein test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="word", data_type=TextType()), + ColumnDefinition(dialect, name="word", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -175,13 +175,13 @@ async def _async_setup_diff_table(backend, dialect, table): """Async: create and populate the difference test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py index 1530b978..2f7bf8fa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hstore_integration.py @@ -47,17 +47,17 @@ # --- Helper functions --- -def _make_hstore_columns(): +def _make_hstore_columns(dialect): """Return standard column definitions for an hstore test table.""" return [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="data", data_type=PostgresHstoreType()), + ColumnDefinition(dialect, name="data", data_type=PostgresHstoreType(dialect=dialect)), ] @@ -66,7 +66,7 @@ def _setup_hstore_table(backend, dialect, table, hstore_value): create_expr = CreateTableExpression( dialect=dialect, table=table, - columns=_make_hstore_columns(), + columns=_make_hstore_columns(dialect), if_not_exists=True, ) sql, params = create_expr.to_sql() @@ -101,7 +101,7 @@ async def _async_setup_hstore_table(backend, dialect, table, hstore_value): create_expr = CreateTableExpression( dialect=dialect, table=table, - columns=_make_hstore_columns(), + columns=_make_hstore_columns(dialect), if_not_exists=True, ) sql, params = create_expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py index 7cf7ab52..badb7f92 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_hypopg_integration.py @@ -51,14 +51,14 @@ def hypopg_env(postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="email", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="email", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -267,14 +267,14 @@ async def async_hypopg_env(async_postgres_backend_single): # Setup: create test table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="email", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="email", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py index ccd5a613..fef4b978 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_intarray_integration.py @@ -55,13 +55,13 @@ def _setup_intarray_table(backend, dialect, table): """Create and populate the basic intarray test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -93,13 +93,13 @@ def _setup_intarray_overlap_table(backend, dialect, table): """Create and populate the intarray overlap test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -131,13 +131,13 @@ def _setup_intarray_contained_table(backend, dialect, table): """Create and populate the intarray contained-by test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -169,13 +169,13 @@ def _setup_intarray_idx_table(backend, dialect, table): """Create and populate the intarray idx() test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -207,13 +207,13 @@ def _setup_intarray_gin_table(backend, dialect, table, index_name): """ columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -259,13 +259,13 @@ async def _async_setup_intarray_table(backend, dialect, table): """Async: create and populate the basic intarray test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -297,13 +297,13 @@ async def _async_setup_intarray_overlap_table(backend, dialect, table): """Async: create and populate the intarray overlap test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -335,13 +335,13 @@ async def _async_setup_intarray_contained_table(backend, dialect, table): """Async: create and populate the intarray contained-by test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -373,13 +373,13 @@ async def _async_setup_intarray_idx_table(backend, dialect, table): """Async: create and populate the intarray idx() test table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -411,13 +411,13 @@ async def _async_setup_intarray_gin_table(backend, dialect, table, index_name): """ columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="tags", data_type=PostgresArrayType(IntegerType())), + ColumnDefinition(dialect, name="tags", data_type=PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py index 6a968c93..0f6d4998 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_ltree_integration.py @@ -69,13 +69,13 @@ def ltree_env(postgres_backend_single): table="test_ltree", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -107,14 +107,14 @@ def ltree_env(postgres_backend_single): table="test_ltree_tree", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), - ColumnDefinition(name="label", data_type=TextType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), + ColumnDefinition(dialect, name="label", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) @@ -146,13 +146,13 @@ def ltree_env(postgres_backend_single): table="test_ltree_func", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -178,13 +178,13 @@ def ltree_env(postgres_backend_single): table="test_ltree_idx", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -226,13 +226,13 @@ def ltree_env(postgres_backend_single): table="test_ltree_txtq", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -575,13 +575,13 @@ async def async_ltree_env(async_postgres_backend_single): table="test_ltree_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -613,14 +613,14 @@ async def async_ltree_env(async_postgres_backend_single): table="test_ltree_tree_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), - ColumnDefinition(name="label", data_type=TextType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), + ColumnDefinition(dialect, name="label", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) @@ -652,13 +652,13 @@ async def async_ltree_env(async_postgres_backend_single): table="test_ltree_func_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -684,13 +684,13 @@ async def async_ltree_env(async_postgres_backend_single): table="test_ltree_idx_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) @@ -732,13 +732,13 @@ async def async_ltree_env(async_postgres_backend_single): table="test_ltree_txtq_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="path", data_type=PostgresLtreeType()), + ColumnDefinition(dialect, name="path", data_type=PostgresLtreeType(dialect=dialect)), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py index eb0243b8..1d7476f5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_orafce_integration.py @@ -62,17 +62,17 @@ def orafce_backend(postgres_backend_single): # Create test table using expressions columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type=DateType()), - ColumnDefinition(name="ref_dt", data_type=DateType()), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="discount", data_type=DecimalType()), - ColumnDefinition(name="description", data_type=TextType()), + ColumnDefinition(dialect, name="dt", data_type=DateType(dialect=dialect)), + ColumnDefinition(dialect, name="ref_dt", data_type=DateType(dialect=dialect)), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="discount", data_type=DecimalType(dialect=dialect)), + ColumnDefinition(dialect, name="description", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -323,17 +323,17 @@ async def async_orafce_backend(async_postgres_backend_single): # Create test table using expressions columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="dt", data_type=DateType()), - ColumnDefinition(name="ref_dt", data_type=DateType()), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="discount", data_type=DecimalType()), - ColumnDefinition(name="description", data_type=TextType()), + ColumnDefinition(dialect, name="dt", data_type=DateType(dialect=dialect)), + ColumnDefinition(dialect, name="ref_dt", data_type=DateType(dialect=dialect)), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="discount", data_type=DecimalType(dialect=dialect)), + ColumnDefinition(dialect, name="description", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py index b022fb98..a1edff96 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_trgm_integration.py @@ -61,13 +61,13 @@ def _setup_trgm_table(backend, dialect, table): """Create and populate the test_trgm table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -98,13 +98,13 @@ def _setup_trgm_ops_table(backend, dialect, table): """Create and populate the test_trgm_ops table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -139,13 +139,13 @@ def _setup_trgm_idx_table(backend, dialect, table): """ columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -179,13 +179,13 @@ def _setup_trgm_word_table(backend, dialect, table): """Create and populate the test_trgm_word table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type=TextType()), + ColumnDefinition(dialect, name="description", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -224,13 +224,13 @@ async def _async_setup_trgm_table(backend, dialect, table): """Async: create and populate the test_trgm table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -261,13 +261,13 @@ async def _async_setup_trgm_ops_table(backend, dialect, table): """Async: create and populate the test_trgm_ops table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -302,13 +302,13 @@ async def _async_setup_trgm_idx_table(backend, dialect, table): """ columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -342,13 +342,13 @@ async def _async_setup_trgm_word_table(backend, dialect, table): """Async: create and populate the test_trgm_word table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="description", data_type=TextType()), + ColumnDefinition(dialect, name="description", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_walinspect_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_walinspect_integration.py index 1b54c6b3..c23576c7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_walinspect_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_walinspect_integration.py @@ -115,14 +115,21 @@ def test_pg_get_wal_records_info_with_lsn(self, pg_walinspect_env): opts = ExecutionOptions(stmt_type=StatementType.DQL) try: - # Get current WAL position to use as LSN values - current_lsn = _get_current_wal_lsn(backend, dialect) + # Get current WAL position as the range start. + start_lsn = _get_current_wal_lsn(backend, dialect) - # Query WAL records info with the obtained LSN + # Generate a small amount of WAL so the range is non-empty. + backend.execute("CREATE TABLE IF NOT EXISTS _wal_probe (id int)") + backend.execute("DROP TABLE IF EXISTS _wal_probe") + end_lsn = _get_current_wal_lsn(backend, dialect) + if start_lsn == end_lsn: + pytest.skip("No WAL activity generated between LSN reads") + + # Query WAL records info with the obtained LSN range wal_func = pg_get_wal_records_info( dialect, - current_lsn, - current_lsn, + start_lsn, + end_lsn, ) query = QueryExpression( dialect=dialect, @@ -136,7 +143,8 @@ def test_pg_get_wal_records_info_with_lsn(self, pg_walinspect_env): if ("permission denied" in error_msg or "must be superuser" in error_msg or "could not find a valid record" in error_msg - or "future start lsn" in error_msg): + or "future start lsn" in error_msg + or "must be less than" in error_msg): pytest.skip( "pg_walinspect requires superuser privileges or valid WAL range" ) @@ -193,14 +201,21 @@ async def test_async_pg_get_wal_records_info_with_lsn(self, async_pg_walinspect_ opts = ExecutionOptions(stmt_type=StatementType.DQL) try: - # Get current WAL position to use as LSN values - current_lsn = await _async_get_current_wal_lsn(backend, dialect) + # Get current WAL position as the range start. + start_lsn = await _async_get_current_wal_lsn(backend, dialect) + + # Generate a small amount of WAL so the range is non-empty. + await backend.execute("CREATE TABLE IF NOT EXISTS _wal_probe (id int)") + await backend.execute("DROP TABLE IF EXISTS _wal_probe") + end_lsn = await _async_get_current_wal_lsn(backend, dialect) + if start_lsn == end_lsn: + pytest.skip("No WAL activity generated between LSN reads") - # Query WAL records info with the obtained LSN + # Query WAL records info with the obtained LSN range wal_func = pg_get_wal_records_info( dialect, - current_lsn, - current_lsn, + start_lsn, + end_lsn, ) query = QueryExpression( dialect=dialect, @@ -214,7 +229,8 @@ async def test_async_pg_get_wal_records_info_with_lsn(self, async_pg_walinspect_ if ("permission denied" in error_msg or "must be superuser" in error_msg or "could not find a valid record" in error_msg - or "future start lsn" in error_msg): + or "future start lsn" in error_msg + or "must be less than" in error_msg): pytest.skip( "pg_walinspect requires superuser privileges or valid WAL range" ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py index 7005a7dd..c6867850 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgcrypto_integration.py @@ -58,14 +58,14 @@ def _setup_users_table(backend, dialect, table): """Create and populate the test_pgcrypto_users table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=TextType()), - ColumnDefinition(name="password_hash", data_type=TextType()), + ColumnDefinition(dialect, name="username", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="password_hash", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -99,13 +99,13 @@ def _setup_encrypt_table(backend, dialect, table): """Create and populate the test_pgcrypto_encrypt table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), + ColumnDefinition(dialect, name="secret_data", data_type=PostgresByteaType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -150,14 +150,14 @@ async def _async_setup_users_table(backend, dialect, table): """Async: create and populate the test_pgcrypto_users table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="username", data_type=TextType()), - ColumnDefinition(name="password_hash", data_type=TextType()), + ColumnDefinition(dialect, name="username", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="password_hash", data_type=TextType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -191,13 +191,13 @@ async def _async_setup_encrypt_table(backend, dialect, table): """Async: create and populate the test_pgcrypto_encrypt table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="secret_data", data_type=PostgresByteaType()), + ColumnDefinition(dialect, name="secret_data", data_type=PostgresByteaType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py index 622b86d7..a6564e7f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgrouting_integration.py @@ -49,20 +49,20 @@ def pgrouting_env(postgres_backend_single): # Setup: create test_route_edges table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type=IntegerType()), - ColumnDefinition(name="target", data_type=IntegerType()), - ColumnDefinition(name="cost", data_type=FloatType()), - ColumnDefinition(name="reverse_cost", data_type=FloatType()), - ColumnDefinition(name="x1", data_type=FloatType()), - ColumnDefinition(name="y1", data_type=FloatType()), - ColumnDefinition(name="x2", data_type=FloatType()), - ColumnDefinition(name="y2", data_type=FloatType()), + ColumnDefinition(dialect, name="source", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="target", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="cost", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="reverse_cost", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="x1", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="y1", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="x2", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="y2", data_type=FloatType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -178,20 +178,20 @@ async def async_pgrouting_env(async_postgres_backend_single): # Setup: create test_route_edges_async table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="source", data_type=IntegerType()), - ColumnDefinition(name="target", data_type=IntegerType()), - ColumnDefinition(name="cost", data_type=FloatType()), - ColumnDefinition(name="reverse_cost", data_type=FloatType()), - ColumnDefinition(name="x1", data_type=FloatType()), - ColumnDefinition(name="y1", data_type=FloatType()), - ColumnDefinition(name="x2", data_type=FloatType()), - ColumnDefinition(name="y2", data_type=FloatType()), + ColumnDefinition(dialect, name="source", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="target", data_type=IntegerType(dialect=dialect)), + ColumnDefinition(dialect, name="cost", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="reverse_cost", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="x1", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="y1", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="x2", data_type=FloatType(dialect=dialect)), + ColumnDefinition(dialect, name="y2", data_type=FloatType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py index 1541c41b..d724e920 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pgvector_integration.py @@ -48,16 +48,16 @@ def _id_column(): return ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ) def _embedding_column(dim=3): - return ColumnDefinition(name="embedding", data_type=PostgresVectorType(dim)) + return ColumnDefinition(dialect, name="embedding", data_type=PostgresVectorType(dim, dialect=dialect)) def _vector_literal(value, vector_type="vector"): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py index a0a89473..2464a103 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_integration.py @@ -47,13 +47,13 @@ def _setup_geo_points_table(backend, dialect, table): """Create and populate the test_geo_points table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -84,13 +84,13 @@ def _setup_geo_distance_table(backend, dialect, table): """Create and populate the test_geo_distance table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -121,13 +121,13 @@ def _setup_geo_contains_table(backend, dialect, table): """Create and populate the test_geo_contains table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -164,13 +164,13 @@ def _setup_geo_index_table(backend, dialect, table, index_name): """Create and populate the test_geo_index table with GiST index using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -211,13 +211,13 @@ def _setup_geog_table(backend, dialect, table): """Create and populate the test_geog table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type=PostgresGeographyType()), + ColumnDefinition(dialect, name="geog", data_type=PostgresGeographyType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -258,13 +258,13 @@ async def _async_setup_geo_points_table(backend, dialect, table): """Async: create and populate the test_geo_points table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -295,13 +295,13 @@ async def _async_setup_geo_distance_table(backend, dialect, table): """Async: create and populate the test_geo_distance table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -332,13 +332,13 @@ async def _async_setup_geo_contains_table(backend, dialect, table): """Async: create and populate the test_geo_contains table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -375,13 +375,13 @@ async def _async_setup_geo_index_table(backend, dialect, table, index_name): """Async: create and populate the test_geo_index table with GiST index using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geom", data_type=PostgresGeometryType()), + ColumnDefinition(dialect, name="geom", data_type=PostgresGeometryType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -422,13 +422,13 @@ async def _async_setup_geog_table(backend, dialect, table): """Async: create and populate the test_geog table using expressions.""" columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="geog", data_type=PostgresGeographyType()), + ColumnDefinition(dialect, name="geog", data_type=PostgresGeographyType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py index e5b462e2..e012292f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_postgis_raster_integration.py @@ -89,14 +89,14 @@ def raster_env(postgres_backend_single): # Setup: create test_raster_data table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="rast", data_type=PostgresRasterType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="rast", data_type=PostgresRasterType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, @@ -208,14 +208,14 @@ async def async_raster_env(async_postgres_backend_single): # Setup: create test_raster_data_async table using expression columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="name", data_type=TextType()), - ColumnDefinition(name="rast", data_type=PostgresRasterType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="rast", data_type=PostgresRasterType(dialect=dialect)), ] create_expr = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py index 7b1fa9df..1fc5b3ae 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_tablefunc_integration.py @@ -61,15 +61,15 @@ def tablefunc_env(postgres_backend_single): # Setup: create test_tf_sales table using expression sales_columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type=TextType()), - ColumnDefinition(name="category", data_type=TextType()), - ColumnDefinition(name="amount", data_type=IntegerType()), + ColumnDefinition(dialect, name="month", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="category", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="amount", data_type=IntegerType(dialect=dialect)), ] create_sales = CreateTableExpression( dialect=dialect, @@ -101,14 +101,14 @@ def tablefunc_env(postgres_backend_single): # Setup: create test_tf_tree table using expression tree_columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type=TextType()), - ColumnDefinition(name="parent_id", data_type=IntegerType()), + ColumnDefinition(dialect, name="node_name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="parent_id", data_type=IntegerType(dialect=dialect)), ] create_tree = CreateTableExpression( dialect=dialect, @@ -339,15 +339,15 @@ async def async_tablefunc_env(async_postgres_backend_single): # Setup: create test_tf_sales_async table using expression sales_columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="month", data_type=TextType()), - ColumnDefinition(name="category", data_type=TextType()), - ColumnDefinition(name="amount", data_type=IntegerType()), + ColumnDefinition(dialect, name="month", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="category", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="amount", data_type=IntegerType(dialect=dialect)), ] create_sales = CreateTableExpression( dialect=dialect, @@ -379,14 +379,14 @@ async def async_tablefunc_env(async_postgres_backend_single): # Setup: create test_tf_tree_async table using expression tree_columns = [ ColumnDefinition( - name="id", - data_type=PostgresSerialType(), + dialect, name="id", + data_type=PostgresSerialType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="node_name", data_type=TextType()), - ColumnDefinition(name="parent_id", data_type=IntegerType()), + ColumnDefinition(dialect, name="node_name", data_type=TextType(dialect=dialect)), + ColumnDefinition(dialect, name="parent_id", data_type=IntegerType(dialect=dialect)), ] create_tree = CreateTableExpression( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py index 388bee98..69f2c7a6 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_uuid_ossp_integration.py @@ -76,17 +76,17 @@ def uuid_ossp_env(postgres_backend_single): table="test_uuid_ossp", columns=[ ColumnDefinition( - name="id", - data_type=PostgresUUIDType(), + dialect, name="id", + data_type=PostgresUUIDType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( - ColumnConstraintType.DEFAULT, + dialect, ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, "uuid_generate_v4"), ), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) @@ -112,13 +112,13 @@ def uuid_ossp_env(postgres_backend_single): table="test_uuid_explicit", columns=[ ColumnDefinition( - name="id", - data_type=PostgresUUIDType(), + dialect, name="id", + data_type=PostgresUUIDType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type=TextType()), + ColumnDefinition(dialect, name="label", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) @@ -281,17 +281,17 @@ async def async_uuid_ossp_env(async_postgres_backend_single): table="test_uuid_ossp_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresUUIDType(), + dialect, name="id", + data_type=PostgresUUIDType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ColumnConstraint( - ColumnConstraintType.DEFAULT, + dialect, ColumnConstraintType.DEFAULT, default_value=FunctionCall(dialect, "uuid_generate_v4"), ), ], ), - ColumnDefinition(name="name", data_type=TextType()), + ColumnDefinition(dialect, name="name", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) @@ -317,13 +317,13 @@ async def async_uuid_ossp_env(async_postgres_backend_single): table="test_uuid_explicit_async", columns=[ ColumnDefinition( - name="id", - data_type=PostgresUUIDType(), + dialect, name="id", + data_type=PostgresUUIDType(dialect=dialect), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY), + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY), ], ), - ColumnDefinition(name="label", data_type=TextType()), + ColumnDefinition(dialect, name="label", data_type=TextType(dialect=dialect)), ], if_not_exists=True, ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_connection_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_connection_config.py new file mode 100644 index 00000000..335b57e9 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_connection_config.py @@ -0,0 +1,154 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_connection_config.py +""" +Unit tests for PostgresConnectionConfig autocommit option. + +The backend reads ``config.autocommit`` to decide whether an explicit COMMIT +should be issued after statements executed outside a managed transaction. +The field must therefore exist on the config (previously it was only read +via ``getattr`` with a fallback, and passing it raised a TypeError). + +Covers: + - Default value and explicit assignment of ``autocommit`` + - Sync/Async backend ``requires_manual_commit()`` reflecting the config +""" + +import pytest + +from rhosocial.activerecord.backend.impl.postgres import ( + AsyncPostgresBackend, + PostgresBackend, + PostgresConnectionConfig, +) + + +class FakeConnection: + def __init__(self): + self.commit_count = 0 + + def commit(self): + self.commit_count += 1 + + def rollback(self): + pass + + +class FakeAsyncConnection: + def __init__(self): + self.commit_count = 0 + + async def commit(self): + self.commit_count += 1 + + async def rollback(self): + pass + + +def make_config(**overrides) -> PostgresConnectionConfig: + params = dict( + host="localhost", + port=5432, + database="test", + username="postgres", + password="", + ) + params.update(overrides) + return PostgresConnectionConfig(**params) + + +class TestAutocommitConfig: + """autocommit must be a first-class config field.""" + + def test_default_autocommit_is_false(self): + config = make_config() + assert config.autocommit is False + + def test_autocommit_can_be_enabled(self): + config = make_config(autocommit=True) + assert config.autocommit is True + + def test_autocommit_not_in_connection_string(self): + """autocommit is not a libpq conninfo keyword, so the URI must not include it.""" + conn_str = make_config(autocommit=True).to_connection_string() + assert "autocommit" not in conn_str + + +class TestRequiresManualCommit: + """Backends expose requires_manual_commit() based on config.autocommit.""" + + def test_sync_backend_requires_commit_by_default(self): + backend = PostgresBackend(connection_config=make_config()) + assert backend.requires_manual_commit() is True + + def test_sync_backend_skips_commit_when_autocommit(self): + backend = PostgresBackend(connection_config=make_config(autocommit=True)) + assert backend.requires_manual_commit() is False + + @pytest.mark.asyncio + async def test_async_backend_requires_commit_by_default(self): + backend = AsyncPostgresBackend(connection_config=make_config()) + assert backend.requires_manual_commit() is True + + @pytest.mark.asyncio + async def test_async_backend_skips_commit_when_autocommit(self): + backend = AsyncPostgresBackend(connection_config=make_config(autocommit=True)) + assert backend.requires_manual_commit() is False + + +class TestAutoCommitHandlers: + """The backend auto-commit handlers honor config.autocommit.""" + + def test_handle_auto_commit_commits_when_disabled(self): + backend = PostgresBackend(connection_config=make_config()) + backend._connection = FakeConnection() + backend._handle_auto_commit() + assert backend._connection.commit_count == 1 + + def test_handle_auto_commit_skips_when_enabled(self): + backend = PostgresBackend(connection_config=make_config(autocommit=True)) + backend._connection = FakeConnection() + backend._handle_auto_commit() + assert backend._connection.commit_count == 0 + + def test_handle_auto_commit_noop_without_connection(self): + backend = PostgresBackend(connection_config=make_config()) + backend._handle_auto_commit() + + def test_handle_auto_commit_if_needed_commits_when_disabled(self): + backend = PostgresBackend(connection_config=make_config()) + backend._connection = FakeConnection() + backend._handle_auto_commit_if_needed() + assert backend._connection.commit_count == 1 + + def test_handle_auto_commit_if_needed_skips_when_enabled(self): + backend = PostgresBackend(connection_config=make_config(autocommit=True)) + backend._connection = FakeConnection() + backend._handle_auto_commit_if_needed() + assert backend._connection.commit_count == 0 + + @pytest.mark.asyncio + async def test_async_handle_auto_commit_commits_when_disabled(self): + backend = AsyncPostgresBackend(connection_config=make_config()) + backend._connection = FakeAsyncConnection() + await backend._handle_auto_commit() + assert backend._connection.commit_count == 1 + + @pytest.mark.asyncio + async def test_async_handle_auto_commit_skips_when_enabled(self): + backend = AsyncPostgresBackend(connection_config=make_config(autocommit=True)) + backend._connection = FakeAsyncConnection() + await backend._handle_auto_commit() + assert backend._connection.commit_count == 0 + + @pytest.mark.asyncio + async def test_async_handle_auto_commit_if_needed_commits_when_disabled(self): + backend = AsyncPostgresBackend(connection_config=make_config()) + backend._connection = FakeAsyncConnection() + await backend._handle_auto_commit_if_needed() + assert backend._connection.commit_count == 1 + + @pytest.mark.asyncio + async def test_async_handle_auto_commit_if_needed_skips_when_enabled(self): + backend = AsyncPostgresBackend(connection_config=make_config(autocommit=True)) + backend._connection = FakeAsyncConnection() + await backend._handle_auto_commit_if_needed() + assert backend._connection.commit_count == 0 diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py index 7dd8ea9e..63ce1194 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_core_type_mappings.py @@ -27,69 +27,69 @@ class TestCoreTypeMappings: # The five critical mappings def test_boolean_to_boolean(self, dialect): from rhosocial.activerecord.backend.expression.types import BooleanType - assert BooleanType().to_sql(dialect) == ("BOOLEAN", ()) + assert BooleanType(dialect=dialect).to_sql() == ("BOOLEAN", ()) def test_tinyint_to_smallint(self, dialect): from rhosocial.activerecord.backend.expression.types import TinyIntType - assert TinyIntType().to_sql(dialect) == ("SMALLINT", ()) + assert TinyIntType(dialect=dialect).to_sql() == ("SMALLINT", ()) def test_blob_to_bytea(self, dialect): from rhosocial.activerecord.backend.expression.types import BlobType - assert BlobType().to_sql(dialect) == ("BYTEA", ()) + assert BlobType(dialect=dialect).to_sql() == ("BYTEA", ()) def test_datetime_to_timestamp(self, dialect): from rhosocial.activerecord.backend.expression.types import DateTimeType - assert DateTimeType().to_sql(dialect) == ("TIMESTAMP", ()) + assert DateTimeType(dialect=dialect).to_sql() == ("TIMESTAMP", ()) def test_int_to_integer(self, dialect): from rhosocial.activerecord.backend.expression.types import IntType - assert IntType().to_sql(dialect) == ("INTEGER", ()) + assert IntType(dialect=dialect).to_sql() == ("INTEGER", ()) # Standard integer family def test_smallint(self, dialect): from rhosocial.activerecord.backend.expression.types import SmallIntType - assert SmallIntType().to_sql(dialect) == ("SMALLINT", ()) + assert SmallIntType(dialect=dialect).to_sql() == ("SMALLINT", ()) def test_integer(self, dialect): from rhosocial.activerecord.backend.expression.types import IntegerType - assert IntegerType().to_sql(dialect) == ("INTEGER", ()) + assert IntegerType(dialect=dialect).to_sql() == ("INTEGER", ()) def test_bigint(self, dialect): from rhosocial.activerecord.backend.expression.types import BigIntType - assert BigIntType().to_sql(dialect) == ("BIGINT", ()) + assert BigIntType(dialect=dialect).to_sql() == ("BIGINT", ()) # Numeric family def test_real(self, dialect): from rhosocial.activerecord.backend.expression.types import RealType - assert RealType().to_sql(dialect) == ("REAL", ()) + assert RealType(dialect=dialect).to_sql() == ("REAL", ()) def test_double(self, dialect): from rhosocial.activerecord.backend.expression.types import DoubleType - assert DoubleType().to_sql(dialect) == ("DOUBLE PRECISION", ()) + assert DoubleType(dialect=dialect).to_sql() == ("DOUBLE PRECISION", ()) def test_numeric(self, dialect): from rhosocial.activerecord.backend.expression.types import DecimalType - assert DecimalType(10, 2).to_sql(dialect) == ("DECIMAL(10,2)", ()) - assert DecimalType().to_sql(dialect) == ("DECIMAL", ()) + assert DecimalType(precision=10, scale=2, dialect=dialect).to_sql() == ("DECIMAL(10,2)", ()) + assert DecimalType(dialect=dialect).to_sql() == ("DECIMAL", ()) # String family def test_varchar(self, dialect): from rhosocial.activerecord.backend.expression.types import VarCharType - assert VarCharType(255).to_sql(dialect) == ("VARCHAR(255)", ()) - assert VarCharType().to_sql(dialect) == ("VARCHAR", ()) + assert VarCharType(length=255, dialect=dialect).to_sql() == ("VARCHAR(255)", ()) + assert VarCharType(dialect=dialect).to_sql() == ("VARCHAR", ()) def test_text(self, dialect): from rhosocial.activerecord.backend.expression.types import TextType - assert TextType().to_sql(dialect) == ("TEXT", ()) + assert TextType(dialect=dialect).to_sql() == ("TEXT", ()) # JSON family def test_json(self, dialect): from rhosocial.activerecord.backend.expression.types import JsonType - assert JsonType().to_sql(dialect) == ("JSON", ()) + assert JsonType(dialect=dialect).to_sql() == ("JSON", ()) def test_jsonb(self, dialect): from rhosocial.activerecord.backend.expression.types import JsonBType - assert JsonBType().to_sql(dialect) == ("JSONB", ()) + assert JsonBType(dialect=dialect).to_sql() == ("JSONB", ()) class TestArrayType: @@ -100,32 +100,32 @@ def test_array_1d_rendering(self, dialect): PostgresArrayType, ) from rhosocial.activerecord.backend.expression.types import IntegerType - arr = PostgresArrayType(IntegerType()) - assert arr.to_sql(dialect) == ("INTEGER[]", ()) + arr = PostgresArrayType(element_type=IntegerType(dialect=dialect), dialect=dialect) + assert arr.to_sql() == ("INTEGER[]", ()) def test_array_2d_rendering(self, dialect): from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresArrayType, ) from rhosocial.activerecord.backend.expression.types import IntegerType - arr = PostgresArrayType(IntegerType(), dimensions=2) - assert arr.to_sql(dialect) == ("INTEGER[][]", ()) + arr = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=2, dialect=dialect) + assert arr.to_sql() == ("INTEGER[][]", ()) def test_array_varchar_rendering(self, dialect): from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresArrayType, ) from rhosocial.activerecord.backend.expression.types import VarCharType - arr = PostgresArrayType(VarCharType(255)) - assert arr.to_sql(dialect) == ("VARCHAR(255)[]", ()) + arr = PostgresArrayType(element_type=VarCharType(length=255, dialect=dialect), dialect=dialect) + assert arr.to_sql() == ("VARCHAR(255)[]", ()) def test_array_boolean_rendering(self, dialect): from rhosocial.activerecord.backend.impl.postgres.expression.types import ( PostgresArrayType, ) from rhosocial.activerecord.backend.expression.types import BooleanType - arr = PostgresArrayType(BooleanType()) - assert arr.to_sql(dialect) == ("BOOLEAN[]", ()) + arr = PostgresArrayType(element_type=BooleanType(dialect=dialect), dialect=dialect) + assert arr.to_sql() == ("BOOLEAN[]", ()) def test_array_equality(self, dialect): from rhosocial.activerecord.backend.impl.postgres.expression.types import ( @@ -134,14 +134,14 @@ def test_array_equality(self, dialect): from rhosocial.activerecord.backend.expression.types import ( IntegerType, VarCharType, ) - a1 = PostgresArrayType(IntegerType(), 2) - a2 = PostgresArrayType(IntegerType(), 2) - a3 = PostgresArrayType(VarCharType(255), 2) + a1 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=2, dialect=dialect) + a2 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=2, dialect=dialect) + a3 = PostgresArrayType(element_type=VarCharType(length=255, dialect=dialect), dimensions=2, dialect=dialect) assert a1 == a2 assert a1 != a3 assert hash(a1) == hash(a2) # PostgresArrayType.is_equivalent ignores dimensions - a4 = PostgresArrayType(IntegerType(), 1) + a4 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=1, dialect=dialect) assert a1.is_equivalent(a4) def test_parse_array_bracket_suffix(self, dialect): @@ -198,14 +198,14 @@ def test_is_element_type_equivalent(self, dialect): from rhosocial.activerecord.backend.expression.types import ( IntegerType, SmallIntType, ) - a1 = PostgresArrayType(IntegerType(), 2) - a2 = PostgresArrayType(IntegerType(), 1) # different dimension - a3 = PostgresArrayType(SmallIntType()) # different element type + a1 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=2, dialect=dialect) + a2 = PostgresArrayType(element_type=IntegerType(dialect=dialect), dimensions=1, dialect=dialect) # different dimension + a3 = PostgresArrayType(element_type=SmallIntType(dialect=dialect), dialect=dialect) # different element type # Same element, different dimension → equivalent assert a1.is_element_type_equivalent(a2) # Different element → not equivalent assert not a1.is_element_type_equivalent(a3) # Plain IntegerType (not array) as other → matches element - assert a1.is_element_type_equivalent(IntegerType()) + assert a1.is_element_type_equivalent(IntegerType(dialect=dialect)) # Plain SmallIntType as other → doesn't match Integer element - assert not a1.is_element_type_equivalent(SmallIntType()) + assert not a1.is_element_type_equivalent(SmallIntType(dialect=dialect)) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py index c44a8926..a84af276 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_alter_config.py @@ -263,8 +263,9 @@ def test_capabilities(self, dialect): def test_add_column(self, dialect): column = ColumnDefinition( - "email", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "email", TextType(dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ) sql, params = dialect.format_add_column_action( AddColumn(dialect, column) @@ -273,7 +274,7 @@ def test_add_column(self, dialect): assert params == () def test_add_column_if_not_exists(self, dialect): - column = ColumnDefinition("email", TextType()) + column = ColumnDefinition(dialect, "email", TextType(dialect)) sql, _ = dialect.format_add_column_action( AddColumn(dialect, column, if_not_exists=True) ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py index 22231b29..c729efa5 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ddl_expressions.py @@ -247,10 +247,11 @@ def test_create_range_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", BigIntType()), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect)), ColumnDefinition( - "created_at", TimestampType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "created_at", TimestampType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ), ], partition=PartitionClause( @@ -271,8 +272,8 @@ def test_create_list_partitioned_parent_table(self, dialect): dialect=dialect, table="events", columns=[ - ColumnDefinition("id", BigIntType()), - ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect)), + ColumnDefinition(dialect, "status", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -293,8 +294,9 @@ def test_create_hash_partitioned_parent_table_pg10(self): table="events", columns=[ ColumnDefinition( - "tenant_id", BigIntType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "tenant_id", BigIntType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ) ], partition=PartitionClause( @@ -315,8 +317,9 @@ def test_create_hash_partitioned_parent_table_pg11(self): table="events", columns=[ ColumnDefinition( - "tenant_id", BigIntType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "tenant_id", BigIntType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ) ], partition=PartitionClause( @@ -338,8 +341,9 @@ def test_create_partitioned_parent_table_pg9(self): table="events", columns=[ ColumnDefinition( - "created_at", TimestampType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "created_at", TimestampType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ) ], partition=PartitionClause( @@ -368,14 +372,16 @@ def test_multi_column_range_partitioned_parent_table(self, dialect): table="tenanted_events", columns=[ ColumnDefinition( - "tenant_id", BigIntType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "tenant_id", BigIntType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ), ColumnDefinition( - "created_at", TimestampType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], + dialect, + "created_at", TimestampType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ), - ColumnDefinition("payload", TextType()), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py index f8fa1597..d133a64d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_dialect_security_integration.py @@ -69,6 +69,7 @@ def test_malicious_data_type_rejected_at_construction(self, postgres_backend): # ColumnDefinition now rejects string data_type values at construction time with pytest.raises(TypeError, match="data_type must be a DataType instance"): ColumnDefinition( + postgres_backend.dialect, name="test_col", data_type="VARCHAR(255); DROP TABLE users--", ) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py index d289917c..5cc56cfa 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_enum_types.py @@ -17,6 +17,7 @@ from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, + PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, ) @@ -451,17 +452,27 @@ def test_format_enum_values(self): def test_format_create_enum_type(self): """Test format_create_enum_type method.""" dialect = PostgresDialect() - sql = dialect.format_create_enum_type('status', ['pending', 'ready']) + expr = PostgresCreateEnumTypeExpression( + dialect=dialect, name='status', values=['pending', 'ready'] + ) + sql, params = dialect.format_create_enum_type(expr) assert sql == "CREATE TYPE status AS ENUM ('pending', 'ready')" + assert params == () def test_format_drop_enum_type(self): """Test format_drop_enum_type method.""" dialect = PostgresDialect() - sql = dialect.format_drop_enum_type('status') + expr = PostgresDropEnumTypeExpression(dialect=dialect, name='status') + sql, params = dialect.format_drop_enum_type(expr) assert sql == "DROP TYPE status" + assert params == () def test_format_alter_enum_add_value(self): """Test format_alter_enum_add_value method.""" dialect = PostgresDialect() - sql = dialect.format_alter_enum_add_value('status', 'failed') + expr = PostgresAlterEnumAddValueExpression( + dialect=dialect, type_name='status', new_value='failed' + ) + sql, params = dialect.format_alter_enum_add_value(expr) assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py index 40fc7ee7..8ccf4742 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_index_pg.py @@ -505,32 +505,57 @@ def test_supports_fulltext_boolean_mode(self): assert PostgresDialect().supports_fulltext_boolean_mode() is True def test_format_fulltext_match_natural_language(self): - sql, params = PostgresDialect().format_fulltext_match(["col"], "search") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) + dialect = PostgresDialect() + expr = FulltextMatchExpression(dialect, ["col"], "search") + sql, params = dialect.format_fulltext_match(expr) assert "to_tsvector" in sql assert "plainto_tsquery" in sql assert "@@" in sql assert params == ("search",) def test_format_fulltext_match_boolean(self): - sql, params = PostgresDialect().format_fulltext_match(["col"], "a & b", mode="BOOLEAN") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) + dialect = PostgresDialect() + expr = FulltextMatchExpression(dialect, ["col"], "a & b", mode="BOOLEAN") + sql, params = dialect.format_fulltext_match(expr) assert "to_tsvector" in sql assert "to_tsquery" in sql assert "plainto_tsquery" not in sql assert params == ("a & b",) def test_format_fulltext_match_phrase(self): - sql, params = PostgresDialect().format_fulltext_match(["col"], "cat dog", mode="PHRASE") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) + dialect = PostgresDialect() + expr = FulltextMatchExpression(dialect, ["col"], "cat dog", mode="PHRASE") + sql, params = dialect.format_fulltext_match(expr) assert "phraseto_tsquery" in sql assert "plainto_tsquery" not in sql assert params == ("cat dog",) def test_format_fulltext_match_unknown_mode_uses_plain(self): - sql, params = PostgresDialect().format_fulltext_match(["col"], "search", mode="CUSTOM") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) + dialect = PostgresDialect() + expr = FulltextMatchExpression(dialect, ["col"], "search", mode="CUSTOM") + sql, params = dialect.format_fulltext_match(expr) assert "plainto_tsquery" in sql assert params == ("search",) def test_format_fulltext_match_multi_column(self): - sql, params = PostgresDialect().format_fulltext_match(["a", "b"], "search") + from rhosocial.activerecord.backend.expression.statements.fulltext_match import ( + FulltextMatchExpression, + ) + dialect = PostgresDialect() + expr = FulltextMatchExpression(dialect, ["a", "b"], "search") + sql, params = dialect.format_fulltext_match(expr) assert " || " in sql assert "to_tsvector" in sql assert params == ("search",) @@ -789,7 +814,7 @@ class TestFormatAddDropIndexAction: def test_format_add_index_action_raises(self): d = PostgresDialect() - add = AddIndex(d, index=IndexDefinition(name="idx_test", columns=["a"])) + add = AddIndex(d, index=IndexDefinition(d, name="idx_test", columns=["a"])) with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE ADD INDEX"): d.format_add_index_action(add) @@ -1013,9 +1038,8 @@ def test_supports_index_if_exists(self): def test_format_create_index_expression_column(self): from rhosocial.activerecord.backend.expression import Column, Literal # noqa: F401 - expr = CreateIndexExpression( - PostgresDialect((15, 0, 0)), "idx_e", "t", [Column(PostgresDialect((15, 0, 0)), "a")], - ) + d = PostgresDialect((15, 0, 0)) + expr = CreateIndexExpression(d, "idx_e", "t", [Column(d, "a")]) sql, _ = expr.to_sql() assert '"a"' in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py index c36e307f..b54de82d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_property_graph_query_scenarios.py @@ -24,7 +24,7 @@ WhereClause, OrderByClause, GroupByHavingClause, LimitOffsetClause, ) from rhosocial.activerecord.backend.expression.core import Column -from rhosocial.activerecord.backend.expression.query_parts import JoinExpression +from rhosocial.activerecord.backend.expression.query_parts import JoinClause from rhosocial.activerecord.backend.expression.types import ( IntegerType, TextType, ) @@ -44,48 +44,48 @@ def social_data(postgres_backend): backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", TextType()), - ColumnDefinition("email", TextType()), - ColumnDefinition("city", TextType()), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", TextType(dialect=dialect)), + ColumnDefinition(dialect, "email", TextType(dialect=dialect)), + ColumnDefinition(dialect, "city", TextType(dialect=dialect)), ] backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "follower_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "followed_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", TextType()), + ColumnDefinition(dialect, "since", TextType(dialect=dialect)), ] backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) posts_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("author_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "author_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("content", TextType()), - ColumnDefinition("created_at", TextType()), + ColumnDefinition(dialect, "content", TextType(dialect=dialect)), + ColumnDefinition(dialect, "created_at", TextType(dialect=dialect)), ] backend.execute(*CreateTableExpression(dialect, "posts", posts_cols).to_sql()) likes_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("user_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "user_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("post_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "post_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("posts", ["id"]))]), - ColumnDefinition("created_at", TextType()), + ColumnDefinition(dialect, "created_at", TextType(dialect=dialect)), ] backend.execute(*CreateTableExpression(dialect, "likes", likes_cols).to_sql()) @@ -275,7 +275,7 @@ def test_graph_table_combined_with_regular_sql(self, postgres_backend, social_da GraphColumn("f", "since", "since")) gt = GraphTableExpression(dialect, GRAPH_NAME, match, cols, alias="g") - join = JoinExpression(dialect, + join = JoinClause(dialect, left_table=gt, right_table=TableExpression(dialect, "people", alias="p"), join_type="INNER JOIN", @@ -326,24 +326,24 @@ async def async_social_data(self, async_postgres_backend): await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) people_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", TextType()), - ColumnDefinition("email", TextType()), - ColumnDefinition("city", TextType()), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", TextType(dialect=dialect)), + ColumnDefinition(dialect, "email", TextType(dialect=dialect)), + ColumnDefinition(dialect, "city", TextType(dialect=dialect)), ] await backend.execute(*CreateTableExpression(dialect, "people", people_cols).to_sql()) follows_cols = [ - ColumnDefinition("id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "follower_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("followed_id", IntegerType(), - constraints=[ColumnConstraint(ColumnConstraintType.FOREIGN_KEY, + ColumnDefinition(dialect, "followed_id", IntegerType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.FOREIGN_KEY, foreign_key_reference=("people", ["id"]))]), - ColumnDefinition("since", TextType()), + ColumnDefinition(dialect, "since", TextType(dialect=dialect)), ] await backend.execute(*CreateTableExpression(dialect, "follows", follows_cols).to_sql()) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py new file mode 100644 index 00000000..ae7ae482 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py @@ -0,0 +1,226 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_qualified_reference_context.py +"""Column qualifier resolution under JOINs (regression tests). + +History: the query layer used to force ``alias=alias or table_name`` on every +JOIN range while field accessors baked ``"schema"."table"."column"`` into +Column expressions at access time. PostgreSQL forbids schema-qualified +references to aliased ranges, so plain join queries failed with +``invalid reference to FROM-clause entry``. + +The forced aliasing was removed (JoinQueryMixin / AsyncJoinQueryMixin); these +tests pin down the fixed behaviour: + +* R1 plain field accessors under JOIN must execute +* R2a mixed aliased/plain accessors must execute +* R3 pre-built predicates remain reusable across plain/join contexts +* R4 dynamic ``__table_name__`` / ``__schema_name__``: the supported usage — + change the override, then build expressions — produces fully consistent + DQL across FROM / WHERE / SELECT (qualifiers are captured when column + expressions are constructed; rebuild conditions after a change) +* Control: fully alias-consistent accessors keep working, and the generated + FROM carries no implicit aliases. +""" + +from typing import ClassVar, Optional + +import pytest + +from rhosocial.activerecord.backend.impl.postgres import ( + PostgresBackend, + PostgresConnectionConfig, +) +from rhosocial.activerecord.backend.options import ExecutionOptions +from rhosocial.activerecord.backend.schema import StatementType +from rhosocial.activerecord.base.field_proxy import FieldProxy +from rhosocial.activerecord.model import ActiveRecord + +RISK_SCHEMA = "tenant_qualified_ref" + + +class Author(ActiveRecord): + __table_name__ = "authors" + __schema_name__ = RISK_SCHEMA + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + name: str + active: Optional[bool] = None + + +class Book(ActiveRecord): + __table_name__ = "books" + __schema_name__ = RISK_SCHEMA + __primary_key__ = "id" + c: ClassVar[FieldProxy] = FieldProxy() + + id: Optional[int] = None + title: str + author_id: Optional[int] = None + + +@pytest.fixture +def risk_env(postgres_backend_single): + """Provision schema/tables, bind models to the scenario config, capture SQL. + + Yields a namespace with: + backend -- the fixture backend (raw SQL setup/teardown) + config -- connection config shared by the models + captured -- list of SQL strings executed by the models' backends + """ + backend = postgres_backend_single + ddl = ExecutionOptions(stmt_type=StatementType.DDL) + + backend.execute(f'DROP SCHEMA IF EXISTS "{RISK_SCHEMA}" CASCADE', options=ddl) + backend.execute(f'CREATE SCHEMA "{RISK_SCHEMA}"', options=ddl) + for table in ("authors", "authors_archive", "books"): + if table == "books": + cols = "id SERIAL PRIMARY KEY, title TEXT, author_id INTEGER" + else: + cols = "id SERIAL PRIMARY KEY, name TEXT, active BOOLEAN DEFAULT TRUE" + backend.execute(f'CREATE TABLE "{RISK_SCHEMA}".{table} ({cols})', options=ddl) + backend.execute( + f"INSERT INTO \"{RISK_SCHEMA}\".authors (name) VALUES ('a1'), ('a2')", + options=ExecutionOptions(stmt_type=StatementType.DML), + ) + backend.execute( + f"INSERT INTO \"{RISK_SCHEMA}\".authors_archive (name) VALUES ('old1')", + options=ExecutionOptions(stmt_type=StatementType.DML), + ) + + # A second schema for dynamic __schema_name__ switching tests. + backend.execute(f'DROP SCHEMA IF EXISTS "{RISK_SCHEMA}_alt" CASCADE', options=ddl) + backend.execute(f'CREATE SCHEMA "{RISK_SCHEMA}_alt"', options=ddl) + backend.execute( + f'CREATE TABLE "{RISK_SCHEMA}_alt".authors ' + f'(id SERIAL PRIMARY KEY, name TEXT, active BOOLEAN DEFAULT TRUE)', + options=ddl, + ) + backend.execute( + f"INSERT INTO \"{RISK_SCHEMA}_alt\".authors (name) VALUES ('alt1')", + options=ExecutionOptions(stmt_type=StatementType.DML), + ) + + config: PostgresConnectionConfig = backend.config + captured: list = [] + original_execute = PostgresBackend.execute + + def spying_execute(self, sql, params=None, **kwargs): + captured.append(sql) + return original_execute(self, sql, params, **kwargs) + + PostgresBackend.execute = spying_execute + try: + Author.configure(config, PostgresBackend) + Book.configure(config, PostgresBackend) + yield type("Env", (), {"backend": backend, "config": config, "captured": captured}) + finally: + PostgresBackend.execute = original_execute + Author.__table_name__ = "authors" + Author.__schema_name__ = RISK_SCHEMA + backend.execute(f'DROP SCHEMA IF EXISTS "{RISK_SCHEMA}" CASCADE', options=ddl) + backend.execute(f'DROP SCHEMA IF EXISTS "{RISK_SCHEMA}_alt" CASCADE', options=ddl) + + +class TestQualifiedReferenceContext: + """See module docstring for the risk catalogue.""" + + def test_join_with_plain_accessors(self, risk_env): + """R1: natural join form with unaliased field accessors.""" + rows = ( + Author.query() + .join(Book, on=Author.c.id == Book.c.author_id) + .select(Author.c.name) + .all() + ) + assert isinstance(rows, list) + + def test_join_with_partial_aliases(self, risk_env): + """R2a: mixing a table-aliased accessor with plain accessors.""" + rows = ( + Author.query() + .join(Book, on=Author.c.id == Book.c.with_table_alias("books").author_id) + .select(Author.c.name.as_("n")) + .all() + ) + assert isinstance(rows, list) + + def test_prebuilt_predicate_reuse_under_join(self, risk_env): + """R3: predicates stay valid when reused across query contexts.""" + predicate = Author.c.active == True # noqa: E712 + assert len(Author.query().where(predicate).all()) == 2 + rows = ( + Author.query() + .join(Book, on=Author.c.id == Book.c.author_id) + .where(predicate) + .all() + ) + assert isinstance(rows, list) + + def test_explicit_alias_join(self, risk_env): + """Explicit join aliases keep working via matching accessors.""" + rows = ( + Author.query() + .join(Book, on=Author.c.id == Book.c.with_table_alias("b").author_id, alias="b") + .select(Author.c.name.as_("n")) + .all() + ) + assert isinstance(rows, list) + + def test_dynamic_table_name_full_dql_consistency(self, risk_env): + """R4: dynamic __table_name__ used the supported way. + + Change the override first, then build expressions — field proxies + pick up the new name and FROM / WHERE / SELECT all stay consistent. + """ + Author.__table_name__ = "authors_archive" + try: + rows = Author.query().where(Author.c.active == True).all() # noqa: E712 + assert len(rows) == 1 + select_sql = next( + (s for s in risk_env.captured + if s.startswith("SELECT") and "authors_archive" in s), + "", + ) + assert 'FROM "tenant_qualified_ref"."authors_archive"' in select_sql + assert '"authors_archive"."active"' in select_sql + finally: + Author.__table_name__ = "authors" + + def test_dynamic_schema_switch_full_dql_consistency(self, risk_env): + """R4: same contract for dynamic __schema_name__ (tenant switching).""" + Author.__schema_name__ = f"{RISK_SCHEMA}_alt" + try: + rows = Author.query().where(Author.c.name == "alt1").all() + assert len(rows) == 1 and rows[0].name == "alt1" + select_sql = next( + (s for s in risk_env.captured if s.startswith("SELECT") and "alt" in s), + "", + ) + assert f'FROM "{RISK_SCHEMA}_alt"."authors"' in select_sql + finally: + Author.__schema_name__ = RISK_SCHEMA + + def test_baseline_plain_query_with_predicate(self, risk_env): + """Sanity: prebuilt predicates work in their construction context.""" + predicate = Author.c.active == True # noqa: E712 + rows = Author.query().where(predicate).all() + assert len(rows) == 2 + + def test_control_fully_alias_consistent(self, risk_env): + """Control: FROM ranges carry no implicit aliases; references resolve.""" + rows = ( + Author.query() + .join(Book, on=Author.c.with_table_alias("authors").id + == Book.c.with_table_alias("books").author_id) + .select(Author.c.with_table_alias("authors").name.as_("n")) + .all() + ) + assert isinstance(rows, list) + select_sql = next((s for s in risk_env.captured if s.startswith("SELECT")), "") + # Schema-qualified FROM ranges, no forced AS aliases ... + assert 'FROM "tenant_qualified_ref"."authors"' in select_sql + assert 'JOIN "tenant_qualified_ref"."books"' in select_sql + assert " AS " not in select_sql.split("FROM", 1)[1] + # ... and table-name-qualified column references. + assert '"authors"."id"' in select_sql and '"books"."author_id"' in select_sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py index b05c6fb1..6ce11a75 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_recursive_cte_graph_traversal.py @@ -39,7 +39,7 @@ WhereClause, OrderByClause, GroupByHavingClause, - JoinExpression, + JoinClause, ) @@ -53,16 +53,16 @@ def social_network_data(postgres_backend): backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) backend.execute(*CreateTableExpression(dialect, "users", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=100, dialect=dialect)), ]).to_sql()) backend.execute(*CreateTableExpression(dialect, "follows", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", IntegerType()), - ColumnDefinition("followed_id", IntegerType()), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "follower_id", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "followed_id", IntegerType(dialect=dialect)), ]).to_sql()) backend.execute(*InsertExpression(dialect, "users", columns=["id", "name"], @@ -98,18 +98,18 @@ def aml_data(postgres_backend): backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) backend.execute(*CreateTableExpression(dialect, "accounts", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("account_holder", VarCharType(100)), - ColumnDefinition("account_type", VarCharType(20)), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "account_holder", VarCharType(length=100, dialect=dialect)), + ColumnDefinition(dialect, "account_type", VarCharType(length=20, dialect=dialect)), ]).to_sql()) backend.execute(*CreateTableExpression(dialect, "transactions", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("source_account_id", IntegerType()), - ColumnDefinition("target_account_id", IntegerType()), - ColumnDefinition("amount", DecimalType(12, 2)), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "source_account_id", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "target_account_id", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "amount", DecimalType(precision=12, scale=2, dialect=dialect)), ]).to_sql()) backend.execute(*InsertExpression(dialect, "accounts", columns=["id", "account_holder", "account_type"], @@ -152,7 +152,7 @@ def _build_traversal_cte(self, dialect): where=WhereClause(dialect, condition=Column(dialect, "name") == Literal(dialect, "Alice")), ) - recursive_join = JoinExpression( + recursive_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, "traversal", alias="t"), right_table=TableExpression(dialect, "follows", alias="f"), @@ -289,7 +289,7 @@ class TestAMLFundTracing: def _build_aml_cte(self, dialect): """Build recursive CTE expression for fund tracing.""" - base_join = JoinExpression( + base_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, "transactions", alias="tx"), right_table=TableExpression(dialect, "accounts", alias="a"), @@ -314,7 +314,7 @@ def _build_aml_cte(self, dialect): ), ) - recursive_join = JoinExpression( + recursive_join = JoinClause( dialect=dialect, left_table=TableExpression(dialect, "fund_trace", alias="tr"), right_table=TableExpression(dialect, "transactions", alias="tx"), @@ -465,16 +465,16 @@ async def async_social_network_data(self, async_postgres_backend): await backend.execute(*DropTableExpression(dialect, t, if_exists=True, cascade=True).to_sql()) await backend.execute(*CreateTableExpression(dialect, "users", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("name", VarCharType(100)), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "name", VarCharType(length=100, dialect=dialect)), ]).to_sql()) await backend.execute(*CreateTableExpression(dialect, "follows", [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY)]), - ColumnDefinition("follower_id", IntegerType()), - ColumnDefinition("followed_id", IntegerType()), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY)]), + ColumnDefinition(dialect, "follower_id", IntegerType(dialect=dialect)), + ColumnDefinition(dialect, "followed_id", IntegerType(dialect=dialect)), ]).to_sql()) await backend.execute(*InsertExpression(dialect, "users", columns=["id", "name"], diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py index 50058992..84bef0e1 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_type_casting.py @@ -10,7 +10,7 @@ import pytest # noqa: F401 import warnings -from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.core import Column, Literal, CastExpression from rhosocial.activerecord.backend.impl.postgres import PostgresDialect from rhosocial.activerecord.backend.impl.postgres.types.constants import ( MONEY, NUMERIC, FLOAT8, INTEGER, VARCHAR, TEXT, @@ -123,34 +123,32 @@ def setup_method(self): def test_format_cast_basic(self): """Test basic type cast formatting.""" - sql, params = self.dialect.format_cast_expression( - "col", INTEGER, () - ) - assert sql == "col::integer" + expr = CastExpression(self.dialect, Column(self.dialect, "col"), INTEGER) + sql, params = expr.to_sql() + assert sql == '"col"::integer' assert params == () def test_format_cast_with_type_modifier(self): """Test type cast with type modifiers.""" - sql, params = self.dialect.format_cast_expression( - "name", "VARCHAR(100)", () - ) - assert sql == "name::VARCHAR(100)" + expr = CastExpression(self.dialect, Column(self.dialect, "name"), "VARCHAR(100)") + sql, params = expr.to_sql() + assert sql == '"name"::VARCHAR(100)' assert params == () def test_format_cast_with_alias(self): """Test type cast with alias.""" - sql, params = self.dialect.format_cast_expression( - "value", INTEGER, (), "int_val" + expr = CastExpression( + self.dialect, Column(self.dialect, "value"), INTEGER, alias="int_val" ) - assert sql == 'value::integer AS "int_val"' + sql, params = expr.to_sql() + assert sql == '"value"::integer AS "int_val"' assert params == () def test_format_cast_preserves_params(self): """Test that parameters are preserved.""" params_in = ("test_value",) - sql, params = self.dialect.format_cast_expression( - "%s", TEXT, params_in - ) + expr = Literal(self.dialect, "test_value").cast(TEXT) + sql, params = expr.to_sql() assert sql == "%s::text" assert params == params_in diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py index e3215a19..639e509e 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py @@ -1,348 +1,228 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_adapters.py +"""Offline adapter round-trip coverage for the PostgreSQL backend.""" +import uuid +from decimal import Decimal +from typing import Any, Tuple -import ipaddress -import json # noqa: F401 import pytest -from psycopg.types.json import Jsonb - -from rhosocial.activerecord.backend.impl.postgres.adapters import PostgresJSONBAdapter, \ - PostgresNetworkAddressAdapter - -# Tests for PostgresJSONBAdapter -def test_jsonb_adapter_supported_types(): - adapter = PostgresJSONBAdapter() - supported = adapter.supported_types - assert dict in supported - assert Jsonb in supported[dict] - - -def test_jsonb_to_database(): - adapter = PostgresJSONBAdapter() - my_dict = {"a": 1, "b": [2, 3]} - my_list = [1, "test", {"c": 4}] - - # Test with dict - result_dict = adapter.to_database(my_dict, dict) - assert isinstance(result_dict, Jsonb) - assert result_dict.obj == my_dict - - # Test with list - result_list = adapter.to_database(my_list, list) - assert isinstance(result_list, Jsonb) - assert result_list.obj == my_list - - # Test with None - assert adapter.to_database(None, dict) is None - - -def test_jsonb_from_database(): - adapter = PostgresJSONBAdapter() - my_dict = {"key": "value"} - my_list = ["item1", "item2"] - json_string = '{"key": "value_from_string"}' - - # Test with value already being a dict or list - assert adapter.from_database(my_dict, dict) is my_dict - assert adapter.from_database(my_list, list) is my_list - - # Test with a JSON string representation - result = adapter.from_database(json_string, dict) - assert isinstance(result, dict) - assert result["key"] == "value_from_string" - - # Test with None - assert adapter.from_database(None, dict) is None - - -# Tests for PostgresNetworkAddressAdapter -def test_network_adapter_supported_types(): - adapter = PostgresNetworkAddressAdapter() - supported = adapter.supported_types - assert ipaddress.IPv4Address in supported - assert str in supported[ipaddress.IPv4Address] - assert ipaddress.IPv6Address in supported - assert str in supported[ipaddress.IPv6Address] - assert ipaddress.IPv4Network in supported - assert str in supported[ipaddress.IPv4Network] - assert ipaddress.IPv6Network in supported - assert str in supported[ipaddress.IPv6Network] - - - -@pytest.mark.parametrize("ip_obj, expected_str", [ - (ipaddress.ip_address("192.168.1.1"), "192.168.1.1"), - (ipaddress.ip_address("2001:db8::1"), "2001:db8::1"), - (ipaddress.ip_network("192.168.0.0/24"), "192.168.0.0/24"), - (ipaddress.ip_network("2001:db8::/32"), "2001:db8::/32"), -]) -def test_network_to_database(ip_obj, expected_str): - adapter = PostgresNetworkAddressAdapter() - result = adapter.to_database(ip_obj, type(ip_obj)) - assert result == expected_str - - -def test_network_to_database_none(): - adapter = PostgresNetworkAddressAdapter() - assert adapter.to_database(None, ipaddress.IPv4Address) is None - - -@pytest.mark.parametrize("ip_str, expected_type", [ - ("192.168.1.1", ipaddress.IPv4Address), - ("2001:db8::1", ipaddress.IPv6Address), - ("192.168.0.0/24", ipaddress.IPv4Network), - ("2001:db8::/32", ipaddress.IPv6Network), -]) -def test_network_from_database(ip_str, expected_type): - adapter = PostgresNetworkAddressAdapter() - result = adapter.from_database(ip_str, expected_type) - assert isinstance(result, expected_type) - assert str(result) == ip_str - - -def test_network_from_database_fallback(): - """Test fallback behavior for invalid network strings.""" - adapter = PostgresNetworkAddressAdapter() - invalid_str = "not-an-ip" - result = adapter.from_database(invalid_str, str) - assert result == invalid_str - - -def test_network_from_database_none(): - adapter = PostgresNetworkAddressAdapter() - assert adapter.from_database(None, ipaddress.IPv4Address) is None - - -# ============================================================================= -# Batch Operation Tests -# ============================================================================= - -from rhosocial.activerecord.backend.impl.postgres.adapters.base import ( # noqa: E402 - PostgresListAdapter, - PostgresJSONBAdapter, # noqa: F811 - PostgresNetworkAddressAdapter, # noqa: F811 +from rhosocial.activerecord.backend.impl.postgres.adapters.bit_string import PostgresBitStringAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.hstore import PostgresHstoreAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.json import PostgresJSONBAdapter, PostgresJsonPathAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.pg_lsn import PostgresLsnAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.monetary import PostgresMoneyAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.network_address import PostgresMacaddrAdapter, PostgresNetworkAddressAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.object_identifier import PostgresOidAdapter, PostgresTidAdapter, PostgresXidAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.text_search import PostgresTsQueryAdapter, PostgresTsVectorAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.uuid import PostgresUUIDAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.xml import PostgresXMLAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.range import PostgresRangeAdapter, PostgresMultirangeAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.base import PostgresListAdapter, PostgresEnumAdapter +from rhosocial.activerecord.backend.impl.postgres.adapters.pgvector import PostgresVectorAdapter +from rhosocial.activerecord.backend.impl.postgres.types.range import ( + PostgresRange as PG_RANGE, + PostgresMultirange as PG_MULTIRANGE, ) -class TestPostgresListAdapterBatch: - """Test PostgresListAdapter batch methods.""" - - def test_to_database_batch_pass_through(self): - """Test that to_database_batch returns the same list (pass-through).""" - adapter = PostgresListAdapter() - test_data = [[1, 2, 3], [4, 5, 6], None, [7, 8, 9]] - result = adapter.to_database_batch(test_data, list) - # Should be the same object (no copy) - assert result is test_data - - def test_to_database_batch_empty_list(self): - """Test batch conversion with empty list.""" - adapter = PostgresListAdapter() - result = adapter.to_database_batch([], list) - assert result == [] - - def test_from_database_batch_pass_through(self): - """Test that from_database_batch returns the same list.""" - adapter = PostgresListAdapter() - test_data = [[1, 2], [3, 4], None] - result = adapter.from_database_batch(test_data, list) - assert result is test_data - - def test_from_database_batch_empty_list(self): - """Test batch conversion from database with empty list.""" - adapter = PostgresListAdapter() - result = adapter.from_database_batch([], list) - assert result == [] - - -class TestPostgresJSONBAdapterBatch: - """Test PostgresJSONBAdapter batch methods.""" - - def test_to_database_batch(self): - """Test batch conversion to database format.""" - adapter = PostgresJSONBAdapter() - test_data = [ - {'a': 1}, - {'b': 2}, - None, - {'c': [1, 2, 3]} - ] - result = adapter.to_database_batch(test_data, dict) - - assert len(result) == 4 - assert isinstance(result[0], Jsonb) - assert result[0].obj == {'a': 1} - assert isinstance(result[1], Jsonb) - assert result[1].obj == {'b': 2} - assert result[2] is None - assert isinstance(result[3], Jsonb) - assert result[3].obj == {'c': [1, 2, 3]} - - def test_to_database_batch_empty_list(self): - """Test batch conversion with empty list.""" - adapter = PostgresJSONBAdapter() - result = adapter.to_database_batch([], dict) - assert result == [] - - def test_from_database_batch_to_dict(self): - """Test batch conversion from database to dict.""" - adapter = PostgresJSONBAdapter() - test_data = [ - {'a': 1}, - None, - {'b': 2} - ] - result = adapter.from_database_batch(test_data, dict) - - assert len(result) == 3 - assert result[0] == {'a': 1} - assert result[1] is None - assert result[2] == {'b': 2} - - def test_from_database_batch_to_str(self): - """Test batch conversion from database to JSON string.""" - adapter = PostgresJSONBAdapter() - test_data = [ - {'a': 1}, - None, - {'b': [2, 3]} - ] - result = adapter.from_database_batch(test_data, str) - - assert len(result) == 3 - assert result[0] == '{"a": 1}' - assert result[1] is None - assert result[2] == '{"b": [2, 3]}' - - def test_from_database_batch_empty_list(self): - """Test batch conversion from database with empty list.""" - adapter = PostgresJSONBAdapter() - result = adapter.from_database_batch([], dict) - assert result == [] - - -class TestPostgresNetworkAddressAdapterBatch: - """Test PostgresNetworkAddressAdapter batch methods.""" - - def test_to_database_batch(self): - """Test batch conversion to database format.""" - adapter = PostgresNetworkAddressAdapter() - test_data = [ - ipaddress.IPv4Address('192.168.1.1'), - ipaddress.IPv6Address('::1'), - None, - ipaddress.IPv4Network('10.0.0.0/24') - ] - result = adapter.to_database_batch(test_data, str) - - assert len(result) == 4 - assert result[0] == '192.168.1.1' - assert result[1] == '::1' - assert result[2] is None - assert result[3] == '10.0.0.0/24' - - def test_to_database_batch_empty_list(self): - """Test batch conversion with empty list.""" - adapter = PostgresNetworkAddressAdapter() - result = adapter.to_database_batch([], str) - assert result == [] - - def test_from_database_batch(self): - """Test batch conversion from database.""" - adapter = PostgresNetworkAddressAdapter() - test_data = [ - '192.168.1.1', - None, - '10.0.0.0/24', - '::1' - ] - result = adapter.from_database_batch(test_data, str) - - assert len(result) == 4 - assert isinstance(result[0], ipaddress.IPv4Address) - assert str(result[0]) == '192.168.1.1' - assert result[1] is None - assert isinstance(result[2], ipaddress.IPv4Network) - assert str(result[2]) == '10.0.0.0/24' - assert isinstance(result[3], ipaddress.IPv6Address) - assert str(result[3]) == '::1' - - def test_from_database_batch_invalid_values(self): - """Test batch conversion with invalid values falls back to string.""" - adapter = PostgresNetworkAddressAdapter() - test_data = [ - '192.168.1.1', - 'not-an-ip', - '10.0.0.0/24' - ] - result = adapter.from_database_batch(test_data, str) - - assert len(result) == 3 - assert isinstance(result[0], ipaddress.IPv4Address) - assert result[1] == 'not-an-ip' # Falls back to original string - assert isinstance(result[2], ipaddress.IPv4Network) - - def test_from_database_batch_empty_list(self): - """Test batch conversion from database with empty list.""" - adapter = PostgresNetworkAddressAdapter() - result = adapter.from_database_batch([], str) - assert result == [] - - -class TestPostgresEnumAdapterBatch: - """Test PostgresEnumAdapter batch methods.""" - - def test_to_database_batch(self): - """Test batch conversion to database format.""" - from rhosocial.activerecord.backend.impl.postgres.adapters.base import PostgresEnumAdapter - from enum import Enum - - class TestEnum(Enum): - A = 1 - B = 2 - C = 3 - - adapter = PostgresEnumAdapter() - test_data = [ - TestEnum.A, - 'existing_string', - None, - TestEnum.C - ] - result = adapter.to_database_batch(test_data, str) - - assert len(result) == 4 - assert result[0] == 'A' - assert result[1] == 'existing_string' - assert result[2] is None - assert result[3] == 'C' - - def test_from_database_batch(self): - """Test batch conversion from database.""" - from rhosocial.activerecord.backend.impl.postgres.adapters.base import PostgresEnumAdapter - - adapter = PostgresEnumAdapter() - test_data = ['value1', None, 'value2'] - result = adapter.from_database_batch(test_data, str) - - assert len(result) == 3 - assert result[0] == 'value1' - assert result[1] is None - assert result[2] == 'value2' - - def test_from_database_batch_to_enum(self): - """Test batch conversion from database to Python Enum.""" - from rhosocial.activerecord.backend.impl.postgres.adapters.base import PostgresEnumAdapter - from enum import Enum - - class TestEnum(Enum): - A = 1 - B = 2 - - adapter = PostgresEnumAdapter() - test_data = ['A', 'B', None] - result = adapter.from_database_batch(test_data, str, {'enum_class': TestEnum}) - - assert len(result) == 3 - assert result[0] == TestEnum.A - assert result[1] == TestEnum.B - assert result[2] is None +@pytest.fixture +def uuid_a(): return PostgresUUIDAdapter() +@pytest.fixture +def jsonb(): return PostgresJSONBAdapter() +@pytest.fixture +def json_path(): return PostgresJsonPathAdapter() +@pytest.fixture +def range_a(): return PostgresRangeAdapter() +@pytest.fixture +def multi_range(): return PostgresMultirangeAdapter() +@pytest.fixture +def net_addr(): return PostgresNetworkAddressAdapter() +@pytest.fixture +def mac(): return PostgresMacaddrAdapter() +@pytest.fixture +def lsn(): return PostgresLsnAdapter() +@pytest.fixture +def money(): return PostgresMoneyAdapter() +@pytest.fixture +def hstore(): return PostgresHstoreAdapter() +@pytest.fixture +def tsvec(): return PostgresTsVectorAdapter() +@pytest.fixture +def tsquery(): return PostgresTsQueryAdapter() +@pytest.fixture +def xml_a(): return PostgresXMLAdapter() +@pytest.fixture +def bit(): return PostgresBitStringAdapter() +@pytest.fixture +def oid(): return PostgresOidAdapter() +@pytest.fixture +def tid(): return PostgresTidAdapter() +@pytest.fixture +def xid(): return PostgresXidAdapter() +@pytest.fixture +def list_a(): return PostgresListAdapter() +@pytest.fixture +def enum_a(): return PostgresEnumAdapter() +@pytest.fixture +def vec(): return PostgresVectorAdapter() + + +class TestUUID: + def test_roundtrip(self, uuid_a): + u = uuid.uuid4() + assert uuid_a.to_database(u, str) == str(u) + assert uuid_a.from_database(str(u), uuid.UUID) == u + def test_none(self, uuid_a): + assert uuid_a.to_database(None, str) is None + +class TestJSONB: + def test_to_database_returns_jsonb(self, jsonb): + val = {"k": 1, "v": [2, 3]} + result = jsonb.to_database(val, str) + assert str(result) # serializable to JSON string + +class TestJsonPath: + def test_roundtrip(self, json_path): + p = '$.store.book[*].author' + assert json_path.from_database(p, str) == p + +class TestRange: + def test_to_database(self, range_a): + r = PG_RANGE(1, 10, lower_inc=True, upper_inc=False) + s = r.to_postgres_string() + assert isinstance(s, str) + assert range_a.to_database(r, str) == s + +class TestMultirange: + def test_to_database(self, multi_range): + mr = PG_MULTIRANGE([]) + assert multi_range.to_database(mr, str) is not None + +class TestNetworkAddress: + def test_inet_roundtrip(self, net_addr): + assert net_addr.to_database("192.168.1.1", str) == "192.168.1.1" + parsed = net_addr.from_database("192.168.1.1", str) + assert str(parsed) == "192.168.1.1" # IPv4Address/IPInterface repr + def test_none(self, net_addr): + assert net_addr.to_database(None, str) is None + +class TestMacaddr: + def test_roundtrip(self, mac): + m = "08:00:2b:01:02:03" + assert mac.to_database(m, str) == m + assert mac.from_database(m, str) == m + +class TestLsn: + def test_roundtrip(self, lsn): + s = "0/3000000" + assert lsn.from_database(s, str) == s + +class TestHstore: + def test_roundtrip(self, hstore): + d = {"key": "val", "k2": "v2"} + s = hstore.to_database(d, str) + assert isinstance(s, str) + assert hstore.from_database(s, dict) == d + +class TestTsVector: + def test_from_database(self, tsvec): + result = tsvec.from_database("hello world", str) + assert result is not None + assert hasattr(result, "lexemes") + +class TestTsQuery: + def test_from_database(self, tsquery): + result = tsquery.from_database("hello & world", str) + assert result is not None + +class TestXML: + def test_roundtrip(self, xml_a): + s = "1" + assert xml_a.from_database(s, str) == s + +class TestBitString: + def test_roundtrip(self, bit): + s = "101010" + assert bit.from_database(s, str) == s + +class TestOid: + def test_roundtrip(self, oid): + assert oid.from_database(42, int) == 42 + +class TestXid: + def test_roundtrip(self, xid): + assert xid.from_database(123, int) == 123 + +class TestTid: + def test_roundtrip(self, tid): + s = "(0,1)" + assert tid.from_database(s, str) == s + +class TestMoney: + def test_roundtrip(self, money): + assert money.from_database(Decimal("100.50"), Decimal) == Decimal("100.50") + def test_none(self, money): + assert money.from_database(None, Decimal) is None + +class TestEnum: + def test_roundtrip(self, enum_a): + s = "draft" + assert enum_a.to_database(s, str) == s + assert enum_a.from_database(s, str) == s + +class TestList: + def test_to_database(self, list_a): + arr = [1, 2, 3] + assert list_a.to_database(arr, list) == arr + + def test_roundtrip(self, list_a): + arr = [1, 2, 3] + assert list_a.from_database(arr, list) == arr + + def test_from_database_json_string(self, list_a): + assert list_a.from_database("[1, 2, 3]", list) == [1, 2, 3] + + def test_from_database_json_string_with_whitespace(self, list_a): + assert list_a.from_database(' ["a", "b"] ', list) == ["a", "b"] + + def test_from_database_pg_array_literal(self, list_a): + assert list_a.from_database("{1,2,3}", list) == ["1", "2", "3"] + + def test_from_database_pg_array_literal_quoted(self, list_a): + assert list_a.from_database('{"a","b c"}', list) == ["a", "b c"] + + def test_from_database_pg_array_literal_null(self, list_a): + assert list_a.from_database("{1,NULL,3}", list) == ["1", None, "3"] + + def test_from_database_invalid_string_raises(self, list_a): + with pytest.raises(TypeError, match="Cannot convert str to list"): + list_a.from_database("not a list", list) + + def test_from_database_invalid_json_string_fallthrough(self, list_a): + with pytest.raises(TypeError, match="Cannot convert str to list"): + list_a.from_database("[1, 2", list) + + def test_from_database_invalid_array_literal_fallthrough(self, list_a): + with pytest.raises(TypeError, match="Cannot convert str to list"): + list_a.from_database("{}}", list) # extra brace triggers DataError + + def test_from_database_non_string_raises(self, list_a): + with pytest.raises(TypeError, match="Cannot convert int to list"): + list_a.from_database(123, list) + + def test_parse_array_literal_invalid_raises(self): + with pytest.raises(ValueError, match="Not a PostgreSQL array literal"): + PostgresListAdapter._parse_array_literal("not-an-array") + + def test_to_database_batch(self, list_a): + assert list_a.to_database_batch([[1], [2, 3]], list) == [[1], [2, 3]] + + def test_from_database_batch(self, list_a): + assert list_a.from_database_batch([[1], [2, 3]], list) == [[1], [2, 3]] + +class TestVector: + def test_roundtrip(self, vec): + v = [1.0, 2.5, 3.0] + s = vec.to_database(v, str) + assert isinstance(s, str) + assert vec.from_database(s, list) == v \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_auto_increment_ddl.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_auto_increment_ddl.py new file mode 100644 index 00000000..6fb0a85b --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_auto_increment_ddl.py @@ -0,0 +1,83 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_auto_increment_ddl.py +"""Regression tests for DDL compilation of auto-increment, standard types, +boolean defaults, and timestamps. + +Covers bugs found in rbac cross-backend testing: +- is_auto_increment=True on PRIMARY_KEY must produce GENERATED BY DEFAULT + AS IDENTITY (was missing — caused INSERT id=NULL NOT NULL violation). +- format_create_table_statement must return a string for all types. +- BooleanType with DEFAULT True must be accepted. +- TimestampType must map to TIMESTAMP. +""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + CreateTableExpression, ColumnDefinition, ColumnConstraint, ColumnConstraintType, +) +from rhosocial.activerecord.backend.expression.types import ( + BooleanType, IntegerType, TextType, TimestampType, VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +@pytest.fixture +def dialect(): + return PostgresDialect() + + +def _pk(dialect): + return [ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] + + +def _build_table(dialect): + return CreateTableExpression( + dialect=dialect, table="test_tbl", + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=_pk(dialect)), + ColumnDefinition(dialect, "name", TextType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + ColumnDefinition(dialect, "code", VarCharType(length=16, dialect=dialect)), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect)), + ], + ) + + +class TestAutoIncrementDDL: + + def test_auto_increment_in_ddl(self, dialect): + sql, _ = dialect.format_create_table_statement(_build_table(dialect)) + assert "GENERATED BY DEFAULT AS IDENTITY" in sql.upper(), \ + f"auto-increment missing: {sql}" + + def test_all_standard_types_compile(self, dialect): + sql, params = dialect.format_create_table_statement(_build_table(dialect)) + assert isinstance(sql, str) + assert isinstance(params, tuple) + + def test_boolean_default_true(self, dialect): + expr = CreateTableExpression( + dialect=dialect, table="bool_test", + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=_pk(dialect)), + ColumnDefinition(dialect, "flag", BooleanType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL), + ColumnConstraint(dialect, ColumnConstraintType.DEFAULT, default_value=True)]), + ], + ) + sql, _ = dialect.format_create_table_statement(expr) + assert "BOOLEAN" in sql.upper() + + def test_timestamp_type(self, dialect): + expr = CreateTableExpression( + dialect=dialect, table="ts_test", + columns=[ + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=_pk(dialect)), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect)), + ], + ) + sql, _ = dialect.format_create_table_statement(expr) + assert "TIMESTAMP" in sql.upper() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli_connection_args.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli_connection_args.py new file mode 100644 index 00000000..fccee4ec --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli_connection_args.py @@ -0,0 +1,46 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_cli_connection_args.py +"""Offline tests for ``resolve_connection_config_from_args`` (SSL mapping, etc.).""" +import argparse + +from rhosocial.activerecord.backend.impl.postgres.cli.connection import ( + add_connection_args, + resolve_connection_config_from_args, +) + + +def _parse(extra=None): + parser = argparse.ArgumentParser() + add_connection_args(parser) + return parser.parse_args(extra or []) + + +class TestCLISSLMapping: + """SSL parameter mapping in resolve_connection_config_from_args.""" + + def test_default_ssl_maps_to_prefer(self): + config = resolve_connection_config_from_args(_parse()) + assert config.sslmode == "prefer" + + def test_ssl_auto(self): + config = resolve_connection_config_from_args(_parse(["--ssl", "auto"])) + assert config.sslmode == "prefer" + + def test_ssl_require(self): + config = resolve_connection_config_from_args(_parse(["--ssl", "require"])) + assert config.sslmode == "require" + + def test_ssl_verify_ca(self): + config = resolve_connection_config_from_args(_parse(["--ssl", "verify-ca"])) + assert config.sslmode == "verify-ca" + + def test_ssl_verify_full(self): + config = resolve_connection_config_from_args(_parse(["--ssl", "verify-full"])) + assert config.sslmode == "verify-full" + + def test_ssl_disabled(self): + config = resolve_connection_config_from_args(_parse(["--ssl", "disabled"])) + assert config.sslmode == "disable" + + def test_is_async_flag(self): + assert _parse(["--async"]).is_async is True + assert _parse([]).is_async is False \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py index f8485637..09be55ce 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_create_table_like.py @@ -207,10 +207,10 @@ def test_like_ignores_columns(self): """Test that LIKE syntax ignores columns parameter.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", VarCharType(255)) + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect)) ] create_expr = CreateTableExpression( dialect=dialect, @@ -261,11 +261,11 @@ def test_fallback_to_base_when_no_like(self): """Test that base implementation is used when LIKE is not specified.""" dialect = PostgresDialect() columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.PRIMARY_KEY) ]), - ColumnDefinition("name", VarCharType(255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL) + ColumnDefinition(dialect, "name", VarCharType(length=255, dialect=dialect), constraints=[ + ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL) ]) ] create_expr = CreateTableExpression( diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py new file mode 100644 index 00000000..15c816cf --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py @@ -0,0 +1,80 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_expression_roundtrip_all.py +""" +Functional serialization coverage for PostgreSQL expression classes. + +Every expression class defined in ``rhosocial.activerecord.backend.impl +.postgres.expression`` must round-trip losslessly through dict / JSON / XML +encodings, and produce identical ``to_sql()`` where the PostgreSQL dialect +supports it. +""" + +import pytest + +from rhosocial.activerecord.testsuite.utils.expression import ( + collect_expression_classes, + make_instance, + register_all, + register_special_constructor, + roundtrip_expression, + sql_consistent, +) + +PG_EXPR_PKG = "rhosocial.activerecord.backend.impl.postgres.expression" + +CLASSES = collect_expression_classes(PG_EXPR_PKG) +register_all(CLASSES) + + +def _register_postgres_specials(): + from rhosocial.activerecord.backend.expression.core import Column, Literal + + def for_update(d): + from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( + PostgresForUpdateExpression, + ) + return PostgresForUpdateExpression(d, columns=[Column(dialect, d, "id")]) + + def advisory(d): + from rhosocial.activerecord.backend.impl.postgres.expression.advisory.lock import ( + PostgresAdvisoryLockExpression, + ) + return PostgresAdvisoryLockExpression(d, key=1) + + register_special_constructor("locking.PostgresForUpdateExpression", for_update) + register_special_constructor("advisory.lock.PostgresAdvisoryLockExpression", advisory) + + +_register_postgres_specials() + + +@pytest.fixture(params=[fqn for fqn in sorted(CLASSES)], ids=sorted(CLASSES)) +def postgres_expr_case(request, postgres_dialect): + fqn = request.param + cls = CLASSES[fqn] + instance, source = make_instance(cls, postgres_dialect) + if instance is None: + pytest.skip(f"{fqn}: {source}") + return fqn, instance + + +class TestPostgresExpressionRoundtrip: + """All constructible PostgreSQL expression classes round-trip losslessly.""" + + def test_get_params_roundtrip(self, postgres_expr_case, postgres_dialect): + fqn, instance = postgres_expr_case + roundtrip_expression(fqn, instance, postgres_dialect) + + def test_to_sql_consistent(self, postgres_expr_case, postgres_dialect): + fqn, instance = postgres_expr_case + sql_consistent(fqn, instance, postgres_dialect) + + +def test_core_expressions_also_roundtrip(postgres_dialect): + from rhosocial.activerecord.backend.expression.core import Column, Literal + from rhosocial.activerecord.backend.expression.predicates import ComparisonPredicate + + expr = ComparisonPredicate( + postgres_dialect, "=", Column(postgres_dialect, "a"), Literal(postgres_dialect, 1) + ) + roundtrip_expression("core", expr, postgres_dialect) + sql_consistent("core", expr, postgres_dialect) \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_identifier_quoting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_identifier_quoting.py new file mode 100644 index 00000000..3b51b45a --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_identifier_quoting.py @@ -0,0 +1,57 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_identifier_quoting.py +""" +Tests for PostgresDialect identifier quoting. + +Verifies that format_identifier correctly applies double-quote quoting, +escapes internal double quotes, respects need_quote, and warns about +reserved words used without quoting. +""" + +import pytest + +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.warnings import IdentifierQuotingWarning + + +class TestPostgreSQLIdentifierQuoting: + """Test PostgresDialect format_identifier and reserved words.""" + + def test_format_identifier_default_double_quote(self): + d = PostgresDialect() + assert d.format_identifier("users") == '"users"' + + def test_format_identifier_need_quote_false(self): + d = PostgresDialect() + assert d.format_identifier("users", need_quote=False) == "users" + + def test_format_identifier_escapes_internal_quotes(self): + d = PostgresDialect() + assert d.format_identifier('my"table') == '"my""table"' + + def test_format_identifier_need_quote_false_no_escaping(self): + d = PostgresDialect() + assert d.format_identifier('my"table', need_quote=False) == 'my"table' + + def test_reserved_words_is_frozenset(self): + d = PostgresDialect() + assert isinstance(d.reserved_words, frozenset) + + def test_is_reserved_word_case_insensitive(self): + d = PostgresDialect() + assert d.is_reserved_word("SELECT") is True + assert d.is_reserved_word("select") is True + + def test_is_reserved_word_non_reserved(self): + d = PostgresDialect() + assert d.is_reserved_word("users") is False + + def test_reserved_word_warning_emitted(self): + d = PostgresDialect() + with pytest.warns(IdentifierQuotingWarning, match="select"): + d.format_identifier("select", need_quote=False) + + def test_balanced_quotes_security(self): + d = PostgresDialect() + for ident in ["users", 'my"table', 'a""b']: + result = d.format_identifier(ident) + assert result.count('"') % 2 == 0, f"Unbalanced quotes: {result}" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py index 95649d01..1feedbf9 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_advanced_operations.py @@ -64,9 +64,9 @@ def _create_list_parent_expression(dialect): dialect=dialect, table=LIST_PARENT, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("status", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "status", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -92,9 +92,9 @@ def _create_hash_parent_expression(dialect): dialect=dialect, table=HASH_PARENT, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("bucket", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "bucket", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -120,9 +120,9 @@ def _create_range_parent_expression(dialect): dialect=dialect, table=RANGE_PARENT, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, @@ -322,9 +322,9 @@ def test_default_attach_and_detach_workflow(self, postgres_backend, postgres_ran dialect=dialect, table=default_partition_name, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], ) postgres_backend.execute(*create_default.to_sql()) @@ -382,8 +382,8 @@ def test_metadata_include_partitions_false_and_schema_filter(self, postgres_back dialect=dialect, table=TableExpression(dialect, SCHEMA_PARENT, schema_name=schema), columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], partition=PartitionClause( dialect=dialect, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py index e8a5bdbb..cc4480b7 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_explain.py @@ -68,17 +68,17 @@ def _create_parent_table_expression(dialect): table=PARTITION_EXPLAIN_TABLE, if_not_exists=False, columns=[ - ColumnDefinition("id", BigIntType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(6), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tenant_id", BigIntType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType()), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(precision=6, dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect=dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), ], table_constraints=[ TableConstraint( - TableConstraintType.PRIMARY_KEY, + dialect, TableConstraintType.PRIMARY_KEY, columns=["id", "created_at"], ), ], diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py index fed659ae..ba245139 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_partition_operations.py @@ -82,9 +82,9 @@ def _create_partitioned_parent_sql(dialect, table_name: str): dialect=dialect, table=table_name, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType()), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect)), ], partition=PartitionClause( dialect=dialect, @@ -206,14 +206,14 @@ def _create_production_parent_sql(dialect): dialect=dialect, table=PRODUCTION_PARTITION_TABLE, columns=[ - ColumnDefinition("id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("created_at", TimestampType(6), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("tenant_id", BigIntType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("payload", TextType(), constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "created_at", TimestampType(precision=6, dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "tenant_id", BigIntType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), + ColumnDefinition(dialect, "payload", TextType(dialect=dialect), constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)]), ], table_constraints=[ TableConstraint( - TableConstraintType.PRIMARY_KEY, + dialect, TableConstraintType.PRIMARY_KEY, columns=["id", "created_at"], ), ], diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py index dca80715..e972f799 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_dialect_security.py @@ -18,6 +18,11 @@ TableConstraintType, ) from rhosocial.activerecord.backend.expression.types import VarCharType +from rhosocial.activerecord.backend.expression.operators import ( + BinaryExpression, + RawSQLExpression, +) +from rhosocial.activerecord.backend.expression.core import CastExpression from typing import Tuple, Any # noqa: F401 @@ -44,8 +49,9 @@ def test_postgres_validate_data_type(dialect): def test_postgres_format_column_definition_data_type_validation(dialect): """Test column definition formats data_type correctly.""" col_def = ColumnDefinition( + dialect, name="test_col", - data_type=VarCharType(255), + data_type=VarCharType(length=255, dialect=dialect), ) sql, params = dialect.format_column_definition(col_def) @@ -56,6 +62,7 @@ def test_postgres_column_definition_rejects_string_data_type(dialect): """Test that ColumnDefinition rejects string data_type at construction.""" with pytest.raises(TypeError, match="data_type must be a DataType instance"): ColumnDefinition( + dialect, name="test_col", data_type="VARCHAR(255); DROP TABLE users--", ) @@ -64,6 +71,7 @@ def test_postgres_column_definition_rejects_string_data_type(dialect): def test_postgres_format_default_constraint_string_escaping(dialect): """Test DEFAULT constraint string is escaped.""" constraint = ColumnConstraint( + dialect, constraint_type=ColumnConstraintType.DEFAULT, default_value="test's value", ) @@ -82,14 +90,16 @@ def test_postgres_format_storage_options_string_escaping(dialect): def test_postgres_format_cast_expression_valid(dialect): - """Test that CAST expression validates target_type.""" - sql, params = dialect.format_cast_expression("column", "INTEGER", (), None) + """Test that CAST expression renders the target type (:: syntax).""" + expr = CastExpression(dialect, RawSQLExpression(dialect, "column"), "INTEGER") + sql, params = expr.to_sql() assert "INTEGER" in sql def test_exclude_constraint_valid_using_methods(dialect): """Test valid index access methods are accepted.""" constraint = TableConstraint( + dialect, constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", dialect_options={ @@ -111,12 +121,13 @@ def test_exclude_constraint_valid_operators(dialect): ("box", "~="), ] - for expr, op in valid_ops: + for elem, op in valid_ops: constraint = TableConstraint( + dialect, constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", dialect_options={ - "exclude_elements": [(expr, op)], + "exclude_elements": [(elem, op)], }, ) @@ -127,6 +138,7 @@ def test_exclude_constraint_valid_operators(dialect): def test_exclude_constraint_rejects_invalid_using(dialect): """Test that invalid index access method is rejected.""" constraint = TableConstraint( + dialect, constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", dialect_options={ @@ -142,6 +154,7 @@ def test_exclude_constraint_rejects_invalid_using(dialect): def test_exclude_constraint_rejects_invalid_operator(dialect): """Test that invalid exclude operator is rejected.""" constraint = TableConstraint( + dialect, constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", dialect_options={ @@ -156,6 +169,7 @@ def test_exclude_constraint_rejects_invalid_operator(dialect): def test_exclude_constraint_sql_injection_prevention(dialect): """Test that SQL injection attempts are blocked.""" constraint = TableConstraint( + dialect, constraint_type=TableConstraintType.EXCLUDE, name="test_exclude", dialect_options={ @@ -494,4 +508,73 @@ def test_format_identifier_naive_vs_proper_malicious(dialect): def test_format_identifier_empty_string(dialect): """Empty identifier produces empty double quotes.""" - assert dialect.format_identifier("") == '""' \ No newline at end of file + assert dialect.format_identifier("") == '""' + + +# ── format_binary_operator % escaping ───────────────────────────────── + + +def test_format_binary_operator_percent_escaped(): + """format_binary_operator escapes % to %% for psycopg compatibility.""" + from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + from rhosocial.activerecord.backend.expression.core import Literal + d = PostgresDialect((16, 0, 0)) + + def binop(op, left_sql, right_sql, right_params=()): + left = RawSQLExpression(d, left_sql) + if right_params: + right = Literal(d, right_params[0]) + else: + right = RawSQLExpression(d, right_sql) + return BinaryExpression(d, op, left, right).to_sql() + + # % operator → %% (psycopg requires %% for literal %) + sql, params = binop("%", "a", "b") + assert sql == "a %% b", f"Expected escaped %%, got: {sql}" + assert params == () + + # %% is pre-escaped → should NOT be double-escaped to %%%% + # (callers must pass raw operator, not pre-escaped) + sql, params = binop("%%", "a", "b") + assert sql == "a %%%% b", f"raw %% should be escaped to %%%%, got: {sql}" + + # Operator without % → unchanged + sql, params = binop("=", "a", "b") + assert sql == "a = b" + assert params == () + + # ? operator (hstore/jsonb) → preserved as-is, not treated as placeholder + sql, params = binop("?", "data", "%s", ("key",)) + assert sql == "data ? %s", f"? operator preserved: {sql}" + assert params == ("key",) + + # ?| and ?& operators → preserved + sql, params = binop("?|", "data", "%s", ("key",)) + assert sql == "data ?| %s", f"?| operator preserved: {sql}" + + # %# (pg_trgm) → %# (unchanged, no % to escape) + sql, params = binop("%#", "a", "b") + assert sql == "a %%# b", f"%# operator: {sql}" + + +def test_hstore_operator_not_double_escaped(): + """hstore_to_array_operator uses raw %, not pre-escaped %%.""" + hstore_path = "src/rhosocial/activerecord/backend/impl/postgres/functions/hstore.py" + # Try to find the file relative to the repo root + import os + for candidate in [ + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..", "..", hstore_path), + os.path.join(os.getcwd(), hstore_path), + ]: + candidate = os.path.normpath(candidate) + if os.path.exists(candidate): + hstore_path = candidate + break + with open(hstore_path) as f: + src = f.read() + assert ( + "BinaryExpression(" in src and '"%",' in src + ), "hstore operator must pass raw % (not pre-escaped %%)" + import re + for m in re.finditer(r'BinaryExpression\([^)]*?["\']%%["\']', src): + pytest.fail(f"Found pre-escaped %% operator: {m.group()[:80]}") \ No newline at end of file diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py index 839cb1c0..f35f2f52 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_protocol_conformance.py @@ -114,6 +114,14 @@ def get_postgres_protocols(): dialect_protocols.IntrospectionSupport, dialect_protocols.TransactionControlSupport, dialect_protocols.SQLFunctionSupport, + # Generic protocols Postgres also satisfies (previously omitted). + dialect_protocols.AlterTableModifierSupport, + dialect_protocols.DDLTypeSupport, + dialect_protocols.GraphTableSupport, + dialect_protocols.ILIKESupport, + dialect_protocols.TriggerSupport, + dialect_protocols.AutoIncrementSupport, + dialect_protocols.GeneratedColumnSupport, ] postgres_mro = postgres_dialect.PostgresDialect.__mro__ @@ -148,6 +156,62 @@ def test_implements_protocol(self, dialect, protocol): ) +# Generic protocols PostgresDialect intentionally does NOT implement. +# +# Listing them makes the omission a deliberate, tested contract: if Postgres +# ever satisfies one by accident, the negative test fails and forces a +# conscious decision (move to POSTGRES_PROTOCOLS or revert). +POSTGRES_NOT_IMPLEMENTED = [ + # --- Intentional non-support --- + # Postgres exposes routine DDL through its own PostgresRoutineSupport + # protocol rather than the generic SQL/PSM FunctionSupport. + dialect_protocols.FunctionSupport, +] + + +def get_all_generic_protocols() -> dict: + """Discover every generic dialect protocol defined in protocols.py.""" + from typing import Protocol + + discovered = {} + for name, obj in inspect.getmembers(dialect_protocols, inspect.isclass): + if Protocol in getattr(obj, "__mro__", []) and name.endswith("Support"): + discovered[name] = obj + return discovered + + +class TestPostgresDialectNegativeProtocolConformance: + """Assert PostgresDialect does not implement intentionally-unsupported protocols.""" + + @pytest.fixture + def dialect(self): + return postgres_dialect.PostgresDialect() + + @pytest.mark.parametrize("protocol", POSTGRES_NOT_IMPLEMENTED) + def test_does_not_implement_protocol(self, dialect, protocol): + """PostgresDialect must NOT implement any protocol in POSTGRES_NOT_IMPLEMENTED.""" + assert not isinstance(dialect, protocol), ( + f"PostgresDialect unexpectedly implements {protocol.__name__}. " + f"If intentional, move it from POSTGRES_NOT_IMPLEMENTED to " + f"POSTGRES_PROTOCOLS (and implement the behaviour fully)." + ) + + def test_positive_and_negative_lists_partition_all_protocols(self): + """Every generic protocol must be classified for Postgres.""" + all_protos = set(get_all_generic_protocols()) + positive = {p.__name__ for p in POSTGRES_PROTOCOLS if p.__module__ == dialect_protocols.__name__} + negative = {p.__name__ for p in POSTGRES_NOT_IMPLEMENTED} + + overlap = positive & negative + assert not overlap, f"Protocols in BOTH lists: {sorted(overlap)}" + + unclassified = all_protos - positive - negative + assert not unclassified, ( + f"Generic protocols not classified for Postgres: {sorted(unclassified)}. " + f"Add each to POSTGRES_PROTOCOLS or POSTGRES_NOT_IMPLEMENTED." + ) + + class TestProtocolNonOverlap: """Assert protocols do not have overlapping method names.""" @@ -253,6 +317,14 @@ def test_no_interface_overlap_between_protocols(self): # PostgresFeaturesSupport shares some stored procedure methods ('PostgresFeaturesSupport', 'PostgresStoredProcedureSupport'), ('PostgresStoredProcedureSupport', 'PostgresFeaturesSupport'), + # Generic capabilities restated by PG-specific protocols + ('AlterTableModifierSupport', 'PostgresConstraintSupport'), + ('PostgresConstraintSupport', 'AlterTableModifierSupport'), + ('ILIKESupport', 'PostgresILIKESupport'), + ('PostgresILIKESupport', 'ILIKESupport'), + # Generic generated-column capability also declared by PG features protocol + ('GeneratedColumnSupport', 'PostgresFeaturesSupport'), + ('PostgresFeaturesSupport', 'GeneratedColumnSupport'), } violations = [] @@ -477,7 +549,7 @@ class TestProtocolMethodSignatureConformance: # ArraySupport: Mixin uses expr-based signatures ('ArraySupport', 'format_array_expression'), # AdvancedGroupingSupport: Mixin uses expr instead of named params - ('AdvancedGroupingSupport', 'format_grouping_expression'), + ('AdvancedGroupingSupport', 'format_grouping_clause'), # GraphSupport: Mixin uses expr instead of clause ('GraphSupport', 'format_match_clause'), # OrderedSetAggregationSupport: Mixin uses expr instead of aggregation diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py index c7c286f9..82bbec8a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_data_type_formatting.py @@ -86,6 +86,8 @@ PostgresTsTzRangeType, PostgresUUIDType, PostgresVarBitType, + PostgresHalfvecType, + PostgresSparsevecType, PostgresVectorType, PostgresXID8Type, PostgresXIDType, @@ -152,6 +154,8 @@ def dialect(): (PostgresGeometryType(), "GEOMETRY"), (PostgresGeographyType(), "GEOGRAPHY"), (PostgresVectorType(dim=384), "VECTOR(384)"), + (PostgresHalfvecType(dim=128), "HALFVEC(128)"), + (PostgresSparsevecType(dim=16), "SPARSEVEC(16)"), (PostgresCitextType(), "CITEXT"), (PostgresCubeType(), "CUBE"), (PostgresLtreeType(), "LTREE"), @@ -217,31 +221,31 @@ def test_format_core_types(dialect, data_type, expected): def test_format_array_type(dialect): - sql, _ = dialect.format_data_type(ArrayType(IntegerType(), dimensions=1)) + sql, _ = dialect.format_data_type(ArrayType(element_type=IntegerType(), dimensions=1)) assert sql == "INTEGER[]" - sql, _ = dialect.format_data_type(ArrayType(VarCharType(10), dimensions=3)) + sql, _ = dialect.format_data_type(ArrayType(element_type=VarCharType(length=10), dimensions=3)) assert sql == "VARCHAR(10)[][][]" def test_format_array_type_multidimensional(dialect): - sql, _ = dialect.format_data_type(ArrayType(IntegerType(), dimensions=2)) + sql, _ = dialect.format_data_type(ArrayType(element_type=IntegerType(), dimensions=2)) assert sql == "INTEGER[][]" def test_format_array_type_with_postgres_element(dialect): - sql, _ = dialect.format_data_type(ArrayType(PostgresUUIDType(), dimensions=1)) + sql, _ = dialect.format_data_type(ArrayType(element_type=PostgresUUIDType(), dimensions=1)) assert sql == "UUID[]" -def test_format_array_type_unsupported_element_raises(dialect): - with pytest.raises(TypeError, match="is not supported"): - dialect.format_data_type(ArrayType(CustomType("X"), dimensions=1)) +def test_format_array_type_with_custom_element(dialect): + sql, _ = dialect.format_data_type(ArrayType(element_type=CustomType(raw="X"), dimensions=1)) + assert sql == "X[]" -def test_format_unregistered_type_raises(dialect): - with pytest.raises(TypeError, match="does not support"): - dialect.format_data_type(CustomType("X")) +def test_format_custom_type_passthrough(dialect): + sql, _ = dialect.format_data_type(CustomType(raw="X")) + assert sql == "X" # --------------------------------------------------------------------------- @@ -346,6 +350,10 @@ def test_format_unregistered_type_raises(dialect): ("GEOMETRY", PostgresGeometryType, {}), ("VECTOR", PostgresVectorType, {"dim": 0}), ("VECTOR(768)", PostgresVectorType, {"dim": 768}), + ("HALFVEC", PostgresHalfvecType, {"dim": 0}), + ("HALFVEC(128)", PostgresHalfvecType, {"dim": 128}), + ("SPARSEVEC", PostgresSparsevecType, {"dim": 0}), + ("SPARSEVEC(16)", PostgresSparsevecType, {"dim": 16}), ] @@ -412,6 +420,54 @@ def test_parse_type_case_insensitive(dialect): assert result.length == 25 +def test_parse_vector_requires_extension(dialect): + """parse_type must reject pgvector types when the extension is not installed.""" + from rhosocial.activerecord.backend.impl.postgres.protocols.base import ( + PostgresExtensionInfo, + ) + dialect._extensions = { + "vector": PostgresExtensionInfo(name="vector", installed=False, available=True) + } + with pytest.raises(RuntimeError, match="vector.*extension"): + dialect.parse_type("VECTOR(3)") + with pytest.raises(RuntimeError, match="vector.*extension"): + dialect.parse_type("HALFVEC(3)") + with pytest.raises(RuntimeError, match="vector.*extension"): + dialect.parse_type("SPARSEVEC(3)") + + +def test_parse_vector_allowed_when_extension_installed(dialect): + from rhosocial.activerecord.backend.impl.postgres.protocols.base import ( + PostgresExtensionInfo, + ) + dialect._extensions = { + "vector": PostgresExtensionInfo(name="vector", installed=True) + } + assert type(dialect.parse_type("VECTOR(3)")) is PostgresVectorType + assert type(dialect.parse_type("HALFVEC(3)")) is PostgresHalfvecType + assert type(dialect.parse_type("SPARSEVEC(3)")) is PostgresSparsevecType + + +def test_vector_types_equality_and_hash(): + assert PostgresVectorType(3) == PostgresVectorType(3) + assert PostgresVectorType(3) != PostgresVectorType(4) + assert PostgresVectorType(3) != PostgresHalfvecType(3) + assert PostgresVectorType(3) != object() + assert hash(PostgresVectorType(3)) == hash(PostgresVectorType(3)) + + assert PostgresHalfvecType(3) == PostgresHalfvecType(3) + assert PostgresHalfvecType(3) != PostgresHalfvecType(4) + assert PostgresHalfvecType(3) != PostgresSparsevecType(3) + assert PostgresHalfvecType(3) != object() + assert hash(PostgresHalfvecType(3)) == hash(PostgresHalfvecType(3)) + + assert PostgresSparsevecType(5) == PostgresSparsevecType(5) + assert PostgresSparsevecType(5) != PostgresSparsevecType(6) + assert PostgresSparsevecType(5) != PostgresVectorType(5) + assert PostgresSparsevecType(5) != object() + assert hash(PostgresSparsevecType(5)) == hash(PostgresSparsevecType(5)) + + # --------------------------------------------------------------------------- # supports_data_types # --------------------------------------------------------------------------- @@ -419,9 +475,9 @@ def test_parse_type_case_insensitive(dialect): def test_supports_data_types_registers_all_postgres_types(dialect): supported = dialect.supports_data_types() - assert isinstance(supported, list) + assert isinstance(supported, dict) assert len(supported) > 0 - classes = {cls for cls, _ in supported} + classes = set(supported.values()) for expected in ( PostgresSerialType, PostgresByteaType, diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py index e8a96295..16dd3e39 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_expression_types.py @@ -54,7 +54,7 @@ def test_none_vs_value(self): assert PostgresBitType(None) != PostgresBitType(8) def test_hash(self): - assert hash(PostgresBitType(8)) == hash((type(PostgresBitType(8)), 8)) + assert hash(PostgresBitType(8)) == hash((type(PostgresBitType(8)), PostgresBitType(8)._type_params())) class TestPostgresVarBitTypeEquality: @@ -69,7 +69,7 @@ def test_not_equal_types(self): assert PostgresVarBitType(16) != object() def test_hash(self): - assert hash(PostgresVarBitType(16)) == hash((type(PostgresVarBitType(16)), 16)) + assert hash(PostgresVarBitType(16)) == hash((type(PostgresVarBitType(16)), PostgresVarBitType(16)._type_params())) class TestPostgresVectorTypeEquality: @@ -84,21 +84,21 @@ def test_not_equal_types(self): assert PostgresVectorType(384) != "vector" def test_hash(self): - assert hash(PostgresVectorType(384)) == hash((type(PostgresVectorType(384)), 384)) + assert hash(PostgresVectorType(384)) == hash((type(PostgresVectorType(384)), PostgresVectorType(384)._type_params())) class TestPostgresArrayType: def test_is_equivalent_matching_element(self): - arr1 = PostgresArrayType(IntegerType(), dimensions=1) - arr2 = PostgresArrayType(IntegerType(), dimensions=3) + arr1 = PostgresArrayType(element_type=IntegerType(), dimensions=1) + arr2 = PostgresArrayType(element_type=IntegerType(), dimensions=3) assert arr1.is_equivalent(arr2) def test_is_equivalent_non_array(self): - arr = PostgresArrayType(IntegerType(), dimensions=1) + arr = PostgresArrayType(element_type=IntegerType(), dimensions=1) assert arr.is_equivalent(IntegerType()) is False assert arr.is_equivalent(None) is False def test_is_equivalent_different_element(self): - arr1 = PostgresArrayType(IntegerType(), dimensions=1) - arr2 = PostgresArrayType(VarCharType(), dimensions=1) + arr1 = PostgresArrayType(element_type=IntegerType(), dimensions=1) + arr2 = PostgresArrayType(element_type=VarCharType(), dimensions=1) assert arr1.is_equivalent(arr2) is False diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py new file mode 100644 index 00000000..4ecf2bd8 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py @@ -0,0 +1,448 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/types/test_postgres_type_protocol.py +"""Tests for the formalised core DataType protocol conformance. + +Verifies: +- format family == supports family (1:1 correspondence) +- supports_data_types() includes postgres_* + core entries +- suggested_data_types(): values are DataType classes; keys disjoint from supported +- dialect_options forwarding and equality +""" + +import pytest + +from rhosocial.activerecord.backend.expression.types import ( + BigIntType, + BlobType, + BooleanType, + CharType, + DataType, + DecimalType, + DoubleType, + FloatType, + IntegerType, + IntType, + JsonBType, + JsonType, + RealType, + SmallIntType, + TextType, + TimeType, + TimestampType, + TinyIntType, + VarCharType, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.types import ( + PostgresArrayType, + PostgresBigSerialType, + PostgresBitType, + PostgresBoxType, + PostgresByteaType, + PostgresCIDType, + PostgresCharacterVaryingType, + PostgresCidrType, + PostgresCircleType, + PostgresCitextType, + PostgresCubeType, + PostgresDateMultirangeType, + PostgresDateRangeType, + PostgresGeographyType, + PostgresGeometryType, + PostgresHalfvecType, + PostgresHstoreType, + PostgresInetType, + PostgresInt4MultirangeType, + PostgresInt4RangeType, + PostgresInt8MultirangeType, + PostgresInt8RangeType, + PostgresJsonPathType, + PostgresLineSegmentType, + PostgresLineType, + PostgresLtreeType, + PostgresMacAddr8Type, + PostgresMacAddrType, + PostgresMoneyType, + PostgresNumMultirangeType, + PostgresNumRangeType, + PostgresOIDType, + PostgresPathType, + PostgresPgLSNType, + PostgresPointType, + PostgresPolygonType, + PostgresRasterType, + PostgresRegClassType, + PostgresRegTypeType, + PostgresSerialType, + PostgresSmallSerialType, + PostgresSparsevecType, + PostgresTIDType, + PostgresTSQueryType, + PostgresTSVectorType, + PostgresTsMultirangeType, + PostgresTsRangeType, + PostgresTsTzMultirangeType, + PostgresTsTzRangeType, + PostgresUUIDType, + PostgresVarBitType, + PostgresVectorType, + PostgresXID8Type, + PostgresXIDType, + PostgresXMLType, +) + + +@pytest.fixture +def dialect(): + return PostgresDialect(version=(16, 0, 0)) + + +# --------------------------------------------------------------------------- +# W2: format_data_type_postgres_enum / supports_data_type_postgres_enum pair +# --------------------------------------------------------------------------- + + +def test_format_data_type_postgres_enum_exists(dialect): + """format_data_type_postgres_enum method exists on the dialect.""" + assert hasattr(dialect, "format_data_type_postgres_enum") + assert callable(getattr(dialect, "format_data_type_postgres_enum")) + + +def test_supports_data_type_postgres_enum_exists(dialect): + """supports_data_type_postgres_enum method exists on the dialect.""" + assert hasattr(dialect, "supports_data_type_postgres_enum") + assert callable(getattr(dialect, "supports_data_type_postgres_enum")) + assert dialect.supports_data_type_postgres_enum() is True + + +def test_postgres_enum_expression_renders_via_format_enum_type_expression(dialect): + """PostgresEnumType (BaseExpression) renders via format_enum_type_expression.""" + from rhosocial.activerecord.backend.impl.postgres.types.enum import PostgresEnumType + + enum_ref = PostgresEnumType( + dialect=dialect, + name="video_status", + values=["pending", "processing", "ready"], + ) + sql, params = dialect.format_enum_type_expression(enum_ref) + assert sql == "video_status" + assert params == () + + +def test_postgres_enum_expression_with_schema_renders_via_dialect(dialect): + from rhosocial.activerecord.backend.impl.postgres.types.enum import PostgresEnumType + + enum_ref = PostgresEnumType( + dialect=dialect, + name="video_status", + values=["pending", "processing"], + schema="app", + ) + sql, params = dialect.format_enum_type_expression(enum_ref) + assert sql == "app.video_status" + assert params == () + + +# --------------------------------------------------------------------------- +# W3: format family == supports family (1:1) +# --------------------------------------------------------------------------- + +# Collect all format_data_type_* and supports_data_type_* names from the dialect +# and verify they match exactly. + + +def _get_method_suffixes(dialect, prefix): + """Extract method name suffixes matching ``prefix_``.""" + import re + pattern = re.compile(rf"^{prefix}([a-z][a-z0-9_]*)$") + suffixes = set() + for member_name in dir(type(dialect)): + match = pattern.match(member_name) + if match: + suffixes.add(match.group(1)) + return suffixes + + +def test_format_supports_family_1to1(dialect): + """Every format_data_type_ must have supports_data_type_ and vice versa.""" + format_names = _get_method_suffixes(dialect, "format_data_type_") + supports_names = _get_method_suffixes(dialect, "supports_data_type_") + + # Remove non-dispatch names (these are helpers, not type names) + format_names.discard("custom") + supports_names.discard("custom") + # supports_data_types and format_data_type are the entry points + format_names.discard("") + supports_names.discard("") + + missing_supports = format_names - supports_names + missing_format = supports_names - format_names + + assert not missing_supports, ( + f"format_data_type_ methods without corresponding supports_data_type_: " + f"{sorted(missing_supports)}" + ) + assert not missing_format, ( + f"supports_data_type_ methods without corresponding format_data_type_: " + f"{sorted(missing_format)}" + ) + + +# --------------------------------------------------------------------------- +# W3: supports_data_types() includes postgres_* + core entries +# --------------------------------------------------------------------------- + + +def test_supports_data_types_includes_postgres_types(dialect): + supported = dialect.supports_data_types() + assert isinstance(supported, dict) + assert len(supported) > 0 + + # Verify a representative sample of postgres-specific types + postgres_expected = { + "postgres_bytea": PostgresByteaType, + "postgres_serial": PostgresSerialType, + "postgres_uuid": PostgresUUIDType, + "postgres_vector": PostgresVectorType, + "postgres_tsrange": PostgresTsRangeType, + "postgres_inet": PostgresInetType, + "postgres_point": PostgresPointType, + "postgres_jsonpath": PostgresJsonPathType, + } + for name, cls in postgres_expected.items(): + assert name in supported, f"Missing postgres type: {name}" + assert supported[name] is cls, ( + f"Type {name}: expected {cls.__name__}, got {supported[name].__name__}" + ) + + +def test_supports_data_types_includes_core_types(dialect): + supported = dialect.supports_data_types() + + core_expected = { + "integer": IntegerType, + "bigint": BigIntType, + "smallint": SmallIntType, + "decimal": DecimalType, + "varchar": VarCharType, + "text": TextType, + "boolean": BooleanType, + "blob": BlobType, + "date": None, # DateType + "timestamp": None, # TimestampType + "json": JsonType, + "jsonb": JsonBType, + } + for name in core_expected: + assert name in supported, f"Missing core type: {name}" + + +def test_supports_data_types_values_are_data_type_classes(dialect): + supported = dialect.supports_data_types() + for name, cls in supported.items(): + assert isinstance(cls, type), ( + f"supports_data_types()[{name!r}] is not a class: {cls!r}" + ) + assert issubclass(cls, DataType), ( + f"supports_data_types()[{name!r}] = {cls.__name__} is not a DataType subclass" + ) + + +# --------------------------------------------------------------------------- +# W4: suggested_data_types() +# --------------------------------------------------------------------------- + + +def test_suggested_data_types_returns_dict(dialect): + result = dialect.suggested_data_types() + assert isinstance(result, dict) + + +def test_suggested_data_types_values_are_classes(dialect): + result = dialect.suggested_data_types() + for name, cls in result.items(): + assert isinstance(cls, type), ( + f"suggested_data_types()[{name!r}] is not a class: {cls!r}" + ) + assert issubclass(cls, DataType), ( + f"suggested_data_types()[{name!r}] = {cls.__name__} is not a DataType subclass" + ) + + +def test_suggested_data_types_keys_disjoint_from_supported(dialect): + supported = set(dialect.supports_data_types().keys()) + suggested = set(dialect.suggested_data_types().keys()) + overlap = supported & suggested + assert not overlap, ( + f"suggested_data_types() keys overlap with supports_data_types(): {overlap}" + ) + + +# --------------------------------------------------------------------------- +# W1: dialect_options forwarding and equality +# --------------------------------------------------------------------------- + + +def test_bit_type_dialect_options_forwarded(): + t1 = PostgresBitType(n=8, dialect_options={"flag": True}) + t2 = PostgresBitType(n=8, dialect_options={"flag": True}) + t3 = PostgresBitType(n=8, dialect_options={"flag": False}) + t4 = PostgresBitType(n=8) + + # Same options → equal + assert t1 == t2 + assert hash(t1) == hash(t2) + + # Different options → not equal + assert t1 != t3 + assert t1 != t4 + + +def test_vector_type_dialect_options_forwarded(): + t1 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) + t2 = PostgresVectorType(dim=384, dialect_options={"metric": "cosine"}) + t3 = PostgresVectorType(dim=384, dialect_options={"metric": "l2"}) + + assert t1 == t2 + assert hash(t1) == hash(t2) + assert t1 != t3 + + +def test_halfvec_type_dialect_options_forwarded(): + t1 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) + t2 = PostgresHalfvecType(dim=128, dialect_options={"quantize": True}) + t3 = PostgresHalfvecType(dim=128) + + assert t1 == t2 + assert hash(t1) == hash(t2) + assert t1 != t3 + + +def test_sparsevec_type_dialect_options_forwarded(): + t1 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) + t2 = PostgresSparsevecType(dim=16, dialect_options={"sparse": True}) + t3 = PostgresSparsevecType(dim=16) + + assert t1 == t2 + assert hash(t1) == hash(t2) + assert t1 != t3 + + +def test_varbit_type_dialect_options_forwarded(): + t1 = PostgresVarBitType(n=16, dialect_options={"strict": True}) + t2 = PostgresVarBitType(n=16, dialect_options={"strict": True}) + t3 = PostgresVarBitType(n=16) + + assert t1 == t2 + assert hash(t1) == hash(t2) + assert t1 != t3 + + +# --------------------------------------------------------------------------- +# W1: _type_params() based equality replaces hand-written __eq__/__hash__ +# --------------------------------------------------------------------------- + + +def test_bit_type_type_params(): + t1 = PostgresBitType(n=8) + t2 = PostgresBitType(n=8) + t3 = PostgresBitType(n=16) + t4 = PostgresBitType() + + assert t1 == t2 + assert t1 != t3 + assert t1 != t4 + assert hash(t1) == hash(t2) + assert hash(t1) != hash(t3) + + +def test_vector_type_type_params(): + t1 = PostgresVectorType(dim=384) + t2 = PostgresVectorType(dim=384) + t3 = PostgresVectorType(dim=768) + + assert t1 == t2 + assert t1 != t3 + assert hash(t1) == hash(t2) + assert hash(t1) != hash(t3) + + +def test_vector_types_cross_class_inequality(): + """Different vector types with same dim are not equal.""" + assert PostgresVectorType(3) != PostgresHalfvecType(3) + assert PostgresVectorType(3) != PostgresSparsevecType(3) + assert PostgresHalfvecType(3) != PostgresSparsevecType(3) + + +# --------------------------------------------------------------------------- +# W5: Precision validation +# --------------------------------------------------------------------------- + + +def test_decimal_precision_validation(dialect): + """PostgreSQL allows DECIMAL precision up to 1000.""" + # Valid + sql, _ = dialect.format_data_type(DecimalType(precision=1000, scale=0)) + assert sql == "DECIMAL(1000,0)" + + # Invalid: precision 0 + with pytest.raises(ValueError, match="precision must be between 1 and 1000"): + dialect.format_data_type(DecimalType(precision=0)) + + # Invalid: precision > 1000 + with pytest.raises(ValueError, match="precision must be between 1 and 1000"): + dialect.format_data_type(DecimalType(precision=1001)) + + +def test_float_precision_validation(dialect): + """PostgreSQL allows FLOAT precision up to 53.""" + # Valid + sql, _ = dialect.format_data_type(FloatType(precision=53)) + assert sql == "FLOAT(53)" + + # Invalid: precision 0 + with pytest.raises(ValueError, match="precision must be between 1 and 53"): + dialect.format_data_type(FloatType(precision=0)) + + # Invalid: precision > 53 + with pytest.raises(ValueError, match="precision must be between 1 and 53"): + dialect.format_data_type(FloatType(precision=54)) + + +def test_timestamp_precision_validation(dialect): + """PostgreSQL allows TIMESTAMP precision 0-6.""" + # Valid + sql, _ = dialect.format_data_type(TimestampType(precision=6)) + assert sql == "TIMESTAMP(6)" + + # Invalid: precision -1 + with pytest.raises(ValueError, match="precision must be between 0 and 6"): + dialect.format_data_type(TimestampType(precision=-1)) + + # Invalid: precision 7 + with pytest.raises(ValueError, match="precision must be between 0 and 6"): + dialect.format_data_type(TimestampType(precision=7)) + + +def test_time_precision_validation(dialect): + """PostgreSQL allows TIME precision 0-6.""" + # Valid + sql, _ = dialect.format_data_type(TimeType(precision=6)) + assert sql == "TIME(6)" + + # Invalid: precision 7 + with pytest.raises(ValueError, match="precision must be between 0 and 6"): + dialect.format_data_type(TimeType(precision=7)) + + +# --------------------------------------------------------------------------- +# PostgresEnumType not a DataType — protocol boundary +# --------------------------------------------------------------------------- + + +def test_postgres_enum_type_is_not_data_type(): + """PostgresEnumType extends BaseExpression, not DataType.""" + from rhosocial.activerecord.backend.expression.bases import BaseExpression + from rhosocial.activerecord.backend.impl.postgres.types.enum import PostgresEnumType + + assert not issubclass(PostgresEnumType, DataType) + assert issubclass(PostgresEnumType, BaseExpression) diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py index 540631b5..1c68d47b 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field.py @@ -137,7 +137,7 @@ def _insert(self, Model, name, price, quantity): def test_derived_dict_with_lambda(self, product_class): self._insert(product_class, "D1", 100.0, 4) results = product_class.find_all( - derived={"my_discount": lambda d: Column(d, "price") * Literal(d, 0.8)} + derived={"my_discount": lambda d: Column(dialect, d, "price") * Literal(d, 0.8)} ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -185,7 +185,7 @@ def _insert(self, Model, name, price, quantity): def test_extra_derived_basic(self, product_class): self._insert(product_class, "F", 100.0, 10) results = product_class.find_all( - extra_derived={"triple_price": lambda d: Column(d, "price") * Literal(d, 3)} + extra_derived={"triple_price": lambda d: Column(dialect, d, "price") * Literal(d, 3)} ) assert len(results) == 1 assert results[0].__dict__["triple_price"] == pytest.approx(300.0) @@ -194,14 +194,14 @@ def test_extra_derived_conflict_raises(self, product_class): self._insert(product_class, "G", 50.0, 2) with pytest.raises(ValueError, match="conflicts with a declared derived field"): product_class.find_all( - extra_derived={"discounted_price": lambda d: Column(d, "price") * Literal(d, 0.5)} + extra_derived={"discounted_price": lambda d: Column(dialect, d, "price") * Literal(d, 0.5)} ) def test_derived_and_extra_derived_together(self, product_class): self._insert(product_class, "H", 40.0, 5) results = product_class.find_all( derived=True, - extra_derived={"double_qty": lambda d: Column(d, "quantity") * Literal(d, 2)} + extra_derived={"double_qty": lambda d: Column(dialect, d, "quantity") * Literal(d, 2)} ) assert results[0].discounted_price == pytest.approx(36.0) assert results[0].total_value == pytest.approx(200.0) @@ -315,7 +315,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(d, "price") * Literal(d, 0.9), + lambda d: Column(dialect, d, "price") * Literal(d, 0.9), ), UseColumn("disc")]] def test_use_column_no_conflict_different_names(self): @@ -325,7 +325,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(d, "price") * Literal(d, 0.9), + lambda d: Column(dialect, d, "price") * Literal(d, 0.9), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py index 8720acd9..77485609 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/basic/fields/test_derived_field_async.py @@ -137,7 +137,7 @@ async def _insert(self, Model, name, price, quantity): async def test_derived_dict_with_lambda(self, async_product_class): await self._insert(async_product_class, "D1", 100.0, 4) results = await async_product_class.find_all( - derived={"my_discount": lambda d: Column(d, "price") * Literal(d, 0.8)} + derived={"my_discount": lambda d: Column(dialect, d, "price") * Literal(d, 0.8)} ) assert results[0].__dict__["my_discount"] == pytest.approx(80.0) @@ -185,7 +185,7 @@ async def _insert(self, Model, name, price, quantity): async def test_extra_derived_basic(self, async_product_class): await self._insert(async_product_class, "F", 100.0, 10) results = await async_product_class.find_all( - extra_derived={"triple_price": lambda d: Column(d, "price") * Literal(d, 3)} + extra_derived={"triple_price": lambda d: Column(dialect, d, "price") * Literal(d, 3)} ) assert len(results) == 1 assert results[0].__dict__["triple_price"] == pytest.approx(300.0) @@ -194,14 +194,14 @@ async def test_extra_derived_conflict_raises(self, async_product_class): await self._insert(async_product_class, "G", 50.0, 2) with pytest.raises(ValueError, match="conflicts with a declared derived field"): await async_product_class.find_all( - extra_derived={"discounted_price": lambda d: Column(d, "price") * Literal(d, 0.5)} + extra_derived={"discounted_price": lambda d: Column(dialect, d, "price") * Literal(d, 0.5)} ) async def test_derived_and_extra_derived_together(self, async_product_class): await self._insert(async_product_class, "H", 40.0, 5) results = await async_product_class.find_all( derived=True, - extra_derived={"double_qty": lambda d: Column(d, "quantity") * Literal(d, 2)} + extra_derived={"double_qty": lambda d: Column(dialect, d, "quantity") * Literal(d, 2)} ) assert results[0].discounted_price == pytest.approx(36.0) assert results[0].total_value == pytest.approx(200.0) @@ -315,7 +315,7 @@ class ConflictModel(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("disc")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(d, "price") * Literal(d, 0.9), + lambda d: Column(dialect, d, "price") * Literal(d, 0.9), ), UseColumn("disc")]] async def test_use_column_no_conflict_different_names(self): @@ -325,7 +325,7 @@ class NoConflict(ActiveRecord): id: Optional[int] = None discount_rate: Annotated[float, UseColumn("rate")] discounted: ClassVar[Annotated[float, DerivedField( - lambda d: Column(d, "price") * Literal(d, 0.9), + lambda d: Column(dialect, d, "price") * Literal(d, 0.9), ), UseColumn("disc")]] assert NoConflict.__derived_fields__["discounted"].column_name == "disc" diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py index 0d4b8bd2..7935cb2a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/conftest.py @@ -91,7 +91,14 @@ def get_scenario_config(name: str) -> Dict[str, Any]: name = next(iter(SCENARIO_MAP)) else: raise ValueError("No scenarios registered or enabled") - return SCENARIO_MAP[name] + config_dict = SCENARIO_MAP[name].copy() + # Apply the pooled database name so parallel workers never share a schema. + from providers.pooling import resolve_database_name + + pooled_db = resolve_database_name(name) + if pooled_db: + config_dict["database"] = pooled_db + return config_dict def get_scenario_names(): diff --git a/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py index a3b41ee4..af16ce11 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/connection/test_connection_pool_concurrent.py @@ -39,47 +39,42 @@ def test_concurrent_connections_are_distinct(self, postgres_pool: BackendPool): simultaneously, each thread gets a different connection instance. """ num_threads = 5 - connection_ids: List[int] = [] - connection_ids_lock = threading.Lock() + held_ids: set = set() + held_lock = threading.Lock() + double_checkouts: List[int] = [] barrier = threading.Barrier(num_threads) errors: List[Exception] = [] errors_lock = threading.Lock() def acquire_and_record(thread_id: int): try: - # Synchronize all threads to start at the same time barrier.wait() - # Acquire connection backend = postgres_pool.acquire() try: conn_id = id(backend) - with connection_ids_lock: - connection_ids.append((thread_id, conn_id)) + with held_lock: + if conn_id in held_ids: + double_checkouts.append(conn_id) + held_ids.add(conn_id) - # Hold connection briefly to ensure overlap time.sleep(0.1) finally: + with held_lock: + held_ids.discard(id(backend)) postgres_pool.release(backend) except Exception as e: with errors_lock: errors.append(e) - # Run concurrent acquires with ThreadPoolExecutor(max_workers=num_threads) as executor: futures = [executor.submit(acquire_and_record, i) for i in range(num_threads)] for future in as_completed(futures): future.result() - # Verify no errors assert len(errors) == 0, f"Errors occurred: {errors}" - - # Verify all connections are distinct - assert len(connection_ids) == num_threads - unique_ids = set(conn_id for _, conn_id in connection_ids) - assert len(unique_ids) == num_threads, ( - f"Expected {num_threads} distinct connections, " - f"but got {len(unique_ids)}: {connection_ids}" + assert not double_checkouts, ( + f"Pool handed the same connection to two threads concurrently: {double_checkouts}" ) def test_concurrent_operations_no_deadlock(self, postgres_pool_with_tables: BackendPool): @@ -359,33 +354,32 @@ class TestAsyncBackendPoolConcurrent: async def test_concurrent_connections_are_distinct(self, async_postgres_pool: AsyncBackendPool): """Verify that concurrently acquired async connections are distinct.""" num_concurrent = 5 - connection_ids: List[int] = [] - connection_ids_lock = asyncio.Lock() + held_ids: set = set() + held_lock = asyncio.Lock() + double_checkouts: List[int] = [] start_event = asyncio.Event() async def acquire_and_record(task_id: int): - # Wait for all tasks to be ready await start_event.wait() async with async_postgres_pool.connection() as backend: conn_id = id(backend) - async with connection_ids_lock: - connection_ids.append((task_id, conn_id)) - - # Hold connection briefly - await asyncio.sleep(0.1) + async with held_lock: + if conn_id in held_ids: + double_checkouts.append(conn_id) + held_ids.add(conn_id) + try: + await asyncio.sleep(0.1) + finally: + async with held_lock: + held_ids.discard(conn_id) - # Start all tasks simultaneously tasks = [acquire_and_record(i) for i in range(num_concurrent)] - start_event.set() # Release all tasks at once + start_event.set() await asyncio.gather(*tasks) - # Verify all connections are distinct - assert len(connection_ids) == num_concurrent - unique_ids = set(conn_id for _, conn_id in connection_ids) - assert len(unique_ids) == num_concurrent, ( - f"Expected {num_concurrent} distinct connections, " - f"but got {len(unique_ids)}" + assert not double_checkouts, ( + f"Pool handed the same connection to two tasks concurrently: {double_checkouts}" ) @pytest.mark.asyncio diff --git a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py index 3e1f957f..7a5199d8 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/relation/fixtures/models.py @@ -121,17 +121,17 @@ class User(ActiveRecord): # DerivedField: display name (coalesce email to name) display_name: ClassVar[Annotated[str, DerivedField( - lambda d: coalesce(d, Column(d, "email"), Column(d, "name")), + lambda d: coalesce(d, Column(dialect, d, "email"), Column(dialect, d, "name")), )]] # DerivedField (JSON): extract language preference language: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "settings"), "$.language"), + lambda d: json_extract_text(d, Column(dialect, d, "settings"), "$.language"), )]] # DerivedField (JSON): extract theme preference theme: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "settings"), "$.theme"), + lambda d: json_extract_text(d, Column(dialect, d, "settings"), "$.theme"), )]] # Relation @@ -159,17 +159,17 @@ class Post(ActiveRecord): # DerivedField: hotness score hotness: ClassVar[Annotated[int, DerivedField( - lambda d: Column(d, "view_count") + Literal(d, 1), + lambda d: Column(dialect, d, "view_count") + Literal(d, 1), )]] # DerivedField (JSON): first tag from metadata first_tag: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "metadata"), "$.tags[0]"), + lambda d: json_extract_text(d, Column(dialect, d, "metadata"), "$.tags[0]"), )]] # DerivedField (JSON): source from metadata source: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "metadata"), "$.source"), + lambda d: json_extract_text(d, Column(dialect, d, "metadata"), "$.source"), )]] # Relations @@ -199,7 +199,7 @@ class Comment(ActiveRecord): # DerivedField (JSON): platform from meta platform: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "meta"), "$.platform"), + lambda d: json_extract_text(d, Column(dialect, d, "meta"), "$.platform"), )]] # Relation @@ -221,15 +221,15 @@ class AsyncUser(AsyncActiveRecord): settings: Optional[str] = None display_name: ClassVar[Annotated[str, DerivedField( - lambda d: coalesce(d, Column(d, "email"), Column(d, "name")), + lambda d: coalesce(d, Column(dialect, d, "email"), Column(dialect, d, "name")), )]] language: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "settings"), "$.language"), + lambda d: json_extract_text(d, Column(dialect, d, "settings"), "$.language"), )]] theme: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "settings"), "$.theme"), + lambda d: json_extract_text(d, Column(dialect, d, "settings"), "$.theme"), )]] posts: ClassVar[AsyncHasMany["AsyncPost"]] = AsyncHasMany( @@ -254,15 +254,15 @@ class AsyncPost(AsyncActiveRecord): )]] hotness: ClassVar[Annotated[int, DerivedField( - lambda d: Column(d, "view_count") + Literal(d, 1), + lambda d: Column(dialect, d, "view_count") + Literal(d, 1), )]] first_tag: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "metadata"), "$.tags[0]"), + lambda d: json_extract_text(d, Column(dialect, d, "metadata"), "$.tags[0]"), )]] source: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "metadata"), "$.source"), + lambda d: json_extract_text(d, Column(dialect, d, "metadata"), "$.source"), )]] user: ClassVar[AsyncBelongsTo["AsyncUser"]] = AsyncBelongsTo( @@ -289,7 +289,7 @@ class AsyncComment(AsyncActiveRecord): )]] platform: ClassVar[Annotated[str, DerivedField( - lambda d: json_extract_text(d, Column(d, "meta"), "$.platform"), + lambda d: json_extract_text(d, Column(dialect, d, "meta"), "$.platform"), )]] post: ClassVar[AsyncBelongsTo["AsyncPost"]] = AsyncBelongsTo( diff --git a/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py b/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py new file mode 100644 index 00000000..210f89ad --- /dev/null +++ b/tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py @@ -0,0 +1,103 @@ +# tests/rhosocial/activerecord_test/feature/backend/cli/test_cli_blackbox.py +"""Black-box CLI tests for the PostgreSQL backend (live scenario server).""" + +import io +import json +from contextlib import redirect_stderr, redirect_stdout + +import pytest + +from rhosocial.activerecord.backend.impl.postgres.__main__ import main +from providers.scenarios import get_scenario_raw + +import socket +COMMANDS = [ + "info", "query", "introspect", "status", + "named-expression", "named-procedure", "named-procedure-graph", + "named-migration", "named-connection", +] + + +@pytest.fixture(scope="module") +def conn_args(): + backend_cls, config = get_scenario_raw("postgres_exts_18") + _s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + _s.settimeout(2) + try: + _s.connect((config.host, int(config.port))) + except OSError: + pytest.skip(f"Scenario server unreachable: {config.host}:{config.port}") + finally: + _s.close() + return [ + "--host", config.host, + "--port", str(config.port), + "--database", config.database, + "--user", config.username, + "--password", config.password, + ] + + +def run_cli(argv): + out, err = io.StringIO(), io.StringIO() + exc = None + with redirect_stdout(out), redirect_stderr(err): + try: + main(argv) + except SystemExit as e: + exc = e + return out.getvalue(), err.getvalue(), exc + + +class TestCommandSurface: + def test_help_lists_all_commands(self): + out, _, _ = run_cli(["--help"]) + for cmd in COMMANDS: + assert cmd in out + + def test_missing_command_errors(self): + _, _, exc = run_cli([]) + assert exc is not None and exc.code == 1 + + +class TestQuery: + def test_query_json(self, conn_args): + out, err, exc = run_cli(["query"] + conn_args + ["SELECT 1 AS one", "-o", "json"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert json.loads(out) == [{"one": 1}] + + def test_query_async(self, conn_args): + out, _, exc = run_cli(["query"] + conn_args + ["SELECT 1 AS one", "-o", "json", "--async"]) + assert exc is None + assert json.loads(out) == [{"one": 1}] + + +class TestStatus: + def test_status(self, conn_args): + out, err, exc = run_cli(["status"] + conn_args + ["-o", "json"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert json.loads(out) + + def test_status_async(self, conn_args): + out, err, exc = run_cli(["status"] + conn_args + ["-o", "json", "--async"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert json.loads(out) + + +class TestIntrospect: + def test_introspect(self, conn_args): + out, err, exc = run_cli(["introspect", "tables"] + conn_args + ["-o", "json"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert isinstance(json.loads(out), list) + + def test_introspect_async(self, conn_args): + out, err, exc = run_cli(["introspect", "tables"] + conn_args + ["-o", "json", "--async"]) + assert exc is None, f"stderr: {err}\nstdout: {out}" + assert isinstance(json.loads(out), list) + + +class TestInfo: + def test_info(self): + out, _, exc = run_cli(["info"]) + assert exc is None + assert "PostgreSQL" in out or "postgres" in out