diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1f9792b..2100cb73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,7 +69,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + 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: @@ -280,7 +280,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -392,7 +392,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | @@ -491,7 +491,7 @@ jobs: - name: Install activerecord and testsuite run: | pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" - pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/activerecord-derived-ddl" - name: Create postgres scenario config run: | diff --git a/.gitignore b/.gitignore index 75711906..13ffb522 100644 --- a/.gitignore +++ b/.gitignore @@ -181,5 +181,4 @@ htmlcov/ # Claude Code temporary files (local only) .claude/tmp/ -.claude/plans/ -.claude/plan/ +.claude/plan*/ diff --git a/changelog.d/57.added.md b/changelog.d/57.added.md new file mode 100644 index 00000000..2d0482c1 --- /dev/null +++ b/changelog.d/57.added.md @@ -0,0 +1 @@ +Comprehensive PostgreSQL backend overhaul: expression-based formatter system, DDL improvements, protocol compliance, and identifier quoting. Breaking changes: CREATE TABLE LIKE migrated to CreateTableLikeExpression, UNLOGGED via typed CreateTableOptions, dialect methods renamed to Postgres-prefixed convention. diff --git a/docs/en_US/postgres_specific_features/dialect.md b/docs/en_US/postgres_specific_features/dialect.md index a74b7c04..f6c95709 100644 --- a/docs/en_US/postgres_specific_features/dialect.md +++ b/docs/en_US/postgres_specific_features/dialect.md @@ -139,66 +139,60 @@ sql, params = expr.to_sql() PostgreSQL supports copying table structure using the `LIKE` clause with fine-grained control over what gets copied via INCLUDING/EXCLUDING options. +PostgreSQL renders the clause form `CREATE TABLE t (LIKE src [INCLUDING ...])`, +modelled by `CreateTableLikeExpression`. + ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableLikeExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect # Basic usage - copy table structure -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={'like_table': 'users'} +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table="users", ) # Generates: CREATE TABLE "users_copy" (LIKE "users") # With INCLUDING options (dictionary format - recommended) -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - } +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + 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) +# Generates: CREATE TABLE "users_copy" (LIKE "users" INCLUDING DEFAULTS +# INCLUDING CONSTRAINTS INCLUDING INDEXES EXCLUDING COMMENTS) # With schema-qualified source table -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table=('public', 'users'), ) # Generates: CREATE TABLE "users_copy" (LIKE "public"."users") # With TEMPORARY and IF NOT EXISTS -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="temp_users", - columns=[], +expr = CreateTableLikeExpression( + PostgresDialect(), + table="temp_users", + like_table="users", temporary=True, if_not_exists=True, - dialect_options={'like_table': 'users'} ) # Generates: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") # Including everything -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table="users", + like_options={'including': ['ALL']}, ) -# Generates: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) +# Generates: CREATE TABLE "users_copy" (LIKE "users" INCLUDING ALL) ``` **Available INCLUDING/EXCLUDING Options:** @@ -213,10 +207,10 @@ create_expr = CreateTableExpression( - `ALL` - Copy everything **Important Notes:** -- When `like_table` is specified in `dialect_options`, it takes highest priority -- All other parameters (columns, indexes, constraints, etc.) are IGNORED +- `CreateTableLikeExpression` carries only the target table and source `like_table` +- `like_options` accepts a dict (`including`/`excluding`), a list of feature + strings (treated as `INCLUDING`), or a list of `(action, feature)` tuples - Only `temporary` and `if_not_exists` flags are considered -- The `like_options` key supports both dictionary and list formats ## RETURNING Clause diff --git a/docs/zh_CN/postgres_specific_features/dialect.md b/docs/zh_CN/postgres_specific_features/dialect.md index 0bc9cc56..b3547c44 100644 --- a/docs/zh_CN/postgres_specific_features/dialect.md +++ b/docs/zh_CN/postgres_specific_features/dialect.md @@ -137,68 +137,60 @@ sql, params = expr.to_sql() ### CREATE TABLE ... LIKE -PostgreSQL 支持使用 `LIKE` 子句复制表结构,并通过 INCLUDING/EXCLUDING 选项精细控制要复制的内容。 +PostgreSQL 渲染为子句形式 `CREATE TABLE t (LIKE src [INCLUDING ...])`, +由 `CreateTableLikeExpression` 建模。 ```python -from rhosocial.activerecord.backend.expression import CreateTableExpression +from rhosocial.activerecord.backend.expression import CreateTableLikeExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect # 基本用法 - 复制表结构 -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={'like_table': 'users'} +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table="users", ) # 生成: CREATE TABLE "users_copy" (LIKE "users") # 带 INCLUDING 选项(字典格式 - 推荐) -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], - 'excluding': ['COMMENTS'] - } - } +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table="users", + like_options={ + 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'], + 'excluding': ['COMMENTS'], + }, ) -# 生成: CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, -# INCLUDING CONSTRAINTS, INCLUDING INDEXES, EXCLUDING COMMENTS) +# 生成: CREATE TABLE "users_copy" (LIKE "users" INCLUDING DEFAULTS +# INCLUDING CONSTRAINTS INCLUDING INDEXES EXCLUDING COMMENTS) # 带模式限定的源表 -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table=('public', 'users'), ) # 生成: CREATE TABLE "users_copy" (LIKE "public"."users") # 带临时表和 IF NOT EXISTS -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="temp_users", - columns=[], +expr = CreateTableLikeExpression( + PostgresDialect(), + table="temp_users", + like_table="users", temporary=True, if_not_exists=True, - dialect_options={'like_table': 'users'} ) # 生成: CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users" (LIKE "users") # 包含所有内容 -create_expr = CreateTableExpression( - dialect=PostgresDialect(), - table_name="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } +expr = CreateTableLikeExpression( + PostgresDialect(), + table="users_copy", + like_table="users", + like_options={'including': ['ALL']}, ) -# 生成: CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL) +# 生成: CREATE TABLE "users_copy" (LIKE "users" INCLUDING ALL) ``` **可用的 INCLUDING/EXCLUDING 选项:** @@ -213,10 +205,10 @@ create_expr = CreateTableExpression( - `ALL` - 复制所有内容 **重要说明:** -- 当 `dialect_options` 中指定 `like_table` 时,具有最高优先级 -- 所有其他参数(columns、indexes、constraints 等)都会被忽略 +- `CreateTableLikeExpression` 只携带目标表和源 `like_table` +- `like_options` 支持字典(`including`/`excluding`)、字符串列表(视为 + `INCLUDING`)以及 `(action, feature)` 元组列表 - 只有 `temporary` 和 `if_not_exists` 标志会被考虑 -- `like_options` 键支持字典和列表两种格式 ## RETURNING 子句 diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 00000000..e06f0cbd --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# scripts/install-git-hooks.sh +# +# Installs an optional pre-commit hook that runs scripts/preflight.sh so a +# broken import/lint is caught before the commit (and before a CI round-trip). +# Bypass a single commit with: git commit --no-verify +set -euo pipefail + +cd "$(dirname "$0")/.." + +HOOK=".git/hooks/pre-commit" +if [ ! -d .git/hooks ]; then + echo "install-git-hooks: .git/hooks not found (not a git repo?)" >&2 + exit 2 +fi + +cat > "$HOOK" <<'HOOK_BODY' +#!/usr/bin/env bash +# Installed by scripts/install-git-hooks.sh — runs the local pre-flight. +# Skip with: git commit --no-verify +exec "$(git rev-parse --show-toplevel)/scripts/preflight.sh" +HOOK_BODY +chmod +x "$HOOK" + +echo "install-git-hooks: installed $HOOK -> scripts/preflight.sh" diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100644 index 00000000..ec382261 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# scripts/preflight.sh +# +# Fast local pre-flight so a trivial ImportError/lint break is caught in +# seconds-to-a-minute instead of after a full CI round-trip. +# +# Steps: +# 1. ruff check report-only by default (STRICT_LINT=1 to gate) +# 2. import smoke FATAL: catches broken src imports +# 3. pytest --collect-only FATAL: imports every test module -> ImportError +# +# `--collect-only` does NOT execute tests, but it does import conftest.py, so a +# missing local DB may print connection warnings. Collection still succeeds. +# +# Overridable env vars: +# VENV= venv dir (default: .venv3.14-ubuntu26.04) +# IMPORT_MODULE= package to import (repo-specific default below) +# PYTEST_TARGET= collection target (default: tests) +# RUFF_TARGET= lint target (default: src) +# STRICT_LINT=1 make ruff failures fatal (default: report-only) +# SKIP_LINT=1 / SKIP_IMPORT=1 / SKIP_COLLECT=1 +set -uo pipefail + +cd "$(dirname "$0")/.." + +VENV="${VENV:-.venv3.14-ubuntu26.04}" +PY="$VENV/bin/python" +RUFF="$VENV/bin/ruff" +IMPORT_MODULE="${IMPORT_MODULE:-rhosocial.activerecord.backend.impl.postgres}" +PYTEST_TARGET="${PYTEST_TARGET:-tests}" +RUFF_TARGET="${RUFF_TARGET:-src}" + +if [ ! -x "$PY" ]; then + echo "preflight: venv python not found at $VENV (set VENV=...)" >&2 + exit 2 +fi + +fail=0 + +if [ "${SKIP_LINT:-0}" != "1" ]; then + echo "==> [1/3] ruff check $RUFF_TARGET" + if [ -x "$RUFF" ]; then lint_cmd=("$RUFF"); else lint_cmd=("$PY" -m ruff); fi + if ! "${lint_cmd[@]}" check "$RUFF_TARGET"; then + if [ "${STRICT_LINT:-0}" = "1" ]; then + echo "preflight: lint failed (STRICT_LINT=1)" >&2 + fail=1 + else + echo "preflight: lint issues found (non-fatal; set STRICT_LINT=1 to gate)" + fi + fi +else + echo "==> [1/3] ruff check (skipped)" +fi + +if [ "${SKIP_IMPORT:-0}" != "1" ]; then + echo "==> [2/3] import smoke: $IMPORT_MODULE" + if ! "$PY" -c "import $IMPORT_MODULE"; then + echo "preflight: import of '$IMPORT_MODULE' failed" >&2 + fail=1 + fi +else + echo "==> [2/3] import smoke (skipped)" +fi + +if [ "${SKIP_COLLECT:-0}" != "1" ]; then + echo "==> [3/3] pytest --collect-only $PYTEST_TARGET (no tests executed)" + log="$(mktemp)" + if ! PYTHONPATH=tests "$PY" -m pytest "$PYTEST_TARGET" \ + --collect-only -q -p no:cacheprovider > "$log" 2>&1; then + echo "preflight: test collection failed (ImportError?):" >&2 + grep -E "ImportError|ModuleNotFoundError|NameError|ERROR collecting" "$log" | head -30 >&2 + fail=1 + fi + rm -f "$log" +else + echo "==> [3/3] pytest --collect-only (skipped)" +fi + +if [ "$fail" -ne 0 ]; then + echo "" + echo "preflight: FAILED — fix the above before committing/pushing." + exit 1 +fi + +echo "" +echo "preflight: OK" diff --git a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py index 08cc6723..314f4629 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/dialect.py @@ -6,31 +6,28 @@ 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, CTEMixin, - FilterClauseMixin, + WindowFunctionMixin, JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, + ArrayMixin, ExplainMixin, GraphMixin, GraphTableMixin, - LockingMixin, + MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, + TemporalTableMixin, UpsertMixin, LateralJoinMixin, @@ -46,7 +43,6 @@ ConstraintMixin, PartitionMixin, # New Mixins - IdentifierMixin, PredicateMixin, ExpressionMixin, DateTimeMixin, @@ -188,9 +184,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 +288,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, @@ -339,22 +326,20 @@ class PostgresDialect( TruncateMixin, ILIKEMixin, CTEMixin, - FilterClauseMixin, + WindowFunctionMixin, PostgresJSONBEnhancedMixin, JSONMixin, - ReturningMixin, - AdvancedGroupingMixin, + ArrayMixin, ExplainMixin, PostgresPropertyGraphQueryMixin, GraphMixin, GraphTableMixin, PostgresLockingMixin, - LockingMixin, + MergeMixin, - OrderedSetAggregationMixin, - QualifyClauseMixin, + TemporalTableMixin, UpsertMixin, LateralJoinMixin, @@ -368,7 +353,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 +422,6 @@ class PostgresDialect( PostgresStoredProcedureMixin, PostgresAdvisoryLockMixin, # New Mixins - IdentifierMixin, PredicateMixin, ExpressionMixin, DateTimeMixin, @@ -603,6 +587,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 +606,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 +639,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/ddl/alter_table.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py index 117c989b..ca5cb766 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/ddl/alter_table.py @@ -82,7 +82,7 @@ ) from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( AddColumn, - RenameColumn, + RenameObject, ) # Add a new column @@ -130,7 +130,7 @@ print("Column age added successfully") # Rename a column -rename_action = RenameColumn( +rename_action = RenameObject( dialect=dialect, old_name='name', new_name='full_name', 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/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/examples/transaction/for_update.py b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py index 61f52a9f..fc8cc9fa 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/examples/transaction/for_update.py @@ -184,9 +184,9 @@ # FOR SHARE - shared lock (read lock) # FOR KEY SHARE - for foreign key detection -# Use PostgresForUpdateClause with LockStrength.SHARE for FOR SHARE -from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( # noqa: E402 - PostgresForUpdateClause, +# Use the generic ForUpdateClause with LockStrength.SHARE for FOR SHARE +from rhosocial.activerecord.backend.expression import ( # noqa: E402 + ForUpdateClause, LockStrength, ) @@ -196,7 +196,7 @@ dialect=dialect, select=[Column(dialect, 'id'), Column(dialect, 'name'), Column(dialect, 'balance')], from_=TableExpression(dialect, 'accounts'), - for_update=PostgresForUpdateClause(dialect, strength=LockStrength.SHARE), + for_update=ForUpdateClause(dialect, strength=LockStrength.SHARE), ) sql, params = share_query.to_sql() result = backend.execute(sql, params, options=dql_options) @@ -222,5 +222,5 @@ # 1. Use ForUpdateClause with QueryExpression for SELECT ... FOR UPDATE # 2. ForUpdateClause(dialect, skip_locked=True) for SKIP LOCKED # 3. ForUpdateClause(dialect, nowait=True) for NOWAIT -# 4. Use PostgresForUpdateClause with LockStrength.SHARE for FOR SHARE +# 4. Use ForUpdateClause with LockStrength.SHARE for FOR SHARE # 5. Locks released on COMMIT/ROLLBACK diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py index 7d79bfc2..98684799 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/expression/__init__.py @@ -18,7 +18,7 @@ PostgresTryAdvisoryLockExpression, AdvisoryLockType, ) -from .locking import LockStrength +from .ilike import ILIKEExpression from .ddl import ( PostgresVacuumExpression, PostgresAnalyzeExpression, @@ -109,7 +109,8 @@ "PostgresTryAdvisoryLockExpression", "AdvisoryLockType", # locking - "LockStrength", + # ilike + "ILIKEExpression", # ddl "PostgresVacuumExpression", "PostgresAnalyzeExpression", 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..ec0c4f4b --- /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_expression" + + +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_expression" 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..af3d5317 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_expression" + + +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_expression" + + +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" + + +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" + + +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" 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/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py deleted file mode 100644 index e135f41b..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py +++ /dev/null @@ -1,51 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/expression/locking.py -""" -PostgreSQL-specific row-level locking expressions. - -PostgreSQL supports advanced row-level locking with multiple lock strengths -beyond the standard FOR UPDATE: -- FOR NO KEY UPDATE: Weaker exclusive lock that allows FOR KEY SHARE -- FOR SHARE: Shared lock that allows other readers -- FOR KEY SHARE: Weakest shared lock - -Version requirements: -- FOR UPDATE: All PostgreSQL versions -- FOR NO KEY UPDATE: PostgreSQL 9.0+ -- FOR SHARE: PostgreSQL 9.0+ -- FOR KEY SHARE: PostgreSQL 9.3+ - -Use the generic ``ForUpdateClause`` with ``dialect_options={"lock_strength": ...}`` -to specify PostgreSQL lock strengths: - - >>> from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause - >>> from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength - >>> clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) -""" - -from enum import Enum - - -class LockStrength(Enum): - """ - Enumeration of PostgreSQL row-level lock strength options. - - These options control the type of lock acquired on selected rows. - Only PostgreSQL supports lock strengths beyond FOR UPDATE. - - Lock strength hierarchy (PostgreSQL): - - UPDATE: Strongest exclusive lock, blocks all other locks - - NO_KEY_UPDATE: Weaker exclusive lock, allows KEY_SHARE - - SHARE: Shared lock, allows other SHARE and KEY_SHARE - - KEY_SHARE: Weakest shared lock, allows NO_KEY_UPDATE and SHARE - - Version requirements (PostgreSQL): - - FOR UPDATE: All versions - - FOR NO KEY UPDATE: PostgreSQL 9.0+ - - FOR SHARE: PostgreSQL 9.0+ - - FOR KEY SHARE: PostgreSQL 9.3+ - """ - - UPDATE = "FOR UPDATE" # Exclusive lock (strongest) - NO_KEY_UPDATE = "FOR NO KEY UPDATE" # Weaker exclusive lock - SHARE = "FOR SHARE" # Shared lock - KEY_SHARE = "FOR KEY SHARE" # Weakest shared lock diff --git a/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py b/src/rhosocial/activerecord/backend/impl/postgres/expression/types.py index d4dbefa2..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,292 +91,283 @@ 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 __hash__(self) -> int: - return hash((type(self), self.dim)) + def _type_params(self) -> tuple: + return (self.dim,) -class PostgresHalfvecType(DataType, backend="postgres"): +class PostgresHalfvecType(DataType): """pgvector ``HALFVEC(n)`` — half-precision vector (pgvector 0.5.0+). Stores each component as a half-precision float, halving memory compared @@ -378,22 +377,19 @@ class PostgresHalfvecType(DataType, backend="postgres"): dim: Number of dimensions. """ + name = "postgres_halfvec" 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 __hash__(self) -> int: - return hash((type(self), self.dim)) + def _type_params(self) -> tuple: + return (self.dim,) -class PostgresSparsevecType(DataType, backend="postgres"): +class PostgresSparsevecType(DataType): """pgvector ``SPARSEVEC(n)`` — sparse vector (pgvector 0.7.0+). Stores only non-zero components as ``{idx:value,...}/dim``, suitable for @@ -403,19 +399,16 @@ class PostgresSparsevecType(DataType, backend="postgres"): dim: Number of dimensions. """ + name = "postgres_sparsevec" 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 __hash__(self) -> int: - return hash((type(self), self.dim)) + def _type_params(self) -> tuple: + return (self.dim,) # --------------------------------------------------------------------------- @@ -432,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/introspection/status_introspector.py b/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py index 0b07e182..5964c3f7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/introspection/status_introspector.py @@ -379,7 +379,7 @@ def get_storage_info(self) -> StorageInfo: db_name = getattr(self._backend.config, 'database', 'postgres') # Get database size - sql = "SELECT pg_database_size(%s) as size_bytes" + sql = f"SELECT pg_database_size({self.dialect.p()}) as size_bytes" rows = self._exec_query(sql, (db_name,)) size_bytes = rows[0].get('size_bytes') if rows else None @@ -974,7 +974,7 @@ async def get_storage_info(self) -> StorageInfo: db_name = getattr(self._backend.config, 'database', 'postgres') # Get database size - sql = "SELECT pg_database_size(%s) as size_bytes" + sql = f"SELECT pg_database_size({self.dialect.p()}) as size_bytes" rows = await self._exec_query_async(sql, (db_name,)) size_bytes = rows[0].get('size_bytes') if rows else None diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py index a43bfe77..bae798e1 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/__init__.py @@ -13,7 +13,13 @@ from .extension import PostgresExtensionMixin from .materialized_view import PostgresMaterializedViewMixin -from .table import PostgresTableMixin +from .ddl_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 @@ -105,7 +111,7 @@ from .schema import PostgresSchemaMixin from .sequence import PostgresSequenceMixin from .transaction import PostgresTransactionMixin -from .view import PostgresViewMixin +from .ddl_view import PostgresViewMixin from .xml import PostgresXMLMixin from .collation import PostgresCollationMixin from .ordered_set_aggregation import PostgresOrderedSetAggMixin @@ -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..7468cd61 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py @@ -0,0 +1,41 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/datetime.py +"""PostgreSQL datetime formatting mixin.""" + +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.datetime import DateTimeDiffExpression + + +class PostgresDateTimeMixin: + """PostgreSQL datetime diff expression formatting.""" + + def format_datetime_diff_expression(self, expr: "DateTimeDiffExpression") -> 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_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/ddl/partition.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py index c20496f4..0dfa40d8 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl/partition.py @@ -591,7 +591,7 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres params: List[Any] = [expr.parent_table] schema_filter = "" if expr.schema is not None: - schema_filter = " AND parent_ns.nspname = %s" + schema_filter = f" AND parent_ns.nspname = {self.p()}" params.append(expr.schema) if expr.include_partitions: @@ -603,7 +603,7 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace LEFT JOIN pg_inherits i ON i.inhparent = parent.oid LEFT JOIN pg_class child ON child.oid = i.inhrelid - WHERE parent.relname = %s{schema_filter} + WHERE parent.relname = {self.p()}{schema_filter} ORDER BY child.relname """ else: @@ -613,6 +613,6 @@ def format_partition_metadata_query(self, expr: "PostgresPartitionMetadataExpres NULL::text AS bound FROM pg_class parent JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace - WHERE parent.relname = %s{schema_filter} + WHERE parent.relname = {self.p()}{schema_filter} """ return sql, tuple(params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_database.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_database.py new file mode 100644 index 00000000..aebd8a25 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_database.py @@ -0,0 +1,119 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_database.py +"""PostgreSQL database DDL mixin.""" +from __future__ import annotations + +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements.ddl_database import ( + AlterDatabaseExpression, + CreateDatabaseExpression, + DropDatabaseExpression, + ) + + +class PostgresDatabaseMixin: + """PostgreSQL database DDL support. + + PostgreSQL has rich database support including OWNER, ENCODING, + TABLESPACE, TEMPLATE, CONNECTION LIMIT, and WITH (FORCE) drop. + """ + + def supports_database(self) -> bool: + return True + + def supports_create_database(self) -> bool: + return True + + def supports_drop_database(self) -> bool: + return True + + def supports_alter_database(self) -> bool: + return True + + def supports_database_if_not_exists(self) -> bool: + """PostgreSQL 13+ supports IF NOT EXISTS.""" + return self.version >= (13, 0, 0) + + def supports_database_if_exists(self) -> bool: + """PostgreSQL 13+ supports IF EXISTS.""" + return self.version >= (13, 0, 0) + + def supports_database_owner(self) -> bool: + """PostgreSQL supports OWNER TO.""" + return True + + def supports_database_encoding(self) -> bool: + """PostgreSQL supports ENCODING (immutable after creation).""" + return True + + def supports_database_collation(self) -> bool: + """PostgreSQL supports LC_COLLATE.""" + return True + + def supports_database_template(self) -> bool: + """PostgreSQL supports TEMPLATE.""" + return True + + def supports_database_tablespace(self) -> bool: + """PostgreSQL supports TABLESPACE.""" + return True + + def supports_database_connection_limit(self) -> bool: + """PostgreSQL supports CONNECTION LIMIT.""" + return True + + def supports_database_force_drop(self) -> bool: + """PostgreSQL supports DROP DATABASE WITH (FORCE).""" + return True + + def format_create_database_statement( + self, expr: CreateDatabaseExpression + ) -> Tuple[str, tuple]: + parts = ["CREATE DATABASE"] + parts.append(self.format_identifier(expr.database_name)) + if expr.encoding: + parts.append(f"ENCODING = '{expr.encoding}'") + if expr.collation: + parts.append(f"LC_COLLATE = '{expr.collation}'") + if expr.template: + parts.append(f"TEMPLATE = {self.format_identifier(expr.template)}") + if expr.owner: + parts.append(f"OWNER = {self.format_identifier(expr.owner)}") + if expr.connection_limit is not None: + parts.append(f"CONNECTION LIMIT = {expr.connection_limit}") + if expr.tablespace: + parts.append(f"TABLESPACE = {self.format_identifier(expr.tablespace)}") + return " ".join(parts), () + + def format_drop_database_statement( + self, expr: DropDatabaseExpression + ) -> Tuple[str, tuple]: + parts = ["DROP DATABASE"] + if expr.if_exists and self.supports_database_if_exists(): + parts.append("IF EXISTS") + parts.append(self.format_identifier(expr.database_name)) + if expr.force and self.supports_database_force_drop(): + parts.append("WITH (FORCE)") + return " ".join(parts), () + + def format_alter_database_statement( + self, expr: AlterDatabaseExpression + ) -> Tuple[str, tuple]: + from rhosocial.activerecord.backend.expression.statements.ddl_database import AlterDatabaseAction + parts = ["ALTER DATABASE"] + parts.append(self.format_identifier(expr.database_name)) + if expr.action == AlterDatabaseAction.RENAME_TO: + parts.append(f"RENAME TO {self.format_identifier(expr.target)}") + elif expr.action == AlterDatabaseAction.OWNER_TO: + parts.append(f"OWNER TO {self.format_identifier(expr.target)}") + elif expr.action == AlterDatabaseAction.CONNECTION_LIMIT: + parts.append(f"CONNECTION LIMIT = {expr.target}") + elif expr.action == AlterDatabaseAction.TABLESPACE: + parts.append(f"SET TABLESPACE {self.format_identifier(expr.target)}") + return " ".join(parts), () + + +__all__ = ['PostgresDatabaseMixin'] diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py new file mode 100644 index 00000000..77d6e8fb --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_table.py @@ -0,0 +1,171 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +import re +from typing import Any, List, Tuple + +class PostgresTableMixin: + """PostgreSQL table extended features implementation.""" + + def supports_if_not_exists_table(self) -> bool: + """CREATE TABLE IF NOT EXISTS is supported since PostgreSQL 9.5+.""" + return self.version >= (9, 5, 0) + + def supports_if_exists_table(self) -> bool: + """DROP TABLE IF EXISTS is supported in all versions.""" + return True + + def supports_temporary_table(self) -> bool: + """TEMPORARY tables are supported in all versions.""" + return True + + def supports_unlogged_table(self) -> bool: + """CREATE UNLOGGED TABLE is supported since PostgreSQL 9.5. + + UNLOGGED tables write no WAL; they are faster for staging data but are + not crash-safe and are truncated on crash recovery. PostgreSQL 14 added + the ``UNLOGGED`` / ``LOGGED`` table as a distribution. The bare + ``UNLOGGED`` qualifier on CREATE TABLE exists since 9.5, matching the + §0 baseline. + """ + return self.version >= (9, 5, 0) + + def supports_table_inheritance(self) -> bool: + """PostgreSQL supports table inheritance.""" + return True + + def supports_table_tablespace(self) -> bool: + """TABLESPACE specification is supported in all versions.""" + return True + + 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 for PostgreSQL. + + Handles PostgreSQL-specific CREATE TABLE behavior: + * Declarative partitioning validation for ``partition`` expressions. + + ``UNLOGGED`` is carried by :class:`CreateTableOptions` (``unlogged=True``) + and rendered by the generic core renderer, gated on + :meth:`supports_unlogged_table`. + + 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. + """ + if getattr(expr, "partition", None) is not None: + # Validate through the PartitionClause -> format_partition_clause chain. + expr.partition.to_sql() + + table_options = getattr(expr, "table_options", None) + if ( + table_options is not None + and table_options.unlogged + and getattr(expr, "temporary", False) + ): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + raise UnsupportedFeatureError( + self.name, + "CREATE UNLOGGED TEMPORARY TABLE", + suggestion="UNLOGGED and TEMPORARY are mutually exclusive in PostgreSQL", + ) + + # Delegate to base implementation (TableMixin) + return super().format_create_table_statement(expr) + + def format_create_table_like_statement(self, expr) -> Tuple[str, tuple]: + """Format CREATE TABLE (LIKE ...) for PostgreSQL. + + PostgreSQL uses a column-list clause rather than the generic + ``LIKE `` form:: + + CREATE [TEMPORARY] TABLE [IF NOT EXISTS] + (LIKE [INCLUDING ]... [EXCLUDING ]...) + + ``expr.like_options`` accepts: + + * ``dict`` with ``"including"`` / ``"excluding"`` lists; + * ``list`` of feature strings (treated as ``INCLUDING``); + * ``list`` of ``(action, feature)`` tuples (``ACTION FEATURE``). + """ + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) + + if not self.supports_create_table_like(): + raise UnsupportedFeatureError(self.name, "CREATE TABLE ... (LIKE ...)") + + parts = ["CREATE"] + if expr.temporary: + parts.append("TEMPORARY") + parts.append("TABLE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + + table_sql, table_params = expr.table.to_sql() + parts.append(table_sql) + + source_sql, source_params = expr.like_table.to_sql() + + like_parts = [f"LIKE {source_sql}"] + + like_options = expr.like_options or [] + 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), tuple(table_params) + tuple(source_params) + + 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() + if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): + raise ValueError( + f"Invalid data type '{type_sql}': " + "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." + ) + col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" + + identity = getattr(col_def, 'identity', None) + if not identity: + dialect_opts = col_def.dialect_options or {} + identity = dialect_opts.get("identity") + if identity: + if identity.upper() in ("ALWAYS", "BY DEFAULT"): + 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) + if col_def.comment: + escaped_comment = SQLDialectBase._escape_sql_string(col_def.comment) + col_sql += f" COMMENT '{escaped_comment}'" + if col_def.generated_expression is not None: + gen_sql, gen_params = col_def.generated_expression.to_sql() + col_sql += gen_sql + all_params.extend(gen_params) + return col_sql, tuple(all_params) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py new file mode 100644 index 00000000..256b4b68 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py @@ -0,0 +1,86 @@ +# src/rhosocial/activerecord/backend/impl/postgres/mixins/ddl_view.py +"""PostgreSQL view feature support implementation.""" + +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.statements.ddl_view import CreateViewExpression + + +class PostgresViewMixin: + """PostgreSQL view feature support implementation.""" + + def supports_or_replace_view(self) -> bool: + return True + + def supports_create_or_replace_view(self) -> bool: + return True + + def supports_if_not_exists_view(self) -> bool: + """PostgreSQL does not support IF NOT EXISTS for views.""" + return False + + def supports_temporary_view(self) -> bool: + return True + + def supports_if_exists_view(self) -> bool: + return True + + def supports_view_check_option(self) -> bool: + return True + + def supports_cascade_view(self) -> bool: + return True + + def supports_materialized_view(self) -> bool: + return True + + def supports_refresh_materialized_view(self) -> bool: + return True + + def supports_materialized_view_tablespace(self) -> bool: + return True + + def supports_materialized_view_storage_options(self) -> bool: + return True + + 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 and self.supports_create_or_replace_view(): + 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/mixins/dml/advisory_lock.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py index 6ac4ab86..f2c5297a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/advisory_lock.py @@ -84,9 +84,9 @@ def format_advisory_lock(self, expr: "PostgresAdvisoryLockExpression") -> Tuple[ # Handle key format if isinstance(expr.key, tuple): key1, key2 = expr.key - return f"SELECT {func_name}(%s, %s)", (key1, key2) + return f"SELECT {func_name}({self.p()}, {self.p()})", (key1, key2) else: - return f"SELECT {func_name}(%s)", (expr.key,) + return f"SELECT {func_name}({self.p()})", (expr.key,) def format_advisory_unlock(self, expr: "PostgresAdvisoryUnlockExpression") -> Tuple[str, tuple]: """ @@ -109,9 +109,9 @@ def format_advisory_unlock(self, expr: "PostgresAdvisoryUnlockExpression") -> Tu # Handle key format if isinstance(expr.key, tuple): key1, key2 = expr.key - return f"SELECT {func_name}(%s, %s)", (key1, key2) + return f"SELECT {func_name}({self.p()}, {self.p()})", (key1, key2) else: - return f"SELECT {func_name}(%s)", (expr.key,) + return f"SELECT {func_name}({self.p()})", (expr.key,) def format_advisory_unlock_all(self, expr: "PostgresAdvisoryUnlockAllExpression") -> Tuple[str, tuple]: """ @@ -161,6 +161,6 @@ def format_try_advisory_lock(self, expr: "PostgresTryAdvisoryLockExpression") -> # Handle key format if isinstance(expr.key, tuple): key1, key2 = expr.key - return f"SELECT {func_name}(%s, %s)", (key1, key2) + return f"SELECT {func_name}({self.p()}, {self.p()})", (key1, key2) else: - return f"SELECT {func_name}(%s)", (expr.key,) \ No newline at end of file + return f"SELECT {func_name}({self.p()})", (expr.key,) \ No newline at end of file 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..afa0b94f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/locking.py @@ -37,17 +37,17 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: FOR SHARE, FOR KEY SHARE) and options (NOWAIT, SKIP LOCKED). Args: - clause: PostgresForUpdateClause instance + clause: ForUpdateClause instance. Returns: Tuple of (SQL string, parameters tuple) """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - from rhosocial.activerecord.backend.impl.postgres.expression.locking import LockStrength + from rhosocial.activerecord.backend.expression import LockStrength all_params = [] - strength = clause.dialect_options.get("lock_strength", LockStrength.UPDATE) + strength = clause.strength # Check version support for lock strength if strength == LockStrength.NO_KEY_UPDATE: @@ -65,6 +65,10 @@ def format_for_update_clause(self, clause) -> Tuple[str, tuple]: raise UnsupportedFeatureError( self.name, "FOR KEY SHARE (requires PostgreSQL 9.3+)" ) + elif strength != LockStrength.UPDATE: + raise UnsupportedFeatureError( + self.name, f"{strength.value} (unsupported lock strength)" + ) # Use the strength value directly (e.g., "FOR UPDATE", "FOR SHARE") sql_parts = [strength.value] @@ -94,3 +98,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.expression 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/dml/stored_procedure.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py index 9a6e5cfb..5d1be711 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dml/stored_procedure.py @@ -161,7 +161,7 @@ def format_call_statement( full_name = f"{self.format_identifier(schema)}.{self.format_identifier(name)}" if schema else self.format_identifier(name) # noqa: E501 if arguments: - placeholders = ", ".join(["%s"] * len(arguments)) + placeholders = ", ".join([self.p()] * len(arguments)) sql = f"CALL {full_name}({placeholders})" return sql, tuple(arguments) else: 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..8b202671 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/dql.py @@ -0,0 +1,20 @@ +# 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 + + def supports_nulls_first_last(self) -> bool: + """PostgreSQL supports explicit NULLS FIRST / NULLS LAST ordering.""" + 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..d0605175 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py @@ -1,6 +1,11 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/explain.py """PostgreSQL explain feature support implementation.""" +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.statements.explain import ExplainExpression + class PostgresExplainMixin: """PostgreSQL explain override implementation. @@ -15,3 +20,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, expr: "ExplainExpression") -> Tuple[str, 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 = expr.statement.to_sql() + options = 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/features.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py index 9f083272..5cb75735 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/features.py @@ -14,9 +14,6 @@ def supports_generated_columns(self) -> bool: def supports_cte_search_cycle(self) -> bool: return self.version >= (14, 0, 0) - def supports_fetch_with_ties(self) -> bool: - return self.version >= (13, 0, 0) - def supports_call_statement(self) -> bool: return self.version >= (11, 0, 0) 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..4bd8888a 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,26 @@ class PostgresILIKEMixin: def supports_ilike(self) -> bool: return True + + def format_ilike_expression(self, expr: "ILIKEExpression") -> Tuple[str, tuple]: + """Format an ILIKE expression for PostgreSQL. + + Args: + expr: The :class:`ILIKEExpression` node to render. The node stores + its own ``column``, ``pattern`` and ``negate`` data; the + formatter takes no extra arguments so it can be dispatched + uniformly as ``formatter(expr)``. + + Returns: + Tuple of (SQL string, (pattern,) params tuple) + + """ + col_sql, _ = expr.column.to_sql() + + placeholder = self.p() + if expr.negate: + sql = f"{col_sql} NOT ILIKE {placeholder}" + else: + sql = f"{col_sql} ILIKE {placeholder}" + + return sql, (expr.pattern,) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py index f489329b..47040fef 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/introspection.py @@ -195,7 +195,7 @@ def format_table_list_query(self, expr: "TableListExpression") -> Tuple[str, tup pg_total_relation_size(c.oid) as size_bytes FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = %s + WHERE n.nspname = {self.p()} AND c.relkind IN {relkind_filter} """ @@ -231,7 +231,7 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup table_name = params.get("table_name", "") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT c.relname as table_name, CASE c.relkind WHEN 'r' THEN 'BASE TABLE' @@ -245,7 +245,7 @@ def format_table_info_query(self, expr: "TableInfoExpression") -> Tuple[str, tup pg_total_relation_size(c.oid) as size_bytes FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = %s AND c.relname = %s + WHERE n.nspname = {self.p()} AND c.relname = {self.p()} """ return (sql, (schema, table_name)) @@ -269,7 +269,7 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t schema = params.get("schema") or self._get_default_schema() # Base query with standard columns - sql = """ + sql = f""" SELECT a.attname as column_name, a.attnum as ordinal_position, pg_catalog.format_type(a.atttypid, a.atttypmod) as data_type, @@ -286,8 +286,8 @@ def format_column_info_query(self, expr: "ColumnInfoExpression") -> Tuple[str, t LEFT JOIN pg_attrdef d ON a.attrelid = d.adrelid AND a.attnum = d.adnum JOIN pg_class c ON a.attrelid = c.oid JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = %s - AND c.relname = %s + WHERE n.nspname = {self.p()} + AND c.relname = {self.p()} AND a.attnum > 0 AND NOT a.attisdropped """ @@ -317,7 +317,7 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup table_name = params.get("table_name", "") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT i.relname as index_name, a.attname as column_name, array_position(ix.indkey, a.attnum) as column_position, @@ -331,7 +331,7 @@ def format_index_info_query(self, expr: "IndexInfoExpression") -> Tuple[str, tup JOIN pg_namespace n ON t.relnamespace = n.oid JOIN pg_am am ON i.relam = am.oid JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = %s AND t.relname = %s + WHERE n.nspname = {self.p()} AND t.relname = {self.p()} ORDER BY i.relname, column_position """ @@ -360,7 +360,7 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t table_name = params.get("table_name", "") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT con.conname as constraint_name, a.attname as column_name, @@ -379,8 +379,8 @@ def format_foreign_key_query(self, expr: "ForeignKeyExpression") -> Tuple[str, t JOIN pg_attribute ref_a ON ref_a.attrelid = ref_class.oid AND ref_a.attnum = con.confkey[array_position(con.conkey, a.attnum)] WHERE con.contype = 'f' - AND ns.nspname = %s - AND class.relname = %s + AND ns.nspname = {self.p()} + AND class.relname = {self.p()} ORDER BY con.conname, column_position """ @@ -402,13 +402,13 @@ def format_view_list_query(self, expr: "ViewListExpression") -> Tuple[str, tuple schema = params.get("schema") or self._get_default_schema() include_system = params.get("include_system", False) - sql = """ + sql = f""" SELECT c.relname as view_name, pg_get_viewdef(c.oid, true) as definition, obj_description(c.oid) as comment FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = %s AND c.relkind = 'v' + WHERE n.nspname = {self.p()} AND c.relkind = 'v' """ params_list = [schema] @@ -435,13 +435,13 @@ def format_view_info_query(self, expr: "ViewInfoExpression") -> Tuple[str, tuple view_name = params.get("view_name", "") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT c.relname as view_name, pg_get_viewdef(c.oid, true) as definition, obj_description(c.oid) as comment FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid - WHERE n.nspname = %s AND c.relname = %s AND c.relkind = 'v' + WHERE n.nspname = {self.p()} AND c.relname = {self.p()} AND c.relkind = 'v' """ return (sql, (schema, view_name)) @@ -470,7 +470,7 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, table_name = params.get("table_name") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT t.tgname as trigger_name, c.relname as table_name, @@ -487,14 +487,14 @@ def format_trigger_list_query(self, expr: "TriggerListExpression") -> Tuple[str, JOIN pg_class c ON t.tgrelid = c.oid JOIN pg_namespace n ON c.relnamespace = n.oid JOIN pg_proc p ON t.tgfoid = p.oid - WHERE n.nspname = %s + WHERE n.nspname = {self.p()} AND NOT t.tgisinternal """ params_list = [schema] if table_name: - sql += " AND c.relname = %s" + sql += f" AND c.relname = {self.p()}" params_list.append(table_name) sql += " ORDER BY t.tgname" @@ -517,7 +517,7 @@ def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, table_name = params.get("table_name") schema = params.get("schema") or self._get_default_schema() - sql = """ + sql = f""" SELECT t.tgname as trigger_name, c.relname as table_name, @@ -534,15 +534,15 @@ def format_trigger_info_query(self, expr: "TriggerInfoExpression") -> Tuple[str, JOIN pg_class c ON t.tgrelid = c.oid JOIN pg_namespace n ON c.relnamespace = n.oid JOIN pg_proc p ON t.tgfoid = p.oid - WHERE n.nspname = %s - AND t.tgname = %s + WHERE n.nspname = {self.p()} + AND t.tgname = {self.p()} AND NOT t.tgisinternal """ params_list = [schema, trigger_name] if table_name: - sql += " AND c.relname = %s" + sql += f" AND c.relname = {self.p()}" params_list.append(table_name) return (sql, tuple(params_list)) 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..972964c0 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/materialized_view.py @@ -3,6 +3,7 @@ if TYPE_CHECKING: from ...expression.ddl import PostgresRefreshMaterializedViewExpression + from ...expression.statements.ddl_view import CreateMaterializedViewExpression class PostgresMaterializedViewMixin: @@ -12,6 +13,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: "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_refresh_materialized_view_pg_statement( self, expr: "PostgresRefreshMaterializedViewExpression" ) -> Tuple[str, tuple]: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py index 62ac985e..e47b1394 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/returning.py @@ -16,3 +16,7 @@ def supports_returning_update(self) -> bool: def supports_returning_delete(self) -> bool: return self.version >= (8, 2, 0) + + def supports_returning_old_new(self) -> bool: + """PostgreSQL 17+ allows ``OLD.`` / ``NEW.`` in RETURNING.""" + return self.version >= (17, 0, 0) diff --git a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py b/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py deleted file mode 100644 index e3d33eae..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py +++ /dev/null @@ -1,111 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/mixins/table.py -import re -from typing import Any, List, Tuple - -class PostgresTableMixin: - """PostgreSQL table extended features implementation.""" - - def supports_if_not_exists_table(self) -> bool: - """CREATE TABLE IF NOT EXISTS is supported since PostgreSQL 9.5+.""" - return self.version >= (9, 5, 0) - - def supports_if_exists_table(self) -> bool: - """DROP TABLE IF EXISTS is supported in all versions.""" - return True - - def supports_temporary_table(self) -> bool: - """TEMPORARY tables are supported in all versions.""" - return True - - def supports_unlogged_table(self) -> bool: - """CREATE UNLOGGED TABLE is supported since PostgreSQL 9.5. - - UNLOGGED tables write no WAL; they are faster for staging data but are - not crash-safe and are truncated on crash recovery. PostgreSQL 14 added - the ``UNLOGGED`` / ``LOGGED`` table as a distribution. The bare - ``UNLOGGED`` qualifier on CREATE TABLE exists since 9.5, matching the - §0 baseline. - """ - return self.version >= (9, 5, 0) - - def supports_table_inheritance(self) -> bool: - """PostgreSQL supports table inheritance.""" - return True - - def supports_table_tablespace(self) -> bool: - """TABLESPACE specification is supported in all versions.""" - return True - - def supports_table_like_syntax(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. - """ - sql, params = super().format_create_table_statement(expr) - if not (getattr(expr, "dialect_options", None) or {}).get("unlogged_table"): - return sql, params - if getattr(expr, "temporary", False): - return sql, params - if not self.supports_unlogged_table(): - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - raise UnsupportedFeatureError( - self.name, - "CREATE UNLOGGED TABLE", - suggestion="requires PostgreSQL 9.5+", - ) - prefix = "CREATE TABLE " - if sql.startswith(prefix): - return sql.replace(prefix, "CREATE UNLOGGED TABLE ", 1), params - temp_prefix = "CREATE TEMPORARY TABLE " - if sql.startswith(temp_prefix): - return sql.replace(temp_prefix, "CREATE UNLOGGED TABLE ", 1), params - return sql, params - - def format_create_table_like(self, expr) -> Tuple[str, tuple]: - """Format CREATE TABLE (LIKE ...) statement for PostgreSQL. - - Delegates to format_create_table_statement which already handles - the 'like_table' key in dialect_options. - """ - return self.format_create_table_statement(expr) - - 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) - if not re.fullmatch(r"[A-Za-z0-9\s(),\[\]]+", type_sql): - raise ValueError( - f"Invalid data type '{type_sql}': " - "must contain only alphanumeric characters, spaces, parentheses, commas, and brackets." - ) - col_sql = f"{self.format_identifier(col_def.name)} {type_sql}" - dialect_opts = col_def.dialect_options or {} - identity = dialect_opts.get("identity") - if identity: - if identity.upper() in ("ALWAYS", "BY DEFAULT"): - 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) - if col_def.comment: - escaped_comment = SQLDialectBase._escape_sql_string(col_def.comment) - col_sql += f" COMMENT '{escaped_comment}'" - return col_sql, tuple(all_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..49db620f 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py @@ -1,6 +1,13 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/truncate.py """PostgreSQL truncate feature support implementation.""" +from typing import Tuple, TYPE_CHECKING + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + +if TYPE_CHECKING: + from ...expression.statements.ddl_truncate import TruncateExpression + class PostgresTruncateMixin: """PostgreSQL truncate override implementation. @@ -13,3 +20,28 @@ def supports_truncate_restart_identity(self) -> bool: def supports_truncate_cascade(self) -> bool: return True + + 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``. + """ + parts = ["TRUNCATE TABLE"] + parts.append(self.format_identifier(expr.table_name)) + + if expr.restart_identity: + if not self.supports_truncate_restart_identity(): + raise UnsupportedFeatureError( + self.name, + "RESTART IDENTITY", + f"{self.name} does not support TRUNCATE ... RESTART IDENTITY " + f"for versions < 8.4." + ) + 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 2cfb4833..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,60 +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, - PostgresHalfvecType, - PostgresSparsevecType, - PostgresXID8Type, - PostgresXIDType, - PostgresXMLType, - PostgresCidrType, PostgresCharacterVaryingType, - PostgresCitextType, - PostgresCubeType, - PostgresLtreeType, - PostgresRasterType, ) if TYPE_CHECKING: @@ -111,355 +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(PostgresHalfvecType) - def format_data_type_halfvec(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_halfvec(self, data_type) -> Tuple[str, tuple]: return f"HALFVEC({data_type.dim})", () - @DDLTypeMixin.handles(PostgresSparsevecType) - def format_data_type_sparsevec(self, data_type) -> Tuple[str, tuple]: + def format_data_type_postgres_sparsevec(self, data_type) -> Tuple[str, tuple]: return f"SPARSEVEC({data_type.dim})", () - @DDLTypeMixin.handles(PostgresCitextType) - def format_data_type_citext(self, data_type) -> Tuple[str, tuple]: + 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 # ------------------------------------------------------------------ @@ -548,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 @@ -564,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 ---- @@ -576,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): @@ -670,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): @@ -683,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): @@ -713,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): @@ -736,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): @@ -759,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): @@ -784,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): @@ -793,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): @@ -807,17 +1015,17 @@ 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() + return PostgresGeometryType(self) # Vector types (pgvector) vector_match = re.match(r"^(VECTOR|HALFVEC|SPARSEVEC)\b", upper) @@ -836,13 +1044,13 @@ def parse_type(self, raw: str) -> DataType: vector_kind = vector_match.group(1) if vector_kind == "HALFVEC": from ...expression.types import PostgresHalfvecType - return PostgresHalfvecType(dim) + return PostgresHalfvecType(dim=dim, dialect=self) if vector_kind == "SPARSEVEC": from ...expression.types import PostgresSparsevecType - return PostgresSparsevecType(dim) + 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..b9f2826e 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, ) @@ -21,7 +26,7 @@ class EnumTypeMixin: This mixin implements the EnumTypeSupport protocol. """ - def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: + def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> Tuple[str, tuple]: """Format enum type name with optional schema. Args: @@ -29,28 +34,52 @@ def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: schema: Optional schema name Returns: - Formatted type name (e.g., 'schema.name' or 'name') + Tuple of (formatted type name, empty params tuple) """ if schema: - return f"{schema}.{name}" - return name + return (f"{schema}.{name}", ()) + return (name, ()) - def format_enum_values(self, values: List[str]) -> str: + 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]) -> Tuple[str, tuple]: """Format enum values list for SQL. Args: values: List of enum values Returns: - SQL-formatted values string + Tuple of (SQL-formatted values string, empty params tuple) + + """ + 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 ", ".join(f"'{v}'" for v in values) + 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: + ) -> Tuple[str, tuple]: """Format CREATE TYPE statement for enum. Args: @@ -60,17 +89,34 @@ def format_create_enum_type_raw( if_not_exists: Add IF NOT EXISTS Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ - full_name = self.format_enum_type_name(name, schema) - values_str = self.format_enum_values(values) + full_name, _ = self.format_enum_type_name(name, schema) + values_str, _ = self.format_enum_values(values) exists_clause = "IF NOT EXISTS " if if_not_exists else "" - return f"CREATE TYPE {exists_clause}{full_name} AS ENUM ({values_str})" + 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). + + """ + return self.format_create_enum_type_raw( + expr.name, + expr.values, + expr.schema, + expr.if_not_exists, + ) def format_drop_enum_type_raw( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False - ) -> str: + ) -> Tuple[str, tuple]: """Format DROP TYPE statement. Args: @@ -80,18 +126,35 @@ def format_drop_enum_type_raw( cascade: Add CASCADE Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ - full_name = self.format_enum_type_name(name, schema) + full_name, _ = self.format_enum_type_name(name, schema) exists_clause = "IF EXISTS " if if_exists else "" cascade_clause = " CASCADE" if cascade else "" - return f"DROP TYPE {exists_clause}{full_name}{cascade_clause}" + 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). + + """ + return self.format_drop_enum_type_raw( + expr.name, + expr.schema, + expr.if_exists, + expr.cascade, + ) 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 - ) -> str: + ) -> Tuple[str, tuple]: """Format ALTER TYPE ADD VALUE statement. Args: @@ -102,16 +165,34 @@ def format_alter_enum_add_value_raw( after: Add after this value Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ - full_name = self.format_enum_type_name(type_name, schema) + full_name, _ = self.format_enum_type_name(type_name, schema) sql = f"ALTER TYPE {full_name} ADD VALUE '{new_value}'" if before: sql += f" BEFORE '{before}'" elif after: sql += f" AFTER '{after}'" - return sql + 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). + + """ + return self.format_alter_enum_add_value_raw( + expr.type_name, + expr.new_value, + expr.schema, + expr.before, + expr.after, + ) # ========================================================================= # Convenience methods for EnumTypeSupport protocol @@ -134,7 +215,8 @@ def create_enum_type( SQL statement string """ - return self.format_create_enum_type_raw(name, values, schema, if_not_exists) + sql, _ = self.format_create_enum_type_raw(name, values, schema, if_not_exists) + return sql def drop_enum_type( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False @@ -153,7 +235,8 @@ def drop_enum_type( SQL statement string """ - return self.format_drop_enum_type_raw(name, schema, if_exists, cascade) + sql, _ = self.format_drop_enum_type_raw(name, schema, if_exists, cascade) + return sql def alter_enum_add_value( self, @@ -180,7 +263,8 @@ def alter_enum_add_value( SQL statement string """ - return self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) + sql, _ = self.format_alter_enum_add_value_raw(type_name, new_value, schema, before, after) + return sql # ========================================================================= # Expression-based methods (for expression-dialect architecture) @@ -188,135 +272,81 @@ 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) + return self.format_create_enum_type_raw( + expr.name, + expr.values, + expr.schema, + expr.if_not_exists, + ) 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) + return self.format_drop_enum_type_raw( + expr.name, + expr.schema, + expr.if_exists, + expr.cascade, + ) 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. - - **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, ())``. - - **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. + expr: "PostgresAlterEnumAddValueExpression", + ) -> Tuple[str, tuple]: + """Format ALTER TYPE ADD VALUE from its expression node. 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:`PostgresAlterEnumAddValueExpression` 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, 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, ()) + return self.format_alter_enum_add_value_raw( + expr.type_name, + expr.new_value, + expr.schema, + expr.before, + expr.after, + ) + + def format_enum_type_expression(self, expr) -> Tuple[str, tuple]: + """Format a :class:`PostgresEnumType` type reference expression. - if new_value is None: - raise TypeError("new_value must be provided when formatting enum value by name") + The reference is schema-qualified when the expression carries a + schema: ``"schema"."name"`` / ``"name"``. - return self.format_alter_enum_add_value_raw(expr_or_type_name, new_value, schema, before, after) + Args: + expr: :class:`PostgresEnumType` instance. + + Returns: + Tuple of (SQL string, empty params tuple). + + """ + 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. @@ -328,14 +358,13 @@ def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: Tuple of (SQL string, empty params tuple) """ - sql = self.format_alter_enum_add_value_raw( + return self.format_alter_enum_add_value_raw( expr.type_name, expr.new_value, expr.schema, expr.before, expr.after, ) - return (sql, ()) def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: """Format ALTER TYPE RENAME VALUE statement from expression object. @@ -347,6 +376,6 @@ def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: Tuple of (SQL string, empty params tuple) """ - full_name = self.format_enum_type_name(expr.type_name, expr.schema) + full_name, _ = self.format_enum_type_name(expr.type_name, expr.schema) sql = f"ALTER TYPE {full_name} RENAME VALUE '{expr.old_value}' TO '{expr.new_value}'" return (sql, ()) 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..51a1b905 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py @@ -1,6 +1,11 @@ # src/rhosocial/activerecord/backend/impl/postgres/mixins/upsert.py """PostgreSQL upsert feature support implementation.""" +from typing import Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...expression.statements.dml import OnConflictClause + class PostgresUpsertMixin: """PostgreSQL upsert override implementation. @@ -17,3 +22,66 @@ 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: "OnConflictClause") -> 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 + + all_params = [] + parts = ["ON CONFLICT"] + + if expr.conflict_target: + target_parts = [] + for target in expr.conflict_target: + if 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)})") + + if expr.do_nothing: + parts.append("DO NOTHING") + elif expr.update_assignments: + update_parts = [] + for col, expr_val in expr.update_assignments.items(): + if getattr(expr_val, 'table', None) == 'EXCLUDED': + 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)}") + + 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 deleted file mode 100644 index 14dcd2a0..00000000 --- a/src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py +++ /dev/null @@ -1,33 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/postgres/mixins/view.py -"""PostgreSQL view feature support implementation.""" - - -class PostgresViewMixin: - """PostgreSQL view feature support implementation.""" - - def supports_or_replace_view(self) -> bool: - return True - - def supports_temporary_view(self) -> bool: - return True - - def supports_if_exists_view(self) -> bool: - return True - - def supports_view_check_option(self) -> bool: - return True - - def supports_cascade_view(self) -> bool: - return True - - def supports_materialized_view(self) -> bool: - return True - - def supports_refresh_materialized_view(self) -> bool: - return True - - def supports_materialized_view_tablespace(self) -> bool: - return True - - def supports_materialized_view_storage_options(self) -> bool: - return True diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py index 7f324ac0..c799b56d 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/constraint.py @@ -13,10 +13,13 @@ - EXCLUDE constraints """ -from typing import Protocol, runtime_checkable +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.protocols import ConstraintSupport +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.statements import AddTableConstraint + @runtime_checkable class PostgresConstraintSupport(ConstraintSupport, Protocol): @@ -63,3 +66,19 @@ def supports_drop_constraint_if_exists(self) -> bool: supported it since 9.6. """ ... + + def format_add_table_constraint_action( + self, action: "AddTableConstraint" + ) -> Tuple[str, tuple]: + """Format ALTER TABLE ADD CONSTRAINT with PostgreSQL extensions. + + Adds EXCLUDE constraint support and the PostgreSQL-proprietary + ``NOT VALID`` suffix to the standard formatting. + + Args: + action: AddTableConstraint action to format. + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py index 8ae185da..a156daed 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ddl/index.py @@ -193,6 +193,36 @@ def format_reindex_statement(self, expr: "PostgresReindexExpression") -> Tuple[s """ ... # pragma: no cover + def format_add_index_action(self, action: Any) -> Tuple[str, tuple]: + """Format ALTER TABLE ADD INDEX action. + + PostgreSQL does not support ADD INDEX inside ALTER TABLE; this + raises ``UnsupportedFeatureError`` and directs callers to + ``CREATE INDEX``. + + Args: + action: Index action to format. + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... # pragma: no cover + + def format_drop_index_action(self, action: Any) -> Tuple[str, tuple]: + """Format ALTER TABLE DROP INDEX action. + + PostgreSQL does not support DROP INDEX inside ALTER TABLE; this + raises ``UnsupportedFeatureError`` and directs callers to + ``DROP INDEX``. + + Args: + action: Index action to format. + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... # pragma: no cover + def format_create_index_pg_statement( self, index_name: str, diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py index 5eb271bb..3ce4c221 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/dml/locking.py @@ -52,9 +52,26 @@ def format_for_update_clause(self, clause: Any) -> Tuple[str, tuple]: """Format PostgreSQL-specific FOR UPDATE clause. Args: - clause: PostgresForUpdateClause instance + clause: ForUpdateClause instance Returns: Tuple of (SQL string, parameters tuple) """ ... + + def supports_lock_strength(self, strength: Any) -> bool: + """Whether 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. + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py index 470f9a55..df9dfaab 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/explain.py """PostgreSQL explain feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable, Tuple @runtime_checkable @@ -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, expr: Any) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py index 3bfb58fe..332d2a4a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/features.py @@ -12,8 +12,6 @@ def supports_generated_columns(self)-> bool: ... def supports_cte_search_cycle(self)-> bool: ... - def supports_fetch_with_ties(self)-> bool: ... - def supports_call_statement(self)-> bool: ... def supports_stored_procedure_transaction_control(self)-> bool: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py index 815eb213..9835453a 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py @@ -1,11 +1,13 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/ilike.py """PostgreSQL ilike feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Protocol, runtime_checkable, Tuple @runtime_checkable class PostgresILIKESupport(Protocol): """PostgreSQL ilike feature support protocol.""" - def supports_ilike(self)-> bool: ... + def supports_ilike(self) -> bool: ... + + def format_ilike_expression(self, expr) -> Tuple[str, tuple]: ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py index f6702db9..fb3162a7 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/materialized_view.py @@ -5,7 +5,7 @@ that extend beyond the SQL standard. """ -from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING +from typing import Any, Protocol, runtime_checkable, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ...expression.ddl import PostgresRefreshMaterializedViewExpression @@ -51,3 +51,17 @@ def format_refresh_materialized_view_pg_statement( Tuple of (SQL statement, parameters tuple) """ ... + + def format_create_materialized_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE MATERIALIZED VIEW statement for PostgreSQL. + + Supports column aliases, TABLESPACE, WITH (storage_options), + and WITH DATA / WITH NO DATA. + + Args: + expr: CreateMaterializedViewExpression instance + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py index d0c8d38d..5ee31374 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/returning.py @@ -13,3 +13,5 @@ def supports_returning_insert(self)-> bool: ... def supports_returning_update(self)-> bool: ... def supports_returning_delete(self)-> bool: ... + + def supports_returning_old_new(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..769fd361 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/table.py @@ -5,10 +5,15 @@ exclusive to PostgreSQL table management. """ -from typing import Protocol, runtime_checkable +from typing import TYPE_CHECKING, Protocol, runtime_checkable from rhosocial.activerecord.backend.dialect.protocols import TableSupport +if TYPE_CHECKING: + from rhosocial.activerecord.backend.expression.statements import ( + CreateTableLikeExpression, + ) + @runtime_checkable class PostgresTableSupport(TableSupport, Protocol): @@ -41,12 +46,12 @@ def supports_unlogged_table(self) -> bool: """Whether CREATE UNLOGGED TABLE is supported. UNLOGGED tables skip WAL writes (9.6+), trading crash safety for - speed; callers opt in via ``dialect_options={"unlogged_table": - True}`` on ``CreateTableExpression``. + speed; callers opt in via ``CreateTableOptions(unlogged=True)`` on + ``CreateTableExpression``. """ ... - 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 @@ -54,7 +59,7 @@ def supports_table_like_syntax(self) -> bool: """ ... - def format_create_table_like(self, expr) -> tuple: + def format_create_table_like_statement(self, expr: "CreateTableLikeExpression") -> tuple: """Format CREATE TABLE (LIKE ...) statement.""" def format_column_definition(self, col_def) -> tuple: diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py index 50fb4665..72adc730 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py @@ -1,7 +1,13 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/transaction.py """PostgreSQL transaction feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Protocol, runtime_checkable, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.expression.transaction import ( + BeginTransactionExpression, + SetTransactionExpression, + ) @runtime_checkable @@ -17,3 +23,34 @@ def supports_read_only_transaction(self)-> bool: ... def supports_deferrable_transaction(self)-> bool: ... def supports_savepoint(self)-> bool: ... + + def format_begin_transaction( + self, expr: "BeginTransactionExpression" + ) -> Tuple[str, tuple]: + """Format BEGIN TRANSACTION with PostgreSQL inline options. + + PostgreSQL syntax:: + + BEGIN [ ISOLATION LEVEL { ... } ] [ { READ WRITE | READ ONLY } ] + [ { NOT DEFERRABLE | DEFERRABLE } ] + + Args: + expr: BeginTransactionExpression carrying isolation level and mode. + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... + + def format_set_transaction( + self, expr: "SetTransactionExpression" + ) -> Tuple[str, tuple]: + """Format SET TRANSACTION / SET SESSION CHARACTERISTICS statement. + + Args: + expr: SetTransactionExpression carrying transaction characteristics. + + Returns: + Tuple of (SQL statement, parameters tuple) + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py index ee45d091..889511ec 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/truncate.py """PostgreSQL truncate feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable, Tuple @runtime_checkable @@ -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: Any) -> Tuple[str, 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..f967d6de 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 Any, Protocol, runtime_checkable, List, Optional, Tuple, TYPE_CHECKING if TYPE_CHECKING: from ...expression.ddl.type import ( @@ -83,7 +83,7 @@ def alter_enum_add_value( """ ... - def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: + def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> Tuple[str, tuple]: """Format enum type name with optional schema. Args: @@ -91,44 +91,38 @@ def format_enum_type_name(self, name: str, schema: Optional[str] = None) -> str: schema: Optional schema name Returns: - Formatted type name (e.g., 'schema.name' or 'name') + Tuple of (formatted type name, empty params tuple) """ ... - def format_enum_values(self, values: List[str]) -> str: + def format_enum_values(self, values: List[str]) -> Tuple[str, tuple]: """Format enum values list for SQL. Args: values: List of enum values Returns: - SQL-formatted values string + Tuple of (SQL-formatted values string, empty params tuple) """ ... 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) """ ... def format_create_enum_type_raw( self, name: str, values: List[str], schema: Optional[str] = None, if_not_exists: bool = False - ) -> str: + ) -> Tuple[str, tuple]: """Format CREATE TYPE statement for enum. Args: @@ -138,33 +132,27 @@ def format_create_enum_type_raw( if_not_exists: Add IF NOT EXISTS Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ ... 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) """ ... def format_drop_enum_type_raw( self, name: str, schema: Optional[str] = None, if_exists: bool = False, cascade: bool = False - ) -> str: + ) -> Tuple[str, tuple]: """Format DROP TYPE statement. Args: @@ -174,36 +162,39 @@ def format_drop_enum_type_raw( cascade: Add CASCADE Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ ... 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: - SQL statement string or tuple of (SQL string, params tuple) + Tuple of (SQL string, params tuple) + """ + ... + + def format_enum_type_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format a PostgresEnumType type reference expression. + + Args: + expr: PostgresEnumType instance + + Returns: + Tuple of (SQL string, params tuple) """ ... 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 - ) -> str: + ) -> Tuple[str, tuple]: """Format ALTER TYPE ADD VALUE statement. Args: @@ -214,11 +205,11 @@ def format_alter_enum_add_value_raw( after: Add after this value Returns: - SQL statement string + Tuple of (SQL statement string, empty params tuple) """ ... - def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: + def format_alter_enum_type_add_value(self, expr: Any) -> Tuple[str, tuple]: """Format ALTER TYPE ADD VALUE statement from expression object. Args: @@ -229,7 +220,7 @@ def format_alter_enum_type_add_value(self, expr) -> Tuple[str, tuple]: """ ... - def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: + def format_alter_enum_type_rename_value(self, expr: Any) -> Tuple[str, tuple]: """Format ALTER TYPE RENAME VALUE statement from expression object. Args: @@ -239,3 +230,23 @@ def format_alter_enum_type_rename_value(self, expr) -> Tuple[str, tuple]: Tuple of (SQL string, empty params tuple) """ ... + + def format_enum_type_name_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format enum type name from expression object.""" + ... + + def format_enum_values_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format enum values from expression object.""" + ... + + def format_create_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE TYPE from expression object.""" + ... + + def format_drop_enum_type_raw_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP TYPE from expression object.""" + ... + + def format_alter_enum_add_value_raw_expression(self, expr: Any) -> Tuple[str, tuple]: + """Format ALTER TYPE ADD VALUE from expression object.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py index ecd7477c..467eb100 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/types/multirange.py @@ -77,3 +77,11 @@ def format_multirange_agg_function( Tuple of (SQL statement, parameters) """ ... + + def format_create_multirange_type_statement_expression(self, expr) -> Tuple[str, tuple]: + """Format CREATE TYPE for multirange from expression object.""" + ... + + def format_multirange_agg_function_expression(self, expr) -> Tuple[str, tuple]: + """Format multirange_agg from expression object.""" + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py index a2ca5175..1b406334 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/upsert.py """PostgreSQL upsert feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable, Tuple @runtime_checkable @@ -13,3 +13,16 @@ def supports_upsert(self) -> bool: ... def supports_on_conflict_clause(self) -> bool: ... def supports_multiple_on_conflict_clauses(self) -> bool: ... + + def format_on_conflict_clause(self, expr: Any) -> Tuple[str, tuple]: + """Format ON CONFLICT clause for PostgreSQL. + + Handles the EXCLUDED pseudo-table without quoting its name. + + Args: + expr: OnConflictClause expression instance + + Returns: + Tuple of (SQL string, parameters tuple) + """ + ... diff --git a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py index 8cc0e29a..ca2419ba 100644 --- a/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py +++ b/src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py @@ -1,7 +1,7 @@ # src/rhosocial/activerecord/backend/impl/postgres/protocols/view.py """PostgreSQL view feature support protocol.""" -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable, Tuple @runtime_checkable @@ -9,6 +9,8 @@ class PostgresViewSupport(Protocol): """PostgreSQL view feature support protocol.""" def supports_or_replace_view(self) -> bool: ... + def supports_create_or_replace_view(self) -> bool: ... + def supports_if_not_exists_view(self) -> bool: ... def supports_temporary_view(self) -> bool: ... def supports_if_exists_view(self) -> bool: ... def supports_view_check_option(self) -> bool: ... @@ -17,3 +19,17 @@ def supports_materialized_view(self) -> bool: ... def supports_refresh_materialized_view(self) -> bool: ... def supports_materialized_view_tablespace(self) -> bool: ... def supports_materialized_view_storage_options(self) -> bool: ... + + def format_create_view_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE VIEW statement for PostgreSQL. + + Supports TEMPORARY, OR REPLACE, column aliases, and + WITH {LOCAL|CASCADED} CHECK OPTION. + + Args: + expr: CreateViewExpression instance + + Returns: + Tuple of (SQL string, parameters 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..843a1321 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 ( @@ -312,11 +301,11 @@ def type_exists(self, name: str, schema: Optional[str] = None) -> bool: Returns: True if type exists """ - where_clause = "typname = %s" + where_clause = f"typname = {self._backend.dialect.p()}" params = [name] if schema: - where_clause += " AND n.nspname = %s" + where_clause += f" AND n.nspname = {self._backend.dialect.p()}" params.append(schema) sql = f""" @@ -340,11 +329,11 @@ def get_type_values(self, name: str, schema: Optional[str] = None) -> Optional[L Returns: List of enum values, or None if type doesn't exist """ - where_clause = "t.typname = %s" + where_clause = f"t.typname = {self._backend.dialect.p()}" params = [name] if schema: - where_clause += " AND n.nspname = %s" + where_clause += f" AND n.nspname = {self._backend.dialect.p()}" params.append(schema) sql = f""" 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/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..18fa6841 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( @@ -173,10 +173,10 @@ class TestPostgresRenameColumnAndTable: def test_rename_column(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - RenameColumn, + RenameObject, ) - sql, params = RenameColumn( + sql, params = RenameObject( dialect, old_name="id", new_name="order_id" ).to_sql() assert 'RENAME COLUMN "id" TO "order_id"' == sql @@ -184,13 +184,13 @@ def test_rename_column(self, dialect): def test_rename_column_inside_alter_table(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_alter import ( - RenameColumn, + RenameObject, ) expr = AlterTableExpression( dialect, table_name="orders", - actions=[RenameColumn(dialect, old_name="id", new_name="order_id")], + actions=[RenameObject(dialect, old_name="id", new_name="order_id")], ) sql, params = expr.to_sql() assert 'ALTER TABLE "orders"' in sql @@ -214,7 +214,14 @@ def test_capability_switches(self, dialect): class TestPostgresCreateUnloggedTable: - """CREATE UNLOGGED TABLE via dialect_options on CreateTableExpression.""" + """CREATE UNLOGGED TABLE via CreateTableOptions on CreateTableExpression.""" + + @staticmethod + def _options(dialect): + from rhosocial.activerecord.backend.expression.statements.ddl_table import ( + CreateTableOptions, + ) + return CreateTableOptions(dialect, unlogged=True) def test_unlogged_renders_qualifier(self, dialect): from rhosocial.activerecord.backend.expression.statements.ddl_table import ( @@ -224,8 +231,8 @@ def test_unlogged_renders_qualifier(self, dialect): expr = CreateTableExpression( dialect, table="audit", - columns=[ColumnDefinition("id", TextType())], - dialect_options={"unlogged_table": True}, + columns=[ColumnDefinition(dialect, "id", TextType(dialect=dialect))], + table_options=self._options(dialect), ) sql, params = expr.to_sql() assert sql.startswith('CREATE UNLOGGED TABLE "audit"') @@ -239,13 +246,16 @@ 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") assert params == () - def test_temporary_wins_over_unlogged(self, dialect): + def test_unlogged_with_temporary_rejected(self, dialect): + from rhosocial.activerecord.backend.dialect.exceptions import ( + UnsupportedFeatureError, + ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableExpression, ) @@ -253,13 +263,12 @@ 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}, + table_options=self._options(dialect), ) - sql, params = expr.to_sql() - assert sql.startswith("CREATE TEMPORARY TABLE") - assert params == () + with pytest.raises(UnsupportedFeatureError): + expr.to_sql() def test_version_gate_94(self): from rhosocial.activerecord.backend.dialect.exceptions import ( @@ -267,14 +276,15 @@ def test_version_gate_94(self): ) from rhosocial.activerecord.backend.expression.statements.ddl_table import ( CreateTableExpression, + CreateTableOptions, ) low = PostgresDialect(version=(9, 4, 0)) expr = CreateTableExpression( low, table="audit", - columns=[ColumnDefinition("id", TextType())], - dialect_options={"unlogged_table": True}, + columns=[ColumnDefinition(low, "id", TextType(dialect=low))], + table_options=CreateTableOptions(low, unlogged=True), ) with pytest.raises(UnsupportedFeatureError): expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_dql_capabilities.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_dql_capabilities.py new file mode 100644 index 00000000..744a4014 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_dql_capabilities.py @@ -0,0 +1,34 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_dql_capabilities.py +"""PostgreSQL DQL capability tests: WITH TIES (PG13+) and NULLS ordering.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ( + Column, + LimitOffsetClause, + OrderByClause, + OrderByExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + + +def test_fetch_with_ties_pre_13(): + dialect = PostgresDialect(version=(12, 0, 0)) + assert dialect.supports_fetch_with_ties() is False + + +def test_fetch_with_ties_at_13(): + dialect = PostgresDialect(version=(13, 0, 0)) + assert dialect.supports_fetch_with_ties() is True + sql, _ = dialect.format_limit_offset_clause(LimitOffsetClause(dialect, limit=5, with_ties=True)) + assert "WITH TIES" in sql + + +def test_nulls_first_last_supported(): + dialect = PostgresDialect(version=(17, 0, 0)) + assert dialect.supports_nulls_first_last() is True + sql, _ = OrderByClause( + dialect, + expressions=[OrderByExpression(dialect, Column(dialect, "name"), nulls_last=True)], + ).to_sql() + assert "NULLS LAST" in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_returning_capabilities.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_returning_capabilities.py new file mode 100644 index 00000000..124ceacb --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_returning_capabilities.py @@ -0,0 +1,56 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/dialect/test_returning_capabilities.py +"""PostgreSQL RETURNING capability tests (incl. PG17 OLD/NEW references).""" + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.expression.core import Column +from rhosocial.activerecord.backend.expression.statements import ReturningClause +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect + +pytestmark = [pytest.mark.feature, pytest.mark.backend] + + +class TestPostgresReturningCapabilities: + def test_dml_returning_supported(self): + dialect = PostgresDialect(version=(14, 0, 0)) + assert dialect.supports_returning_insert() is True + assert dialect.supports_returning_update() is True + assert dialect.supports_returning_delete() is True + + def test_old_new_pre_17(self): + dialect = PostgresDialect(version=(16, 0, 0)) + assert dialect.supports_returning_old_new() is False + + def test_old_new_at_17(self): + dialect = PostgresDialect(version=(17, 0, 0)) + assert dialect.supports_returning_old_new() is True + + def test_defaults_for_generic_switches(self): + dialect = PostgresDialect(version=(17, 0, 0)) + assert dialect.supports_returning_expressions() is True + assert dialect.supports_returning_alias() is True + assert dialect.supports_returning_wildcard() is True + assert dialect.supports_returning_single_row() is False + assert dialect.supports_returning_into() is False + + +class TestPostgresOldNewFormatting: + def test_old_new_rejected_pre_17(self): + dialect = PostgresDialect(version=(16, 0, 0)) + clause = ReturningClause(dialect, expressions=[Column(dialect, "name", table="OLD")]) + with pytest.raises(UnsupportedFeatureError, match="OLD/NEW in RETURNING"): + dialect.format_returning_clause(clause) + + def test_old_new_allowed_at_17(self): + dialect = PostgresDialect(version=(17, 0, 0)) + clause = ReturningClause( + dialect, + expressions=[ + Column(dialect, "name", table="OLD"), + Column(dialect, "name", table="NEW"), + ], + ) + sql, _ = dialect.format_returning_clause(clause) + assert '"OLD"."name"' in sql + assert '"NEW"."name"' in sql 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..05aa0421 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/expression/test_expression_signatures.py @@ -0,0 +1,202 @@ +# 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. +""" +from rhosocial.activerecord.backend.expression.core import Column +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=Column(postgres_dialect, "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=Column(postgres_dialect, "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=Column(postgres_dialect, "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=Column(postgres_dialect, "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/postgres/extensions/test_bloom.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py index c8a694b5..539d37da 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_bloom.py @@ -7,6 +7,7 @@ - format_bloom_access_method """ +from rhosocial.activerecord.backend.expression import CreateIndexExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -18,19 +19,33 @@ def setup_method(self): self.dialect = PostgresDialect(version=(14, 0, 0)) def test_format_bloom_index(self): - """format_bloom_index should return SQL containing bloom.""" - result = self.dialect.format_bloom_index("idx_name", "table_name", ["col1", "col2"]) - assert "bloom" in result - assert "idx_name" in result - assert "table_name" in result + """CreateIndexExpression with index_type bloom should render a bloom index.""" + expr = CreateIndexExpression( + self.dialect, + index_name="idx_name", + table_name="table_name", + columns=["col1", "col2"], + index_type="bloom", + ) + sql, params = expr.to_sql() + assert "bloom" in sql + assert "idx_name" in sql + assert "table_name" in sql + assert params == () def test_format_bloom_index_with_fill_factor(self): - """format_bloom_index with fill_factor should include fillfactor option.""" - result = self.dialect.format_bloom_index( - "idx_name", "table_name", ["col1", "col2"], fill_factor=90 + """CreateIndexExpression with fillfactor should include WITH (fillfactor = ...).""" + expr = CreateIndexExpression( + self.dialect, + index_name="idx_name", + table_name="table_name", + columns=["col1", "col2"], + index_type="bloom", + dialect_options={"with": {"fillfactor": 90}}, ) - assert "bloom" in result - assert "fillfactor" in result + sql, _ = expr.to_sql() + assert "bloom" in sql + assert "fillfactor" in sql def test_format_bloom_access_method(self): """format_bloom_access_method should return SQL containing bloom.""" 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.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin.py index 25e036ee..993a73ad 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gin.py @@ -7,6 +7,7 @@ - format_btree_gin_operator_class """ +from rhosocial.activerecord.backend.expression import CreateIndexExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -18,11 +19,19 @@ def setup_method(self): self.dialect = PostgresDialect(version=(14, 0, 0)) def test_format_gin_index(self): - """format_gin_index should return SQL containing gin.""" - result = self.dialect.format_gin_index("idx_name", "table_name", ["column"]) - assert "gin" in result - assert "idx_name" in result - assert "table_name" in result + """CreateIndexExpression with index_type gin should render a gin index.""" + expr = CreateIndexExpression( + self.dialect, + index_name="idx_name", + table_name="table_name", + columns=["column"], + index_type="gin", + ) + sql, params = expr.to_sql() + assert "gin" in sql + assert "idx_name" in sql + assert "table_name" in sql + assert params == () def test_format_btree_gin_operator_class(self): """format_btree_gin_operator_class should return operator class name.""" 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.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist.py index 03db53e6..c0dfee06 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_btree_gist.py @@ -7,6 +7,7 @@ - format_btree_gist_operator_class """ +from rhosocial.activerecord.backend.expression import CreateIndexExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -18,19 +19,33 @@ def setup_method(self): self.dialect = PostgresDialect(version=(14, 0, 0)) def test_format_gist_index(self): - """format_gist_index should return SQL containing gist.""" - result = self.dialect.format_gist_index("idx_name", "table_name", ["column"]) - assert "gist" in result - assert "idx_name" in result - assert "table_name" in result + """CreateIndexExpression with index_type gist should render a gist index.""" + expr = CreateIndexExpression( + self.dialect, + index_name="idx_name", + table_name="table_name", + columns=["column"], + index_type="gist", + ) + sql, params = expr.to_sql() + assert "gist" in sql + assert "idx_name" in sql + assert "table_name" in sql + assert params == () def test_format_gist_index_with_include(self): - """format_gist_index with include should include additional columns.""" - result = self.dialect.format_gist_index( - "idx_name", "table_name", ["column"], include=["extra_col"] + """CreateIndexExpression with include should include additional columns.""" + expr = CreateIndexExpression( + self.dialect, + index_name="idx_name", + table_name="table_name", + columns=["column"], + index_type="gist", + include=["extra_col"], ) - assert "gist" in result - assert "INCLUDE" in result + sql, _ = expr.to_sql() + assert "gist" in sql + assert "INCLUDE" in sql def test_format_btree_gist_operator_class(self): """format_btree_gist_operator_class should return operator class name.""" 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_partman.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py index 24a1a307..8f393855 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/extensions/test_pg_partman.py @@ -71,7 +71,7 @@ def test_format_auto_partition_config(self): parent_table='public.events', automatic_maintenance='on', ) - sql, params = dialect.format_pg_partman_update_config(expr) + sql, params = expr.to_sql() assert "part_config" in sql def test_format_delete_config(self): @@ -82,7 +82,7 @@ def test_format_delete_config(self): parent_table='public.events', schema='custom_partman', ) - sql, params = dialect.format_pg_partman_delete_config(expr) + sql, params = expr.to_sql() assert "DELETE FROM" in sql assert '"custom_partman"."part_config"' in sql assert "WHERE parent_table = %s" in sql 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_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_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..a7ffff4b 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 @@ -206,7 +206,7 @@ def test_using_clause(self, dialect): new_value="NUMERIC(10,2)", dialect_options={"using": Column(dialect, "price") + Literal(dialect, 1)}, ) - sql, serialized = dialect.format_alter_column_action(action) + sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql assert 'USING ("price" + %s)' in sql assert serialized == (1,) @@ -220,7 +220,7 @@ def test_using_clause_with_cascade(self, dialect): cascade=True, dialect_options={"using": Column(dialect, "price")}, ) - sql, serialized = dialect.format_alter_column_action(action) + sql, serialized = action.to_sql() assert 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' in sql assert 'USING ("price")' in sql assert sql.endswith(" CASCADE") @@ -235,7 +235,7 @@ def test_using_rejected_for_non_set_data_type(self, dialect): dialect_options={"using": Column(dialect, "price")}, ) with pytest.raises(ValueError, match="USING"): - dialect.format_alter_column_action(action) + action.to_sql() def test_without_using_unaffected(self, dialect): """No USING -> output matches the standard form.""" @@ -245,7 +245,7 @@ def test_without_using_unaffected(self, dialect): "SET DATA TYPE", new_value="NUMERIC(10,2)", ) - sql, _ = dialect.format_alter_column_action(action) + sql, _ = action.to_sql() assert sql == 'ALTER COLUMN "price" SET DATA TYPE NUMERIC(10,2)' @@ -263,52 +263,46 @@ def test_capabilities(self, dialect): def test_add_column(self, dialect): column = ColumnDefinition( - "email", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)], - ) - sql, params = dialect.format_add_column_action( - AddColumn(dialect, column) + dialect, + "email", TextType(dialect), + constraints=[ColumnConstraint(dialect, ColumnConstraintType.NOT_NULL)], ) + add = AddColumn(dialect, column) + sql, params = add.to_sql() assert sql == 'ADD COLUMN "email" TEXT NOT NULL' assert params == () def test_add_column_if_not_exists(self, dialect): - column = ColumnDefinition("email", TextType()) - sql, _ = dialect.format_add_column_action( - AddColumn(dialect, column, if_not_exists=True) - ) + column = ColumnDefinition(dialect, "email", TextType(dialect)) + add = AddColumn(dialect, column, if_not_exists=True) + sql, _ = add.to_sql() assert sql == 'ADD COLUMN IF NOT EXISTS "email" TEXT' def test_drop_column(self, dialect): - sql, _ = dialect.format_drop_column_action( - DropColumn(dialect, "email") - ) + drop = DropColumn(dialect, "email") + sql, _ = drop.to_sql() assert sql == 'DROP COLUMN "email"' def test_drop_column_if_exists(self, dialect): - sql, _ = dialect.format_drop_column_action( - DropColumn(dialect, "email", if_exists=True) - ) + drop = DropColumn(dialect, "email", if_exists=True) + sql, _ = drop.to_sql() assert sql == 'DROP COLUMN IF EXISTS "email"' def test_drop_constraint(self, dialect): - sql, _ = dialect.format_drop_table_constraint_action( - DropTableConstraint(dialect, "user_email_key") - ) + drop = DropTableConstraint(dialect, "user_email_key") + sql, _ = drop.to_sql() assert sql == 'DROP CONSTRAINT "user_email_key"' def test_drop_constraint_if_exists(self, dialect): - sql, _ = dialect.format_drop_table_constraint_action( - DropTableConstraint(dialect, "user_email_key", if_exists=True) - ) + drop = DropTableConstraint(dialect, "user_email_key", if_exists=True) + sql, _ = drop.to_sql() assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key"' def test_drop_constraint_if_exists_cascade(self, dialect): - sql, _ = dialect.format_drop_table_constraint_action( - DropTableConstraint( - dialect, "user_email_key", if_exists=True, cascade=True - ) + drop = DropTableConstraint( + dialect, "user_email_key", if_exists=True, cascade=True ) + sql, _ = drop.to_sql() assert sql == 'DROP CONSTRAINT IF EXISTS "user_email_key" CASCADE' 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..413024f0 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,8 +17,11 @@ from rhosocial.activerecord.backend.impl.postgres.expression.ddl import ( PostgresCreateEnumTypeExpression, PostgresDropEnumTypeExpression, + PostgresAlterEnumAddValueExpression, PostgresAlterEnumTypeAddValueExpression, PostgresAlterEnumTypeRenameValueExpression, + EnumTypeNameExpression, + EnumValuesExpression, ) @@ -437,31 +440,50 @@ class TestEnumTypeMixin: """Tests for EnumTypeMixin formatting methods.""" def test_format_enum_type_name(self): - """Test format_enum_type_name method.""" + """Test format_enum_type_name via EnumTypeNameExpression.""" dialect = PostgresDialect() - assert dialect.format_enum_type_name('status') == 'status' - assert dialect.format_enum_type_name('status', schema='app') == 'app.status' + sql, params = EnumTypeNameExpression(dialect, name='status').to_sql() + assert sql == 'status' + assert params == () + sql, params = EnumTypeNameExpression( + dialect, name='status', schema='app' + ).to_sql() + assert sql == 'app.status' + assert params == () def test_format_enum_values(self): - """Test format_enum_values method.""" + """Test format_enum_values via EnumValuesExpression.""" dialect = PostgresDialect() - result = dialect.format_enum_values(['a', 'b', 'c']) - assert result == "'a', 'b', 'c'" + sql, params = EnumValuesExpression( + dialect, values=['a', 'b', 'c'] + ).to_sql() + assert sql == "'a', 'b', 'c'" + assert params == () 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 = expr.to_sql() 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 = expr.to_sql() 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 = expr.to_sql() assert sql == "ALTER TYPE status ADD VALUE 'failed'" + assert params == () diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py index d2160cc7..88a3273a 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_extended_statistics.py @@ -72,7 +72,7 @@ def test_create_statistics_pg9_raises_error(self): table_name="test_table" ) with pytest.raises(ValueError, match="requires PostgreSQL 10"): - dialect.format_create_statistics_statement(expr) + expr.to_sql() def test_create_statistics_basic(self): """Test basic CREATE STATISTICS statement.""" @@ -83,7 +83,7 @@ def test_create_statistics_basic(self): columns=["col1", "col2"], table_name="test_table" ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert 'CREATE STATISTICS "test_stats"' in sql assert "ON col1, col2" in sql assert 'FROM "test_table"' in sql @@ -99,7 +99,7 @@ def test_create_statistics_with_schema(self): table_name="test_table", schema="public" ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert '"public"."test_stats"' in sql assert 'FROM "public"."test_table"' in sql @@ -113,7 +113,7 @@ def test_create_statistics_if_not_exists(self): table_name="test_table", if_not_exists=True ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert 'CREATE STATISTICS IF NOT EXISTS "test_stats"' in sql def test_create_statistics_with_ndistinct(self): @@ -126,7 +126,7 @@ def test_create_statistics_with_ndistinct(self): table_name="test_table", statistics_type="ndistinct" ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert '"test_stats"(ndistinct)' in sql def test_create_statistics_with_dependencies(self): @@ -139,7 +139,7 @@ def test_create_statistics_with_dependencies(self): table_name="test_table", statistics_type="dependencies" ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert '"test_stats"(dependencies)' in sql def test_create_statistics_with_mcv_pg12(self): @@ -152,7 +152,7 @@ def test_create_statistics_with_mcv_pg12(self): table_name="test_table", statistics_type="mcv" ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert '"test_stats"(mcv)' in sql def test_create_statistics_with_mcv_pg11_raises_error(self): @@ -166,7 +166,7 @@ def test_create_statistics_with_mcv_pg11_raises_error(self): statistics_type="mcv" ) with pytest.raises(ValueError, match="MCV statistics require PostgreSQL 12"): - dialect.format_create_statistics_statement(expr) + expr.to_sql() def test_create_statistics_invalid_type_raises_error(self): """Test CREATE STATISTICS with invalid type raises error.""" @@ -179,7 +179,7 @@ def test_create_statistics_invalid_type_raises_error(self): statistics_type="invalid" ) with pytest.raises(ValueError, match="Invalid statistics type"): - dialect.format_create_statistics_statement(expr) + expr.to_sql() class TestFormatDropStatisticsStatement: @@ -192,7 +192,7 @@ def test_drop_statistics_basic(self): dialect, name="test_stats" ) - sql, params = dialect.format_drop_statistics_statement(expr) + sql, params = expr.to_sql() assert sql == 'DROP STATISTICS "test_stats"' assert params == () @@ -204,7 +204,7 @@ def test_drop_statistics_with_schema(self): name="test_stats", schema="public" ) - sql, params = dialect.format_drop_statistics_statement(expr) + sql, params = expr.to_sql() assert 'DROP STATISTICS "public"."test_stats"' in sql def test_drop_statistics_if_exists(self): @@ -215,5 +215,5 @@ def test_drop_statistics_if_exists(self): name="test_stats", if_exists=True ) - sql, params = dialect.format_drop_statistics_statement(expr) + sql, params = expr.to_sql() assert 'DROP STATISTICS IF EXISTS "test_stats"' in sql diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py index d5ccf14f..0e822d93 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py @@ -1,7 +1,10 @@ # tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_ilike_support.py """Tests for ILIKE protocol support in PostgreSQL dialect.""" -import pytest # noqa: F401 +from rhosocial.activerecord.backend.expression.core import Column from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.impl.postgres.expression.ilike import ( + ILIKEExpression, +) from rhosocial.activerecord.backend.dialect.protocols import ILIKESupport @@ -21,63 +24,88 @@ def test_supports_ilike(self): def test_format_ilike_expression_basic(self): """Test basic ILIKE expression formatting.""" dialect = PostgresDialect() - sql, params = dialect.format_ilike_expression("username", "%smith%") - + expr = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="%smith%" + ) + sql, params = expr.to_sql() + assert sql == '"username" ILIKE %s' assert params == ('%smith%',) def test_format_ilike_expression_with_negate(self): """Test NOT ILIKE expression formatting.""" dialect = PostgresDialect() - sql, params = dialect.format_ilike_expression("username", "%smith%", negate=True) - + expr = ILIKEExpression( + dialect, + column=Column(dialect, "username"), + pattern="%smith%", + negate=True, + ) + sql, params = expr.to_sql() + assert sql == '"username" NOT ILIKE %s' assert params == ('%smith%',) def test_format_ilike_expression_case_insensitive(self): """Test ILIKE is truly case-insensitive.""" dialect = PostgresDialect() - + # ILIKE should handle case differences automatically - sql, params = dialect.format_ilike_expression("username", "%SMITH%") + sql, params = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="%SMITH%" + ).to_sql() assert sql == '"username" ILIKE %s' assert params == ('%SMITH%',) - + # Pattern is passed as-is to ILIKE, which handles case-insensitivity - sql, params = dialect.format_ilike_expression("username", "%Smith%") + _, params = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="%Smith%" + ).to_sql() assert params == ('%Smith%',) def test_format_ilike_expression_with_wildcards(self): """Test ILIKE with various wildcard patterns.""" dialect = PostgresDialect() - + # Test with % wildcard - sql, params = dialect.format_ilike_expression("email", "%@example.com") + sql, params = ILIKEExpression( + dialect, column=Column(dialect, "email"), pattern="%@example.com" + ).to_sql() assert sql == '"email" ILIKE %s' assert params == ('%@example.com',) - + # Test with _ wildcard - sql, params = dialect.format_ilike_expression("username", "user_") + sql, params = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="user_" + ).to_sql() assert sql == '"username" ILIKE %s' assert params == ('user_',) - + # Test with mixed wildcards - sql, params = dialect.format_ilike_expression("username", "%user_%") + sql, params = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="%user_%" + ).to_sql() assert sql == '"username" ILIKE %s' assert params == ('%user_%',) def test_format_ilike_expression_with_special_characters(self): """Test ILIKE with special characters in pattern.""" dialect = PostgresDialect() - + # Test with hyphen - sql, params = dialect.format_ilike_expression("username", "user-name%") + _, params = ILIKEExpression( + dialect, column=Column(dialect, "username"), pattern="user-name%" + ).to_sql() assert params == ('user-name%',) - + # Test with dot - sql, params = dialect.format_ilike_expression("email", "%.com") + _, params = ILIKEExpression( + dialect, column=Column(dialect, "email"), pattern="%.com" + ).to_sql() assert params == ('%.com',) - + # Test with spaces - sql, params = dialect.format_ilike_expression("name", "%John Doe%") + _, params = ILIKEExpression( + dialect, column=Column(dialect, "name"), pattern="%John Doe%" + ).to_sql() assert params == ('%John Doe%',) 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..75d08005 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 @@ -129,7 +129,7 @@ def test_reindex_index_basic(self): target_type="INDEX", name="idx_users_email" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX INDEX "idx_users_email"' in sql assert params == () @@ -141,7 +141,7 @@ def test_reindex_table(self): target_type="TABLE", name="users" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX TABLE "users"' in sql def test_reindex_schema(self): @@ -152,7 +152,7 @@ def test_reindex_schema(self): target_type="SCHEMA", name="public" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX SCHEMA "public"' in sql def test_reindex_database(self): @@ -163,7 +163,7 @@ def test_reindex_database(self): target_type="DATABASE", name="mydb" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX DATABASE "mydb"' in sql def test_reindex_system(self): @@ -174,7 +174,7 @@ def test_reindex_system(self): target_type="SYSTEM", name="mydb" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX SYSTEM "mydb"' in sql def test_reindex_invalid_target_type(self): @@ -186,7 +186,7 @@ def test_reindex_invalid_target_type(self): name="test" ) with pytest.raises(ValueError, match="Invalid target_type"): - dialect.format_reindex_statement(expr) + expr.to_sql() def test_reindex_concurrently_pg11_raises_error(self): """REINDEX CONCURRENTLY should raise error on PostgreSQL 11.""" @@ -198,7 +198,7 @@ def test_reindex_concurrently_pg11_raises_error(self): concurrently=True ) with pytest.raises(ValueError, match="requires PostgreSQL 12"): - dialect.format_reindex_statement(expr) + expr.to_sql() def test_reindex_concurrently_pg12(self): """Test REINDEX CONCURRENTLY on PostgreSQL 12.""" @@ -209,7 +209,7 @@ def test_reindex_concurrently_pg12(self): name="idx_test", concurrently=True ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'REINDEX CONCURRENTLY INDEX "idx_test"' in sql def test_reindex_with_tablespace(self): @@ -221,7 +221,7 @@ def test_reindex_with_tablespace(self): name="idx_test", tablespace="pg_fast" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert 'TABLESPACE "pg_fast"' in sql def test_reindex_verbose(self): @@ -233,7 +233,7 @@ def test_reindex_verbose(self): name="idx_test", verbose=True ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert "VERBOSE" in sql def test_reindex_with_schema(self): @@ -245,7 +245,7 @@ def test_reindex_with_schema(self): name="idx_test", schema="public" ) - sql, params = dialect.format_reindex_statement(expr) + sql, params = expr.to_sql() assert '"public"' in sql @@ -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 = expr.to_sql() 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 = expr.to_sql() 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 = expr.to_sql() 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 = expr.to_sql() 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 = expr.to_sql() assert " || " in sql assert "to_tsvector" in sql assert params == ("search",) @@ -543,7 +568,7 @@ def test_format_create_fulltext_index_statement_uses_gin(self): table_name="articles", columns=["body"], ) - sql, params = dialect.format_create_fulltext_index_statement(expr) + sql, params = expr.to_sql() assert sql.startswith("CREATE INDEX") assert "USING GIN" in sql assert "to_tsvector" in sql @@ -559,7 +584,7 @@ def test_format_create_fulltext_index_statement_if_not_exists(self): columns=["body"], if_not_exists=True, ) - sql, _ = dialect.format_create_fulltext_index_statement(expr) + sql, _ = expr.to_sql() assert "IF NOT EXISTS" in sql def test_format_create_fulltext_index_statement_multi_column(self): @@ -570,7 +595,7 @@ def test_format_create_fulltext_index_statement_multi_column(self): table_name="articles", columns=["title", "body"], ) - sql, _ = dialect.format_create_fulltext_index_statement(expr) + sql, _ = expr.to_sql() assert " || " in sql assert "to_tsvector" in sql assert "title" in sql and "body" in sql @@ -582,7 +607,7 @@ def test_format_drop_fulltext_index_statement(self): index_name="idx_ft", table_name="articles", ) - sql, _ = dialect.format_drop_fulltext_index_statement(expr) + sql, _ = expr.to_sql() assert sql.startswith("DROP INDEX") assert "idx_ft" in sql @@ -594,7 +619,7 @@ def test_format_drop_fulltext_index_statement_if_exists(self): table_name="articles", if_exists=True, ) - sql, _ = dialect.format_drop_fulltext_index_statement(expr) + sql, _ = expr.to_sql() assert "IF EXISTS" in sql @@ -789,15 +814,15 @@ 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) + add.to_sql() def test_format_drop_index_action_raises(self): d = PostgresDialect() drop = DropIndexAction(d, index_name="idx_test") with pytest.raises(UnsupportedFeatureError, match="ALTER TABLE DROP INDEX"): - d.format_drop_index_action(drop) + drop.to_sql() class TestDialectIndexSupport: @@ -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_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_set_operation_and_truncate.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py index 4269b5a5..e704c65d 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_set_operation_and_truncate.py @@ -94,7 +94,7 @@ def test_format_truncate_statement_basic(self): dialect = PostgresDialect() expr = TruncateExpression(dialect, table_name="users") - sql, params = dialect.format_truncate_statement(expr) + sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE "users"' assert params == () @@ -105,7 +105,7 @@ def test_format_truncate_statement_with_restart_identity(self): dialect = PostgresDialect(version=(9, 0, 0)) expr = TruncateExpression(dialect, table_name="users", restart_identity=True) - sql, params = dialect.format_truncate_statement(expr) + sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE "users" RESTART IDENTITY' assert params == () @@ -116,7 +116,7 @@ def test_format_truncate_statement_with_cascade(self): dialect = PostgresDialect() expr = TruncateExpression(dialect, table_name="orders", cascade=True) - sql, params = dialect.format_truncate_statement(expr) + sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE "orders" CASCADE' assert params == () @@ -132,20 +132,21 @@ def test_format_truncate_statement_with_restart_identity_and_cascade(self): restart_identity=True, cascade=True ) - sql, params = dialect.format_truncate_statement(expr) + sql, params = expr.to_sql() assert sql == 'TRUNCATE TABLE "orders" RESTART IDENTITY CASCADE' assert params == () def test_format_truncate_statement_restart_identity_unsupported_version(self): - """Test TRUNCATE with RESTART IDENTITY on unsupported version (should be ignored).""" + """Test TRUNCATE with RESTART IDENTITY on unsupported version raises error.""" + import pytest from rhosocial.activerecord.backend.expression.statements import TruncateExpression + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError # PostgreSQL 8.3 does not support RESTART IDENTITY dialect = PostgresDialect(version=(8, 3, 0)) expr = TruncateExpression(dialect, table_name="users", restart_identity=True) - sql, params = dialect.format_truncate_statement(expr) - # RESTART IDENTITY should be ignored on unsupported version - assert sql == 'TRUNCATE TABLE "users"' - assert params == () + # RESTART IDENTITY should raise UnsupportedFeatureError on unsupported version + with pytest.raises(UnsupportedFeatureError, match="RESTART IDENTITY"): + expr.to_sql() diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py index 46f159bc..dcced9f6 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_table.py @@ -1,5 +1,5 @@ from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect -from rhosocial.activerecord.backend.impl.postgres.mixins.table import PostgresTableMixin +from rhosocial.activerecord.backend.impl.postgres.mixins.ddl_table import PostgresTableMixin class TestTableSupport: diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py index e936a5ae..4e080def 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/postgres/test_trigger.py @@ -87,7 +87,7 @@ def test_create_trigger_basic_before(self): events=[TriggerEvent.UPDATE], function_name="update_updated_at_column" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert 'CREATE TRIGGER "update_timestamp"' in sql assert "BEFORE UPDATE" in sql assert 'ON "users"' in sql @@ -104,7 +104,7 @@ def test_create_trigger_basic_after(self): events=[TriggerEvent.INSERT], function_name="log_user_insert" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "AFTER INSERT" in sql def test_create_trigger_multiple_events(self): @@ -118,7 +118,7 @@ def test_create_trigger_multiple_events(self): events=[TriggerEvent.INSERT, TriggerEvent.UPDATE, TriggerEvent.DELETE], function_name="audit_function" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "INSERT OR UPDATE OR DELETE" in sql def test_create_trigger_update_of(self): @@ -133,7 +133,7 @@ def test_create_trigger_update_of(self): update_columns=["status", "updated_at"], function_name="validate_status_change" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert 'UPDATE OF "status", "updated_at"' in sql def test_create_trigger_with_level_row(self): @@ -148,7 +148,7 @@ def test_create_trigger_with_level_row(self): function_name="test_func", level=TriggerLevel.ROW ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "FOR EACH ROW" in sql def test_create_trigger_with_level_statement(self): @@ -163,7 +163,7 @@ def test_create_trigger_with_level_statement(self): function_name="test_func", level=TriggerLevel.STATEMENT ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "FOR EACH STATEMENT" in sql def test_create_trigger_if_not_exists(self): @@ -178,7 +178,7 @@ def test_create_trigger_if_not_exists(self): function_name="test_func", if_not_exists=True ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert 'CREATE TRIGGER IF NOT EXISTS "test_trigger"' in sql def test_create_trigger_with_referencing_pg10(self): @@ -193,7 +193,7 @@ def test_create_trigger_with_referencing_pg10(self): function_name="test_func", referencing="OLD TABLE AS old NEW TABLE AS new" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "OLD TABLE AS old NEW TABLE AS new" in sql def test_create_trigger_no_level(self): @@ -209,7 +209,7 @@ def test_create_trigger_no_level(self): function_name="simple_func", level=None, ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "FOR EACH" not in sql def test_create_trigger_with_condition(self): @@ -226,7 +226,7 @@ def test_create_trigger_with_condition(self): function_name="validate_status", condition=condition, ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "WHEN" in sql assert "%s" in sql assert params == ("ACTIVE",) @@ -242,7 +242,7 @@ def test_create_trigger_instead_of(self): events=[TriggerEvent.INSERT], function_name="handle_view_insert" ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert "INSTEAD OF INSERT" in sql @@ -256,7 +256,7 @@ def test_drop_trigger_basic(self): dialect, trigger_name="test_trigger" ) - sql, params = dialect.format_drop_trigger_statement(expr) + sql, params = expr.to_sql() assert 'DROP TRIGGER "test_trigger"' in sql def test_drop_trigger_with_table(self): @@ -267,7 +267,7 @@ def test_drop_trigger_with_table(self): trigger_name="test_trigger", table_name="users" ) - sql, params = dialect.format_drop_trigger_statement(expr) + sql, params = expr.to_sql() assert 'ON "users"' in sql def test_drop_trigger_if_exists(self): @@ -278,7 +278,7 @@ def test_drop_trigger_if_exists(self): trigger_name="test_trigger", if_exists=True ) - sql, params = dialect.format_drop_trigger_statement(expr) + sql, params = expr.to_sql() assert "DROP TRIGGER IF EXISTS" in sql 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_auto_increment_ddl.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_auto_increment_ddl.py index 840f5139..50dd237e 100644 --- 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 @@ -26,22 +26,22 @@ def dialect(): return PostgresDialect() -def _pk(): - return [ColumnConstraint(ColumnConstraintType.PRIMARY_KEY, is_auto_increment=True)] +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("id", IntegerType(), constraints=_pk()), - ColumnDefinition("name", TextType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL)]), - ColumnDefinition("flag", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), - ColumnDefinition("code", VarCharType(16)), - ColumnDefinition("created_at", TimestampType()), + 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)), ], ) @@ -49,12 +49,12 @@ def _build_table(dialect): class TestAutoIncrementDDL: def test_auto_increment_in_ddl(self, dialect): - sql, _ = dialect.format_create_table_statement(_build_table(dialect)) + sql, _ = _build_table(dialect).to_sql() 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)) + sql, params = _build_table(dialect).to_sql() assert isinstance(sql, str) assert isinstance(params, tuple) @@ -62,22 +62,22 @@ def test_boolean_default_true(self, dialect): expr = CreateTableExpression( dialect=dialect, table="bool_test", columns=[ - ColumnDefinition("id", IntegerType(), constraints=_pk()), - ColumnDefinition("flag", BooleanType(), - constraints=[ColumnConstraint(ColumnConstraintType.NOT_NULL), - ColumnConstraint(ColumnConstraintType.DEFAULT, default_value=True)]), + 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) + sql, _ = expr.to_sql() assert "BOOLEAN" in sql.upper() def test_timestamp_type(self, dialect): expr = CreateTableExpression( dialect=dialect, table="ts_test", columns=[ - ColumnDefinition("id", IntegerType(), constraints=_pk()), - ColumnDefinition("created_at", TimestampType()), + ColumnDefinition(dialect, "id", IntegerType(dialect=dialect), constraints=_pk(dialect)), + ColumnDefinition(dialect, "created_at", TimestampType(dialect=dialect)), ], ) - sql, _ = dialect.format_create_table_statement(expr) + sql, _ = expr.to_sql() assert "TIMESTAMP" in sql.upper() 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..9b483a2a 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 @@ -4,13 +4,12 @@ This module tests the PostgreSQL-specific LIKE syntax for CREATE TABLE statements, including INCLUDING/EXCLUDING options. + +The LIKE form is modelled by :class:`CreateTableLikeExpression` and rendered by +the dialect's ``format_create_table_like_statement`` into PostgreSQL's clause +form ``CREATE TABLE t (LIKE src [INCLUDING ...])``. """ -import pytest # noqa: F401 -from rhosocial.activerecord.backend.expression import CreateTableExpression, ColumnDefinition -from rhosocial.activerecord.backend.expression.statements import ColumnConstraint, ColumnConstraintType -from rhosocial.activerecord.backend.expression.types import ( - IntegerType, VarCharType, -) +from rhosocial.activerecord.backend.expression import CreateTableLikeExpression from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect @@ -20,13 +19,8 @@ class TestPostgreSQLCreateTableLike: def test_basic_like_syntax(self): """Test basic CREATE TABLE ... LIKE syntax.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, - table="users_copy", - columns=[], - dialect_options={'like_table': 'users'} - ) - sql, params = create_expr.to_sql() + expr = CreateTableLikeExpression(dialect, table="users_copy", like_table="users") + sql, params = expr.to_sql() assert sql == 'CREATE TABLE "users_copy" (LIKE "users")' assert params == () @@ -34,142 +28,147 @@ def test_basic_like_syntax(self): def test_like_with_including_defaults(self): """Test LIKE with INCLUDING DEFAULTS option.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['DEFAULTS']} - } + like_table="users", + like_options={'including': ['defaults']}, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users" INCLUDING DEFAULTS)' assert params == () def test_like_with_including_constraints(self): """Test LIKE with INCLUDING CONSTRAINTS option.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['CONSTRAINTS']} - } + like_table="users", + like_options={'including': ['constraints']}, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING CONSTRAINTS)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users" INCLUDING CONSTRAINTS)' assert params == () def test_like_with_including_indexes(self): """Test LIKE with INCLUDING INDEXES option.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['INDEXES']} - } + like_table="users", + like_options={'including': ['indexes']}, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING INDEXES)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users" INCLUDING INDEXES)' assert params == () def test_like_with_multiple_including_options(self): """Test LIKE with multiple INCLUDING options (dictionary format).""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS', 'INDEXES'] - } - } + like_table="users", + like_options={'including': ['defaults', 'constraints', 'indexes']}, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, INCLUDING INDEXES)' # noqa: E501 + assert sql == ( + 'CREATE TABLE "users_copy" (LIKE "users" ' + 'INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING INDEXES)' + ) assert params == () def test_like_with_including_and_excluding_options(self): """Test LIKE with both INCLUDING and EXCLUDING options.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': { - 'including': ['DEFAULTS', 'CONSTRAINTS'], - 'excluding': ['INDEXES', 'COMMENTS'] - } - } + like_table="users", + like_options={ + 'including': ['defaults', 'constraints'], + 'excluding': ['indexes', 'comments'], + }, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS, EXCLUDING INDEXES, EXCLUDING COMMENTS)' # noqa: E501 + assert sql == ( + 'CREATE TABLE "users_copy" (LIKE "users" ' + 'INCLUDING DEFAULTS INCLUDING CONSTRAINTS ' + 'EXCLUDING INDEXES EXCLUDING COMMENTS)' + ) assert params == () def test_like_with_list_format_options(self): - """Test LIKE with options in list format (backwards compatibility).""" + """Test LIKE with options in list format (plain strings => INCLUDING).""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': ['DEFAULTS', 'CONSTRAINTS'] - } + like_table="users", + like_options=['defaults', 'constraints'], ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - # Should default to INCLUDING when just feature name is provided - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, INCLUDING CONSTRAINTS)' + assert sql == ( + 'CREATE TABLE "users_copy" (LIKE "users" ' + 'INCLUDING DEFAULTS INCLUDING CONSTRAINTS)' + ) assert params == () def test_like_with_tuple_format_options(self): - """Test LIKE with options in tuple format (backwards compatibility).""" + """Test LIKE with options in (action, feature) tuple format.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': [ - ('INCLUDING', 'DEFAULTS'), - ('EXCLUDING', 'INDEXES') - ] - } + like_table="users", + like_options=[ + ('INCLUDING', 'DEFAULTS'), + ('EXCLUDING', 'INDEXES'), + ], ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING DEFAULTS, EXCLUDING INDEXES)' + assert sql == ( + 'CREATE TABLE "users_copy" (LIKE "users" ' + 'INCLUDING DEFAULTS EXCLUDING INDEXES)' + ) + assert params == () + + def test_like_with_lowercase_option_names(self): + """Option feature names are upper-cased in the rendered clause.""" + dialect = PostgresDialect() + expr = CreateTableLikeExpression( + dialect, + table="users_copy", + like_table="users", + like_options={'including': ['comments', 'defaults']}, + ) + sql, params = expr.to_sql() + + assert sql == ( + 'CREATE TABLE "users_copy" (LIKE "users" ' + 'INCLUDING COMMENTS INCLUDING DEFAULTS)' + ) assert params == () def test_like_with_if_not_exists(self): """Test CREATE TABLE ... LIKE with IF NOT EXISTS.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], + like_table="users", if_not_exists=True, - dialect_options={'like_table': 'users'} ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() assert sql == 'CREATE TABLE IF NOT EXISTS "users_copy" (LIKE "users")' assert params == () @@ -177,14 +176,13 @@ def test_like_with_if_not_exists(self): def test_like_with_temporary(self): """Test CREATE TEMPORARY TABLE ... LIKE.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="temp_users", - columns=[], + like_table="users", temporary=True, - dialect_options={'like_table': 'users'} ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() assert sql == 'CREATE TEMPORARY TABLE "temp_users" (LIKE "users")' assert params == () @@ -192,93 +190,49 @@ def test_like_with_temporary(self): def test_like_with_schema_qualified_table(self): """Test CREATE TABLE ... LIKE with schema-qualified source table.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={'like_table': ('public', 'users')} + like_table=('public', 'users'), ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() assert sql == 'CREATE TABLE "users_copy" (LIKE "public"."users")' assert params == () - 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("name", VarCharType(255)) - ] - create_expr = CreateTableExpression( - dialect=dialect, - table="users_copy", - columns=columns, - dialect_options={'like_table': 'users'} - ) - sql, params = create_expr.to_sql() - - # LIKE syntax should take precedence, columns should be ignored - assert sql == 'CREATE TABLE "users_copy" (LIKE "users")' - assert params == () - def test_like_with_temporary_and_if_not_exists(self): """Test CREATE TEMPORARY TABLE ... LIKE with IF NOT EXISTS.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="temp_users_copy", - columns=[], + like_table=('public', 'users'), temporary=True, if_not_exists=True, - dialect_options={'like_table': ('public', 'users')} ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users_copy" (LIKE "public"."users")' + assert sql == ( + 'CREATE TEMPORARY TABLE IF NOT EXISTS "temp_users_copy" ' + '(LIKE "public"."users")' + ) assert params == () def test_like_with_all_option(self): """Test LIKE with INCLUDING ALL option.""" dialect = PostgresDialect() - create_expr = CreateTableExpression( - dialect=dialect, + expr = CreateTableLikeExpression( + dialect, table="users_copy", - columns=[], - dialect_options={ - 'like_table': 'users', - 'like_options': {'including': ['ALL']} - } + like_table="users", + like_options={'including': ['all']}, ) - sql, params = create_expr.to_sql() + sql, params = expr.to_sql() - assert sql == 'CREATE TABLE "users_copy" (LIKE "users", INCLUDING ALL)' + assert sql == 'CREATE TABLE "users_copy" (LIKE "users" INCLUDING ALL)' assert params == () - def test_fallback_to_base_when_no_like(self): - """Test that base implementation is used when LIKE is not specified.""" + def test_supports_create_table_like(self): + """PostgreSQL advertises CREATE TABLE ... (LIKE ...) support.""" dialect = PostgresDialect() - columns = [ - ColumnDefinition("id", IntegerType(), constraints=[ - ColumnConstraint(ColumnConstraintType.PRIMARY_KEY) - ]), - ColumnDefinition("name", VarCharType(255), constraints=[ - ColumnConstraint(ColumnConstraintType.NOT_NULL) - ]) - ] - create_expr = CreateTableExpression( - dialect=dialect, - table="users", - columns=columns - ) - sql, params = create_expr.to_sql() - - # Should use base implementation - assert "CREATE TABLE" in sql - assert '"users"' in sql - assert '"id"' in sql - assert '"name"' in sql - assert "PRIMARY KEY" in sql - assert "NOT NULL" in sql + assert dialect.supports_create_table_like() is True 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 index 9234a7a3..15c816cf 100644 --- 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 @@ -32,7 +32,7 @@ def for_update(d): from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( PostgresForUpdateExpression, ) - return PostgresForUpdateExpression(d, columns=[Column(d, "id")]) + return PostgresForUpdateExpression(d, columns=[Column(dialect, d, "id")]) def advisory(d): from rhosocial.activerecord.backend.impl.postgres.expression.advisory.lock import ( 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_ddl_improvements.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py new file mode 100644 index 00000000..fda7eeb0 --- /dev/null +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py @@ -0,0 +1,81 @@ +# tests/rhosocial/activerecord_postgres_test/feature/backend/test_postgres_ddl_improvements.py +"""Tests for PostgreSQL DDL improvements: capability gating, UnsupportedFeatureError.""" +import pytest +from unittest.mock import patch, PropertyMock + +from rhosocial.activerecord.backend.expression import ( + Column, + TableExpression, + QueryExpression, + CreateViewExpression, + DropViewExpression, +) +from rhosocial.activerecord.backend.impl.postgres.dialect import PostgresDialect +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + +class TestPostgresViewCapabilityGating: + """Tests for PostgreSQL VIEW DDL capability gating.""" + + def test_create_or_replace_view_supported(self): + """PostgreSQL supports CREATE OR REPLACE VIEW.""" + dialect = PostgresDialect() + assert dialect.supports_create_or_replace_view() is True + + def test_drop_view_if_exists_supported(self): + """PostgreSQL supports DROP VIEW IF EXISTS.""" + dialect = PostgresDialect() + assert dialect.supports_if_exists_view() is True + + def test_drop_view_cascade_supported(self): + """PostgreSQL supports DROP VIEW CASCADE.""" + dialect = PostgresDialect() + assert dialect.supports_cascade_view() is True + + def test_materialized_view_supported(self): + """PostgreSQL supports materialized views.""" + dialect = PostgresDialect() + assert dialect.supports_materialized_view() is True + + +class TestPostgresColumnCapabilityGating: + """Tests for PostgreSQL COLUMN DDL capability gating.""" + + def test_foreign_key_on_delete_supported(self): + """PostgreSQL supports FK ON DELETE.""" + dialect = PostgresDialect() + assert dialect.supports_foreign_key_on_delete() is True + + def test_foreign_key_on_update_supported(self): + """PostgreSQL supports FK ON UPDATE.""" + dialect = PostgresDialect() + assert dialect.supports_foreign_key_on_update() is True + + +class TestPostgresSchemaCapabilityGating: + """Tests for PostgreSQL SCHEMA DDL capability gating.""" + + def test_create_schema_supported(self): + """PostgreSQL supports CREATE SCHEMA.""" + dialect = PostgresDialect() + assert dialect.supports_create_schema() is True + + def test_drop_schema_supported(self): + """PostgreSQL supports DROP SCHEMA.""" + dialect = PostgresDialect() + assert dialect.supports_drop_schema() is True + + def test_schema_if_not_exists_supported(self): + """PostgreSQL supports CREATE SCHEMA IF NOT EXISTS.""" + dialect = PostgresDialect() + assert dialect.supports_schema_if_not_exists() is True + + def test_schema_if_exists_supported(self): + """PostgreSQL supports DROP SCHEMA IF EXISTS.""" + dialect = PostgresDialect() + assert dialect.supports_schema_if_exists() is True + + def test_schema_cascade_supported(self): + """PostgreSQL supports DROP SCHEMA CASCADE.""" + dialect = PostgresDialect() + assert dialect.supports_schema_cascade() is True 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 bd13795b..60aeae37 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,12 @@ 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 rhosocial.activerecord.backend.impl.postgres.expression.ddl import PartitionValue from typing import Tuple, Any # noqa: F401 @@ -44,11 +50,12 @@ 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) + sql, params = col_def.to_sql() assert "VARCHAR(255)" in sql @@ -56,6 +63,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 +72,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", ) @@ -75,21 +84,24 @@ def test_postgres_format_default_constraint_string_escaping(dialect): def test_postgres_format_storage_options_string_escaping(dialect): """Test storage options string values are escaped.""" - storage_opts = {"key": "value's"} + from rhosocial.activerecord.backend.expression.statements import StorageOptionsExpression + storage_opts = StorageOptionsExpression(dialect, {"key": "value's"}) sql, params = dialect.format_storage_options(storage_opts) assert "value''s" in sql assert "'; DROP" not in sql 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 +123,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 +140,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 +156,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 +171,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={ @@ -319,7 +335,7 @@ def test_trigger_function_name_quoted(self, dialect): function_name="my_function", ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert '"my_function"' in sql @@ -340,7 +356,7 @@ def test_trigger_function_name_special_chars_quoted(self, dialect): function_name="Function With Spaces", ) - sql, params = dialect.format_create_trigger_statement(expr) + sql, params = expr.to_sql() assert '"Function With Spaces"' in sql @@ -362,7 +378,7 @@ def test_create_statistics_name_quoted(self, dialect): statistics_type="ndistinct", ) - sql, params = dialect.format_create_statistics_statement(expr) + sql, params = expr.to_sql() assert '"my_stats"' in sql @@ -377,7 +393,7 @@ def test_drop_statistics_name_quoted(self, dialect): name="my_stats", ) - sql, params = dialect.format_drop_statistics_statement(expr) + sql, params = expr.to_sql() assert '"my_stats"' in sql @@ -386,57 +402,58 @@ def test_drop_statistics_name_quoted(self, dialect): # format_partition_value — single-quote escaping # ============================================================ -def _format_partition_value(dialect, value): - from rhosocial.activerecord.backend.impl.postgres.expression.ddl import PartitionValue - - sql, params = dialect.format_partition_value(PartitionValue(dialect=dialect, value=value)) - assert params == () - return sql - - def test_partition_value_none(dialect): """None partition value returns NULL.""" - result = _format_partition_value(dialect, None) - assert result == "NULL" + sql, params = PartitionValue(dialect=dialect, value=None).to_sql() + assert params == () + assert sql == "NULL" def test_partition_value_maxvalue(dialect): """MAXVALUE is returned as-is (case-insensitive).""" - result = _format_partition_value(dialect, "MAXVALUE") - assert result == "MAXVALUE" + sql, params = PartitionValue(dialect=dialect, value="MAXVALUE").to_sql() + assert params == () + assert sql == "MAXVALUE" def test_partition_value_minvalue(dialect): """MINVALUE is returned as-is (case-insensitive).""" - result = _format_partition_value(dialect, "minvalue") - assert result == "MINVALUE" + sql, params = PartitionValue(dialect=dialect, value="minvalue").to_sql() + assert params == () + assert sql == "MINVALUE" def test_partition_value_normal_string(dialect): """Normal string value is single-quoted.""" - result = _format_partition_value(dialect, "2024-01-01") - assert result == "'2024-01-01'" + sql, params = PartitionValue(dialect=dialect, value="2024-01-01").to_sql() + assert params == () + assert sql == "'2024-01-01'" def test_partition_value_escaped_single_quote(dialect): """String value with single quote is properly escaped.""" - result = _format_partition_value(dialect, "it's") - assert result == "'it''s'" - assert "'; DROP" not in result + sql, params = PartitionValue(dialect=dialect, value="it's").to_sql() + assert params == () + assert sql == "'it''s'" + assert "'; DROP" not in sql def test_partition_value_injection_blocked(dialect): """SQL injection in partition value is safely escaped (inside quotes).""" - result = _format_partition_value(dialect, "x'; DROP TABLE users--") - assert result.count("'") % 2 == 0 - assert result.startswith("'") - assert result.endswith("'") + sql, params = PartitionValue( + dialect=dialect, value="x'; DROP TABLE users--" + ).to_sql() + assert params == () + assert sql.count("'") % 2 == 0 + assert sql.startswith("'") + assert sql.endswith("'") def test_partition_value_integer(dialect): """Integer partition value is returned as str().""" - result = _format_partition_value(dialect, 42) - assert result == "42" + sql, params = PartitionValue(dialect=dialect, value=42).to_sql() + assert params == () + assert sql == "42" # ============================================================ @@ -503,34 +520,43 @@ def test_format_identifier_empty_string(dialect): 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 = d.format_binary_operator("%", "a", "b", (), ()) + 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 = d.format_binary_operator("%%", "a", "b", (), ()) + sql, params = binop("%%", "a", "b") assert sql == "a %%%% b", f"raw %% should be escaped to %%%%, got: {sql}" # Operator without % → unchanged - sql, params = d.format_binary_operator("=", "a", "b", (), ()) + sql, params = binop("=", "a", "b") assert sql == "a = b" assert params == () # ? operator (hstore/jsonb) → preserved as-is, not treated as placeholder - sql, params = d.format_binary_operator("?", "data", "%s", (), ("key",)) + sql, params = binop("?", "data", "%s", ("key",)) assert sql == "data ? %s", f"? operator preserved: {sql}" assert params == ("key",) # ?| and ?& operators → preserved - sql, params = d.format_binary_operator("?|", "data", "%s", (), ("key",)) + sql, params = binop("?|", "data", "%s", ("key",)) assert sql == "data ?| %s", f"?| operator preserved: {sql}" # %# (pg_trgm) → %# (unchanged, no % to escape) - sql, params = d.format_binary_operator("%#", "a", "b", (), ()) + sql, params = binop("%#", "a", "b") assert sql == "a %%# b", f"%# operator: {sql}" 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..93b913ff 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,64 @@ 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 --- + # The generic DatabaseSupport protocol is not composed by PostgresDialect. + dialect_protocols.DatabaseSupport, + # 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.""" @@ -230,6 +296,9 @@ def test_no_interface_overlap_between_protocols(self): ('PostgresSetOperationSupport', 'SetOperationSupport'), ('ViewSupport', 'PostgresViewSupport'), ('PostgresViewSupport', 'ViewSupport'), + # PG materialized-view protocol restates the generic view formatter + ('ViewSupport', 'PostgresMaterializedViewSupport'), + ('PostgresMaterializedViewSupport', 'ViewSupport'), ('SchemaSupport', 'PostgresSchemaSupport'), ('PostgresSchemaSupport', 'SchemaSupport'), ('SequenceSupport', 'PostgresSequenceSupport'), @@ -253,6 +322,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 +554,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 @@ -490,6 +567,8 @@ class TestProtocolMethodSignatureConformance: ('ViewSupport', 'format_refresh_materialized_view_statement'), # ExplainSupport: Mixin uses different signature ('ExplainSupport', 'format_explain_statement'), + # ILIKESupport: Mixin uses expr-based signature instead of named params + ('ILIKESupport', 'format_ilike_expression'), } @pytest.fixture diff --git a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py index 19325d28..8a3ad16f 100644 --- a/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py +++ b/tests/rhosocial/activerecord_postgres_test/feature/backend/test_row_level_locks.py @@ -18,10 +18,7 @@ import pytest import pytest_asyncio # noqa: F401 -from rhosocial.activerecord.backend.expression.query_parts import ForUpdateClause -from rhosocial.activerecord.backend.impl.postgres.expression.locking import ( - LockStrength, -) +from rhosocial.activerecord.backend.expression import ForUpdateClause, LockStrength from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -79,7 +76,7 @@ def test_for_share_basic(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE) sql, params = clause.to_sql() assert sql == "FOR SHARE" assert params == () @@ -90,7 +87,7 @@ def test_for_no_key_update_basic(self, postgres_backend_single): if not dialect.supports_for_no_key_update(): pytest.skip("FOR NO KEY UPDATE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.NO_KEY_UPDATE}) + clause = ForUpdateClause(dialect, strength=LockStrength.NO_KEY_UPDATE) sql, params = clause.to_sql() assert sql == "FOR NO KEY UPDATE" assert params == () @@ -101,7 +98,7 @@ def test_for_key_share_basic(self, postgres_backend_single): if not dialect.supports_for_key_share(): pytest.skip("FOR KEY SHARE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.KEY_SHARE}) + clause = ForUpdateClause(dialect, strength=LockStrength.KEY_SHARE) sql, params = clause.to_sql() assert sql == "FOR KEY SHARE" assert params == () @@ -120,7 +117,7 @@ def test_for_share_with_nowait(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, nowait=True) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE, nowait=True) sql, params = clause.to_sql() assert sql == "FOR SHARE NOWAIT" assert params == () @@ -144,7 +141,7 @@ def test_for_share_with_skip_locked(self, postgres_backend_single): if not dialect.supports_for_update_skip_locked(): pytest.skip("SKIP LOCKED not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, skip_locked=True) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE, skip_locked=True) sql, params = clause.to_sql() assert sql == "FOR SHARE SKIP LOCKED" assert params == () @@ -163,7 +160,7 @@ def test_for_share_with_of_columns(self, postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}, of_columns=["users"]) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE, of_columns=["users"]) sql, params = clause.to_sql() assert sql == 'FOR SHARE OF "users"' assert params == () @@ -177,7 +174,7 @@ def test_unsupported_lock_strength_raises_error(self, postgres_backend_single): # Skip if this version actually supports it pytest.skip("This test requires a PostgreSQL version < 9.0") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE) with pytest.raises(UnsupportedFeatureError): clause.to_sql() @@ -213,7 +210,7 @@ async def test_async_for_share_basic(self, async_postgres_backend_single): if not dialect.supports_for_share(): pytest.skip("FOR SHARE not supported on this PostgreSQL version") - clause = ForUpdateClause(dialect, dialect_options={"lock_strength": LockStrength.SHARE}) + clause = ForUpdateClause(dialect, strength=LockStrength.SHARE) sql, params = clause.to_sql() assert sql == "FOR SHARE" assert params == () \ No newline at end of file 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 ef74389f..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 @@ -221,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" # --------------------------------------------------------------------------- @@ -475,9 +475,9 @@ def test_vector_types_equality_and_hash(): 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/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(