diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d8d0de..f006e16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,7 +53,7 @@ jobs: - name: Install activerecord and testsuite from git 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@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Wait for Oracle and test connectivity run: | @@ -181,7 +181,7 @@ jobs: - name: Install activerecord and testsuite from git 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@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Wait for Oracle and test connectivity run: | @@ -309,7 +309,7 @@ jobs: - name: Install activerecord and testsuite from git 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@feature/activerecord-derived-ddl" + pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" - name: Wait for Oracle and test connectivity run: | diff --git a/CLAUDE.md b/CLAUDE.md index eb34397..6f9bdef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,3 +35,9 @@ This project is in early development. Key features being implemented: ## Version Control and Changelog This project adheres to the same version control, branching, commit message, and changelog management standards as the main `python-activerecord` project. + +## Search Scope + +- Use `rg` for repository searches; never use `grep`. +- Limit searches to this `python-activerecord` repository and its related testsuite/backend directories. +- Exclude `.git/`, `.venv*/`, `__pycache__/`, `build/`, `dist/`, and other cache directories. diff --git a/changelog.d/20.added.md b/changelog.d/20.added.md new file mode 100644 index 0000000..945977b --- /dev/null +++ b/changelog.d/20.added.md @@ -0,0 +1 @@ +Add Oracle materialized view refresh via `OracleRefreshMaterializedViewExpression`, which renders a `DBMS_MVIEW.REFRESH` PL/SQL block. Oracle has no standalone `REFRESH MATERIALIZED VIEW` statement, so refresh was previously unreachable. Supports the procedure's refresh method codes (`F`/`C`/`?`/`P`/`A`, which differ from the `REFRESH FAST`/`COMPLETE` DDL keywords) plus `atomic_refresh`, `out_of_place`, `nested`, `parallelism`, `purge_option`, `refresh_after_errors` and `push_deferred_rpc`. `supports_refresh_materialized_view()` now reports `True` and the generic `RefreshMaterializedViewExpression` routes to the same formatter. diff --git a/changelog.d/5.added.md b/changelog.d/5.added.md new file mode 100644 index 0000000..1d2a4e7 --- /dev/null +++ b/changelog.d/5.added.md @@ -0,0 +1 @@ +Adapt the Oracle backend to derived DDL: `OracleColumnDefinition` with typed column options, a partition lifecycle provider with the provider protocol declared, partition definitions derived from the generic base declarations, type DDL support, `DROP TABLE ... PURGE` rendering, `CommentSupport` classification, index drop by bare name without `ON `, `OracleDatabaseMixin` wiring, and materialized view refresh via `DBMS_MVIEW.REFRESH`. diff --git a/changelog.d/5.fixed.md b/changelog.d/5.fixed.md new file mode 100644 index 0000000..58fee53 --- /dev/null +++ b/changelog.d/5.fixed.md @@ -0,0 +1 @@ +Stop the Oracle backend from discarding or over-claiming declared DDL: `supports_temporal_tables` is declared `False` instead of left unstated, unrenderable `ALTER SCHEMA` capabilities are no longer advertised, `supports_comment` is renamed to `supports_comment_on` and declared comments are no longer dropped, `CREATE VIEW ... WITH CHECK OPTION` is gated on `supports_view_check_option`, `DROP TYPE` no longer applies a default `VALIDATE`, and `DROP TABLE ... PURGE` renders independently of `CASCADE`. diff --git a/changelog.d/5.internal.md b/changelog.d/5.internal.md new file mode 100644 index 0000000..0896154 --- /dev/null +++ b/changelog.d/5.internal.md @@ -0,0 +1 @@ +Install core and testsuite from release branches in CI instead of the derived-DDL feature branch, repair TYPE_CHECKING imports broken by the expression namespace split, register the pytest markers the suite already uses, and stop the pre-commit hook from blocking commits on revisions that predate `scripts/preflight.sh`. diff --git a/changelog.d/5.removed.md b/changelog.d/5.removed.md new file mode 100644 index 0000000..f33a81d --- /dev/null +++ b/changelog.d/5.removed.md @@ -0,0 +1 @@ +Remove the reserved `dialect_options` bag from data-type value objects, DDL expressions, the insert/merge formatters and `OracleForUpdateClause`, replace the legacy partition bag paths with typed dispatch, drop the legacy identity fields from column classes, and remove the generated DDL lifecycle. diff --git a/pyproject.toml b/pyproject.toml index 01269d9..ccbc771 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,9 @@ markers = [ "requires_protocol: marks tests requiring specific backend protocol support", "requires_inner_join: marks tests requiring INNER JOIN support", "backend: marks tests for backend-specific features", + "feature: category label for feature-area tests; changes collection grouping only, not ordering", + "benchmark_relation: marks relation benchmarks; changes collection grouping only, not ordering", + ] [tool.coverage.run] diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index e06f0cb..81b3dda 100644 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -4,21 +4,42 @@ # 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 +# +# Re-run this after pulling, and re-run it from the repository root. To remove +# the hook, delete .git/hooks/pre-commit (or the core.hooksPath directory). 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 +# git ignores .git/hooks entirely once core.hooksPath is set, so a hook written +# there would never run while this script still reported success. +HOOKS_PATH="$(git config core.hooksPath || true)" +if [ -n "$HOOKS_PATH" ]; then + echo "install-git-hooks: core.hooksPath is set to '$HOOKS_PATH'." >&2 + echo "install-git-hooks: .git/hooks is ignored by git; writing the hook to" >&2 + echo " '$HOOKS_PATH/pre-commit' instead." >&2 + HOOK="$HOOKS_PATH/pre-commit" + mkdir -p "$HOOKS_PATH" +else + 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 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" +# +# The guard below matters on older revisions: this hook is a local, untracked +# artifact, so it survives a checkout of a commit that predates +# scripts/preflight.sh. Without the guard, exec fails with a bare +# "No such file or directory" and blocks the commit with nothing to act on. +PREFLIGHT="$(git rev-parse --show-toplevel)/scripts/preflight.sh" +[ -x "$PREFLIGHT" ] || exit 0 +exec "$PREFLIGHT" HOOK_BODY chmod +x "$HOOK" diff --git a/src/rhosocial/activerecord/backend/impl/oracle/__init__.py b/src/rhosocial/activerecord/backend/impl/oracle/__init__.py index 4998313..08351db 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/__init__.py @@ -75,6 +75,40 @@ OracleVarChar2Type, OracleXmlType, ) +from .expression.ddl.type import ( + DropTypeBodyExpression, + OracleAlterTypeAddAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeCompileAction, + OracleAlterTypeDependentHandling, + OracleTypeDependentHandling, + OracleAlterTypeAttributeAction, + OracleAlterTypeMethodAction, + OracleAlterTypeModifyLimitAction, + OracleCompileTypeAction, + OracleSetTypeFinalAction, + OracleSetTypeInstantiableAction, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeElementTypeAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeLimitAction, + OracleAlterTypeModifyAttributeAction, + OracleAlterTypeResetAction, + OracleCreateTypeBodyExpression, + OracleDropTypeBodyExpression, + OracleDropTypeExpression, + OracleIncompleteTypeDefinition, + OracleNestedTableTypeDefinition, + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleTypeAlterAction, + OracleTypeAttribute, + OracleTypeMethod, + OracleVarrayTypeDefinition, +) +from .version import VERSION_FULL_QUERY, VERSION_QUERY, ru_from_version_full from .schema import OracleSchemaDiffer @@ -143,6 +177,42 @@ "OracleLongRawType", "OracleBlobType", + # User-defined TYPE DDL + "OracleTypeAttribute", + "OracleTypeMethod", + "OracleObjectTypeDefinition", + "OracleSqljTypeDefinition", + "OracleVarrayTypeDefinition", + "OracleNestedTableTypeDefinition", + "OracleIncompleteTypeDefinition", + "OracleAlterTypeDependentHandling", + "OracleTypeDependentHandling", + "OracleAlterTypeAttributeAction", + "OracleAlterTypeMethodAction", + "OracleAlterTypeModifyLimitAction", + "OracleCompileTypeAction", + "OracleSetTypeFinalAction", + "OracleSetTypeInstantiableAction", + "OracleTypeAlterAction", + "OracleAlterTypeAddAttributeAction", + "OracleAlterTypeModifyAttributeAction", + "OracleAlterTypeDropAttributeAction", + "OracleAlterTypeAddMethodAction", + "OracleAlterTypeDropMethodAction", + "OracleAlterTypeLimitAction", + "OracleAlterTypeElementTypeAction", + "OracleAlterTypeCompileAction", + "OracleAlterTypeFinalAction", + "OracleAlterTypeInstantiableAction", + "OracleAlterTypeResetAction", + "OracleCreateTypeBodyExpression", + "OracleDropTypeExpression", + "DropTypeBodyExpression", + "OracleDropTypeBodyExpression", + "VERSION_FULL_QUERY", + "VERSION_QUERY", + "ru_from_version_full", + # Schema differ "OracleSchemaDiffer", diff --git a/src/rhosocial/activerecord/backend/impl/oracle/async_backend.py b/src/rhosocial/activerecord/backend/impl/oracle/async_backend.py index 667ad55..a0658ca 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/async_backend.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/async_backend.py @@ -33,6 +33,13 @@ from .dialect import OracleDialect from .async_transaction import AsyncOracleTransactionManager from .mixins import OracleBackendMixin +from .version import ( + VERSION_FULL_QUERY, + VERSION_QUERY, + normalize_version, + parse_version_row, + ru_from_version_full, +) from .backend import OracleBackend, _is_numeric_python_type @@ -42,6 +49,8 @@ class AsyncOracleBackend(OracleBackendMixin, IntrospectorBackendMixin, AsyncStor def __init__(self, **kwargs): """Initialize async Oracle backend.""" version = kwargs.pop('version', None) or (19, 0, 0) + version_full = kwargs.pop('version_full', None) + ru_version = kwargs.pop('ru_version', None) connection_config = kwargs.get('connection_config') @@ -72,6 +81,10 @@ def __init__(self, **kwargs): super().__init__(**kwargs) self._version = version or (19, 0, 0) + self._version_full = normalize_version(version_full, minimum_length=2) or None + self._ru_version = ( + ru_version if ru_version is not None else ru_from_version_full(self._version_full) + ) self._current_column_types = None self._dialect = None self._transaction_manager = AsyncOracleTransactionManager(None, self.logger) @@ -94,7 +107,11 @@ def transaction_manager(self) -> AsyncOracleTransactionManager: def dialect(self) -> OracleDialect: """Get Oracle SQL dialect.""" if self._dialect is None: - self._dialect = OracleDialect(self._version) + self._dialect = OracleDialect( + self._version, + version_full=self._version_full, + ru_version=self._ru_version, + ) return self._dialect async def _handle_auto_commit(self) -> None: @@ -112,10 +129,37 @@ async def introspect_and_adapt(self) -> None: """Introspect backend and adapt to actual server capabilities.""" if not self._connection: await self.connect() + previous_version = getattr(self, "_version", None) + previous_version_full = getattr(self, "_version_full", None) + previous_ru_version = getattr(self, "_ru_version", None) + previous_dialect = getattr(self, "_dialect", None) actual_version = await self.get_server_version() - if self._version != actual_version: + actual_version_full = getattr(self, "_version_full", None) + if actual_version_full is None: + actual_version_full = await self.get_server_version_full() + actual_ru_version = getattr(self, "_ru_version", None) + if actual_ru_version is None and actual_version_full is not None: + actual_ru_version = ru_from_version_full(actual_version_full) + dialect_version = getattr(previous_dialect, "_version", None) + dialect_version_full = getattr(previous_dialect, "version_full", None) + dialect_ru_version = getattr(previous_dialect, "ru_version", None) + if ( + previous_version != actual_version + or previous_version_full != actual_version_full + or previous_ru_version != actual_ru_version + or previous_dialect is None + or dialect_version != actual_version + or dialect_version_full != actual_version_full + or dialect_ru_version != actual_ru_version + ): self._version = actual_version - self._dialect = OracleDialect(actual_version) + self._version_full = actual_version_full + self._ru_version = actual_ru_version + self._dialect = OracleDialect( + actual_version, + version_full=actual_version_full, + ru_version=actual_ru_version, + ) self._register_oracle_adapters() self.log(logging.INFO, f"Adapted to Oracle server version {actual_version}") @@ -562,29 +606,44 @@ async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: cursor.close() async def get_server_version(self) -> tuple: - """Get Oracle server version asynchronously.""" + """Get the Oracle base and full release versions asynchronously.""" if not self._connection: await self.connect() cursor = None try: cursor = await self._get_cursor() - await cursor.execute("SELECT VERSION FROM PRODUCT_COMPONENT_VERSION WHERE PRODUCT LIKE 'Oracle%'") - row = await cursor.fetchone() - version_str = row[0] - - version_parts = version_str.split('.') - major = int(version_parts[0]) if len(version_parts) > 0 else 0 - minor = int(version_parts[1]) if len(version_parts) > 1 else 0 - patch = int(version_parts[2]) if len(version_parts) > 2 else 0 - - return (major, minor, patch) + try: + await cursor.execute(VERSION_FULL_QUERY) + row = await cursor.fetchone() + base, version_full = parse_version_row(row) + except Exception: + cursor.close() + cursor = await self._get_cursor() + await cursor.execute(VERSION_QUERY) + row = await cursor.fetchone() + base, version_full = parse_version_row(row) + base = normalize_version(base)[:3] + if not base: + raise ValueError("Oracle PRODUCT_COMPONENT_VERSION returned no version") + self._version_full = version_full + self._ru_version = ru_from_version_full(version_full) + return base except Exception: + self._version_full = None + self._ru_version = None return (19, 0, 0) finally: if cursor: cursor.close() + async def get_server_version_full(self) -> Optional[Tuple[int, ...]]: + """Return the full Oracle version, including the RU when available.""" + if self._version_full is not None: + return self._version_full + await self.get_server_version() + return self._version_full + async def ping(self, reconnect: bool = True) -> bool: """Ping the Oracle server asynchronously.""" try: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/backend.py b/src/rhosocial/activerecord/backend/impl/oracle/backend.py index c6b3e58..c7bc99d 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/backend.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/backend.py @@ -35,6 +35,13 @@ from .dialect import OracleDialect from .transaction import OracleTransactionManager from .mixins import OracleBackendMixin, OracleConcurrencyMixin +from .version import ( + VERSION_FULL_QUERY, + VERSION_QUERY, + normalize_version, + parse_version_row, + ru_from_version_full, +) def _is_numeric_python_type(python_type: Optional[Type]) -> bool: @@ -71,6 +78,8 @@ def __init__(self, **kwargs): """ # Extract version from kwargs if provided version = kwargs.pop('version', None) or (19, 0, 0) + version_full = kwargs.pop('version_full', None) + ru_version = kwargs.pop('ru_version', None) # Ensure we have proper Oracle configuration connection_config = kwargs.get('connection_config') @@ -105,6 +114,10 @@ def __init__(self, **kwargs): # Store the expected Oracle server version self._version = version or (19, 0, 0) + self._version_full = normalize_version(version_full, minimum_length=2) or None + self._ru_version = ( + ru_version if ru_version is not None else ru_from_version_full(self._version_full) + ) # Cached column type info from cursor.description for _adapt_row_types self._current_column_types = None # Initialize Oracle-specific components (lazy load dialect) @@ -127,10 +140,37 @@ def introspect_and_adapt(self) -> None: """Introspect backend and adapt to actual server capabilities.""" if not self._connection: self.connect() + previous_version = getattr(self, "_version", None) + previous_version_full = getattr(self, "_version_full", None) + previous_ru_version = getattr(self, "_ru_version", None) + previous_dialect = getattr(self, "_dialect", None) actual_version = self.get_server_version() - if self._version != actual_version: + actual_version_full = getattr(self, "_version_full", None) + if actual_version_full is None: + actual_version_full = self.get_server_version_full() + actual_ru_version = getattr(self, "_ru_version", None) + if actual_ru_version is None and actual_version_full is not None: + actual_ru_version = ru_from_version_full(actual_version_full) + dialect_version = getattr(previous_dialect, "_version", None) + dialect_version_full = getattr(previous_dialect, "version_full", None) + dialect_ru_version = getattr(previous_dialect, "ru_version", None) + if ( + previous_version != actual_version + or previous_version_full != actual_version_full + or previous_ru_version != actual_ru_version + or previous_dialect is None + or dialect_version != actual_version + or dialect_version_full != actual_version_full + or dialect_ru_version != actual_ru_version + ): self._version = actual_version - self._dialect = OracleDialect(actual_version) + self._version_full = actual_version_full + self._ru_version = actual_ru_version + self._dialect = OracleDialect( + actual_version, + version_full=actual_version_full, + ru_version=actual_ru_version, + ) self._register_oracle_adapters() self.log(logging.INFO, f"Adapted to Oracle server version {actual_version}") @@ -138,7 +178,11 @@ def introspect_and_adapt(self) -> None: def dialect(self) -> OracleDialect: """Get Oracle SQL dialect.""" if self._dialect is None: - self._dialect = OracleDialect(self._version) + self._dialect = OracleDialect( + self._version, + version_full=self._version_full, + ru_version=self._ru_version, + ) return self._dialect @property @@ -629,33 +673,46 @@ def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult: cursor.close() def get_server_version(self) -> tuple: - """Get Oracle server version.""" + """Get the Oracle base and full release versions.""" if not self._connection: self.connect() cursor = None try: cursor = self._get_cursor() - cursor.execute("SELECT VERSION FROM PRODUCT_COMPONENT_VERSION WHERE PRODUCT LIKE 'Oracle%'") - version_str = cursor.fetchone()[0] - - # Parse version string (e.g., "19.0.0.0.0") - version_parts = version_str.split('.') - major = int(version_parts[0]) if len(version_parts) > 0 else 0 - minor = int(version_parts[1]) if len(version_parts) > 1 else 0 - patch = int(version_parts[2]) if len(version_parts) > 2 else 0 - - version_tuple = (major, minor, patch) - - self.log(logging.INFO, f"Oracle server version: {major}.{minor}.{patch}") - return version_tuple + try: + cursor.execute(VERSION_FULL_QUERY) + row = cursor.fetchone() + base, version_full = parse_version_row(row) + except Exception: + cursor.close() + cursor = self._get_cursor() + cursor.execute(VERSION_QUERY) + row = cursor.fetchone() + base, version_full = parse_version_row(row) + base = normalize_version(base)[:3] + if not base: + raise ValueError("Oracle PRODUCT_COMPONENT_VERSION returned no version") + self._version_full = version_full + self._ru_version = ru_from_version_full(version_full) + self.log(logging.INFO, f"Oracle server version: {base}") + return base except Exception as e: self.log(logging.WARNING, f"Could not determine Oracle version: {str(e)}, defaulting to 19.0.0") + self._version_full = None + self._ru_version = None return (19, 0, 0) finally: if cursor: cursor.close() + def get_server_version_full(self) -> Optional[Tuple[int, ...]]: + """Return the full Oracle version, including the RU when available.""" + if self._version_full is not None: + return self._version_full + self.get_server_version() + return self._version_full + def ping(self, reconnect: bool = True) -> bool: """Ping the Oracle server to check if the connection is alive.""" try: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/dialect.py b/src/rhosocial/activerecord/backend/impl/oracle/dialect.py index e5f9d85..8f61e25 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/dialect.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/dialect.py @@ -5,7 +5,7 @@ (provided by the core framework) together with Oracle-specific mixins that override version-gated capability checks and syntax formatters. """ -from typing import Optional, Tuple, TYPE_CHECKING +from typing import Any, Optional, Tuple, TYPE_CHECKING from rhosocial.activerecord.backend.dialect.base import SQLDialectBase from rhosocial.activerecord.backend.dialect.protocols import ( @@ -46,6 +46,7 @@ ConstraintSupport, TransactionControlSupport, SQLFunctionSupport, + UserDefinedTypeSupport, ) from rhosocial.activerecord.backend.dialect.mixins import ( SQLXMLMixin, @@ -82,6 +83,7 @@ DQLMixin, DMLMixin, PartitionMixin, + UserDefinedTypeMixin, ) from .mixins import ( OracleAnalyzeMixin, @@ -106,7 +108,6 @@ OracleOptimizerHintMixin, OraclePaginationMixin, OraclePartitionMixin, - OraclePartitionLifecycleMixin, OraclePivotMixin, OracleRoutineMixin, OracleSchemaMixin, @@ -121,12 +122,14 @@ OracleTruncateMixin, OracleTypeSupportMixin, OracleTypeSuggestionMixin, + OracleTypeDDLMixin, OracleVectorMixin, OracleViewMixin, OracleIdentifierMixin, OracleExplainMixin, ) from .protocols.partition import OraclePartitionSupport +from .protocols.ddl_type import OracleTypeDDLSupport from .reserved_words import ORACLE_RESERVED_WORDS if TYPE_CHECKING: @@ -142,6 +145,8 @@ class OracleDialect( OracleAnalyzeMixin, OracleCollationMixin, OracleDDLMixin, + OracleTypeDDLMixin, + UserDefinedTypeMixin, OracleCommentMixin, OracleDatabaseLinkMixin, OracleDateTimeMixin, @@ -161,7 +166,6 @@ class OracleDialect( OracleOptimizerHintMixin, OraclePaginationMixin, OraclePartitionMixin, - OraclePartitionLifecycleMixin, OraclePivotMixin, OracleRoutineMixin, OracleSchemaMixin, @@ -259,6 +263,8 @@ class OracleDialect( TransactionControlSupport, SQLFunctionSupport, OraclePartitionSupport, + OracleTypeDDLSupport, + UserDefinedTypeSupport, ): """Oracle dialect implementation that adapts to the Oracle version. @@ -267,7 +273,13 @@ class OracleDialect( composition skeleton that wires them together. """ - def __init__(self, version: Optional[Tuple[int, int, int]] = None): + def __init__( + self, + version: Optional[Tuple[int, int, int]] = None, + *, + version_full: Optional[Tuple[int, ...]] = None, + ru_version: Optional[int] = None, + ): """Initialise the dialect with an optional version tuple. Args: @@ -277,9 +289,56 @@ def __init__(self, version: Optional[Tuple[int, int, int]] = None): """ super().__init__() self._reserved_words = ORACLE_RESERVED_WORDS + self._version_full = self._normalize_version_full(version_full) + self._ru_version = self._normalize_ru_version(ru_version) if version is not None: self.version = version + @staticmethod + def _normalize_version_full( + value: Optional[Tuple[int, ...]], + ) -> Optional[Tuple[int, ...]]: + if value is None: + return None + if isinstance(value, str): + value = tuple( + int(part) + for part in value.strip().split(".") + if part.strip().isdigit() + ) + if not isinstance(value, (tuple, list)): + raise TypeError("version_full must be a tuple or string") + result = tuple(int(part) for part in value) + return result or None + + @staticmethod + def _normalize_ru_version(value: Optional[int]) -> Optional[int]: + if value is None: + return None + if not isinstance(value, int) or isinstance(value, bool): + raise TypeError("ru_version must be an int") + return value + + @property + def version_full(self) -> Optional[Tuple[int, ...]]: + return self._version_full + + @version_full.setter + def version_full(self, value: Optional[Tuple[int, ...]]) -> None: + self._version_full = self._normalize_version_full(value) + + @property + def ru_version(self) -> Optional[int]: + if self._ru_version is not None: + return self._ru_version + if self._version_full and len(self._version_full) >= 2: + return self._version_full[1] + return None + + @ru_version.setter + def ru_version(self, value: Optional[int]) -> None: + self._ru_version = self._normalize_ru_version(value) + def format_identifier(self, identifier: str, need_quote: bool = True) -> str: """Format identifier for Oracle with double-quote quoting and uppercasing.""" if not need_quote: @@ -308,7 +367,7 @@ def get_server_version(self) -> Tuple[int, int, int]: """Return the configured Oracle version tuple.""" return self.version - def create_schema_differ(self): + def create_schema_differ(self) -> Any: """Return the Oracle schema differ for this dialect.""" from rhosocial.activerecord.backend.impl.oracle.schema.differ import ( OracleSchemaDiffer, diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/__init__.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/__init__.py index bf282bf..9710893 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/__init__.py @@ -38,6 +38,8 @@ OracleCreateMaterializedViewExpression, OracleCreateMaterializedViewLogExpression, OracleDropMaterializedViewExpression, + OracleRefreshMaterializedViewExpression, + OracleRefreshMethod, ) from .comment import ( OracleCommentObjectType, OracleCommentExpression, @@ -48,6 +50,10 @@ OracleFlashbackTableExpression, OraclePurgeObjectType, OraclePurgeExpression, ) +from .column import ( + OracleColumnDefinition, + OracleColumnOptions, +) from .alter_table import ( OracleSetUnusedColumnsAction, OracleDropUnusedColumnsAction, OracleMoveTableAction, OracleShrinkSpaceAction, @@ -60,6 +66,22 @@ OracleCreateProcedureExpression, OracleCreateFunctionExpression, OracleCreatePackageExpression, OracleCreatePackageBodyExpression, OracleDropRoutineObjectType, OracleDropRoutineExpression, + OracleTypeAttribute, OracleTypeMethod, + OracleObjectTypeDefinition, OracleSqljTypeDefinition, + OracleVarrayTypeDefinition, OracleNestedTableTypeDefinition, + OracleIncompleteTypeDefinition, OracleAlterTypeDependentHandling, + OracleTypeDependentHandling, OracleAlterTypeAttributeAction, + OracleAlterTypeMethodAction, + OracleAlterTypeModifyLimitAction, OracleCompileTypeAction, + OracleSetTypeFinalAction, OracleSetTypeInstantiableAction, + OracleTypeAlterAction, OracleAlterTypeAddAttributeAction, + OracleAlterTypeModifyAttributeAction, OracleAlterTypeDropAttributeAction, + OracleAlterTypeAddMethodAction, OracleAlterTypeDropMethodAction, + OracleAlterTypeLimitAction, OracleAlterTypeElementTypeAction, + OracleAlterTypeCompileAction, OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, OracleAlterTypeResetAction, + OracleCreateTypeBodyExpression, OracleDropTypeExpression, + DropTypeBodyExpression, OracleDropTypeBodyExpression, ) from .analyze import ( OracleAnalyzeMode, OracleAnalyzeExpression, @@ -69,6 +91,7 @@ from .trigger import DisableTriggerExpression, EnableTriggerExpression __all__ = [ +'OracleColumnDefinition', 'OracleColumnOptions', # Hierarchical query expressions 'ConnectByExpression', 'PriorExpression', 'ConnectByRootExpression', 'SysConnectByPathExpression', @@ -98,6 +121,8 @@ 'OracleCreateMaterializedViewExpression', 'OracleCreateMaterializedViewLogExpression', 'OracleDropMaterializedViewExpression', + 'OracleRefreshMaterializedViewExpression', + 'OracleRefreshMethod', # COMMENT ON expressions 'OracleCommentObjectType', 'OracleCommentExpression', # FLASHBACK family expressions @@ -117,6 +142,23 @@ 'OracleCreateProcedureExpression', 'OracleCreateFunctionExpression', 'OracleCreatePackageExpression', 'OracleCreatePackageBodyExpression', 'OracleDropRoutineObjectType', 'OracleDropRoutineExpression', + # User-defined TYPE DDL expressions + 'OracleTypeAttribute', 'OracleTypeMethod', + 'OracleObjectTypeDefinition', 'OracleSqljTypeDefinition', + 'OracleVarrayTypeDefinition', 'OracleNestedTableTypeDefinition', + 'OracleIncompleteTypeDefinition', 'OracleAlterTypeDependentHandling', + 'OracleTypeDependentHandling', 'OracleAlterTypeAttributeAction', + 'OracleAlterTypeMethodAction', + 'OracleAlterTypeModifyLimitAction', 'OracleCompileTypeAction', + 'OracleSetTypeFinalAction', 'OracleSetTypeInstantiableAction', + 'OracleTypeAlterAction', 'OracleAlterTypeAddAttributeAction', + 'OracleAlterTypeModifyAttributeAction', 'OracleAlterTypeDropAttributeAction', + 'OracleAlterTypeAddMethodAction', 'OracleAlterTypeDropMethodAction', + 'OracleAlterTypeLimitAction', 'OracleAlterTypeElementTypeAction', + 'OracleAlterTypeCompileAction', 'OracleAlterTypeFinalAction', + 'OracleAlterTypeInstantiableAction', 'OracleAlterTypeResetAction', + 'OracleCreateTypeBodyExpression', 'OracleDropTypeExpression', + 'DropTypeBodyExpression', 'OracleDropTypeBodyExpression', # ANALYZE TABLE expressions 'OracleAnalyzeMode', 'OracleAnalyzeExpression', # Vector expressions diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/alter_table.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/alter_table.py index 6b163af..826df88 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/alter_table.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/alter_table.py @@ -19,9 +19,8 @@ """ from __future__ import annotations -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import List, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import SQLQueryAndParams from rhosocial.activerecord.backend.expression.statements.ddl_alter import AlterTableAction if TYPE_CHECKING: # pragma: no cover @@ -38,7 +37,6 @@ class OracleSetUnusedColumnsAction(AlterTableAction): Args: dialect: the Oracle dialect instance. columns: list of column names to mark as unused. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``columns`` is empty. @@ -48,14 +46,11 @@ def __init__( self, dialect: "OracleDialect", columns: List[str], - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not columns: raise ValueError("columns must be a non-empty list") self.columns = list(columns) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -71,17 +66,13 @@ class OracleDropUnusedColumnsAction(AlterTableAction): Args: dialect: the Oracle dialect instance. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -97,17 +88,13 @@ class OracleMoveTableAction(AlterTableAction): Args: dialect: the Oracle dialect instance. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -124,19 +111,15 @@ class OracleShrinkSpaceAction(AlterTableAction): Args: dialect: the Oracle dialect instance. cascade: when True, also shrink dependent segments (indexes, LOBs). - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", cascade: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.cascade = bool(cascade) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -153,19 +136,15 @@ class OracleReadOnlyAction(AlterTableAction): Args: dialect: the Oracle dialect instance. read_only: when True emit ``READ ONLY``, otherwise ``READ WRITE``. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", read_only: bool = True, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.read_only = bool(read_only) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -184,19 +163,15 @@ class OracleRowMovementAction(AlterTableAction): dialect: the Oracle dialect instance. enable: when True emit ``ENABLE ROW MOVEMENT``, otherwise ``DISABLE ROW MOVEMENT``. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", enable: bool = True, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.enable = bool(enable) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/analyze.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/analyze.py index d7e9414..6ebc083 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/analyze.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/analyze.py @@ -17,9 +17,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import OracleDialect @@ -49,7 +49,6 @@ class OracleAnalyzeExpression(BaseExpression): structures (``CASCADE``). into: with ``LIST CHAINED ROWS``, the table to receive the chained row list (``INTO ...``). - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``table`` is empty, ``sample_percent`` is used @@ -68,8 +67,6 @@ def __init__( sample_percent: Optional[int] = None, cascade: bool = False, into: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(table, str) or not table.strip(): @@ -101,7 +98,6 @@ def __init__( self.sample_percent = sample_percent self.cascade = bool(cascade) self.into = into - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/column.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/column.py new file mode 100644 index 0000000..f46e9f1 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/column.py @@ -0,0 +1,82 @@ +# src/rhosocial/activerecord/backend/impl/oracle/expression/column.py +"""Oracle-specific column definition expressions. + +Oracle extends the standard column definition with per-column attributes that +have no generic equivalent: + +* ``INVISIBLE`` — column hidden from ``SELECT *`` (Oracle 12c+). + +These live on ``OracleColumnDefinition`` (deriving the generic +``ColumnDefinition``) and are rendered by the Oracle +``format_column_definition`` override. They are declared through +``OracleColumnOptions`` (deriving the generic ``ColumnOptions``). +""" + +from typing import Optional, TYPE_CHECKING + +from rhosocial.activerecord.backend.expression.statements import ColumnDefinition +from rhosocial.activerecord.base import ColumnOptions + +if TYPE_CHECKING: # pragma: no cover + from rhosocial.activerecord.backend.dialect import SQLDialectBase + + +__all__ = [ + "OracleColumnDefinition", + "OracleColumnOptions", +] + + +class OracleColumnDefinition(ColumnDefinition): + """An Oracle column definition extending the generic one. + + Adds the Oracle-only ``invisible`` attribute. + """ + + def __init__( + self, + dialect: "SQLDialectBase", + name: str, + data_type, + constraints=None, + comment: Optional[str] = None, + generated_expression=None, + attributes=None, + *, + invisible: Optional[bool] = None, + ): + super().__init__( + dialect, + name, + data_type, + constraints=constraints, + comment=comment, + generated_expression=generated_expression, + attributes=attributes, + ) + self.invisible = invisible + + +class OracleColumnOptions(ColumnOptions): + """Oracle per-column options declaration.""" + + def __init__( + self, + *, + invisible: Optional[bool] = None, + ): + super().__init__() + self.invisible = invisible + + def column_definition_class(self): + """Build an ``OracleColumnDefinition`` for these options.""" + return OracleColumnDefinition + + def apply_to(self, column) -> None: + """Transfer the Oracle-only fields onto the column definition.""" + if not isinstance(column, OracleColumnDefinition): + raise TypeError( + "OracleColumnOptions.apply_to requires an OracleColumnDefinition, " + f"got {type(column).__name__}" + ) + column.invisible = self.invisible diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/comment.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/comment.py index b5f41d5..a4a9d3f 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/comment.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/comment.py @@ -17,9 +17,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import OracleDialect @@ -53,7 +53,6 @@ class OracleCommentExpression(BaseExpression): ``table.column``. comment: the comment text. ``None`` renders ``IS NULL``, which removes any existing comment from the object. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``object_name`` is empty. @@ -67,8 +66,6 @@ def __init__( object_type: OracleCommentObjectType, object_name: str, comment: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(object_type, OracleCommentObjectType): @@ -81,7 +78,6 @@ def __init__( self.object_type = object_type self.object_name = object_name self.comment = comment - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/__init__.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/__init__.py index f7dc5b8..16c5dd6 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/__init__.py @@ -23,6 +23,39 @@ OracleCreateSynonymExpression, OracleDropSynonymExpression, ) +from .type import ( + DropTypeBodyExpression, + OracleAlterTypeAddAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeCompileAction, + OracleAlterTypeDependentHandling, + OracleTypeDependentHandling, + OracleAlterTypeAttributeAction, + OracleAlterTypeMethodAction, + OracleAlterTypeModifyLimitAction, + OracleCompileTypeAction, + OracleSetTypeFinalAction, + OracleSetTypeInstantiableAction, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeElementTypeAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeLimitAction, + OracleAlterTypeModifyAttributeAction, + OracleAlterTypeResetAction, + OracleCreateTypeBodyExpression, + OracleDropTypeBodyExpression, + OracleDropTypeExpression, + OracleIncompleteTypeDefinition, + OracleNestedTableTypeDefinition, + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleTypeAlterAction, + OracleTypeAttribute, + OracleTypeMethod, + OracleVarrayTypeDefinition, +) __all__ = [ "OracleCreateSynonymExpression", @@ -37,4 +70,35 @@ "OracleCreatePackageBodyExpression", "OracleDropRoutineObjectType", "OracleDropRoutineExpression", + "OracleTypeAttribute", + "OracleTypeMethod", + "OracleObjectTypeDefinition", + "OracleSqljTypeDefinition", + "OracleVarrayTypeDefinition", + "OracleNestedTableTypeDefinition", + "OracleIncompleteTypeDefinition", + "OracleAlterTypeDependentHandling", + "OracleTypeDependentHandling", + "OracleAlterTypeAttributeAction", + "OracleAlterTypeMethodAction", + "OracleAlterTypeModifyLimitAction", + "OracleCompileTypeAction", + "OracleSetTypeFinalAction", + "OracleSetTypeInstantiableAction", + "OracleTypeAlterAction", + "OracleAlterTypeAddAttributeAction", + "OracleAlterTypeModifyAttributeAction", + "OracleAlterTypeDropAttributeAction", + "OracleAlterTypeAddMethodAction", + "OracleAlterTypeDropMethodAction", + "OracleAlterTypeLimitAction", + "OracleAlterTypeElementTypeAction", + "OracleAlterTypeCompileAction", + "OracleAlterTypeFinalAction", + "OracleAlterTypeInstantiableAction", + "OracleAlterTypeResetAction", + "OracleCreateTypeBodyExpression", + "OracleDropTypeExpression", + "DropTypeBodyExpression", + "OracleDropTypeBodyExpression", ] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/database_link.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/database_link.py index 43a1d42..ac8f00f 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/database_link.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/database_link.py @@ -16,12 +16,12 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover - from ..dialect import OracleDialect + from ...dialect import OracleDialect class OracleCreateDatabaseLinkExpression(BaseExpression): @@ -37,7 +37,6 @@ class OracleCreateDatabaseLinkExpression(BaseExpression): public: create a PUBLIC database link instead of a private one. shared: create a SHARED database link used by multiple sessions through the same connection. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``link_name`` is empty, or if only one of ``user`` / @@ -53,8 +52,6 @@ def __init__( using: Optional[str] = None, public: bool = False, shared: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(link_name, str) or not link_name.strip(): @@ -67,7 +64,6 @@ def __init__( self.using = using self.public = bool(public) self.shared = bool(shared) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -82,7 +78,6 @@ class OracleDropDatabaseLinkExpression(BaseExpression): dialect: the Oracle dialect instance. link_name: name of the database link to drop. public: drop the PUBLIC database link. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``link_name`` is empty. @@ -93,15 +88,12 @@ def __init__( dialect: "OracleDialect", link_name: str, public: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(link_name, str) or not link_name.strip(): raise ValueError("link_name must be a non-empty string") self.link_name = link_name self.public = bool(public) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/routine.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/routine.py index 95d2999..ba46bca 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/routine.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/routine.py @@ -27,12 +27,12 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover - from ..dialect import OracleDialect + from ...dialect import OracleDialect class OracleRoutineParameterMode(Enum): @@ -88,7 +88,6 @@ class OracleCreateProcedureExpression(BaseExpression): or_replace: emit ``OR REPLACE`` (default True). keyword: the ``AS`` / ``IS`` separator between the signature and the body. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``procedure_name`` or ``body`` is empty, or @@ -103,8 +102,6 @@ def __init__( parameters: Optional[List[OracleRoutineParameter]] = None, or_replace: bool = True, keyword: str = "AS", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(procedure_name, str) or not procedure_name.strip(): @@ -118,7 +115,6 @@ def __init__( self.parameters = list(parameters) if parameters else [] self.or_replace = bool(or_replace) self.keyword = keyword - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -139,7 +135,6 @@ class OracleCreateFunctionExpression(BaseExpression): return_keyword: ``RETURN`` (Oracle syntax) or ``RETURNS`` spelling. keyword: the ``AS`` / ``IS`` separator between the signature and the body. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``function_name``, ``return_type`` or ``body`` is @@ -156,8 +151,6 @@ def __init__( or_replace: bool = True, return_keyword: str = "RETURN", keyword: str = "AS", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(function_name, str) or not function_name.strip(): @@ -177,7 +170,6 @@ def __init__( self.or_replace = bool(or_replace) self.return_keyword = return_keyword self.keyword = keyword - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -195,7 +187,6 @@ class OracleCreatePackageExpression(BaseExpression): p (x NUMBER);"``. or_replace: emit ``OR REPLACE`` (default True). keyword: the ``AS`` / ``IS`` separator before the body. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``package_name`` or ``body`` is empty, or ``keyword`` @@ -209,8 +200,6 @@ def __init__( body: str, or_replace: bool = True, keyword: str = "AS", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(package_name, str) or not package_name.strip(): @@ -223,7 +212,6 @@ def __init__( self.body = body self.or_replace = bool(or_replace) self.keyword = keyword - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -240,7 +228,6 @@ class OracleCreatePackageBodyExpression(BaseExpression): body: the package body as a raw string. or_replace: emit ``OR REPLACE`` (default True). keyword: the ``AS`` / ``IS`` separator before the body. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``package_name`` or ``body`` is empty, or ``keyword`` @@ -254,8 +241,6 @@ def __init__( body: str, or_replace: bool = True, keyword: str = "AS", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(package_name, str) or not package_name.strip(): @@ -268,7 +253,6 @@ def __init__( self.body = body self.or_replace = bool(or_replace) self.keyword = keyword - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -292,7 +276,6 @@ class OracleDropRoutineExpression(BaseExpression): dialect: the Oracle dialect instance. object_type: the PL/SQL object kind to drop. object_name: the name of the object to drop. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``object_name`` is empty. @@ -305,8 +288,6 @@ def __init__( dialect: "OracleDialect", object_type: OracleDropRoutineObjectType, object_name: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(object_type, OracleDropRoutineObjectType): @@ -318,7 +299,6 @@ def __init__( raise ValueError("object_name must be a non-empty string") self.object_type = object_type self.object_name = object_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/synonym.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/synonym.py index a8b2794..7576506 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/synonym.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/synonym.py @@ -14,12 +14,12 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover - from ..dialect import OracleDialect + from ...dialect import OracleDialect class OracleCreateSynonymExpression(BaseExpression): @@ -32,7 +32,6 @@ class OracleCreateSynonymExpression(BaseExpression): schema_name: optional schema qualifier of the target object. public: create a PUBLIC synonym (shared by all users) instead of a private one. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``synonym_name`` or ``table_name`` is empty. @@ -45,8 +44,6 @@ def __init__( table_name: str, schema_name: Optional[str] = None, public: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(synonym_name, str) or not synonym_name.strip(): @@ -57,7 +54,6 @@ def __init__( self.table_name = table_name self.schema_name = schema_name self.public = bool(public) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -74,7 +70,6 @@ class OracleDropSynonymExpression(BaseExpression): public: drop the PUBLIC synonym. force: append ``FORCE`` to drop the synonym even when it has dependents. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``synonym_name`` is empty. @@ -86,8 +81,6 @@ def __init__( synonym_name: str, public: bool = False, force: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(synonym_name, str) or not synonym_name.strip(): @@ -95,7 +88,6 @@ def __init__( self.synonym_name = synonym_name self.public = bool(public) self.force = bool(force) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/type.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/type.py new file mode 100644 index 0000000..d8ef30b --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/type.py @@ -0,0 +1,1410 @@ +# src/rhosocial/activerecord/backend/impl/oracle/expression/ddl/type.py +"""Oracle user-defined type DDL expressions.""" + +from __future__ import annotations + +from collections.abc import Sequence as ABCSequence +from dataclasses import dataclass +from enum import Enum +import re +from typing import Any, Dict, List, Optional, Sequence, Tuple, TYPE_CHECKING, cast + +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.serialization import ExpressionRegistry +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) + +if TYPE_CHECKING: + from ...dialect import OracleDialect + + +def _validate_name(value: str, field_name: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + + +def _validate_fragment(value: str, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field_name} must be a non-empty string") + if any(token in value for token in (";", "--", "/*", "*/")): + raise ValueError(f"{field_name} must not contain SQL statement or comment tokens") + return value.strip() + + +def _validate_optional_name(value: Optional[str], field_name: str) -> Optional[str]: + if value is not None: + _validate_name(value, field_name) + return value + + +def _normalize_flag(value: Optional[bool], alias: Optional[bool], field_name: str) -> Optional[bool]: + if value is not None and alias is not None: + raise ValueError(f"{field_name} and its NOT alias are mutually exclusive") + if alias is not None: + return not alias + return value + + +def _normalize_invoker_rights(value: Any) -> Optional[bool]: + if value is None or isinstance(value, bool): + return value + if isinstance(value, str): + normalized = re.sub(r"\s+", " ", value.strip().upper()) + if normalized == "INVOKER RIGHTS": + return True + raise ValueError("invoker_rights must be a bool or 'INVOKER RIGHTS'") + + +def _normalize_keyword(value: str, field_name: str) -> str: + keyword = str(value).upper() + if keyword not in ("AS", "IS"): + raise ValueError(f"{field_name} must be 'AS' or 'IS'") + return keyword + + +_ACCESSOR_KINDS = frozenset(("FUNCTION", "PACKAGE", "PROCEDURE", "TRIGGER", "TYPE")) +_IDENTIFIER_PART = r'(?:[A-Za-z][A-Za-z0-9_$#]*|"(?:[^"]|"")*")' +_IDENTIFIER_PART_RE = re.compile(_IDENTIFIER_PART) +_ACCESSOR_NAME_RE = re.compile(rf"^{_IDENTIFIER_PART}(?:\.{_IDENTIFIER_PART})*$") + + +def _validate_accessor(value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("ACCESSIBLE BY items must be non-empty strings") + text = value.strip() + if any(token in text for token in (";", "--", "/*", "*/", "(", ")")): + raise ValueError("ACCESSIBLE BY items must be accessor names") + kind_match = re.match( + rf"^(?:{'|'.join(sorted(_ACCESSOR_KINDS))})\s+(.+)$", + text, + re.IGNORECASE, + ) + if kind_match is not None: + name = kind_match.group(1).strip() + else: + name = text + if _ACCESSOR_NAME_RE.fullmatch(name) is None: + raise ValueError("ACCESSIBLE BY accessor name is invalid") + return text + + +def _validate_accessible_by(value: Any) -> Optional[Tuple[str, ...]]: + if value is None: + return None + if isinstance(value, str): + values = [value] + elif isinstance(value, (list, tuple)): + values = list(value) + else: + raise TypeError("accessible_by must be a string or a sequence of strings") + if not values: + raise ValueError("ACCESSIBLE BY requires at least one accessor") + return tuple(_validate_accessor(item) for item in values) + + +_METHOD_KIND_ALIASES = { + "MEMBER": "MEMBER FUNCTION", + "STATIC": "STATIC FUNCTION", + "CONSTRUCTOR": "CONSTRUCTOR FUNCTION", + "MAP": "MAP MEMBER FUNCTION", + "ORDER": "ORDER MEMBER FUNCTION", + "MAP MEMBER": "MAP MEMBER FUNCTION", + "ORDER MEMBER": "ORDER MEMBER FUNCTION", + "MEMBER FUNCTION": "MEMBER FUNCTION", + "MEMBER PROCEDURE": "MEMBER PROCEDURE", + "STATIC FUNCTION": "STATIC FUNCTION", + "STATIC PROCEDURE": "STATIC PROCEDURE", + "CONSTRUCTOR FUNCTION": "CONSTRUCTOR FUNCTION", + "MAP MEMBER FUNCTION": "MAP MEMBER FUNCTION", + "ORDER MEMBER FUNCTION": "ORDER MEMBER FUNCTION", +} +_METHOD_FUNCTION_KINDS = frozenset( + ( + "MEMBER FUNCTION", + "STATIC FUNCTION", + "CONSTRUCTOR FUNCTION", + "MAP MEMBER FUNCTION", + "ORDER MEMBER FUNCTION", + ) +) +_METHOD_PROCEDURE_KINDS = frozenset(("MEMBER PROCEDURE", "STATIC PROCEDURE")) +_SQLJ_USING_CLAUSES = { + "SQLDATA": "SQLData", + "CUSTOMDATUM": "CustomDatum", + "ORADATA": "OraData", +} + + +def _normalize_sqlj_using(value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError("SQLJ USING clause is required") + normalized = re.sub(r"\s+", " ", value.strip().upper()) + try: + return _SQLJ_USING_CLAUSES[normalized] + except KeyError as error: + raise ValueError("USING must be SQLData, CustomDatum or OraData") from error + + +def _normalize_method_kind(value: Optional[str]) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str) or not value.strip(): + raise ValueError("method kind must be a non-empty string") + normalized = re.sub(r"\s+", " ", value.strip().upper()) + try: + return _METHOD_KIND_ALIASES[normalized] + except KeyError as error: + raise ValueError(f"Unsupported Oracle method kind: {value!r}") from error + + +def _validate_method_name(value: str) -> str: + _validate_name(value, "method name") + if _IDENTIFIER_PART_RE.fullmatch(value) is None: + raise ValueError("method name must be an Oracle identifier") + return value + + +def _has_top_level_comma(value: str) -> bool: + depth = 0 + quote: Optional[str] = None + for character in value: + if quote is not None: + if character == quote: + quote = None + continue + if character in ("'", '"'): + quote = character + elif character == "(": + depth += 1 + elif character == ")": + depth -= 1 + if depth < 0: + return True + elif character == "," and depth == 0: + return True + return depth != 0 or quote is not None + + +def _validate_method_parameter(value: Any) -> Any: + if isinstance(value, BaseExpression): + return value + if not isinstance(value, str) or not value.strip(): + raise ValueError("method parameters must be non-empty strings or expressions") + text = _validate_fragment(value, "method parameter") + if _has_top_level_comma(text): + raise ValueError("method parameter contains an unsafe separator") + return text + + +def _validate_method_parameters(value: Any) -> Any: + if value is None or isinstance(value, BaseExpression): + return value + if isinstance(value, str): + return _validate_method_parameter(value) + if isinstance(value, ABCSequence): + for parameter in value: + _validate_method_parameter(parameter) + return value + raise TypeError("method parameters must be a sequence, string or expression") + + +def _coerce_attribute(value: Any, require_type: bool = True) -> "OracleTypeAttribute": + if isinstance(value, OracleTypeAttribute): + attribute = value + elif isinstance(value, dict): + if "name" not in value and len(value) == 1: + name, data_type = next(iter(value.items())) + attribute = OracleTypeAttribute(name, data_type) + else: + attribute = OracleTypeAttribute(**value) + elif isinstance(value, (tuple, list)) and len(value) in (2, 3): + attribute = OracleTypeAttribute(*value) + elif isinstance(value, str): + text = _validate_fragment(value, "attribute") + pieces = text.split(None, 1) + if len(pieces) != 2: + if require_type: + raise ValueError("attribute declarations must contain a name and a data type") + attribute = OracleTypeAttribute(text, None) + else: + attribute = OracleTypeAttribute(pieces[0], pieces[1]) + else: + raise TypeError( + "attribute must be an OracleTypeAttribute, mapping, pair or declaration string, " + f"got {type(value).__name__}" + ) + if require_type and attribute.data_type is None: + raise ValueError("attribute data_type is required") + return attribute + + +def _coerce_attributes(value: Any, require_type: bool = True) -> List["OracleTypeAttribute"]: + if value is None: + return [] + if isinstance(value, (OracleTypeAttribute, dict)): + return [_coerce_attribute(value, require_type)] + if isinstance(value, str): + return [_coerce_attribute(value, require_type)] + if isinstance(value, tuple) and len(value) in (2, 3): + first = value[0] + if len(value) == 2 and isinstance(first, str) and " " in first: + return [_coerce_attribute(item, require_type) for item in value] + return [_coerce_attribute(value, require_type)] + if isinstance(value, (list, tuple, ABCSequence)): + return [_coerce_attribute(item, require_type) for item in value] + raise TypeError( + "attributes must be a sequence of attribute declarations, " + f"got {type(value).__name__}" + ) + + +def _coerce_method(value: Any) -> "OracleTypeMethod": + if isinstance(value, OracleTypeMethod): + return value + if isinstance(value, dict): + return OracleTypeMethod(**value) + if isinstance(value, str): + return OracleTypeMethod(_validate_fragment(value, "method")) + if isinstance(value, (tuple, list)) and len(value) in (1, 2): + return OracleTypeMethod(*value) + if isinstance(value, (tuple, list)) and len(value) in (4, 5): + return OracleTypeMethod( + kind=value[0], + name=value[1], + parameters=value[2], + return_type=value[3], + external_name=value[4] if len(value) == 5 else None, + ) + raise TypeError( + "method must be an OracleTypeMethod, mapping or declaration string, " + f"got {type(value).__name__}" + ) + + +def _coerce_methods(value: Any) -> List["OracleTypeMethod"]: + if value is None: + return [] + if isinstance(value, (OracleTypeMethod, dict, str)): + return [_coerce_method(value)] + if isinstance(value, ABCSequence): + return [_coerce_method(item) for item in value] + raise TypeError( + "methods must be a sequence of method declarations, " + f"got {type(value).__name__}" + ) + + +def _validate_object_external_names( + attributes: List["OracleTypeAttribute"], + methods: List["OracleTypeMethod"], +) -> None: + if any(attribute.external_name is not None for attribute in attributes): + raise ValueError("EXTERNAL NAME is only valid for SQLJ object types") + for method in methods: + if method.external_name is not None: + raise ValueError("EXTERNAL NAME is only valid for SQLJ object types") + if method.declaration and re.search( + r"\bEXTERNAL\s+(?:NAME|VARIABLE)\b", + method.declaration, + re.IGNORECASE, + ): + raise ValueError("EXTERNAL NAME is only valid for SQLJ object types") + + +def _coerce_dependent_handling(value: Any) -> Optional["OracleAlterTypeDependentHandling"]: + if value is None or isinstance(value, OracleAlterTypeDependentHandling): + return value + if not isinstance(value, str) or not value.strip(): + raise TypeError("dependent_handling must be an OracleAlterTypeDependentHandling or string") + normalized = re.sub(r"\s+", " ", value.strip().upper()) + for member in OracleAlterTypeDependentHandling: + if member.value == normalized: + return member + aliases = { + "CASCADE INCLUDING DATA": OracleAlterTypeDependentHandling.CASCADE_INCLUDING_TABLE_DATA, + "CASCADE NOT INCLUDING DATA": ( + OracleAlterTypeDependentHandling.CASCADE_NOT_INCLUDING_TABLE_DATA + ), + "CONVERT TO SUBSTITUTABLE": ( + OracleAlterTypeDependentHandling.CASCADE_CONVERT_TO_SUBSTITUTABLE + ), + } + if normalized in aliases: + return aliases[normalized] + raise ValueError(f"Unsupported ALTER TYPE dependent handling: {value!r}") + + +@dataclass(frozen=True) +class OracleTypeAttribute: + """One attribute declaration in an Oracle object type.""" + + name: str + data_type: Any = None + external_name: Optional[str] = None + not_null: bool = False + + def __post_init__(self) -> None: + _validate_name(self.name, "attribute name") + if self.external_name is not None: + _validate_fragment(self.external_name, "external_name") + if not isinstance(self.not_null, bool): + raise TypeError("attribute not_null must be a bool") + + +@dataclass(frozen=True) +class OracleTypeMethod: + """One method declaration in an Oracle object type.""" + + declaration: Optional[str] = None + external_name: Optional[str] = None + kind: Optional[str] = None + name: Optional[str] = None + parameters: Optional[Sequence[Any]] = None + return_type: Any = None + require_return_type: bool = True + + def __post_init__(self) -> None: + if not isinstance(self.require_return_type, bool): + raise TypeError("require_return_type must be a bool") + if self.declaration is not None: + if any( + value is not None + for value in (self.kind, self.name, self.parameters, self.return_type) + ): + raise ValueError("raw method declarations cannot include structured fields") + _validate_fragment(self.declaration, "method declaration") + if re.search(r"\b(?:IS|BEGIN|DECLARE|END)\b", self.declaration, re.IGNORECASE): + raise ValueError("method implementations belong in a separate TYPE BODY") + else: + normalized_kind = _normalize_method_kind(self.kind) + if normalized_kind is None: + raise ValueError("structured methods require a method kind") + object.__setattr__(self, "kind", normalized_kind) + if self.require_return_type and normalized_kind in _METHOD_FUNCTION_KINDS: + if self.return_type is None: + raise ValueError(f"{normalized_kind} requires return_type") + if normalized_kind in _METHOD_PROCEDURE_KINDS and self.return_type is not None: + raise ValueError(f"{normalized_kind} must not define return_type") + if normalized_kind == "CONSTRUCTOR FUNCTION": + return_text = str(self.return_type or "").strip() + if self.require_return_type and re.sub( + r"\s+", " ", return_text.upper() + ) != "SELF AS RESULT": + raise ValueError("CONSTRUCTOR FUNCTION requires RETURN SELF AS RESULT") + if self.external_name is not None: + _validate_fragment(self.external_name, "external_name") + if self.name is not None: + _validate_method_name(self.name) + if self.declaration is None and not self.name: + raise ValueError("method requires declaration or name") + if self.declaration is None: + object.__setattr__(self, "parameters", _validate_method_parameters(self.parameters)) + if self.return_type is not None and not isinstance(self.return_type, (str, BaseExpression)): + raise TypeError("return_type must be a string or expression") + if isinstance(self.return_type, str): + _validate_fragment(self.return_type, "return_type") + + +class OracleTypeDefinitionBase(TypeDefinition): + """Shared state for Oracle type definitions.""" + + def _initialize_options( + self, + under: Optional[str], + final: Optional[bool], + instantiable: Optional[bool], + persistable: Optional[bool], + not_final: Optional[bool], + not_instantiable: Optional[bool], + not_persistable: Optional[bool], + force: bool, + authid: Optional[str], + keyword: str, + editionable: Optional[bool], + sharing: Optional[str], + oid: Optional[str], + default_collation: Optional[str], + accessible_by: Any, + invoker_rights: Any, + ) -> None: + self.under = _validate_optional_name(under, "under") + self.final = _normalize_flag(final, not_final, "final") + self.not_final = not_final + self.instantiable = _normalize_flag( + instantiable, + not_instantiable, + "instantiable", + ) + self.not_instantiable = not_instantiable + self.persistable = _normalize_flag( + persistable, + not_persistable, + "persistable", + ) + self.not_persistable = not_persistable + if self.under is not None and self.persistable is not None: + raise ValueError("subtypes inherit PERSISTABLE and cannot specify it") + if not isinstance(force, bool): + raise TypeError("force must be a bool") + self.force = force + self.authid = authid.upper() if isinstance(authid, str) else authid + if self.authid is not None and self.authid not in ("CURRENT_USER", "DEFINER"): + raise ValueError("authid must be CURRENT_USER or DEFINER") + self.keyword = _normalize_keyword(keyword, "keyword") + if editionable is not None and not isinstance(editionable, bool): + raise TypeError("editionable must be a bool or None") + self.editionable = editionable + self.sharing = _validate_fragment(sharing, "sharing") if sharing is not None else None + self.oid = _validate_fragment(oid, "oid") if oid is not None else None + self.default_collation = ( + _validate_fragment(default_collation, "default_collation") + if default_collation is not None + else None + ) + self.accessible_by = _validate_accessible_by(accessible_by) + self.invoker_rights = _normalize_invoker_rights(invoker_rights) + if self.authid is not None and self.invoker_rights: + raise ValueError("authid and invoker_rights are mutually exclusive") + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + for alias in ("not_final", "not_instantiable", "not_persistable"): + params.pop(alias, None) + params["final"] = self.final + params["instantiable"] = self.instantiable + params["persistable"] = self.persistable + return params + + +class OracleObjectTypeDefinition(OracleTypeDefinitionBase): + """Oracle object type definition.""" + + definition_kind = "oracle.object" + + def __init__( + self, + dialect: "OracleDialect", + attributes: Any = None, + methods: Any = None, + *, + external_name: Optional[str] = None, + under: Optional[str] = None, + final: Optional[bool] = None, + instantiable: Optional[bool] = None, + persistable: Optional[bool] = None, + not_final: Optional[bool] = None, + not_instantiable: Optional[bool] = None, + not_persistable: Optional[bool] = None, + force: bool = False, + authid: Optional[str] = None, + keyword: str = "AS", + editionable: Optional[bool] = None, + sharing: Optional[str] = None, + oid: Optional[str] = None, + default_collation: Optional[str] = None, + accessible_by: Any = None, + invoker_rights: Any = None, + ) -> None: + super().__init__(dialect) + if external_name is not None: + raise ValueError("EXTERNAL NAME is only valid for SQLJ object types") + self.external_name = None + self.attributes = _coerce_attributes(attributes) + if not self.attributes: + raise ValueError("Oracle object type definitions require at least one attribute") + if any(attribute.not_null for attribute in self.attributes): + raise ValueError("Oracle object type attributes do not support NOT NULL") + self.methods = _coerce_methods(methods) + _validate_object_external_names(self.attributes, self.methods) + self._initialize_options( + under, + final, + instantiable, + persistable, + not_final, + not_instantiable, + not_persistable, + force, + authid, + keyword, + editionable, + sharing, + oid, + default_collation, + accessible_by, + invoker_rights, + ) + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("external_name", None) + return params + + +class OracleSqljTypeDefinition(OracleTypeDefinitionBase): + """Oracle SQLJ object type definition.""" + + definition_kind = "oracle.sqlj" + + def __init__( + self, + dialect: "OracleDialect", + attributes: Any = None, + methods: Any = None, + *, + external_name: Optional[str] = None, + language: Optional[str] = None, + using_clause: Optional[str] = None, + java_class: Optional[str] = None, + using_type: Optional[str] = None, + under: Optional[str] = None, + final: Optional[bool] = None, + instantiable: Optional[bool] = None, + persistable: Optional[bool] = None, + not_final: Optional[bool] = None, + not_instantiable: Optional[bool] = None, + not_persistable: Optional[bool] = None, + force: bool = False, + authid: Optional[str] = None, + keyword: str = "AS", + editionable: Optional[bool] = None, + sharing: Optional[str] = None, + oid: Optional[str] = None, + default_collation: Optional[str] = None, + accessible_by: Any = None, + invoker_rights: Any = None, + ) -> None: + super().__init__(dialect) + self.attributes = _coerce_attributes(attributes) + if not self.attributes: + raise ValueError("Oracle object type definitions require at least one attribute") + if any(attribute.not_null for attribute in self.attributes): + raise ValueError("Oracle object type attributes do not support NOT NULL") + self.methods = _coerce_methods(methods) + if java_class is not None: + if external_name is not None and external_name != java_class: + raise ValueError("external_name and java_class are mutually exclusive") + external_name = java_class + if using_type is not None: + if not isinstance(using_type, str) or not using_type.strip(): + raise ValueError("using_type must be a non-empty string") + if ( + using_clause is not None + and ( + not isinstance(using_clause, str) + or using_clause.strip().upper() not in ("SQLDATA", using_type.upper()) + ) + ): + raise ValueError("using_clause and using_type are mutually exclusive") + using_clause = using_type + if external_name is None: + raise ValueError("SQLJ object types require EXTERNAL NAME") + self.external_name = _validate_fragment(external_name, "external_name") + self.java_class = java_class + if not isinstance(language, str) or language.strip().upper() != "JAVA": + raise ValueError("SQLJ object types only support LANGUAGE JAVA") + self.language = "JAVA" + self.using_clause = _normalize_sqlj_using(using_clause) + self.using_type = using_type + self._initialize_options( + under, + final, + instantiable, + persistable, + not_final, + not_instantiable, + not_persistable, + force, + authid, + keyword, + editionable, + sharing, + oid, + default_collation, + accessible_by, + invoker_rights, + ) + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("java_class", None) + params.pop("using_type", None) + params["external_name"] = self.external_name + params["using_clause"] = self.using_clause + return params + + +class OracleVarrayTypeDefinition(TypeDefinition): + """Oracle named VARRAY type definition.""" + + definition_kind = "oracle.varray" + + def __init__( + self, + dialect: "OracleDialect", + element_type: Any = None, + size_limit: Any = None, + *, + force: bool = False, + not_null: bool = False, + persistable: Optional[bool] = None, + not_persistable: Optional[bool] = None, + max_size: Any = None, + max_length: Any = None, + size: Any = None, + ) -> None: + super().__init__(dialect) + if not isinstance(force, bool): + raise TypeError("force must be a bool") + if element_type is None: + raise ValueError("VARRAY element_type is required") + if isinstance(element_type, int) and not isinstance(element_type, bool): + if size_limit is not None and not isinstance(size_limit, int): + element_type, size_limit = size_limit, element_type + if size_limit is None: + size_limit = ( + max_size + if max_size is not None + else max_length + if max_length is not None + else size + ) + elif max_size is not None or max_length is not None or size is not None: + raise ValueError( + "size_limit, max_size, max_length and size are mutually exclusive aliases" + ) + if not isinstance(size_limit, int) or isinstance(size_limit, bool): + raise TypeError("size_limit must be an int") + if size_limit <= 0: + raise ValueError("size_limit must be positive") + if not isinstance(not_null, bool): + raise TypeError("not_null must be a bool") + self.element_type = element_type + self.force = force + self.size_limit = size_limit + self.max_size = max_size + self.max_length = max_length + self.size = size + self.not_null = not_null + self.persistable = _normalize_flag( + persistable, + not_persistable, + "persistable", + ) + self.not_persistable = not_persistable + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + for key in ("max_size", "max_length", "size", "not_persistable"): + params.pop(key, None) + params["size_limit"] = self.size_limit + params["persistable"] = self.persistable + return params + + +class OracleNestedTableTypeDefinition(TypeDefinition): + """Oracle named nested table type definition.""" + + definition_kind = "oracle.nested_table" + + def __init__( + self, + dialect: "OracleDialect", + element_type: Any = None, + *, + force: bool = False, + not_null: bool = False, + persistable: Optional[bool] = None, + not_persistable: Optional[bool] = None, + ) -> None: + super().__init__(dialect) + if not isinstance(force, bool): + raise TypeError("force must be a bool") + if element_type is None: + raise ValueError("nested table element_type is required") + if not isinstance(not_null, bool): + raise TypeError("not_null must be a bool") + self.element_type = element_type + self.force = force + self.not_null = not_null + self.persistable = _normalize_flag( + persistable, + not_persistable, + "persistable", + ) + self.not_persistable = not_persistable + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("not_persistable", None) + params["persistable"] = self.persistable + return params + + +class OracleIncompleteTypeDefinition(TypeDefinition): + """Oracle forward/incomplete type definition.""" + + definition_kind = "oracle.incomplete" + + def __init__(self, dialect: "OracleDialect") -> None: + super().__init__(dialect) + + +class OracleAlterTypeDependentHandling(Enum): + """Dependent handling clauses accepted after ALTER TYPE actions.""" + + INVALIDATE = "INVALIDATE" + CASCADE = "CASCADE" + CASCADE_INCLUDING_TABLE_DATA = "CASCADE INCLUDING TABLE DATA" + CASCADE_NOT_INCLUDING_TABLE_DATA = "CASCADE NOT INCLUDING TABLE DATA" + CASCADE_CONVERT_TO_SUBSTITUTABLE = "CASCADE CONVERT TO SUBSTITUTABLE" + + +class OracleTypeAlterAction(TypeAlterAction): + """Base for Oracle ALTER TYPE actions with dependent handling.""" + + def __init__( + self, + dialect: "OracleDialect", + *, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect) + self.dependent_handling = _coerce_dependent_handling(dependent_handling) + if not isinstance(force, bool): + raise TypeError("force must be a bool") + self.force = force + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params["dependent_handling"] = self.dependent_handling + params["force"] = self.force + return cast(Dict[str, Any], params) + + +class OracleAlterTypeAddAttributeAction(OracleTypeAlterAction): + """Add one or more object attributes.""" + + action_kind = "oracle.attribute.add" + + def __init__( + self, + dialect: "OracleDialect", + attribute: Any = None, + data_type: Any = None, + *, + attribute_name: Optional[str] = None, + attributes: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + supplied = [ + item + for item in (attribute, attribute_name, attributes) + if item is not None + ] + if len(supplied) > 1: + raise ValueError("attribute, attribute_name and attributes are mutually exclusive") + value = attribute if attribute is not None else attribute_name + if value is None: + value = attributes + if data_type is not None: + if isinstance(value, OracleTypeAttribute): + raise ValueError("data_type cannot be combined with OracleTypeAttribute") + value = OracleTypeAttribute(str(value), data_type) + self.attributes = _coerce_attributes(value) + if not self.attributes: + raise ValueError("ADD ATTRIBUTE requires at least one attribute") + self.attribute = self.attributes[0] if len(self.attributes) == 1 else self.attributes + self.attribute_name = attribute_name + self.data_type = data_type + self.attributes_argument = attributes + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("attribute_name", None) + params.pop("attributes", None) + params.pop("data_type", None) + params.pop("attributes_argument", None) + params["attribute"] = self.attribute + return params + + +class OracleAlterTypeModifyAttributeAction(OracleAlterTypeAddAttributeAction): + """Modify one or more scalar object attributes.""" + + action_kind = "oracle.attribute.modify" + + +class OracleAlterTypeDropAttributeAction(OracleTypeAlterAction): + """Drop one or more object attributes.""" + + action_kind = "oracle.attribute.drop" + + def __init__( + self, + dialect: "OracleDialect", + attribute: Any = None, + *, + attribute_name: Optional[str] = None, + attributes: Any = None, + names: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + supplied = [ + item + for item in (attribute, attribute_name, attributes, names) + if item is not None + ] + if len(supplied) > 1: + raise ValueError( + "attribute, attribute_name, attributes and names are mutually exclusive" + ) + if not supplied: + raise ValueError("DROP ATTRIBUTE requires at least one attribute name") + value = attribute if attribute is not None else attribute_name + if value is None: + value = attributes if attributes is not None else names + if isinstance(value, str): + values = [value] + elif isinstance(value, (list, tuple, ABCSequence)): + values = list(value) + else: + values = [value] + self.attributes = [_coerce_attribute(item, require_type=False) for item in values] + self.attribute = self.attributes[0] if len(self.attributes) == 1 else self.attributes + self.attribute_name = attribute_name + self.names = names + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("attribute_name", None) + params.pop("attributes", None) + params.pop("names", None) + params["attribute"] = self.attribute + return params + + +class OracleAlterTypeAddMethodAction(OracleTypeAlterAction): + """Add one or more method declarations.""" + + action_kind = "oracle.method.add" + + def __init__( + self, + dialect: "OracleDialect", + method: Any = None, + *, + declaration: Any = None, + external_name: Optional[str] = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + if method is not None and declaration is not None: + raise ValueError("method and declaration are mutually exclusive") + value = method if method is not None else declaration + if isinstance(value, OracleTypeMethod) and external_name is not None: + value = OracleTypeMethod( + declaration=value.declaration, + external_name=external_name, + kind=value.kind, + name=value.name, + parameters=value.parameters, + return_type=value.return_type, + ) + elif external_name is not None: + value = OracleTypeMethod(declaration=str(value), external_name=external_name) + self.method = _coerce_method(value) + self.declaration = declaration + self.external_name = external_name + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("declaration", None) + params.pop("external_name", None) + params["method"] = self.method + return params + + +class OracleAlterTypeDropMethodAction(OracleTypeAlterAction): + """Drop one or more method specifications.""" + + action_kind = "oracle.method.drop" + + def __init__( + self, + dialect: "OracleDialect", + method: Any = None, + *, + name: Optional[str] = None, + method_name: Optional[str] = None, + kind: Optional[str] = None, + parameters: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + supplied = [item for item in (method, name, method_name) if item is not None] + if len(supplied) > 1: + raise ValueError("method, name and method_name are mutually exclusive") + if method is None and (name is not None or method_name is not None): + method = OracleTypeMethod( + kind=kind or "MEMBER FUNCTION", + name=name or method_name, + parameters=parameters, + require_return_type=False, + ) + self.method = _coerce_method(method) + self.name = name + self.method_name = method_name or name + self.kind = kind + self.parameters = parameters + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + for alias in ("name", "method_name", "kind", "parameters"): + params.pop(alias, None) + params["method"] = self.method + return cast(Dict[str, Any], params) + + +class OracleAlterTypeLimitAction(OracleTypeAlterAction): + """Increase a VARRAY type's element limit.""" + + action_kind = "oracle.limit" + + def __init__( + self, + dialect: "OracleDialect", + limit: Any = None, + *, + size_limit: Any = None, + new_limit: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + supplied = [item for item in (limit, size_limit, new_limit) if item is not None] + if len(supplied) > 1: + raise ValueError("limit, size_limit and new_limit are mutually exclusive") + value = limit if limit is not None else size_limit + if value is None: + value = new_limit + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError("limit must be a positive integer") + self.limit = value + self.size_limit = size_limit + self.new_limit = new_limit + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("size_limit", None) + params.pop("new_limit", None) + return params + + +class OracleAlterTypeElementTypeAction(OracleTypeAlterAction): + """Modify a collection element data type.""" + + action_kind = "oracle.element_type" + + def __init__( + self, + dialect: "OracleDialect", + element_type: Any = None, + *, + new_element_type: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + if element_type is not None and new_element_type is not None: + raise ValueError("element_type and new_element_type are mutually exclusive") + self.element_type = element_type if element_type is not None else new_element_type + self.new_element_type = new_element_type + if self.element_type is None: + raise ValueError("MODIFY ELEMENT TYPE requires a data type") + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("new_element_type", None) + return params + + +class OracleAlterTypeCompileAction(OracleTypeAlterAction): + """Compile an Oracle type specification or body.""" + + action_kind = "oracle.compile" + + def __init__( + self, + dialect: "OracleDialect", + *, + debug: bool = False, + target: Optional[str] = None, + compile_target: Optional[str] = None, + reuse_settings: bool = False, + compiler_parameters: Any = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + if target is not None and compile_target is not None and target != compile_target: + raise ValueError("target and compile_target are mutually exclusive") + if target is None: + target = compile_target + if target is not None: + target = str(target).upper() + if target not in ("SPECIFICATION", "BODY"): + raise ValueError("target must be SPECIFICATION or BODY") + if not isinstance(debug, bool) or not isinstance(reuse_settings, bool): + raise TypeError("debug and reuse_settings must be bools") + if compiler_parameters is None: + compiler_parameters = [] + elif isinstance(compiler_parameters, str): + compiler_parameters = [compiler_parameters] + else: + compiler_parameters = list(compiler_parameters) + self.debug = debug + self.target = target + self.compile_target = compile_target + self.reuse_settings = reuse_settings + self.compiler_parameters = compiler_parameters + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("compile_target", None) + return params + + +class OracleAlterTypeFinalAction(OracleTypeAlterAction): + """Change the FINAL property of an object type.""" + + action_kind = "oracle.final" + + def __init__( + self, + dialect: "OracleDialect", + final: Optional[bool] = None, + *, + is_final: Optional[bool] = None, + not_final: Optional[bool] = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + if final is not None and is_final is not None: + raise ValueError("final and is_final are mutually exclusive") + if final is None: + final = is_final + self.final = _normalize_flag(final, not_final, "final") + if self.final is None: + self.final = True + self.is_final = is_final + self.not_final = not_final + + def get_params(self) -> Dict[str, Any]: + params = super().get_params() + params.pop("is_final", None) + params.pop("not_final", None) + return params + + +class OracleAlterTypeInstantiableAction(OracleAlterTypeFinalAction): + """Change the INSTANTIABLE property of an object type.""" + + action_kind = "oracle.instantiable" + + def __init__( + self, + dialect: "OracleDialect", + instantiable: Optional[bool] = None, + *, + is_instantiable: Optional[bool] = None, + not_instantiable: Optional[bool] = None, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + OracleTypeAlterAction.__init__( + self, + dialect, + dependent_handling=dependent_handling, + force=force, + ) + if instantiable is not None and is_instantiable is not None: + raise ValueError("instantiable and is_instantiable are mutually exclusive") + if instantiable is None: + instantiable = is_instantiable + self.instantiable = _normalize_flag( + instantiable, + not_instantiable, + "instantiable", + ) + self.is_instantiable = is_instantiable + if self.instantiable is None: + self.instantiable = True + self.not_instantiable = not_instantiable + self.final = None + self.not_final = None + + def get_params(self) -> Dict[str, Any]: + params = OracleTypeAlterAction.get_params(self) + params.pop("final", None) + params.pop("not_final", None) + params.pop("is_instantiable", None) + params.pop("not_instantiable", None) + return params + + +class OracleAlterTypeResetAction(OracleTypeAlterAction): + """Reset an evolved type to version one.""" + + action_kind = "oracle.reset" + + def __init__( + self, + dialect: "OracleDialect", + *, + dependent_handling: Any = None, + force: bool = False, + ) -> None: + super().__init__(dialect, dependent_handling=dependent_handling, force=force) + + +class OracleCreateTypeBodyExpression(BaseExpression): + """Oracle CREATE TYPE BODY expression.""" + + def __init__( + self, + dialect: "OracleDialect", + type_name: str, + body: str, + *, + schema_name: Optional[str] = None, + schema: Optional[str] = None, + if_not_exists: bool = False, + or_replace: bool = False, + keyword: str = "AS", + editionable: Optional[bool] = None, + ) -> None: + super().__init__(dialect) + _validate_name(type_name, "type_name") + if schema_name is not None and schema is not None and schema_name != schema: + raise ValueError("schema_name and schema are mutually exclusive") + schema_name = schema_name if schema_name is not None else schema + _validate_optional_name(schema_name, "schema_name") + if not isinstance(body, str) or not body.strip(): + raise ValueError("body must be a non-empty string") + if if_not_exists and or_replace: + raise ValueError("CREATE TYPE BODY IF NOT EXISTS and OR REPLACE are mutually exclusive") + self.type_name = type_name + self.body = body + self.schema_name = schema_name + self.schema = schema + self.if_not_exists = bool(if_not_exists) + self.or_replace = bool(or_replace) + self.keyword = _normalize_keyword(keyword, "keyword") + if editionable is not None and not isinstance(editionable, bool): + raise TypeError("editionable must be a bool or None") + self.editionable = editionable + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("schema", None) + return params + + @property + def format_method(self) -> str: + return "format_create_type_body_statement" + + +class OracleDropTypeExpression(DropTypeExpression): + """Oracle DROP TYPE expression with FORCE or VALIDATE.""" + + def __init__( + self, + dialect: "OracleDialect", + type_name: str, + *, + schema_name: Optional[str] = None, + schema: Optional[str] = None, + if_exists: bool = False, + force: bool = False, + validate: bool = False, + ) -> None: + if schema_name is not None and schema is not None and schema_name != schema: + raise ValueError("schema_name and schema are mutually exclusive") + schema_name = schema_name if schema_name is not None else schema + super().__init__( + dialect, + type_name, + schema_name=schema_name, + if_exists=if_exists, + ) + if force and validate: + raise ValueError("DROP TYPE FORCE and VALIDATE are mutually exclusive") + if not isinstance(force, bool) or not isinstance(validate, bool): + raise TypeError("force and validate must be bools") + self.schema = schema + self.force = force + self.validate = validate + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("schema", None) + return params + + +class DropTypeBodyExpression(BaseExpression): + """Oracle DROP TYPE BODY expression.""" + + def __init__( + self, + dialect: "OracleDialect", + type_name: str, + *, + schema_name: Optional[str] = None, + schema: Optional[str] = None, + if_exists: bool = False, + ) -> None: + super().__init__(dialect) + _validate_name(type_name, "type_name") + if schema_name is not None and schema is not None and schema_name != schema: + raise ValueError("schema_name and schema are mutually exclusive") + schema_name = schema_name if schema_name is not None else schema + _validate_optional_name(schema_name, "schema_name") + self.type_name = type_name + self.schema_name = schema_name + self.schema = schema + self.if_exists = bool(if_exists) + + def get_params(self) -> Dict[str, Any]: + params = cast(Dict[str, Any], super().get_params()) + params.pop("schema", None) + return params + + @property + def format_method(self) -> str: + return "format_drop_type_body_statement" + + +class OracleDropTypeBodyExpression(DropTypeBodyExpression): + """Oracle-prefixed alias for DROP TYPE BODY.""" + + +OracleTypeAddAttributeAction = OracleAlterTypeAddAttributeAction +OracleTypeModifyAttributeAction = OracleAlterTypeModifyAttributeAction +OracleTypeDropAttributeAction = OracleAlterTypeDropAttributeAction +OracleTypeAddMethodAction = OracleAlterTypeAddMethodAction +OracleTypeDropMethodAction = OracleAlterTypeDropMethodAction +OracleTypeLimitAction = OracleAlterTypeLimitAction +OracleTypeElementTypeAction = OracleAlterTypeElementTypeAction +OracleTypeCompileAction = OracleAlterTypeCompileAction +OracleTypeFinalAction = OracleAlterTypeFinalAction +OracleTypeInstantiableAction = OracleAlterTypeInstantiableAction +OracleTypeResetAction = OracleAlterTypeResetAction +OracleAddTypeAttributeAction = OracleAlterTypeAddAttributeAction +OracleModifyTypeAttributeAction = OracleAlterTypeModifyAttributeAction +OracleDropTypeAttributeAction = OracleAlterTypeDropAttributeAction +OracleAddTypeMethodAction = OracleAlterTypeAddMethodAction +OracleDropTypeMethodAction = OracleAlterTypeDropMethodAction +OracleModifyTypeLimitAction = OracleAlterTypeLimitAction +OracleTypeBodyCreateExpression = OracleCreateTypeBodyExpression +OracleTypeBodyDropExpression = DropTypeBodyExpression +OracleTypeDependentHandling = OracleAlterTypeDependentHandling +OracleAlterTypeAttributeAction = OracleAlterTypeAddAttributeAction +OracleAlterTypeMethodAction = OracleAlterTypeAddMethodAction +OracleAlterTypeModifyLimitAction = OracleAlterTypeLimitAction +OracleCompileTypeAction = OracleAlterTypeCompileAction +OracleSetTypeFinalAction = OracleAlterTypeFinalAction +OracleSetTypeInstantiableAction = OracleAlterTypeInstantiableAction +OracleDropTypeWithOptionsExpression = OracleDropTypeExpression + + +_EXPRESSION_TYPES = ( + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleVarrayTypeDefinition, + OracleNestedTableTypeDefinition, + OracleIncompleteTypeDefinition, + OracleAlterTypeAddAttributeAction, + OracleAlterTypeModifyAttributeAction, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeLimitAction, + OracleAlterTypeElementTypeAction, + OracleAlterTypeCompileAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeResetAction, + OracleCreateTypeBodyExpression, + OracleDropTypeExpression, + DropTypeBodyExpression, + OracleDropTypeBodyExpression, +) +for _expression_type in _EXPRESSION_TYPES: + ExpressionRegistry.register(_expression_type) + + +__all__ = [ + "OracleTypeAttribute", + "OracleTypeMethod", + "OracleObjectTypeDefinition", + "OracleSqljTypeDefinition", + "OracleVarrayTypeDefinition", + "OracleNestedTableTypeDefinition", + "OracleIncompleteTypeDefinition", + "OracleAlterTypeDependentHandling", + "OracleTypeAlterAction", + "OracleAlterTypeAddAttributeAction", + "OracleAlterTypeModifyAttributeAction", + "OracleAlterTypeDropAttributeAction", + "OracleAlterTypeAddMethodAction", + "OracleAlterTypeDropMethodAction", + "OracleAlterTypeLimitAction", + "OracleAlterTypeElementTypeAction", + "OracleAlterTypeCompileAction", + "OracleAlterTypeFinalAction", + "OracleAlterTypeInstantiableAction", + "OracleAlterTypeResetAction", + "OracleCreateTypeBodyExpression", + "OracleDropTypeExpression", + "DropTypeBodyExpression", + "OracleDropTypeBodyExpression", + "OracleTypeAddAttributeAction", + "OracleTypeModifyAttributeAction", + "OracleTypeDropAttributeAction", + "OracleTypeAddMethodAction", + "OracleTypeDropMethodAction", + "OracleTypeLimitAction", + "OracleTypeElementTypeAction", + "OracleTypeCompileAction", + "OracleTypeFinalAction", + "OracleTypeInstantiableAction", + "OracleTypeResetAction", + "OracleAddTypeAttributeAction", + "OracleModifyTypeAttributeAction", + "OracleDropTypeAttributeAction", + "OracleAddTypeMethodAction", + "OracleDropTypeMethodAction", + "OracleModifyTypeLimitAction", + "OracleTypeBodyCreateExpression", + "OracleTypeBodyDropExpression", + "OracleTypeDependentHandling", + "OracleAlterTypeAttributeAction", + "OracleAlterTypeMethodAction", + "OracleAlterTypeModifyLimitAction", + "OracleCompileTypeAction", + "OracleSetTypeFinalAction", + "OracleSetTypeInstantiableAction", + "OracleDropTypeWithOptionsExpression", +] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/flashback.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/flashback.py index 6b5ae41..dd2b2cf 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/flashback.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/flashback.py @@ -22,9 +22,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import OracleDialect @@ -57,7 +57,6 @@ class OracleAsOfClause(BaseExpression): value: the timestamp/SCN value. Accepts a ``BaseExpression`` (rendered via ``to_sql()``) or a raw SQL fragment string, e.g. ``"SYSTIMESTAMP - INTERVAL '1' DAY"``. - dialect_options: reserved for future dialect-specific options. Raises: TypeError: if ``mode`` is not an :class:`OracleAsOfMode`. @@ -68,8 +67,6 @@ def __init__( dialect: "OracleDialect", mode: OracleAsOfMode, value: Any, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(mode, OracleAsOfMode): @@ -79,7 +76,6 @@ def __init__( ) self.mode = mode self.value = value - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -98,7 +94,6 @@ class OracleVersionsBetweenClause(BaseExpression): mode: ``TIMESTAMP`` or ``SCN``. low_value: the lower bound of the range. high_value: the upper bound of the range. - dialect_options: reserved for future dialect-specific options. Raises: TypeError: if ``mode`` is not an :class:`OracleVersionsBetweenMode`. @@ -110,8 +105,6 @@ def __init__( mode: OracleVersionsBetweenMode, low_value: Any, high_value: Any, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(mode, OracleVersionsBetweenMode): @@ -122,7 +115,6 @@ def __init__( self.mode = mode self.low_value = low_value self.high_value = high_value - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -149,7 +141,6 @@ class OracleFlashbackTableExpression(BaseExpression): rename_to: with ``to_before_drop``, rename the restored table. enable_triggers: append ``ENABLE TRIGGERS``. disable_triggers: append ``DISABLE TRIGGERS``. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``table`` is empty, if no flashback target clause is @@ -167,8 +158,6 @@ def __init__( rename_to: Optional[str] = None, enable_triggers: bool = False, disable_triggers: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(table, str) or not table.strip(): @@ -195,7 +184,6 @@ def __init__( self.rename_to = rename_to self.enable_triggers = bool(enable_triggers) self.disable_triggers = bool(disable_triggers) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -223,7 +211,6 @@ class OraclePurgeExpression(BaseExpression): object_type: ``TABLE``, ``INDEX`` or ``RECYCLEBIN``. object_name: the object name; required for ``TABLE``/``INDEX`` and must be ``None`` for ``RECYCLEBIN``. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``object_type`` is ``TABLE``/``INDEX`` without an @@ -236,8 +223,6 @@ def __init__( dialect: "OracleDialect", object_type: OraclePurgeObjectType, object_name: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(object_type, OraclePurgeObjectType): @@ -252,7 +237,6 @@ def __init__( raise ValueError("object_name must be a non-empty string") self.object_type = object_type self.object_name = object_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/locking.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/locking.py index 842b279..718ceaf 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/locking.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/locking.py @@ -36,7 +36,6 @@ def __init__( nowait: bool = False, skip_locked: bool = False, wait: Optional[int] = None, - dialect_options: Optional[dict] = None, ): super().__init__( dialect, @@ -44,7 +43,6 @@ def __init__( of_columns=of_columns, nowait=nowait, skip_locked=skip_locked, - dialect_options=dialect_options, ) if sum([bool(nowait), wait is not None, bool(skip_locked)]) > 1: raise ValueError("NOWAIT, WAIT n, and SKIP LOCKED are mutually exclusive") diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/materialized_view.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/materialized_view.py index 9ad5629..544327a 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/materialized_view.py @@ -11,6 +11,9 @@ LOG ON t WITH { ROWID | PRIMARY KEY }``. * ``OracleDropMaterializedViewExpression`` — ``DROP MATERIALIZED VIEW`` with the optional ``PRESERVE TABLE`` clause. +* ``OracleRefreshMaterializedViewExpression`` — ``DBMS_MVIEW.REFRESH`` inside a + PL/SQL block. Oracle has **no** standalone ``REFRESH MATERIALIZED VIEW`` + statement, so refreshing is a stored-procedure call. Clause order follows the Oracle SQL Language Reference grammar: ``name [column_aliases] [TABLESPACE] [BUILD ...] [REFRESH ...] @@ -22,9 +25,9 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, List, Optional, TYPE_CHECKING +from typing import List, Optional, TYPE_CHECKING -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams +from rhosocial.activerecord.backend.expression.bases import BaseExpression if TYPE_CHECKING: # pragma: no cover from ..dialect import OracleDialect @@ -70,7 +73,6 @@ class OracleCreateMaterializedViewExpression(BaseExpression): ``REFRESH`` clause). query_rewrite: ``ENABLE QUERY REWRITE`` when True, ``DISABLE QUERY REWRITE`` when False, omitted when None. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``view_name`` is empty. @@ -89,8 +91,6 @@ def __init__( refresh_method: Optional[MaterializedViewRefreshMethod] = None, refresh_trigger: Optional[MaterializedViewRefreshTrigger] = None, query_rewrite: Optional[bool] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(view_name, str) or not view_name.strip(): @@ -109,7 +109,6 @@ def __init__( self.refresh_method = refresh_method self.refresh_trigger = refresh_trigger self.query_rewrite = query_rewrite - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -125,7 +124,6 @@ class OracleCreateMaterializedViewLogExpression(BaseExpression): table: name of the master (base) table to log. with_rowid: emit ``WITH ROWID``. with_primary_key: emit ``WITH PRIMARY KEY``. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``table`` is empty, or neither ``with_rowid`` nor @@ -138,8 +136,6 @@ def __init__( table: str, with_rowid: bool = False, with_primary_key: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(table, str) or not table.strip(): @@ -151,7 +147,6 @@ def __init__( self.table = table self.with_rowid = bool(with_rowid) self.with_primary_key = bool(with_primary_key) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -168,7 +163,6 @@ class OracleDropMaterializedViewExpression(BaseExpression): if_exists: if True, emit ``IF EXISTS`` (Oracle 23ai+). preserve_table: if True, append ``PRESERVE TABLE`` to keep the underlying container table. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``view_name`` is empty. @@ -180,8 +174,6 @@ def __init__( view_name: str, if_exists: bool = False, preserve_table: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(view_name, str) or not view_name.strip(): @@ -189,9 +181,88 @@ def __init__( self.view_name = view_name self.if_exists = bool(if_exists) self.preserve_table = bool(preserve_table) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: """The dialect formatting method that renders this expression.""" return "format_drop_materialized_view_statement" + + +class OracleRefreshMethod(Enum): + """``DBMS_MVIEW.REFRESH`` method codes. + + These are the *procedure* codes, which differ from the ``REFRESH ON DEMAND`` + / ``REFRESH FAST`` DDL keywords: ``f`` fast, ``C`` complete, ``?`` force, + ``P`` partition change tracking, ``A`` always (equivalent to complete). + """ + + FAST = "F" + COMPLETE = "C" + FORCE = "?" + PARTITION_CHANGE_TRACKING = "P" + ALWAYS = "A" + + +class OracleRefreshMaterializedViewExpression(BaseExpression): + """Oracle ``DBMS_MVIEW.REFRESH('mv', ...)`` expression. + + Oracle exposes materialized view refresh only through the + ``DBMS_MVIEW.REFRESH`` PL/SQL procedure, so the rendered statement is a + ``BEGIN ... END;`` anonymous block. + + Args: + dialect: the Oracle dialect instance. + view_name: name of the materialized view to refresh. + schema: optional owner/schema of the materialized view. + method: refresh method code, e.g. ``OracleRefreshMethod.COMPLETE``. + atomic_refresh: refresh the list in a single transaction (server default + is ``TRUE``). + out_of_place: perform an out-of-place refresh. + nested: also refresh dependent materialized views in dependency order. + parallelism: DML parallelism degree (``0`` = serial). + purge_option: ``0`` none, ``1`` lazy (server default), ``2`` aggressive. + refresh_after_errors: continue past conflicts in ``DEFERROR``. + push_deferred_rpc: push deferred changes from an updatable MV first. + + Raises: + ValueError: if ``view_name`` is empty. + """ + + def __init__( + self, + dialect: "OracleDialect", + view_name: str, + schema: Optional[str] = None, + method: Optional[OracleRefreshMethod] = None, + atomic_refresh: Optional[bool] = None, + out_of_place: Optional[bool] = None, + nested: Optional[bool] = None, + parallelism: Optional[int] = None, + purge_option: Optional[int] = None, + refresh_after_errors: Optional[bool] = None, + push_deferred_rpc: Optional[bool] = None, + ): + super().__init__(dialect) + if not isinstance(view_name, str) or not view_name.strip(): + raise ValueError("view_name must be a non-empty string") + if schema is not None and (not isinstance(schema, str) or not schema.strip()): + raise ValueError("schema must be a non-empty string when provided") + if purge_option is not None and purge_option not in (0, 1, 2): + raise ValueError("purge_option must be 0 (none), 1 (lazy) or 2 (aggressive)") + if parallelism is not None and (not isinstance(parallelism, int) or parallelism < 0): + raise ValueError("parallelism must be a non-negative integer") + self.view_name = view_name + self.schema = schema + self.method = method + self.atomic_refresh = atomic_refresh + self.out_of_place = out_of_place + self.nested = nested + self.parallelism = parallelism + self.purge_option = purge_option + self.refresh_after_errors = refresh_after_errors + self.push_deferred_rpc = push_deferred_rpc + + @property + def format_method(self) -> str: + """The dialect formatting method that renders this expression.""" + return "format_refresh_materialized_view_statement" diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/partition.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/partition.py index 47320d7..46083ca 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/partition.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/partition.py @@ -1,9 +1,8 @@ # src/rhosocial/activerecord/backend/impl/oracle/expression/partition.py """Oracle partition DDL expressions. -This module defines backend-specific partition clause expressions that -upgrade Phase 4's ``dialect_options``-based partition definition carrying -into structured ``Expression`` / dataclass objects. +This module defines backend-specific partition clause expressions as +structured ``Expression`` / dataclass objects. Phase 5 scope (this module): @@ -34,10 +33,14 @@ from decimal import Decimal from enum import Enum from math import isfinite -from typing import Any, Dict, List, Optional, Sequence, TYPE_CHECKING, Union +from typing import Any, List, Optional, Sequence, TYPE_CHECKING, Union -from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams -from rhosocial.activerecord.backend.expression.statements import PartitionClause +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements import ( + PartitionClause, + PartitionDefinition, + SubpartitionDefinition, +) if TYPE_CHECKING: # pragma: no cover from ..dialect import OracleDialect @@ -135,37 +138,31 @@ def format_method(self) -> str: @dataclass -class OracleSubpartitionDefinition: - """A single named subpartition within a partition definition. +class OracleSubpartitionDefinition(SubpartitionDefinition): + """A single named Oracle subpartition within a partition definition. - Used when individual subpartitions need explicit names or distinct - boundary values. When omitted, Oracle applies the template from the - ``SUBPARTITION BY`` clause automatically. + Derives from the generic ``SubpartitionDefinition`` and adds Oracle's + subpartition boundary fields (RANGE ``less_than`` / LIST ``in_values``). + When omitted, Oracle applies the template from the ``SUBPARTITION BY`` + clause automatically. Raises: ValueError: if ``name`` is empty or whitespace-only. - TypeError: if ``dialect_options`` is not a dict when provided. """ - name: str less_than: Optional[Sequence[BaseExpression]] = None in_values: Optional[Sequence[Union[BaseExpression, Sequence[BaseExpression]]]] = None - dialect_options: Optional[Dict[str, Any]] = None - - def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name.strip(): - raise ValueError("subpartition name must be a non-empty string") - if self.dialect_options is not None and not isinstance(self.dialect_options, dict): - raise TypeError( - "dialect_options must be dict or None, " - f"got {type(self.dialect_options).__name__}" - ) @dataclass -class OraclePartitionDefinition: +class OraclePartitionDefinition(PartitionDefinition): """An Oracle ``PARTITION ... VALUES ...`` definition. + Derives from the generic ``PartitionDefinition``. Oracle permits a + boundary to be omitted (HASH / composite HASH subpartitioning), so the + base's "mutually exclusive" check is the only boundary validation here; + the strategy-specific requirement is enforced by the formatter. + For RANGE: provide ``less_than`` (sequence of ``BaseExpression`` or ``OraclePartitionMaxValue``). The special ``MAXVALUE`` upper bound is expressed via :class:`OraclePartitionMaxValue`. @@ -179,30 +176,10 @@ class OraclePartitionDefinition: be provided to override the template. Raises: - ValueError: if both ``less_than`` and ``in_values`` are provided, - or if neither is provided when the strategy requires - boundaries (the strategy check is enforced by the - formatter; this dataclass only rejects the mutual - exclusion violation). - TypeError: if ``dialect_options`` is not a dict when provided. + ValueError: if both ``less_than`` and ``in_values`` are provided. """ - name: str - less_than: Optional[Sequence[BaseExpression]] = None - in_values: Optional[Sequence[Union[BaseExpression, Sequence[BaseExpression]]]] = None subpartition_definitions: Optional[Sequence[OracleSubpartitionDefinition]] = None - dialect_options: Optional[Dict[str, Any]] = None - - def __post_init__(self) -> None: - if not isinstance(self.name, str) or not self.name.strip(): - raise ValueError("partition name must be a non-empty string") - if self.less_than is not None and self.in_values is not None: - raise ValueError("less_than and in_values are mutually exclusive") - if self.dialect_options is not None and not isinstance(self.dialect_options, dict): - raise TypeError( - "dialect_options must be dict or None, " - f"got {type(self.dialect_options).__name__}" - ) # --------------------------------------------------------------------------- @@ -430,5 +407,4 @@ def __init__( raise ValueError("fk_constraint must be a non-empty string") self.method = OraclePartitionStrategy.REFERENCE.value self.keys: List[BaseExpression] = [] - self.dialect_options: Dict[str, Any] = {} self.fk_constraint = fk_constraint diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/sequence.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/sequence.py index 5111ee3..b24b044 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/sequence.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/sequence.py @@ -17,7 +17,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression, SQLQueryAndParams @@ -92,7 +92,6 @@ class OracleCreateSequenceExpression(BaseExpression): cache: ``CACHE n`` for a positive int, ``NOCACHE`` for ``0``, omitted when None. order: ``ORDER`` when True, ``NOORDER`` when False, omitted when None. - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``sequence_name`` is empty, or ``cache`` is negative. @@ -111,8 +110,6 @@ def __init__( cycle: Optional[bool] = None, cache: Optional[int] = None, order: Optional[bool] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(sequence_name, str) or not sequence_name.strip(): @@ -134,7 +131,6 @@ def __init__( self.cycle = cycle self.cache = cache self.order = order - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -149,7 +145,6 @@ class OracleDropSequenceExpression(BaseExpression): dialect: the Oracle dialect instance. sequence_name: name of the sequence to drop. if_exists: if True, emit ``IF EXISTS`` (Oracle 23ai+). - dialect_options: reserved for future dialect-specific options. Raises: ValueError: if ``sequence_name`` is empty. @@ -160,15 +155,12 @@ def __init__( dialect: "OracleDialect", sequence_name: str, if_exists: bool = False, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) if not isinstance(sequence_name, str) or not sequence_name.strip(): raise ValueError("sequence_name must be a non-empty string") self.sequence_name = sequence_name self.if_exists = bool(if_exists) - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/table.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/table.py index 765654c..1a010ad 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/table.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/table.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -29,19 +29,15 @@ class TableCompressionClauseExpression(BaseExpression): mode: the compression mode — ``'none'`` (or empty) yields ``NOCOMPRESS``; any other value becomes ``COMPRESS FOR `` (uppercased). - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", mode: str = "BASIC", - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.mode = mode - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -57,19 +53,15 @@ class TablespaceClauseExpression(BaseExpression): Args: dialect: the Oracle dialect instance. tablespace_name: the tablespace identifier. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", tablespace_name: str, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.tablespace_name = tablespace_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/trigger.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/trigger.py index 7c960b2..a761fce 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/trigger.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/trigger.py @@ -10,7 +10,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Optional, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -27,7 +27,6 @@ class DisableTriggerExpression(BaseExpression): dialect: the Oracle dialect instance. trigger_name: the trigger to disable. table_name: optional table name (reserved for future use). - dialect_options: reserved for future dialect-specific options. """ def __init__( @@ -35,13 +34,10 @@ def __init__( dialect: "OracleDialect", trigger_name: str, table_name: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.trigger_name = trigger_name self.table_name = table_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -58,7 +54,6 @@ class EnableTriggerExpression(BaseExpression): dialect: the Oracle dialect instance. trigger_name: the trigger to enable. table_name: optional table name (reserved for future use). - dialect_options: reserved for future dialect-specific options. """ def __init__( @@ -66,13 +61,10 @@ def __init__( dialect: "OracleDialect", trigger_name: str, table_name: Optional[str] = None, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.trigger_name = trigger_name self.table_name = table_name - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/types.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/types.py index 9ce7efb..e255691 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/types.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/types.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Set +from typing import Optional, Set from rhosocial.activerecord.backend.expression.types import ( BigIntType, @@ -163,9 +163,8 @@ class OracleRawType(BlobType): length: Optional[int] = None - def __init__(self, dialect=None, *, length: Optional[int] = None, - dialect_options: Optional[Dict[str, Any]] = None): - super().__init__(dialect, dialect_options=dialect_options) + def __init__(self, dialect=None, *, length: Optional[int] = None): + super().__init__(dialect) self.length = length def _type_params(self) -> tuple: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/expression/vector.py b/src/rhosocial/activerecord/backend/impl/oracle/expression/vector.py index 3c37f7b..3efaec7 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/expression/vector.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/expression/vector.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, TYPE_CHECKING from rhosocial.activerecord.backend.expression.bases import BaseExpression @@ -27,19 +27,15 @@ class VectorLiteralExpression(BaseExpression): dialect: the Oracle dialect instance. vec: the vector value — a list/tuple of numbers, a string, or an ``oracledb.Vector`` instance. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", vec: Any, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.vec = vec - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: @@ -56,19 +52,15 @@ class VectorOperandExpression(BaseExpression): Args: dialect: the Oracle dialect instance. operand: the vector operand value. - dialect_options: reserved for future dialect-specific options. """ def __init__( self, dialect: "OracleDialect", operand: Any, - *, - dialect_options: Optional[Dict[str, Any]] = None, ): super().__init__(dialect) self.operand = operand - self.dialect_options = dialect_options or {} @property def format_method(self) -> str: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/__init__.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/__init__.py index f8d39ba..ba66188 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/__init__.py @@ -14,6 +14,7 @@ from .database_link import OracleDatabaseLinkMixin from .datetime_op import OracleDateTimeMixin from .ddl import OracleDDLMixin +from .ddl_type import OracleTypeDDLMixin from .dml import OracleDMLOperationMixin from .expression import OracleExpressionMixin from .features import OracleFeaturesMixin @@ -29,7 +30,6 @@ from .optimizer_hint import OracleOptimizerHintMixin from .pagination import OraclePaginationMixin from .partition import OraclePartitionMixin -from .partition_lifecycle import OraclePartitionLifecycleMixin from .pivot import OraclePivotMixin from .routine import OracleRoutineMixin from .schema import OracleSchemaMixin @@ -54,6 +54,7 @@ "OracleCollationMixin", "OracleDateTimeMixin", "OracleDDLMixin", + "OracleTypeDDLMixin", "OracleExpressionMixin", "OracleFeaturesMixin", "OracleFunctionFormatMixin", @@ -73,7 +74,6 @@ "OracleOptimizerHintMixin", "OraclePaginationMixin", "OraclePartitionMixin", - "OraclePartitionLifecycleMixin", "OraclePivotMixin", "OracleRoutineMixin", "OracleSchemaMixin", diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/comment.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/comment.py index 06323d5..7a95cb2 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/comment.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/comment.py @@ -1,14 +1,10 @@ # src/rhosocial/activerecord/backend/impl/oracle/mixins/comment.py """Oracle COMMENT ON formatter mixin.""" -from typing import Tuple, TYPE_CHECKING +from typing import Tuple from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError -if TYPE_CHECKING: # pragma: no cover - from ..expression.comment import OracleCommentExpression - - class OracleCommentMixin: """Oracle ``COMMENT ON`` capability check and formatter. @@ -18,12 +14,23 @@ class OracleCommentMixin: statement, never through an inline column clause. """ - def supports_comment(self) -> bool: + def supports_comment_on(self) -> bool: + """Whether standalone ``COMMENT ON`` statements are supported. + + Oracle annotates schema objects through the standalone statement; + always ``True`` (the version gate lives in + :meth:`format_comment_statement`). + """ return True def format_comment_statement( - self, expr: "OracleCommentExpression" + self, expr ) -> Tuple[str, tuple]: + """Format a standalone ``COMMENT ON`` statement. + + Accepts any comment expression carrying ``object_type``, + ``object_name`` and ``comment``. + """ if self.version < (9, 0, 0): raise UnsupportedFeatureError( self.name, @@ -38,7 +45,8 @@ def format_comment_statement( object_sql = ".".join( self.format_identifier(part) for part in expr.object_name.split(".") ) - head = f"COMMENT ON {expr.object_type.value} {object_sql} IS" + object_type = getattr(expr.object_type, "value", expr.object_type) + head = f"COMMENT ON {object_type} {object_sql} IS" if expr.comment is None: return f"{head} NULL", () escaped = self._escape_sql_string(expr.comment) diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl.py index 556138d..b68c89d 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl.py @@ -32,6 +32,32 @@ def format_create_table_statement( PL/SQL block that checks ``user_tables`` and only executes the DDL when the table does not exist, making creation idempotent. """ + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + + table_options = getattr(expr, "table_options", None) + if getattr(table_options, "comment", None): + # Oracle annotates comments through the standalone COMMENT ON + # statement (no inline table option); a comment on the table + # options is never silently dropped. + raise UnsupportedFeatureError( + self.name, "TABLE COMMENT", + "Oracle has no inline table comment; use a standalone " + "COMMENT ON TABLE statement.", + ) + if expr.inherits: + # Oracle has no table inheritance; a declared INHERITS clause is + # never silently dropped. + raise UnsupportedFeatureError( + self.name, "table INHERITS", + "Oracle does not support table inheritance.", + ) + if expr.storage_options is not None: + # Oracle has no generic WITH (...) storage options clause. + raise UnsupportedFeatureError( + self.name, "storage options", + "Oracle does not support the generic WITH (...) storage " + "options clause.", + ) all_params: List[Any] = [] parts = ["CREATE"] if expr.temporary: @@ -93,9 +119,28 @@ def format_column_definition( self, col_def: "ColumnDefinition", ) -> Tuple[str, tuple]: + """Format a single column definition with Oracle-specific syntax. + + Accepts both the generic ``ColumnDefinition`` and the Oracle + ``OracleColumnDefinition``; the latter's ``invisible`` attribute is + rendered here. + """ from rhosocial.activerecord.backend.expression.statements.ddl_table import ( ColumnConstraintType, ) + from rhosocial.activerecord.backend.impl.oracle.expression.column import ( + OracleColumnDefinition, + ) + if getattr(col_def, "comment", None): + # Oracle annotates column comments through the standalone + # COMMENT ON COLUMN statement (no inline column clause); a + # comment on a column definition is never silently dropped. + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "COLUMN COMMENT", + "Oracle has no inline column comment; use a standalone " + "COMMENT ON COLUMN statement.", + ) type_sql, type_params = col_def.data_type.to_sql() parts = [self.format_identifier(col_def.name), type_sql] params: List[Any] = list(type_params) @@ -127,19 +172,6 @@ def format_column_definition( constraint_parts.append(f"DEFAULT {constraint.default_value}") elif constraint.constraint_type == ColumnConstraintType.NULL: constraint_parts.append("NULL") - elif constraint.constraint_type == ColumnConstraintType.COLLATE: - if constraint.collation: - if self.version >= (12, 2, 0): - constraint_parts.append( - f"COLLATE {self.format_identifier(constraint.collation)}" - ) - else: - from rhosocial.activerecord.backend.dialect.exceptions import ( - UnsupportedFeatureError, - ) - raise UnsupportedFeatureError( - self.name, "column-level COLLATE (requires Oracle 12.2+)" - ) if constraint.is_auto_increment: if self.version >= (12, 0, 0): @@ -151,6 +183,14 @@ def format_column_definition( if constraint_parts: parts.append(" ".join(constraint_parts)) + attr_sql, attr_params = self.format_column_attributes(col_def) + if attr_sql: + parts.append(attr_sql.strip()) + params.extend(attr_params) + + if isinstance(col_def, OracleColumnDefinition) and col_def.invisible: + parts.append("INVISIBLE") + if col_def.generated_expression is not None: gen_sql, gen_params = col_def.generated_expression.to_sql() parts.append(gen_sql.lstrip()) @@ -184,6 +224,13 @@ def format_table_constraint( if t_const.name: parts.append(f"CONSTRAINT {self.format_identifier(t_const.name)}") + if getattr(t_const, "match_type", None) is not None: + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "FOREIGN KEY MATCH", + "Oracle does not support FOREIGN KEY MATCH.", + ) + if t_const.constraint_type == TableConstraintType.PRIMARY_KEY: if t_const.columns: cols_str = ", ".join(self.format_identifier(c) for c in t_const.columns) @@ -231,4 +278,16 @@ def format_table_constraint( ) parts.append(f"ON UPDATE {t_const.on_update.value}") + deferrable = getattr(t_const, "deferrable", None) + if deferrable is not None: + if deferrable: + parts.append("DEFERRABLE") + initially = getattr(t_const, "initially_deferred", None) + if initially is True: + parts.append("INITIALLY DEFERRED") + elif initially is False: + parts.append("INITIALLY IMMEDIATE") + else: + parts.append("NOT DEFERRABLE") + return " ".join(parts), tuple(params) \ No newline at end of file diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_table_op.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_table_op.py index baffe55..590e8aa 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_table_op.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_table_op.py @@ -10,7 +10,7 @@ from typing import Tuple, TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover - from ...expression.statements import DropTableExpression + from ....expression.statements import DropTableExpression class OracleTableCapabilityMixin: @@ -60,7 +60,7 @@ def format_drop_table_statement( Oracle has no IF EXISTS clause and no bare CASCADE/RESTRICT keyword; instead, the dialect-specific ``CASCADE CONSTRAINTS`` form (optionally followed by ``PURGE``) is emitted when ``expr.cascade is True``. The - dialect_options dict may carry ``purge=True`` to append PURGE. + typed ``expr.purge`` flag appends PURGE. """ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError @@ -73,9 +73,9 @@ def format_drop_table_statement( self.name, "DROP TABLE ... CASCADE CONSTRAINTS" ) parts.append("CASCADE CONSTRAINTS") - if expr.dialect_options.get("purge") and self.supports_purge_on_drop_table(): - parts.append("PURGE") elif expr.cascade is False: raise UnsupportedFeatureError(self.name, "DROP TABLE ... RESTRICT") + if getattr(expr, "purge", False) and self.supports_purge_on_drop_table(): + parts.append("PURGE") return " ".join(parts), table_params diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_type.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_type.py new file mode 100644 index 0000000..47b3358 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_type.py @@ -0,0 +1,742 @@ +# src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_type.py +"""Oracle user-defined type DDL capability and SQL formatting.""" + +from __future__ import annotations + +import re +from typing import Any, Iterable, List, Optional, Tuple, Type, cast + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins.user_defined_type import UserDefinedTypeMixin +from rhosocial.activerecord.backend.expression.bases import BaseExpression +from rhosocial.activerecord.backend.expression.statements.ddl_type import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, + TypeAlterAction, + TypeDefinition, +) + +from ..expression.ddl.type import ( + DropTypeBodyExpression, + OracleAlterTypeAddAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeCompileAction, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeElementTypeAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeLimitAction, + OracleAlterTypeModifyAttributeAction, + OracleAlterTypeResetAction, + OracleCreateTypeBodyExpression, + OracleIncompleteTypeDefinition, + OracleNestedTableTypeDefinition, + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleTypeAlterAction, + OracleTypeAttribute, + OracleTypeMethod, + OracleVarrayTypeDefinition, + _normalize_invoker_rights, + _normalize_method_kind, + _normalize_sqlj_using, + _validate_accessible_by, + _validate_method_name, + _validate_method_parameter, +) + + +class OracleTypeDDLMixin(UserDefinedTypeMixin): + """Oracle TYPE DDL capability checks and formatters.""" + + _TYPE_DEFINITION_TYPES = ( + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleVarrayTypeDefinition, + OracleNestedTableTypeDefinition, + OracleIncompleteTypeDefinition, + ) + _TYPE_ACTION_TYPES = ( + OracleAlterTypeAddAttributeAction, + OracleAlterTypeModifyAttributeAction, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeLimitAction, + OracleAlterTypeElementTypeAction, + OracleAlterTypeCompileAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeResetAction, + ) + _TYPE_MIN_VERSION = (9, 0, 0) + _TYPE_PERSISTABLE_MIN_VERSION = (18, 0, 0) + _TYPE_IF_EXISTS_MAJOR = 19 + _TYPE_IF_EXISTS_RU = 28 + + def _has_type_support(self) -> bool: + version = getattr(self, "_version", None) + return version is not None and tuple(version) >= self._TYPE_MIN_VERSION + + def supports_type_persistable(self) -> bool: + version = getattr(self, "_version", None) + return version is not None and tuple(version) >= self._TYPE_PERSISTABLE_MIN_VERSION + + def _supports_type_if_exists(self) -> bool: + version = getattr(self, "_version", None) + if version is None: + return False + version = tuple(version) + if not version or version[0] != self._TYPE_IF_EXISTS_MAJOR: + return False + full_version = getattr(self, "version_full", None) + configured_ru = cast(Optional[int], getattr(self, "_ru_version", None)) + ru_version = configured_ru + if full_version and len(full_version) >= 2: + full_version = cast(Tuple[int, ...], tuple(full_version)) + if full_version[0] != self._TYPE_IF_EXISTS_MAJOR: + return False + if configured_ru is not None and configured_ru != full_version[1]: + return False + ru_version = full_version[1] + return ru_version is not None and ru_version >= self._TYPE_IF_EXISTS_RU + + def supports_type_objects(self) -> bool: + return self._has_type_support() + + def supports_create_type(self) -> bool: + return self._has_type_support() + + def supports_alter_type(self) -> bool: + return self._has_type_support() + + def supports_drop_type(self) -> bool: + return self._has_type_support() + + def supported_type_definitions(self) -> Tuple[Type[TypeDefinition], ...]: + return self._TYPE_DEFINITION_TYPES + + def supports_type_definition( + self, + definition_type: Type[TypeDefinition], + ) -> bool: + try: + return issubclass(definition_type, self._TYPE_DEFINITION_TYPES) + except TypeError: + return False + + def supports_type_alter_action( + self, + action_type: Type[TypeAlterAction], + ) -> bool: + try: + return issubclass(action_type, self._TYPE_ACTION_TYPES) + except TypeError: + return False + + def supports_create_type_or_replace(self) -> bool: + return self._has_type_support() + + def supports_create_type_if_not_exists(self) -> bool: + return self._supports_type_if_exists() + + def supports_alter_type_if_exists(self) -> bool: + return self._supports_type_if_exists() + + def supports_drop_type_if_exists(self) -> bool: + return self._supports_type_if_exists() + + def supports_multiple_type_alter_actions(self) -> bool: + return self._has_type_support() + + def supports_create_type_body(self) -> bool: + return self._has_type_support() + + def supports_drop_type_body(self) -> bool: + return self._has_type_support() + + def supports_create_type_body_if_not_exists(self) -> bool: + return self._supports_type_if_exists() + + def supports_drop_type_body_if_exists(self) -> bool: + return self._supports_type_if_exists() + + def supports_drop_type_force(self) -> bool: + return self._has_type_support() + + def supports_drop_type_validate(self) -> bool: + return self._has_type_support() + + def supports_type_attribute_actions(self) -> bool: + return self._has_type_support() + + def supports_type_method_actions(self) -> bool: + return self._has_type_support() + + def supports_type_limit_actions(self) -> bool: + return self._has_type_support() + + def supports_type_compile(self) -> bool: + return self._has_type_support() + + def supports_type_final(self) -> bool: + return self._has_type_support() + + def supports_type_instantiable(self) -> bool: + return self._has_type_support() + + def supports_sqlj_type(self) -> bool: + return self._has_type_support() + + def _format_type_name(self, type_name: str, schema_name: Optional[str]) -> str: + name_sql = cast(str, self.format_identifier(type_name)) + if schema_name is not None: + name_sql = f"{cast(str, self.format_identifier(schema_name))}.{name_sql}" + return name_sql + + def _format_type_value(self, value: Any) -> str: + if isinstance(value, BaseExpression): + sql, params = value.to_sql() + if params: + raise ValueError("Oracle type declarations must render without bind parameters") + return cast(str, sql) + if not isinstance(value, str) or not value.strip(): + raise TypeError("Oracle data type must be a BaseExpression or non-empty string") + if any(token in value for token in (";", "--", "/*", "*/")): + raise ValueError("Oracle data type must not contain SQL statement or comment tokens") + return value.strip() + + def _format_type_reference(self, value: Any) -> str: + if isinstance(value, BaseExpression): + return self._format_type_value(value) + if not isinstance(value, str) or not value.strip(): + raise TypeError("Oracle type reference must be a non-empty string") + text = value.strip() + if any(token in text for token in (";", "--", "/*", "*/")): + raise ValueError("Oracle type reference must not contain SQL tokens") + if "." in text and '"' not in text: + return ".".join(self.format_identifier(part) for part in text.split(".")) + return text + + def _format_external_name(self, value: str) -> str: + escaped = value.replace("'", "''") + return f"EXTERNAL NAME '{escaped}'" + + def _format_attribute(self, attribute: OracleTypeAttribute) -> str: + if attribute.data_type is None: + raise ValueError("attribute data_type is required") + if attribute.not_null: + raise ValueError("Oracle object type attributes do not support NOT NULL") + parts = [self.format_identifier(attribute.name), self._format_type_value(attribute.data_type)] + if attribute.external_name is not None: + parts.append(self._format_external_name(attribute.external_name)) + return " ".join(parts) + + def _format_method_parameter(self, parameter: Any) -> str: + if isinstance(parameter, BaseExpression): + return self._format_type_value(parameter) + return cast(str, _validate_method_parameter(parameter)) + + def _format_method(self, method: OracleTypeMethod) -> str: + if method.declaration is not None: + declaration = method.declaration + else: + if method.require_return_type is False: + raise ValueError("return_type is required when formatting a method specification") + kind = _normalize_method_kind(method.kind) + if kind is None or method.name is None: + raise ValueError("structured method kind and name are required") + _validate_method_name(method.name) + parameters = method.parameters + if parameters is None: + parameter_values = [] + elif isinstance(parameters, (str, BaseExpression)): + parameter_values = [parameters] + else: + parameter_values = list(parameters) + rendered_parameters = [ + self._format_method_parameter(parameter) for parameter in parameter_values + ] + parameter_sql = f"({', '.join(rendered_parameters)})" + if kind in { + "MEMBER FUNCTION", + "STATIC FUNCTION", + "CONSTRUCTOR FUNCTION", + "MAP MEMBER FUNCTION", + "ORDER MEMBER FUNCTION", + }: + if method.return_type is None: + raise ValueError(f"{kind} requires return_type") + return_sql = f"RETURN {self._format_type_value(method.return_type)}" + else: + if method.return_type is not None: + raise ValueError(f"{kind} must not define return_type") + return_sql = "" + declaration = f"{kind} {method.name}{parameter_sql}" + if return_sql: + declaration = f"{declaration} {return_sql}" + if method.external_name is not None: + declaration = f"{declaration} {self._format_external_name(method.external_name)}" + return declaration + + def _format_drop_method(self, method: OracleTypeMethod) -> str: + if method.name is not None: + _validate_method_name(method.name) + kind = _normalize_method_kind(method.kind) or "MEMBER FUNCTION" + parameters = method.parameters + if isinstance(parameters, (str, BaseExpression)): + parameter_values = [parameters] + elif parameters is not None: + parameter_values = list(parameters) + else: + parameter_values = [] + rendered_parameters = [ + self._format_method_parameter(parameter) for parameter in parameter_values + ] + parameter_sql = f"({', '.join(rendered_parameters)})" + return f"{kind} {method.name}{parameter_sql}" + if method.declaration is None: + raise ValueError("DROP method requires a method name or declaration") + declaration = re.sub(r"\s+", " ", method.declaration.strip()) + declaration = re.sub(r"\bRETURN\b.*$", "", declaration, flags=re.IGNORECASE).strip() + declaration = re.sub(r"\bDROP\s+", "", declaration, flags=re.IGNORECASE).strip() + if not declaration: + raise ValueError("DROP method requires a method name or declaration") + if not re.match( + r"(?:MAP|ORDER|MEMBER|STATIC|CONSTRUCTOR)\b", + declaration, + re.IGNORECASE, + ): + declaration = f"MEMBER FUNCTION {declaration}" + return declaration + + def _format_attributes(self, attributes: Iterable[OracleTypeAttribute]) -> str: + return ", ".join(self._format_attribute(attribute) for attribute in attributes) + + def _format_methods(self, methods: Iterable[OracleTypeMethod]) -> str: + return ", ".join(self._format_method(method) for method in methods) + + def _format_flags(self, expr: Any) -> List[str]: + flags: List[str] = [] + for name in ("final", "instantiable", "persistable"): + value = getattr(expr, name, None) + if value is None: + continue + flags.append(name.upper() if value else f"NOT {name.upper()}") + return flags + + def _format_common_prefix(self, expr: Any) -> List[str]: + parts: List[str] = [] + if getattr(expr, "force", False): + parts.append("FORCE") + authid = getattr(expr, "authid", None) + invoker_rights = _normalize_invoker_rights(getattr(expr, "invoker_rights", None)) + if authid is not None and invoker_rights: + raise ValueError("authid and invoker_rights are mutually exclusive") + if invoker_rights: + parts.append("AUTHID CURRENT_USER") + elif authid: + parts.append(f"AUTHID {authid}") + sharing = getattr(expr, "sharing", None) + if sharing: + parts.append(sharing) + default_collation = getattr(expr, "default_collation", None) + if default_collation: + parts.append(f"DEFAULT COLLATION {default_collation}") + accessible_by = getattr(expr, "accessible_by", None) + if accessible_by is not None: + accessors = _validate_accessible_by(accessible_by) or () + parts.append(f"ACCESSIBLE BY ({', '.join(accessors)})") + return parts + + def _format_object_definition(self, expr: OracleObjectTypeDefinition) -> str: + parts = self._format_common_prefix(expr) + oid = getattr(expr, "oid", None) + if oid: + escaped = oid.replace("'", "''") + oid_sql = f"OID '{escaped}'" + parts.insert(1 if parts and parts[0] == "FORCE" else 0, oid_sql) + if expr.under is not None: + parts.append(f"UNDER {self._format_type_reference(expr.under)}") + else: + parts.append(f"{expr.keyword} OBJECT") + parts.append(f"({self._format_attributes(expr.attributes)})") + if expr.methods: + parts[-1] = f"({self._format_attributes(expr.attributes)}, {self._format_methods(expr.methods)})" + parts.extend(self._format_flags(expr)) + return " ".join(parts) + + def _format_sqlj_definition(self, expr: OracleSqljTypeDefinition) -> str: + parts = self._format_common_prefix(expr) + if expr.oid: + escaped = expr.oid.replace("'", "''") + parts.append(f"OID '{escaped}'") + if expr.under is not None: + parts.append(f"UNDER {self._format_type_reference(expr.under)}") + else: + parts.append(f"{expr.keyword} OBJECT") + external_name = getattr(expr, "external_name", None) + if not isinstance(external_name, str): + raise ValueError("SQLJ object types require EXTERNAL NAME") + parts.append(self._format_external_name(external_name)) + language = getattr(expr, "language", None) + if not isinstance(language, str) or re.sub(r"\s+", " ", language.strip().upper()) != "JAVA": + raise ValueError("SQLJ object types only support LANGUAGE JAVA") + parts.append("LANGUAGE JAVA") + members = [] + if expr.attributes: + members.append(self._format_attributes(expr.attributes)) + if expr.methods: + members.append(self._format_methods(expr.methods)) + using_clause = _normalize_sqlj_using(getattr(expr, "using_clause", None)) + parts.append(f"USING {using_clause} ({', '.join(members)})") + parts.extend(self._format_flags(expr)) + return " ".join(parts) + + def _format_varray_definition(self, expr: OracleVarrayTypeDefinition) -> str: + prefix = "FORCE " if expr.force else "" + element_sql = self._format_type_value(expr.element_type) + if expr.not_null: + element_sql = f"{element_sql} NOT NULL" + if expr.persistable is None: + return f"{prefix}AS VARRAY({expr.size_limit}) OF {element_sql}" + persistable = "PERSISTABLE" if expr.persistable else "NOT PERSISTABLE" + return f"{prefix}AS VARRAY({expr.size_limit}) OF ({element_sql}) {persistable}" + + def _format_nested_table_definition(self, expr: OracleNestedTableTypeDefinition) -> str: + prefix = "FORCE " if expr.force else "" + element_sql = self._format_type_value(expr.element_type) + if expr.not_null: + element_sql = f"{element_sql} NOT NULL" + if expr.persistable is None: + return f"{prefix}AS TABLE OF {element_sql}" + persistable = "PERSISTABLE" if expr.persistable else "NOT PERSISTABLE" + return f"{prefix}AS TABLE OF ({element_sql}) {persistable}" + + def _format_dependent_clause(self, expr: OracleTypeAlterAction) -> str: + handling = expr.dependent_handling + if handling is None: + if not expr.force: + return "" + return "CASCADE FORCE" + text = handling.value + if expr.force: + if not text.startswith("CASCADE"): + raise ValueError("ALTER TYPE force requires a CASCADE dependent clause") + return f"{text} FORCE" + return text + + def format_type_definition(self, expr: TypeDefinition) -> Tuple[str, tuple]: + if not self.supports_type_objects(): + raise UnsupportedFeatureError(self.name, "TYPE definition") + if getattr(expr, "persistable", None) is not None and not self.supports_type_persistable(): + raise UnsupportedFeatureError(self.name, "TYPE PERSISTABLE clause") + if not self.supports_type_definition(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {getattr(expr, 'definition_kind', type(expr).__name__)}", + ) + if isinstance(expr, OracleSqljTypeDefinition): + return self._format_sqlj_definition(expr), () + if isinstance(expr, OracleObjectTypeDefinition): + return self._format_object_definition(expr), () + if isinstance(expr, OracleVarrayTypeDefinition): + return self._format_varray_definition(expr), () + if isinstance(expr, OracleNestedTableTypeDefinition): + return self._format_nested_table_definition(expr), () + if isinstance(expr, OracleIncompleteTypeDefinition): + return "", () + raise UnsupportedFeatureError(self.name, "TYPE definition") + + def format_create_type_statement( + self, + expr: CreateTypeExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_create_type(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE") + if expr.if_not_exists and not self.supports_create_type_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE") + if expr.if_not_exists and expr.or_replace: + raise ValueError("CREATE TYPE IF NOT EXISTS and OR REPLACE are mutually exclusive") + if expr.if_not_exists and getattr(expr.definition, "force", False): + raise ValueError("CREATE TYPE IF NOT EXISTS cannot be combined with FORCE") + if not self.supports_type_definition(type(expr.definition)): + raise UnsupportedFeatureError( + self.name, + f"TYPE definition {expr.definition.definition_kind}", + ) + definition_sql, definition_params = expr.definition.to_sql() + if definition_params: + raise ValueError("CREATE TYPE must render without bind parameters") + parts = ["CREATE"] + if expr.or_replace: + parts.append("OR REPLACE") + editionable = getattr(expr.definition, "editionable", None) + if editionable is not None: + parts.append("EDITIONABLE" if editionable else "NONEDITIONABLE") + parts.append("TYPE") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + if definition_sql: + parts.append(definition_sql) + return " ".join(parts), tuple(definition_params) + + def _format_single_action(self, action: TypeAlterAction) -> Tuple[str, tuple]: + if not self.supports_type_alter_action(type(action)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(action, 'action_kind', type(action).__name__)}", + ) + action_sql, params = action.to_sql() + return action_sql, tuple(params) + + def _alter_action_family(self, action: TypeAlterAction) -> Optional[str]: + if isinstance(action, OracleAlterTypeModifyAttributeAction): + return "attribute.modify" + if isinstance(action, OracleAlterTypeAddAttributeAction): + return "attribute.add" + if isinstance(action, OracleAlterTypeDropAttributeAction): + return "attribute.drop" + if isinstance(action, (OracleAlterTypeAddMethodAction, OracleAlterTypeDropMethodAction)): + return "method" + if isinstance(action, OracleAlterTypeLimitAction): + return "collection.limit" + if isinstance(action, OracleAlterTypeElementTypeAction): + return "collection.element" + if isinstance(action, OracleAlterTypeInstantiableAction): + return "standalone.instantiable" + if isinstance(action, OracleAlterTypeFinalAction): + return "standalone.final" + if isinstance(action, OracleAlterTypeCompileAction): + return "standalone.compile" + if isinstance(action, OracleAlterTypeResetAction): + return "standalone.reset" + return None + + def _format_alter_dependent_group(self, actions: List[TypeAlterAction]) -> str: + suffixes = [] + for action in actions: + suffix = self._format_dependent_clause(cast(OracleTypeAlterAction, action)) + if suffix: + suffixes.append(suffix) + if len(suffixes) > 1: + raise UnsupportedFeatureError( + self.name, + "multiple ALTER TYPE dependent handling clauses", + ) + return suffixes[0] if suffixes else "" + + def _format_attribute_action_group( + self, + actions: List[TypeAlterAction], + keyword: str, + ) -> Tuple[str, tuple]: + attributes: List[OracleTypeAttribute] = [] + for action in actions: + attributes.extend(cast(OracleAlterTypeAddAttributeAction, action).attributes) + suffix = self._format_alter_dependent_group(actions) + if keyword == "DROP": + clause = self._format_attribute_names(attributes) + else: + clause = self._format_attributes(attributes) + return f"{keyword} ATTRIBUTE ({clause}) {suffix}".rstrip(), () + + def _format_method_action_group(self, actions: List[TypeAlterAction]) -> Tuple[str, tuple]: + action_parts: List[str] = [] + for action in actions: + action_sql, _ = self.format_type_alter_action(action) + suffix = self._format_dependent_clause(cast(OracleTypeAlterAction, action)) + if suffix and action_sql.endswith(suffix): + action_sql = action_sql[: -len(suffix)].rstrip() + action_parts.append(action_sql) + suffix = self._format_alter_dependent_group(actions) + return f"{', '.join(action_parts)} {suffix}".rstrip(), () + + def _validate_alter_action_sequence(self, actions: List[TypeAlterAction]) -> str: + families = [self._alter_action_family(action) for action in actions] + if any(family is None for family in families): + raise UnsupportedFeatureError(self.name, "unsupported ALTER TYPE action") + unique_families = set(families) + if len(unique_families) != 1: + raise UnsupportedFeatureError(self.name, "mixed ALTER TYPE action families") + family = next(iter(unique_families)) + if family not in { + "attribute.add", + "attribute.modify", + "attribute.drop", + "method", + }: + raise UnsupportedFeatureError(self.name, f"multiple {family} actions") + return cast(str, family) + + def format_type_alter_action(self, expr: TypeAlterAction) -> Tuple[str, tuple]: + if not self.supports_type_alter_action(type(expr)): + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + if isinstance(expr, OracleAlterTypeInstantiableAction): + value = "INSTANTIABLE" if expr.instantiable else "NOT INSTANTIABLE" + suffix = self._format_dependent_clause(expr) + return f"{value} {suffix}".rstrip(), () + if isinstance(expr, OracleAlterTypeFinalAction): + value = "FINAL" if expr.final else "NOT FINAL" + suffix = self._format_dependent_clause(expr) + return f"{value} {suffix}".rstrip(), () + if isinstance(expr, OracleAlterTypeCompileAction): + if expr.dependent_handling is not None or expr.force: + raise ValueError("COMPILE does not accept dependent handling") + parts = ["COMPILE"] + if expr.debug: + parts.append("DEBUG") + if expr.target: + parts.append(expr.target) + parts.extend(str(parameter) for parameter in expr.compiler_parameters) + if expr.reuse_settings: + parts.append("REUSE SETTINGS") + return " ".join(parts), () + if isinstance(expr, OracleAlterTypeResetAction): + if expr.dependent_handling is not None or expr.force: + raise ValueError("RESET does not accept dependent handling") + return "RESET", () + if isinstance(expr, OracleAlterTypeAddAttributeAction): + if isinstance(expr, OracleAlterTypeModifyAttributeAction): + keyword = "MODIFY" + else: + keyword = "ADD" + return ( + f"{keyword} ATTRIBUTE ({self._format_attributes(expr.attributes)}) " + f"{self._format_dependent_clause(expr)}".rstrip(), + (), + ) + if isinstance(expr, OracleAlterTypeDropAttributeAction): + return ( + f"DROP ATTRIBUTE ({self._format_attribute_names(expr.attributes)}) " + f"{self._format_dependent_clause(expr)}".rstrip(), + (), + ) + if isinstance(expr, OracleAlterTypeDropMethodAction): + return ( + f"DROP {self._format_drop_method(expr.method)} " + f"{self._format_dependent_clause(expr)}".rstrip(), + (), + ) + if isinstance(expr, OracleAlterTypeAddMethodAction): + return ( + f"ADD {self._format_method(expr.method)} " + f"{self._format_dependent_clause(expr)}".rstrip(), + (), + ) + if isinstance(expr, OracleAlterTypeLimitAction): + return ( + f"MODIFY LIMIT {expr.limit} {self._format_dependent_clause(expr)}".rstrip(), + (), + ) + if isinstance(expr, OracleAlterTypeElementTypeAction): + return ( + f"MODIFY ELEMENT TYPE {self._format_type_value(expr.element_type)} " + f"{self._format_dependent_clause(expr)}".rstrip(), + (), + ) + raise UnsupportedFeatureError( + self.name, + f"ALTER TYPE action {getattr(expr, 'action_kind', type(expr).__name__)}", + ) + + def format_alter_type_statement(self, expr: AlterTypeExpression) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_alter_type(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE") + if expr.if_exists and not self.supports_alter_type_if_exists(): + raise UnsupportedFeatureError(self.name, "ALTER TYPE IF EXISTS") + actions = list(expr.actions) + if len(actions) > 1 and not self.supports_multiple_type_alter_actions(): + raise UnsupportedFeatureError(self.name, "multiple ALTER TYPE actions") + if len(actions) == 1: + action_sql, action_params = self._format_single_action(actions[0]) + else: + family = self._validate_alter_action_sequence(actions) + if family.startswith("attribute."): + action_sql, action_params = self._format_attribute_action_group( + actions, + family.rsplit(".", 1)[1].upper(), + ) + else: + action_sql, action_params = self._format_method_action_group(actions) + parts = ["ALTER TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + parts.append(action_sql) + return " ".join(parts), tuple(action_params) + + def format_drop_type_statement(self, expr: DropTypeExpression) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_drop_type(): + raise UnsupportedFeatureError(self.name, "DROP TYPE") + if expr.if_exists and not self.supports_drop_type_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP TYPE IF EXISTS") + force = bool(getattr(expr, "force", False)) + validate = bool(vars(expr).get("validate", False)) + if force and validate: + raise ValueError("DROP TYPE FORCE and VALIDATE are mutually exclusive") + if force and not self.supports_drop_type_force(): + raise UnsupportedFeatureError(self.name, "DROP TYPE FORCE") + if validate and not self.supports_drop_type_validate(): + raise UnsupportedFeatureError(self.name, "DROP TYPE VALIDATE") + parts = ["DROP TYPE"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + if force: + parts.append("FORCE") + elif validate: + parts.append("VALIDATE") + return " ".join(parts), () + + def format_create_type_body_statement( + self, + expr: OracleCreateTypeBodyExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_create_type_body(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE BODY") + if expr.if_not_exists and not self.supports_create_type_body_if_not_exists(): + raise UnsupportedFeatureError(self.name, "CREATE TYPE BODY IF NOT EXISTS") + if expr.or_replace and not self.supports_create_type_or_replace(): + raise UnsupportedFeatureError(self.name, "CREATE OR REPLACE TYPE BODY") + if expr.if_not_exists and expr.or_replace: + raise ValueError("CREATE TYPE BODY IF NOT EXISTS and OR REPLACE are mutually exclusive") + parts = ["CREATE"] + if expr.or_replace: + parts.append("OR REPLACE") + if expr.editionable is not None: + parts.append("EDITIONABLE" if expr.editionable else "NONEDITIONABLE") + parts.append("TYPE BODY") + if expr.if_not_exists: + parts.append("IF NOT EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + parts.append(f"{expr.keyword} {expr.body}") + return " ".join(parts), () + + def format_drop_type_body_statement( + self, + expr: DropTypeBodyExpression, + ) -> Tuple[str, tuple]: + if not self.supports_type_objects() or not self.supports_drop_type_body(): + raise UnsupportedFeatureError(self.name, "DROP TYPE BODY") + if expr.if_exists and not self.supports_drop_type_body_if_exists(): + raise UnsupportedFeatureError(self.name, "DROP TYPE BODY IF EXISTS") + parts = ["DROP TYPE BODY"] + if expr.if_exists: + parts.append("IF EXISTS") + parts.append(self._format_type_name(expr.type_name, expr.schema_name)) + return " ".join(parts), () + + def _format_attribute_names(self, attributes: Iterable[OracleTypeAttribute]) -> str: + return ", ".join(self.format_identifier(attribute.name) for attribute in attributes) + + +__all__ = ["OracleTypeDDLMixin"] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_view.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_view.py index 0d5008d..5c2d8e8 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_view.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/ddl_view.py @@ -21,6 +21,10 @@ def supports_if_not_exists_view(self) -> bool: """Oracle does not support IF NOT EXISTS for views.""" return False + def supports_view_check_option(self) -> bool: + """Oracle supports ``WITH [LOCAL|CASCADED] CHECK OPTION`` on views.""" + return True + def format_create_view_statement( self, expr: "CreateViewExpression" ) -> Tuple[str, tuple]: @@ -35,6 +39,12 @@ def format_create_view_statement( query_sql, query_params = expr.query.to_sql() parts.append(f"AS {query_sql}") if expr.options and expr.options.check_option: + if not self.supports_view_check_option(): + from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError + raise UnsupportedFeatureError( + self.name, "WITH CHECK OPTION", + f"{self.name} does not support WITH 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/oracle/mixins/dml.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/dml.py index e3b6729..83baa13 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/dml.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/dml.py @@ -56,8 +56,7 @@ def format_merge_statement(self, target_table: str, source_query: str, *, delete_where: Optional[str] = None, log_errors_into: Optional[str] = None, - reject_limit: Optional[int] = None, - dialect_options: Optional[dict] = None) -> Tuple[str, tuple]: + reject_limit: Optional[int] = None) -> Tuple[str, tuple]: """Format an Oracle-standard MERGE statement. Composes caller-supplied SQL fragments into the canonical pattern: @@ -83,7 +82,6 @@ def format_merge_statement(self, target_table: str, source_query: str, ``LOG ERRORS INTO
`` (Oracle 10g+). reject_limit: optional ``REJECT LIMIT n`` for the error-logging clause. - dialect_options: reserved for future dialect-specific options. """ if delete_where is not None and self.version < (10, 0, 0): raise UnsupportedFeatureError( @@ -130,7 +128,6 @@ def format_insert_all_statement( *, when_clauses: Optional[List[dict]] = None, else_clause: Optional[dict] = None, - dialect_options: Optional[dict] = None, ) -> Tuple[str, tuple]: """Format an Oracle ``INSERT ALL`` multi-table insert statement. @@ -147,7 +144,6 @@ def format_insert_all_statement( return self.format_multi_table_insert_statement( "ALL", into_clauses, select_query, when_clauses=when_clauses, else_clause=else_clause, - dialect_options=dialect_options, ) def format_insert_first_statement( @@ -157,7 +153,6 @@ def format_insert_first_statement( *, when_clauses: Optional[List[dict]] = None, else_clause: Optional[dict] = None, - dialect_options: Optional[dict] = None, ) -> Tuple[str, tuple]: """Format an Oracle ``INSERT FIRST`` multi-table insert statement. @@ -168,7 +163,6 @@ def format_insert_first_statement( return self.format_multi_table_insert_statement( "FIRST", into_clauses, select_query, when_clauses=when_clauses, else_clause=else_clause, - dialect_options=dialect_options, ) def format_multi_table_insert_statement( @@ -179,7 +173,6 @@ def format_multi_table_insert_statement( *, when_clauses: Optional[List[dict]] = None, else_clause: Optional[dict] = None, - dialect_options: Optional[dict] = None, ) -> Tuple[str, tuple]: if self.version < (9, 0, 0): raise UnsupportedFeatureError( diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/features.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/features.py index 265aa6e..9538da5 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/features.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/features.py @@ -164,6 +164,10 @@ def supports_fetch_with_ties(self) -> bool: def supports_auto_increment(self) -> bool: return self.version >= (12, 0, 0) + def supports_column_collation(self) -> bool: + """Oracle supports column-level COLLATE since 12.2.""" + return self.version >= (12, 2, 0) + def supports_generated_columns(self) -> bool: return self.version >= (11, 0, 0) @@ -186,7 +190,8 @@ def supports_merge_statement(self) -> bool: # --- Temporal tables ---------------------------------------------- def supports_temporal_tables(self) -> bool: - return True + # Oracle has no SQL:2011 system-versioned temporal tables. + return False # --- QUALIFY ------------------------------------------------------ def supports_qualify_clause(self) -> bool: diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/index.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/index.py index d1da018..37228fa 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/index.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/index.py @@ -11,6 +11,9 @@ def supports_create_index(self) -> bool: def supports_drop_index(self) -> bool: return True + def supports_drop_index_on_table(self) -> bool: + return False + def supports_unique_index(self) -> bool: return True diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/materialized_view.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/materialized_view.py index 45b8a72..289e5c3 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/materialized_view.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/materialized_view.py @@ -12,6 +12,7 @@ ) from ..expression.materialized_view import ( OracleCreateMaterializedViewLogExpression, + OracleRefreshMaterializedViewExpression, ) @@ -28,6 +29,13 @@ class OracleMaterializedViewMixin: def supports_materialized_view(self) -> bool: return True + def supports_refresh_materialized_view(self) -> bool: + """Oracle refreshes through ``DBMS_MVIEW.REFRESH`` (no SQL statement). + + The procedure has existed since Oracle 8i, so this is not version gated. + """ + return True + def supports_materialized_view_log(self) -> bool: return True @@ -133,3 +141,76 @@ def format_drop_materialized_view_statement( if getattr(expr, "preserve_table", False): parts.append("PRESERVE TABLE") return " ".join(parts), () + + def format_refresh_materialized_view_statement( + self, expr: "OracleRefreshMaterializedViewExpression" + ) -> Tuple[str, tuple]: + """Refresh a materialized view via ``DBMS_MVIEW.REFRESH``. + + Oracle has no ``REFRESH MATERIALIZED VIEW`` statement; refresh is a call + to the ``DBMS_MVIEW`` PL/SQL package, so the rendered SQL is an + anonymous ``BEGIN ... END;`` block. + + Also serves the generic ``RefreshMaterializedViewExpression``; its + SQL-standard options have no Oracle counterpart and are handled as: + ``concurrent`` is rejected (Oracle refreshes hold the appropriate locks + and offer no concurrent mode), ``with_data`` is ignored (a refresh + always repopulates). + + Args: + expr: Oracle or generic refresh expression. + + Returns: + Tuple of (SQL string, empty params tuple). + + Raises: + UnsupportedFeatureError: if ``concurrent`` was requested. + """ + if getattr(expr, "concurrent", False): + raise UnsupportedFeatureError( + self.name, + "REFRESH MATERIALIZED VIEW CONCURRENTLY", + suggestion=( + "Oracle has no concurrent refresh mode; use " + "DBMS_MVIEW.REFRESH with atomic_refresh or out_of_place." + ), + ) + + name = self._materialized_view_refresh_target(expr) + args = [f"'{name}'"] + + method = getattr(expr, "method", None) + if method is not None: + args.append(f"method => '{method.value}'") + for attr in ( + "atomic_refresh", + "out_of_place", + "nested", + "refresh_after_errors", + "push_deferred_rpc", + ): + value = getattr(expr, attr, None) + if value is not None: + args.append(f"{attr} => {'TRUE' if value else 'FALSE'}") + for attr in ("parallelism", "purge_option"): + value = getattr(expr, attr, None) + if value is not None: + args.append(f"{attr} => {value}") + + call = f"DBMS_MVIEW.REFRESH({', '.join(args)});" + return f"BEGIN {call} END;", () + + def _materialized_view_refresh_target(self, expr) -> str: + """Build the materialized view name passed to ``DBMS_MVIEW.REFRESH``. + + The procedure takes a *string* name, not an identifier, so the name is + upper-cased (unquoted identifiers fold to upper case in Oracle) and any + embedded single quote is escaped. + """ + name = str(getattr(expr, "view_name", "") or "").strip() + if not name: + raise ValueError("materialized view refresh requires a view name") + schema = getattr(expr, "schema", None) + if schema: + name = f"{schema}.{name}" + return name.replace("'", "''").upper() diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition.py index 54635b7..9379ab8 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition.py @@ -7,24 +7,22 @@ Phase 5 scope: -* Generic RANGE / LIST / HASH through the core ``PartitionClause`` - expression (Phase 4 backward compatibility). Partition definitions are - read from ``PartitionClause.dialect_options`` for the legacy path. * Backend-specific :class:`~rhosocial.activerecord.backend.impl.oracle.expression.partition.OraclePartitionByRange` / ``...ByList`` / ``...ByHash`` expressions with structured :class:`OraclePartitionDefinition` definitions. * Oracle-specific INTERVAL / REFERENCE partitioning. * Composite partitioning via :class:`OracleSubpartitionClause`. -* Partition maintenance statements (ADD / DROP / SPLIT / MERGE / - EXCHANGE / MOVE / TRUNCATE) implemented in - :mod:`.partition_lifecycle_mixin` (mixed in by the dialect). +* Partition maintenance statement formatters for the Oracle-specific + maintenance expressions. Dispatch rule (expression-dialect-architecture §7.1): the public ``format_partition_clause`` formatter inspects the expression type via ``isinstance`` and dispatches to the backend-specific public formatters (``format_partition_by_range`` etc.). The generic ``PartitionClause`` -path is preserved for Phase 4 backward compatibility. +expression is **not** supported: Oracle partitioning requires one of the +Oracle-specific subclasses. Passing a generic ``PartitionClause`` raises +``TypeError`` instead of reading ``dialect_options``. Note on parameter binding: Oracle does not accept bind variables in DDL statements (CREATE TABLE ... PARTITION BY ...). Partition boundary @@ -55,6 +53,15 @@ OracleSubpartitionClause, OracleSubpartitionDefinition, ) +from ..expression.partition_lifecycle import ( + OracleAddPartitionExpression, + OracleDropPartitionExpression, + OracleExchangePartitionExpression, + OracleMergePartitionsExpression, + OracleMovePartitionExpression, + OracleSplitPartitionExpression, + OracleTruncatePartitionExpression, +) if TYPE_CHECKING: # pragma: no cover from rhosocial.activerecord.backend.expression.statements import PartitionClause @@ -121,9 +128,6 @@ def supports_reference_partitioning(self) -> bool: """REFERENCE partitioning is supported for Oracle 11g+.""" return self.version >= _ORACLE_REFERENCE_MIN_VERSION - # Maintenance statement capabilities. The actual formatters live on - # the PartitionLifecycleMixin (mixed into the dialect alongside this - # mixin). These flags gate the public API. def supports_add_partition(self) -> bool: """ADD PARTITION maintenance statement is supported for 11g+.""" return self.version >= _ORACLE_PARTITION_MIN_VERSION @@ -171,13 +175,16 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: """Format Oracle ``PARTITION BY`` clause from a partition expression. Dispatches by ``isinstance`` to the backend-specific public - formatters first (``format_partition_by_range`` etc.), falling - back to the legacy generic ``PartitionClause`` path (Phase 4 - compatibility) that reads ``expr.dialect_options``. + formatters (``format_partition_by_range`` etc.). Only the + Oracle-specific subclasses are accepted; a generic core + ``PartitionClause`` is rejected because its untyped + ``dialect_options`` bag is no longer read. Args: - expr: a core ``PartitionClause`` (legacy) or an Oracle-specific - subclass (``OraclePartitionByRange`` etc.). + expr: an Oracle-specific partition clause + (``OraclePartitionByRange`` / ``...ByList`` / ``...ByHash`` + / ``OracleIntervalPartitionClause`` / + ``OracleReferencePartitionClause``). Returns: Tuple of (SQL string, parameters tuple). The SQL string is @@ -189,9 +196,10 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: Raises: UnsupportedFeatureError: if table partitioning or the requested strategy is not supported by the current Oracle version. - ValueError: if the partition method is invalid or required - partition definition data is missing/malformed. - TypeError: if partition definition fields have wrong types. + ValueError: if required partition definition data is + missing/malformed. + TypeError: if ``expr`` is not an Oracle-specific partition + clause, or partition definition fields have wrong types. """ if not self.supports_table_partitioning(): raise UnsupportedFeatureError( @@ -218,17 +226,12 @@ def format_partition_clause(self, expr: "PartitionClause") -> Tuple[str, tuple]: if isinstance(expr, OraclePartitionByHash): return self.format_partition_by_hash(expr) - # Legacy generic PartitionClause path (Phase 4 compatibility). - method = expr.method.upper() - if method == "RANGE": - return self.format_legacy_range(expr) - if method == "LIST": - return self.format_legacy_list(expr) - if method == "HASH": - return self.format_legacy_hash(expr) - raise ValueError( - f"Invalid Oracle partition method: {expr.method!r}. " - "Supported: RANGE, LIST, HASH, INTERVAL, REFERENCE." + raise TypeError( + "Oracle table partitioning requires an Oracle-specific partition " + "clause expression (OraclePartitionByRange, OraclePartitionByList, " + "OraclePartitionByHash, OracleIntervalPartitionClause, or " + f"OracleReferencePartitionClause); got {type(expr).__name__}. " + "The generic PartitionClause dialect_options bag is not supported." ) # ------------------------------------------------------------------ @@ -381,6 +384,11 @@ def format_partition_definition( raise ValueError( f"RANGE partition {definition.name!r} requires 'less_than' boundary values" ) + if not isinstance(definition.less_than, (list, tuple)): + raise TypeError( + "'less_than' must be a list or tuple, " + f"got {type(definition.less_than).__name__}" + ) value_parts = [self.format_partition_boundary_value(v)[0] for v in definition.less_than] body = f"VALUES LESS THAN ({', '.join(value_parts)})" elif strategy.upper() == "LIST": @@ -388,6 +396,11 @@ def format_partition_definition( raise ValueError( f"LIST partition {definition.name!r} requires 'in_values' boundary values" ) + if not isinstance(definition.in_values, (list, tuple)): + raise TypeError( + "'in_values' must be a list or tuple, " + f"got {type(definition.in_values).__name__}" + ) value_parts: List[str] = [] for value in definition.in_values: if isinstance(value, (list, tuple)): @@ -515,6 +528,148 @@ def format_partition_boundary_value(self, value: Any) -> Tuple[str, tuple]: f"got {type(value).__name__}" ) + def format_add_partition_statement( + self, expr: OracleAddPartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t ADD PARTITION (...)``.""" + if not self.supports_add_partition(): + raise UnsupportedFeatureError( + self.name, + "ADD PARTITION", + f"Oracle {self.version} does not support ADD PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_def = expr.partition + if part_def.less_than is not None: + strategy = "RANGE" + elif part_def.in_values is not None: + strategy = "LIST" + else: + strategy = "HASH" + part_sql, _ = self.format_partition_definition(part_def, strategy=strategy) + return f"ALTER TABLE {table_sql} ADD {part_sql}", () + + def format_drop_partition_statement( + self, expr: OracleDropPartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t DROP PARTITION p [UPDATE INDEXES]``.""" + if not self.supports_drop_partition(): + raise UnsupportedFeatureError( + self.name, + "DROP PARTITION", + f"Oracle {self.version} does not support DROP PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_sql = self.format_identifier(expr.partition_name) + sql = f"ALTER TABLE {table_sql} DROP PARTITION {part_sql}" + if expr.update_indexes: + sql = f"{sql} UPDATE INDEXES" + return sql, () + + def format_split_partition_statement( + self, expr: OracleSplitPartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t SPLIT PARTITION p AT (...) INTO (p1, p2)``.""" + if not self.supports_split_partition(): + raise UnsupportedFeatureError( + self.name, + "SPLIT PARTITION", + f"Oracle {self.version} does not support SPLIT PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_sql = self.format_identifier(expr.partition_name) + at_parts = [self.format_partition_boundary_value(v)[0] for v in expr.at_values] + at_sql = ", ".join(at_parts) + new_parts = [ + f"PARTITION {self.format_identifier(np.name)}" + for np in expr.new_partitions + ] + new_sql = ", ".join(new_parts) + return ( + f"ALTER TABLE {table_sql} SPLIT PARTITION {part_sql} " + f"AT ({at_sql}) INTO ({new_sql})", + (), + ) + + def format_merge_partitions_statement( + self, expr: OracleMergePartitionsExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t MERGE PARTITIONS p1, p2 INTO p3``.""" + if not self.supports_merge_partition(): + raise UnsupportedFeatureError( + self.name, + "MERGE PARTITIONS", + f"Oracle {self.version} does not support MERGE PARTITIONS.", + ) + table_sql = self.format_identifier(expr.table) + names_sql = ", ".join(self.format_identifier(n) for n in expr.partition_names) + into_name = self.format_identifier(expr.into_partition.name) + return ( + f"ALTER TABLE {table_sql} MERGE PARTITIONS {names_sql} " + f"INTO PARTITION {into_name}", + (), + ) + + def format_exchange_partition_statement( + self, expr: OracleExchangePartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t EXCHANGE PARTITION p WITH TABLE other``.""" + if not self.supports_exchange_partition(): + raise UnsupportedFeatureError( + self.name, + "EXCHANGE PARTITION", + f"Oracle {self.version} does not support EXCHANGE PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_sql = self.format_identifier(expr.partition_name) + with_sql = self.format_identifier(expr.with_table) + sql = ( + f"ALTER TABLE {table_sql} EXCHANGE PARTITION {part_sql} " + f"WITH TABLE {with_sql}" + ) + if expr.including_indexes: + sql = f"{sql} INCLUDING INDEXES" + if expr.with_validation: + sql = f"{sql} WITH VALIDATION" + else: + sql = f"{sql} WITHOUT VALIDATION" + return sql, () + + def format_move_partition_statement( + self, expr: OracleMovePartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t MOVE PARTITION p [TABLESPACE ts]``.""" + if not self.supports_move_partition(): + raise UnsupportedFeatureError( + self.name, + "MOVE PARTITION", + f"Oracle {self.version} does not support MOVE PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_sql = self.format_identifier(expr.partition_name) + sql = f"ALTER TABLE {table_sql} MOVE PARTITION {part_sql}" + if expr.tablespace_name is not None: + ts_sql = self.format_identifier(expr.tablespace_name) + sql = f"{sql} TABLESPACE {ts_sql}" + return sql, () + + def format_truncate_partition_statement( + self, expr: OracleTruncatePartitionExpression + ) -> Tuple[str, tuple]: + """Format ``ALTER TABLE t TRUNCATE PARTITION p [UPDATE INDEXES]``.""" + if not self.supports_truncate_partition(): + raise UnsupportedFeatureError( + self.name, + "TRUNCATE PARTITION", + f"Oracle {self.version} does not support TRUNCATE PARTITION.", + ) + table_sql = self.format_identifier(expr.table) + part_sql = self.format_identifier(expr.partition_name) + sql = f"ALTER TABLE {table_sql} TRUNCATE PARTITION {part_sql}" + if expr.update_indexes: + sql = f"{sql} UPDATE INDEXES" + return sql, () + def render_partition_literal(self, value: Any) -> str: """Render a Python scalar as a safe inline SQL literal. @@ -550,98 +705,3 @@ def render_partition_literal(self, value: Any) -> str: "date, datetime, or None, got " f"{type(value).__name__}" ) - - # ------------------------------------------------------------------ - # Legacy generic PartitionClause path (Phase 4 compatibility) - # ------------------------------------------------------------------ - def format_legacy_range(self, expr: "PartitionClause") -> Tuple[str, tuple]: - """Legacy RANGE path reading ``expr.dialect_options['partitions']``.""" - key_sql, _ = self.format_partition_keys(expr.keys) - sql = f"PARTITION BY RANGE ({key_sql})" - partitions = expr.dialect_options.get("partitions") or [] - if partitions: - parts = [self.format_legacy_range_definition(p)[0] for p in partitions] - sql = f"{sql} ({', '.join(parts)})" - return f" {sql}", () - - def format_legacy_list(self, expr: "PartitionClause") -> Tuple[str, tuple]: - """Legacy LIST path reading ``expr.dialect_options['partitions']``.""" - key_sql, _ = self.format_partition_keys(expr.keys) - sql = f"PARTITION BY LIST ({key_sql})" - partitions = expr.dialect_options.get("partitions") or [] - if partitions: - parts = [self.format_legacy_list_definition(p)[0] for p in partitions] - sql = f"{sql} ({', '.join(parts)})" - return f" {sql}", () - - def format_legacy_hash(self, expr: "PartitionClause") -> Tuple[str, tuple]: - """Legacy HASH path reading ``expr.dialect_options['partitions_count']``.""" - key_sql, _ = self.format_partition_keys(expr.keys) - sql = f"PARTITION BY HASH ({key_sql})" - partitions_count = expr.dialect_options.get("partitions_count") - if partitions_count is not None: - if not isinstance(partitions_count, int) or isinstance(partitions_count, bool): - raise TypeError( - "partitions_count must be an int, " - f"got {type(partitions_count).__name__}" - ) - if partitions_count <= 0: - raise ValueError( - f"partitions_count must be a positive integer, got {partitions_count}" - ) - sql = f"{sql} PARTITIONS {partitions_count}" - return f" {sql}", () - - def format_legacy_range_definition(self, partition: Any) -> Tuple[str, tuple]: - """Render a single legacy RANGE partition definition dict.""" - if not isinstance(partition, dict): - raise TypeError( - f"RANGE partition definition must be a dict, got {type(partition).__name__}" - ) - name = partition.get("name") - less_than = partition.get("less_than") - if not name or not isinstance(name, str): - raise TypeError("RANGE partition definition requires a non-empty string 'name'") - if less_than is None: - raise ValueError(f"RANGE partition {name!r} requires 'less_than' boundary values") - if not isinstance(less_than, (list, tuple)): - raise TypeError( - "'less_than' must be a list or tuple, " - f"got {type(less_than).__name__}" - ) - value_sql_parts = [self.format_partition_boundary_value(v)[0] for v in less_than] - return ( - f"PARTITION {self.format_identifier(name)} " - f"VALUES LESS THAN ({', '.join(value_sql_parts)})", - (), - ) - - def format_legacy_list_definition(self, partition: Any) -> Tuple[str, tuple]: - """Render a single legacy LIST partition definition dict.""" - if not isinstance(partition, dict): - raise TypeError( - f"LIST partition definition must be a dict, got {type(partition).__name__}" - ) - name = partition.get("name") - in_values = partition.get("in_values") - if not name or not isinstance(name, str): - raise TypeError("LIST partition definition requires a non-empty string 'name'") - if in_values is None: - raise ValueError(f"LIST partition {name!r} requires 'in_values' boundary values") - if not isinstance(in_values, (list, tuple)): - raise TypeError( - "'in_values' must be a list or tuple, " - f"got {type(in_values).__name__}" - ) - value_sql_parts: List[str] = [] - for value in in_values: - if isinstance(value, (list, tuple)): - inner = [self.format_partition_boundary_value(v)[0] for v in value] - value_sql_parts.append(f"({', '.join(inner)})") - else: - value_sql_parts.append(self.format_partition_boundary_value(value)[0]) - return ( - f"PARTITION {self.format_identifier(name)} " - f"VALUES ({', '.join(value_sql_parts)})", - (), - ) diff --git a/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition_lifecycle.py b/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition_lifecycle.py deleted file mode 100644 index 2fae8c3..0000000 --- a/src/rhosocial/activerecord/backend/impl/oracle/mixins/partition_lifecycle.py +++ /dev/null @@ -1,206 +0,0 @@ -# src/rhosocial/activerecord/backend/impl/oracle/mixins/partition_lifecycle.py -"""Oracle partition maintenance statement formatters. - -This mixin implements the public ``format_*`` formatters for the Oracle -partition maintenance expressions declared in -:mod:`..expression.partition_lifecycle`. It is mixed into the Oracle -dialect alongside :class:`OraclePartitionMixin` so all partition -formatters live on the dialect instance. - -All formatters are public (no leading underscore) per -expression-dialect-architecture §8. -""" -from typing import Tuple - -from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError - -from ..expression.partition_lifecycle import ( - OracleAddPartitionExpression, - OracleDropPartitionExpression, - OracleExchangePartitionExpression, - OracleMergePartitionsExpression, - OracleMovePartitionExpression, - OracleSplitPartitionExpression, - OracleTruncatePartitionExpression, -) - - -class OraclePartitionLifecycleMixin: - """Oracle partition maintenance statement formatters. - - These formatters are gated by the corresponding ``supports_*`` - capability methods defined on :class:`OraclePartitionMixin`. - """ - - # ------------------------------------------------------------------ - # ADD PARTITION - # ------------------------------------------------------------------ - def format_add_partition_statement( - self, expr: OracleAddPartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t ADD PARTITION (...)``.""" - if not self.supports_add_partition(): - raise UnsupportedFeatureError( - self.name, - "ADD PARTITION", - f"Oracle {self.version} does not support ADD PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - # Determine the strategy from the partition definition: if it has - # less_than set -> RANGE; if in_values set -> LIST. - part_def = expr.partition - if part_def.less_than is not None: - strategy = "RANGE" - elif part_def.in_values is not None: - strategy = "LIST" - else: - # HASH partitions can be added by name without VALUES clause. - strategy = "HASH" - part_sql, _ = self.format_partition_definition(part_def, strategy=strategy) - return f"ALTER TABLE {table_sql} ADD {part_sql}", () - - # ------------------------------------------------------------------ - # DROP PARTITION - # ------------------------------------------------------------------ - def format_drop_partition_statement( - self, expr: OracleDropPartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t DROP PARTITION p [UPDATE INDEXES]``.""" - if not self.supports_drop_partition(): - raise UnsupportedFeatureError( - self.name, - "DROP PARTITION", - f"Oracle {self.version} does not support DROP PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - part_sql = self.format_identifier(expr.partition_name) - sql = f"ALTER TABLE {table_sql} DROP PARTITION {part_sql}" - if expr.update_indexes: - sql = f"{sql} UPDATE INDEXES" - return sql, () - - # ------------------------------------------------------------------ - # SPLIT PARTITION (Oracle-specific) - # ------------------------------------------------------------------ - def format_split_partition_statement( - self, expr: OracleSplitPartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t SPLIT PARTITION p AT (...) INTO (p1, p2)``.""" - if not self.supports_split_partition(): - raise UnsupportedFeatureError( - self.name, - "SPLIT PARTITION", - f"Oracle {self.version} does not support SPLIT PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - part_sql = self.format_identifier(expr.partition_name) - at_parts = [self.format_partition_boundary_value(v)[0] for v in expr.at_values] - at_sql = ", ".join(at_parts) - # In the INTO partition list, Oracle does NOT accept a VALUES clause - # for SPLIT PARTITION (ORA-14020). Only the partition name (and - # optional physical attributes like TABLESPACE) are allowed; the - # split boundary is solely determined by the AT (...) expression. - new_parts = [f"PARTITION {self.format_identifier(np.name)}" - for np in expr.new_partitions] - new_sql = ", ".join(new_parts) - return ( - f"ALTER TABLE {table_sql} SPLIT PARTITION {part_sql} " - f"AT ({at_sql}) INTO ({new_sql})", - (), - ) - - # ------------------------------------------------------------------ - # MERGE PARTITIONS - # ------------------------------------------------------------------ - def format_merge_partitions_statement( - self, expr: OracleMergePartitionsExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t MERGE PARTITIONS p1, p2 INTO p3``.""" - if not self.supports_merge_partition(): - raise UnsupportedFeatureError( - self.name, - "MERGE PARTITIONS", - f"Oracle {self.version} does not support MERGE PARTITIONS.", - ) - table_sql = self.format_identifier(expr.table) - names_sql = ", ".join(self.format_identifier(n) for n in expr.partition_names) - # In Oracle MERGE PARTITIONS, the INTO clause only accepts the - # resulting partition name (and optional physical attributes); it - # must NOT include a VALUES LESS THAN clause (ORA-14020). The merged - # partition inherits its upper bound from the higher of the two - # source partitions. - into_name = self.format_identifier(expr.into_partition.name) - return ( - f"ALTER TABLE {table_sql} MERGE PARTITIONS {names_sql} " - f"INTO PARTITION {into_name}", - (), - ) - - # ------------------------------------------------------------------ - # EXCHANGE PARTITION - # ------------------------------------------------------------------ - def format_exchange_partition_statement( - self, expr: OracleExchangePartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t EXCHANGE PARTITION p WITH TABLE other [...]``.""" - if not self.supports_exchange_partition(): - raise UnsupportedFeatureError( - self.name, - "EXCHANGE PARTITION", - f"Oracle {self.version} does not support EXCHANGE PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - part_sql = self.format_identifier(expr.partition_name) - with_sql = self.format_identifier(expr.with_table) - sql = ( - f"ALTER TABLE {table_sql} EXCHANGE PARTITION {part_sql} " - f"WITH TABLE {with_sql}" - ) - if expr.including_indexes: - sql = f"{sql} INCLUDING INDEXES" - if expr.with_validation: - sql = f"{sql} WITH VALIDATION" - else: - sql = f"{sql} WITHOUT VALIDATION" - return sql, () - - # ------------------------------------------------------------------ - # MOVE PARTITION (Oracle-specific) - # ------------------------------------------------------------------ - def format_move_partition_statement( - self, expr: OracleMovePartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t MOVE PARTITION p [TABLESPACE ts]``.""" - if not self.supports_move_partition(): - raise UnsupportedFeatureError( - self.name, - "MOVE PARTITION", - f"Oracle {self.version} does not support MOVE PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - part_sql = self.format_identifier(expr.partition_name) - sql = f"ALTER TABLE {table_sql} MOVE PARTITION {part_sql}" - if expr.tablespace_name is not None: - ts_sql = self.format_identifier(expr.tablespace_name) - sql = f"{sql} TABLESPACE {ts_sql}" - return sql, () - - # ------------------------------------------------------------------ - # TRUNCATE PARTITION - # ------------------------------------------------------------------ - def format_truncate_partition_statement( - self, expr: OracleTruncatePartitionExpression - ) -> Tuple[str, tuple]: - """Format ``ALTER TABLE t TRUNCATE PARTITION p [UPDATE INDEXES]``.""" - if not self.supports_truncate_partition(): - raise UnsupportedFeatureError( - self.name, - "TRUNCATE PARTITION", - f"Oracle {self.version} does not support TRUNCATE PARTITION.", - ) - table_sql = self.format_identifier(expr.table) - part_sql = self.format_identifier(expr.partition_name) - sql = f"ALTER TABLE {table_sql} TRUNCATE PARTITION {part_sql}" - if expr.update_indexes: - sql = f"{sql} UPDATE INDEXES" - return sql, () diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols.py index a70c529..38f2868 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/protocols.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols.py @@ -10,6 +10,9 @@ OracleBooleanTypeSupport, OracleVectorTypeSupport, OracleJSONDualitySupport, + OracleTypeDDLSupport, + OracleTypeSupport, + OracleUserDefinedTypeSupport, ) __all__ = [ @@ -21,4 +24,7 @@ "OracleBooleanTypeSupport", "OracleVectorTypeSupport", "OracleJSONDualitySupport", -] \ No newline at end of file + "OracleTypeDDLSupport", + "OracleTypeSupport", + "OracleUserDefinedTypeSupport", +] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols/__init__.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols/__init__.py index d85ec99..3fb8044 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/protocols/__init__.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols/__init__.py @@ -12,6 +12,11 @@ OracleJSONDualitySupport, ) from .partition import OraclePartitionSupport +from .ddl_type import ( + OracleTypeDDLSupport, + OracleTypeSupport, + OracleUserDefinedTypeSupport, +) __all__ = [ "HierarchicalQuerySupport", @@ -23,4 +28,7 @@ "OracleVectorTypeSupport", "OracleJSONDualitySupport", "OraclePartitionSupport", -] \ No newline at end of file + "OracleTypeDDLSupport", + "OracleTypeSupport", + "OracleUserDefinedTypeSupport", +] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols/comment_capabilities.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols/comment_capabilities.py index 0058420..acb7566 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/protocols/comment_capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols/comment_capabilities.py @@ -14,7 +14,7 @@ class OracleCommentSupport(Protocol): """Auto-generated capability protocol (P7).""" - def supports_comment(self) -> bool: + def supports_comment_on(self) -> bool: ... # pragma: no cover - def format_comment_statement(self, expr: 'OracleCommentExpression') -> Tuple[str, tuple]: + def format_comment_statement(self, expr) -> Tuple[str, tuple]: ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols/ddl_type.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols/ddl_type.py new file mode 100644 index 0000000..ba32ad0 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols/ddl_type.py @@ -0,0 +1,156 @@ +# src/rhosocial/activerecord/backend/impl/oracle/protocols/ddl_type.py +"""Oracle user-defined type DDL protocol.""" + +from __future__ import annotations + +from typing import Any, Protocol, Tuple, Type, runtime_checkable + +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport + + +@runtime_checkable +class OracleTypeDDLSupport(UserDefinedTypeSupport, Protocol): + """Protocol for Oracle TYPE DDL support.""" + + def supports_type_objects(self) -> bool: + """Whether Oracle user-defined type objects are supported.""" + ... + + def supports_create_type(self) -> bool: + """Whether CREATE TYPE is supported.""" + ... + + def supports_alter_type(self) -> bool: + """Whether ALTER TYPE is supported.""" + ... + + def supports_drop_type(self) -> bool: + """Whether DROP TYPE is supported.""" + ... + + def supports_type_persistable(self) -> bool: + """Whether PERSISTABLE type clauses are supported.""" + ... + + def supports_type_definition(self, definition_type: Type[Any]) -> bool: + """Whether a type-definition class is supported.""" + ... + + def supported_type_definitions(self) -> Tuple[Type[Any], ...]: + """Return supported type-definition classes.""" + ... + + def supports_type_alter_action(self, action_type: Type[Any]) -> bool: + """Whether an ALTER TYPE action class is supported.""" + ... + + def supports_create_type_if_not_exists(self) -> bool: + """Whether CREATE TYPE IF NOT EXISTS is supported.""" + ... + + def supports_create_type_or_replace(self) -> bool: + """Whether CREATE OR REPLACE TYPE is supported.""" + ... + + def supports_alter_type_if_exists(self) -> bool: + """Whether ALTER TYPE IF EXISTS is supported.""" + ... + + def supports_drop_type_if_exists(self) -> bool: + """Whether DROP TYPE IF EXISTS is supported.""" + ... + + def supports_multiple_type_alter_actions(self) -> bool: + """Whether multiple ALTER TYPE actions are supported.""" + ... + + def supports_create_type_body(self) -> bool: + """Whether CREATE TYPE BODY is supported.""" + ... + + def supports_drop_type_body(self) -> bool: + """Whether DROP TYPE BODY is supported.""" + ... + + def supports_create_type_body_if_not_exists(self) -> bool: + """Whether CREATE TYPE BODY IF NOT EXISTS is supported.""" + ... + + def supports_drop_type_body_if_exists(self) -> bool: + """Whether DROP TYPE BODY IF EXISTS is supported.""" + ... + + def supports_drop_type_force(self) -> bool: + """Whether DROP TYPE FORCE is supported.""" + ... + + def supports_drop_type_validate(self) -> bool: + """Whether DROP TYPE VALIDATE is supported.""" + ... + + def supports_type_attribute_actions(self) -> bool: + """Whether attribute ALTER TYPE actions are supported.""" + ... + + def supports_type_method_actions(self) -> bool: + """Whether method ALTER TYPE actions are supported.""" + ... + + def supports_type_limit_actions(self) -> bool: + """Whether collection ALTER TYPE actions are supported.""" + ... + + def supports_type_compile(self) -> bool: + """Whether ALTER TYPE COMPILE is supported.""" + ... + + def supports_type_final(self) -> bool: + """Whether ALTER TYPE FINAL is supported.""" + ... + + def supports_type_instantiable(self) -> bool: + """Whether ALTER TYPE INSTANTIABLE is supported.""" + ... + + def supports_sqlj_type(self) -> bool: + """Whether SQLJ type definitions are supported.""" + ... + + def format_type_definition(self, expr: Any) -> Tuple[str, tuple]: + """Format a type definition.""" + ... + + def format_type_alter_action(self, expr: Any) -> Tuple[str, tuple]: + """Format an ALTER TYPE action.""" + ... + + def format_create_type_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE TYPE.""" + ... + + def format_alter_type_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format ALTER TYPE.""" + ... + + def format_drop_type_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP TYPE.""" + ... + + def format_create_type_body_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format CREATE TYPE BODY.""" + ... + + def format_drop_type_body_statement(self, expr: Any) -> Tuple[str, tuple]: + """Format DROP TYPE BODY.""" + ... + + +OracleTypeSupport = OracleTypeDDLSupport +OracleUserDefinedTypeSupport = OracleTypeDDLSupport + + +__all__ = [ + "OracleTypeDDLSupport", + "OracleTypeSupport", + "OracleUserDefinedTypeSupport", +] diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols/dml_capabilities.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols/dml_capabilities.py index 375144c..02fc1a8 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/protocols/dml_capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols/dml_capabilities.py @@ -30,11 +30,11 @@ def supports_hint_in_insert(self) -> bool: ... # pragma: no cover def supports_insert_first(self) -> bool: ... # pragma: no cover - def format_insert_all_statement(self, into_clauses: List[dict], select_query: Optional[str]=None, *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None, dialect_options: Optional[dict]=None) -> Tuple[str, tuple]: + def format_insert_all_statement(self, into_clauses: List[dict], select_query: Optional[str]=None, *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None) -> Tuple[str, tuple]: ... # pragma: no cover - def format_insert_first_statement(self, into_clauses: List[dict], select_query: Optional[str]=None, *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None, dialect_options: Optional[dict]=None) -> Tuple[str, tuple]: + def format_insert_first_statement(self, into_clauses: List[dict], select_query: Optional[str]=None, *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None) -> Tuple[str, tuple]: ... # pragma: no cover - def format_multi_table_insert_statement(self, keyword: str, into_clauses: List[dict], select_query: Optional[str], *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None, dialect_options: Optional[dict]=None) -> Tuple[str, tuple]: + def format_multi_table_insert_statement(self, keyword: str, into_clauses: List[dict], select_query: Optional[str], *, when_clauses: Optional[List[dict]]=None, else_clause: Optional[dict]=None) -> Tuple[str, tuple]: ... # pragma: no cover def format_insert_into_spec(self, spec: dict, params: List[Any]) -> Tuple[str, tuple]: ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/impl/oracle/protocols/partition_capabilities.py b/src/rhosocial/activerecord/backend/impl/oracle/protocols/partition_capabilities.py index 1d0b032..94a8453 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/protocols/partition_capabilities.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/protocols/partition_capabilities.py @@ -7,7 +7,7 @@ when mixins gain new public rendering methods. """ -from typing import Any, Dict, List, Optional, Sequence, Tuple +from typing import Any, Sequence, Tuple from typing import Protocol @@ -20,13 +20,3 @@ def format_partition_keys(self, keys: Sequence[BaseExpression]) -> Tuple[str, tu ... # pragma: no cover def format_partition_boundary_value(self, value: Any) -> Tuple[str, tuple]: ... # pragma: no cover - def format_legacy_range(self, expr: 'PartitionClause') -> Tuple[str, tuple]: - ... # pragma: no cover - def format_legacy_list(self, expr: 'PartitionClause') -> Tuple[str, tuple]: - ... # pragma: no cover - def format_legacy_hash(self, expr: 'PartitionClause') -> Tuple[str, tuple]: - ... # pragma: no cover - def format_legacy_range_definition(self, partition: Any) -> Tuple[str, tuple]: - ... # pragma: no cover - def format_legacy_list_definition(self, partition: Any) -> Tuple[str, tuple]: - ... # pragma: no cover diff --git a/src/rhosocial/activerecord/backend/impl/oracle/show/expressions.py b/src/rhosocial/activerecord/backend/impl/oracle/show/expressions.py index 0532d93..4a60c99 100644 --- a/src/rhosocial/activerecord/backend/impl/oracle/show/expressions.py +++ b/src/rhosocial/activerecord/backend/impl/oracle/show/expressions.py @@ -22,7 +22,7 @@ ) if TYPE_CHECKING: - from ...dialect import OracleDialect + from ..dialect import OracleDialect class OracleQueryExpression(BaseExpression): diff --git a/src/rhosocial/activerecord/backend/impl/oracle/version.py b/src/rhosocial/activerecord/backend/impl/oracle/version.py new file mode 100644 index 0000000..57dcf50 --- /dev/null +++ b/src/rhosocial/activerecord/backend/impl/oracle/version.py @@ -0,0 +1,80 @@ +# src/rhosocial/activerecord/backend/impl/oracle/version.py +"""Oracle version parsing helpers.""" + +from __future__ import annotations + +import re +from typing import Any, Mapping, Optional, Tuple + + +VERSION_FULL_QUERY = ( + "SELECT VERSION, VERSION_FULL FROM PRODUCT_COMPONENT_VERSION " + "WHERE PRODUCT LIKE 'Oracle Database%'" +) +VERSION_QUERY = "SELECT VERSION FROM PRODUCT_COMPONENT_VERSION WHERE PRODUCT LIKE 'Oracle Database%'" +_VERSION_PART_RE = re.compile(r"^\d+") + + +def parse_version(value: Any) -> Tuple[int, ...]: + """Parse an Oracle dotted version into integer components.""" + if value is None: + return () + if isinstance(value, (tuple, list)): + parts = list(value) + else: + text = str(value).strip() + if not text: + return () + parts = text.split(".") + result = [] + for part in parts: + text = str(part).strip() + match = _VERSION_PART_RE.match(text) + if match is None: + break + result.append(int(match.group(0))) + return tuple(result) + + +def parse_version_row(row: Any) -> Tuple[Tuple[int, ...], Optional[Tuple[int, ...]]]: + """Parse a PRODUCT_COMPONENT_VERSION row into base and full versions.""" + if row is None: + return (), None + if isinstance(row, Mapping): + lowered = {str(key).lower(): value for key, value in row.items()} + version = lowered.get("version") + version_full = lowered.get("version_full") + else: + version = row[0] if len(row) > 0 else None + version_full = row[1] if len(row) > 1 else None + base = parse_version(version) + full = parse_version(version_full) + return base, full or None + + +def normalize_version(value: Any, minimum_length: int = 3) -> Tuple[int, ...]: + """Normalize a version to at least the requested component count.""" + parsed = parse_version(value) + if not parsed: + return () + if len(parsed) < minimum_length: + return parsed + (0,) * (minimum_length - len(parsed)) + return parsed + + +def ru_from_version_full(value: Any) -> Optional[int]: + """Return the RU component from an Oracle VERSION_FULL value.""" + parsed = parse_version(value) + if len(parsed) < 2: + return None + return parsed[1] + + +__all__ = [ + "VERSION_FULL_QUERY", + "VERSION_QUERY", + "parse_version", + "parse_version_row", + "normalize_version", + "ru_from_version_full", +] diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_drop_table_cascade.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_drop_table_cascade.py index 49eface..f3cd2b0 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_drop_table_cascade.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_drop_table_cascade.py @@ -47,12 +47,19 @@ def test_cascade_true_with_purge_option(self, dialect): dialect, table="users", cascade=True, - dialect_options={"purge": True}, + purge=True, ) sql, params = expr.to_sql() assert sql.endswith(" CASCADE CONSTRAINTS PURGE") assert params == () + def test_purge_without_cascade(self, dialect): + expr = DropTableExpression(dialect, table="users", purge=True) + sql, params = expr.to_sql() + assert sql.endswith(" PURGE") + assert "CASCADE" not in sql + assert params == () + def test_cascade_false_raises(self, dialect): expr = DropTableExpression(dialect, table="users", cascade=False) with pytest.raises(UnsupportedFeatureError, match="DROP TABLE ... RESTRICT"): diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_capability_accuracy.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_capability_accuracy.py new file mode 100644 index 0000000..c46443e --- /dev/null +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_capability_accuracy.py @@ -0,0 +1,14 @@ +# tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_capability_accuracy.py +"""Oracle capability accuracy: Oracle has no system-versioned temporal tables. + +The previous declaration claimed ``supports_temporal_tables() is True`` with +no implementation; Oracle's Flashback / temporal validity features are a +different mechanism and are not modelled here. +""" + +from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect + + +def test_temporal_tables_unsupported(): + dialect = OracleDialect(version=(21, 0, 0)) + assert dialect.supports_temporal_tables() is False diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_comment_expressions.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_comment_expressions.py index fcd4c1b..90a5bcd 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_comment_expressions.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_comment_expressions.py @@ -24,8 +24,8 @@ def dialect(): class TestOracleCommentCapabilities: - def test_supports_comment(self, dialect): - assert dialect.supports_comment() is True + def test_supports_comment_on(self, dialect): + assert dialect.supports_comment_on() is True class TestOracleCommentExpression: diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_materialized_view_expressions.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_materialized_view_expressions.py index dd16ef8..cecf3a7 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_materialized_view_expressions.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/dialect/test_oracle_materialized_view_expressions.py @@ -3,7 +3,8 @@ Covers ``CREATE MATERIALIZED VIEW`` (REFRESH / QUERY REWRITE / BUILD options), ``CREATE MATERIALIZED VIEW LOG`` (WITH ROWID / PRIMARY KEY) and -``DROP MATERIALIZED VIEW`` (PRESERVE TABLE), plus capability switches, +``DROP MATERIALIZED VIEW`` (PRESERVE TABLE), ``DBMS_MVIEW.REFRESH``, +plus capability switches, identifier quoting, the ``IF NOT EXISTS`` / ``IF EXISTS`` version gate (23ai) and the ``(9, 0, 0)`` version boundary. @@ -18,6 +19,9 @@ QueryExpression, ) from rhosocial.activerecord.backend.expression.core import Column, TableExpression +from rhosocial.activerecord.backend.expression.statements.ddl_view import ( + RefreshMaterializedViewExpression, +) from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect from rhosocial.activerecord.backend.impl.oracle.expression import ( MaterializedViewBuildMode, @@ -26,6 +30,8 @@ OracleCreateMaterializedViewExpression, OracleCreateMaterializedViewLogExpression, OracleDropMaterializedViewExpression, + OracleRefreshMaterializedViewExpression, + OracleRefreshMethod, ) @@ -240,3 +246,120 @@ def test_at_9i_works(self): d9 = OracleDialect(version=(9, 0, 0)) expr = OracleCreateMaterializedViewExpression(d9, view_name="mv", query=build_query(d9)) assert expr.to_sql()[0].startswith('CREATE MATERIALIZED VIEW "MV"') + + +class TestOracleRefreshMaterializedViewExpression: + """``DBMS_MVIEW.REFRESH`` — Oracle has no REFRESH MATERIALIZED VIEW statement.""" + + def test_minimal_block(self, dialect): + expr = OracleRefreshMaterializedViewExpression(dialect, "sales_summary") + sql, params = expr.to_sql() + assert sql == "BEGIN DBMS_MVIEW.REFRESH('SALES_SUMMARY'); END;" + assert params == () + + def test_name_is_upper_cased(self, dialect): + expr = OracleRefreshMaterializedViewExpression(dialect, "Sales_Summary") + assert "'SALES_SUMMARY'" in expr.to_sql()[0] + + def test_single_quote_in_name_is_escaped(self, dialect): + expr = OracleRefreshMaterializedViewExpression(dialect, "o'brien_mv") + sql, _ = expr.to_sql() + assert "'O''BRIEN_MV'" in sql + + def test_schema_qualified(self, dialect): + expr = OracleRefreshMaterializedViewExpression( + dialect, "sales_summary", schema="reporting" + ) + assert expr.to_sql()[0] == "BEGIN DBMS_MVIEW.REFRESH('REPORTING.SALES_SUMMARY'); END;" + + @pytest.mark.parametrize( + "method,code", + [ + (OracleRefreshMethod.FAST, "F"), + (OracleRefreshMethod.COMPLETE, "C"), + (OracleRefreshMethod.FORCE, "?"), + (OracleRefreshMethod.PARTITION_CHANGE_TRACKING, "P"), + (OracleRefreshMethod.ALWAYS, "A"), + ], + ) + def test_method_codes(self, dialect, method, code): + """The procedure codes differ from the REFRESH FAST/COMPLETE DDL keywords.""" + expr = OracleRefreshMaterializedViewExpression(dialect, "mv", method=method) + assert f"method => '{code}'" in expr.to_sql()[0] + + def test_boolean_options_use_named_notation(self, dialect): + expr = OracleRefreshMaterializedViewExpression( + dialect, + "mv", + atomic_refresh=False, + out_of_place=True, + nested=True, + refresh_after_errors=True, + push_deferred_rpc=False, + ) + sql, _ = expr.to_sql() + assert "atomic_refresh => FALSE" in sql + assert "out_of_place => TRUE" in sql + assert "nested => TRUE" in sql + assert "refresh_after_errors => TRUE" in sql + assert "push_deferred_rpc => FALSE" in sql + + def test_numeric_options(self, dialect): + expr = OracleRefreshMaterializedViewExpression( + dialect, "mv", parallelism=4, purge_option=2 + ) + sql, _ = expr.to_sql() + assert "parallelism => 4" in sql + assert "purge_option => 2" in sql + + def test_options_default_to_omitted(self, dialect): + """Server defaults (atomic_refresh TRUE, purge_option 1) stay implicit.""" + sql, _ = OracleRefreshMaterializedViewExpression(dialect, "mv").to_sql() + assert "atomic_refresh" not in sql + assert "purge_option" not in sql + assert "parallelism" not in sql + + def test_invalid_purge_option(self, dialect): + with pytest.raises(ValueError, match="purge_option"): + OracleRefreshMaterializedViewExpression(dialect, "mv", purge_option=5) + + def test_invalid_parallelism(self, dialect): + with pytest.raises(ValueError, match="parallelism"): + OracleRefreshMaterializedViewExpression(dialect, "mv", parallelism=-1) + + def test_empty_name_rejected(self, dialect): + with pytest.raises(ValueError, match="view_name"): + OracleRefreshMaterializedViewExpression(dialect, " ") + + def test_capability_probe_is_true_and_version_independent(self): + """DBMS_MVIEW.REFRESH predates 9i, so no version gate applies.""" + assert OracleDialect(version=(19, 0, 0)).supports_refresh_materialized_view() is True + assert OracleDialect(version=(8, 1, 0)).supports_refresh_materialized_view() is True + + def test_renders_without_adapted_dialect(self): + """Unlike CREATE/DROP, refresh needs no server version.""" + d = OracleDialect(version=(19, 0, 0)) + sql, _ = OracleRefreshMaterializedViewExpression(d, "mv").to_sql() + assert sql.startswith("BEGIN DBMS_MVIEW.REFRESH(") + + def test_generic_expression_routes_here(self, dialect): + """The generic RefreshMaterializedViewExpression works on Oracle.""" + expr = RefreshMaterializedViewExpression(dialect=dialect, view_name="mv") + sql, params = expr.to_sql() + assert sql == "BEGIN DBMS_MVIEW.REFRESH('MV'); END;" + assert params == () + + def test_generic_with_data_is_ignored(self, dialect): + """A refresh always repopulates; WITH [NO] DATA has no counterpart.""" + expr = RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", with_data=False + ) + assert "WITH NO DATA" not in expr.to_sql()[0] + + def test_generic_concurrent_is_rejected(self, dialect): + expr = RefreshMaterializedViewExpression( + dialect=dialect, view_name="mv", concurrent=True + ) + with pytest.raises(UnsupportedFeatureError) as exc: + expr.to_sql() + assert "CONCURRENTLY" in str(exc.value) diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_async_dml_deep.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_async_dml_deep.py index 17684ba..320d7b6 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_async_dml_deep.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_async_dml_deep.py @@ -28,7 +28,6 @@ class AsyncCustomer(AsyncActiveRecord): __table_name__ = "customers" __schema_name__ = "ar_crm" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_ddl_source_mixin.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_ddl_source_mixin.py new file mode 100644 index 0000000..c809549 --- /dev/null +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_ddl_source_mixin.py @@ -0,0 +1,529 @@ +# tests/rhosocial/activerecord_oracle_test/feature/backend/test_ddl_source_mixin.py + +try: + from typing import Annotated, ClassVar, Optional +except ImportError: + from typing_extensions import Annotated, ClassVar, Optional + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.core import Column, Literal +from rhosocial.activerecord.backend.expression.statements import ( + ColumnConstraintType, + CreateTableOptions, + GeneratedColumnExpression, + GeneratedColumnType, + IndexDefinition, + StorageOptionsExpression, + TableConstraint, + TableConstraintType, +) +from rhosocial.activerecord.backend.expression.statements.ddl_partition import ( + PartitionClause, + PartitionStrategy, +) +from rhosocial.activerecord.backend.impl.oracle import OracleDialect +from rhosocial.activerecord.backend.impl.oracle.expression.column import ( + OracleColumnOptions, +) +from rhosocial.activerecord.backend.impl.oracle.expression.types import ( + OracleCharType, + OracleVarChar2Type, +) +from rhosocial.activerecord.base import ( + CharacterSetAttribute, + CollationAttribute, + DDLAnnotation, + DDLAnnotationHandler, + DDLSourceMixin, + DDLSource, + IdentityAttribute, + UseColumn, + UseColumnAttributes, + UseComment, + UseConstraint, + UseGeneratedColumn, + UseIndex, + UseSqlType, +) +from rhosocial.activerecord.model import ActiveRecord, AsyncActiveRecord + + +class UseColumnOptions(DDLAnnotation): + def __init__(self, *options): + self.options = options + + +class ColumnOptionsHandler(DDLAnnotationHandler): + annotation_types = (UseColumnOptions,) + + @classmethod + def apply(cls, new_class, field_name, annotation, metadata): + metadata.add_column_options(*annotation.options) + + +def check_condition(dialect): + return Column(dialect, "value_col") == "active" + + +def index_condition(dialect): + return Column(dialect, "value_col") == "active" + + +PRIMARY_TYPE = OracleVarChar2Type(length=64) +FALLBACK_TYPE = OracleCharType(length=8) +TYPE_MARKER = UseSqlType(PRIMARY_TYPE, FALLBACK_TYPE) +DEFAULT_VALUE = Literal(None, "model-default", inline_literals=True) +DEFAULT_CONSTRAINT = UseConstraint( + ColumnConstraintType.DEFAULT, + name="df_value", + default_value=DEFAULT_VALUE, +) +CHECK_CONSTRAINT = UseConstraint( + ColumnConstraintType.CHECK, + name="ck_value", + check_condition=check_condition, +) +INCLUDE_COLUMNS = ["nullable_col"] +INDEX_MARKER = UseIndex( + "ix_value", + unique=True, + type="BTREE", + partial_condition=index_condition, + include_columns=INCLUDE_COLUMNS, + if_not_exists=True, + tablespace="index_space", + if_exists=True, + concurrent=True, +) +SECOND_INDEX_MARKER = UseIndex("ix_value_suffix", type="BTREE") +IDENTITY_ATTRIBUTE = IdentityAttribute(generation="ALWAYS", start=5, increment=2) +COLLATION_ATTRIBUTE = CollationAttribute(name="NOCASE") +CHARACTER_SET_ATTRIBUTE = CharacterSetAttribute(name="utf8mb4") +ATTRIBUTES_MARKER = UseColumnAttributes(IDENTITY_ATTRIBUTE, COLLATION_ATTRIBUTE) +SECOND_ATTRIBUTES_MARKER = UseColumnAttributes(CHARACTER_SET_ATTRIBUTE) +COMMENT_MARKER = UseComment("value comment") +GENERATED_EXPRESSION = GeneratedColumnExpression( + None, + Column(None, "value_col"), + GeneratedColumnType.STORED, +) +GENERATED_MARKER = UseGeneratedColumn(GENERATED_EXPRESSION) +VALUE_OPTIONS = ( + OracleColumnOptions(invisible=True), + OracleColumnOptions(invisible=False), +) +VALUE_OPTIONS_MARKER = UseColumnOptions(*VALUE_OPTIONS) +SINGLE_OPTION = OracleColumnOptions(invisible=True) +SINGLE_OPTION_MARKER = UseColumnOptions(SINGLE_OPTION) +NULL_CONSTRAINT = UseConstraint(ColumnConstraintType.NULL) +PK_TYPE = Annotated[int, UseColumn("id")] +NULLABLE_TYPE = Annotated[Optional[str], NULL_CONSTRAINT] +VALUE_TYPE = Annotated[ + str, + UseColumn("value_col"), + TYPE_MARKER, + DEFAULT_CONSTRAINT, + CHECK_CONSTRAINT, + INDEX_MARKER, + SECOND_INDEX_MARKER, + ATTRIBUTES_MARKER, + SECOND_ATTRIBUTES_MARKER, + COMMENT_MARKER, + GENERATED_MARKER, + VALUE_OPTIONS_MARKER, +] + +TABLE_INDEXES = [ + IndexDefinition(None, name="ix_table_first", columns=["value_col"], unique=True), + IndexDefinition(None, name="ix_table_second", columns=["id"]), +] +TABLE_CONSTRAINTS = [ + TableConstraint( + None, + TableConstraintType.UNIQUE, + name="uq_value", + columns=["value_col"], + ), + TableConstraint( + None, + TableConstraintType.CHECK, + name="ck_value_table", + columns=["value_col"], + check_condition=check_condition, + ), +] +TABLE_OPTIONS = CreateTableOptions(None, or_replace=True) +STORAGE_OPTIONS = StorageOptionsExpression(None, {"fillfactor": 80}) +PARTITION_KEY = Column(None, "id") +TABLE_PARTITION = PartitionClause( + None, + PartitionStrategy.RANGE, + [PARTITION_KEY], +) +INHERITED_TABLES = ["parent_a", "parent_b"] +TABLE_SPACE = "ts_data" +BATCH_METHODS = ( + "columns_name", + "columns_type", + "columns_constraints", + "columns_attributes", + "columns_indexes", + "columns_comment", + "columns_generated", + "columns_options", +) + + +class DeclarationMixin: + __table_indexes__ = TABLE_INDEXES + __table_constraints__ = TABLE_CONSTRAINTS + _feature_handlers: ClassVar[list] = [DDLAnnotationHandler, ColumnOptionsHandler] + + @classmethod + def table_options(cls): + return TABLE_OPTIONS + + @classmethod + def table_storage_options(cls): + return STORAGE_OPTIONS + + @classmethod + def table_partition(cls): + return TABLE_PARTITION + + @classmethod + def table_inherits(cls): + return INHERITED_TABLES + + @classmethod + def table_tablespace(cls): + return TABLE_SPACE + + +class SyncModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_sync" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class AsyncModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async" + __primary_key__ = "id" + + pk_field: PK_TYPE + value_field: VALUE_TYPE = DEFAULT_VALUE + nullable_field: NULLABLE_TYPE = None + optional_field: Annotated[Optional[str], SINGLE_OPTION_MARKER] = None + + +class CompositeModel(DeclarationMixin, ActiveRecord): + __table_name__ = "ddl_source_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class AsyncCompositeModel(DeclarationMixin, AsyncActiveRecord): + __table_name__ = "ddl_source_async_composite" + __primary_key__ = ("left_col", "right_col") + + left_field: Annotated[int, UseColumn("left_col")] + right_field: Annotated[int, UseColumn("right_col")] + value_field: str + + +class PlainModel(ActiveRecord): + __table_name__ = "ddl_source_plain" + + id: int + optional: Optional[str] = None + + +class AsyncPlainModel(AsyncActiveRecord): + __table_name__ = "ddl_source_async_plain" + + id: int + optional: Optional[str] = None + + +def _constraint_signature(model, field): + return [ + ( + constraint.constraint_type, + constraint.name, + constraint.default_value, + constraint.check_condition, + ) + for constraint in model.column_constraints(field) + ] + + +def _index_signature(index): + return ( + index.name, + index.columns, + index.unique, + index.type, + index.partial_condition, + index.include_columns, + index.if_not_exists, + index.tablespace, + index.if_exists, + index.concurrent, + ) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_field_declarations_are_collected_in_declaration_order(model): + assert issubclass(model, DDLSourceMixin) + assert isinstance(model, DDLSource) + assert ColumnOptionsHandler in model.get_feature_handlers() + assert model.ddl_field_names() == ( + "pk_field", + "value_field", + "nullable_field", + "optional_field", + ) + assert model.columns_name() == { + "pk_field": "id", + "value_field": "value_col", + "nullable_field": "nullable_field", + "optional_field": "optional_field", + } + assert model.column_name("pk_field") == "id" + assert model.column_name("value_field") == "value_col" + assert model.column_type("value_field") is TYPE_MARKER + assert model.column_type("value_field").data_types == (PRIMARY_TYPE, FALLBACK_TYPE) + assert model.column_type("value_field").data_types[0] is PRIMARY_TYPE + assert model.column_type("value_field").data_types[1] is FALLBACK_TYPE + assert isinstance(model.column_type("value_field").data_type, OracleVarChar2Type) + assert model.column_type("value_field").data_type.length == 64 + + value_constraints = model.column_constraints("value_field") + assert value_constraints[0] is DEFAULT_CONSTRAINT.constraint + assert value_constraints[1] is CHECK_CONSTRAINT.constraint + assert value_constraints[2].constraint_type is ColumnConstraintType.NOT_NULL + assert value_constraints[0].default_value is DEFAULT_VALUE + assert value_constraints[1].check_condition is check_condition + assert model.column_constraints("nullable_field") == [NULL_CONSTRAINT.constraint] + assert model.column_constraints("optional_field") == [] + + attributes = model.column_attributes("value_field") + assert attributes[0] is IDENTITY_ATTRIBUTE + assert attributes[1] is COLLATION_ATTRIBUTE + assert attributes[2] is CHARACTER_SET_ATTRIBUTE + + indexes = model.column_indexes("value_field") + assert [index.name for index in indexes] == ["ix_value", "ix_value_suffix"] + assert all(index.columns == ["value_col"] for index in indexes) + assert indexes[0].unique is True + assert indexes[0].type == "BTREE" + assert indexes[0].partial_condition is index_condition + assert indexes[0].include_columns is INCLUDE_COLUMNS + assert indexes[0].if_not_exists is True + assert indexes[0].tablespace == "index_space" + assert indexes[0].if_exists is True + assert indexes[0].concurrent is True + + assert model.column_comment("value_field") == "value comment" + assert model.generated_column("value_field") is GENERATED_EXPRESSION + options = model.column_options("value_field") + assert options[0] is VALUE_OPTIONS[0] + assert options[1] is VALUE_OPTIONS[1] + assert options[0].invisible is True + assert options[1].invisible is False + assert model.column_options("optional_field") is SINGLE_OPTION + assert model.field_python_type("value_field") is str + assert model.field_is_optional("value_field") is False + assert model.field_is_optional("optional_field") is True + + +def test_collected_backend_data_type_renders_with_dialect(): + dialect = OracleDialect((19, 0, 0)) + data_type = SyncModel.column_type("value_field").data_types[0] + assert isinstance(data_type, OracleVarChar2Type) + data_type.dialect = dialect + try: + rendered = ColumnDefinition(dialect, "value_col", data_type).to_sql() + finally: + data_type.dialect = None + assert rendered == ('"VALUE_COL" VARCHAR2(64)', ()) + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_table_declarations_are_returned_unchanged(model): + assert model.__table_indexes__ is TABLE_INDEXES + assert model.__table_constraints__ is TABLE_CONSTRAINTS + indexes = model.table_indexes() + assert indexes is not TABLE_INDEXES + assert indexes[0] is TABLE_INDEXES[0] + assert indexes[1] is TABLE_INDEXES[1] + constraints = model.table_constraints() + assert constraints is not TABLE_CONSTRAINTS + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert model.table_options() is TABLE_OPTIONS + assert model.table_options().or_replace is True + assert model.table_storage_options() is STORAGE_OPTIONS + assert model.table_partition() is TABLE_PARTITION + assert model.table_partition().method == PartitionStrategy.RANGE.value + assert model.table_partition().keys[0] is PARTITION_KEY + assert model.table_inherits() is INHERITED_TABLES + assert model.table_inherits() == ["parent_a", "parent_b"] + assert model.table_tablespace() == TABLE_SPACE + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_single_primary_key_is_composed_on_the_physical_column(model): + assert model.primary_key_columns() == ("id",) + assert model.primary_key_field() == "pk_field" + assert model.column_name("pk_field") == "id" + constraints = model.column_constraints("pk_field") + assert [item.constraint_type for item in constraints] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert all( + item.constraint_type is not TableConstraintType.PRIMARY_KEY + for item in model.table_constraints() + ) + + +@pytest.mark.parametrize( + "model", [CompositeModel, AsyncCompositeModel], ids=("sync", "async") +) +def test_composite_primary_key_is_composed_as_a_table_constraint(model): + assert model.primary_key_columns() == ("left_col", "right_col") + assert model.primary_key_field() == ("left_field", "right_field") + assert model.column_name("left_field") == "left_col" + assert model.column_name("right_field") == "right_col" + assert [item.constraint_type for item in model.column_constraints("left_field")] == [ + ColumnConstraintType.NOT_NULL + ] + assert [item.constraint_type for item in model.column_constraints("right_field")] == [ + ColumnConstraintType.NOT_NULL + ] + constraints = model.table_constraints() + assert constraints[0] is TABLE_CONSTRAINTS[0] + assert constraints[1] is TABLE_CONSTRAINTS[1] + assert constraints[2].constraint_type is TableConstraintType.PRIMARY_KEY + assert constraints[2].columns == ["left_col", "right_col"] + + +@pytest.mark.parametrize("model", [PlainModel, AsyncPlainModel], ids=("sync", "async")) +def test_default_declarations_and_batch_interfaces(model): + assert model.ddl_field_names() == ("id", "optional") + assert model.columns_name() == {"id": "id", "optional": "optional"} + assert model.column_type("id") is None + assert model.column_comment("id") is None + assert model.generated_column("id") is None + assert model.column_options("id") is None + assert model.column_attributes("id") == [] + assert model.column_indexes("id") == [] + assert [item.constraint_type for item in model.column_constraints("id")] == [ + ColumnConstraintType.PRIMARY_KEY, + ColumnConstraintType.NOT_NULL, + ] + assert model.column_constraints("optional") == [] + assert model.columns_type() == {"id": None, "optional": None} + assert model.columns_constraints()["optional"] == [] + assert model.columns_attributes() == {"id": [], "optional": []} + assert model.columns_indexes() == {"id": [], "optional": []} + assert model.columns_comment() == {"id": None, "optional": None} + assert model.columns_generated() == {"id": None, "optional": None} + assert model.columns_options() == {"id": None, "optional": None} + assert model.table_options() is None + assert model.table_storage_options() is None + assert model.table_partition() is None + assert model.table_inherits() is None + assert model.table_tablespace() is None + assert model.table_indexes() == [] + assert model.table_constraints() == [] + assert model.create_table_statement_classes() is None + + +@pytest.mark.parametrize("model", [SyncModel, AsyncModel], ids=("sync", "async")) +def test_batch_interfaces_preserve_requested_field_order(model): + for method_name in BATCH_METHODS: + method = getattr(model, method_name) + assert tuple(method()) == model.ddl_field_names() + + fields = ["value_field", "pk_field", "optional_field"] + names = model.columns_name(fields) + types = model.columns_type(fields) + constraints = model.columns_constraints(fields) + attributes = model.columns_attributes(fields) + indexes = model.columns_indexes(fields) + comments = model.columns_comment(fields) + generated = model.columns_generated(fields) + options = model.columns_options(fields) + + assert list(names) == fields + assert list(types) == fields + assert list(constraints) == fields + assert list(attributes) == fields + assert list(indexes) == fields + assert list(comments) == fields + assert list(generated) == fields + assert list(options) == fields + assert names == { + "value_field": "value_col", + "pk_field": "id", + "optional_field": "optional_field", + } + assert types["value_field"] is TYPE_MARKER + assert constraints["value_field"][0] is DEFAULT_CONSTRAINT.constraint + assert attributes["value_field"][0] is IDENTITY_ATTRIBUTE + assert indexes["value_field"][0].columns == ["value_col"] + assert comments["value_field"] == "value comment" + assert generated["value_field"] is GENERATED_EXPRESSION + assert options["value_field"][0] is VALUE_OPTIONS[0] + assert options["optional_field"] is SINGLE_OPTION + + +def test_sync_and_async_ddl_sources_collect_the_same_declarations(): + assert SyncModel.columns_name() == AsyncModel.columns_name() + assert SyncModel.columns_type() == AsyncModel.columns_type() + assert SyncModel.columns_attributes() == AsyncModel.columns_attributes() + assert SyncModel.columns_comment() == AsyncModel.columns_comment() + assert SyncModel.columns_generated() == AsyncModel.columns_generated() + assert SyncModel.columns_options() == AsyncModel.columns_options() + for field in ("pk_field", "value_field", "nullable_field", "optional_field"): + assert _constraint_signature(SyncModel, field) == _constraint_signature( + AsyncModel, field + ) + sync_indexes = SyncModel.column_indexes(field) + async_indexes = AsyncModel.column_indexes(field) + assert len(sync_indexes) == len(async_indexes) + for sync_index, async_index in zip(sync_indexes, async_indexes): + assert _index_signature(sync_index) == _index_signature(async_index) + assert SyncModel.table_indexes()[0] is AsyncModel.table_indexes()[0] + assert SyncModel.table_indexes()[1] is AsyncModel.table_indexes()[1] + assert SyncModel.table_constraints()[0] is AsyncModel.table_constraints()[0] + assert SyncModel.table_constraints()[1] is AsyncModel.table_constraints()[1] + assert SyncModel.table_options() is AsyncModel.table_options() + assert SyncModel.table_storage_options() is AsyncModel.table_storage_options() + assert SyncModel.table_partition() is AsyncModel.table_partition() + assert SyncModel.table_inherits() is AsyncModel.table_inherits() + assert SyncModel.table_tablespace() == AsyncModel.table_tablespace() + assert _constraint_signature(CompositeModel, "left_field") == _constraint_signature( + AsyncCompositeModel, "left_field" + ) + assert _constraint_signature(CompositeModel, "right_field") == _constraint_signature( + AsyncCompositeModel, "right_field" + ) + assert [ + (item.constraint_type, item.columns) for item in CompositeModel.table_constraints() + ] == [ + (item.constraint_type, item.columns) + for item in AsyncCompositeModel.table_constraints() + ] diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_introspector_deep.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_introspector_deep.py index 7d95ae2..90713a2 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_introspector_deep.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_introspector_deep.py @@ -40,7 +40,6 @@ class DeepCustomer(ActiveRecord): __table_name__ = "customers" __schema_name__ = "ar_crm" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_column_definition.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_column_definition.py new file mode 100644 index 0000000..f6f59ae --- /dev/null +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_column_definition.py @@ -0,0 +1,50 @@ +# tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_column_definition.py +"""Tests for the Oracle-specific column definition expressions.""" + +import pytest + +from rhosocial.activerecord.backend.expression import ColumnDefinition +from rhosocial.activerecord.backend.expression.types import IntegerType +from rhosocial.activerecord.backend.impl.oracle import OracleDialect +from rhosocial.activerecord.backend.impl.oracle.expression import ( + OracleColumnDefinition, + OracleColumnOptions, +) + + +@pytest.fixture +def dialect(): + return OracleDialect((19, 0, 0)) + + +def test_derives_generic_column_definition(): + assert ColumnDefinition in OracleColumnDefinition.__mro__ + + +def test_invisible(dialect): + sql, _ = OracleColumnDefinition(dialect, "x", IntegerType(dialect), invisible=True).to_sql() + assert sql == '"X" NUMBER(10) INVISIBLE' + + +def test_generic_column_still_renders_on_oracle(dialect): + generic = ColumnDefinition(dialect, "x", IntegerType(dialect)) + sql, _ = generic.to_sql() + assert sql == '"X" NUMBER(10)' + + +def test_options_select_oracle_column_class(): + assert OracleColumnOptions(invisible=True).column_definition_class() is OracleColumnDefinition + + +def test_options_apply_to(dialect): + options = OracleColumnOptions(invisible=True) + col = OracleColumnDefinition(dialect, "c", IntegerType(dialect)) + options.apply_to(col) + assert col.invisible is True + + +def test_options_apply_to_rejects_generic_column(dialect): + options = OracleColumnOptions(invisible=True) + generic = ColumnDefinition(dialect, "c", IntegerType(dialect)) + with pytest.raises(TypeError, match="OracleColumnDefinition"): + options.apply_to(generic) diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_ddl_improvements.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_ddl_improvements.py index 7c4623f..8cbb739 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_ddl_improvements.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_ddl_improvements.py @@ -1,22 +1,77 @@ # tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_ddl_improvements.py """Tests for Oracle DDL improvements: capability gating, UnsupportedFeatureError.""" import pytest -from unittest.mock import patch, PropertyMock +from unittest.mock import patch from rhosocial.activerecord.backend.expression import ( Column, - TableExpression, - QueryExpression, + CreateTableExpression, CreateViewExpression, - DropViewExpression, + QueryExpression, + TableExpression, ) +from rhosocial.activerecord.backend.expression.statements import ViewOptions, ViewCheckOption from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +class TestOracleTableCapabilityGating: + def test_table_declaration_defaults_are_absent(self): + dialect = OracleDialect(version=(19, 0, 0)) + expression = CreateTableExpression(dialect, "plain_table_defaults", []) + + assert expression.inherits == [] + assert expression.tablespace is None + + def test_table_inherits_declaration_is_propagated_and_fails_fast(self): + dialect = OracleDialect(version=(19, 0, 0)) + expression = CreateTableExpression( + dialect, + "inheriting_table", + [], + inherits=["parent_a", "parent_b"], + ) + + assert expression.inherits == ["parent_a", "parent_b"] + assert dialect.supports_table_inheritance() is False + with pytest.raises(UnsupportedFeatureError, match="INHERITS"): + expression.to_sql() + + def test_table_tablespace_declaration_is_propagated_and_rendered(self): + dialect = OracleDialect(version=(19, 0, 0)) + expression = CreateTableExpression( + dialect, + "tablespaced_table", + [], + tablespace="ts_data", + ) + + assert expression.tablespace == "ts_data" + assert dialect.supports_tablespace_option() is True + sql, params = expression.to_sql() + assert 'TABLESPACE "TS_DATA"' in sql + assert params == () + + class TestOracleViewCapabilityGating: """Tests for Oracle VIEW DDL capability gating.""" + def test_create_view_check_option_gated(self): + """WITH CHECK OPTION must fail fast when the capability is off.""" + dialect = OracleDialect() + query = QueryExpression( + dialect, select=[Column(dialect, "id")], from_=TableExpression(dialect, "t") + ) + expr = CreateViewExpression( + dialect, + view_name="v", + query=query, + options=ViewOptions(check_option=ViewCheckOption.CASCADED), + ) + with patch.object(type(dialect), "supports_view_check_option", return_value=False): + with pytest.raises(UnsupportedFeatureError, match="CHECK OPTION"): + expr.to_sql() + def test_create_or_replace_view_supported(self): """Oracle supports CREATE OR REPLACE VIEW.""" dialect = OracleDialect() diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase4.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase4.py index 0173306..e7433e5 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase4.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase4.py @@ -1,27 +1,42 @@ # tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase4.py -"""Phase 4 tests for Oracle generic table partitioning (RANGE/LIST/HASH). +"""Phase 4 tests for Oracle table partitioning (RANGE/LIST/HASH). + +Originally written against the Phase 4 generic ``PartitionClause`` + +``dialect_options`` path; migrated to the structured backend-specific +``OraclePartitionByRange`` / ``...ByList`` / ``...ByHash`` API with +``OraclePartitionDefinition`` boundary definitions. The untyped +``dialect_options`` bag is no longer read by Oracle. Covers: -* Expression construction: ``PartitionClause.to_sql()`` produces the - expected Oracle SQL shape and parameter order for RANGE / LIST / HASH. +* Expression construction: ``to_sql()`` produces the expected Oracle SQL + shape and parameter order for RANGE / LIST / HASH. * Protocol conformance: ``OracleDialect`` satisfies ``PartitionSupport`` and ``OraclePartitionSupport``. * Capability gating: ``supports_*`` methods reflect Oracle version. * ``format_create_table_statement`` appends the PARTITION BY clause. * Error paths: unsupported version, invalid method, malformed definitions. +* Rejection of the generic core ``PartitionClause`` expression. """ import pytest from rhosocial.activerecord.backend.dialect.protocols import PartitionSupport -from rhosocial.activerecord.backend.expression import Column, Literal +from rhosocial.activerecord.backend.expression import Column from rhosocial.activerecord.backend.expression.statements import ( PartitionClause, PartitionStrategy, ) from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect +from rhosocial.activerecord.backend.impl.oracle.expression.partition import ( + OraclePartitionByHash, + OraclePartitionByList, + OraclePartitionByRange, + OraclePartitionDefinition, + OraclePartitionMaxValue, + OraclePartitionValue, +) from rhosocial.activerecord.backend.impl.oracle.protocols.partition import ( OraclePartitionSupport, ) @@ -122,10 +137,7 @@ def test_phase5_oracle_strategies_supported(self): def test_pre_11g_format_partition_raises_unsupported(self): d = OracleDialect(version=(10, 0, 0)) - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": 2}, - ) + clause = OraclePartitionByHash(d, [Column(d, "id")], partitions_count=2) with pytest.raises(UnsupportedFeatureError): clause.to_sql() @@ -139,14 +151,12 @@ class TestOracleRangePartitionClause: def test_range_with_literal_and_maxvalue(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={ - "partitions": [ - {"name": "p1", "less_than": [Literal(d, 18)]}, - {"name": "p2", "less_than": ["MAXVALUE"]}, - ], - }, + clause = OraclePartitionByRange( + d, [Column(d, "age")], + partitions=[ + OraclePartitionDefinition(name="p1", less_than=[OraclePartitionValue(d, 18)]), + OraclePartitionDefinition(name="p2", less_than=[OraclePartitionMaxValue(d)]), + ], ) sql, params = clause.to_sql() assert sql == ( @@ -159,14 +169,18 @@ def test_range_with_literal_and_maxvalue(self): def test_range_multi_column(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "a"), Column(d, "b")], - dialect_options={ - "partitions": [ - {"name": "p1", "less_than": [Literal(d, 1), Literal(d, 100)]}, - {"name": "p2", "less_than": ["MAXVALUE", "MAXVALUE"]}, - ], - }, + clause = OraclePartitionByRange( + d, [Column(d, "a"), Column(d, "b")], + partitions=[ + OraclePartitionDefinition( + name="p1", + less_than=[OraclePartitionValue(d, 1), OraclePartitionValue(d, 100)], + ), + OraclePartitionDefinition( + name="p2", + less_than=[OraclePartitionMaxValue(d), OraclePartitionMaxValue(d)], + ), + ], ) sql, params = clause.to_sql() assert sql.startswith(' PARTITION BY RANGE ("A", "B") ') @@ -177,14 +191,12 @@ def test_range_multi_column(self): def test_range_string_boundary_escaped(self): """String boundary values are escaped and quoted, not bound.""" d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "code")], - dialect_options={ - "partitions": [ - {"name": "p1", "less_than": [Literal(d, "M")]}, - {"name": "p2", "less_than": ["MAXVALUE"]}, - ], - }, + clause = OraclePartitionByRange( + d, [Column(d, "code")], + partitions=[ + OraclePartitionDefinition(name="p1", less_than=[OraclePartitionValue(d, "M")]), + OraclePartitionDefinition(name="p2", less_than=[OraclePartitionMaxValue(d)]), + ], ) sql, params = clause.to_sql() assert "VALUES LESS THAN ('M')" in sql @@ -193,29 +205,27 @@ def test_range_string_boundary_escaped(self): def test_range_no_partitions(self): """RANGE without explicit partition definitions is valid Oracle syntax.""" d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - ) + clause = OraclePartitionByRange(d, [Column(d, "age")]) sql, params = clause.to_sql() assert sql == ' PARTITION BY RANGE ("AGE")' assert params == () def test_range_missing_less_than_raises(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={"partitions": [{"name": "p1"}]}, + clause = OraclePartitionByRange( + d, [Column(d, "age")], + partitions=[OraclePartitionDefinition(name="p1")], ) with pytest.raises(ValueError, match="less_than"): clause.to_sql() def test_range_pre_11g_raises(self): d = OracleDialect(version=(10, 0, 0)) - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={ - "partitions": [{"name": "p1", "less_than": ["MAXVALUE"]}], - }, + clause = OraclePartitionByRange( + d, [Column(d, "age")], + partitions=[ + OraclePartitionDefinition(name="p1", less_than=[OraclePartitionMaxValue(d)]), + ], ) with pytest.raises(UnsupportedFeatureError, match="table partitioning"): clause.to_sql() @@ -230,14 +240,15 @@ class TestOracleListPartitionClause: def test_list_single_column(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.LIST, [Column(d, "region")], - dialect_options={ - "partitions": [ - {"name": "p_east", "in_values": [Literal(d, "EAST"), Literal(d, "NORTH")]}, - {"name": "p_west", "in_values": [Literal(d, "WEST")]}, - ], - }, + clause = OraclePartitionByList( + d, [Column(d, "region")], + partitions=[ + OraclePartitionDefinition( + name="p_east", + in_values=[OraclePartitionValue(d, "EAST"), OraclePartitionValue(d, "NORTH")], + ), + OraclePartitionDefinition(name="p_west", in_values=[OraclePartitionValue(d, "WEST")]), + ], ) sql, params = clause.to_sql() assert sql == ( @@ -249,18 +260,16 @@ def test_list_single_column(self): def test_list_no_partitions(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.LIST, [Column(d, "region")], - ) + clause = OraclePartitionByList(d, [Column(d, "region")]) sql, params = clause.to_sql() assert sql == ' PARTITION BY LIST ("REGION")' assert params == () def test_list_missing_in_values_raises(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.LIST, [Column(d, "region")], - dialect_options={"partitions": [{"name": "p1"}]}, + clause = OraclePartitionByList( + d, [Column(d, "region")], + partitions=[OraclePartitionDefinition(name="p1")], ) with pytest.raises(ValueError, match="in_values"): clause.to_sql() @@ -275,20 +284,14 @@ class TestOracleHashPartitionClause: def test_hash_with_partitions_count(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": 4}, - ) + clause = OraclePartitionByHash(d, [Column(d, "id")], partitions_count=4) sql, params = clause.to_sql() assert sql == ' PARTITION BY HASH ("ID") PARTITIONS 4' assert params == () def test_hash_multi_column(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "a"), Column(d, "b")], - dialect_options={"partitions_count": 8}, - ) + clause = OraclePartitionByHash(d, [Column(d, "a"), Column(d, "b")], partitions_count=8) sql, params = clause.to_sql() assert sql == ' PARTITION BY HASH ("A", "B") PARTITIONS 8' assert params == () @@ -296,93 +299,71 @@ def test_hash_multi_column(self): def test_hash_without_count(self): """HASH without partitions_count omits the PARTITIONS clause.""" d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - ) + clause = OraclePartitionByHash(d, [Column(d, "id")]) sql, params = clause.to_sql() assert sql == ' PARTITION BY HASH ("ID")' assert params == () def test_hash_invalid_count_raises(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": 0}, - ) with pytest.raises(ValueError, match="positive integer"): - clause.to_sql() + OraclePartitionByHash(d, [Column(d, "id")], partitions_count=0) def test_hash_negative_count_raises(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": -3}, - ) with pytest.raises(ValueError, match="positive integer"): - clause.to_sql() + OraclePartitionByHash(d, [Column(d, "id")], partitions_count=-3) def test_hash_bool_count_raises(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": True}, - ) with pytest.raises(TypeError, match="partitions_count"): - clause.to_sql() + OraclePartitionByHash(d, [Column(d, "id")], partitions_count=True) # --------------------------------------------------------------------------- -# Invalid method / type errors +# Error paths # --------------------------------------------------------------------------- class TestOraclePartitionClauseErrors: - """Error path coverage.""" + """Error path coverage for the structured expressions.""" - def test_invalid_method_raises(self): + def test_generic_partition_clause_rejected(self): + """The generic core PartitionClause (dialect_options bag) is rejected.""" d = _dialect() - # Build a PartitionClause with a valid strategy then mutate method - # to an unsupported value to exercise the expression dispatch default - # branch. - clause = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": 2}, - ) - clause.method = "INTERVAL" - with pytest.raises(ValueError, match="Invalid Oracle partition method"): + clause = PartitionClause(d, PartitionStrategy.HASH, [Column(d, "id")]) + with pytest.raises(TypeError, match="Oracle-specific partition clause"): clause.to_sql() def test_range_partition_definition_wrong_type(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={"partitions": ["not-a-dict"]}, + clause = OraclePartitionByRange( + d, [Column(d, "age")], partitions=["not-a-definition"], ) - with pytest.raises(TypeError, match="dict"): + with pytest.raises(TypeError, match="OraclePartitionDefinition"): clause.to_sql() def test_list_partition_definition_wrong_type(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.LIST, [Column(d, "region")], - dialect_options={"partitions": [42]}, + clause = OraclePartitionByList( + d, [Column(d, "region")], partitions=[42], ) - with pytest.raises(TypeError, match="dict"): + with pytest.raises(TypeError, match="OraclePartitionDefinition"): clause.to_sql() def test_boundary_value_wrong_type(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={"partitions": [{"name": "p1", "less_than": [123]}]}, + clause = OraclePartitionByRange( + d, [Column(d, "age")], + partitions=[OraclePartitionDefinition(name="p1", less_than=[123])], ) - with pytest.raises(TypeError, match="Literal"): + with pytest.raises(TypeError, match="OraclePartitionValue"): clause.to_sql() def test_less_than_wrong_container(self): d = _dialect() - clause = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "age")], - dialect_options={"partitions": [{"name": "p1", "less_than": "MAXVALUE"}]}, + clause = OraclePartitionByRange( + d, [Column(d, "age")], + partitions=[OraclePartitionDefinition(name="p1", less_than="MAXVALUE")], ) with pytest.raises(TypeError, match="list or tuple"): clause.to_sql() diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5a.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5a.py index d9c7f92..2084e37 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5a.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5a.py @@ -23,17 +23,12 @@ from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect from rhosocial.activerecord.backend.impl.oracle.expression.partition import ( - OracleIntervalPartitionClause, OraclePartitionByHash, OraclePartitionByList, OraclePartitionByRange, OraclePartitionDefinition, OraclePartitionMaxValue, OraclePartitionValue, - OracleReferencePartitionClause, - OracleSubpartitionClause, - OracleSubpartitionDefinition, - OracleSubpartitionStrategy, ) @@ -60,7 +55,9 @@ def test_range_with_value_and_maxvalue(self): ) sql, params = c.to_sql() assert sql == ( - ' PARTITION BY RANGE ("ID") (PARTITION "P1" VALUES LESS THAN (100), PARTITION "P2" VALUES LESS THAN (MAXVALUE))' + ' PARTITION BY RANGE ("ID") ' + '(PARTITION "P1" VALUES LESS THAN (100), ' + 'PARTITION "P2" VALUES LESS THAN (MAXVALUE))' ) assert params == () @@ -160,7 +157,9 @@ def test_list_single_column(self): ) sql, params = c.to_sql() assert sql == ( - ' PARTITION BY LIST ("REGION") (PARTITION "P_EAST" VALUES (\'EAST\', \'NORTH\'), PARTITION "P_WEST" VALUES (\'WEST\'))' + ' PARTITION BY LIST ("REGION") ' + '(PARTITION "P_EAST" VALUES (\'EAST\', \'NORTH\'), ' + 'PARTITION "P_WEST" VALUES (\'WEST\'))' ) assert params == () @@ -271,9 +270,6 @@ def test_less_than_and_in_values_mutually_exclusive(self): in_values=[OraclePartitionValue(d, "x")], ) - def test_dialect_options_must_be_dict(self): - with pytest.raises(TypeError): - OraclePartitionDefinition(name="p1", dialect_options="not a dict") # type: ignore[arg-type] # --------------------------------------------------------------------------- @@ -333,45 +329,32 @@ def test_unsupported_type_rejected(self): # --------------------------------------------------------------------------- -# Backward compatibility: legacy PartitionClause path +# Generic PartitionClause is no longer supported # --------------------------------------------------------------------------- -class TestLegacyPartitionClauseCompat: - """Phase 4 generic PartitionClause path must still work.""" +class TestGenericPartitionClauseRejected: + """The Phase 4 generic PartitionClause/dialect_options path is removed.""" - def test_legacy_range_path(self): + def test_generic_partition_clause_raises_type_error(self): d = _dialect() c = PartitionClause( d, PartitionStrategy.RANGE, [Column(d, "id")], - dialect_options={ - "partitions": [ - {"name": "p1", "less_than": [Literal(d, 100)]}, - {"name": "p2", "less_than": ["MAXVALUE"]}, - ], - }, ) - sql, params = c.to_sql() - assert 'PARTITION BY RANGE ("ID")' in sql - assert 'PARTITION "P1" VALUES LESS THAN (100)' in sql - assert 'PARTITION "P2" VALUES LESS THAN (MAXVALUE)' in sql - assert params == () + with pytest.raises(TypeError, match="Oracle-specific partition clause"): + c.to_sql() - def test_legacy_hash_path(self): + def test_generic_hash_clause_raises_type_error(self): d = _dialect() c = PartitionClause( d, PartitionStrategy.HASH, [Column(d, "id")], - dialect_options={"partitions_count": 2}, ) - sql, _ = c.to_sql() - assert sql == ' PARTITION BY HASH ("ID") PARTITIONS 2' + with pytest.raises(TypeError, match="Oracle-specific partition clause"): + c.to_sql() def test_backend_specific_dispatch_takes_priority(self): - """Backend-specific expression dispatches before legacy method string.""" + """Backend-specific expression dispatches to the structured formatter.""" d = _dialect() - # Even though method is RANGE, an OraclePartitionByRange instance - # should dispatch through format_partition_by_range (structured form), - # not the legacy dialect_options path. c = OraclePartitionByRange( d, [Column(d, "id")], partitions=[ @@ -379,8 +362,5 @@ def test_backend_specific_dispatch_takes_priority(self): ], ) sql, _ = c.to_sql() - # Structured form produces identical SQL to legacy for this case, - # but the dispatch path is verifiable through the absence of - # dialect_options (structured form never reads dialect_options). assert 'PARTITION BY RANGE ("ID")' in sql assert 'PARTITION "P1" VALUES LESS THAN (100)' in sql diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5e.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5e.py index a70af99..d6a05f6 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5e.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_phase5e.py @@ -10,7 +10,6 @@ import pytest -from rhosocial.activerecord.backend.expression import Column, Literal from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect from rhosocial.activerecord.backend.impl.oracle.expression.partition import ( @@ -230,7 +229,8 @@ def test_exchange_with_options(self): ) sql, _ = e.to_sql() assert sql == ( - 'ALTER TABLE "ORDERS" EXCHANGE PARTITION "P1" WITH TABLE "ORDERS_P1_STAGING" INCLUDING INDEXES WITHOUT VALIDATION' + 'ALTER TABLE "ORDERS" EXCHANGE PARTITION "P1" WITH TABLE ' + '"ORDERS_P1_STAGING" INCLUDING INDEXES WITHOUT VALIDATION' ) def test_exchange_pre_11g_raises(self): diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_real.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_real.py index 08d923d..0dd27e1 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_real.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_partition_real.py @@ -24,9 +24,13 @@ TableExpression, ) from rhosocial.activerecord.backend.errors import DatabaseError -from rhosocial.activerecord.backend.expression.statements import ( - PartitionClause, - PartitionStrategy, +from rhosocial.activerecord.backend.impl.oracle.expression.partition import ( + OraclePartitionByHash, + OraclePartitionByList, + OraclePartitionByRange, + OraclePartitionDefinition, + OraclePartitionMaxValue, + OraclePartitionValue, ) from rhosocial.activerecord.backend.expression.types import ( IntegerType, @@ -105,15 +109,13 @@ def test_create_range_partitioned_table_real(oracle_backend_single): _drop(backend, table_name) try: d = backend.dialect - partition = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "AGE")], - dialect_options={ - "partitions": [ - {"name": "p_minor", "less_than": [Literal(d, 18)]}, - {"name": "p_adult", "less_than": [Literal(d, 65)]}, - {"name": "p_senior", "less_than": ["MAXVALUE"]}, - ], - }, + partition = OraclePartitionByRange( + d, [Column(d, "AGE")], + partitions=[ + OraclePartitionDefinition(name="p_minor", less_than=[OraclePartitionValue(d, 18)]), + OraclePartitionDefinition(name="p_adult", less_than=[OraclePartitionValue(d, 65)]), + OraclePartitionDefinition(name="p_senior", less_than=[OraclePartitionMaxValue(d)]), + ], ) expr = CreateTableExpression( dialect=d, @@ -151,14 +153,12 @@ def test_create_list_partitioned_table_real(oracle_backend_single): _drop(backend, table_name) try: d = backend.dialect - partition = PartitionClause( - d, PartitionStrategy.LIST, [Column(d, "REGION")], - dialect_options={ - "partitions": [ - {"name": "p_east", "in_values": [Literal(d, "EAST")]}, - {"name": "p_west", "in_values": [Literal(d, "WEST")]}, - ], - }, + partition = OraclePartitionByList( + d, [Column(d, "REGION")], + partitions=[ + OraclePartitionDefinition(name="p_east", in_values=[OraclePartitionValue(d, "EAST")]), + OraclePartitionDefinition(name="p_west", in_values=[OraclePartitionValue(d, "WEST")]), + ], ) expr = CreateTableExpression( dialect=d, @@ -195,10 +195,7 @@ def test_create_hash_partitioned_table_real(oracle_backend_single): _drop(backend, table_name) try: d = backend.dialect - partition = PartitionClause( - d, PartitionStrategy.HASH, [Column(d, "ID")], - dialect_options={"partitions_count": 4}, - ) + partition = OraclePartitionByHash(d, [Column(d, "ID")], partitions_count=4) expr = CreateTableExpression( dialect=d, table=table_name, @@ -234,14 +231,12 @@ def test_explain_range_partitioned_table_real(oracle_backend_single): _drop(backend, table_name) try: d = backend.dialect - partition = PartitionClause( - d, PartitionStrategy.RANGE, [Column(d, "AGE")], - dialect_options={ - "partitions": [ - {"name": "p1", "less_than": [Literal(d, 100)]}, - {"name": "p2", "less_than": ["MAXVALUE"]}, - ], - }, + partition = OraclePartitionByRange( + d, [Column(d, "AGE")], + partitions=[ + OraclePartitionDefinition(name="p1", less_than=[OraclePartitionValue(d, 100)]), + OraclePartitionDefinition(name="p2", less_than=[OraclePartitionMaxValue(d)]), + ], ) expr = CreateTableExpression( dialect=d, diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_protocol_conformance.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_protocol_conformance.py index 0aa03b6..4ff843e 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_protocol_conformance.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_protocol_conformance.py @@ -13,7 +13,7 @@ import inspect import sys -from typing import Protocol +from typing import Any, Protocol if sys.version_info >= (3, 13): from typing import get_protocol_members @@ -52,9 +52,12 @@ def get_all_protocol_methods(proto: type) -> set: dialect_protocols.ArraySupport, dialect_protocols.AutoIncrementSupport, dialect_protocols.CTESupport, + dialect_protocols.ColumnAttributeSupport, + dialect_protocols.CommentSupport, dialect_protocols.CollationSupport, dialect_protocols.ConstraintSupport, - dialect_protocols.DDLTypeSupport, + dialect_protocols.DataTypeSupport, + dialect_protocols.UserDefinedTypeSupport, dialect_protocols.ExplainSupport, dialect_protocols.FilterClauseSupport, dialect_protocols.GeneratedColumnSupport, @@ -96,12 +99,16 @@ class TestOracleDialectProtocolConformance: """Assert OracleDialect implements all generic protocols it declares to support.""" @pytest.fixture - def dialect(self): + def dialect(self) -> oracle_dialect.OracleDialect: """Create an OracleDialect instance for testing.""" return oracle_dialect.OracleDialect(version=(23, 0, 0)) @pytest.mark.parametrize("protocol", ORACLE_PROTOCOLS) - def test_implements_protocol(self, dialect, protocol): + def test_implements_protocol( + self, + dialect: oracle_dialect.OracleDialect, + protocol: Any, + ) -> None: """OracleDialect should implement each protocol in ORACLE_PROTOCOLS.""" assert isinstance(dialect, protocol), ( f"OracleDialect does not implement protocol {protocol.__name__}, " @@ -118,6 +125,7 @@ def test_implements_protocol(self, dialect, protocol): # --- Intentional non-support --- # The generic DatabaseSupport protocol is not composed by OracleDialect. dialect_protocols.DatabaseSupport, + dialect_protocols.DomainSupport, # Oracle has no SQL/PSM generic function protocol; routines are exposed # through the Oracle-specific OracleRoutineSupport / OracleFunctionFormatSupport # capability protocols instead. @@ -131,11 +139,17 @@ def test_implements_protocol(self, dialect, protocol): dialect_protocols.ILIKESupport, ] +ORACLE_PROTOCOL_ALIASES = { + "DDLTypeSupport": dialect_protocols.DataTypeSupport, +} + def get_all_generic_protocols() -> dict: """Discover every generic dialect protocol defined in protocols.py.""" discovered = {} for name, obj in inspect.getmembers(dialect_protocols, inspect.isclass): + if name in ORACLE_PROTOCOL_ALIASES: + continue if Protocol in getattr(obj, "__mro__", []) and name.endswith("Support"): discovered[name] = obj return discovered @@ -145,11 +159,15 @@ class TestOracleDialectNegativeProtocolConformance: """Assert OracleDialect does not implement intentionally-unsupported protocols.""" @pytest.fixture - def dialect(self): + def dialect(self) -> oracle_dialect.OracleDialect: return oracle_dialect.OracleDialect(version=(23, 0, 0)) @pytest.mark.parametrize("protocol", ORACLE_NOT_IMPLEMENTED) - def test_does_not_implement_protocol(self, dialect, protocol): + def test_does_not_implement_protocol( + self, + dialect: oracle_dialect.OracleDialect, + protocol: Any, + ) -> None: """OracleDialect must NOT implement any protocol in ORACLE_NOT_IMPLEMENTED.""" assert not isinstance(dialect, protocol), ( f"OracleDialect unexpectedly implements {protocol.__name__}. " @@ -157,7 +175,11 @@ def test_does_not_implement_protocol(self, dialect, protocol): f"(and implement the behaviour fully)." ) - def test_positive_and_negative_lists_partition_all_protocols(self): + def test_ddl_type_support_alias_is_explicitly_exempt(self) -> None: + assert dialect_protocols.DDLTypeSupport is dialect_protocols.DataTypeSupport + assert "DDLTypeSupport" not in get_all_generic_protocols() + + def test_positive_and_negative_lists_partition_all_protocols(self) -> None: """Every generic protocol must be classified for Oracle.""" all_protos = set(get_all_generic_protocols()) positive = {p.__name__ for p in ORACLE_PROTOCOLS if p.__module__ == dialect_protocols.__name__} diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_ddl.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_ddl.py new file mode 100644 index 0000000..ce62678 --- /dev/null +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_ddl.py @@ -0,0 +1,480 @@ +# tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_ddl.py +"""Oracle TYPE DDL SQL and capability tests.""" + +from typing import Any, Optional, Tuple + +import pytest + +from rhosocial.activerecord.backend.dialect.exceptions import UnsupportedFeatureError +from rhosocial.activerecord.backend.dialect.mixins import UserDefinedTypeMixin +from rhosocial.activerecord.backend.dialect.protocols import UserDefinedTypeSupport +from rhosocial.activerecord.backend.expression.serialization import deserialize, serialize +from rhosocial.activerecord.backend.expression.statements import ( + AlterTypeExpression, + CreateTypeExpression, + DropTypeExpression, +) +from rhosocial.activerecord.backend.impl.oracle.async_backend import AsyncOracleBackend +from rhosocial.activerecord.backend.impl.oracle.backend import OracleBackend +from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect +from rhosocial.activerecord.backend.impl.oracle.mixins import OracleTypeDDLMixin +from rhosocial.activerecord.backend.impl.oracle.expression.ddl.type import ( + DropTypeBodyExpression, + OracleAlterTypeAddAttributeAction, + OracleAlterTypeAddMethodAction, + OracleAlterTypeCompileAction, + OracleAlterTypeDependentHandling, + OracleAlterTypeDropAttributeAction, + OracleAlterTypeDropMethodAction, + OracleAlterTypeFinalAction, + OracleAlterTypeInstantiableAction, + OracleAlterTypeLimitAction, + OracleAlterTypeModifyAttributeAction, + OracleCreateTypeBodyExpression, + OracleDropTypeExpression, + OracleIncompleteTypeDefinition, + OracleNestedTableTypeDefinition, + OracleObjectTypeDefinition, + OracleSqljTypeDefinition, + OracleTypeAttribute, + OracleTypeMethod, + OracleVarrayTypeDefinition, +) +from rhosocial.activerecord.backend.impl.oracle.protocols import OracleTypeDDLSupport + + +def _dialect( + version: Tuple[int, int, int] = (19, 0, 0), + version_full: Optional[Tuple[int, ...]] = None, +) -> OracleDialect: + return OracleDialect(version=version, version_full=version_full) + + +def test_type_protocol_and_definition_sql() -> None: + dialect = _dialect(version_full=(19, 28, 0, 0, 0)) + assert issubclass(OracleTypeDDLSupport, UserDefinedTypeSupport) + assert isinstance(dialect, UserDefinedTypeSupport) + assert isinstance(dialect, OracleTypeDDLSupport) + mro = OracleDialect.__mro__ + assert mro.index(OracleTypeDDLMixin) < mro.index(UserDefinedTypeMixin) + assert mro.index(OracleTypeDDLSupport) < mro.index(UserDefinedTypeSupport) + + definition = OracleObjectTypeDefinition( + dialect, + attributes=[ + OracleTypeAttribute("id", "NUMBER(10)"), + OracleTypeAttribute("name", "VARCHAR2(100)"), + ], + methods=[OracleTypeMethod("MEMBER FUNCTION display RETURN VARCHAR2")], + not_final=True, + instantiable=False, + ) + create = CreateTypeExpression(dialect, "person_t", definition, or_replace=True) + sql, params = create.to_sql() + assert sql == ( + 'CREATE OR REPLACE TYPE "PERSON_T" AS OBJECT (' + '"ID" NUMBER(10), "NAME" VARCHAR2(100), ' + 'MEMBER FUNCTION display RETURN VARCHAR2) NOT FINAL NOT INSTANTIABLE' + ) + assert params == () + + +def test_definition_option_validation_and_accessible_by() -> None: + dialect = _dialect() + definition = OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + accessible_by=["PROCEDURE trusted", "PACKAGE APP.TRUSTED"], + ) + assert CreateTypeExpression(dialect, "secured_t", definition).to_sql()[0] == ( + 'CREATE TYPE "SECURED_T" ACCESSIBLE BY (PROCEDURE trusted, ' + 'PACKAGE APP.TRUSTED) AS OBJECT ("ID" NUMBER)' + ) + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + under="base_t", + persistable=True, + ) + subtype = OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + under="base_t", + ) + assert "PERSISTABLE" not in CreateTypeExpression(dialect, "sub_t", subtype).to_sql()[0] + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + accessible_by=[], + ) + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + external_name="id", + ) + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + accessible_by="(PROCEDURE trusted)", + ) + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER", external_name="id")], + ) + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + methods=[OracleTypeMethod("MEMBER FUNCTION f RETURN NUMBER", "f() return int")], + ) + + +def test_invoker_rights_is_validated_and_rendered_canonically() -> None: + dialect = _dialect() + for invoker_rights in (True, "invoker rights"): + definition = OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + invoker_rights=invoker_rights, + ) + sql = CreateTypeExpression(dialect, "secure_t", definition).to_sql()[0] + assert "AUTHID CURRENT_USER" in sql + assert "INVOKER RIGHTS" not in sql + with pytest.raises(ValueError, match="invoker_rights"): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + invoker_rights="DROP TABLE person_t", + ) + with pytest.raises(ValueError, match="mutually exclusive"): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + authid="DEFINER", + invoker_rights=True, + ) + definition.invoker_rights = "INVALID" + with pytest.raises(ValueError, match="invoker_rights"): + CreateTypeExpression(dialect, "secure_t", definition).to_sql() + + +def test_structured_method_kind_return_type_and_name_validation() -> None: + with pytest.raises(ValueError, match="method kind"): + OracleTypeMethod(name="value", return_type="NUMBER") + with pytest.raises(ValueError, match="requires return_type"): + OracleTypeMethod(kind="MEMBER FUNCTION", name="value") + with pytest.raises(ValueError, match="must not define return_type"): + OracleTypeMethod(kind="MEMBER PROCEDURE", name="refresh", return_type="NUMBER") + with pytest.raises(ValueError, match="Oracle identifier"): + OracleTypeMethod(kind="MEMBER FUNCTION", name="value() return int", return_type="NUMBER") + + +def test_collection_sqlj_and_incomplete_sql() -> None: + dialect = _dialect(version_full=(19, 28, 0, 0, 0)) + varray = OracleVarrayTypeDefinition(dialect, "NUMBER", 10, not_null=True) + nested = OracleNestedTableTypeDefinition(dialect, "VARCHAR2(30)", not_persistable=True) + sqlj = OracleSqljTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("name", "VARCHAR2(30)", "name")], + methods=[OracleTypeMethod("MEMBER FUNCTION id RETURN NUMBER", "id() return int")], + external_name="Person", + language="JAVA", + using_clause="SQLData", + ) + incomplete = OracleIncompleteTypeDefinition(dialect) + + assert CreateTypeExpression(dialect, "numbers_t", varray).to_sql()[0] == ( + 'CREATE TYPE "NUMBERS_T" AS VARRAY(10) OF NUMBER NOT NULL' + ) + assert CreateTypeExpression(dialect, "names_t", nested).to_sql()[0] == ( + 'CREATE TYPE "NAMES_T" AS TABLE OF (VARCHAR2(30)) NOT PERSISTABLE' + ) + assert CreateTypeExpression(dialect, "person_java", sqlj).to_sql()[0] == ( + 'CREATE TYPE "PERSON_JAVA" AS OBJECT EXTERNAL NAME \'Person\' LANGUAGE JAVA ' + 'USING SQLData ("NAME" VARCHAR2(30) EXTERNAL NAME \'name\', ' + 'MEMBER FUNCTION id RETURN NUMBER EXTERNAL NAME \'id() return int\')' + ) + with pytest.raises(ValueError, match="EXTERNAL NAME"): + OracleSqljTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + language="JAVA", + using_clause="SQLData", + ) + with pytest.raises(ValueError, match="LANGUAGE JAVA"): + OracleSqljTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + external_name="Person", + using_clause="SQLData", + ) + with pytest.raises(ValueError, match="USING"): + OracleSqljTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + external_name="Person", + language="JAVA", + ) + assert CreateTypeExpression(dialect, "forward_t", incomplete).to_sql()[0] == ( + 'CREATE TYPE "FORWARD_T"' + ) + + +def test_persistable_clause_requires_oracle_18c() -> None: + legacy = _dialect(version=(12, 2, 0)) + supported = _dialect(version=(18, 0, 0)) + legacy_definition = OracleVarrayTypeDefinition(legacy, "NUMBER", 4, persistable=True) + supported_definition = OracleVarrayTypeDefinition( + supported, + "NUMBER", + 4, + not_persistable=True, + ) + + assert legacy.supports_type_persistable() is False + assert supported.supports_type_persistable() is True + with pytest.raises(UnsupportedFeatureError, match="PERSISTABLE"): + CreateTypeExpression(legacy, "legacy_numbers_t", legacy_definition).to_sql() + assert "NOT PERSISTABLE" in CreateTypeExpression( + supported, + "numbers_t", + supported_definition, + ).to_sql()[0] + + +def test_alter_actions_and_dependent_handling() -> None: + dialect = _dialect(version_full=(19, 28, 0, 0, 0)) + actions = [ + OracleAlterTypeAddAttributeAction( + dialect, + OracleTypeAttribute("created_at", "TIMESTAMP"), + dependent_handling=OracleAlterTypeDependentHandling.CASCADE, + ), + OracleAlterTypeAddAttributeAction( + dialect, + OracleTypeAttribute("tag", "VARCHAR2(20)"), + ), + ] + alter = AlterTypeExpression(dialect, "person_t", actions) + sql, params = alter.to_sql() + assert sql == ( + 'ALTER TYPE "PERSON_T" ADD ATTRIBUTE ("CREATED_AT" TIMESTAMP, ' + '"TAG" VARCHAR2(20)) CASCADE' + ) + assert params == () + + method_actions = [ + OracleAlterTypeAddMethodAction(dialect, "MEMBER FUNCTION label RETURN VARCHAR2"), + OracleAlterTypeDropMethodAction( + dialect, + name="old_label", + kind="MEMBER FUNCTION", + parameters=[], + dependent_handling=OracleAlterTypeDependentHandling.CASCADE, + ), + ] + method_sql = AlterTypeExpression(dialect, "person_t", method_actions).to_sql()[0] + assert method_sql == ( + 'ALTER TYPE "PERSON_T" ADD MEMBER FUNCTION label RETURN VARCHAR2, ' + 'DROP MEMBER FUNCTION old_label() CASCADE' + ) + with pytest.raises(UnsupportedFeatureError): + AlterTypeExpression( + dialect, + "person_t", + [actions[0], method_actions[0]], + ).to_sql() + with pytest.raises(UnsupportedFeatureError): + AlterTypeExpression( + dialect, + "person_t", + [ + actions[0], + OracleAlterTypeModifyAttributeAction( + dialect, + OracleTypeAttribute("name", "VARCHAR2(200)"), + ), + ], + ).to_sql() + + drop_attribute = OracleAlterTypeDropAttributeAction(dialect, "created_at") + drop_method = OracleAlterTypeDropMethodAction( + dialect, + name="label", + kind="MEMBER FUNCTION", + parameters=[], + ) + compile_action = OracleAlterTypeCompileAction(dialect, target="BODY", debug=True) + final_action = OracleAlterTypeFinalAction(dialect, not_final=True) + instantiable_action = OracleAlterTypeInstantiableAction(dialect, not_instantiable=True) + limit_action = OracleAlterTypeLimitAction(dialect, 20) + assert drop_attribute.to_sql()[0].startswith("DROP ATTRIBUTE") + assert drop_method.to_sql()[0] == "DROP MEMBER FUNCTION label()" + assert compile_action.to_sql()[0] == "COMPILE DEBUG BODY" + assert final_action.to_sql()[0] == "NOT FINAL" + assert instantiable_action.to_sql()[0] == "NOT INSTANTIABLE" + assert limit_action.to_sql()[0] == "MODIFY LIMIT 20" + + +def test_body_and_force_validate_drop_are_separate_expressions() -> None: + dialect = _dialect(version_full=(19, 28, 0, 0, 0)) + body = OracleCreateTypeBodyExpression( + dialect, + "person_t", + "MEMBER FUNCTION display RETURN VARCHAR2 IS BEGIN RETURN name; END;", + or_replace=True, + ) + force_drop = OracleDropTypeExpression(dialect, "person_t", force=True) + validate_drop = OracleDropTypeExpression(dialect, "person_t", validate=True) + body_drop = DropTypeBodyExpression(dialect, "person_t", if_exists=True) + + assert body.to_sql()[0].startswith("CREATE OR REPLACE TYPE BODY") + assert force_drop.to_sql()[0] == 'DROP TYPE "PERSON_T" FORCE' + assert validate_drop.to_sql()[0] == 'DROP TYPE "PERSON_T" VALIDATE' + assert body_drop.to_sql()[0] == 'DROP TYPE BODY IF EXISTS "PERSON_T"' + with pytest.raises(ValueError): + OracleDropTypeExpression(dialect, "person_t", force=True, validate=True) + + +def test_if_clauses_require_reliable_19_28_ru_source() -> None: + base = _dialect(version=(19, 0, 0), version_full=(19, 27, 0, 0, 0)) + fake = _dialect(version=(19, 28, 0)) + other_major = _dialect(version=(20, 0, 0), version_full=(20, 28, 0, 0, 0)) + future = _dialect(version=(23, 0, 0), version_full=(23, 28, 0, 0, 0)) + supported = _dialect(version=(19, 0, 0), version_full=(19, 28, 0, 0, 0)) + definition = OracleIncompleteTypeDefinition(base) + + assert base.supports_create_type_if_not_exists() is False + assert fake.supports_create_type_if_not_exists() is False + assert other_major.supports_create_type_if_not_exists() is False + assert future.supports_create_type_if_not_exists() is False + assert supported.supports_create_type_if_not_exists() is True + with pytest.raises(UnsupportedFeatureError): + CreateTypeExpression(base, "forward_t", definition, if_not_exists=True).to_sql() + with pytest.raises(UnsupportedFeatureError): + DropTypeBodyExpression(base, "forward_t", if_exists=True).to_sql() + assert CreateTypeExpression( + supported, + "forward_t", + OracleIncompleteTypeDefinition(supported), + if_not_exists=True, + ).to_sql()[0] == 'CREATE TYPE IF NOT EXISTS "FORWARD_T"' + with pytest.raises(ValueError): + CreateTypeExpression( + supported, + "forward_t", + OracleIncompleteTypeDefinition(supported), + if_not_exists=True, + or_replace=True, + ) + + +def test_common_definition_does_not_accept_method_body() -> None: + dialect = _dialect() + with pytest.raises(TypeError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER")], + body="BEGIN NULL; END;", + ) + with pytest.raises(ValueError): + OracleTypeMethod("MEMBER FUNCTION id RETURN NUMBER IS BEGIN RETURN 1; END;") + with pytest.raises(ValueError): + OracleObjectTypeDefinition( + dialect, + attributes=[OracleTypeAttribute("id", "NUMBER", not_null=True)], + ) + + +def test_type_ddl_serialization_registration() -> None: + dialect = _dialect(version_full=(19, 28, 0, 0, 0)) + expressions = ( + OracleObjectTypeDefinition(dialect, [OracleTypeAttribute("id", "NUMBER")]), + OracleSqljTypeDefinition( + dialect, + [OracleTypeAttribute("id", "NUMBER")], + external_name="Person", + language="JAVA", + using_clause="SQLData", + ), + OracleVarrayTypeDefinition(dialect, "NUMBER", 4), + OracleNestedTableTypeDefinition(dialect, "NUMBER"), + OracleIncompleteTypeDefinition(dialect), + OracleAlterTypeAddAttributeAction(dialect, "id", "NUMBER"), + OracleAlterTypeModifyAttributeAction(dialect, "id", "NUMBER"), + OracleAlterTypeDropAttributeAction(dialect, "id"), + OracleAlterTypeAddMethodAction(dialect, "MEMBER FUNCTION id RETURN NUMBER"), + OracleAlterTypeDropMethodAction(dialect, name="id", kind="MEMBER FUNCTION", parameters=[]), + OracleAlterTypeLimitAction(dialect, 4), + OracleAlterTypeCompileAction(dialect), + OracleAlterTypeFinalAction(dialect), + OracleAlterTypeInstantiableAction(dialect), + OracleCreateTypeBodyExpression(dialect, "person_t", "BEGIN NULL; END;"), + OracleDropTypeExpression(dialect, "person_t", force=True), + DropTypeBodyExpression(dialect, "person_t"), + ) + for expression in expressions: + restored = deserialize(serialize(expression), dialect) + assert type(restored) is type(expression) + assert serialize(restored) == serialize(expression) + + +def _bare_backend(backend_type: Any) -> Any: + backend = backend_type.__new__(backend_type) + backend._connection = object() + backend._version = (19, 0, 0) + backend._version_full = None + backend._ru_version = None + backend._dialect = OracleDialect(version=(19, 0, 0)) + backend._register_oracle_adapters = lambda: None + backend.log = lambda *args: None + return backend + + +def test_sync_introspect_refreshes_version_full_cache() -> None: + backend = _bare_backend(OracleBackend) + previous_dialect = backend._dialect + + def get_server_version() -> Tuple[int, int, int]: + backend._version_full = (19, 28, 0, 0, 0) + backend._ru_version = 28 + return (19, 0, 0) + + backend.get_server_version = get_server_version + backend.introspect_and_adapt() + assert backend._dialect is not previous_dialect + assert backend._version_full == (19, 28, 0, 0, 0) + assert backend._ru_version == 28 + assert backend.dialect.version_full == (19, 28, 0, 0, 0) + assert backend.dialect.ru_version == 28 + + +@pytest.mark.asyncio +async def test_async_introspect_refreshes_version_full_cache() -> None: + backend = _bare_backend(AsyncOracleBackend) + previous_dialect = backend._dialect + + async def get_server_version() -> Tuple[int, int, int]: + backend._version_full = (19, 28, 0, 0, 0) + backend._ru_version = 28 + return (19, 0, 0) + + backend.get_server_version = get_server_version + await backend.introspect_and_adapt() + assert backend._dialect is not previous_dialect + assert backend._version_full == (19, 28, 0, 0, 0) + assert backend._ru_version == 28 + assert backend.dialect.version_full == (19, 28, 0, 0, 0) + assert backend.dialect.ru_version == 28 + + +def test_core_drop_type_expression_is_supported() -> None: + dialect = _dialect() + sql, params = DropTypeExpression(dialect, "person_t").to_sql() + assert sql == 'DROP TYPE "PERSON_T"' + assert params == () diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_protocol.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_protocol.py index acf1a5c..872e60b 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_protocol.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_oracle_type_protocol.py @@ -13,17 +13,12 @@ import pytest from rhosocial.activerecord.backend.expression.types import ( - BigIntType, BlobType, BooleanType, CharType, DateType, DateTimeType, - DecimalType, DoubleType, FloatType, IntegerType, JsonBType, JsonType, - RealType, SmallIntType, TextType, TimeTzType, TimestampTzType, - TinyIntType, VarCharType, + DateTimeType, + DecimalType, FloatType, ) from rhosocial.activerecord.backend.impl.oracle.dialect import OracleDialect from rhosocial.activerecord.backend.impl.oracle.expression.types import ( - OracleBigIntType, OracleBlobType, OracleCharType, OracleClobType, - OracleIntegerType, OracleLongRawType, OracleLongType, OracleNClobType, - OracleNVarChar2Type, OracleRawType, OracleSmallIntType, OracleVarChar2Type, - OracleXmlType, + OracleRawType, ) @@ -111,13 +106,13 @@ def test_values_are_classes(self, dialect): assert isinstance(cls, type), f"{name}: value is not a class" -# --- W1 + W4: dialect_options forwarding and equality --- +# --- W4: type-param equality (dialect_options bag removed) --- -class TestDialectOptionsForwarding: - def test_oracle_raw_type_forwards_dialect_options(self): - opts = {"unsigned": True} - t = OracleRawType(length=10, dialect_options=opts) - assert t.dialect_options == {"unsigned": True} +class TestDialectOptionsRemoved: + + def test_constructor_rejects_dialect_options(self): + with pytest.raises(TypeError): + OracleRawType(length=16, dialect_options={"a": 1}) def test_oracle_raw_type_equality_ignores_dialect(self): t1 = OracleRawType(length=16) @@ -134,19 +129,8 @@ def test_oracle_raw_type_hash_consistency(self): t2 = OracleRawType(length=16) assert hash(t1) == hash(t2) - def test_oracle_raw_type_equality_with_dialect_options(self): - t1 = OracleRawType(length=16, dialect_options={"a": 1}) - t2 = OracleRawType(length=16, dialect_options={"a": 1}) - assert t1 == t2 - def test_oracle_raw_type_inequality_on_dialect_options(self): - t1 = OracleRawType(length=16, dialect_options={"a": 1}) - t2 = OracleRawType(length=16, dialect_options={"a": 2}) - assert t1 != t2 - def test_oracle_varchar2_dialect_options_forwarded(self): - t = VarCharType(dialect_options={"charset": "utf8"}) - assert t.dialect_options == {"charset": "utf8"} # --- W4: Precision validation --- diff --git a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_schema_qualified_dml.py b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_schema_qualified_dml.py index 963babc..17aea05 100644 --- a/tests/rhosocial/activerecord_oracle_test/feature/backend/test_schema_qualified_dml.py +++ b/tests/rhosocial/activerecord_oracle_test/feature/backend/test_schema_qualified_dml.py @@ -30,7 +30,6 @@ class SchemaCustomer(ActiveRecord): __table_name__ = "customers" __schema_name__ = "ar_crm" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -40,7 +39,6 @@ class SchemaCustomer(ActiveRecord): class SchemaOrder(ActiveRecord): __table_name__ = "orders" __schema_name__ = "ar_shop" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: Optional[int] = None @@ -165,7 +163,6 @@ async def test_async_insert_is_scoped(provisioned): class AsyncSchemaCustomer(AsyncActiveRecord): __table_name__ = "customers" __schema_name__ = "ar_crm" - __primary_key__ = "id" c: ClassVar[FieldProxy] = FieldProxy() id: _Optional[int] = None