sqlalchemy-excel 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_excel/__init__.py +12 -0
- sqlalchemy_excel/compiler.py +159 -0
- sqlalchemy_excel/ddl.py +57 -0
- sqlalchemy_excel/dialect.py +221 -0
- sqlalchemy_excel/reflection.py +173 -0
- sqlalchemy_excel/types.py +100 -0
- sqlalchemy_excel-0.1.1.dist-info/METADATA +96 -0
- sqlalchemy_excel-0.1.1.dist-info/RECORD +11 -0
- sqlalchemy_excel-0.1.1.dist-info/WHEEL +4 -0
- sqlalchemy_excel-0.1.1.dist-info/entry_points.txt +2 -0
- sqlalchemy_excel-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""SQL compiler for the Excel dialect.
|
|
2
|
+
|
|
3
|
+
Compiles SQLAlchemy expression trees into the SQL subset that
|
|
4
|
+
excel-dbapi's parser understands:
|
|
5
|
+
|
|
6
|
+
Supported:
|
|
7
|
+
SELECT columns FROM table [WHERE ...] [ORDER BY col [ASC|DESC]] [LIMIT n]
|
|
8
|
+
INSERT INTO table (cols) VALUES (vals)
|
|
9
|
+
UPDATE table SET col=val [WHERE ...]
|
|
10
|
+
DELETE FROM table [WHERE ...]
|
|
11
|
+
|
|
12
|
+
Rejected (raises CompileError):
|
|
13
|
+
JOIN, GROUP BY, HAVING, DISTINCT, OFFSET, subqueries,
|
|
14
|
+
CTEs, aggregate functions, window functions, RETURNING
|
|
15
|
+
|
|
16
|
+
excel-dbapi's parser uses unquoted, unprefixed column names:
|
|
17
|
+
SELECT id, name FROM users (correct)
|
|
18
|
+
SELECT users.id, users.name FROM users (WRONG — parser rejects)
|
|
19
|
+
|
|
20
|
+
So we override the identifier preparer to never use table prefixes.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
|
|
27
|
+
from sqlalchemy import exc
|
|
28
|
+
from sqlalchemy.sql import compiler, elements
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ExcelIdentifierPreparer(compiler.IdentifierPreparer):
|
|
32
|
+
"""Identifier preparer that never quotes identifiers.
|
|
33
|
+
|
|
34
|
+
excel-dbapi's parser expects bare column names without quotes
|
|
35
|
+
or table prefixes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
reserved_words: ClassVar[set[str]] = set()
|
|
39
|
+
|
|
40
|
+
def __init__(self, dialect: Any) -> None:
|
|
41
|
+
super().__init__(dialect, initial_quote="", final_quote="")
|
|
42
|
+
|
|
43
|
+
def quote_identifier(self, value: str) -> str:
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
def quote(self, ident: str, force: Any = None) -> str:
|
|
47
|
+
return ident
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExcelCompiler(compiler.SQLCompiler):
|
|
51
|
+
"""Compiles SQLAlchemy SQL expressions for excel-dbapi."""
|
|
52
|
+
|
|
53
|
+
def visit_column(
|
|
54
|
+
self,
|
|
55
|
+
column: Any,
|
|
56
|
+
add_to_result_map: Any = None,
|
|
57
|
+
include_table: bool = True,
|
|
58
|
+
**kw: Any,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""Override to never include table prefix in column references.
|
|
61
|
+
|
|
62
|
+
excel-dbapi expects: SELECT id, name FROM users
|
|
63
|
+
Not: SELECT users.id, users.name FROM users
|
|
64
|
+
"""
|
|
65
|
+
# Force include_table=False to avoid table.column notation
|
|
66
|
+
return super().visit_column(
|
|
67
|
+
column,
|
|
68
|
+
add_to_result_map=add_to_result_map,
|
|
69
|
+
include_table=False,
|
|
70
|
+
**kw,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def visit_label(
|
|
74
|
+
self,
|
|
75
|
+
label: Any,
|
|
76
|
+
add_to_result_map: Any = None,
|
|
77
|
+
within_label_clause: bool = False,
|
|
78
|
+
within_columns_clause: bool = False,
|
|
79
|
+
render_label_as_label: Any = None,
|
|
80
|
+
result_map_targets: Any = (),
|
|
81
|
+
**kw: Any,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Override to never emit AS <label> in SELECT columns.
|
|
84
|
+
|
|
85
|
+
excel-dbapi's parser does not understand column aliases.
|
|
86
|
+
We still register the result map so SQLAlchemy can map
|
|
87
|
+
result columns back to ORM attributes by position.
|
|
88
|
+
"""
|
|
89
|
+
render_label_with_as = within_columns_clause and not within_label_clause
|
|
90
|
+
|
|
91
|
+
if render_label_with_as:
|
|
92
|
+
# Compute the label name for the result map
|
|
93
|
+
if isinstance(label.name, elements._truncated_label):
|
|
94
|
+
labelname = self._truncated_identifier("colident", label.name)
|
|
95
|
+
else:
|
|
96
|
+
labelname = label.name
|
|
97
|
+
|
|
98
|
+
if add_to_result_map is not None:
|
|
99
|
+
add_to_result_map(
|
|
100
|
+
labelname,
|
|
101
|
+
label.name,
|
|
102
|
+
(label, labelname) + label._alt_names + result_map_targets,
|
|
103
|
+
label.type,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Emit the column WITHOUT "AS <label>"
|
|
107
|
+
return label.element._compiler_dispatch(
|
|
108
|
+
self,
|
|
109
|
+
within_columns_clause=True,
|
|
110
|
+
within_label_clause=True,
|
|
111
|
+
**kw,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if render_label_as_label is label:
|
|
115
|
+
if isinstance(label.name, elements._truncated_label):
|
|
116
|
+
labelname = self._truncated_identifier("colident", label.name)
|
|
117
|
+
else:
|
|
118
|
+
labelname = label.name
|
|
119
|
+
return self.preparer.format_label(label, labelname)
|
|
120
|
+
|
|
121
|
+
return label.element._compiler_dispatch(self, within_columns_clause=False, **kw)
|
|
122
|
+
|
|
123
|
+
# ── Unsupported feature guards ─────────────────────────
|
|
124
|
+
|
|
125
|
+
def visit_join(self, join: Any, **kw: Any) -> str:
|
|
126
|
+
raise exc.CompileError("Excel dialect does not support JOIN")
|
|
127
|
+
|
|
128
|
+
def group_by_clause(self, select: Any, **kw: Any) -> str:
|
|
129
|
+
if select._group_by_clauses:
|
|
130
|
+
raise exc.CompileError("Excel dialect does not support GROUP BY")
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
def having_clause(self, select: Any, **kw: Any) -> str:
|
|
134
|
+
raise exc.CompileError("Excel dialect does not support HAVING")
|
|
135
|
+
|
|
136
|
+
def limit_clause(self, select: Any, **kw: Any) -> str:
|
|
137
|
+
text = ""
|
|
138
|
+
if select._limit_clause is not None:
|
|
139
|
+
text += " LIMIT " + self.process(select._limit_clause, **kw)
|
|
140
|
+
if select._offset_clause is not None:
|
|
141
|
+
raise exc.CompileError("Excel dialect does not support OFFSET")
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
def visit_cte(self, cte: Any, **kw: Any) -> str:
|
|
145
|
+
raise exc.CompileError("Excel dialect does not support CTEs")
|
|
146
|
+
|
|
147
|
+
def visit_subquery(self, subquery: Any, **kw: Any) -> str:
|
|
148
|
+
raise exc.CompileError("Excel dialect does not support subqueries")
|
|
149
|
+
|
|
150
|
+
def returning_clause(
|
|
151
|
+
self,
|
|
152
|
+
stmt: Any,
|
|
153
|
+
returning_cols: Any,
|
|
154
|
+
**kw: Any,
|
|
155
|
+
) -> str:
|
|
156
|
+
raise exc.CompileError("Excel dialect does not support RETURNING")
|
|
157
|
+
|
|
158
|
+
def for_update_clause(self, select: Any, **kw: Any) -> str:
|
|
159
|
+
raise exc.CompileError("Excel dialect does not support SELECT ... FOR UPDATE")
|
sqlalchemy_excel/ddl.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""DDL compiler for the Excel dialect.
|
|
2
|
+
|
|
3
|
+
CREATE TABLE → creates a worksheet and writes column metadata.
|
|
4
|
+
DROP TABLE → deletes the worksheet and removes metadata.
|
|
5
|
+
ALTER TABLE → rejected (not supported).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import exc
|
|
13
|
+
from sqlalchemy.sql import compiler
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExcelDDLCompiler(compiler.DDLCompiler):
|
|
17
|
+
"""Compiles DDL statements for excel-dbapi."""
|
|
18
|
+
|
|
19
|
+
def visit_create_table(self, create: Any, **kw: Any) -> str:
|
|
20
|
+
"""Compile CREATE TABLE into SQL that excel-dbapi's parser accepts.
|
|
21
|
+
|
|
22
|
+
Format: CREATE TABLE name (col1 TYPE, col2 TYPE, ...)
|
|
23
|
+
"""
|
|
24
|
+
table = create.element
|
|
25
|
+
table_name = self.preparer.format_table(table)
|
|
26
|
+
|
|
27
|
+
columns = []
|
|
28
|
+
for col in table.columns:
|
|
29
|
+
col_name = self.preparer.format_column(col)
|
|
30
|
+
col_type = self.dialect.type_compiler.process(col.type)
|
|
31
|
+
columns.append(f"{col_name} {col_type}")
|
|
32
|
+
|
|
33
|
+
return f"CREATE TABLE {table_name} ({', '.join(columns)})"
|
|
34
|
+
|
|
35
|
+
def visit_drop_table(self, drop: Any, **kw: Any) -> str:
|
|
36
|
+
"""Compile DROP TABLE."""
|
|
37
|
+
table = drop.element
|
|
38
|
+
table_name = self.preparer.format_table(table)
|
|
39
|
+
return f"DROP TABLE {table_name}"
|
|
40
|
+
|
|
41
|
+
def visit_create_index(self, create: Any, **kw: Any) -> str:
|
|
42
|
+
raise exc.CompileError("Excel dialect does not support CREATE INDEX")
|
|
43
|
+
|
|
44
|
+
def visit_drop_index(self, drop: Any, **kw: Any) -> str:
|
|
45
|
+
raise exc.CompileError("Excel dialect does not support DROP INDEX")
|
|
46
|
+
|
|
47
|
+
def visit_add_constraint(self, create: Any, **kw: Any) -> str:
|
|
48
|
+
raise exc.CompileError("Excel dialect does not support constraints")
|
|
49
|
+
|
|
50
|
+
def visit_drop_constraint(self, drop: Any, **kw: Any) -> str:
|
|
51
|
+
raise exc.CompileError("Excel dialect does not support constraints")
|
|
52
|
+
|
|
53
|
+
def visit_create_sequence(self, create: Any, **kw: Any) -> str:
|
|
54
|
+
raise exc.CompileError("Excel dialect does not support sequences")
|
|
55
|
+
|
|
56
|
+
def visit_drop_sequence(self, drop: Any, **kw: Any) -> str:
|
|
57
|
+
raise exc.CompileError("Excel dialect does not support sequences")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""ExcelDialect — SQLAlchemy dialect that uses excel-dbapi as the DB-API driver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import event, pool
|
|
9
|
+
from sqlalchemy.engine import default
|
|
10
|
+
from sqlalchemy.schema import Table
|
|
11
|
+
|
|
12
|
+
from .compiler import ExcelCompiler, ExcelIdentifierPreparer
|
|
13
|
+
from .ddl import ExcelDDLCompiler
|
|
14
|
+
from .reflection import ExcelInspectionMixin
|
|
15
|
+
from .types import ExcelTypeCompiler
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from sqlalchemy.engine import URL
|
|
19
|
+
from sqlalchemy.engine.interfaces import ConnectArgsType
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _after_create(
|
|
23
|
+
target: Table,
|
|
24
|
+
connection: Any,
|
|
25
|
+
**kw: Any,
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Write column metadata after CREATE TABLE (Excel dialect only)."""
|
|
28
|
+
if connection.dialect.name != "excel":
|
|
29
|
+
return
|
|
30
|
+
import excel_dbapi
|
|
31
|
+
|
|
32
|
+
raw_conn = connection.connection.dbapi_connection
|
|
33
|
+
pk_cols = {col.name for col in target.primary_key.columns}
|
|
34
|
+
|
|
35
|
+
columns = []
|
|
36
|
+
for col in target.columns:
|
|
37
|
+
type_compiler = connection.dialect.type_compiler
|
|
38
|
+
type_name = type_compiler.process(col.type)
|
|
39
|
+
columns.append(
|
|
40
|
+
{
|
|
41
|
+
"name": col.name,
|
|
42
|
+
"type_name": type_name,
|
|
43
|
+
"nullable": col.nullable if col.nullable is not None else True,
|
|
44
|
+
"primary_key": col.name in pk_cols,
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
excel_dbapi.write_table_metadata(raw_conn, target.name, columns)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _after_drop(
|
|
52
|
+
target: Table,
|
|
53
|
+
connection: Any,
|
|
54
|
+
**kw: Any,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Remove column metadata after DROP TABLE (Excel dialect only)."""
|
|
57
|
+
if connection.dialect.name != "excel":
|
|
58
|
+
return
|
|
59
|
+
import excel_dbapi
|
|
60
|
+
|
|
61
|
+
raw_conn = connection.connection.dbapi_connection
|
|
62
|
+
excel_dbapi.remove_table_metadata(raw_conn, target.name)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Register DDL events globally for all Table objects
|
|
66
|
+
event.listen(Table, "after_create", _after_create)
|
|
67
|
+
event.listen(Table, "after_drop", _after_drop)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ExcelDialect(ExcelInspectionMixin, default.DefaultDialect): # type: ignore[misc]
|
|
71
|
+
"""SQLAlchemy dialect for Excel files via excel-dbapi.
|
|
72
|
+
|
|
73
|
+
Connection URLs::
|
|
74
|
+
|
|
75
|
+
# Relative path
|
|
76
|
+
excel:///data.xlsx
|
|
77
|
+
|
|
78
|
+
# Absolute path
|
|
79
|
+
excel:////home/user/data.xlsx
|
|
80
|
+
excel:///C:/Users/data.xlsx (Windows)
|
|
81
|
+
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
name: ClassVar[str] = "excel"
|
|
85
|
+
driver: ClassVar[str] = "dbapi"
|
|
86
|
+
default_paramstyle: ClassVar[str] = "qmark"
|
|
87
|
+
|
|
88
|
+
# ── Feature flags ──────────────────────────────────────
|
|
89
|
+
supports_alter: ClassVar[bool] = False
|
|
90
|
+
supports_sequences: ClassVar[bool] = False
|
|
91
|
+
supports_schemas: ClassVar[bool] = False
|
|
92
|
+
supports_views: ClassVar[bool] = False
|
|
93
|
+
supports_native_boolean: ClassVar[bool] = True
|
|
94
|
+
supports_native_decimal: ClassVar[bool] = False
|
|
95
|
+
supports_statement_cache: ClassVar[bool] = False
|
|
96
|
+
supports_default_values: ClassVar[bool] = False
|
|
97
|
+
supports_default_metavalue: ClassVar[bool] = False
|
|
98
|
+
supports_empty_insert: ClassVar[bool] = False
|
|
99
|
+
supports_multivalues_insert: ClassVar[bool] = False
|
|
100
|
+
postfetch_lastrowid: ClassVar[bool] = False
|
|
101
|
+
insertmanyvalues_implicit_sentinel: ClassVar[Any] = None
|
|
102
|
+
|
|
103
|
+
# ── Compiler classes ──────────────────────────────────
|
|
104
|
+
statement_compiler = ExcelCompiler
|
|
105
|
+
ddl_compiler = ExcelDDLCompiler
|
|
106
|
+
type_compiler_cls = ExcelTypeCompiler
|
|
107
|
+
preparer = ExcelIdentifierPreparer
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def import_dbapi(cls) -> Any:
|
|
111
|
+
import excel_dbapi
|
|
112
|
+
|
|
113
|
+
return excel_dbapi
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def get_pool_class(cls, url: URL) -> type[pool.Pool]:
|
|
117
|
+
return pool.StaticPool
|
|
118
|
+
|
|
119
|
+
def create_connect_args(self, url: URL) -> ConnectArgsType:
|
|
120
|
+
"""Translate a SQLAlchemy URL to excel-dbapi connect() arguments.
|
|
121
|
+
|
|
122
|
+
URL formats:
|
|
123
|
+
excel:///relative/path.xlsx → file_path="relative/path.xlsx"
|
|
124
|
+
excel:////absolute/path.xlsx → file_path="/absolute/path.xlsx"
|
|
125
|
+
"""
|
|
126
|
+
# url.database contains the path after the third slash
|
|
127
|
+
file_path = url.database
|
|
128
|
+
if not file_path:
|
|
129
|
+
raise ValueError("No file path in URL. Use excel:///path/to/file.xlsx")
|
|
130
|
+
|
|
131
|
+
kwargs: dict[str, Any] = {
|
|
132
|
+
"file_path": file_path,
|
|
133
|
+
"engine": "openpyxl",
|
|
134
|
+
"autocommit": False,
|
|
135
|
+
"create": True,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Forward query parameters
|
|
139
|
+
query = dict(url.query)
|
|
140
|
+
if "engine" in query:
|
|
141
|
+
kwargs["engine"] = query.pop("engine")
|
|
142
|
+
if "autocommit" in query:
|
|
143
|
+
kwargs["autocommit"] = query.pop("autocommit").lower() in (
|
|
144
|
+
"true",
|
|
145
|
+
"1",
|
|
146
|
+
"yes",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return ([], kwargs)
|
|
150
|
+
|
|
151
|
+
def on_connect(self) -> None:
|
|
152
|
+
"""No-op: no special connection initialization needed."""
|
|
153
|
+
|
|
154
|
+
def do_execute(
|
|
155
|
+
self,
|
|
156
|
+
cursor: Any,
|
|
157
|
+
statement: str,
|
|
158
|
+
parameters: Any,
|
|
159
|
+
context: Any = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Execute a statement, normalizing whitespace for excel-dbapi."""
|
|
162
|
+
normalized = re.sub(r"\s+", " ", statement).strip()
|
|
163
|
+
cursor.execute(normalized, parameters)
|
|
164
|
+
|
|
165
|
+
def do_execute_no_params(
|
|
166
|
+
self,
|
|
167
|
+
cursor: Any,
|
|
168
|
+
statement: str,
|
|
169
|
+
context: Any = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Execute a statement with no parameters."""
|
|
172
|
+
normalized = re.sub(r"\s+", " ", statement).strip()
|
|
173
|
+
cursor.execute(normalized, None)
|
|
174
|
+
|
|
175
|
+
def do_ping(self, dbapi_connection: Any) -> bool:
|
|
176
|
+
"""Ping the connection by verifying it's not closed."""
|
|
177
|
+
return not getattr(dbapi_connection, "closed", True)
|
|
178
|
+
|
|
179
|
+
def is_disconnect(self, e: Exception, connection: Any, cursor: Any) -> bool:
|
|
180
|
+
"""Excel connections don't have network-level disconnects."""
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
def get_default_isolation_level(self, dbapi_conn: Any) -> str:
|
|
184
|
+
return "SERIALIZABLE"
|
|
185
|
+
|
|
186
|
+
def _check_unicode_returns(
|
|
187
|
+
self, connection: Any, additional_tests: Any = None
|
|
188
|
+
) -> bool:
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
def _check_unicode_description(self, connection: Any) -> bool:
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def has_table(
|
|
195
|
+
self,
|
|
196
|
+
connection: Any,
|
|
197
|
+
table_name: str,
|
|
198
|
+
schema: str | None = None,
|
|
199
|
+
**kw: Any,
|
|
200
|
+
) -> bool:
|
|
201
|
+
"""Check if a worksheet (table) exists."""
|
|
202
|
+
import excel_dbapi
|
|
203
|
+
|
|
204
|
+
raw_conn = connection.connection.dbapi_connection
|
|
205
|
+
return excel_dbapi.has_table(raw_conn, table_name)
|
|
206
|
+
|
|
207
|
+
def do_begin(self, dbapi_connection: Any) -> None:
|
|
208
|
+
"""No-op: excel-dbapi doesn't have explicit BEGIN."""
|
|
209
|
+
|
|
210
|
+
def do_commit(self, dbapi_connection: Any) -> None:
|
|
211
|
+
"""Commit (save) the workbook."""
|
|
212
|
+
dbapi_connection.commit()
|
|
213
|
+
|
|
214
|
+
def do_rollback(self, dbapi_connection: Any) -> None:
|
|
215
|
+
"""Rollback is not supported with autocommit=False in a meaningful way
|
|
216
|
+
for Excel files. We silently ignore it to avoid crashes during
|
|
217
|
+
connection pool cleanup."""
|
|
218
|
+
|
|
219
|
+
def do_close(self, dbapi_connection: Any) -> None:
|
|
220
|
+
"""Close the underlying excel-dbapi connection."""
|
|
221
|
+
dbapi_connection.close()
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Reflection (introspection) support for the Excel dialect.
|
|
2
|
+
|
|
3
|
+
Provides the Inspector integration so that SQLAlchemy can discover
|
|
4
|
+
tables (worksheets), columns, and primary keys from an Excel file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import types as sa_types
|
|
12
|
+
|
|
13
|
+
_TYPE_MAP: dict[str, type[sa_types.TypeEngine]] = {
|
|
14
|
+
"TEXT": sa_types.String,
|
|
15
|
+
"INTEGER": sa_types.Integer,
|
|
16
|
+
"FLOAT": sa_types.Float,
|
|
17
|
+
"BOOLEAN": sa_types.Boolean,
|
|
18
|
+
"DATE": sa_types.Date,
|
|
19
|
+
"DATETIME": sa_types.DateTime,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _sa_type_from_name(type_name: str) -> sa_types.TypeEngine:
|
|
24
|
+
"""Convert an excel-dbapi type name to a SQLAlchemy type instance."""
|
|
25
|
+
cls = _TYPE_MAP.get(type_name.upper(), sa_types.String)
|
|
26
|
+
return cls()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ExcelInspectionMixin:
|
|
30
|
+
"""Mixin that provides reflection methods for ExcelDialect.
|
|
31
|
+
|
|
32
|
+
These methods are called by SQLAlchemy's Inspector to discover
|
|
33
|
+
the structure of an Excel workbook.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def get_table_names(
|
|
37
|
+
self,
|
|
38
|
+
connection: Any,
|
|
39
|
+
schema: str | None = None,
|
|
40
|
+
**kw: Any,
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
"""Return all worksheet names, excluding the metadata sheet."""
|
|
43
|
+
import excel_dbapi
|
|
44
|
+
|
|
45
|
+
raw_conn = connection.connection.dbapi_connection
|
|
46
|
+
return excel_dbapi.list_tables(raw_conn, include_meta=False)
|
|
47
|
+
|
|
48
|
+
def get_view_names(
|
|
49
|
+
self,
|
|
50
|
+
connection: Any,
|
|
51
|
+
schema: str | None = None,
|
|
52
|
+
**kw: Any,
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""Excel does not support views."""
|
|
55
|
+
return []
|
|
56
|
+
|
|
57
|
+
def get_columns(
|
|
58
|
+
self,
|
|
59
|
+
connection: Any,
|
|
60
|
+
table_name: str,
|
|
61
|
+
schema: str | None = None,
|
|
62
|
+
**kw: Any,
|
|
63
|
+
) -> list[dict[str, Any]]:
|
|
64
|
+
"""Return column information for the given table (worksheet).
|
|
65
|
+
|
|
66
|
+
First tries to read from the metadata sheet (written by CREATE TABLE).
|
|
67
|
+
Falls back to type inference from data sampling.
|
|
68
|
+
"""
|
|
69
|
+
import excel_dbapi
|
|
70
|
+
|
|
71
|
+
raw_conn = connection.connection.dbapi_connection
|
|
72
|
+
|
|
73
|
+
# Try metadata sheet first
|
|
74
|
+
meta = excel_dbapi.read_table_metadata(raw_conn, table_name)
|
|
75
|
+
if meta is not None:
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
"name": col["name"],
|
|
79
|
+
"type": _sa_type_from_name(col["type_name"]),
|
|
80
|
+
"nullable": col.get("nullable", True),
|
|
81
|
+
"default": None,
|
|
82
|
+
"autoincrement": False,
|
|
83
|
+
"comment": None,
|
|
84
|
+
}
|
|
85
|
+
for col in meta
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# Fallback: infer from data
|
|
89
|
+
inferred = excel_dbapi.get_columns(raw_conn, table_name)
|
|
90
|
+
return [
|
|
91
|
+
{
|
|
92
|
+
"name": col["name"],
|
|
93
|
+
"type": _sa_type_from_name(col["type"]),
|
|
94
|
+
"nullable": col.get("nullable", True),
|
|
95
|
+
"default": None,
|
|
96
|
+
"autoincrement": False,
|
|
97
|
+
"comment": None,
|
|
98
|
+
}
|
|
99
|
+
for col in inferred
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def get_pk_constraint(
|
|
103
|
+
self,
|
|
104
|
+
connection: Any,
|
|
105
|
+
table_name: str,
|
|
106
|
+
schema: str | None = None,
|
|
107
|
+
**kw: Any,
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Return primary key constraint info from the metadata sheet."""
|
|
110
|
+
import excel_dbapi
|
|
111
|
+
|
|
112
|
+
raw_conn = connection.connection.dbapi_connection
|
|
113
|
+
meta = excel_dbapi.read_table_metadata(raw_conn, table_name)
|
|
114
|
+
if meta is not None:
|
|
115
|
+
pk_cols = [col["name"] for col in meta if col.get("primary_key", False)]
|
|
116
|
+
if pk_cols:
|
|
117
|
+
return {"constrained_columns": pk_cols, "name": None}
|
|
118
|
+
|
|
119
|
+
return {"constrained_columns": [], "name": None}
|
|
120
|
+
|
|
121
|
+
def get_foreign_keys(
|
|
122
|
+
self,
|
|
123
|
+
connection: Any,
|
|
124
|
+
table_name: str,
|
|
125
|
+
schema: str | None = None,
|
|
126
|
+
**kw: Any,
|
|
127
|
+
) -> list[dict[str, Any]]:
|
|
128
|
+
"""Excel does not support foreign keys."""
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
def get_indexes(
|
|
132
|
+
self,
|
|
133
|
+
connection: Any,
|
|
134
|
+
table_name: str,
|
|
135
|
+
schema: str | None = None,
|
|
136
|
+
**kw: Any,
|
|
137
|
+
) -> list[dict[str, Any]]:
|
|
138
|
+
"""Excel does not support indexes."""
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
def get_unique_constraints(
|
|
142
|
+
self,
|
|
143
|
+
connection: Any,
|
|
144
|
+
table_name: str,
|
|
145
|
+
schema: str | None = None,
|
|
146
|
+
**kw: Any,
|
|
147
|
+
) -> list[dict[str, Any]]:
|
|
148
|
+
"""Excel does not support unique constraints."""
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
def get_check_constraints(
|
|
152
|
+
self,
|
|
153
|
+
connection: Any,
|
|
154
|
+
table_name: str,
|
|
155
|
+
schema: str | None = None,
|
|
156
|
+
**kw: Any,
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
"""Excel does not support check constraints."""
|
|
159
|
+
return []
|
|
160
|
+
|
|
161
|
+
def get_table_comment(
|
|
162
|
+
self,
|
|
163
|
+
connection: Any,
|
|
164
|
+
table_name: str,
|
|
165
|
+
schema: str | None = None,
|
|
166
|
+
**kw: Any,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
"""Excel does not support table comments."""
|
|
169
|
+
return {"text": None}
|
|
170
|
+
|
|
171
|
+
def get_schema_names(self, connection: Any, **kw: Any) -> list[str]:
|
|
172
|
+
"""Excel does not support schemas."""
|
|
173
|
+
return []
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Type compiler for the Excel dialect.
|
|
2
|
+
|
|
3
|
+
Maps SQLAlchemy types to the type names that excel-dbapi understands:
|
|
4
|
+
TEXT, INTEGER, FLOAT, BOOLEAN, DATE, DATETIME
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import exc
|
|
12
|
+
from sqlalchemy.sql import compiler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ExcelTypeCompiler(compiler.GenericTypeCompiler):
|
|
16
|
+
"""Maps SQLAlchemy column types to Excel-compatible type strings."""
|
|
17
|
+
|
|
18
|
+
def visit_STRING(self, type_: Any, **kw: Any) -> str:
|
|
19
|
+
return "TEXT"
|
|
20
|
+
|
|
21
|
+
def visit_TEXT(self, type_: Any, **kw: Any) -> str:
|
|
22
|
+
return "TEXT"
|
|
23
|
+
|
|
24
|
+
def visit_NVARCHAR(self, type_: Any, **kw: Any) -> str:
|
|
25
|
+
return "TEXT"
|
|
26
|
+
|
|
27
|
+
def visit_VARCHAR(self, type_: Any, **kw: Any) -> str:
|
|
28
|
+
return "TEXT"
|
|
29
|
+
|
|
30
|
+
def visit_CHAR(self, type_: Any, **kw: Any) -> str:
|
|
31
|
+
return "TEXT"
|
|
32
|
+
|
|
33
|
+
def visit_NCHAR(self, type_: Any, **kw: Any) -> str:
|
|
34
|
+
return "TEXT"
|
|
35
|
+
|
|
36
|
+
def visit_CLOB(self, type_: Any, **kw: Any) -> str:
|
|
37
|
+
return "TEXT"
|
|
38
|
+
|
|
39
|
+
def visit_INTEGER(self, type_: Any, **kw: Any) -> str:
|
|
40
|
+
return "INTEGER"
|
|
41
|
+
|
|
42
|
+
def visit_SMALLINT(self, type_: Any, **kw: Any) -> str:
|
|
43
|
+
return "INTEGER"
|
|
44
|
+
|
|
45
|
+
def visit_BIGINT(self, type_: Any, **kw: Any) -> str:
|
|
46
|
+
return "INTEGER"
|
|
47
|
+
|
|
48
|
+
def visit_FLOAT(self, type_: Any, **kw: Any) -> str:
|
|
49
|
+
return "FLOAT"
|
|
50
|
+
|
|
51
|
+
def visit_REAL(self, type_: Any, **kw: Any) -> str:
|
|
52
|
+
return "FLOAT"
|
|
53
|
+
|
|
54
|
+
def visit_DOUBLE(self, type_: Any, **kw: Any) -> str:
|
|
55
|
+
return "FLOAT"
|
|
56
|
+
|
|
57
|
+
def visit_DOUBLE_PRECISION(self, type_: Any, **kw: Any) -> str:
|
|
58
|
+
return "FLOAT"
|
|
59
|
+
|
|
60
|
+
def visit_NUMERIC(self, type_: Any, **kw: Any) -> str:
|
|
61
|
+
return "FLOAT"
|
|
62
|
+
|
|
63
|
+
def visit_DECIMAL(self, type_: Any, **kw: Any) -> str:
|
|
64
|
+
return "FLOAT"
|
|
65
|
+
|
|
66
|
+
def visit_BOOLEAN(self, type_: Any, **kw: Any) -> str:
|
|
67
|
+
return "BOOLEAN"
|
|
68
|
+
|
|
69
|
+
def visit_DATE(self, type_: Any, **kw: Any) -> str:
|
|
70
|
+
return "DATE"
|
|
71
|
+
|
|
72
|
+
def visit_DATETIME(self, type_: Any, **kw: Any) -> str:
|
|
73
|
+
return "DATETIME"
|
|
74
|
+
|
|
75
|
+
def visit_TIMESTAMP(self, type_: Any, **kw: Any) -> str:
|
|
76
|
+
return "DATETIME"
|
|
77
|
+
|
|
78
|
+
def visit_TIME(self, type_: Any, **kw: Any) -> str:
|
|
79
|
+
return "TEXT"
|
|
80
|
+
|
|
81
|
+
def visit_BLOB(self, type_: Any, **kw: Any) -> str:
|
|
82
|
+
raise exc.CompileError("Excel dialect does not support BLOB type")
|
|
83
|
+
|
|
84
|
+
def visit_BINARY(self, type_: Any, **kw: Any) -> str:
|
|
85
|
+
raise exc.CompileError("Excel dialect does not support BINARY type")
|
|
86
|
+
|
|
87
|
+
def visit_VARBINARY(self, type_: Any, **kw: Any) -> str:
|
|
88
|
+
raise exc.CompileError("Excel dialect does not support VARBINARY type")
|
|
89
|
+
|
|
90
|
+
def visit_JSON(self, type_: Any, **kw: Any) -> str:
|
|
91
|
+
raise exc.CompileError("Excel dialect does not support JSON type")
|
|
92
|
+
|
|
93
|
+
def visit_ARRAY(self, type_: Any, **kw: Any) -> str:
|
|
94
|
+
raise exc.CompileError("Excel dialect does not support ARRAY type")
|
|
95
|
+
|
|
96
|
+
def visit_large_binary(self, type_: Any, **kw: Any) -> str:
|
|
97
|
+
raise exc.CompileError("Excel dialect does not support LargeBinary type")
|
|
98
|
+
|
|
99
|
+
def visit_uuid(self, type_: Any, **kw: Any) -> str:
|
|
100
|
+
return "TEXT"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-excel
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: SQLAlchemy dialect for Excel files — use Excel as a database
|
|
5
|
+
Project-URL: Homepage, https://github.com/yeongseon/sqlalchemy-excel
|
|
6
|
+
Project-URL: Repository, https://github.com/yeongseon/sqlalchemy-excel
|
|
7
|
+
Project-URL: Issues, https://github.com/yeongseon/sqlalchemy-excel/issues
|
|
8
|
+
Author: Yeongseon Choe
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: database,dialect,excel,openpyxl,sqlalchemy
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Database
|
|
21
|
+
Classifier: Topic :: Office/Business :: Financial :: Spreadsheet
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: excel-dbapi>=0.1.1
|
|
25
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# sqlalchemy-excel
|
|
34
|
+
|
|
35
|
+
SQLAlchemy dialect for Excel files — use Excel as a database.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from sqlalchemy import create_engine, Column, Integer, String
|
|
39
|
+
from sqlalchemy.orm import DeclarativeBase, Session, Mapped, mapped_column
|
|
40
|
+
|
|
41
|
+
engine = create_engine("excel:///data.xlsx")
|
|
42
|
+
|
|
43
|
+
class Base(DeclarativeBase):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
class User(Base):
|
|
47
|
+
__tablename__ = "Sheet1"
|
|
48
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
49
|
+
name: Mapped[str] = mapped_column()
|
|
50
|
+
|
|
51
|
+
Base.metadata.create_all(engine)
|
|
52
|
+
|
|
53
|
+
with Session(engine) as session:
|
|
54
|
+
session.add(User(id=1, name="Alice"))
|
|
55
|
+
session.commit()
|
|
56
|
+
|
|
57
|
+
with Session(engine) as session:
|
|
58
|
+
users = session.query(User).all()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Installation
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install sqlalchemy-excel
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## URL Format
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
# Relative path
|
|
71
|
+
engine = create_engine("excel:///data.xlsx")
|
|
72
|
+
|
|
73
|
+
# Absolute path (note four slashes)
|
|
74
|
+
engine = create_engine("excel:////home/user/data.xlsx")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Features
|
|
78
|
+
|
|
79
|
+
- Full SQLAlchemy 2.0 dialect
|
|
80
|
+
- PEP 249 DB-API 2.0 compliant driver ([excel-dbapi](https://github.com/yeongseon/excel-dbapi))
|
|
81
|
+
- SELECT with WHERE, ORDER BY, LIMIT
|
|
82
|
+
- INSERT, UPDATE, DELETE
|
|
83
|
+
- CREATE TABLE / DROP TABLE
|
|
84
|
+
- ORM support with `DeclarativeBase`
|
|
85
|
+
- Type mapping: String, Integer, Float, Boolean, Date, DateTime
|
|
86
|
+
|
|
87
|
+
## Limitations
|
|
88
|
+
|
|
89
|
+
- No JOIN, GROUP BY, HAVING, DISTINCT, OFFSET
|
|
90
|
+
- No subqueries, CTEs, or aggregate functions
|
|
91
|
+
- No ALTER TABLE, foreign keys, or indexes
|
|
92
|
+
- Single-table operations only
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
sqlalchemy_excel/__init__.py,sha256=EmKZ04CUp9__0xiVTtpvNfAsAnYwuDsmgrSqDn9xBVg,211
|
|
2
|
+
sqlalchemy_excel/compiler.py,sha256=UIb3C-m6iEDMxiuNvjzoKGJc_x7dBnFar4pD04FpciY,5511
|
|
3
|
+
sqlalchemy_excel/ddl.py,sha256=7t-huNzOvCtsYL71DWvC3bnT_ufOKgq_U6HsL-wTFWA,2117
|
|
4
|
+
sqlalchemy_excel/dialect.py,sha256=F5u0Eqi4XFXAX-d-OfaznSIIgwjKM3zUdoFe_crWBRs,7174
|
|
5
|
+
sqlalchemy_excel/reflection.py,sha256=JO9qS7uzwXabtScRB9XObLxs6X8K1rCCNmGWBl2GdVI,5026
|
|
6
|
+
sqlalchemy_excel/types.py,sha256=FnnhRuxKhBamQxmvyZFuiYzE-gQd_1LGkIDfpnL0Yko,3058
|
|
7
|
+
sqlalchemy_excel-0.1.1.dist-info/METADATA,sha256=UrMik9qyZJzQygxz3IPAr2xWc9wBHfR_JCCAMJrcxUw,2734
|
|
8
|
+
sqlalchemy_excel-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
sqlalchemy_excel-0.1.1.dist-info/entry_points.txt,sha256=Bl0Hy4J_V012f3oe-WPevI-KE5rVyaqMi_zYBiCIjkE,68
|
|
10
|
+
sqlalchemy_excel-0.1.1.dist-info/licenses/LICENSE,sha256=gBTzk1NFKuyZKqa5-8leVY816k7POZUMpw2_PKl2MsY,1071
|
|
11
|
+
sqlalchemy_excel-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Yeongseon Choe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|