Skip to content

Commit 7cde386

Browse files
committed
fix: close oracle round 11 gaps
1 parent e24b8c8 commit 7cde386

11 files changed

Lines changed: 359 additions & 20 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Before writing any code, understand the dialect's capabilities and limits:
4444
| **ALTER TABLE** | ✅ (ADD/DROP/RENAME COLUMN) |
4545
| Raw DDL metadata sync (`text()` / `exec_driver_sql()`) | ✅ (`CREATE TABLE` / `ALTER TABLE` / `DROP TABLE`) |
4646
| **Foreign keys / indexes** ||
47-
| **UNIQUE / CHECK enforcement** | ❌ (accepted for compatibility; compile-time warnings are emitted for CREATE TABLE and ALTER TABLE ADD COLUMN) |
47+
| **UNIQUE / CHECK / FOREIGN KEY enforcement** | ❌ (accepted for compatibility; compile-time warnings are emitted for CREATE TABLE and ALTER TABLE ADD COLUMN) |
4848
| **Concurrent writes** ||
4949
| **Session.rollback()** | Partial (openpyxl with autocommit=False; graph: no-op) |
5050

@@ -102,6 +102,11 @@ engine = create_engine("excel:////home/user/data.xlsx")
102102

103103
# With engine options
104104
engine = create_engine("excel:///data.xlsx", connect_args={"engine": "openpyxl"})
105+
106+
# URL query forwarding to excel-dbapi
107+
engine = create_engine(
108+
"excel:///data.xlsx?data_only=true&sanitize_formulas=true&create=true&file_locking=false"
109+
)
105110
```
106111

107112
## Type Mapping

docs/DEVELOPMENT.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,31 +116,43 @@ sqlalchemy-excel/
116116
│ ├── dialect.py # ExcelDialect, ExcelGraphDialect
117117
│ ├── compiler.py # ExcelCompiler (SQL compilation, HAVING guard)
118118
│ ├── ddl.py # ExcelDDLCompiler (CREATE/DROP TABLE)
119+
│ ├── dml.py # Excel INSERT ... ON CONFLICT helpers
119120
│ ├── types.py # ExcelTypeCompiler (type mappings)
120121
│ ├── reflection.py # ExcelInspectionMixin (schema inspection)
121122
│ └── py.typed # PEP 561 marker file
122-
├── tests/ # 371+ tests (>95% coverage)
123+
├── tests/ # 18 test modules (400 passed, 2 xfailed)
123124
│ ├── conftest.py # Shared fixtures
125+
│ ├── test_alter_table.py
124126
│ ├── test_dialect.py
125127
│ ├── test_compiler.py
126128
│ ├── test_compiler_guards.py
129+
│ ├── test_coverage_boost.py
127130
│ ├── test_ddl.py
131+
│ ├── test_default_guards.py
128132
│ ├── test_dml.py
129133
│ ├── test_e2e.py
130134
│ ├── test_graph_dialect.py
131135
│ ├── test_orm.py
136+
│ ├── test_orm_bulk.py
137+
│ ├── test_orm_relationships.py
132138
│ ├── test_reflection.py
133139
│ ├── test_reflection_full.py
134140
│ ├── test_type_compiler_full.py
135-
│ └── test_types.py
141+
│ ├── test_types.py
142+
│ └── test_upsert.py
136143
├── docs/
137144
│ ├── USAGE.md # Usage guide
138145
│ ├── DEVELOPMENT.md # This file
146+
│ ├── COMPATIBILITY.md # Version pairing and migration guidance
147+
│ ├── RELEASE_NOTES.md # Release highlights and notes
139148
│ └── ROADMAP.md # Project roadmap
140149
├── pyproject.toml # Project metadata (hatchling)
141150
├── Makefile # Development commands
142151
├── README.md
143152
├── CHANGELOG.md
153+
├── SUPPORT.md
154+
├── CODE_OF_CONDUCT.md
155+
├── SECURITY.md
144156
├── CONTRIBUTING.md
145157
└── LICENSE
146158
```

