tigrbl_engine_rom 0.4.5__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.
- tigrbl_engine_rom-0.4.5/.gitignore +62 -0
- tigrbl_engine_rom-0.4.5/LICENSE +17 -0
- tigrbl_engine_rom-0.4.5/NOTICE +7 -0
- tigrbl_engine_rom-0.4.5/PKG-INFO +167 -0
- tigrbl_engine_rom-0.4.5/README.md +122 -0
- tigrbl_engine_rom-0.4.5/pyproject.toml +47 -0
- tigrbl_engine_rom-0.4.5/src/tigrbl_engine_rom/__init__.py +18 -0
- tigrbl_engine_rom-0.4.5/src/tigrbl_engine_rom/engine.py +105 -0
- tigrbl_engine_rom-0.4.5/src/tigrbl_engine_rom/plugin.py +54 -0
- tigrbl_engine_rom-0.4.5/src/tigrbl_engine_rom/session.py +227 -0
- tigrbl_engine_rom-0.4.5/tests/test_rom_engine.py +229 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Root policy: ignore every new root-level entry unless explicitly allowed.
|
|
2
|
+
/*
|
|
3
|
+
|
|
4
|
+
# Root entries intentionally allowed by repository policy.
|
|
5
|
+
!/.codex/
|
|
6
|
+
!/.github/
|
|
7
|
+
!/.gitignore
|
|
8
|
+
!/.ssot/
|
|
9
|
+
!/CODE_OF_CONDUCT.md
|
|
10
|
+
!/CONTRIBUTING.md
|
|
11
|
+
!/LICENSE
|
|
12
|
+
!/NOTICE
|
|
13
|
+
!/README.md
|
|
14
|
+
!/SECURITY.md
|
|
15
|
+
!/assets/
|
|
16
|
+
!/bindings/
|
|
17
|
+
!/docs/
|
|
18
|
+
!/examples/
|
|
19
|
+
!/pkgs/
|
|
20
|
+
!/pyproject.toml
|
|
21
|
+
!/tests/
|
|
22
|
+
!/tools/
|
|
23
|
+
!/uv.lock
|
|
24
|
+
|
|
25
|
+
# Generated caches and build artifacts.
|
|
26
|
+
__pycache__/
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.ruff_cache/
|
|
29
|
+
.mypy_cache/
|
|
30
|
+
.tox/
|
|
31
|
+
.nox/
|
|
32
|
+
.venv/
|
|
33
|
+
.coverage
|
|
34
|
+
htmlcov/
|
|
35
|
+
build/
|
|
36
|
+
dist/
|
|
37
|
+
site/
|
|
38
|
+
target/
|
|
39
|
+
*.egg-info/
|
|
40
|
+
*.pyc
|
|
41
|
+
*.pyd
|
|
42
|
+
*.tmp*
|
|
43
|
+
*.zip
|
|
44
|
+
*.body
|
|
45
|
+
perf.sqlite
|
|
46
|
+
node_modules/
|
|
47
|
+
|
|
48
|
+
# Root-local generated caches and temporary package artifacts.
|
|
49
|
+
/.benchmarks/
|
|
50
|
+
/.pip-cache/
|
|
51
|
+
/.tmp
|
|
52
|
+
/.tmp/
|
|
53
|
+
/.tmp*/
|
|
54
|
+
/.uv-cache*/
|
|
55
|
+
/.uv-pytest*/
|
|
56
|
+
/.uv-pytest-tigrbl-tests/
|
|
57
|
+
/.uv-python*/
|
|
58
|
+
/.vendor
|
|
59
|
+
/.venv*/
|
|
60
|
+
/.venv-gitvs*/
|
|
61
|
+
/generation_examples/
|
|
62
|
+
/tmp/
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
4
|
+
|
|
5
|
+
Copyright 2026 Swarmauri
|
|
6
|
+
|
|
7
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
8
|
+
you may not use this file except in compliance with the License.
|
|
9
|
+
You may obtain a copy of the License at
|
|
10
|
+
|
|
11
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Unless required by applicable law or agreed to in writing, software
|
|
14
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
15
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
16
|
+
See the License for the specific language governing permissions and
|
|
17
|
+
limitations under the License.
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tigrbl_engine_rom
|
|
3
|
+
Version: 0.4.5
|
|
4
|
+
Summary: Immutable read-only memory engine plugin for Tigrbl.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tigrbl/tigrbl
|
|
6
|
+
Project-URL: Repository, https://github.com/tigrbl/tigrbl/tree/master/pkgs/90_engines/tigrbl_engine_rom
|
|
7
|
+
Project-URL: Issues, https://github.com/tigrbl/tigrbl/issues
|
|
8
|
+
Author-email: Jacob Stewart <jacob@swarmauri.com>
|
|
9
|
+
License: Apache License
|
|
10
|
+
Version 2.0, January 2004
|
|
11
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
12
|
+
|
|
13
|
+
Copyright 2026 Swarmauri
|
|
14
|
+
|
|
15
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
16
|
+
you may not use this file except in compliance with the License.
|
|
17
|
+
You may obtain a copy of the License at
|
|
18
|
+
|
|
19
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
20
|
+
|
|
21
|
+
Unless required by applicable law or agreed to in writing, software
|
|
22
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
23
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
24
|
+
See the License for the specific language governing permissions and
|
|
25
|
+
limitations under the License.
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
License-File: NOTICE
|
|
28
|
+
Keywords: engine,immutable,memory,plugin,readonly,tigrbl
|
|
29
|
+
Classifier: Development Status :: 3 - Alpha
|
|
30
|
+
Classifier: Framework :: AsyncIO
|
|
31
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
32
|
+
Classifier: Operating System :: OS Independent
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
40
|
+
Classifier: Topic :: Database
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Requires-Python: <3.15,>=3.10
|
|
43
|
+
Requires-Dist: tigrbl>=0.3.0.dev4
|
|
44
|
+
Description-Content-Type: text/markdown
|
|
45
|
+
|
|
46
|
+
<div align="center">
|
|
47
|
+
<h1>tigrbl_engine_rom</h1>
|
|
48
|
+
<p><strong>Immutable read-only memory engine plugin for Tigrbl.</strong></p>
|
|
49
|
+
<a href="https://pypi.org/project/tigrbl_engine_rom/"><img src="https://img.shields.io/pypi/v/tigrbl_engine_rom?label=PyPI" alt="PyPI version"/></a>
|
|
50
|
+
<a href="https://discord.gg/K4YTAPapjR"><img src="https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
|
|
51
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-525252" alt="Apache 2.0 license"/></a>
|
|
52
|
+
<a href="pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab" alt="Python 3.10 | 3.11 | 3.12 | 3.13 | 3.14"/></a>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
## What is tigrbl_engine_rom?
|
|
56
|
+
|
|
57
|
+
`tigrbl_engine_rom` loads one mapping-backed row image and serves it through Tigrbl's engine-session contract. The engine configuration is bound as an input to a table.
|
|
58
|
+
|
|
59
|
+
## Why use tigrbl_engine_rom?
|
|
60
|
+
|
|
61
|
+
Use it for embedded catalogs, fixtures, policy data, reference tables, and other datasets that must never change at runtime.
|
|
62
|
+
|
|
63
|
+
## When should I install tigrbl_engine_rom?
|
|
64
|
+
|
|
65
|
+
Install it when your application needs fast reads from a fixed memory image and should fail closed on every write.
|
|
66
|
+
|
|
67
|
+
## Who is tigrbl_engine_rom for?
|
|
68
|
+
|
|
69
|
+
It is for application developers shipping trusted reference data or deterministic read-only test images.
|
|
70
|
+
|
|
71
|
+
## Where does tigrbl_engine_rom fit?
|
|
72
|
+
|
|
73
|
+
It is a standalone package under `pkgs/90_engines` and is discovered through the `tigrbl.engine` entry-point group as kind `rom`.
|
|
74
|
+
|
|
75
|
+
## How does tigrbl_engine_rom work?
|
|
76
|
+
|
|
77
|
+
`ROMEngine` subclasses Tigrbl's `EngineBase`. Construction validates the bound table's rows and primary key, deeply detaches the source data, and freezes it. Reads always return caller-owned copies. Sessions force `read_only=True`, even if a caller requests otherwise.
|
|
78
|
+
|
|
79
|
+
## Install
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
uv add tigrbl_engine_rom
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Surface Coverage
|
|
86
|
+
|
|
87
|
+
| Surface | Value |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Engine kind | `rom` |
|
|
90
|
+
| Python import | `tigrbl_engine_rom` |
|
|
91
|
+
| Entry point | `tigrbl.engine` |
|
|
92
|
+
| Persistence | process-local immutable memory |
|
|
93
|
+
| Supported Python | `3.10 | 3.11 | 3.12 | 3.13 | 3.14` |
|
|
94
|
+
|
|
95
|
+
## What It Owns
|
|
96
|
+
|
|
97
|
+
The package owns the ROM image, read-only session, plugin registration, and scalar result facade.
|
|
98
|
+
|
|
99
|
+
## Public API and Import Surface
|
|
100
|
+
|
|
101
|
+
Public symbols are `ROMEngine`, `ROMSession`, `ROMResult`, `build_rom`, `capabilities`, and `register`.
|
|
102
|
+
|
|
103
|
+
## Usage Examples
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from tigrbl_engine_rom import build_rom
|
|
107
|
+
|
|
108
|
+
engine, sessionmaker = build_rom(mapping={
|
|
109
|
+
"rows": [
|
|
110
|
+
{"code": "US", "name": "United States"},
|
|
111
|
+
{"code": "CA", "name": "Canada"},
|
|
112
|
+
],
|
|
113
|
+
"primary_key": "code",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
session = sessionmaker()
|
|
117
|
+
assert session.get_row("CA")["name"] == "Canada"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Bind the engine configuration into a table:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from tigrbl.factories.table import defineTableSpec
|
|
124
|
+
|
|
125
|
+
class CountrySpec(defineTableSpec(engine={
|
|
126
|
+
"kind": "rom",
|
|
127
|
+
"rows": [
|
|
128
|
+
{"code": "US", "name": "United States"},
|
|
129
|
+
{"code": "CA", "name": "Canada"},
|
|
130
|
+
],
|
|
131
|
+
"primary_key": "code",
|
|
132
|
+
})):
|
|
133
|
+
pass
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## How To Choose This Package
|
|
137
|
+
|
|
138
|
+
Choose ROM for immutable embedded data. Choose `tigrbl_engine_inmemory` when transactions and runtime writes are required.
|
|
139
|
+
|
|
140
|
+
## Related Packages
|
|
141
|
+
|
|
142
|
+
- `tigrbl`
|
|
143
|
+
- `tigrbl_engine_inmemory`
|
|
144
|
+
- `tigrbl_engine_memkv`
|
|
145
|
+
|
|
146
|
+
## Documentation Links
|
|
147
|
+
|
|
148
|
+
- [Workspace docs](../../../docs/README.md)
|
|
149
|
+
- [Package catalog](../../../docs/developer/PACKAGE_CATALOG.md)
|
|
150
|
+
- [SSOT registry](../../../.ssot/registry.json)
|
|
151
|
+
|
|
152
|
+
## Support
|
|
153
|
+
|
|
154
|
+
- [Discord](https://discord.gg/K4YTAPapjR)
|
|
155
|
+
- [GitHub issues](https://github.com/tigrbl/tigrbl/issues)
|
|
156
|
+
|
|
157
|
+
## Certification Status
|
|
158
|
+
|
|
159
|
+
The package is implemented with package-local behavioral tests. Certification remains governed by the repository SSOT registry and release workflows.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
Licensed under the Apache License, Version 2.0. See `LICENSE` and `NOTICE`.
|
|
164
|
+
|
|
165
|
+
## Workspace documentation
|
|
166
|
+
|
|
167
|
+
This README is the package-local distribution entry point. See [workspace documentation](../../../docs/README.md), [current conformance target](../../../docs/conformance/CURRENT_TARGET.md), [package catalog](../../../docs/developer/PACKAGE_CATALOG.md), [package layout](../../../docs/developer/PACKAGE_LAYOUT.md), and [SSOT registry](../../../.ssot/registry.json).
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>tigrbl_engine_rom</h1>
|
|
3
|
+
<p><strong>Immutable read-only memory engine plugin for Tigrbl.</strong></p>
|
|
4
|
+
<a href="https://pypi.org/project/tigrbl_engine_rom/"><img src="https://img.shields.io/pypi/v/tigrbl_engine_rom?label=PyPI" alt="PyPI version"/></a>
|
|
5
|
+
<a href="https://discord.gg/K4YTAPapjR"><img src="https://img.shields.io/badge/Discord-Join%20chat-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
|
|
6
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-525252" alt="Apache 2.0 license"/></a>
|
|
7
|
+
<a href="pyproject.toml"><img src="https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776ab" alt="Python 3.10 | 3.11 | 3.12 | 3.13 | 3.14"/></a>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
## What is tigrbl_engine_rom?
|
|
11
|
+
|
|
12
|
+
`tigrbl_engine_rom` loads one mapping-backed row image and serves it through Tigrbl's engine-session contract. The engine configuration is bound as an input to a table.
|
|
13
|
+
|
|
14
|
+
## Why use tigrbl_engine_rom?
|
|
15
|
+
|
|
16
|
+
Use it for embedded catalogs, fixtures, policy data, reference tables, and other datasets that must never change at runtime.
|
|
17
|
+
|
|
18
|
+
## When should I install tigrbl_engine_rom?
|
|
19
|
+
|
|
20
|
+
Install it when your application needs fast reads from a fixed memory image and should fail closed on every write.
|
|
21
|
+
|
|
22
|
+
## Who is tigrbl_engine_rom for?
|
|
23
|
+
|
|
24
|
+
It is for application developers shipping trusted reference data or deterministic read-only test images.
|
|
25
|
+
|
|
26
|
+
## Where does tigrbl_engine_rom fit?
|
|
27
|
+
|
|
28
|
+
It is a standalone package under `pkgs/90_engines` and is discovered through the `tigrbl.engine` entry-point group as kind `rom`.
|
|
29
|
+
|
|
30
|
+
## How does tigrbl_engine_rom work?
|
|
31
|
+
|
|
32
|
+
`ROMEngine` subclasses Tigrbl's `EngineBase`. Construction validates the bound table's rows and primary key, deeply detaches the source data, and freezes it. Reads always return caller-owned copies. Sessions force `read_only=True`, even if a caller requests otherwise.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv add tigrbl_engine_rom
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Surface Coverage
|
|
41
|
+
|
|
42
|
+
| Surface | Value |
|
|
43
|
+
|---|---|
|
|
44
|
+
| Engine kind | `rom` |
|
|
45
|
+
| Python import | `tigrbl_engine_rom` |
|
|
46
|
+
| Entry point | `tigrbl.engine` |
|
|
47
|
+
| Persistence | process-local immutable memory |
|
|
48
|
+
| Supported Python | `3.10 | 3.11 | 3.12 | 3.13 | 3.14` |
|
|
49
|
+
|
|
50
|
+
## What It Owns
|
|
51
|
+
|
|
52
|
+
The package owns the ROM image, read-only session, plugin registration, and scalar result facade.
|
|
53
|
+
|
|
54
|
+
## Public API and Import Surface
|
|
55
|
+
|
|
56
|
+
Public symbols are `ROMEngine`, `ROMSession`, `ROMResult`, `build_rom`, `capabilities`, and `register`.
|
|
57
|
+
|
|
58
|
+
## Usage Examples
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from tigrbl_engine_rom import build_rom
|
|
62
|
+
|
|
63
|
+
engine, sessionmaker = build_rom(mapping={
|
|
64
|
+
"rows": [
|
|
65
|
+
{"code": "US", "name": "United States"},
|
|
66
|
+
{"code": "CA", "name": "Canada"},
|
|
67
|
+
],
|
|
68
|
+
"primary_key": "code",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
session = sessionmaker()
|
|
72
|
+
assert session.get_row("CA")["name"] == "Canada"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Bind the engine configuration into a table:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from tigrbl.factories.table import defineTableSpec
|
|
79
|
+
|
|
80
|
+
class CountrySpec(defineTableSpec(engine={
|
|
81
|
+
"kind": "rom",
|
|
82
|
+
"rows": [
|
|
83
|
+
{"code": "US", "name": "United States"},
|
|
84
|
+
{"code": "CA", "name": "Canada"},
|
|
85
|
+
],
|
|
86
|
+
"primary_key": "code",
|
|
87
|
+
})):
|
|
88
|
+
pass
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## How To Choose This Package
|
|
92
|
+
|
|
93
|
+
Choose ROM for immutable embedded data. Choose `tigrbl_engine_inmemory` when transactions and runtime writes are required.
|
|
94
|
+
|
|
95
|
+
## Related Packages
|
|
96
|
+
|
|
97
|
+
- `tigrbl`
|
|
98
|
+
- `tigrbl_engine_inmemory`
|
|
99
|
+
- `tigrbl_engine_memkv`
|
|
100
|
+
|
|
101
|
+
## Documentation Links
|
|
102
|
+
|
|
103
|
+
- [Workspace docs](../../../docs/README.md)
|
|
104
|
+
- [Package catalog](../../../docs/developer/PACKAGE_CATALOG.md)
|
|
105
|
+
- [SSOT registry](../../../.ssot/registry.json)
|
|
106
|
+
|
|
107
|
+
## Support
|
|
108
|
+
|
|
109
|
+
- [Discord](https://discord.gg/K4YTAPapjR)
|
|
110
|
+
- [GitHub issues](https://github.com/tigrbl/tigrbl/issues)
|
|
111
|
+
|
|
112
|
+
## Certification Status
|
|
113
|
+
|
|
114
|
+
The package is implemented with package-local behavioral tests. Certification remains governed by the repository SSOT registry and release workflows.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
Licensed under the Apache License, Version 2.0. See `LICENSE` and `NOTICE`.
|
|
119
|
+
|
|
120
|
+
## Workspace documentation
|
|
121
|
+
|
|
122
|
+
This README is the package-local distribution entry point. See [workspace documentation](../../../docs/README.md), [current conformance target](../../../docs/conformance/CURRENT_TARGET.md), [package catalog](../../../docs/developer/PACKAGE_CATALOG.md), [package layout](../../../docs/developer/PACKAGE_LAYOUT.md), and [SSOT registry](../../../.ssot/registry.json).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tigrbl_engine_rom"
|
|
7
|
+
version = "0.4.5"
|
|
8
|
+
description = "Immutable read-only memory engine plugin for Tigrbl."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
keywords = ["tigrbl", "engine", "plugin", "memory", "readonly", "immutable"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Framework :: AsyncIO",
|
|
15
|
+
"License :: OSI Approved :: Apache Software License",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Programming Language :: Python :: 3.14",
|
|
24
|
+
"Topic :: Database",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
|
|
28
|
+
requires-python = ">=3.10,<3.15"
|
|
29
|
+
dependencies = ["tigrbl>=0.3.0.dev4"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/tigrbl/tigrbl"
|
|
33
|
+
Repository = "https://github.com/tigrbl/tigrbl/tree/master/pkgs/90_engines/tigrbl_engine_rom"
|
|
34
|
+
Issues = "https://github.com/tigrbl/tigrbl/issues"
|
|
35
|
+
|
|
36
|
+
[project.entry-points."tigrbl.engine_plugins"]
|
|
37
|
+
rom = "tigrbl_engine_rom.plugin:register"
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.24.0", "ruff>=0.11.0", "sqlalchemy>=2.0"]
|
|
41
|
+
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
tigrbl = { workspace = true }
|
|
44
|
+
|
|
45
|
+
[tool.pytest.ini_options]
|
|
46
|
+
asyncio_mode = "auto"
|
|
47
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Read-only memory engine for Tigrbl."""
|
|
2
|
+
|
|
3
|
+
from .engine import ROMEngine
|
|
4
|
+
from .plugin import build_rom, capabilities, register
|
|
5
|
+
from .session import READ_ONLY_ERROR, ROMResult, ROMSession
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"READ_ONLY_ERROR",
|
|
9
|
+
"ROMEngine",
|
|
10
|
+
"ROMResult",
|
|
11
|
+
"ROMSession",
|
|
12
|
+
"build_rom",
|
|
13
|
+
"capabilities",
|
|
14
|
+
"register",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
__version__ = "0.4.5.dev4"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, Iterable, Mapping
|
|
6
|
+
|
|
7
|
+
from tigrbl_base._base import EngineBase
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _freeze(value: Any) -> Any:
|
|
11
|
+
"""Return an immutable, detached representation of configuration data."""
|
|
12
|
+
if isinstance(value, Mapping):
|
|
13
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
14
|
+
if isinstance(value, (list, tuple)):
|
|
15
|
+
return tuple(_freeze(item) for item in value)
|
|
16
|
+
if isinstance(value, (set, frozenset)):
|
|
17
|
+
return frozenset(_freeze(item) for item in value)
|
|
18
|
+
return deepcopy(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _thaw(value: Any) -> Any:
|
|
22
|
+
"""Return a caller-owned copy of a frozen value."""
|
|
23
|
+
if isinstance(value, Mapping):
|
|
24
|
+
return {key: _thaw(item) for key, item in value.items()}
|
|
25
|
+
if isinstance(value, tuple):
|
|
26
|
+
return [_thaw(item) for item in value]
|
|
27
|
+
if isinstance(value, frozenset):
|
|
28
|
+
return {_thaw(item) for item in value}
|
|
29
|
+
return deepcopy(value)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ROMEngine(EngineBase):
|
|
33
|
+
"""Process-local immutable row image bound as an input to one table."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
rows: Iterable[Mapping[str, Any]] | None = None,
|
|
38
|
+
*,
|
|
39
|
+
primary_key: str = "id",
|
|
40
|
+
namespace: str = "default",
|
|
41
|
+
spec: Any = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not isinstance(namespace, str) or not namespace:
|
|
44
|
+
raise ValueError("namespace must be a non-empty string")
|
|
45
|
+
if not isinstance(primary_key, str) or not primary_key:
|
|
46
|
+
raise ValueError("primary_key must be a non-empty string")
|
|
47
|
+
self.namespace = namespace
|
|
48
|
+
self.spec = spec
|
|
49
|
+
self.primary_key = primary_key
|
|
50
|
+
frozen_rows: list[Mapping[str, Any]] = []
|
|
51
|
+
index: dict[Any, Mapping[str, Any]] = {}
|
|
52
|
+
for position, source_row in enumerate(rows or ()):
|
|
53
|
+
if not isinstance(source_row, Mapping):
|
|
54
|
+
raise TypeError(f"row {position} must be a mapping")
|
|
55
|
+
if primary_key not in source_row:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"row {position} is missing primary key {primary_key!r}"
|
|
58
|
+
)
|
|
59
|
+
key = deepcopy(source_row[primary_key])
|
|
60
|
+
try:
|
|
61
|
+
duplicate = key in index
|
|
62
|
+
except TypeError as exc:
|
|
63
|
+
raise TypeError(
|
|
64
|
+
f"primary key {primary_key!r} must be hashable"
|
|
65
|
+
) from exc
|
|
66
|
+
if duplicate:
|
|
67
|
+
raise ValueError(f"duplicate primary key {key!r}")
|
|
68
|
+
frozen_row = _freeze(source_row)
|
|
69
|
+
frozen_rows.append(frozen_row)
|
|
70
|
+
index[key] = frozen_row
|
|
71
|
+
|
|
72
|
+
self._rows = tuple(frozen_rows)
|
|
73
|
+
self._index = MappingProxyType(index)
|
|
74
|
+
|
|
75
|
+
def to_provider(self) -> "ROMEngine":
|
|
76
|
+
"""Expose this immutable engine as its own provider-like object."""
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def _executeloop_impl(self, statements: Any) -> Any:
|
|
80
|
+
from .session import ROMSession
|
|
81
|
+
|
|
82
|
+
session = ROMSession(self)
|
|
83
|
+
try:
|
|
84
|
+
return await session.executeloop(statements)
|
|
85
|
+
finally:
|
|
86
|
+
await session.close()
|
|
87
|
+
|
|
88
|
+
async def _executemany_impl(self, stmt: Any, parameter_sets: Any) -> Any:
|
|
89
|
+
from .session import ROMSession
|
|
90
|
+
|
|
91
|
+
session = ROMSession(self)
|
|
92
|
+
try:
|
|
93
|
+
return await session.executemany(stmt, parameter_sets)
|
|
94
|
+
finally:
|
|
95
|
+
await session.close()
|
|
96
|
+
|
|
97
|
+
def get(self, ident: Any) -> dict[str, Any] | None:
|
|
98
|
+
row = self._index.get(ident)
|
|
99
|
+
return None if row is None else _thaw(row)
|
|
100
|
+
|
|
101
|
+
def scan(self) -> list[dict[str, Any]]:
|
|
102
|
+
return [_thaw(row) for row in self._rows]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["ROMEngine"]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tigrbl.engine.registry import register_engine
|
|
6
|
+
|
|
7
|
+
from .engine import ROMEngine
|
|
8
|
+
from .session import ROMSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def capabilities(*, spec: Any = None, mapping: Any = None) -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
"engine": "rom",
|
|
14
|
+
"transactional": False,
|
|
15
|
+
"async_native": True,
|
|
16
|
+
"isolation_levels": set(),
|
|
17
|
+
"read_only_enforced": True,
|
|
18
|
+
"persistence": "process",
|
|
19
|
+
"storage": "immutable-memory",
|
|
20
|
+
"read_consistency": "immutable-snapshot",
|
|
21
|
+
"binding_scopes": {"table"},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_rom(*, mapping=None, spec=None, dsn=None, **_: Any) -> tuple[object, object]:
|
|
26
|
+
if dsn:
|
|
27
|
+
raise ValueError("ROM engine accepts embedded mapping data, not a DSN")
|
|
28
|
+
config = dict(mapping or {})
|
|
29
|
+
engine = ROMEngine(
|
|
30
|
+
rows=config.get("rows"),
|
|
31
|
+
primary_key=str(config.get("primary_key", "id")),
|
|
32
|
+
namespace=str(config.get("namespace", "default")),
|
|
33
|
+
spec=spec,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def sessionmaker(session_spec=None) -> ROMSession:
|
|
37
|
+
return ROMSession(engine, spec=session_spec)
|
|
38
|
+
|
|
39
|
+
return engine, sessionmaker
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Registration:
|
|
43
|
+
def build(self, *, mapping, spec, dsn):
|
|
44
|
+
return build_rom(mapping=mapping, spec=spec, dsn=dsn)
|
|
45
|
+
|
|
46
|
+
def capabilities(self, *, spec, mapping=None):
|
|
47
|
+
return capabilities(spec=spec, mapping=mapping)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def register() -> None:
|
|
51
|
+
register_engine("rom", _Registration())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = ["build_rom", "capabilities", "register"]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from tigrbl_base._base import EngineSessionBase
|
|
8
|
+
from tigrbl_core._spec.engine_session_spec import EngineSessionSpec
|
|
9
|
+
|
|
10
|
+
from .engine import ROMEngine
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
READ_ONLY_ERROR = "write attempted against read-only memory engine"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ROMResult:
|
|
17
|
+
"""Small result facade compatible with Tigrbl's scalar query helpers."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, items: Sequence[Any]) -> None:
|
|
20
|
+
self._items = tuple(items)
|
|
21
|
+
self.rowcount = len(self._items)
|
|
22
|
+
|
|
23
|
+
def scalars(self) -> "ROMResult":
|
|
24
|
+
return self
|
|
25
|
+
|
|
26
|
+
def all(self) -> list[Any]:
|
|
27
|
+
return list(self._items)
|
|
28
|
+
|
|
29
|
+
def first(self) -> Any | None:
|
|
30
|
+
return self._items[0] if self._items else None
|
|
31
|
+
|
|
32
|
+
def scalar_one(self) -> Any:
|
|
33
|
+
if len(self._items) != 1:
|
|
34
|
+
raise RuntimeError(f"expected exactly one row, found {len(self._items)}")
|
|
35
|
+
return self._items[0]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ROMSession(EngineSessionBase):
|
|
39
|
+
"""Async Tigrbl session over an immutable :class:`ROMEngine` snapshot."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self, engine: ROMEngine, spec: EngineSessionSpec | None = None
|
|
43
|
+
) -> None:
|
|
44
|
+
enforced = replace(spec or EngineSessionSpec(), read_only=True)
|
|
45
|
+
super().__init__(enforced)
|
|
46
|
+
self._engine = engine
|
|
47
|
+
self._closed = False
|
|
48
|
+
self._tracked: dict[int, tuple[Any, dict[str, Any]]] = {}
|
|
49
|
+
|
|
50
|
+
def apply_spec(self, spec: EngineSessionSpec | None) -> None:
|
|
51
|
+
enforced = replace(spec or EngineSessionSpec(), read_only=True)
|
|
52
|
+
super().apply_spec(enforced)
|
|
53
|
+
|
|
54
|
+
def get_row(self, ident: Any) -> dict[str, Any] | None:
|
|
55
|
+
self._require_open_session()
|
|
56
|
+
return self._engine.get(ident)
|
|
57
|
+
|
|
58
|
+
def query_rows(
|
|
59
|
+
self,
|
|
60
|
+
predicate: Callable[[Mapping[str, Any]], bool] | None = None,
|
|
61
|
+
) -> list[dict[str, Any]]:
|
|
62
|
+
self._require_open_session()
|
|
63
|
+
rows = self._engine.scan()
|
|
64
|
+
if predicate is not None:
|
|
65
|
+
rows = [row for row in rows if predicate(row)]
|
|
66
|
+
return rows
|
|
67
|
+
|
|
68
|
+
async def _tx_begin_impl(self) -> None:
|
|
69
|
+
self._require_open_session()
|
|
70
|
+
|
|
71
|
+
async def _tx_commit_impl(self) -> None:
|
|
72
|
+
self._require_open_session()
|
|
73
|
+
|
|
74
|
+
async def _tx_rollback_impl(self) -> None:
|
|
75
|
+
self._require_open_session()
|
|
76
|
+
for obj, snapshot in self._tracked.values():
|
|
77
|
+
for name, value in snapshot.items():
|
|
78
|
+
setattr(obj, name, deepcopy(value))
|
|
79
|
+
|
|
80
|
+
def _add_impl(self, obj: Any) -> None:
|
|
81
|
+
raise RuntimeError(READ_ONLY_ERROR)
|
|
82
|
+
|
|
83
|
+
async def _delete_impl(self, obj: Any) -> None:
|
|
84
|
+
raise RuntimeError(READ_ONLY_ERROR)
|
|
85
|
+
|
|
86
|
+
async def _flush_impl(self) -> None:
|
|
87
|
+
self._require_open_session()
|
|
88
|
+
for obj, snapshot in self._tracked.values():
|
|
89
|
+
if self._snapshot(obj) != snapshot:
|
|
90
|
+
raise RuntimeError(READ_ONLY_ERROR)
|
|
91
|
+
|
|
92
|
+
async def _refresh_impl(self, obj: Any) -> None:
|
|
93
|
+
self._require_open_session()
|
|
94
|
+
row = self._engine.get(getattr(obj, self._engine.primary_key))
|
|
95
|
+
if row is None:
|
|
96
|
+
return
|
|
97
|
+
for name, value in row.items():
|
|
98
|
+
setattr(obj, name, value)
|
|
99
|
+
self._track(obj)
|
|
100
|
+
|
|
101
|
+
async def _get_impl(self, model: type, ident: Any) -> Any | None:
|
|
102
|
+
self._require_open_session()
|
|
103
|
+
row = self._engine.get(ident)
|
|
104
|
+
return None if row is None else self._inflate(model, row)
|
|
105
|
+
|
|
106
|
+
async def _execute_impl(self, stmt: Any) -> ROMResult:
|
|
107
|
+
self._require_open_session()
|
|
108
|
+
if type(stmt).__name__.lower() != "select":
|
|
109
|
+
raise RuntimeError(READ_ONLY_ERROR)
|
|
110
|
+
model = self._extract_model(stmt)
|
|
111
|
+
items = [self._inflate(model, row) for row in self._engine.scan()]
|
|
112
|
+
for name, operator, value in self._extract_predicates(stmt):
|
|
113
|
+
if operator == "eq":
|
|
114
|
+
items = [item for item in items if getattr(item, name, None) == value]
|
|
115
|
+
elif operator == "in":
|
|
116
|
+
accepted = set(value)
|
|
117
|
+
items = [
|
|
118
|
+
item for item in items if getattr(item, name, None) in accepted
|
|
119
|
+
]
|
|
120
|
+
else: # pragma: no cover - protected by extraction
|
|
121
|
+
raise NotImplementedError(f"unsupported ROM predicate: {operator}")
|
|
122
|
+
for name, descending in reversed(self._extract_ordering(stmt)):
|
|
123
|
+
items.sort(key=lambda item: getattr(item, name, None), reverse=descending)
|
|
124
|
+
offset = self._clause_int(getattr(stmt, "_offset_clause", None)) or 0
|
|
125
|
+
limit = self._clause_int(getattr(stmt, "_limit_clause", None))
|
|
126
|
+
items = items[max(0, offset) :]
|
|
127
|
+
if limit is not None:
|
|
128
|
+
items = items[: max(0, limit)]
|
|
129
|
+
return ROMResult(items)
|
|
130
|
+
|
|
131
|
+
async def _executeloop_impl(self, statements: Iterable[Any]) -> list[ROMResult]:
|
|
132
|
+
return [await self._execute_impl(statement) for statement in statements]
|
|
133
|
+
|
|
134
|
+
async def _executemany_impl(self, stmt: Any, parameter_sets: Any) -> Any:
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
"parameterized batch execution is unavailable on read-only memory engine"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def _close_impl(self) -> None:
|
|
140
|
+
self._closed = True
|
|
141
|
+
self._open = False
|
|
142
|
+
self._dirty = False
|
|
143
|
+
self._tracked.clear()
|
|
144
|
+
|
|
145
|
+
def _require_open_session(self) -> None:
|
|
146
|
+
if self._closed:
|
|
147
|
+
raise RuntimeError("ROM session is closed")
|
|
148
|
+
|
|
149
|
+
def _inflate(self, model: type, row: Mapping[str, Any]) -> Any:
|
|
150
|
+
try:
|
|
151
|
+
obj = model(**row)
|
|
152
|
+
except TypeError:
|
|
153
|
+
obj = model()
|
|
154
|
+
for name, value in row.items():
|
|
155
|
+
setattr(obj, name, value)
|
|
156
|
+
self._track(obj)
|
|
157
|
+
return obj
|
|
158
|
+
|
|
159
|
+
def _track(self, obj: Any) -> None:
|
|
160
|
+
self._tracked[id(obj)] = (obj, self._snapshot(obj))
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _snapshot(obj: Any) -> dict[str, Any]:
|
|
164
|
+
return {
|
|
165
|
+
name: deepcopy(value)
|
|
166
|
+
for name, value in vars(obj).items()
|
|
167
|
+
if not name.startswith("_sa_")
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _extract_model(stmt: Any) -> type:
|
|
172
|
+
for description in getattr(stmt, "column_descriptions", ()):
|
|
173
|
+
entity = description.get("entity")
|
|
174
|
+
if isinstance(entity, type):
|
|
175
|
+
return entity
|
|
176
|
+
raise NotImplementedError("ROM selects must target one mapped model")
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def _extract_predicates(cls, stmt: Any) -> list[tuple[str, str, Any]]:
|
|
180
|
+
predicates: list[tuple[str, str, Any]] = []
|
|
181
|
+
for criterion in getattr(stmt, "_where_criteria", ()):
|
|
182
|
+
clauses = getattr(criterion, "clauses", None)
|
|
183
|
+
if clauses is not None:
|
|
184
|
+
boolean_operator = getattr(
|
|
185
|
+
getattr(criterion, "operator", None), "__name__", ""
|
|
186
|
+
)
|
|
187
|
+
if boolean_operator != "and_":
|
|
188
|
+
raise NotImplementedError(
|
|
189
|
+
"ROM supports conjunctions of equality and IN predicates only"
|
|
190
|
+
)
|
|
191
|
+
for clause in clauses:
|
|
192
|
+
predicates.extend(cls._extract_binary_predicate(clause))
|
|
193
|
+
else:
|
|
194
|
+
predicates.extend(cls._extract_binary_predicate(criterion))
|
|
195
|
+
return predicates
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _extract_binary_predicate(expr: Any) -> list[tuple[str, str, Any]]:
|
|
199
|
+
name = getattr(getattr(expr, "left", None), "key", None)
|
|
200
|
+
operator_name = getattr(getattr(expr, "operator", None), "__name__", "")
|
|
201
|
+
right = getattr(expr, "right", None)
|
|
202
|
+
value = getattr(right, "effective_value", getattr(right, "value", None))
|
|
203
|
+
if not name or operator_name not in {"eq", "in_op"}:
|
|
204
|
+
raise NotImplementedError("ROM supports equality and IN predicates only")
|
|
205
|
+
return [(name, "in" if operator_name == "in_op" else "eq", value)]
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _extract_ordering(stmt: Any) -> list[tuple[str, bool]]:
|
|
209
|
+
ordering: list[tuple[str, bool]] = []
|
|
210
|
+
for clause in getattr(stmt, "_order_by_clauses", ()):
|
|
211
|
+
element = getattr(clause, "element", clause)
|
|
212
|
+
name = getattr(element, "key", None)
|
|
213
|
+
if not name:
|
|
214
|
+
raise NotImplementedError("ROM ordering must target mapped columns")
|
|
215
|
+
modifier = getattr(getattr(clause, "modifier", None), "__name__", "")
|
|
216
|
+
ordering.append((name, modifier == "desc_op"))
|
|
217
|
+
return ordering
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _clause_int(clause: Any) -> int | None:
|
|
221
|
+
if clause is None:
|
|
222
|
+
return None
|
|
223
|
+
value = getattr(clause, "effective_value", getattr(clause, "value", clause))
|
|
224
|
+
return int(value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["READ_ONLY_ERROR", "ROMResult", "ROMSession"]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from sqlalchemy import Integer, String, delete, or_, select
|
|
7
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
8
|
+
from tigrbl.factories.table import defineTableSpec
|
|
9
|
+
from tigrbl_base._base import EngineBase
|
|
10
|
+
from tigrbl_concrete._concrete import engine_resolver
|
|
11
|
+
from tigrbl_core._spec.engine_spec import EngineSpec
|
|
12
|
+
from tigrbl_core._spec.engine_session_spec import EngineSessionSpec
|
|
13
|
+
from tigrbl_ops_oltp.crud.ops import merge, replace as replace_row, update
|
|
14
|
+
|
|
15
|
+
from tigrbl_engine_rom import (
|
|
16
|
+
ROMEngine,
|
|
17
|
+
ROMSession,
|
|
18
|
+
build_rom,
|
|
19
|
+
capabilities,
|
|
20
|
+
register,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Base(DeclarativeBase):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Widget(Base):
|
|
29
|
+
__tablename__ = "widgets"
|
|
30
|
+
|
|
31
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
32
|
+
name: Mapped[str] = mapped_column(String)
|
|
33
|
+
rank: Mapped[int] = mapped_column(Integer)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Record:
|
|
38
|
+
id: int
|
|
39
|
+
value: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_engine_detaches_and_protects_nested_source_data() -> None:
|
|
43
|
+
source = [{"id": 1, "value": "alpha", "tags": ["stable"]}]
|
|
44
|
+
engine = ROMEngine(source)
|
|
45
|
+
assert isinstance(engine, EngineBase)
|
|
46
|
+
assert engine.to_provider() is engine
|
|
47
|
+
|
|
48
|
+
source[0]["value"] = "changed"
|
|
49
|
+
source[0]["tags"].append("mutated")
|
|
50
|
+
first = engine.get(1)
|
|
51
|
+
assert first == {"id": 1, "value": "alpha", "tags": ["stable"]}
|
|
52
|
+
|
|
53
|
+
assert first is not None
|
|
54
|
+
first["value"] = "caller mutation"
|
|
55
|
+
first["tags"].append("caller mutation")
|
|
56
|
+
assert engine.get(1) == {
|
|
57
|
+
"id": 1,
|
|
58
|
+
"value": "alpha",
|
|
59
|
+
"tags": ["stable"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize(
|
|
64
|
+
("rows", "message"),
|
|
65
|
+
[
|
|
66
|
+
([{"value": "missing"}], "missing primary key"),
|
|
67
|
+
([{"id": 1}, {"id": 1}], "duplicate primary key"),
|
|
68
|
+
(["not-a-row"], "must be a mapping"),
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
def test_engine_rejects_invalid_rom_images(rows, message: str) -> None:
|
|
72
|
+
with pytest.raises((TypeError, ValueError), match=message):
|
|
73
|
+
ROMEngine(rows)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_engine_supports_a_custom_primary_key() -> None:
|
|
77
|
+
engine = ROMEngine(
|
|
78
|
+
[{"name": "theme", "value": "dark"}],
|
|
79
|
+
primary_key="name",
|
|
80
|
+
)
|
|
81
|
+
assert engine.primary_key == "name"
|
|
82
|
+
assert engine.get("theme") == {"name": "theme", "value": "dark"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_rom_configuration_is_an_input_to_a_table_spec() -> None:
|
|
86
|
+
rom = {
|
|
87
|
+
"kind": "rom",
|
|
88
|
+
"rows": [{"id": 1, "name": "one", "rank": 1}],
|
|
89
|
+
"primary_key": "id",
|
|
90
|
+
}
|
|
91
|
+
Spec = defineTableSpec(engine=rom)
|
|
92
|
+
|
|
93
|
+
assert Spec.table_config["engine"] is rom
|
|
94
|
+
assert Spec.table_config["db"] is rom
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_table_bound_engine_resolves_a_rom_session_for_that_model() -> None:
|
|
99
|
+
register()
|
|
100
|
+
engine_resolver.reset(dispose=False)
|
|
101
|
+
rom = {
|
|
102
|
+
"kind": "rom",
|
|
103
|
+
"rows": [{"id": 1, "name": "one", "rank": 1}],
|
|
104
|
+
}
|
|
105
|
+
Spec = defineTableSpec(engine=rom)
|
|
106
|
+
engine_resolver.register_table(Widget, Spec.table_config["engine"])
|
|
107
|
+
|
|
108
|
+
session, _release = engine_resolver.acquire(model=Widget)
|
|
109
|
+
try:
|
|
110
|
+
widget = await session.get(Widget, 1)
|
|
111
|
+
assert widget.name == "one"
|
|
112
|
+
finally:
|
|
113
|
+
await session.close()
|
|
114
|
+
engine_resolver.reset(dispose=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_session_supports_point_reads_scans_and_sqlalchemy_selects() -> None:
|
|
119
|
+
engine = ROMEngine(
|
|
120
|
+
[
|
|
121
|
+
{"id": 1, "name": "one", "rank": 2},
|
|
122
|
+
{"id": 2, "name": "two", "rank": 1},
|
|
123
|
+
{"id": 3, "name": "three", "rank": 3},
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
session = ROMSession(engine)
|
|
127
|
+
|
|
128
|
+
widget = await session.get(Widget, 2)
|
|
129
|
+
assert (widget.id, widget.name, widget.rank) == (2, "two", 1)
|
|
130
|
+
assert await session.get(Widget, 99) is None
|
|
131
|
+
assert session.query_rows(lambda row: row["rank"] > 1) == [
|
|
132
|
+
{"id": 1, "name": "one", "rank": 2},
|
|
133
|
+
{"id": 3, "name": "three", "rank": 3},
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
result = await session.execute(
|
|
137
|
+
select(Widget)
|
|
138
|
+
.where(Widget.id.in_([1, 3]))
|
|
139
|
+
.order_by(Widget.rank.desc())
|
|
140
|
+
.limit(1)
|
|
141
|
+
)
|
|
142
|
+
assert [item.id for item in result.scalars().all()] == [3]
|
|
143
|
+
|
|
144
|
+
result = await session.execute(select(Widget).where(Widget.name == "two"))
|
|
145
|
+
assert result.scalar_one().id == 2
|
|
146
|
+
|
|
147
|
+
with pytest.raises(NotImplementedError, match="conjunctions"):
|
|
148
|
+
await session.execute(select(Widget).where(or_(Widget.id == 1, Widget.id == 2)))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@pytest.mark.asyncio
|
|
152
|
+
async def test_session_is_always_read_only_and_lifecycle_safe() -> None:
|
|
153
|
+
engine = ROMEngine([{"id": 1, "value": "alpha"}])
|
|
154
|
+
session = ROMSession(engine, EngineSessionSpec(read_only=False))
|
|
155
|
+
assert session.read_only is True
|
|
156
|
+
|
|
157
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
158
|
+
session.add(Record(2, "beta"))
|
|
159
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
160
|
+
await session.delete(Record(1, "alpha"))
|
|
161
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
162
|
+
await session.execute(delete(Widget))
|
|
163
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
164
|
+
await session.executemany(select(Widget), [{"id": 1}])
|
|
165
|
+
|
|
166
|
+
await session.begin()
|
|
167
|
+
assert session.in_transaction() is True
|
|
168
|
+
await session.commit()
|
|
169
|
+
assert session.in_transaction() is False
|
|
170
|
+
await session.close()
|
|
171
|
+
assert session.in_transaction() is False
|
|
172
|
+
with pytest.raises(RuntimeError, match="closed"):
|
|
173
|
+
session.get_row(1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@pytest.mark.asyncio
|
|
177
|
+
@pytest.mark.parametrize("operation", [update, replace_row, merge])
|
|
178
|
+
async def test_generic_mutation_helpers_fail_closed(operation) -> None:
|
|
179
|
+
engine = ROMEngine([{"id": 1, "name": "original", "rank": 1}])
|
|
180
|
+
session = ROMSession(engine)
|
|
181
|
+
|
|
182
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
183
|
+
await operation(Widget, 1, {"name": operation.__name__}, session)
|
|
184
|
+
assert engine.get(1)["name"] == "original"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_rollback_restores_locally_mutated_read_objects() -> None:
|
|
189
|
+
session = ROMSession(ROMEngine([{"id": 1, "name": "original", "rank": 1}]))
|
|
190
|
+
widget = await session.get(Widget, 1)
|
|
191
|
+
widget.name = "local mutation"
|
|
192
|
+
|
|
193
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
194
|
+
await session.commit()
|
|
195
|
+
await session.rollback()
|
|
196
|
+
assert widget.name == "original"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_plugin_factory_and_capabilities_describe_rom_semantics() -> None:
|
|
200
|
+
engine, maker = build_rom(mapping={"rows": [{"id": 1, "value": "alpha"}]})
|
|
201
|
+
assert isinstance(engine, ROMEngine)
|
|
202
|
+
assert isinstance(maker(), ROMSession)
|
|
203
|
+
assert capabilities()["read_only_enforced"] is True
|
|
204
|
+
assert capabilities()["storage"] == "immutable-memory"
|
|
205
|
+
assert capabilities()["binding_scopes"] == {"table"}
|
|
206
|
+
with pytest.raises(ValueError, match="not a DSN"):
|
|
207
|
+
build_rom(dsn="rom://image")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_registration_builds_through_engine_spec() -> None:
|
|
211
|
+
register()
|
|
212
|
+
spec = EngineSpec.from_any({"kind": "rom", "rows": [{"id": 1, "value": "alpha"}]})
|
|
213
|
+
assert spec is not None
|
|
214
|
+
engine, maker = spec.build()
|
|
215
|
+
assert isinstance(engine, EngineBase)
|
|
216
|
+
assert engine.spec is spec
|
|
217
|
+
assert engine.get(1) == {"id": 1, "value": "alpha"}
|
|
218
|
+
assert isinstance(maker(), ROMSession)
|
|
219
|
+
assert spec.supports()["read_only_enforced"] is True
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.mark.asyncio
|
|
223
|
+
async def test_engine_base_batch_surface_delegates_to_rom_sessions() -> None:
|
|
224
|
+
engine = ROMEngine([{"id": 1, "name": "one", "rank": 1}])
|
|
225
|
+
|
|
226
|
+
results = await engine.executeloop([select(Widget)])
|
|
227
|
+
assert results[0].scalar_one().name == "one"
|
|
228
|
+
with pytest.raises(RuntimeError, match="read-only"):
|
|
229
|
+
await engine.executemany(select(Widget), [{"id": 1}])
|