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