sqlalchemy-seerdb 0.2.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Peter Lemenkov <lemenkov@gmail.com>
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.
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlalchemy-seerdb
3
+ Version: 0.2.0
4
+ Summary: SQLAlchemy dialect for the seerdb driver
5
+ Author-email: Peter Lemenkov <lemenkov@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/seerdb/sqlalchemy-seerdb
8
+ Project-URL: Source, https://github.com/seerdb/sqlalchemy-seerdb
9
+ Keywords: sqlalchemy,dialect,database,dbapi
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Database
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSES/MIT.txt
17
+ Requires-Dist: SQLAlchemy>=2.0
18
+ Requires-Dist: seerdb>=2.5.0
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ <!--
24
+ SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
25
+ SPDX-License-Identifier: MIT
26
+ -->
27
+
28
+ # sqlalchemy-seerdb
29
+
30
+ A SQLAlchemy dialect for the [seerdb](https://github.com/seerdb/seerdb) driver.
31
+
32
+ ```python
33
+ import sqlalchemy as sa
34
+
35
+ engine = sa.create_engine('oracle+seerdb://user:password@host:1521/?service_name=XE')
36
+ ```
37
+
38
+ ## Why this exists
39
+
40
+ The SQL is nothing new — this inherits SQLAlchemy's built-in compiler, DDL and
41
+ reflection wholesale and supplies only what is specific to this DBAPI. What it
42
+ adds is **reach**.
43
+
44
+ seerdb speaks the wire protocol itself, in pure Python, with no vendor client
45
+ libraries. The alternatives connect directly only to newer servers and fall back
46
+ to loading vendor client libraries for anything older. So this dialect covers a
47
+ range that otherwise needs a native client installed:
48
+
49
+ | Server | This dialect | Direct connection elsewhere |
50
+ |--------|--------------|-----------------------------|
51
+ | 8i, 9i, 10g, 11g | yes | no, needs vendor client libraries |
52
+ | 12.1 and later | yes | yes |
53
+
54
+ Verified against live servers: an 11g instance reports
55
+ `server_version_info == (11, 2, 0, 2, 0)` and a current one reports
56
+ `(23, 1, 162, 0, 0)`, both over the same dialect.
57
+
58
+ If you are on a modern server and can install a native client, the dialects that
59
+ ship with SQLAlchemy are the better-trodden path. This one is for the cases they
60
+ do not reach.
61
+
62
+ ## Status
63
+
64
+ Early. Connections, Core `select`, DDL, parameter binding and reflection all
65
+ work against live servers — `has_table`, `get_columns`, `get_pk_constraint` and
66
+ `autoload_with` round-trip.
67
+
68
+ The current target is SQLAlchemy's dialect compliance suite. Progress is tracked
69
+ under the [SQLAlchemy conformance](https://github.com/seerdb/sqlalchemy-seerdb/milestone/1)
70
+ milestone.
71
+
72
+ ## Running the tests
73
+
74
+ The suite is SQLAlchemy's dialect compliance suite, which upstream names as the
75
+ target for third-party dialects. It is entirely live-database driven — there is
76
+ no offline mode — so point it at a server:
77
+
78
+ ```bash
79
+ pytest --dburi "oracle+seerdb://user:password@host:1521/?service_name=XE"
80
+ ```
81
+
82
+ Run it from the repository root. `test.cfg` has to be found in the working
83
+ directory: SQLAlchemy's plugin reads it with configparser and does not look at
84
+ `pyproject.toml`.
85
+
86
+ ### One setup step, and it needs a DBA
87
+
88
+ The suite expects a second namespace called `test_schema`, which on this backend
89
+ is a **username**, and the test account must be able to create and drop tables
90
+ inside it. Skipping this does not fail a handful of tests — every test in
91
+ `ComponentReflectionTest` errors in setup, because they share a fixture that
92
+ builds tables there.
93
+
94
+ The test account cannot create it (`ORA-01031`), so run this as a DBA once:
95
+
96
+ ```sql
97
+ CREATE USER test_schema IDENTIFIED BY test_schema;
98
+ GRANT CREATE SESSION TO test_schema;
99
+ ALTER USER test_schema QUOTA UNLIMITED ON USERS;
100
+
101
+ -- so the test account can build and drop the fixtures inside that schema
102
+ GRANT CREATE ANY TABLE, DROP ANY TABLE, SELECT ANY TABLE, INSERT ANY TABLE,
103
+ UPDATE ANY TABLE, DELETE ANY TABLE, CREATE ANY INDEX, DROP ANY INDEX,
104
+ CREATE ANY VIEW, DROP ANY VIEW, CREATE ANY SEQUENCE, DROP ANY SEQUENCE,
105
+ COMMENT ANY TABLE, ANALYZE ANY TO <your test user>;
106
+ ```
107
+
108
+ Those `ANY` privileges are broad. They are fine on a throwaway test instance —
109
+ CI creates the namespace in a container it discards — but narrow them before
110
+ granting on anything long-lived.
111
+
112
+ ## Connect string
113
+
114
+ Everything after `?` is passed through to `seerdb.connect`, with integers and
115
+ booleans coerced:
116
+
117
+ ```
118
+ oracle+seerdb://user:password@host:1521/?service_name=XE
119
+ oracle+seerdb://user:password@host:1521/?sid=XE&timeout=5000
120
+ ```
121
+
122
+ ## Licence
123
+
124
+ MIT. This repository is [REUSE](https://reuse.software/) compliant.
@@ -0,0 +1,102 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
3
+ SPDX-License-Identifier: MIT
4
+ -->
5
+
6
+ # sqlalchemy-seerdb
7
+
8
+ A SQLAlchemy dialect for the [seerdb](https://github.com/seerdb/seerdb) driver.
9
+
10
+ ```python
11
+ import sqlalchemy as sa
12
+
13
+ engine = sa.create_engine('oracle+seerdb://user:password@host:1521/?service_name=XE')
14
+ ```
15
+
16
+ ## Why this exists
17
+
18
+ The SQL is nothing new — this inherits SQLAlchemy's built-in compiler, DDL and
19
+ reflection wholesale and supplies only what is specific to this DBAPI. What it
20
+ adds is **reach**.
21
+
22
+ seerdb speaks the wire protocol itself, in pure Python, with no vendor client
23
+ libraries. The alternatives connect directly only to newer servers and fall back
24
+ to loading vendor client libraries for anything older. So this dialect covers a
25
+ range that otherwise needs a native client installed:
26
+
27
+ | Server | This dialect | Direct connection elsewhere |
28
+ |--------|--------------|-----------------------------|
29
+ | 8i, 9i, 10g, 11g | yes | no, needs vendor client libraries |
30
+ | 12.1 and later | yes | yes |
31
+
32
+ Verified against live servers: an 11g instance reports
33
+ `server_version_info == (11, 2, 0, 2, 0)` and a current one reports
34
+ `(23, 1, 162, 0, 0)`, both over the same dialect.
35
+
36
+ If you are on a modern server and can install a native client, the dialects that
37
+ ship with SQLAlchemy are the better-trodden path. This one is for the cases they
38
+ do not reach.
39
+
40
+ ## Status
41
+
42
+ Early. Connections, Core `select`, DDL, parameter binding and reflection all
43
+ work against live servers — `has_table`, `get_columns`, `get_pk_constraint` and
44
+ `autoload_with` round-trip.
45
+
46
+ The current target is SQLAlchemy's dialect compliance suite. Progress is tracked
47
+ under the [SQLAlchemy conformance](https://github.com/seerdb/sqlalchemy-seerdb/milestone/1)
48
+ milestone.
49
+
50
+ ## Running the tests
51
+
52
+ The suite is SQLAlchemy's dialect compliance suite, which upstream names as the
53
+ target for third-party dialects. It is entirely live-database driven — there is
54
+ no offline mode — so point it at a server:
55
+
56
+ ```bash
57
+ pytest --dburi "oracle+seerdb://user:password@host:1521/?service_name=XE"
58
+ ```
59
+
60
+ Run it from the repository root. `test.cfg` has to be found in the working
61
+ directory: SQLAlchemy's plugin reads it with configparser and does not look at
62
+ `pyproject.toml`.
63
+
64
+ ### One setup step, and it needs a DBA
65
+
66
+ The suite expects a second namespace called `test_schema`, which on this backend
67
+ is a **username**, and the test account must be able to create and drop tables
68
+ inside it. Skipping this does not fail a handful of tests — every test in
69
+ `ComponentReflectionTest` errors in setup, because they share a fixture that
70
+ builds tables there.
71
+
72
+ The test account cannot create it (`ORA-01031`), so run this as a DBA once:
73
+
74
+ ```sql
75
+ CREATE USER test_schema IDENTIFIED BY test_schema;
76
+ GRANT CREATE SESSION TO test_schema;
77
+ ALTER USER test_schema QUOTA UNLIMITED ON USERS;
78
+
79
+ -- so the test account can build and drop the fixtures inside that schema
80
+ GRANT CREATE ANY TABLE, DROP ANY TABLE, SELECT ANY TABLE, INSERT ANY TABLE,
81
+ UPDATE ANY TABLE, DELETE ANY TABLE, CREATE ANY INDEX, DROP ANY INDEX,
82
+ CREATE ANY VIEW, DROP ANY VIEW, CREATE ANY SEQUENCE, DROP ANY SEQUENCE,
83
+ COMMENT ANY TABLE, ANALYZE ANY TO <your test user>;
84
+ ```
85
+
86
+ Those `ANY` privileges are broad. They are fine on a throwaway test instance —
87
+ CI creates the namespace in a container it discards — but narrow them before
88
+ granting on anything long-lived.
89
+
90
+ ## Connect string
91
+
92
+ Everything after `?` is passed through to `seerdb.connect`, with integers and
93
+ booleans coerced:
94
+
95
+ ```
96
+ oracle+seerdb://user:password@host:1521/?service_name=XE
97
+ oracle+seerdb://user:password@host:1521/?sid=XE&timeout=5000
98
+ ```
99
+
100
+ ## Licence
101
+
102
+ MIT. This repository is [REUSE](https://reuse.software/) compliant.
@@ -0,0 +1,50 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ [build-system]
5
+ requires = ["setuptools>=61"]
6
+ build-backend = "setuptools.build_meta"
7
+
8
+ [project]
9
+ name = "sqlalchemy-seerdb"
10
+ version = "0.2.0"
11
+ description = "SQLAlchemy dialect for the seerdb driver"
12
+ readme = "README.md"
13
+ license = "MIT"
14
+ license-files = ["LICENSES/MIT.txt"]
15
+ requires-python = ">=3.10"
16
+ authors = [{ name = "Peter Lemenkov", email = "lemenkov@gmail.com" }]
17
+ keywords = ["sqlalchemy", "dialect", "database", "dbapi"]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Database",
23
+ ]
24
+ dependencies = ["SQLAlchemy>=2.0", "seerdb>=2.5.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/seerdb/sqlalchemy-seerdb"
28
+ Source = "https://github.com/seerdb/sqlalchemy-seerdb"
29
+
30
+ # How SQLAlchemy finds the dialect: this is what makes the URL prefix
31
+ # `oracle+seerdb://` resolve to the class below.
32
+ [project.entry-points."sqlalchemy.dialects"]
33
+ "oracle.seerdb" = "sqlalchemy_seerdb.seerdb:SeerdbDialect"
34
+
35
+ [project.optional-dependencies]
36
+ test = ["pytest>=7"]
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["sqlalchemy_seerdb*"]
40
+
41
+ [tool.ruff]
42
+ line-length = 88
43
+
44
+ [tool.ruff.format]
45
+ quote-style = "single"
46
+
47
+ [tool.pytest.ini_options]
48
+ addopts = "--tb=short"
49
+ testpaths = ["test"]
50
+ python_files = ["test_*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """A SQLAlchemy dialect for the seerdb driver.
5
+
6
+ Registered through the ``sqlalchemy.dialects`` entry point, so a URL of
7
+ ``oracle+seerdb://user:password@host:port/?service_name=…`` selects it.
8
+
9
+ The SQL that goes over the wire is the same dialect SQLAlchemy already speaks,
10
+ so this package inherits the built-in compiler, DDL and reflection wholesale and
11
+ supplies only what is specific to this DBAPI. What it adds is *reach*: seerdb
12
+ speaks the wire protocol itself, so the dialect works against server versions
13
+ that the alternatives can only reach by loading vendor client libraries.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from sqlalchemy_seerdb.seerdb import SeerdbDialect
19
+
20
+ __all__ = ['SeerdbDialect', '__version__']
21
+
22
+ __version__ = '0.2.0'
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Provisioning hooks for SQLAlchemy's dialect compliance suite.
5
+
6
+ The suite asks the dialect how to do a handful of things it cannot express in
7
+ portable SQL — create a temporary table, reset a connection's default schema,
8
+ and so on. Each hook is looked up by the URL's *backend* name, which is
9
+ ``oracle`` here, and a hook with no registration raises `NotImplementedError`
10
+ before the test reaches the database. That is what made the whole
11
+ `ComponentReflectionTest` class error out in setup.
12
+
13
+ Only the hooks the suite actually asks for are registered. The ones SQLAlchemy
14
+ bundles for this backend are written around a different driver's connection
15
+ API and its own CI's throwaway-database model, neither of which applies: this
16
+ suite runs against an existing account, so there is nothing to create or reap.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from sqlalchemy.testing.provision import (
22
+ set_default_schema_on_connection,
23
+ temp_table_keyword_args,
24
+ )
25
+
26
+
27
+ @temp_table_keyword_args.for_db('oracle')
28
+ def _temp_table_keyword_args(cfg, eng):
29
+ """How this backend spells a temporary table.
30
+
31
+ Its temporary tables are *global*: the definition is permanent and shared,
32
+ only the rows are session-private. The suite wants rows to outlive the
33
+ transaction that inserted them, so the table has to preserve them on commit
34
+ rather than the default of deleting them.
35
+ """
36
+ return {
37
+ 'prefixes': ['GLOBAL TEMPORARY'],
38
+ 'oracle_on_commit': 'PRESERVE ROWS',
39
+ }
40
+
41
+
42
+ @set_default_schema_on_connection.for_db('oracle')
43
+ def _set_default_schema_on_connection(cfg, dbapi_connection, schema_name):
44
+ """Point a connection's unqualified name resolution at ``schema_name``."""
45
+ cursor = dbapi_connection.cursor()
46
+ cursor.execute(f'ALTER SESSION SET CURRENT_SCHEMA={schema_name}')
47
+ cursor.close()
@@ -0,0 +1,257 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Which parts of the compliance suite apply to this backend.
5
+
6
+ The suite assumes a capable backend and asks here about anything a particular
7
+ one may not do. Left at the defaults, features this backend simply does not have
8
+ are counted as **failures** rather than **skips**, which buries the real work
9
+ under noise.
10
+
11
+ Every entry below was checked against a live server rather than assumed, and
12
+ each says which it is: something the backend genuinely cannot do, or something
13
+ it can do that the suite is conservative about by default. Entries for our own
14
+ unfinished work do **not** belong here — a dialect gap is a bug to fix, and
15
+ silencing it here would hide it.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from sqlalchemy.testing import exclusions
21
+ from sqlalchemy.testing.requirements import SuiteRequirements
22
+
23
+
24
+ class Requirements(SuiteRequirements):
25
+ # --- things the backend genuinely does not have -----------------------
26
+
27
+ @property
28
+ def time(self):
29
+ """No standalone time-of-day column type.
30
+
31
+ `CREATE TABLE t (c TIME)` is rejected outright with "invalid datatype".
32
+ A time of day is stored inside a date or timestamp instead, so the
33
+ suite's TIME round-trips cannot apply.
34
+ """
35
+ return exclusions.closed()
36
+
37
+ @property
38
+ def time_microseconds(self):
39
+ """Follows from there being no time type at all."""
40
+ return exclusions.closed()
41
+
42
+ @property
43
+ def datetime_microseconds(self):
44
+ """The plain date/time type has one-second resolution.
45
+
46
+ Verified: a value carrying microseconds comes back with them zeroed,
47
+ because that type has no sub-second component. The timestamp type does
48
+ keep them — see below — so this is a property of the type the suite
49
+ exercises here, not of the backend as a whole.
50
+ """
51
+ return exclusions.closed()
52
+
53
+ @property
54
+ def symbol_names_w_double_quote(self):
55
+ """A double quote cannot appear in an identifier at all.
56
+
57
+ Not a quoting bug on our side — the generated DDL is correct, doubling
58
+ the inner quote the way SQL says to. The server rejects it anyway:
59
+
60
+ CREATE TABLE "quote "" two" (...)
61
+ ORA-25716: The identifier contains a double quotation mark (")
62
+
63
+ A single quote in an identifier is fine, so this is specifically about
64
+ the double quote.
65
+ """
66
+ return exclusions.closed()
67
+
68
+ @property
69
+ def expressions_against_unbounded_text(self):
70
+ """An unbounded text column cannot appear in a WHERE clause.
71
+
72
+ Verified: comparing one is refused outright.
73
+
74
+ select 1 from t where clob_column = 'x'
75
+ ORA-22848: cannot use CLOB type as comparison key
76
+
77
+ Such a column has to be converted first, which is a different query
78
+ from the one the suite writes, so these comparisons cannot apply.
79
+ """
80
+ return exclusions.closed()
81
+
82
+ @property
83
+ def empty_strings_varchar(self):
84
+ """An empty string is not a value here — it is NULL.
85
+
86
+ Verified: inserting `''` into a VARCHAR2 and reading it back gives NULL,
87
+ and `v IS NULL` is true. This is the backend's own rule about the empty
88
+ string, not a driver or dialect choice, so a test that round-trips one
89
+ cannot apply.
90
+ """
91
+ return exclusions.closed()
92
+
93
+ @property
94
+ def empty_strings_text(self):
95
+ """The same rule, verified separately on an unbounded text column."""
96
+ return exclusions.closed()
97
+
98
+ @property
99
+ def unbounded_varchar(self):
100
+ """A bounded character column must say how long it is.
101
+
102
+ CREATE TABLE t (one VARCHAR2)
103
+ ORA-00906: missing left parenthesis
104
+
105
+ An unbounded *text* column is a different type and does work — see the
106
+ CLOB round trips, which pass — so this is specifically about declaring a
107
+ varchar with no length.
108
+ """
109
+ return exclusions.closed()
110
+
111
+ @property
112
+ def parens_in_union_contained_select_wo_limit_offset(self):
113
+ """A parenthesised branch of a UNION cannot carry its own ORDER BY.
114
+
115
+ (SELECT id FROM t ORDER BY id) UNION (SELECT id FROM t ORDER BY id)
116
+ ORA-00907: missing right parenthesis
117
+
118
+ SQLAlchemy's own requirement documents this as failing on this backend:
119
+ without a LIMIT or OFFSET nothing wraps the branch in a subquery, and
120
+ the bare form is a syntax error here.
121
+ """
122
+ return exclusions.closed()
123
+
124
+ @property
125
+ def parens_in_union_contained_select_w_limit_offset(self):
126
+ """The same with a row limit, which is refused just as firmly.
127
+
128
+ (SELECT ... ORDER BY id FETCH FIRST 1 ROWS ONLY) UNION (...)
129
+ ORA-00900: invalid SQL statement
130
+ """
131
+ return exclusions.closed()
132
+
133
+ # --- things it can do that the suite is conservative about ------------
134
+
135
+ @property
136
+ def temp_table_names(self):
137
+ """Temporary tables can be listed by name.
138
+
139
+ Off by default in the suite. This backend's temporary tables are
140
+ *global*: the definition is a permanent, ordinary catalog entry — only
141
+ the rows are session-private — so listing them is no different from
142
+ listing any other table. Verified: a `CREATE GLOBAL TEMPORARY TABLE`
143
+ appears in `user_tables` with `TEMPORARY = 'Y'`, and
144
+ `Inspector.get_temp_table_names()` returns it, while `get_table_names()`
145
+ correctly leaves it out.
146
+
147
+ Saying so matters beyond the one test that asks directly. Left closed,
148
+ the suite still *creates* its temporary table — that is governed by the
149
+ separate `temp_table_reflection`, which is on — but then expects
150
+ reflection not to mention it. Reflection here does mention it, correctly,
151
+ and the disagreement was the single largest source of failures in the
152
+ suite: 72 of them, across ten reflection tests that have nothing to do
153
+ with temporary tables as such.
154
+ """
155
+ return exclusions.open()
156
+
157
+ @property
158
+ def has_temp_table(self):
159
+ """A single temporary table can be checked by name.
160
+
161
+ Follows from the above and verified the same way:
162
+ `Inspector.has_table()` on a global temporary table returns True.
163
+ """
164
+ return exclusions.open()
165
+
166
+ @property
167
+ def views(self):
168
+ """Views exist, so the suite may build its own and expect to see them.
169
+
170
+ Off by default. Verified: a view created here is listed by
171
+ `Inspector.get_view_names()` and its text comes back from
172
+ `get_view_definition()`. Left closed, the fixture never creates the
173
+ suite's views and `test_get_view_names` compares an empty list against
174
+ the three names it expected to find.
175
+ """
176
+ return exclusions.open()
177
+
178
+ @property
179
+ def unique_constraints_reflect_as_index(self):
180
+ """A UNIQUE constraint is backed by an index this backend creates for it,
181
+ and reflection reports that index.
182
+
183
+ Verified: `UniqueConstraint('u', name='v_parent_u_uq')` reflects through
184
+ `get_indexes()` as `{'name': 'v_parent_u_uq', 'unique': True, ...}`.
185
+ The suite's expected index sets include such entries only when this is
186
+ declared, so left at the default every reflected unique constraint was
187
+ an unexplained extra — the single largest group of remaining failures.
188
+
189
+ A FOREIGN KEY, by contrast, gets no index here, so
190
+ `foreign_keys_reflect_as_index` stays at its closed default. Also
191
+ verified rather than assumed.
192
+ """
193
+ return exclusions.open()
194
+
195
+ @property
196
+ def reflect_indexes_with_ascdesc_as_expression(self):
197
+ """A DESC column in an index reflects as an expression, not a column.
198
+
199
+ Verified: `Index('ix', text('q DESC'))` comes back as
200
+ `column_names: [None], expressions: ['"Q"'],
201
+ column_sorting: {'"Q"': ('desc',)}`. That is the shape this requirement
202
+ describes, and the shape the suite expects once it is declared.
203
+ """
204
+ return exclusions.open()
205
+
206
+ @property
207
+ def reflect_table_options(self):
208
+ """Table options can be reflected.
209
+
210
+ Verified: `get_table_options()` returns a dict, e.g.
211
+ `{'oracle_tablespace': 'USERS'}`. Off by default the suite expects a
212
+ `NotImplementedError` instead, and fails on "Callable did not raise".
213
+ """
214
+ return exclusions.open()
215
+
216
+ @property
217
+ def reflects_pk_names(self):
218
+ """A primary-key constraint's name survives reflection.
219
+
220
+ Verified: an unnamed key reflects as its system name (`sys_c0012860`) and
221
+ an explicitly named one keeps that name. Off by default the suite treats
222
+ the name check as expected-to-fail, and reports an "Unexpected success"
223
+ when it passes.
224
+ """
225
+ return exclusions.open()
226
+
227
+ @property
228
+ def timestamp_microseconds(self):
229
+ """The timestamp type keeps sub-second precision.
230
+
231
+ Off by default in the suite. Verified: a value with 123456 microseconds
232
+ round-trips intact through a `TIMESTAMP(6)` column.
233
+ """
234
+ return exclusions.open()
235
+
236
+ # ----- what needs a 12c server -----
237
+
238
+ @staticmethod
239
+ def _has_identity_columns(config):
240
+ # The Oracle dialect renders an autoincrement column as an identity
241
+ # column, which the server has from 12c. Before that the column is a
242
+ # plain NUMBER, an INSERT that leaves it out fails with ORA-01400, and
243
+ # there is no other way to say "generate the key" short of a sequence
244
+ # plus trigger, which the suite does not model.
245
+ return config.db.dialect.server_version_info >= (12,)
246
+
247
+ @property
248
+ def autoincrement_insert(self):
249
+ return exclusions.only_if(
250
+ self._has_identity_columns, 'autoincrement needs identity columns (12c+)'
251
+ )
252
+
253
+ @property
254
+ def autoincrement_without_sequence(self):
255
+ return exclusions.only_if(
256
+ self._has_identity_columns, 'autoincrement needs identity columns (12c+)'
257
+ )
@@ -0,0 +1,377 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ r"""The seerdb dialect.
5
+
6
+ Connect string::
7
+
8
+ oracle+seerdb://user:password@host:port/?service_name=ORCLPDB1
9
+ oracle+seerdb://user:password@host:port/?sid=XE
10
+
11
+ Query-string arguments are passed to :func:`seerdb.connect` after the ones the
12
+ URL already carries. Integers and booleans are coerced, so ``?timeout=5000`` and
13
+ ``?autocommit=true`` do the expected thing.
14
+
15
+ This subclasses the *generic* dialect rather than one of the bundled
16
+ DBAPI-specific ones: those carry type handlers written against a different
17
+ driver's extension API, none of which applies here. What is left to supply is
18
+ small, because the DBAPI is already conformant and its paramstyle already
19
+ matches what the generic dialect emits.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING, Any, ClassVar
25
+
26
+ import seerdb
27
+ from sqlalchemy import types as sqltypes
28
+ from sqlalchemy import util
29
+ from sqlalchemy.dialects.oracle import base as _oracle_base
30
+ from sqlalchemy.dialects.oracle.base import (
31
+ OracleCompiler,
32
+ OracleDialect,
33
+ OracleExecutionContext,
34
+ )
35
+ from sqlalchemy.dialects.oracle.types import _OracleDate
36
+ from sqlalchemy.engine import cursor as _cursor
37
+ from sqlalchemy.engine import interfaces
38
+
39
+ if TYPE_CHECKING:
40
+ from sqlalchemy.engine.url import URL
41
+
42
+ # Query-string arguments that are not strings on the driver's side.
43
+ _INT_ARGS = frozenset({'port', 'timeout', 'sdu', 'field_version', 'purity', 'fetch'})
44
+ _BOOL_ARGS = frozenset({'autocommit', 'ssl', 'prelim'})
45
+ _TRUTHY = frozenset({'1', 'true', 'yes', 'on'})
46
+
47
+
48
+ def _coerce(key: str, value: str) -> Any:
49
+ if key in _INT_ARGS:
50
+ return int(value)
51
+ if key in _BOOL_ARGS:
52
+ return value.strip().lower() in _TRUTHY
53
+ return value
54
+
55
+
56
+ class _SeerdbDate(_OracleDate):
57
+ """A `Date` column read back as a `date`, not a `datetime`.
58
+
59
+ This backend has no date-only type: a DATE always carries a time of day, so
60
+ the driver reads one back as a `datetime` — correctly, since that is what
61
+ the column holds. A column the schema declares as `Date` is asking for the
62
+ date part, so it is taken here.
63
+
64
+ The generic dialect leaves this to the driver, because the ones it ships
65
+ with are configured to do the narrowing themselves.
66
+ """
67
+
68
+ def result_processor(self, dialect, coltype):
69
+ def process(value):
70
+ return value.date() if value is not None else None
71
+
72
+ return process
73
+
74
+
75
+ class SeerdbExecutionContext(OracleExecutionContext):
76
+ """Turns this backend's RETURNING into the rows SQLAlchemy expects.
77
+
78
+ There is no result set for RETURNING here. The compiler already emits the
79
+ form the server wants — `RETURNING id INTO :ret_0` — and the values come
80
+ back on those binds afterwards, which is a shape SQLAlchemy has no idea
81
+ what to do with on its own: it asks the cursor for rows and finds none.
82
+
83
+ So the values are collected off the binds after execution and handed back
84
+ as a fully buffered result, which is what the caller was expecting all
85
+ along. `inserted_primary_key` rides on the same path.
86
+ """
87
+
88
+ out_parameters: dict | None = None
89
+
90
+ def _returning_var_type(self, index):
91
+ """The bind type for one returned column, from what the query says."""
92
+ columns = self.compiled._result_columns
93
+ if index < len(columns):
94
+ sqla_type = columns[index].type
95
+ try:
96
+ impl = sqla_type.dialect_impl(self.dialect)
97
+ dbapi_type = impl.get_dbapi_type(self.dialect.loaded_dbapi)
98
+ except (AttributeError, NotImplementedError):
99
+ dbapi_type = None
100
+ # Every numeric column maps to the same database type, so the
101
+ # receiver has to be bound with the Python type the query asked for
102
+ # or the value comes back as whichever one that database type
103
+ # decodes to. The driver honours the request (seerdb#688), so this
104
+ # is all it takes.
105
+ try:
106
+ if sqla_type.python_type is float:
107
+ return float
108
+ except (AttributeError, NotImplementedError):
109
+ pass
110
+ if dbapi_type is not None:
111
+ return dbapi_type
112
+ # A returned column whose type does not map cleanly still has to be
113
+ # bound as something; a string accepts whatever comes back.
114
+ return str
115
+
116
+ def pre_exec(self):
117
+ super().pre_exec()
118
+ compiled = self.compiled
119
+ if not getattr(compiled, '_oracle_returning', False):
120
+ return
121
+ # Bind a receiver per returned column, in the order the compiler named
122
+ # them, and hand them to the driver in place of the plain values.
123
+ self.out_parameters = {}
124
+ names = [f'ret_{i}' for i in range(len(compiled._result_columns))]
125
+ for index, name in enumerate(names):
126
+ var = self.cursor.var(self._returning_var_type(index))
127
+ self.out_parameters[name] = var
128
+ if isinstance(self.parameters, list):
129
+ for parameter_set in self.parameters:
130
+ parameter_set[name] = var
131
+ else:
132
+ self.parameters[name] = var
133
+
134
+ def get_out_parameter_values(self, names):
135
+ assert self.out_parameters is not None
136
+ return [self.out_parameters[name].getvalue() for name in names]
137
+
138
+ def fetchall_for_returning(self, cursor):
139
+ """The returned values, shaped as rows.
140
+
141
+ A receiver reports what it received as a list, one entry per row the
142
+ statement affected, so even a single-row statement arrives wrapped. An
143
+ array execute goes further and reports per iteration, which is read one
144
+ position at a time and concatenated: iterations come back in the order
145
+ the rows were submitted, which is the parameter order the caller can ask
146
+ to have the rows in.
147
+ """
148
+ if not self.out_parameters:
149
+ return []
150
+ iterations = len(self.parameters) if self.executemany else 1
151
+ columns = []
152
+ for index in range(len(self.compiled._result_columns)):
153
+ receiver = self.out_parameters[f'ret_{index}']
154
+ values: list = []
155
+ for iteration in range(iterations):
156
+ received = receiver.getvalue(iteration)
157
+ values += received if isinstance(received, list) else [received]
158
+ columns.append(values)
159
+ return list(zip(*columns)) if columns else []
160
+
161
+ def post_exec(self):
162
+ compiled = self.compiled
163
+ if compiled is not None and getattr(compiled, '_oracle_returning', False):
164
+ self.cursor_fetch_strategy = _cursor.FullyBufferedCursorFetchStrategy(
165
+ self.cursor,
166
+ [(entry.keyname, None) for entry in compiled._result_columns],
167
+ initial_buffer=self.fetchall_for_returning(self.cursor),
168
+ )
169
+ super().post_exec()
170
+
171
+
172
+ class SeerdbCompiler(OracleCompiler):
173
+ """Renders bind names this server will actually accept.
174
+
175
+ The server is far pickier about bind names than SQL generally is. A name may
176
+ not start with a digit or an underscore, and a good many punctuation
177
+ characters are rejected outright with "invalid host/bind variable name" —
178
+ which is what a name like ``/slashes/`` or ``q?marks`` produces. Names like
179
+ those are not contrived: they come from column names, and a caller who names
180
+ a column that way gets a bind named after it.
181
+
182
+ Two mechanisms are needed, because neither covers the other:
183
+
184
+ * an escape map, used for the expanded parameters of an ``IN`` clause, where
185
+ quoting is not available; and
186
+ * quoting the name outright everywhere else, which also handles reserved
187
+ words and the illegal leading characters.
188
+ """
189
+
190
+ # The generic compiler escapes eight characters. These three more are
191
+ # rejected by this server and have to travel as an escape rather than a
192
+ # quoted name, because an expanded parameter cannot be quoted.
193
+ bindname_escape_characters = util.immutabledict(
194
+ {
195
+ '%': 'P',
196
+ '(': 'A',
197
+ ')': 'Z',
198
+ ':': 'C',
199
+ '.': 'C',
200
+ '[': 'C',
201
+ ']': 'C',
202
+ ' ': 'C',
203
+ '\\': 'C',
204
+ '/': 'C',
205
+ '?': 'C',
206
+ }
207
+ )
208
+
209
+ def bindparam_string(self, name, **kw):
210
+ """Rewrite a bind name into one the server accepts.
211
+
212
+ Escaping only, never quoting. The quoted form `:"name"` is what the
213
+ bundled dialects lean on, but this driver does not parse it — the
214
+ placeholder goes unrecognised and the value is reported as never
215
+ provided. Escaping covers the same ground and works everywhere,
216
+ including the expanded parameters of an `IN` clause, where quoting is
217
+ not available anyway.
218
+
219
+ The original name is recorded in `escaped_from` so the value still
220
+ binds to it.
221
+ """
222
+ if not kw.get('escaped_from'):
223
+ translated = name
224
+ if self._bind_translate_re.search(name):
225
+ translated = self._bind_translate_re.sub(
226
+ lambda m: self._bind_translate_chars[m.group(0)], name
227
+ )
228
+ # Escaping the characters is not the whole job. A reserved word
229
+ # (`desc`) and an illegal leading character (a digit or an
230
+ # underscore) are both still rejected, and both are what quoting
231
+ # would normally have solved. A prefix does the same work: the name
232
+ # only has to be unique and legal.
233
+ if self.preparer._bindparam_requires_quotes(translated):
234
+ translated = 'D' + translated
235
+ if translated != name:
236
+ kw['escaped_from'] = name
237
+ name = translated
238
+ return super().bindparam_string(name, **kw)
239
+
240
+
241
+ class SeerdbDialect(OracleDialect):
242
+ """SQLAlchemy dialect driving the seerdb DBAPI."""
243
+
244
+ name = 'oracle'
245
+ driver = 'seerdb'
246
+ statement_compiler = SeerdbCompiler
247
+ execution_ctx_cls = SeerdbExecutionContext
248
+
249
+ # RETURNING works through SeerdbExecutionContext above, which collects the
250
+ # values off the OUT binds the compiler emits and presents them as rows.
251
+ insert_returning = True
252
+ update_returning = True
253
+ delete_returning = True
254
+ # An array insert reports its returned values per iteration, in the order
255
+ # the rows were submitted, so the rows can be handed back in parameter order
256
+ # (seerdb#687).
257
+ insert_executemany_returning = True
258
+ insert_executemany_returning_sort_by_parameter_order = True
259
+
260
+ # Pass a bind's type to the driver rather than leaving it to be guessed from
261
+ # the value. A `None` carries no type, so without this the server infers CHAR
262
+ # and refuses to compare it to a DATE or a NUMBER column (ORA-00932). The
263
+ # driver takes the declaration through setinputsizes (seerdb#696) and sends
264
+ # the value as the declared type (seerdb#701); the hook below hands it over.
265
+ bind_typing = interfaces.BindTyping.SETINPUTSIZES
266
+
267
+ # The driver hands a NUMBER back as a Decimal, not a float. Saying so is
268
+ # what makes SQLAlchemy convert: a column declared Float asks for a float and
269
+ # gets one, and a Numeric column is left as the Decimal it already is. Left
270
+ # at the default, SQLAlchemy assumes the driver returns floats and installs
271
+ # no processor either way, so a Float column came back holding a Decimal.
272
+ supports_native_decimal = True
273
+
274
+ # As the base dialect's, plus the date narrowing above.
275
+ colspecs: ClassVar[dict] = {**_oracle_base.colspecs, sqltypes.Date: _SeerdbDate}
276
+
277
+ # No SQL is generated differently from the base dialect, so cached
278
+ # statements stay valid.
279
+ supports_statement_cache = True
280
+
281
+ def do_set_input_sizes(self, cursor, list_of_tuples, context):
282
+ """Declare the binds' types for the statement about to run.
283
+
284
+ SQLAlchemy hands over one `(name, dbapi_type, sqla_type)` per bind, in
285
+ order. Only the ones with a type to declare are passed on; a bind with
286
+ no `dbapi_type` is left for the driver to read off its value, which it
287
+ does perfectly well whenever the value can say.
288
+ """
289
+ declared = {}
290
+ for name, dbapi_type, sqla_type in list_of_tuples:
291
+ wanted = self._declared_bind_type(dbapi_type, sqla_type)
292
+ if wanted is not None:
293
+ declared[name] = wanted
294
+ if declared:
295
+ cursor.setinputsizes(**declared)
296
+
297
+ @staticmethod
298
+ def _declared_bind_type(dbapi_type, sqla_type):
299
+ """The driver type to declare for one bind, or None to leave it alone.
300
+
301
+ Mostly the DBAPI type SQLAlchemy names. The exception is a value with a
302
+ time of day: the generic mapping asks for the date type, which on this
303
+ backend is seven bytes and holds no fraction of a second, so declaring it
304
+ would round the value on its way in. The timestamp type is the lossless
305
+ one, and the server narrows it for a column that cannot hold the extra
306
+ precision, so it is the right thing to declare for the whole family.
307
+
308
+ A `Date` column keeps the date type: it has no time of day to lose, and
309
+ saying so is what makes an untyped NULL comparable to one.
310
+ """
311
+ if dbapi_type is None:
312
+ return None
313
+ # Through the affinity, not the type itself: a TypeDecorator wrapping a
314
+ # timestamp is still a timestamp, and testing the wrapper would miss it.
315
+ affinity = getattr(sqla_type, '_type_affinity', None)
316
+ if affinity is not None and issubclass(affinity, sqltypes.DateTime):
317
+ return seerdb.DB_TYPE_TIMESTAMP
318
+ return dbapi_type
319
+
320
+ @classmethod
321
+ def import_dbapi(cls) -> Any:
322
+ import seerdb
323
+
324
+ return seerdb
325
+
326
+ # Kept for SQLAlchemy 1.4 callers, which look for the old spelling.
327
+ @classmethod
328
+ def dbapi(cls) -> Any:
329
+ return cls.import_dbapi()
330
+
331
+ def create_connect_args(self, url: URL) -> tuple[list, dict]:
332
+ options: dict[str, Any] = {}
333
+ if url.host:
334
+ options['host'] = url.host
335
+ if url.port:
336
+ options['port'] = url.port
337
+ if url.username:
338
+ options['user'] = url.username
339
+ if url.password:
340
+ options['password'] = url.password
341
+ # A bare path is the service name, so both the modern URL form and the
342
+ # query-string form work.
343
+ if url.database:
344
+ options.setdefault('service_name', url.database)
345
+ # The driver commits every statement unless told otherwise, which would
346
+ # make SQLAlchemy's transactions, and its rollback, mean nothing. The
347
+ # DBAPI contract SQLAlchemy builds on is autocommit off, so that is the
348
+ # default here; ``?autocommit=true`` in the URL still turns it on.
349
+ options['autocommit'] = False
350
+ for key, value in url.query.items():
351
+ # A repeated key arrives as a tuple; the driver takes one value.
352
+ options[key] = _coerce(
353
+ key, value[-1] if isinstance(value, tuple) else value
354
+ )
355
+ return ([], options)
356
+
357
+ def _get_server_version_info(self, connection: Any) -> tuple[int, ...]:
358
+ # The driver already decodes the packed release the server sends at
359
+ # login, so there is no round trip to make here.
360
+ raw = getattr(connection.connection, 'version', None)
361
+ if not raw:
362
+ return ()
363
+ parts = []
364
+ for piece in str(raw).split('.'):
365
+ try:
366
+ parts.append(int(piece))
367
+ except ValueError:
368
+ break
369
+ return tuple(parts)
370
+
371
+ def is_disconnect(self, e: Exception, connection: Any, cursor: Any) -> bool:
372
+ if isinstance(e, self.loaded_dbapi.InterfaceError):
373
+ return True
374
+ return super().is_disconnect(e, connection, cursor)
375
+
376
+
377
+ dialect = SeerdbDialect
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlalchemy-seerdb
3
+ Version: 0.2.0
4
+ Summary: SQLAlchemy dialect for the seerdb driver
5
+ Author-email: Peter Lemenkov <lemenkov@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/seerdb/sqlalchemy-seerdb
8
+ Project-URL: Source, https://github.com/seerdb/sqlalchemy-seerdb
9
+ Keywords: sqlalchemy,dialect,database,dbapi
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Database
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSES/MIT.txt
17
+ Requires-Dist: SQLAlchemy>=2.0
18
+ Requires-Dist: seerdb>=2.5.0
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ <!--
24
+ SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
25
+ SPDX-License-Identifier: MIT
26
+ -->
27
+
28
+ # sqlalchemy-seerdb
29
+
30
+ A SQLAlchemy dialect for the [seerdb](https://github.com/seerdb/seerdb) driver.
31
+
32
+ ```python
33
+ import sqlalchemy as sa
34
+
35
+ engine = sa.create_engine('oracle+seerdb://user:password@host:1521/?service_name=XE')
36
+ ```
37
+
38
+ ## Why this exists
39
+
40
+ The SQL is nothing new — this inherits SQLAlchemy's built-in compiler, DDL and
41
+ reflection wholesale and supplies only what is specific to this DBAPI. What it
42
+ adds is **reach**.
43
+
44
+ seerdb speaks the wire protocol itself, in pure Python, with no vendor client
45
+ libraries. The alternatives connect directly only to newer servers and fall back
46
+ to loading vendor client libraries for anything older. So this dialect covers a
47
+ range that otherwise needs a native client installed:
48
+
49
+ | Server | This dialect | Direct connection elsewhere |
50
+ |--------|--------------|-----------------------------|
51
+ | 8i, 9i, 10g, 11g | yes | no, needs vendor client libraries |
52
+ | 12.1 and later | yes | yes |
53
+
54
+ Verified against live servers: an 11g instance reports
55
+ `server_version_info == (11, 2, 0, 2, 0)` and a current one reports
56
+ `(23, 1, 162, 0, 0)`, both over the same dialect.
57
+
58
+ If you are on a modern server and can install a native client, the dialects that
59
+ ship with SQLAlchemy are the better-trodden path. This one is for the cases they
60
+ do not reach.
61
+
62
+ ## Status
63
+
64
+ Early. Connections, Core `select`, DDL, parameter binding and reflection all
65
+ work against live servers — `has_table`, `get_columns`, `get_pk_constraint` and
66
+ `autoload_with` round-trip.
67
+
68
+ The current target is SQLAlchemy's dialect compliance suite. Progress is tracked
69
+ under the [SQLAlchemy conformance](https://github.com/seerdb/sqlalchemy-seerdb/milestone/1)
70
+ milestone.
71
+
72
+ ## Running the tests
73
+
74
+ The suite is SQLAlchemy's dialect compliance suite, which upstream names as the
75
+ target for third-party dialects. It is entirely live-database driven — there is
76
+ no offline mode — so point it at a server:
77
+
78
+ ```bash
79
+ pytest --dburi "oracle+seerdb://user:password@host:1521/?service_name=XE"
80
+ ```
81
+
82
+ Run it from the repository root. `test.cfg` has to be found in the working
83
+ directory: SQLAlchemy's plugin reads it with configparser and does not look at
84
+ `pyproject.toml`.
85
+
86
+ ### One setup step, and it needs a DBA
87
+
88
+ The suite expects a second namespace called `test_schema`, which on this backend
89
+ is a **username**, and the test account must be able to create and drop tables
90
+ inside it. Skipping this does not fail a handful of tests — every test in
91
+ `ComponentReflectionTest` errors in setup, because they share a fixture that
92
+ builds tables there.
93
+
94
+ The test account cannot create it (`ORA-01031`), so run this as a DBA once:
95
+
96
+ ```sql
97
+ CREATE USER test_schema IDENTIFIED BY test_schema;
98
+ GRANT CREATE SESSION TO test_schema;
99
+ ALTER USER test_schema QUOTA UNLIMITED ON USERS;
100
+
101
+ -- so the test account can build and drop the fixtures inside that schema
102
+ GRANT CREATE ANY TABLE, DROP ANY TABLE, SELECT ANY TABLE, INSERT ANY TABLE,
103
+ UPDATE ANY TABLE, DELETE ANY TABLE, CREATE ANY INDEX, DROP ANY INDEX,
104
+ CREATE ANY VIEW, DROP ANY VIEW, CREATE ANY SEQUENCE, DROP ANY SEQUENCE,
105
+ COMMENT ANY TABLE, ANALYZE ANY TO <your test user>;
106
+ ```
107
+
108
+ Those `ANY` privileges are broad. They are fine on a throwaway test instance —
109
+ CI creates the namespace in a container it discards — but narrow them before
110
+ granting on anything long-lived.
111
+
112
+ ## Connect string
113
+
114
+ Everything after `?` is passed through to `seerdb.connect`, with integers and
115
+ booleans coerced:
116
+
117
+ ```
118
+ oracle+seerdb://user:password@host:1521/?service_name=XE
119
+ oracle+seerdb://user:password@host:1521/?sid=XE&timeout=5000
120
+ ```
121
+
122
+ ## Licence
123
+
124
+ MIT. This repository is [REUSE](https://reuse.software/) compliant.
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ LICENSES/MIT.txt
4
+ sqlalchemy_seerdb/__init__.py
5
+ sqlalchemy_seerdb/provision.py
6
+ sqlalchemy_seerdb/requirements.py
7
+ sqlalchemy_seerdb/seerdb.py
8
+ sqlalchemy_seerdb.egg-info/PKG-INFO
9
+ sqlalchemy_seerdb.egg-info/SOURCES.txt
10
+ sqlalchemy_seerdb.egg-info/dependency_links.txt
11
+ sqlalchemy_seerdb.egg-info/entry_points.txt
12
+ sqlalchemy_seerdb.egg-info/requires.txt
13
+ sqlalchemy_seerdb.egg-info/top_level.txt
14
+ test/test_connect_args.py
15
+ test/test_suite.py
16
+ test/test_version.py
@@ -0,0 +1,2 @@
1
+ [sqlalchemy.dialects]
2
+ oracle.seerdb = sqlalchemy_seerdb.seerdb:SeerdbDialect
@@ -0,0 +1,5 @@
1
+ SQLAlchemy>=2.0
2
+ seerdb>=2.5.0
3
+
4
+ [test]
5
+ pytest>=7
@@ -0,0 +1 @@
1
+ sqlalchemy_seerdb
@@ -0,0 +1,31 @@
1
+ # SPDX-FileCopyrightText: 2025 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """The connect arguments the dialect derives from a URL."""
4
+
5
+ import unittest
6
+
7
+ from sqlalchemy.engine import make_url
8
+
9
+ from sqlalchemy_seerdb.seerdb import SeerdbDialect
10
+
11
+
12
+ def _options(url):
13
+ _args, options = SeerdbDialect().create_connect_args(make_url(url))
14
+ return options
15
+
16
+
17
+ class TestAutocommit(unittest.TestCase):
18
+ """The driver commits every statement by default; SQLAlchemy must not.
19
+
20
+ With the driver's default a transaction's rollback undid nothing, which
21
+ the compliance suite noticed as rows surviving from one test into the
22
+ next.
23
+ """
24
+
25
+ def test_off_by_default(self):
26
+ options = _options('oracle+seerdb://u:p@h:1521/?service_name=s')
27
+ self.assertIs(options['autocommit'], False)
28
+
29
+ def test_the_url_can_turn_it_on(self):
30
+ options = _options('oracle+seerdb://u:p@h:1521/?service_name=s&autocommit=true')
31
+ self.assertIs(options['autocommit'], True)
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """Run SQLAlchemy's dialect compliance suite against this dialect."""
5
+
6
+ from sqlalchemy.testing.suite import *
@@ -0,0 +1,18 @@
1
+ # SPDX-FileCopyrightText: 2026 Peter Lemenkov <lemenkov@gmail.com>
2
+ # SPDX-License-Identifier: MIT
3
+ """The version lives in two places and they must agree."""
4
+
5
+ import re
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ import sqlalchemy_seerdb
10
+
11
+
12
+ class TestVersion(unittest.TestCase):
13
+ def test_module_and_package_metadata_agree(self):
14
+ pyproject = (
15
+ Path(__file__).resolve().parent.parent / 'pyproject.toml'
16
+ ).read_text()
17
+ declared = re.search(r'^version = "([^"]+)"', pyproject, re.MULTILINE).group(1)
18
+ self.assertEqual(sqlalchemy_seerdb.__version__, declared)