Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repos:
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.15.22"
rev: "v0.16.0"
hooks:
- id: ruff
args: ["--fix"]
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,12 @@ from sqlspec.adapters.sqlite import SqliteConfig
class Greeting:
message: str


spec = SQLSpec()
db = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))

with spec.provide_session(db) as session:
greeting = session.select_one(
"SELECT 'Hello, SQLSpec!' AS message",
schema_type=Greeting,
)
greeting = session.select_one("SELECT 'Hello, SQLSpec!' AS message", schema_type=Greeting)
print(greeting.message) # Output: Hello, SQLSpec!
```

Expand Down
6 changes: 2 additions & 4 deletions docs/PYPI_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,12 @@ from sqlspec.adapters.sqlite import SqliteConfig
class Greeting:
message: str


spec = SQLSpec()
db = spec.add_config(SqliteConfig(connection_config={"database": ":memory:"}))

with spec.provide_session(db) as session:
greeting = session.select_one(
"SELECT 'Hello, SQLSpec!' AS message",
schema_type=Greeting,
)
greeting = session.select_one("SELECT 'Hello, SQLSpec!' AS message", schema_type=Greeting)
print(greeting.message) # Output: Hello, SQLSpec!
```

Expand Down
19 changes: 18 additions & 1 deletion docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ important operational fixes.
Recent Updates
==============

v0.57.0
v0.56.1
------------------------------------------------------------------------------

**Added:**
Expand All @@ -24,6 +24,23 @@ v0.57.0
* PostgreSQL-family ADBC drivers no longer fail when a UUID is a statement's
only parameter. Repeated executions of such a statement previously reused a
cached plan that skipped UUID binding and reached PostgreSQL as ``bytea``.
* ``EXPLAIN`` statements now return their query plan. Explained statements were
classified as non-row-returning, so drivers ran the ``EXPLAIN`` and then
discarded the plan, leaving ``select()`` and ``execute()`` with no rows. This
affected every adapter except DuckDB, ADBC, and BigQuery. ``SHOW`` and
``DESCRIBE`` are now classified the same way. Oracle ``.explain()`` calls now
tag the plan-table entry, return ``DBMS_XPLAN`` rows, and remove the entry
before returning. Raw caller-owned ``EXPLAIN PLAN`` statements remain
unchanged. (`#655 <https://github.com/litestar-org/sqlspec/issues/655>`_)
* Explaining a statement no longer discards its configuration. An explained
PostgreSQL statement previously compiled with ``?`` placeholders instead of
``$n``, and a named parameter used more than once was sent once per use
instead of being deduplicated. ``SQL.copy(statement_config=...)`` also raised
``TypeError`` and now accepts an override.
* ``TABLE table_name`` statements now return their rows on PostgreSQL, DuckDB,
and MySQL. SQLGlot does not currently model this shorthand for
``SELECT * FROM table_name``, so SQLSpec previously classified it as a
non-row-returning command and discarded the result.

**Changed:**

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
name = "sqlspec"
readme = "README.md"
requires-python = ">=3.10, <4.0"
version = "0.56.0"
version = "0.56.1"