docs/USAGE.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,36 @@ engine = create_engine("excel:////Users/alice/Documents/data.xlsx")
2828
engine = create_engine("excel:///data.xlsx", connect_args={"engine": "openpyxl"})
2929
```
3030

31+
## URL Query Parameters
32+
33+
The dialect forwards selected URL query parameters to `excel_dbapi.connect(...)`.
34+
35+
Supported boolean query parameters:
36+
37+
- `data_only`
38+
- `sanitize_formulas`
39+
- `create`
40+
- `file_locking`
41+
- `autocommit`
42+
43+
`engine` is also accepted as a string query parameter.
44+
45+
```python
46+
from sqlalchemy import create_engine
47+
48+
engine = create_engine(
49+
"excel:///data.xlsx?data_only=true&sanitize_formulas=true"
50+
)
51+
52+
engine = create_engine(
53+
"excel:///data.xlsx?create=false&file_locking=true&autocommit=false"
54+
)
55+
56+
engine = create_engine("excel:///data.xlsx?engine=openpyxl")
57+
```
58+
59+
Boolean values accept `true/false`, `1/0`, and `yes/no` (case-insensitive).
60+
3161
**Important**: Absolute paths require **four slashes** total (`excel:////absolute/path.xlsx`).
3262

3363
> **Source checkout note**: In development/source mode (without an installed entry point), import `sqlalchemy_excel` before `create_engine(...)` so SQLAlchemy registers `excel://` and `excel+graph://` dialects.
@@ -358,7 +388,7 @@ sqlalchemy-excel has some limitations due to the nature of Excel as a database:
358388
- **ORM relationship limits**: Lazy one-to-many relationship loading can return empty collections; use eager loading (`joinedload`) for reliable one-to-many reads.
359389
- **Many-to-many loading is unsupported**: Association table persistence works, but relationship loader SQL for many-to-many is not fully supported.
360390
- **No foreign keys or indexes**: Excel has no concept of these.
361-
- **UNIQUE/CHECK are not enforced**: They are accepted for SQLAlchemy compatibility, ignored by the backend, and compile-time warnings are emitted for `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN`.
391+
- **UNIQUE/CHECK/FOREIGN KEY are not enforced**: They are accepted for SQLAlchemy compatibility, ignored by the backend, and compile-time warnings are emitted for `CREATE TABLE` and `ALTER TABLE ... ADD COLUMN`.
362392
- **Identifier restrictions**: Table and column names must match `[A-Za-z_][A-Za-z0-9_]*`.
363393
- **No concurrent writes**: Use a single-writer model.
364394
- **Rollback**: Partial support — works with the openpyxl backend when `autocommit=False` (snapshot/restore semantics). The Graph API backend treats rollback as a no-op.

src/sqlalchemy_excel/compiler.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,41 @@ class ExcelCompiler(compiler.SQLCompiler):
9292
_subquery_depth: int = 0
9393
_has_join: bool = False
9494

95+
@staticmethod
96+
def _raise_if_schema_qualified(table: Any) -> None:
97+
if getattr(table, "schema", None) is not None:
98+
raise exc.CompileError("Excel dialect does not support schemas")
99+
100+
def visit_table(
101+
self,
102+
table: Any,
103+
asfrom: bool = False,
104+
iscrud: bool = False,
105+
ashint: bool = False,
106+
fromhints: Any = None,
107+
use_schema: bool = True,
108+
from_linter: Any = None,
109+
ambiguous_table_name_map: Any = None,
110+
enclosing_alias: Any = None,
111+
**kwargs: Any,
112+
) -> str:
113+
self._raise_if_schema_qualified(table)
114+
return cast(
115+
"str",
116+
super().visit_table(
117+
table,
118+
asfrom=asfrom,
119+
iscrud=iscrud,
120+
ashint=ashint,
121+
fromhints=fromhints,
122+
use_schema=use_schema,
123+
from_linter=from_linter,
124+
ambiguous_table_name_map=ambiguous_table_name_map,
125+
enclosing_alias=enclosing_alias,
126+
**kwargs,
127+
), # type: ignore[no-untyped-call]
128+
)
129+
95130
@staticmethod
96131
def _is_true_onclause(onclause: Any) -> bool:
97132
node = onclause
@@ -376,6 +411,7 @@ def visit_insert(
376411
*args: Any,
377412
**kw: Any,
378413
) -> str:
414+
self._raise_if_schema_qualified(insert_stmt.table)
379415
visit_insert = cast("Callable[..., str]", super().visit_insert)
380416
return str(visit_insert(insert_stmt, *args, **kw))
381417

