sqlalchemy-hawdb 0.22.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_hawdb-0.22.0/PKG-INFO +130 -0
- sqlalchemy_hawdb-0.22.0/README.md +110 -0
- sqlalchemy_hawdb-0.22.0/pyproject.toml +34 -0
- sqlalchemy_hawdb-0.22.0/setup.cfg +4 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb/__init__.py +5 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb/dialect.py +323 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/PKG-INFO +130 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/SOURCES.txt +12 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/dependency_links.txt +1 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/entry_points.txt +2 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/requires.txt +6 -0
- sqlalchemy_hawdb-0.22.0/sqlalchemy_hawdb.egg-info/top_level.txt +1 -0
- sqlalchemy_hawdb-0.22.0/tests/test_dialect.py +89 -0
- sqlalchemy_hawdb-0.22.0/tests/test_sqlalchemy_integration.py +123 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-hawdb
|
|
3
|
+
Version: 0.22.0
|
|
4
|
+
Summary: SQLAlchemy dialect for the embedded HawDB database
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: SQLAlchemy<3.0,>=2.0
|
|
16
|
+
Requires-Dist: hawdb<0.23.0,>=0.22.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
19
|
+
Requires-Dist: ruff<1,>=0.14; extra == "test"
|
|
20
|
+
|
|
21
|
+
# SQLAlchemy HawDB
|
|
22
|
+
|
|
23
|
+
`sqlalchemy-hawdb` provides a SQLAlchemy 2.x dialect for the embedded HawDB
|
|
24
|
+
database. It uses HawDB's own PEP 249 driver and does not add a wire protocol or
|
|
25
|
+
duplicate its DBAPI implementation.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.11 or newer
|
|
30
|
+
- HawDB Python binding 0.22.x (CI tests 0.22.0)
|
|
31
|
+
- SQLAlchemy 2.x
|
|
32
|
+
|
|
33
|
+
Install the dialect and its HawDB dependency from PyPI:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
python -m pip install sqlalchemy-hawdb
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
To install this checkout instead:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
python -m pip install /path/to/sqlalchemy_hawdb
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Connect
|
|
46
|
+
|
|
47
|
+
Use an absolute database directory path with four slashes:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine
|
|
51
|
+
|
|
52
|
+
engine = create_engine("hawdb:////var/lib/my-app/knowledge")
|
|
53
|
+
metadata = MetaData()
|
|
54
|
+
records = Table(
|
|
55
|
+
"records",
|
|
56
|
+
metadata,
|
|
57
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
58
|
+
Column("body", String(500), nullable=False),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
metadata.create_all(engine)
|
|
62
|
+
with engine.begin() as connection:
|
|
63
|
+
connection.execute(records.insert(), {"id": 1, "body": "Hello HawDB"})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
HawDB creates the database directory itself; its parent directory must already
|
|
67
|
+
exist.
|
|
68
|
+
|
|
69
|
+
Use `hawdb:///:memory:` for an in-memory database. Like SQLAlchemy's SQLite
|
|
70
|
+
dialect, the in-memory engine keeps one database per thread. Add
|
|
71
|
+
`?read_only=true` to open an existing database read-only.
|
|
72
|
+
|
|
73
|
+
HawDB connections are thread-affine and hold their database path until closed.
|
|
74
|
+
For file databases the dialect uses SQLAlchemy's `NullPool`: each SQLAlchemy
|
|
75
|
+
connection closes its HawDB handle when returned. Do not keep multiple
|
|
76
|
+
connections to the same database path open at once or pass a SQLAlchemy
|
|
77
|
+
connection between threads. In-memory connections are kept per thread and must
|
|
78
|
+
also be used serially within that thread.
|
|
79
|
+
|
|
80
|
+
## Supported behavior
|
|
81
|
+
|
|
82
|
+
- SQLAlchemy Core and ORM statements using HawDB's relational SQL subset
|
|
83
|
+
- Positional `$1`, `$2`, ... binding through HawDB's native DBAPI driver
|
|
84
|
+
- Explicit SQLAlchemy transactions mapped to HawDB commit and rollback
|
|
85
|
+
- Table, column, primary key, unique constraint, and index reflection from
|
|
86
|
+
HawDB's `information_schema` and `pg_catalog` views
|
|
87
|
+
- The default `public` schema
|
|
88
|
+
- HawDB-native scalar DDL types: integer types map to `BIGINT`, floating and
|
|
89
|
+
numeric types to `DOUBLE PRECISION`, strings to `TEXT`, and binary values to
|
|
90
|
+
`BYTEA`; SQLAlchemy string lengths and numeric precision are not enforced by
|
|
91
|
+
HawDB
|
|
92
|
+
|
|
93
|
+
HawDB does not report affected row counts, so DBAPI `rowcount` is `-1` for
|
|
94
|
+
statements without a result set. Date and time types compile to `TEXT`; their
|
|
95
|
+
values are encoded and decoded by SQLAlchemy's standard processors.
|
|
96
|
+
Auto-generated identity columns, sequences, schema alteration, savepoints, and
|
|
97
|
+
concurrent connections to one database path are not supported by this initial
|
|
98
|
+
dialect.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
Install the project, the HawDB dependency, and the test tools:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
python -m pip install -e '.[test]'
|
|
106
|
+
python -m pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
GitHub Actions builds the source distribution and wheel, checks their metadata,
|
|
110
|
+
and tests the installed wheel against `hawdb==0.22.0` from PyPI on Python
|
|
111
|
+
3.11-3.13 for every push and pull request. Missing HawDB wheels fail CI rather
|
|
112
|
+
than skipping integration tests or falling back to a source build.
|
|
113
|
+
|
|
114
|
+
## Publishing
|
|
115
|
+
|
|
116
|
+
`.github/workflows/pypi.yaml` publishes the tested source distribution and wheel
|
|
117
|
+
when a GitHub Release is published. The release tag must match the version in
|
|
118
|
+
`pyproject.toml`, for example `v0.22.0`. A manual workflow dispatch runs the same
|
|
119
|
+
build and tests without uploading to PyPI.
|
|
120
|
+
|
|
121
|
+
Configure a PyPI Trusted Publisher for the `sqlalchemy-hawdb` project with:
|
|
122
|
+
|
|
123
|
+
- Owner: `nowledge-co`
|
|
124
|
+
- Repository: `sqlalchemy-hawdb`
|
|
125
|
+
- Workflow: `pypi.yaml`
|
|
126
|
+
- Environment: `pypi`
|
|
127
|
+
|
|
128
|
+
For the first release, configure a pending publisher in your PyPI account.
|
|
129
|
+
The publish job uses GitHub's `pypi` environment and OIDC; no PyPI API token
|
|
130
|
+
secret is required.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SQLAlchemy HawDB
|
|
2
|
+
|
|
3
|
+
`sqlalchemy-hawdb` provides a SQLAlchemy 2.x dialect for the embedded HawDB
|
|
4
|
+
database. It uses HawDB's own PEP 249 driver and does not add a wire protocol or
|
|
5
|
+
duplicate its DBAPI implementation.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Python 3.11 or newer
|
|
10
|
+
- HawDB Python binding 0.22.x (CI tests 0.22.0)
|
|
11
|
+
- SQLAlchemy 2.x
|
|
12
|
+
|
|
13
|
+
Install the dialect and its HawDB dependency from PyPI:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
python -m pip install sqlalchemy-hawdb
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
To install this checkout instead:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
python -m pip install /path/to/sqlalchemy_hawdb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Connect
|
|
26
|
+
|
|
27
|
+
Use an absolute database directory path with four slashes:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine
|
|
31
|
+
|
|
32
|
+
engine = create_engine("hawdb:////var/lib/my-app/knowledge")
|
|
33
|
+
metadata = MetaData()
|
|
34
|
+
records = Table(
|
|
35
|
+
"records",
|
|
36
|
+
metadata,
|
|
37
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
38
|
+
Column("body", String(500), nullable=False),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
metadata.create_all(engine)
|
|
42
|
+
with engine.begin() as connection:
|
|
43
|
+
connection.execute(records.insert(), {"id": 1, "body": "Hello HawDB"})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
HawDB creates the database directory itself; its parent directory must already
|
|
47
|
+
exist.
|
|
48
|
+
|
|
49
|
+
Use `hawdb:///:memory:` for an in-memory database. Like SQLAlchemy's SQLite
|
|
50
|
+
dialect, the in-memory engine keeps one database per thread. Add
|
|
51
|
+
`?read_only=true` to open an existing database read-only.
|
|
52
|
+
|
|
53
|
+
HawDB connections are thread-affine and hold their database path until closed.
|
|
54
|
+
For file databases the dialect uses SQLAlchemy's `NullPool`: each SQLAlchemy
|
|
55
|
+
connection closes its HawDB handle when returned. Do not keep multiple
|
|
56
|
+
connections to the same database path open at once or pass a SQLAlchemy
|
|
57
|
+
connection between threads. In-memory connections are kept per thread and must
|
|
58
|
+
also be used serially within that thread.
|
|
59
|
+
|
|
60
|
+
## Supported behavior
|
|
61
|
+
|
|
62
|
+
- SQLAlchemy Core and ORM statements using HawDB's relational SQL subset
|
|
63
|
+
- Positional `$1`, `$2`, ... binding through HawDB's native DBAPI driver
|
|
64
|
+
- Explicit SQLAlchemy transactions mapped to HawDB commit and rollback
|
|
65
|
+
- Table, column, primary key, unique constraint, and index reflection from
|
|
66
|
+
HawDB's `information_schema` and `pg_catalog` views
|
|
67
|
+
- The default `public` schema
|
|
68
|
+
- HawDB-native scalar DDL types: integer types map to `BIGINT`, floating and
|
|
69
|
+
numeric types to `DOUBLE PRECISION`, strings to `TEXT`, and binary values to
|
|
70
|
+
`BYTEA`; SQLAlchemy string lengths and numeric precision are not enforced by
|
|
71
|
+
HawDB
|
|
72
|
+
|
|
73
|
+
HawDB does not report affected row counts, so DBAPI `rowcount` is `-1` for
|
|
74
|
+
statements without a result set. Date and time types compile to `TEXT`; their
|
|
75
|
+
values are encoded and decoded by SQLAlchemy's standard processors.
|
|
76
|
+
Auto-generated identity columns, sequences, schema alteration, savepoints, and
|
|
77
|
+
concurrent connections to one database path are not supported by this initial
|
|
78
|
+
dialect.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
Install the project, the HawDB dependency, and the test tools:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
python -m pip install -e '.[test]'
|
|
86
|
+
python -m pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
GitHub Actions builds the source distribution and wheel, checks their metadata,
|
|
90
|
+
and tests the installed wheel against `hawdb==0.22.0` from PyPI on Python
|
|
91
|
+
3.11-3.13 for every push and pull request. Missing HawDB wheels fail CI rather
|
|
92
|
+
than skipping integration tests or falling back to a source build.
|
|
93
|
+
|
|
94
|
+
## Publishing
|
|
95
|
+
|
|
96
|
+
`.github/workflows/pypi.yaml` publishes the tested source distribution and wheel
|
|
97
|
+
when a GitHub Release is published. The release tag must match the version in
|
|
98
|
+
`pyproject.toml`, for example `v0.22.0`. A manual workflow dispatch runs the same
|
|
99
|
+
build and tests without uploading to PyPI.
|
|
100
|
+
|
|
101
|
+
Configure a PyPI Trusted Publisher for the `sqlalchemy-hawdb` project with:
|
|
102
|
+
|
|
103
|
+
- Owner: `nowledge-co`
|
|
104
|
+
- Repository: `sqlalchemy-hawdb`
|
|
105
|
+
- Workflow: `pypi.yaml`
|
|
106
|
+
- Environment: `pypi`
|
|
107
|
+
|
|
108
|
+
For the first release, configure a pending publisher in your PyPI account.
|
|
109
|
+
The publish job uses GitHub's `pypi` environment and OIDC; no PyPI API token
|
|
110
|
+
secret is required.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqlalchemy-hawdb"
|
|
7
|
+
version = "0.22.0"
|
|
8
|
+
description = "SQLAlchemy dialect for the embedded HawDB database"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
dependencies = ["SQLAlchemy>=2.0,<3.0", "hawdb>=0.22.0,<0.23.0"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Database",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
24
|
+
hawdb = "sqlalchemy_hawdb.dialect:HawDBDialect"
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
test = ["pytest>=8,<9", "ruff>=0.14,<1"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
include = ["sqlalchemy_hawdb*"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
34
|
+
addopts = "-ra"
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""SQLAlchemy dialect for HawDB's embedded SQL interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
|
|
7
|
+
from sqlalchemy.engine import default
|
|
8
|
+
from sqlalchemy.pool import NullPool, SingletonThreadPool
|
|
9
|
+
from sqlalchemy.sql.compiler import GenericTypeCompiler, IdentifierPreparer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HawDBIdentifierPreparer(IdentifierPreparer):
|
|
13
|
+
"""Quote identifiers using SQL-standard double quotes."""
|
|
14
|
+
|
|
15
|
+
reserved_words = IdentifierPreparer.reserved_words | {"match", "return", "graph"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class HawDBTypeCompiler(GenericTypeCompiler):
|
|
19
|
+
"""Compile SQLAlchemy types to HawDB's supported PostgreSQL type subset."""
|
|
20
|
+
|
|
21
|
+
def visit_big_integer(self, type_, **kw):
|
|
22
|
+
return "BIGINT"
|
|
23
|
+
|
|
24
|
+
visit_integer = visit_big_integer
|
|
25
|
+
visit_small_integer = visit_big_integer
|
|
26
|
+
|
|
27
|
+
def visit_boolean(self, type_, **kw):
|
|
28
|
+
return "BOOLEAN"
|
|
29
|
+
|
|
30
|
+
def visit_double(self, type_, **kw):
|
|
31
|
+
return "DOUBLE PRECISION"
|
|
32
|
+
|
|
33
|
+
visit_float = visit_double
|
|
34
|
+
visit_real = visit_double
|
|
35
|
+
visit_numeric = visit_double
|
|
36
|
+
|
|
37
|
+
def visit_string(self, type_, **kw):
|
|
38
|
+
return "TEXT"
|
|
39
|
+
|
|
40
|
+
visit_text = visit_string
|
|
41
|
+
visit_unicode = visit_string
|
|
42
|
+
visit_unicode_text = visit_string
|
|
43
|
+
|
|
44
|
+
def visit_large_binary(self, type_, **kw):
|
|
45
|
+
return "BYTEA"
|
|
46
|
+
|
|
47
|
+
def visit_uuid(self, type_, **kw):
|
|
48
|
+
return "UUID"
|
|
49
|
+
|
|
50
|
+
def visit_date(self, type_, **kw):
|
|
51
|
+
return "TEXT"
|
|
52
|
+
|
|
53
|
+
visit_time = visit_date
|
|
54
|
+
visit_datetime = visit_date
|
|
55
|
+
|
|
56
|
+
def visit_enum(self, type_, **kw):
|
|
57
|
+
return "TEXT"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class HawDBDialect(default.DefaultDialect):
|
|
61
|
+
name = "hawdb"
|
|
62
|
+
driver = "embedded"
|
|
63
|
+
supports_statement_cache = True
|
|
64
|
+
supports_native_boolean = True
|
|
65
|
+
supports_native_uuid = True
|
|
66
|
+
supports_sane_rowcount = False
|
|
67
|
+
supports_sane_multi_rowcount = False
|
|
68
|
+
supports_sequences = False
|
|
69
|
+
supports_identity_columns = False
|
|
70
|
+
supports_default_values = False
|
|
71
|
+
supports_empty_insert = False
|
|
72
|
+
supports_alter = False
|
|
73
|
+
supports_comments = False
|
|
74
|
+
postfetch_lastrowid = False
|
|
75
|
+
default_paramstyle = "numeric_dollar"
|
|
76
|
+
preparer = HawDBIdentifierPreparer
|
|
77
|
+
type_compiler_cls = HawDBTypeCompiler
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def import_dbapi(cls):
|
|
81
|
+
try:
|
|
82
|
+
return importlib.import_module("hawdb.dbapi")
|
|
83
|
+
except ImportError as error:
|
|
84
|
+
raise ImportError(
|
|
85
|
+
"HawDB's Python binding is required; install it with "
|
|
86
|
+
"python -m pip install 'hawdb>=0.22.0,<0.23.0'"
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
def create_connect_args(self, url):
|
|
90
|
+
if url.username or url.password or url.host or url.port:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Invalid HawDB URL: provide a local database path without "
|
|
93
|
+
"credentials or a network host"
|
|
94
|
+
)
|
|
95
|
+
query = dict(url.query)
|
|
96
|
+
read_only = query.pop("read_only", "false").lower() in {"1", "true", "yes"}
|
|
97
|
+
if query:
|
|
98
|
+
options = ", ".join(sorted(query))
|
|
99
|
+
raise ValueError(f"unsupported HawDB URL option(s): {options}")
|
|
100
|
+
path = url.database
|
|
101
|
+
if path in (None, ":memory:"):
|
|
102
|
+
path = None
|
|
103
|
+
return (
|
|
104
|
+
[path],
|
|
105
|
+
{"read_only": read_only, "autocommit": False},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def do_begin(self, dbapi_connection):
|
|
109
|
+
# HawDB's PEP 249 connection begins on its first statement.
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def do_rollback(self, dbapi_connection):
|
|
113
|
+
dbapi_connection.rollback()
|
|
114
|
+
|
|
115
|
+
def do_commit(self, dbapi_connection):
|
|
116
|
+
dbapi_connection.commit()
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def get_pool_class(cls, url):
|
|
120
|
+
# File handles own their path and cannot be shared across threads.
|
|
121
|
+
# In-memory URLs need a per-thread handle to persist across checkouts.
|
|
122
|
+
if url.database in (None, ":memory:"):
|
|
123
|
+
return SingletonThreadPool
|
|
124
|
+
return NullPool
|
|
125
|
+
|
|
126
|
+
def get_server_version_info(self, connection):
|
|
127
|
+
import hawdb
|
|
128
|
+
|
|
129
|
+
return tuple(int(part) for part in hawdb.__version__.split(".")[:3])
|
|
130
|
+
|
|
131
|
+
def get_default_schema_name(self, connection):
|
|
132
|
+
return "public"
|
|
133
|
+
|
|
134
|
+
def get_schema_names(self, connection, **kw):
|
|
135
|
+
return ["public"]
|
|
136
|
+
|
|
137
|
+
def has_table(self, connection, table_name, schema=None, **kw):
|
|
138
|
+
if schema not in (None, "public"):
|
|
139
|
+
return False
|
|
140
|
+
result = connection.exec_driver_sql(
|
|
141
|
+
"SELECT table_name FROM information_schema.tables "
|
|
142
|
+
"WHERE table_schema = 'public' AND table_name = $1",
|
|
143
|
+
(table_name,),
|
|
144
|
+
)
|
|
145
|
+
return result.first() is not None
|
|
146
|
+
|
|
147
|
+
def get_table_names(self, connection, schema=None, **kw):
|
|
148
|
+
if schema not in (None, "public"):
|
|
149
|
+
return []
|
|
150
|
+
result = connection.exec_driver_sql(
|
|
151
|
+
"SELECT table_name FROM information_schema.tables "
|
|
152
|
+
"WHERE table_schema = 'public' AND table_type = 'BASE TABLE' "
|
|
153
|
+
"ORDER BY table_name"
|
|
154
|
+
)
|
|
155
|
+
return [row[0] for row in result]
|
|
156
|
+
|
|
157
|
+
def get_view_names(self, connection, schema=None, **kw):
|
|
158
|
+
if schema not in (None, "public"):
|
|
159
|
+
return []
|
|
160
|
+
result = connection.exec_driver_sql(
|
|
161
|
+
"SELECT table_name FROM information_schema.tables "
|
|
162
|
+
"WHERE table_schema = 'public' AND table_type = 'MATERIALIZED VIEW' "
|
|
163
|
+
"ORDER BY table_name"
|
|
164
|
+
)
|
|
165
|
+
return [row[0] for row in result]
|
|
166
|
+
|
|
167
|
+
def get_columns(self, connection, table_name, schema=None, **kw):
|
|
168
|
+
if schema not in (None, "public"):
|
|
169
|
+
return []
|
|
170
|
+
result = connection.exec_driver_sql(
|
|
171
|
+
"SELECT column_name, data_type, is_nullable, column_default "
|
|
172
|
+
"FROM information_schema.columns "
|
|
173
|
+
"WHERE table_schema = 'public' AND table_name = $1 "
|
|
174
|
+
"ORDER BY ordinal_position",
|
|
175
|
+
(table_name,),
|
|
176
|
+
)
|
|
177
|
+
return [
|
|
178
|
+
{
|
|
179
|
+
"name": row[0],
|
|
180
|
+
"type": self._type_from_name(row[1]),
|
|
181
|
+
"nullable": row[2] == "YES",
|
|
182
|
+
"default": row[3],
|
|
183
|
+
"autoincrement": False,
|
|
184
|
+
"comment": None,
|
|
185
|
+
}
|
|
186
|
+
for row in result
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
def get_pk_constraint(self, connection, table_name, schema=None, **kw):
|
|
190
|
+
if schema not in (None, "public"):
|
|
191
|
+
return {"name": None, "constrained_columns": []}
|
|
192
|
+
result = connection.exec_driver_sql(
|
|
193
|
+
"SELECT indexname, indexdef FROM pg_catalog.pg_indexes "
|
|
194
|
+
"WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname",
|
|
195
|
+
(table_name,),
|
|
196
|
+
)
|
|
197
|
+
primary_key_name = f"{table_name}_pkey"
|
|
198
|
+
for row in result:
|
|
199
|
+
if row[0] == primary_key_name:
|
|
200
|
+
return {
|
|
201
|
+
"name": row[0],
|
|
202
|
+
"constrained_columns": self._index_columns(row[1]),
|
|
203
|
+
}
|
|
204
|
+
return {"name": None, "constrained_columns": []}
|
|
205
|
+
|
|
206
|
+
def get_indexes(self, connection, table_name, schema=None, **kw):
|
|
207
|
+
if schema not in (None, "public"):
|
|
208
|
+
return []
|
|
209
|
+
result = connection.exec_driver_sql(
|
|
210
|
+
"SELECT indexname, indexdef FROM pg_catalog.pg_indexes "
|
|
211
|
+
"WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname",
|
|
212
|
+
(table_name,),
|
|
213
|
+
)
|
|
214
|
+
primary_key_name = f"{table_name}_pkey"
|
|
215
|
+
return [
|
|
216
|
+
{
|
|
217
|
+
"name": row[0],
|
|
218
|
+
"column_names": self._index_columns(row[1]),
|
|
219
|
+
"unique": row[1].upper().startswith("CREATE UNIQUE INDEX"),
|
|
220
|
+
"dialect_options": {},
|
|
221
|
+
}
|
|
222
|
+
for row in result
|
|
223
|
+
if row[0] != primary_key_name
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
def get_unique_constraints(self, connection, table_name, schema=None, **kw):
|
|
227
|
+
if schema not in (None, "public"):
|
|
228
|
+
return []
|
|
229
|
+
result = connection.exec_driver_sql(
|
|
230
|
+
"SELECT indexname, indexdef FROM pg_catalog.pg_indexes "
|
|
231
|
+
"WHERE schemaname = 'public' AND tablename = $1 ORDER BY indexname",
|
|
232
|
+
(table_name,),
|
|
233
|
+
)
|
|
234
|
+
primary_key_name = f"{table_name}_pkey"
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
"name": row[0],
|
|
238
|
+
"column_names": self._index_columns(row[1]),
|
|
239
|
+
"duplicates_index": row[0],
|
|
240
|
+
}
|
|
241
|
+
for row in result
|
|
242
|
+
if row[0] != primary_key_name
|
|
243
|
+
and row[1].upper().startswith("CREATE UNIQUE INDEX")
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _index_columns(definition: str) -> list[str]:
|
|
248
|
+
"""Parse the simple parenthesized column list emitted by pg_indexes."""
|
|
249
|
+
opening = closing = -1
|
|
250
|
+
quoted = False
|
|
251
|
+
depth = 0
|
|
252
|
+
index = 0
|
|
253
|
+
while index < len(definition):
|
|
254
|
+
char = definition[index]
|
|
255
|
+
if char == '"':
|
|
256
|
+
if (
|
|
257
|
+
quoted
|
|
258
|
+
and index + 1 < len(definition)
|
|
259
|
+
and definition[index + 1] == '"'
|
|
260
|
+
):
|
|
261
|
+
index += 1
|
|
262
|
+
else:
|
|
263
|
+
quoted = not quoted
|
|
264
|
+
elif not quoted and char == "(":
|
|
265
|
+
if depth == 0:
|
|
266
|
+
opening = index
|
|
267
|
+
depth += 1
|
|
268
|
+
elif not quoted and char == ")" and depth:
|
|
269
|
+
depth -= 1
|
|
270
|
+
if depth == 0:
|
|
271
|
+
closing = index
|
|
272
|
+
index += 1
|
|
273
|
+
if opening < 0 or closing <= opening:
|
|
274
|
+
return []
|
|
275
|
+
columns = definition[opening + 1 : closing]
|
|
276
|
+
result = []
|
|
277
|
+
current = []
|
|
278
|
+
quoted = False
|
|
279
|
+
index = 0
|
|
280
|
+
while index < len(columns):
|
|
281
|
+
char = columns[index]
|
|
282
|
+
if char == '"':
|
|
283
|
+
if quoted and index + 1 < len(columns) and columns[index + 1] == '"':
|
|
284
|
+
current.append('"')
|
|
285
|
+
index += 1
|
|
286
|
+
else:
|
|
287
|
+
quoted = not quoted
|
|
288
|
+
elif char == "," and not quoted:
|
|
289
|
+
result.append("".join(current).strip())
|
|
290
|
+
current = []
|
|
291
|
+
else:
|
|
292
|
+
current.append(char)
|
|
293
|
+
index += 1
|
|
294
|
+
if current:
|
|
295
|
+
result.append("".join(current).strip())
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _type_from_name(name: str):
|
|
300
|
+
from sqlalchemy import types
|
|
301
|
+
|
|
302
|
+
normalized = name.lower()
|
|
303
|
+
if normalized in {"smallint", "integer", "bigint"}:
|
|
304
|
+
return types.Integer()
|
|
305
|
+
if normalized in {"real", "double precision", "numeric", "decimal"}:
|
|
306
|
+
return types.Float()
|
|
307
|
+
if normalized in {"boolean", "bool"}:
|
|
308
|
+
return types.Boolean()
|
|
309
|
+
if normalized in {
|
|
310
|
+
"date",
|
|
311
|
+
"time",
|
|
312
|
+
"timestamp",
|
|
313
|
+
"timestamp without time zone",
|
|
314
|
+
"timestamp with time zone",
|
|
315
|
+
}:
|
|
316
|
+
return types.Text()
|
|
317
|
+
if normalized in {"uuid"}:
|
|
318
|
+
return types.Uuid()
|
|
319
|
+
if normalized in {"bytea", "binary", "varbinary"}:
|
|
320
|
+
return types.LargeBinary()
|
|
321
|
+
if normalized in {"text", "character varying", "varchar", "character", "char"}:
|
|
322
|
+
return types.Text()
|
|
323
|
+
return types.NullType()
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-hawdb
|
|
3
|
+
Version: 0.22.0
|
|
4
|
+
Summary: SQLAlchemy dialect for the embedded HawDB database
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
|
+
Classifier: Topic :: Database
|
|
12
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: SQLAlchemy<3.0,>=2.0
|
|
16
|
+
Requires-Dist: hawdb<0.23.0,>=0.22.0
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
19
|
+
Requires-Dist: ruff<1,>=0.14; extra == "test"
|
|
20
|
+
|
|
21
|
+
# SQLAlchemy HawDB
|
|
22
|
+
|
|
23
|
+
`sqlalchemy-hawdb` provides a SQLAlchemy 2.x dialect for the embedded HawDB
|
|
24
|
+
database. It uses HawDB's own PEP 249 driver and does not add a wire protocol or
|
|
25
|
+
duplicate its DBAPI implementation.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Python 3.11 or newer
|
|
30
|
+
- HawDB Python binding 0.22.x (CI tests 0.22.0)
|
|
31
|
+
- SQLAlchemy 2.x
|
|
32
|
+
|
|
33
|
+
Install the dialect and its HawDB dependency from PyPI:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
python -m pip install sqlalchemy-hawdb
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
To install this checkout instead:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
python -m pip install /path/to/sqlalchemy_hawdb
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Connect
|
|
46
|
+
|
|
47
|
+
Use an absolute database directory path with four slashes:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine
|
|
51
|
+
|
|
52
|
+
engine = create_engine("hawdb:////var/lib/my-app/knowledge")
|
|
53
|
+
metadata = MetaData()
|
|
54
|
+
records = Table(
|
|
55
|
+
"records",
|
|
56
|
+
metadata,
|
|
57
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
58
|
+
Column("body", String(500), nullable=False),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
metadata.create_all(engine)
|
|
62
|
+
with engine.begin() as connection:
|
|
63
|
+
connection.execute(records.insert(), {"id": 1, "body": "Hello HawDB"})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
HawDB creates the database directory itself; its parent directory must already
|
|
67
|
+
exist.
|
|
68
|
+
|
|
69
|
+
Use `hawdb:///:memory:` for an in-memory database. Like SQLAlchemy's SQLite
|
|
70
|
+
dialect, the in-memory engine keeps one database per thread. Add
|
|
71
|
+
`?read_only=true` to open an existing database read-only.
|
|
72
|
+
|
|
73
|
+
HawDB connections are thread-affine and hold their database path until closed.
|
|
74
|
+
For file databases the dialect uses SQLAlchemy's `NullPool`: each SQLAlchemy
|
|
75
|
+
connection closes its HawDB handle when returned. Do not keep multiple
|
|
76
|
+
connections to the same database path open at once or pass a SQLAlchemy
|
|
77
|
+
connection between threads. In-memory connections are kept per thread and must
|
|
78
|
+
also be used serially within that thread.
|
|
79
|
+
|
|
80
|
+
## Supported behavior
|
|
81
|
+
|
|
82
|
+
- SQLAlchemy Core and ORM statements using HawDB's relational SQL subset
|
|
83
|
+
- Positional `$1`, `$2`, ... binding through HawDB's native DBAPI driver
|
|
84
|
+
- Explicit SQLAlchemy transactions mapped to HawDB commit and rollback
|
|
85
|
+
- Table, column, primary key, unique constraint, and index reflection from
|
|
86
|
+
HawDB's `information_schema` and `pg_catalog` views
|
|
87
|
+
- The default `public` schema
|
|
88
|
+
- HawDB-native scalar DDL types: integer types map to `BIGINT`, floating and
|
|
89
|
+
numeric types to `DOUBLE PRECISION`, strings to `TEXT`, and binary values to
|
|
90
|
+
`BYTEA`; SQLAlchemy string lengths and numeric precision are not enforced by
|
|
91
|
+
HawDB
|
|
92
|
+
|
|
93
|
+
HawDB does not report affected row counts, so DBAPI `rowcount` is `-1` for
|
|
94
|
+
statements without a result set. Date and time types compile to `TEXT`; their
|
|
95
|
+
values are encoded and decoded by SQLAlchemy's standard processors.
|
|
96
|
+
Auto-generated identity columns, sequences, schema alteration, savepoints, and
|
|
97
|
+
concurrent connections to one database path are not supported by this initial
|
|
98
|
+
dialect.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
Install the project, the HawDB dependency, and the test tools:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
python -m pip install -e '.[test]'
|
|
106
|
+
python -m pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
GitHub Actions builds the source distribution and wheel, checks their metadata,
|
|
110
|
+
and tests the installed wheel against `hawdb==0.22.0` from PyPI on Python
|
|
111
|
+
3.11-3.13 for every push and pull request. Missing HawDB wheels fail CI rather
|
|
112
|
+
than skipping integration tests or falling back to a source build.
|
|
113
|
+
|
|
114
|
+
## Publishing
|
|
115
|
+
|
|
116
|
+
`.github/workflows/pypi.yaml` publishes the tested source distribution and wheel
|
|
117
|
+
when a GitHub Release is published. The release tag must match the version in
|
|
118
|
+
`pyproject.toml`, for example `v0.22.0`. A manual workflow dispatch runs the same
|
|
119
|
+
build and tests without uploading to PyPI.
|
|
120
|
+
|
|
121
|
+
Configure a PyPI Trusted Publisher for the `sqlalchemy-hawdb` project with:
|
|
122
|
+
|
|
123
|
+
- Owner: `nowledge-co`
|
|
124
|
+
- Repository: `sqlalchemy-hawdb`
|
|
125
|
+
- Workflow: `pypi.yaml`
|
|
126
|
+
- Environment: `pypi`
|
|
127
|
+
|
|
128
|
+
For the first release, configure a pending publisher in your PyPI account.
|
|
129
|
+
The publish job uses GitHub's `pypi` environment and OIDC; no PyPI API token
|
|
130
|
+
secret is required.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
sqlalchemy_hawdb/__init__.py
|
|
4
|
+
sqlalchemy_hawdb/dialect.py
|
|
5
|
+
sqlalchemy_hawdb.egg-info/PKG-INFO
|
|
6
|
+
sqlalchemy_hawdb.egg-info/SOURCES.txt
|
|
7
|
+
sqlalchemy_hawdb.egg-info/dependency_links.txt
|
|
8
|
+
sqlalchemy_hawdb.egg-info/entry_points.txt
|
|
9
|
+
sqlalchemy_hawdb.egg-info/requires.txt
|
|
10
|
+
sqlalchemy_hawdb.egg-info/top_level.txt
|
|
11
|
+
tests/test_dialect.py
|
|
12
|
+
tests/test_sqlalchemy_integration.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sqlalchemy_hawdb
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from sqlalchemy import (
|
|
2
|
+
BigInteger,
|
|
3
|
+
Boolean,
|
|
4
|
+
Column,
|
|
5
|
+
LargeBinary,
|
|
6
|
+
MetaData,
|
|
7
|
+
Numeric,
|
|
8
|
+
String,
|
|
9
|
+
Table,
|
|
10
|
+
column,
|
|
11
|
+
select,
|
|
12
|
+
table,
|
|
13
|
+
)
|
|
14
|
+
from sqlalchemy.engine import make_url
|
|
15
|
+
from sqlalchemy.pool import NullPool, SingletonThreadPool
|
|
16
|
+
from sqlalchemy.schema import CreateTable
|
|
17
|
+
|
|
18
|
+
from sqlalchemy_hawdb.dialect import HawDBDialect
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_dialect_compiles_positional_dollar_parameters():
|
|
22
|
+
statement = (
|
|
23
|
+
select(column("id")).select_from(table("widgets")).where(column("id") == 7)
|
|
24
|
+
)
|
|
25
|
+
compiled = statement.compile(dialect=HawDBDialect())
|
|
26
|
+
|
|
27
|
+
assert "$1" in str(compiled)
|
|
28
|
+
assert compiled.positiontup == ["id_1"]
|
|
29
|
+
assert compiled.params == {"id_1": 7}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_type_compiler_uses_hawdb_scalar_types():
|
|
33
|
+
statement = CreateTable(
|
|
34
|
+
Table(
|
|
35
|
+
"values_table",
|
|
36
|
+
MetaData(),
|
|
37
|
+
Column("id", BigInteger, primary_key=True),
|
|
38
|
+
Column("enabled", Boolean),
|
|
39
|
+
Column("amount", Numeric(12, 3)),
|
|
40
|
+
Column("label", String(40)),
|
|
41
|
+
Column("payload", LargeBinary),
|
|
42
|
+
)
|
|
43
|
+
).compile(dialect=HawDBDialect())
|
|
44
|
+
|
|
45
|
+
sql = str(statement)
|
|
46
|
+
assert "id BIGINT" in sql
|
|
47
|
+
assert "enabled BOOLEAN" in sql
|
|
48
|
+
assert "amount DOUBLE PRECISION" in sql
|
|
49
|
+
assert "label TEXT" in sql
|
|
50
|
+
assert "payload BYTEA" in sql
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_url_uses_path_and_read_only_option():
|
|
54
|
+
dialect = HawDBDialect()
|
|
55
|
+
args, kwargs = dialect.create_connect_args(
|
|
56
|
+
make_url("hawdb:////tmp/example.hawdb?read_only=true")
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert args == ["/tmp/example.hawdb"]
|
|
60
|
+
assert kwargs == {"read_only": True, "autocommit": False}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_memory_url_opens_an_ephemeral_database():
|
|
64
|
+
args, kwargs = HawDBDialect().create_connect_args(make_url("hawdb:///:memory:"))
|
|
65
|
+
|
|
66
|
+
assert args == [None]
|
|
67
|
+
assert kwargs == {"read_only": False, "autocommit": False}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_memory_databases_are_pooled_per_thread_but_file_handles_are_not_pooled():
|
|
71
|
+
memory_url = make_url("hawdb:///:memory:")
|
|
72
|
+
file_url = make_url("hawdb:////tmp/example.hawdb")
|
|
73
|
+
|
|
74
|
+
assert HawDBDialect.get_pool_class(memory_url) is SingletonThreadPool
|
|
75
|
+
assert HawDBDialect.get_pool_class(file_url) is NullPool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_dialect_uses_hawdb_native_dbapi():
|
|
79
|
+
dbapi = HawDBDialect.import_dbapi()
|
|
80
|
+
|
|
81
|
+
assert dbapi.paramstyle == "numeric_dollar"
|
|
82
|
+
assert dbapi.threadsafety == 1
|
|
83
|
+
assert callable(dbapi.connect)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_primary_key_index_parser_handles_quoted_commas_and_escaped_quotes():
|
|
87
|
+
definition = 'CREATE INDEX x ON public.t USING btree ("a,b", "a""b", "p(q)")'
|
|
88
|
+
|
|
89
|
+
assert HawDBDialect._index_columns(definition) == ["a,b", 'a"b', "p(q)"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from sqlalchemy import (
|
|
2
|
+
Column,
|
|
3
|
+
Integer,
|
|
4
|
+
MetaData,
|
|
5
|
+
String,
|
|
6
|
+
Table,
|
|
7
|
+
UniqueConstraint,
|
|
8
|
+
create_engine,
|
|
9
|
+
inspect,
|
|
10
|
+
select,
|
|
11
|
+
)
|
|
12
|
+
from sqlalchemy.engine import URL
|
|
13
|
+
from sqlalchemy.orm import Session, declarative_base
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_engine(path):
|
|
17
|
+
return create_engine(URL.create("hawdb", database=str(path)))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_core_create_insert_select_and_reflection(tmp_path):
|
|
21
|
+
engine = make_engine(tmp_path / "core.hawdb")
|
|
22
|
+
metadata = MetaData()
|
|
23
|
+
widgets = Table(
|
|
24
|
+
"widgets",
|
|
25
|
+
metadata,
|
|
26
|
+
Column("id", Integer, primary_key=True, autoincrement=False),
|
|
27
|
+
Column("name", String(80), nullable=False),
|
|
28
|
+
UniqueConstraint("name"),
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
metadata.create_all(engine)
|
|
32
|
+
with engine.begin() as connection:
|
|
33
|
+
connection.execute(
|
|
34
|
+
widgets.insert(),
|
|
35
|
+
[{"id": 1, "name": "first"}, {"id": 2, "name": "second"}],
|
|
36
|
+
)
|
|
37
|
+
with engine.connect() as connection:
|
|
38
|
+
rows = connection.execute(
|
|
39
|
+
select(widgets.c.id, widgets.c.name).order_by(widgets.c.id)
|
|
40
|
+
).all()
|
|
41
|
+
reordered = connection.execute(
|
|
42
|
+
select(widgets.c.name, widgets.c.id).order_by(widgets.c.id)
|
|
43
|
+
)
|
|
44
|
+
assert list(reordered.keys()) == ["name", "id"]
|
|
45
|
+
assert reordered.all() == [("first", 1), ("second", 2)]
|
|
46
|
+
|
|
47
|
+
empty = connection.execute(
|
|
48
|
+
select(widgets.c.name, widgets.c.id).where(widgets.c.id < 0)
|
|
49
|
+
)
|
|
50
|
+
assert list(empty.keys()) == ["name", "id"]
|
|
51
|
+
assert empty.all() == []
|
|
52
|
+
assert rows == [(1, "first"), (2, "second")]
|
|
53
|
+
|
|
54
|
+
inspector = inspect(engine)
|
|
55
|
+
assert "widgets" in inspector.get_table_names()
|
|
56
|
+
assert [column["name"] for column in inspector.get_columns("widgets")] == [
|
|
57
|
+
"id",
|
|
58
|
+
"name",
|
|
59
|
+
]
|
|
60
|
+
assert inspector.get_pk_constraint("widgets")["constrained_columns"] == ["id"]
|
|
61
|
+
assert [
|
|
62
|
+
constraint["column_names"]
|
|
63
|
+
for constraint in inspector.get_unique_constraints("widgets")
|
|
64
|
+
] == [["name"]]
|
|
65
|
+
finally:
|
|
66
|
+
engine.dispose()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_transaction_rollback(tmp_path):
|
|
70
|
+
engine = make_engine(tmp_path / "rollback.hawdb")
|
|
71
|
+
try:
|
|
72
|
+
with engine.begin() as connection:
|
|
73
|
+
connection.exec_driver_sql(
|
|
74
|
+
"CREATE TABLE events (id BIGINT PRIMARY KEY, name TEXT)"
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
with engine.begin() as connection:
|
|
78
|
+
connection.exec_driver_sql(
|
|
79
|
+
"INSERT INTO events (id, name) VALUES ($1, $2)",
|
|
80
|
+
(1, "rolled back"),
|
|
81
|
+
)
|
|
82
|
+
raise RuntimeError("force rollback")
|
|
83
|
+
except RuntimeError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
with engine.connect() as connection:
|
|
87
|
+
assert connection.exec_driver_sql("SELECT id FROM events").all() == []
|
|
88
|
+
finally:
|
|
89
|
+
engine.dispose()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_orm_session_round_trip(tmp_path):
|
|
93
|
+
engine = make_engine(tmp_path / "orm.hawdb")
|
|
94
|
+
base = declarative_base()
|
|
95
|
+
|
|
96
|
+
class Entry(base):
|
|
97
|
+
__tablename__ = "entries"
|
|
98
|
+
|
|
99
|
+
id = Column(Integer, primary_key=True, autoincrement=False)
|
|
100
|
+
value = Column(String(80), nullable=False)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
base.metadata.create_all(engine)
|
|
104
|
+
with Session(engine) as session:
|
|
105
|
+
session.add(Entry(id=1, value="orm"))
|
|
106
|
+
session.commit()
|
|
107
|
+
assert session.scalars(select(Entry.value)).all() == ["orm"]
|
|
108
|
+
finally:
|
|
109
|
+
engine.dispose()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_in_memory_database_survives_connection_checkin():
|
|
113
|
+
engine = create_engine("hawdb:///:memory:")
|
|
114
|
+
try:
|
|
115
|
+
with engine.begin() as connection:
|
|
116
|
+
connection.exec_driver_sql("CREATE TABLE scratch (id BIGINT PRIMARY KEY)")
|
|
117
|
+
connection.exec_driver_sql("INSERT INTO scratch (id) VALUES ($1)", (9,))
|
|
118
|
+
with engine.connect() as connection:
|
|
119
|
+
assert (
|
|
120
|
+
connection.exec_driver_sql("SELECT id FROM scratch").scalar_one() == 9
|
|
121
|
+
)
|
|
122
|
+
finally:
|
|
123
|
+
engine.dispose()
|