[project.urls]
Discord = "https://discord.gg/litestar"
Expand Down Expand Up @@ -308,7 +308,7 @@ opt_level = "3" # Maximum optimization (0-3)
allow_dirty = true
commit = false
commit_args = "--no-verify"
current_version = "0.56.0"
current_version = "0.56.1"
ignore_missing_files = false
ignore_missing_version = false
message = "chore(release): bump to v{new_version}"
Expand Down
170 changes: 169 additions & 1 deletion sqlspec/adapters/oracledb/driver.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Oracle Driver"""

import logging
from typing import TYPE_CHECKING, Any, NamedTuple, Protocol, cast, overload
from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, cast, overload

from oracledb import create_pipeline as create_oracle_pipeline

Expand Down Expand Up @@ -57,6 +57,7 @@
get_cache_config,
register_driver_profile,
)
from sqlspec.core.explain import ORACLE_EXPLAIN_PREFIX, ORACLE_MANAGED_EXPLAIN_META_KEY
from sqlspec.driver import (
AsyncDriverAdapterBase,
AsyncRowStream,
Expand All @@ -73,6 +74,7 @@
from sqlspec.utils.module_loader import ensure_pyarrow
from sqlspec.utils.text import normalize_identifier, quote_identifier, split_qualified_identifier
from sqlspec.utils.type_guards import has_pipeline_capability, is_async_readable
from sqlspec.utils.uuids import uuid4

if TYPE_CHECKING:
from collections.abc import Sequence
Expand All @@ -96,6 +98,15 @@

logger = get_logger(__name__)

PLAN_STATEMENT_ID_MAX_LENGTH: Final[int] = 30
"""Width of the PLAN_TABLE STATEMENT_ID column."""

PLAN_STATEMENT_ID_PREFIX: Final[str] = "sqlspec_"

PLAN_DISPLAY_SQL: Final[str] = "SELECT plan_table_output FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, :statement_id, 'TYPICAL'))"

PLAN_CLEANUP_SQL: Final[str] = "DELETE FROM plan_table WHERE statement_id = :statement_id"


class OraclePipelineDriver(Protocol):
"""Protocol for Oracle pipeline driver methods used in stack execution."""
Expand Down Expand Up @@ -368,6 +379,10 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult":
)
prepared_parameters = cast("list[Any] | tuple[Any, ...] | dict[Any, Any] | None", prepared_parameters)

explained_sql = _managed_explain_statement(statement, sql)
if explained_sql is not None:
return self._dispatch_explain_plan(cursor, explained_sql, prepared_parameters)

cursor.execute(sql, prepared_parameters or {}, **build_fetch_kwargs(self.driver_features))

is_select_like = statement.returns_rows() or self._should_force_select(statement, cursor)
Expand Down Expand Up @@ -844,6 +859,54 @@ def _detect_oracledb_version(self) -> "tuple[int, int, int]":
def _resolve_row_metadata(self, description: Any) -> "tuple[list[str], bool]":
return resolve_row_metadata(description, self.driver_features, self._row_metadata_cache)

def _dispatch_explain_plan(self, cursor: Any, explained_sql: str, parameters: Any) -> "ExecutionResult":
"""Request a plan, read it back, and release the plan table rows.

Oracle writes the plan to a table instead of returning it, so the plan is
tagged with a generated statement id, formatted through ``DBMS_XPLAN``, and
deleted again. All three statements run in the caller's transaction, so the
plan is read before a rollback can discard it.

Args:
cursor: Oracle cursor object.
explained_sql: Statement whose plan is wanted.
parameters: Bind values for the explained statement.

Returns:
Execution result carrying the formatted plan rows.
"""
statement_id = _new_plan_statement_id()
cursor.execute(_tagged_explain_plan_sql(statement_id, explained_sql), parameters or {})

try:
cursor.execute(PLAN_DISPLAY_SQL, {"statement_id": statement_id}, **build_fetch_kwargs(self.driver_features))
fetched_data = cursor.fetchall()
column_names, requires_lob_coercion = self._resolve_row_metadata(cursor.description)
data, column_names = collect_sync_rows(
cast("list[Any] | None", fetched_data),
cursor.description,
self.driver_features,
column_names=column_names,
requires_lob_coercion=requires_lob_coercion,
)
result = self.create_execution_result(
cursor,
selected_data=data,
column_names=column_names,
data_row_count=len(data),
is_select_result=True,
row_format="tuple",
)
except Exception:
try:
cursor.execute(PLAN_CLEANUP_SQL, {"statement_id": statement_id})
except Exception:
logger.warning("Failed to clean up Oracle plan rows after plan retrieval failed", exc_info=True)
raise
else:
cursor.execute(PLAN_CLEANUP_SQL, {"statement_id": statement_id})
return result

def _execute_arrow_dataframe(self, sql: str, parameters: "Any", batch_size: int | None) -> "Any":
"""Execute SQL and return an Oracle DataFrame."""
params = parameters if parameters is not None else []
Expand Down Expand Up @@ -1020,6 +1083,10 @@ async def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionRes
)
prepared_parameters = cast("list[Any] | tuple[Any, ...] | dict[Any, Any] | None", prepared_parameters)

explained_sql = _managed_explain_statement(statement, sql)
if explained_sql is not None:
return await self._dispatch_explain_plan(cursor, explained_sql, prepared_parameters)

await cursor.execute(sql, prepared_parameters or {}, **build_fetch_kwargs(self.driver_features))

is_select_like = statement.returns_rows() or self._should_force_select(statement, cursor)
Expand Down Expand Up @@ -1512,6 +1579,56 @@ def _detect_oracledb_version(self) -> "tuple[int, int, int]":
def _resolve_row_metadata(self, description: Any) -> "tuple[list[str], bool]":
return resolve_row_metadata(description, self.driver_features, self._row_metadata_cache)

async def _dispatch_explain_plan(self, cursor: Any, explained_sql: str, parameters: Any) -> "ExecutionResult":
"""Request a plan, read it back, and release the plan table rows.