src/sqlalchemy_excel/ddl.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Any
1212

1313
from sqlalchemy import exc
14-
from sqlalchemy.schema import CheckConstraint, UniqueConstraint
14+
from sqlalchemy.schema import CheckConstraint, ForeignKeyConstraint, UniqueConstraint
1515
from sqlalchemy.sql import compiler
1616

1717

@@ -24,6 +24,7 @@ def _warn_unsupported_constraints(
2424
context_msg: str,
2525
seen_unique: set[tuple[str, ...]],
2626
seen_check: set[str],
27+
seen_foreign_key: set[tuple[str, ...]],
2728
) -> None:
2829
if column.unique is True:
2930
key = (column.name,)
@@ -54,6 +55,17 @@ def _warn_unsupported_constraints(
5455
)
5556
seen_check.add(check_key)
5657

58+
if column.foreign_keys:
59+
fk_key = tuple(
60+
sorted(str(fk.target_fullname) for fk in column.foreign_keys)
61+
)
62+
if fk_key not in seen_foreign_key:
63+
warnings.warn(
64+
f"Excel dialect does not enforce FOREIGN KEY constraints ({context_msg})",
65+
stacklevel=2,
66+
)
67+
seen_foreign_key.add(fk_key)
68+
5769
@staticmethod
5870
def _warn_unsupported_generated_columns(column: Any) -> None:
5971
if getattr(column, "computed", None) is not None:
@@ -67,6 +79,11 @@ def _warn_unsupported_generated_columns(column: Any) -> None:
6779
stacklevel=2,
6880
)
6981

