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.
- sqlalchemy_seerdb-0.2.0/LICENSES/MIT.txt +21 -0
- sqlalchemy_seerdb-0.2.0/PKG-INFO +124 -0
- sqlalchemy_seerdb-0.2.0/README.md +102 -0
- sqlalchemy_seerdb-0.2.0/pyproject.toml +50 -0
- sqlalchemy_seerdb-0.2.0/setup.cfg +4 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb/__init__.py +22 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb/provision.py +47 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb/requirements.py +257 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb/seerdb.py +377 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/PKG-INFO +124 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/SOURCES.txt +16 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/dependency_links.txt +1 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/entry_points.txt +2 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/requires.txt +5 -0
- sqlalchemy_seerdb-0.2.0/sqlalchemy_seerdb.egg-info/top_level.txt +1 -0
- sqlalchemy_seerdb-0.2.0/test/test_connect_args.py +31 -0
- sqlalchemy_seerdb-0.2.0/test/test_suite.py +6 -0
- sqlalchemy_seerdb-0.2.0/test/test_version.py +18 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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)
|