Oracle writes the plan to a table instead of returning it, so the plan is
tagged with a generated statement id, formatted through ``DBMS_XPLAN``, and
deleted again. All three statements run in the caller's transaction, so the
plan is read before a rollback can discard it.

Args:
cursor: Oracle cursor object.
explained_sql: Statement whose plan is wanted.
parameters: Bind values for the explained statement.

Returns:
Execution result carrying the formatted plan rows.
"""
statement_id = _new_plan_statement_id()
await cursor.execute(_tagged_explain_plan_sql(statement_id, explained_sql), parameters or {})

try:
await cursor.execute(
PLAN_DISPLAY_SQL, {"statement_id": statement_id}, **build_fetch_kwargs(self.driver_features)
)
fetched_data = await cursor.fetchall()
column_names, requires_lob_coercion = self._resolve_row_metadata(cursor.description)
data, column_names = await collect_async_rows(
cast("list[Any] | None", fetched_data),
cursor.description,
self.driver_features,
column_names=column_names,
requires_lob_coercion=requires_lob_coercion,
)
result = self.create_execution_result(
cursor,
selected_data=data,
column_names=column_names,
data_row_count=len(data),
is_select_result=True,
row_format="tuple",
)
except Exception:
try:
await cursor.execute(PLAN_CLEANUP_SQL, {"statement_id": statement_id})
except Exception:
logger.warning("Failed to clean up Oracle plan rows after plan retrieval failed", exc_info=True)
raise
else:
await cursor.execute(PLAN_CLEANUP_SQL, {"statement_id": statement_id})
return result

async def _execute_arrow_dataframe(self, sql: str, parameters: "Any", batch_size: int | None) -> "Any":
"""Execute SQL and return an Oracle DataFrame."""
params = parameters if parameters is not None else []
Expand Down Expand Up @@ -1676,6 +1793,57 @@ def _row_has_async_readable(row: Any) -> bool:
return any(is_async_readable(value) for value in values)


def _managed_explain_statement(statement: "SQL", sql: str) -> "str | None":
"""Extract a SQLSpec-built Oracle plan request without parsing SQL text.

``Explain.build()`` marks its SQLGlot command expression. Raw caller SQL has no
marker and stays on the ordinary one-statement path. The marked form has one exact
generated prefix, so extracting the inner statement is a constant-prefix slice,
not a second SQL parse.

Args:
statement: Prepared SQL object.
sql: Compiled statement text.

Returns:
The inner statement, or None when SQLSpec does not own the plan flow.

Raises:
SQLSpecError: If a marked statement no longer has SQLSpec's generated shape.
"""
expression = statement.raw_expression
if expression is None or not expression.meta.get(ORACLE_MANAGED_EXPLAIN_META_KEY, False):
return None

if not sql.startswith(ORACLE_EXPLAIN_PREFIX) or len(sql) == len(ORACLE_EXPLAIN_PREFIX):
msg = "SQLSpec-managed Oracle EXPLAIN statement lost its generated prefix"
raise SQLSpecError(msg)
return sql[len(ORACLE_EXPLAIN_PREFIX) :]


def _new_plan_statement_id() -> str:
"""Build an identifier that isolates one plan within the session plan table.

Returns:
Identifier of at most ``PLAN_STATEMENT_ID_MAX_LENGTH`` alphanumeric characters.
"""
suffix_length = PLAN_STATEMENT_ID_MAX_LENGTH - len(PLAN_STATEMENT_ID_PREFIX)
return f"{PLAN_STATEMENT_ID_PREFIX}{uuid4().hex[:suffix_length]}"


def _tagged_explain_plan_sql(statement_id: str, explained_sql: str) -> str:
"""Rewrite a plan request so its rows can be located and removed again.

Args:
statement_id: Identifier produced by ``_new_plan_statement_id``.
explained_sql: Statement whose plan is wanted, with its bind placeholders intact.

Returns:
Plan request tagged with the statement id.
"""
return f"EXPLAIN PLAN SET STATEMENT_ID = '{statement_id}' FOR {explained_sql}"


def _retry_arrow_without_fetch_lobs(exc: TypeError, fetch_kwargs: "dict[str, object]") -> "dict[str, object] | None":
if "fetch_lobs" not in fetch_kwargs or "fetch_lobs" not in str(exc):
return None
Expand Down
Loading
Loading