82+
@staticmethod
83+
def _raise_if_schema_qualified(table: Any) -> None:
84+
if getattr(table, "schema", None) is not None:
85+
raise exc.CompileError("Excel dialect does not support schemas")
86+
7087
def _format_alter_table_name(self, operation: Any) -> str:
7188
schema = getattr(operation, "schema", None)
7289
if schema is not None:
@@ -93,6 +110,7 @@ def visit_create_table(self, create: Any, **kw: Any) -> str:
93110
Format: CREATE TABLE name (col1 TYPE, col2 TYPE, ...)
94111
"""
95112
table = create.element
113+
self._raise_if_schema_qualified(table)
96114
table_name = self.preparer.format_table(table)
97115

98116
columns = []
@@ -103,6 +121,7 @@ def visit_create_table(self, create: Any, **kw: Any) -> str:
103121
inline_pk = len(pk_columns) == 1
104122
seen_unique: set[tuple[str, ...]] = set()
105123
seen_check: set[str] = set()
124+
seen_foreign_key: set[tuple[str, ...]] = set()
106125
for col in table.columns:
107126
col_name = self.preparer.format_column(col)
108127
col_type = self.dialect.type_compiler.process(col.type)
@@ -116,6 +135,7 @@ def visit_create_table(self, create: Any, **kw: Any) -> str:
116135
context_msg="CREATE TABLE",
117136
seen_unique=seen_unique,
118137
seen_check=seen_check,
138+
seen_foreign_key=seen_foreign_key,
119139
)
120140
self._warn_unsupported_generated_columns(col)
121141
suffix = f" {' '.join(constraints)}" if constraints else ""
@@ -141,12 +161,25 @@ def visit_create_table(self, create: Any, **kw: Any) -> str:
141161
stacklevel=2,
142162
)
143163
seen_check.add(check_key)
164+
if isinstance(constraint, ForeignKeyConstraint):
165+
fk_key = tuple(
166+
sorted(
167+
str(element.target_fullname) for element in constraint.elements
168+
)
169+
)
170+
if fk_key not in seen_foreign_key:
171+
warnings.warn(
172+
"Excel dialect does not enforce FOREIGN KEY constraints (CREATE TABLE)",
173+
stacklevel=2,
174+
)
175+
seen_foreign_key.add(fk_key)
144176

145177
return f"CREATE TABLE {table_name} ({', '.join(columns)})"
146178

147179
def visit_drop_table(self, drop: Any, **kw: Any) -> str:
148180
"""Compile DROP TABLE."""
149181
table = drop.element
182+
self._raise_if_schema_qualified(table)
150183
table_name = self.preparer.format_table(table)
151184
return f"DROP TABLE {table_name}"
152185

@@ -168,6 +201,7 @@ def visit_add_column(self, create: Any, **kw: Any) -> str:
168201
context_msg="ALTER TABLE ADD COLUMN",
169202
seen_unique=set(),
170203
seen_check=set(),
204+
seen_foreign_key=set(),
171205
)
172206
self._warn_unsupported_generated_columns(column)
173207
suffix = f" {' '.join(constraints)}" if constraints else ""

src/sqlalchemy_excel/dialect.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from sqlalchemy import event, pool
1212
from sqlalchemy.engine import default
13+
from sqlalchemy.exc import CompileError
1314
from sqlalchemy.schema import Table
1415

1516
from .compiler import ExcelCompiler, ExcelIdentifierPreparer
@@ -368,6 +369,7 @@ def do_execute(
368369
) -> None:
369370
"""Execute a statement, normalizing whitespace for excel-dbapi."""
370371
normalized = _normalize_statement_whitespace_quote_aware(statement)
372+
self._guard_alter_add_column_constraints(cursor, normalized)
371373
pre_alter_meta = self._read_pre_alter_metadata(cursor, normalized)
372374
cursor.execute(_statement_for_driver_execution(normalized), parameters)
373375
self._sync_create_table_metadata(cursor, normalized)
@@ -382,6 +384,7 @@ def do_execute_no_params(
382384
) -> None:
383385
"""Execute a statement with no parameters."""
384386
normalized = _normalize_statement_whitespace_quote_aware(statement)
387+
self._guard_alter_add_column_constraints(cursor, normalized)
385388
pre_alter_meta = self._read_pre_alter_metadata(cursor, normalized)
386389
cursor.execute(_statement_for_driver_execution(normalized), None)
387390
self._sync_create_table_metadata(cursor, normalized)
@@ -413,6 +416,38 @@ def _read_pre_alter_metadata(
413416
current = excel_dbapi.read_table_metadata(raw_conn, table_name) or []
414417
return [dict(column) for column in current]
415418

419+
def _guard_alter_add_column_constraints(self, cursor: Any, statement: str) -> None:
420+
add_match = _parse_alter_add_column(statement)
421+
if add_match is None:
422+
return
423+
424+
raw_table_name, _raw_col_name, remainder = add_match
425+
tail = remainder.upper()
426+
requires_existing_rows_backfill = "NOT NULL" in tail or "PRIMARY KEY" in tail
427+
if not requires_existing_rows_backfill:
428+
return
429+
430+
if self._table_has_existing_rows(cursor, raw_table_name):
431+
table_name = self._unquote_identifier(raw_table_name)
432+
raise CompileError(
433+
"Excel dialect cannot add NOT NULL or PRIMARY KEY columns to "
434+
f"non-empty table '{table_name}'"
435+
)
436+
437+
@staticmethod
438+
def _table_has_existing_rows(cursor: Any, table_name: str) -> bool:
439+
cursor.execute(f"SELECT 1 FROM {table_name} LIMIT 1", None)
440+
441+
fetchone = getattr(cursor, "fetchone", None)
442+
if callable(fetchone):
443+
return fetchone() is not None
444+
445+
fetchall = getattr(cursor, "fetchall", None)
446+
if callable(fetchall):
447+
return bool(fetchall())
448+
449+
return False
450+
416451
@staticmethod
417452
def _unquote_identifier(identifier: str) -> str:
418453
if len(identifier) >= 2 and identifier[0] == identifier[-1] == '"':

0 commit comments

Comments
 (0)