in-layers-data 0.4.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.
- in_layers_data-0.4.0/PKG-INFO +79 -0
- in_layers_data-0.4.0/README.md +60 -0
- in_layers_data-0.4.0/pyproject.toml +204 -0
- in_layers_data-0.4.0/src/in_layers/__init__.py +7 -0
- in_layers_data-0.4.0/src/in_layers/data/__init__.py +21 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/__init__.py +0 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/dynamodb/__init__.py +5 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/dynamodb/libs.py +84 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/dynamodb/services.py +318 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/mongodb/__init__.py +0 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/mongodb/libs.py +229 -0
- in_layers_data-0.4.0/src/in_layers/data/backends/mongodb/services.py +225 -0
- in_layers_data-0.4.0/src/in_layers/data/protocols.py +43 -0
- in_layers_data-0.4.0/src/in_layers/data/services.py +73 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: in-layers-data
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: A data layer for In Layers Core. (in-layers-core). Part of the Node in Layers ecosystem
|
|
5
|
+
License: GPLv3
|
|
6
|
+
Keywords: layers,python,in-layers,node,domains,data,models,orm
|
|
7
|
+
Author: Mike Cornwell
|
|
8
|
+
Author-email: mike@mikecornwell.com
|
|
9
|
+
Requires-Python: >=3.13,<4.0
|
|
10
|
+
Classifier: License :: Other/Proprietary License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
15
|
+
Requires-Dist: in-layers-core (>=0.4.2,<0.5.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.12.5,<3.0.0)
|
|
17
|
+
Requires-Dist: python-box (>=7,<9)
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# In Layers Data
|
|
21
|
+
A data layer for the In Layers Core framework.
|
|
22
|
+
|
|
23
|
+
NOTE: There are no explicit dependencies on any database. To use a specific database you must install it in your own system. These databases are "imported in" as the databases are actually used at runtime.
|
|
24
|
+
|
|
25
|
+
## How To Use
|
|
26
|
+
1. Install in-layers-data
|
|
27
|
+
1. Set `"in_layers_data"` to the `in_layers_core.models.model_backend` property
|
|
28
|
+
1. Add `in_layers_data` configuration to your config
|
|
29
|
+
1. Install database libraries to use. Example: `pymongo` or `boto3`
|
|
30
|
+
|
|
31
|
+
### Configuration Example
|
|
32
|
+
```python
|
|
33
|
+
# config_base.py
|
|
34
|
+
from box import Box
|
|
35
|
+
def get_base_config():
|
|
36
|
+
return Box(
|
|
37
|
+
...,
|
|
38
|
+
in_layers_core=Box(
|
|
39
|
+
...
|
|
40
|
+
models=Box(
|
|
41
|
+
model_backend="in_layers_data",
|
|
42
|
+
)
|
|
43
|
+
),
|
|
44
|
+
in_layers_data=Box(
|
|
45
|
+
default=Box(
|
|
46
|
+
type="mongodb"
|
|
47
|
+
# Connection information here
|
|
48
|
+
),
|
|
49
|
+
# Optional: Set "domain" or "domain.ModelPluralNames" to a specific database configuration.
|
|
50
|
+
# model_to_backend=Box(
|
|
51
|
+
# "domain.ModelPluralNames"=Box(
|
|
52
|
+
# type="mongodb",
|
|
53
|
+
# host="different-host"
|
|
54
|
+
# )
|
|
55
|
+
# )
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Key Features
|
|
63
|
+
- Drop in, Swappable Databases
|
|
64
|
+
- Multi-database support
|
|
65
|
+
- Low dependencies
|
|
66
|
+
|
|
67
|
+
## Databases Supported
|
|
68
|
+
- Mongodb
|
|
69
|
+
- Dynamodb
|
|
70
|
+
|
|
71
|
+
## Database Info
|
|
72
|
+
### Mongo
|
|
73
|
+
Mongodb requires `pymongo`
|
|
74
|
+
|
|
75
|
+
### Dynamodb
|
|
76
|
+
Dynamodb requires `boto3`
|
|
77
|
+
|
|
78
|
+
#### Important
|
|
79
|
+
Dynamodb is very poor at performing search queries. While this is implemented, it is not-recommended for use. Instead use the retrieve.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# In Layers Data
|
|
2
|
+
A data layer for the In Layers Core framework.
|
|
3
|
+
|
|
4
|
+
NOTE: There are no explicit dependencies on any database. To use a specific database you must install it in your own system. These databases are "imported in" as the databases are actually used at runtime.
|
|
5
|
+
|
|
6
|
+
## How To Use
|
|
7
|
+
1. Install in-layers-data
|
|
8
|
+
1. Set `"in_layers_data"` to the `in_layers_core.models.model_backend` property
|
|
9
|
+
1. Add `in_layers_data` configuration to your config
|
|
10
|
+
1. Install database libraries to use. Example: `pymongo` or `boto3`
|
|
11
|
+
|
|
12
|
+
### Configuration Example
|
|
13
|
+
```python
|
|
14
|
+
# config_base.py
|
|
15
|
+
from box import Box
|
|
16
|
+
def get_base_config():
|
|
17
|
+
return Box(
|
|
18
|
+
...,
|
|
19
|
+
in_layers_core=Box(
|
|
20
|
+
...
|
|
21
|
+
models=Box(
|
|
22
|
+
model_backend="in_layers_data",
|
|
23
|
+
)
|
|
24
|
+
),
|
|
25
|
+
in_layers_data=Box(
|
|
26
|
+
default=Box(
|
|
27
|
+
type="mongodb"
|
|
28
|
+
# Connection information here
|
|
29
|
+
),
|
|
30
|
+
# Optional: Set "domain" or "domain.ModelPluralNames" to a specific database configuration.
|
|
31
|
+
# model_to_backend=Box(
|
|
32
|
+
# "domain.ModelPluralNames"=Box(
|
|
33
|
+
# type="mongodb",
|
|
34
|
+
# host="different-host"
|
|
35
|
+
# )
|
|
36
|
+
# )
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Key Features
|
|
44
|
+
- Drop in, Swappable Databases
|
|
45
|
+
- Multi-database support
|
|
46
|
+
- Low dependencies
|
|
47
|
+
|
|
48
|
+
## Databases Supported
|
|
49
|
+
- Mongodb
|
|
50
|
+
- Dynamodb
|
|
51
|
+
|
|
52
|
+
## Database Info
|
|
53
|
+
### Mongo
|
|
54
|
+
Mongodb requires `pymongo`
|
|
55
|
+
|
|
56
|
+
### Dynamodb
|
|
57
|
+
Dynamodb requires `boto3`
|
|
58
|
+
|
|
59
|
+
#### Important
|
|
60
|
+
Dynamodb is very poor at performing search queries. While this is implemented, it is not-recommended for use. Instead use the retrieve.
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "in-layers-data"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "A data layer for In Layers Core. (in-layers-core). Part of the Node in Layers ecosystem"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Mike Cornwell",email = "mike@mikecornwell.com"}
|
|
7
|
+
]
|
|
8
|
+
license = {text = "GPLv3"}
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
homepage = "https://github.com/node-in-layers/python-in-layers-data"
|
|
11
|
+
repository = "https://github.com/node-in-layers/python-in-layers-data"
|
|
12
|
+
keywords = ["layers", "python", "in-layers", "node", "domains", "data", "models", "orm"]
|
|
13
|
+
requires-python = ">=3.13,<4.0"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"httpx (>=0.28.1,<0.29.0)",
|
|
16
|
+
"python-box (>=7,<9)",
|
|
17
|
+
"pydantic (>=2.12.5,<3.0.0)",
|
|
18
|
+
"in-layers-core (>=0.4.2,<0.5.0)"
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.poetry]
|
|
22
|
+
name = "in-layers-data"
|
|
23
|
+
version = "0.1.0"
|
|
24
|
+
description = ""
|
|
25
|
+
authors = []
|
|
26
|
+
packages = [
|
|
27
|
+
{ include = "in_layers", from = "src" }
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
34
|
+
build-backend = "poetry.core.masonry.api"
|
|
35
|
+
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
dev = [
|
|
38
|
+
"black>=24.0.0",
|
|
39
|
+
"ruff>=0.7.0",
|
|
40
|
+
"isort>=5.12.0",
|
|
41
|
+
"mypy>=1.12.0",
|
|
42
|
+
"pytest>=8.0.0",
|
|
43
|
+
"commitizen (>=3.13,<4.0)",
|
|
44
|
+
"pytest-cov (>=7.0.0,<8.0.0)",
|
|
45
|
+
"build (>=1.3.0,<2.0.0)",
|
|
46
|
+
"twine (>=6.2.0,<7.0.0)",
|
|
47
|
+
"pymongo (>=4.15.5,<5.0.0)",
|
|
48
|
+
"behave>=1.2.6",
|
|
49
|
+
"boto3 (>=1.42.13,<2.0.0)"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.poetry.dependencies]
|
|
53
|
+
python = ">=3.13,<4.0"
|
|
54
|
+
python-box = ">=7,<9"
|
|
55
|
+
httpx = ">=0.28.1,<0.29.0"
|
|
56
|
+
|
|
57
|
+
[tool.isort]
|
|
58
|
+
profile = "black"
|
|
59
|
+
multi_line_output = 3
|
|
60
|
+
include_trailing_comma = true
|
|
61
|
+
force_grid_wrap = 0
|
|
62
|
+
lines_after_imports = 2
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ================================================================
|
|
66
|
+
# BLACK (code formatting)
|
|
67
|
+
# ================================================================
|
|
68
|
+
[tool.black]
|
|
69
|
+
line-length = 88
|
|
70
|
+
target-version = ["py313"]
|
|
71
|
+
include = '\.pyi?$'
|
|
72
|
+
exclude = '''
|
|
73
|
+
/(
|
|
74
|
+
\.git
|
|
75
|
+
| \.venv
|
|
76
|
+
| venv
|
|
77
|
+
| build
|
|
78
|
+
| dist
|
|
79
|
+
| __pycache__
|
|
80
|
+
| \.mypy_cache
|
|
81
|
+
| \.ruff_cache
|
|
82
|
+
)/
|
|
83
|
+
'''
|
|
84
|
+
|
|
85
|
+
# ================================================================
|
|
86
|
+
# RUFF (linting + import sorting + auto-fixes)
|
|
87
|
+
# ================================================================
|
|
88
|
+
[tool.ruff]
|
|
89
|
+
target-version = "py313"
|
|
90
|
+
line-length = 88
|
|
91
|
+
# Run Ruff from the root; it will traverse all subdirs except excluded.
|
|
92
|
+
exclude = [
|
|
93
|
+
".git",
|
|
94
|
+
".venv",
|
|
95
|
+
"venv",
|
|
96
|
+
"./features",
|
|
97
|
+
"__pycache__",
|
|
98
|
+
"build",
|
|
99
|
+
"dist",
|
|
100
|
+
".mypy_cache",
|
|
101
|
+
".ruff_cache",
|
|
102
|
+
"tests",
|
|
103
|
+
"*/tests",
|
|
104
|
+
"**/tests"
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
[tool.ruff.lint]
|
|
108
|
+
select = [
|
|
109
|
+
"E", # pycodestyle errors
|
|
110
|
+
"W", # pycodestyle warnings
|
|
111
|
+
"F", # Pyflakes
|
|
112
|
+
"B", # flake8-bugbear (common bugs / bad patterns)
|
|
113
|
+
"I", # isort (import sorting)
|
|
114
|
+
"UP", # pyupgrade (modern syntax)
|
|
115
|
+
"N", # pep8-naming (function, class, etc. naming)
|
|
116
|
+
"C90", # mccabe (complexity)
|
|
117
|
+
"SIM", # flake8-simplify
|
|
118
|
+
"S", # flake8-bandit (basic security checks)
|
|
119
|
+
"DTZ", # flake8-datetimez (timezone-aware datetimes)
|
|
120
|
+
"T20", # flake8-print (no stray prints in non-test code)
|
|
121
|
+
"ARG", # flake8-unused-arguments
|
|
122
|
+
"PL", # pylint-like checks (subset)
|
|
123
|
+
"RUF", # Ruff-specific rules
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
# Rules we choose to ignore or relax:
|
|
127
|
+
ignore = [
|
|
128
|
+
"E501", # Let Black handle line length
|
|
129
|
+
"W291", # Trailing whitespace (Black fixes this)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
# Per-file ignore examples (customize as desired):
|
|
133
|
+
[tool.ruff.lint.per-file-ignores]
|
|
134
|
+
"**/tests/**" = [
|
|
135
|
+
"S101", # Allow 'assert' in tests
|
|
136
|
+
"T201", # Allow 'print' in tests if you wish
|
|
137
|
+
]
|
|
138
|
+
"orchestration/scripts/**" = [
|
|
139
|
+
"T201", # Allow print in CLI-style scripts
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
[tool.ruff.lint.mccabe]
|
|
143
|
+
max-complexity = 10
|
|
144
|
+
|
|
145
|
+
[tool.ruff.lint.pylint]
|
|
146
|
+
max-args = 8
|
|
147
|
+
|
|
148
|
+
# Optionally enable Ruff as a formatter (complementing Black):
|
|
149
|
+
[tool.ruff.format]
|
|
150
|
+
quote-style = "double"
|
|
151
|
+
indent-style = "space"
|
|
152
|
+
line-ending = "auto"
|
|
153
|
+
|
|
154
|
+
# -------------------------------------------------------------------
|
|
155
|
+
# Mypy configuration (type checking / "code lensing")
|
|
156
|
+
# -------------------------------------------------------------------
|
|
157
|
+
[tool.mypy]
|
|
158
|
+
ignore_missing_imports = true
|
|
159
|
+
warn_unused_ignores = true
|
|
160
|
+
warn_redundant_casts = true
|
|
161
|
+
warn_unreachable = true
|
|
162
|
+
disallow_untyped_defs = true # Functions must be typed
|
|
163
|
+
disallow_incomplete_defs = true # Arguments & returns must be fully typed
|
|
164
|
+
disallow_untyped_calls = false # Relaxed for now; can tighten later
|
|
165
|
+
disallow_untyped_decorators = false
|
|
166
|
+
no_implicit_optional = true
|
|
167
|
+
strict_equality = true
|
|
168
|
+
show_error_codes = true
|
|
169
|
+
pretty = true
|
|
170
|
+
exclude = '''
|
|
171
|
+
/(
|
|
172
|
+
\.git
|
|
173
|
+
| \.venv
|
|
174
|
+
| build
|
|
175
|
+
| dist
|
|
176
|
+
| __pycache__
|
|
177
|
+
| \.mypy_cache
|
|
178
|
+
| tmp
|
|
179
|
+
)/
|
|
180
|
+
'''
|
|
181
|
+
|
|
182
|
+
# Optional: per-module/per-package overrides
|
|
183
|
+
[[tool.mypy.overrides]]
|
|
184
|
+
module = "tests.*"
|
|
185
|
+
ignore_errors = true
|
|
186
|
+
|
|
187
|
+
# -------------------------------------------------------------------
|
|
188
|
+
# Pytest configuration (unit tests)
|
|
189
|
+
# -------------------------------------------------------------------
|
|
190
|
+
[tool.pytest.ini_options]
|
|
191
|
+
minversion = "8.0"
|
|
192
|
+
addopts = "-ra -q"
|
|
193
|
+
pythonpath = ["src"]
|
|
194
|
+
python_files = [
|
|
195
|
+
"test_*.py",
|
|
196
|
+
"*_test.py",
|
|
197
|
+
"test_*.py",
|
|
198
|
+
"*-test.py",
|
|
199
|
+
]
|
|
200
|
+
[tool.commitizen]
|
|
201
|
+
name = "cz_conventional_commits"
|
|
202
|
+
tag_format = "$version"
|
|
203
|
+
version_scheme = "pep440"
|
|
204
|
+
version_provider = "pep621"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from . import services
|
|
2
|
+
from .protocols import (
|
|
3
|
+
BackendConfig,
|
|
4
|
+
DataNamespace,
|
|
5
|
+
DynamoDBBackendConfig,
|
|
6
|
+
InLayersDataConfig,
|
|
7
|
+
MongoBackendConfig,
|
|
8
|
+
SupportedBackend,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
name = DataNamespace.root
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"BackendConfig",
|
|
15
|
+
"DynamoDBBackendConfig",
|
|
16
|
+
"InLayersDataConfig",
|
|
17
|
+
"MongoBackendConfig",
|
|
18
|
+
"SupportedBackend",
|
|
19
|
+
"name",
|
|
20
|
+
"services",
|
|
21
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Pure functional utilities for DynamoDB query conversion and data formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from in_layers.core.models.protocols import ModelDefinition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_table_name_for_model(model_definition: ModelDefinition) -> str:
|
|
14
|
+
"""Generate a DynamoDB table name from a model definition."""
|
|
15
|
+
name = model_definition.plural_name.replace("@", "").replace("/", "-")
|
|
16
|
+
# Convert to kebab-case: insert hyphens before uppercase letters (except first)
|
|
17
|
+
# and handle sequences of uppercase letters
|
|
18
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
|
|
19
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
|
|
20
|
+
return name.lower()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_for_dynamodb(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
24
|
+
"""Format data for DynamoDB storage.
|
|
25
|
+
|
|
26
|
+
DynamoDB natively handles:
|
|
27
|
+
- Strings
|
|
28
|
+
- Numbers (int, float, Decimal)
|
|
29
|
+
- Binary data
|
|
30
|
+
- Boolean
|
|
31
|
+
- Null
|
|
32
|
+
- Lists
|
|
33
|
+
- Maps (dicts)
|
|
34
|
+
- Sets (string sets, number sets, binary sets)
|
|
35
|
+
|
|
36
|
+
The boto3 DynamoDBDocumentClient will handle conversion automatically,
|
|
37
|
+
but we ensure datetime objects are converted to ISO format strings.
|
|
38
|
+
"""
|
|
39
|
+
result = dict(data)
|
|
40
|
+
# Convert datetime objects to ISO format strings for DynamoDB
|
|
41
|
+
|
|
42
|
+
for key, value in result.items():
|
|
43
|
+
if isinstance(value, datetime):
|
|
44
|
+
result[key] = value.isoformat()
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_dynamodb(item: dict[str, Any] | None) -> dict[str, Any]:
|
|
49
|
+
"""Convert a DynamoDB item to a plain dictionary.
|
|
50
|
+
|
|
51
|
+
The boto3 DynamoDBDocumentClient already converts AttributeValue format
|
|
52
|
+
to native Python types, so this is mainly for consistency and future-proofing.
|
|
53
|
+
"""
|
|
54
|
+
if item is None:
|
|
55
|
+
return {}
|
|
56
|
+
return dict(item)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_scan_params(
|
|
60
|
+
table_name: str, exclusive_start_key: dict[str, Any] | None = None
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Build parameters for a DynamoDB Scan operation."""
|
|
63
|
+
params: dict[str, Any] = {
|
|
64
|
+
"TableName": table_name,
|
|
65
|
+
}
|
|
66
|
+
if exclusive_start_key:
|
|
67
|
+
params["ExclusiveStartKey"] = exclusive_start_key
|
|
68
|
+
return params
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def split_array_into_batches(array: list[Any], max_batch_size: int) -> list[list[Any]]:
|
|
72
|
+
"""Split an array into batches of maximum size.
|
|
73
|
+
|
|
74
|
+
DynamoDB has limits on batch operations (e.g., BatchWriteItem max 25 items).
|
|
75
|
+
"""
|
|
76
|
+
if not isinstance(array, list):
|
|
77
|
+
raise ValueError("Input must be a list")
|
|
78
|
+
if max_batch_size < 1:
|
|
79
|
+
raise ValueError("max_batch_size must be at least 1")
|
|
80
|
+
|
|
81
|
+
batches = []
|
|
82
|
+
for i in range(0, len(array), max_batch_size):
|
|
83
|
+
batches.append(array[i : i + max_batch_size])
|
|
84
|
+
return batches
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""DynamoDB backend implementation for InLayers models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from box import Box
|
|
10
|
+
from in_layers.core.models.backends import (
|
|
11
|
+
_apply_sort,
|
|
12
|
+
_apply_take,
|
|
13
|
+
_matches_query_tokens,
|
|
14
|
+
)
|
|
15
|
+
from in_layers.core.models.protocols import (
|
|
16
|
+
InLayersModel,
|
|
17
|
+
ModelSearch,
|
|
18
|
+
ModelSearchResult,
|
|
19
|
+
PrimaryKeyType,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from in_layers.data.protocols import DynamoDBBackendConfig
|
|
23
|
+
|
|
24
|
+
from .libs import (
|
|
25
|
+
format_for_dynamodb,
|
|
26
|
+
from_dynamodb,
|
|
27
|
+
get_table_name_for_model,
|
|
28
|
+
split_array_into_batches,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# DynamoDB batch operation limits
|
|
32
|
+
MAX_BATCH_WRITE_SIZE = 25
|
|
33
|
+
SCAN_RETURN_THRESHOLD = 1000
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DynamoDBBackend:
|
|
37
|
+
"""DynamoDB backend implementation."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: DynamoDBBackendConfig):
|
|
40
|
+
self.__config = config
|
|
41
|
+
self.__client: Any = None
|
|
42
|
+
self.__table_client: Any = None
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def create_unique_connection_string(config: DynamoDBBackendConfig) -> str:
|
|
46
|
+
"""Create a unique connection string from config."""
|
|
47
|
+
region = config.region or "us-east-1"
|
|
48
|
+
endpoint_url = config.endpoint_url or ""
|
|
49
|
+
|
|
50
|
+
parts = [f"region={region}"]
|
|
51
|
+
if endpoint_url:
|
|
52
|
+
parts.append(f"endpoint={endpoint_url}")
|
|
53
|
+
|
|
54
|
+
return "|".join(parts)
|
|
55
|
+
|
|
56
|
+
def __connect(self) -> None:
|
|
57
|
+
"""Connect to DynamoDB (private method)."""
|
|
58
|
+
# Use boto3 from config if provided (for testing), otherwise import it
|
|
59
|
+
if self.__config.boto3 is not None:
|
|
60
|
+
boto3 = self.__config.boto3
|
|
61
|
+
else:
|
|
62
|
+
import boto3 # pragma: no cover # noqa: PLC0415
|
|
63
|
+
|
|
64
|
+
# Build client configuration
|
|
65
|
+
client_kwargs: dict[str, Any] = {}
|
|
66
|
+
if self.__config.region:
|
|
67
|
+
client_kwargs["region_name"] = self.__config.region
|
|
68
|
+
if self.__config.endpoint_url:
|
|
69
|
+
client_kwargs["endpoint_url"] = self.__config.endpoint_url
|
|
70
|
+
if self.__config.aws_access_key_id and self.__config.aws_secret_access_key:
|
|
71
|
+
client_kwargs["aws_access_key_id"] = self.__config.aws_access_key_id
|
|
72
|
+
client_kwargs["aws_secret_access_key"] = self.__config.aws_secret_access_key
|
|
73
|
+
|
|
74
|
+
# Create DynamoDB client
|
|
75
|
+
self.__client = boto3.client("dynamodb", **client_kwargs)
|
|
76
|
+
|
|
77
|
+
# Create DynamoDB resource for easier table operations
|
|
78
|
+
dynamodb_resource = boto3.resource("dynamodb", **client_kwargs)
|
|
79
|
+
self.__table_client = dynamodb_resource
|
|
80
|
+
|
|
81
|
+
def __disconnect(self) -> None:
|
|
82
|
+
"""Disconnect from DynamoDB (private method)."""
|
|
83
|
+
# boto3 clients don't need explicit closing, but we can clear references
|
|
84
|
+
self.__client = None
|
|
85
|
+
self.__table_client = None
|
|
86
|
+
|
|
87
|
+
def __ensure_connected(self) -> None:
|
|
88
|
+
"""Ensure DynamoDB connection is established."""
|
|
89
|
+
if self.__client is None:
|
|
90
|
+
self.__connect()
|
|
91
|
+
|
|
92
|
+
def create(self, model: InLayersModel, data: Mapping) -> Mapping:
|
|
93
|
+
"""Create a new item in DynamoDB."""
|
|
94
|
+
self.__ensure_connected()
|
|
95
|
+
|
|
96
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
97
|
+
table = self.__table_client.Table(table_name)
|
|
98
|
+
|
|
99
|
+
payload = dict(data)
|
|
100
|
+
formatted = format_for_dynamodb(payload)
|
|
101
|
+
|
|
102
|
+
pk_name = model.get_primary_key_name()
|
|
103
|
+
pk_value = formatted.get(pk_name)
|
|
104
|
+
if pk_value is None:
|
|
105
|
+
pk_value = str(uuid4())
|
|
106
|
+
formatted[pk_name] = pk_value
|
|
107
|
+
|
|
108
|
+
# Ensure primary key is a string (DynamoDB requirement)
|
|
109
|
+
formatted[pk_name] = str(pk_value)
|
|
110
|
+
|
|
111
|
+
# Put item in DynamoDB
|
|
112
|
+
table.put_item(Item=formatted)
|
|
113
|
+
return formatted
|
|
114
|
+
|
|
115
|
+
def retrieve(self, model: InLayersModel, id: PrimaryKeyType) -> Mapping | None:
|
|
116
|
+
"""Retrieve an item by ID."""
|
|
117
|
+
self.__ensure_connected()
|
|
118
|
+
|
|
119
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
120
|
+
table = self.__table_client.Table(table_name)
|
|
121
|
+
|
|
122
|
+
pk_name = model.get_primary_key_name()
|
|
123
|
+
key = {pk_name: str(id)}
|
|
124
|
+
|
|
125
|
+
response = table.get_item(Key=key)
|
|
126
|
+
item = response.get("Item")
|
|
127
|
+
if not item:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
return from_dynamodb(item)
|
|
131
|
+
|
|
132
|
+
def update(
|
|
133
|
+
self, model: InLayersModel, id: PrimaryKeyType, data: Mapping
|
|
134
|
+
) -> Mapping:
|
|
135
|
+
"""Update an item by ID."""
|
|
136
|
+
self.__ensure_connected()
|
|
137
|
+
|
|
138
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
139
|
+
table = self.__table_client.Table(table_name)
|
|
140
|
+
|
|
141
|
+
pk_name = model.get_primary_key_name()
|
|
142
|
+
key = {pk_name: str(id)}
|
|
143
|
+
|
|
144
|
+
# Check if item exists
|
|
145
|
+
existing = table.get_item(Key=key)
|
|
146
|
+
if "Item" not in existing:
|
|
147
|
+
raise KeyError(f"Instance with id {id!r} not found")
|
|
148
|
+
|
|
149
|
+
payload = dict(data)
|
|
150
|
+
formatted = format_for_dynamodb(payload)
|
|
151
|
+
|
|
152
|
+
# Ensure primary key field remains consistent
|
|
153
|
+
formatted[pk_name] = str(id)
|
|
154
|
+
|
|
155
|
+
# Update item in DynamoDB
|
|
156
|
+
table.put_item(Item=formatted)
|
|
157
|
+
return formatted
|
|
158
|
+
|
|
159
|
+
def delete(self, model: InLayersModel, id: PrimaryKeyType) -> None:
|
|
160
|
+
"""Delete an item by ID."""
|
|
161
|
+
self.__ensure_connected()
|
|
162
|
+
|
|
163
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
164
|
+
table = self.__table_client.Table(table_name)
|
|
165
|
+
|
|
166
|
+
pk_name = model.get_primary_key_name()
|
|
167
|
+
key = {pk_name: str(id)}
|
|
168
|
+
|
|
169
|
+
table.delete_item(Key=key)
|
|
170
|
+
|
|
171
|
+
def search(self, model: InLayersModel, query: ModelSearch) -> ModelSearchResult:
|
|
172
|
+
"""Search for items matching the query.
|
|
173
|
+
|
|
174
|
+
Note: DynamoDB Scan operations are expensive and should be used sparingly.
|
|
175
|
+
For production use, consider using Query operations with Global Secondary Indexes (GSI)
|
|
176
|
+
or Local Secondary Indexes (LSI) for better performance.
|
|
177
|
+
|
|
178
|
+
This implementation continues scanning across pages until:
|
|
179
|
+
- Threshold is met (SCAN_RETURN_THRESHOLD if no take specified)
|
|
180
|
+
- No more keys (LastEvaluatedKey is null)
|
|
181
|
+
- Take limit is reached (if take is specified)
|
|
182
|
+
"""
|
|
183
|
+
self.__ensure_connected()
|
|
184
|
+
|
|
185
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
186
|
+
table = self.__table_client.Table(table_name)
|
|
187
|
+
|
|
188
|
+
# Start recursive scanning
|
|
189
|
+
result = self._do_search_until_threshold_or_no_last_evaluated_key(
|
|
190
|
+
table, query, []
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return Box(instances=result["instances"], page=result["page"])
|
|
194
|
+
|
|
195
|
+
def _do_search_until_threshold_or_no_last_evaluated_key(
|
|
196
|
+
self,
|
|
197
|
+
table: Any,
|
|
198
|
+
query: ModelSearch,
|
|
199
|
+
old_instances_found: list[dict[str, Any]],
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Recursively scan DynamoDB until threshold is met or no more keys.
|
|
202
|
+
|
|
203
|
+
This matches the TypeScript implementation's behavior of continuing
|
|
204
|
+
to scan across pages until enough filtered results are found.
|
|
205
|
+
"""
|
|
206
|
+
# Build scan parameters
|
|
207
|
+
scan_kwargs: dict[str, Any] = {}
|
|
208
|
+
if query.page:
|
|
209
|
+
scan_kwargs["ExclusiveStartKey"] = query.page
|
|
210
|
+
|
|
211
|
+
# Execute scan
|
|
212
|
+
response = table.scan(**scan_kwargs)
|
|
213
|
+
items = response.get("Items", [])
|
|
214
|
+
|
|
215
|
+
# Convert DynamoDB items to plain dicts
|
|
216
|
+
unfiltered = [from_dynamodb(item) for item in items]
|
|
217
|
+
|
|
218
|
+
# Apply filtering using the same logic as MemoryBackend
|
|
219
|
+
filtered = [r for r in unfiltered if _matches_query_tokens(r, query.query)]
|
|
220
|
+
|
|
221
|
+
# Combine with previously found instances
|
|
222
|
+
all_filtered = filtered + old_instances_found
|
|
223
|
+
|
|
224
|
+
# Determine threshold
|
|
225
|
+
using_take = query.take is not None and query.take > 0
|
|
226
|
+
take = query.take if using_take else SCAN_RETURN_THRESHOLD
|
|
227
|
+
|
|
228
|
+
# Get pagination key
|
|
229
|
+
last_evaluated_key = response.get("LastEvaluatedKey")
|
|
230
|
+
|
|
231
|
+
# Check stopping conditions:
|
|
232
|
+
# 1. We have enough results (more than threshold)
|
|
233
|
+
# 2. No more keys to evaluate
|
|
234
|
+
# 3. If using take, we've hit our max
|
|
235
|
+
# Note: TypeScript uses > (strictly greater), meaning we continue if we have exactly 'take' items
|
|
236
|
+
stop_for_threshold = len(all_filtered) > take
|
|
237
|
+
stop_for_no_more = last_evaluated_key is None
|
|
238
|
+
|
|
239
|
+
if stop_for_threshold or stop_for_no_more:
|
|
240
|
+
# Apply sorting and take limit
|
|
241
|
+
sorted_instances = _apply_sort(all_filtered, query.sort)
|
|
242
|
+
limited_instances = _apply_take(sorted_instances, query.take)
|
|
243
|
+
|
|
244
|
+
# Return page: null when using take, otherwise return LastEvaluatedKey
|
|
245
|
+
page = None if using_take else last_evaluated_key
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"instances": [dict(x) for x in limited_instances],
|
|
249
|
+
"page": page,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Continue scanning with the new page key
|
|
253
|
+
# Create a new ModelSearch with updated page (frozen dataclass requires new instance)
|
|
254
|
+
new_query = ModelSearch(
|
|
255
|
+
query=query.query,
|
|
256
|
+
take=query.take,
|
|
257
|
+
sort=query.sort,
|
|
258
|
+
page=last_evaluated_key,
|
|
259
|
+
)
|
|
260
|
+
return self._do_search_until_threshold_or_no_last_evaluated_key(
|
|
261
|
+
table, new_query, all_filtered
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def bulk_insert(self, model: InLayersModel, data: list[Mapping]) -> None:
|
|
265
|
+
"""Bulk insert items."""
|
|
266
|
+
self.__ensure_connected()
|
|
267
|
+
|
|
268
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
269
|
+
table = self.__table_client.Table(table_name)
|
|
270
|
+
pk_name = model.get_primary_key_name()
|
|
271
|
+
|
|
272
|
+
# Prepare items
|
|
273
|
+
items = []
|
|
274
|
+
for item in data:
|
|
275
|
+
payload = dict(item)
|
|
276
|
+
formatted = format_for_dynamodb(payload)
|
|
277
|
+
|
|
278
|
+
pk_value = formatted.get(pk_name)
|
|
279
|
+
if pk_value is None:
|
|
280
|
+
|
|
281
|
+
pk_value = str(uuid4())
|
|
282
|
+
formatted[pk_name] = pk_value
|
|
283
|
+
|
|
284
|
+
formatted[pk_name] = str(pk_value)
|
|
285
|
+
items.append(formatted)
|
|
286
|
+
|
|
287
|
+
# Split into batches (DynamoDB BatchWriteItem limit is 25)
|
|
288
|
+
batches = split_array_into_batches(items, MAX_BATCH_WRITE_SIZE)
|
|
289
|
+
|
|
290
|
+
# Write batches
|
|
291
|
+
for batch in batches:
|
|
292
|
+
with table.batch_writer() as writer:
|
|
293
|
+
for item in batch:
|
|
294
|
+
writer.put_item(Item=item)
|
|
295
|
+
|
|
296
|
+
def bulk_delete(self, model: InLayersModel, ids: list[PrimaryKeyType]) -> None:
|
|
297
|
+
"""Bulk delete items by IDs."""
|
|
298
|
+
self.__ensure_connected()
|
|
299
|
+
|
|
300
|
+
table_name = get_table_name_for_model(model.get_model_definition())
|
|
301
|
+
table = self.__table_client.Table(table_name)
|
|
302
|
+
pk_name = model.get_primary_key_name()
|
|
303
|
+
|
|
304
|
+
# Prepare keys
|
|
305
|
+
keys = [{pk_name: str(id)} for id in ids]
|
|
306
|
+
|
|
307
|
+
# Split into batches
|
|
308
|
+
batches = split_array_into_batches(keys, MAX_BATCH_WRITE_SIZE)
|
|
309
|
+
|
|
310
|
+
# Delete batches
|
|
311
|
+
for batch in batches:
|
|
312
|
+
with table.batch_writer() as writer:
|
|
313
|
+
for key in batch:
|
|
314
|
+
writer.delete_item(Key=key)
|
|
315
|
+
|
|
316
|
+
def dispose(self) -> None:
|
|
317
|
+
"""Clean up resources."""
|
|
318
|
+
self.__disconnect()
|
|
File without changes
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Pure functional utilities for MongoDB query conversion and data formatting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from in_layers.core.models.protocols import (
|
|
11
|
+
BooleanQuery,
|
|
12
|
+
DatastoreValueType,
|
|
13
|
+
EqualitySymbol,
|
|
14
|
+
ModelDefinition,
|
|
15
|
+
PropertyQuery,
|
|
16
|
+
QueryTokens,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_collection_name_for_model(model_definition: ModelDefinition) -> str:
|
|
21
|
+
"""Generate a MongoDB collection name from a model definition."""
|
|
22
|
+
name = model_definition.plural_name.replace("@", "").replace("/", "-")
|
|
23
|
+
# Convert to kebab-case: insert hyphens before uppercase letters (except first)
|
|
24
|
+
# and handle sequences of uppercase letters
|
|
25
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name)
|
|
26
|
+
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
|
|
27
|
+
return name.lower()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def escape_regex(s: str) -> str:
|
|
31
|
+
"""Escape special regex characters in a string."""
|
|
32
|
+
return re.escape(s)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_string_pattern(
|
|
36
|
+
raw: str, starts_with: bool, ends_with: bool, includes: bool
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Build a regex pattern for string matching."""
|
|
39
|
+
escaped = escape_regex(raw)
|
|
40
|
+
if starts_with:
|
|
41
|
+
return f"^{escaped}"
|
|
42
|
+
if ends_with:
|
|
43
|
+
return f"{escaped}$"
|
|
44
|
+
if includes:
|
|
45
|
+
return escaped
|
|
46
|
+
# default: exact match
|
|
47
|
+
return f"^{escaped}$"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def regex_object_for_pattern(pattern: str, case_sensitive: bool) -> dict[str, Any]:
|
|
51
|
+
"""Create a MongoDB regex object from a pattern."""
|
|
52
|
+
if case_sensitive:
|
|
53
|
+
return {"$regex": pattern}
|
|
54
|
+
return {"$regex": pattern, "$options": "i"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_mongo_find_value(query: PropertyQuery) -> dict[str, Any]: # noqa: PLR0911
|
|
58
|
+
"""Convert a PropertyQuery to a MongoDB query value."""
|
|
59
|
+
value = query.value
|
|
60
|
+
if value is None:
|
|
61
|
+
return {query.key: None}
|
|
62
|
+
|
|
63
|
+
# Handle date objects
|
|
64
|
+
if hasattr(value, "isoformat"): # datetime objects
|
|
65
|
+
return {query.key: value}
|
|
66
|
+
|
|
67
|
+
if query.value_type == DatastoreValueType.string:
|
|
68
|
+
case_sensitive = bool(query.options.case_sensitive)
|
|
69
|
+
starts_with = bool(query.options.starts_with)
|
|
70
|
+
ends_with = bool(query.options.ends_with)
|
|
71
|
+
includes = bool(query.options.includes)
|
|
72
|
+
|
|
73
|
+
if query.equality_symbol not in (EqualitySymbol.eq, EqualitySymbol.ne):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"Symbol {query.equality_symbol} is unhandled for string type"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
raw = str(value)
|
|
79
|
+
pattern = build_string_pattern(raw, starts_with, ends_with, includes)
|
|
80
|
+
regex_obj = regex_object_for_pattern(pattern, case_sensitive)
|
|
81
|
+
|
|
82
|
+
if query.equality_symbol == EqualitySymbol.ne:
|
|
83
|
+
is_plain_exact = (
|
|
84
|
+
not starts_with and not ends_with and not includes and case_sensitive
|
|
85
|
+
)
|
|
86
|
+
if is_plain_exact:
|
|
87
|
+
return {query.key: {"$ne": raw}}
|
|
88
|
+
return {query.key: {"$not": regex_obj}}
|
|
89
|
+
|
|
90
|
+
use_regex = starts_with or ends_with or includes or not case_sensitive
|
|
91
|
+
if use_regex:
|
|
92
|
+
return {query.key: regex_obj}
|
|
93
|
+
return {query.key: raw}
|
|
94
|
+
|
|
95
|
+
if query.value_type == DatastoreValueType.number:
|
|
96
|
+
equality_symbol_to_mongo = {
|
|
97
|
+
EqualitySymbol.eq: "$eq",
|
|
98
|
+
EqualitySymbol.gt: "$gt",
|
|
99
|
+
EqualitySymbol.gte: "$gte",
|
|
100
|
+
EqualitySymbol.lt: "$lt",
|
|
101
|
+
EqualitySymbol.lte: "$lte",
|
|
102
|
+
EqualitySymbol.ne: "$ne",
|
|
103
|
+
}
|
|
104
|
+
mongo_symbol = equality_symbol_to_mongo.get(query.equality_symbol)
|
|
105
|
+
if not mongo_symbol:
|
|
106
|
+
raise ValueError(f"Symbol {query.equality_symbol} is unhandled")
|
|
107
|
+
return {query.key: {mongo_symbol: query.value}}
|
|
108
|
+
|
|
109
|
+
return {query.key: value}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def threeitize(
|
|
113
|
+
data: list[QueryTokens],
|
|
114
|
+
) -> list[tuple[QueryTokens, BooleanQuery, QueryTokens]]:
|
|
115
|
+
"""Convert a list of tokens into three-tuples of (left, link, right)."""
|
|
116
|
+
if len(data) in (0, 1):
|
|
117
|
+
return []
|
|
118
|
+
if len(data) % 2 == 0:
|
|
119
|
+
raise ValueError("Must be an odd number of 3 or greater.")
|
|
120
|
+
three = (data[0], _as_link(data[1]), data[2])
|
|
121
|
+
rest = data[2:]
|
|
122
|
+
more = threeitize(rest)
|
|
123
|
+
return [three, *more]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _as_link(value: QueryTokens) -> BooleanQuery:
|
|
127
|
+
"""Convert a token to a BooleanQuery link."""
|
|
128
|
+
if value in {"AND", "OR"}:
|
|
129
|
+
return value
|
|
130
|
+
raise ValueError("Must have AND/OR between statements")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def process_mongo_array(
|
|
134
|
+
tokens: list[QueryTokens],
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Process an array of query tokens into a MongoDB query."""
|
|
137
|
+
# If we don't have any AND/OR, it's all an AND
|
|
138
|
+
if all(t != "AND" and t != "OR" for t in tokens): # noqa: PLR1714
|
|
139
|
+
return {"$and": [handle_mongo_query(t) for t in tokens]}
|
|
140
|
+
|
|
141
|
+
# Process with threeitize
|
|
142
|
+
threes = threeitize(tokens)
|
|
143
|
+
threes.reverse()
|
|
144
|
+
result: dict[str, Any] = {}
|
|
145
|
+
for a, link, b in threes:
|
|
146
|
+
a_query = handle_mongo_query(a)
|
|
147
|
+
if result:
|
|
148
|
+
result = {f"${link.lower()}": [a_query, result]}
|
|
149
|
+
else:
|
|
150
|
+
b_query = handle_mongo_query(b)
|
|
151
|
+
result = {f"${link.lower()}": [a_query, b_query]}
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def handle_mongo_query(token: QueryTokens | list[QueryTokens]) -> dict[str, Any]:
|
|
156
|
+
"""Convert a query token to a MongoDB query object."""
|
|
157
|
+
# Handle list of tokens (the main query)
|
|
158
|
+
if isinstance(token, list):
|
|
159
|
+
return process_mongo_array(token)
|
|
160
|
+
|
|
161
|
+
# Check by shape (duck typing) rather than isinstance
|
|
162
|
+
# PropertyQuery has type="property" and value attribute
|
|
163
|
+
if (
|
|
164
|
+
hasattr(token, "type")
|
|
165
|
+
and getattr(token, "type", None) == "property"
|
|
166
|
+
and hasattr(token, "value")
|
|
167
|
+
):
|
|
168
|
+
return build_mongo_find_value(token)
|
|
169
|
+
|
|
170
|
+
# DatesBeforeQuery has type="datesBefore" and date attribute
|
|
171
|
+
if (
|
|
172
|
+
hasattr(token, "type")
|
|
173
|
+
and getattr(token, "type", None) == "datesBefore"
|
|
174
|
+
and hasattr(token, "date")
|
|
175
|
+
):
|
|
176
|
+
date_value = token.date
|
|
177
|
+
if (
|
|
178
|
+
hasattr(token, "value_type") and token.value_type == DatastoreValueType.date
|
|
179
|
+
) and isinstance(date_value, str):
|
|
180
|
+
# Convert string to datetime if needed
|
|
181
|
+
date_value = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
|
182
|
+
operator = (
|
|
183
|
+
"$lte"
|
|
184
|
+
if (hasattr(token, "options") and token.options.equal_to_and_before)
|
|
185
|
+
else "$lt"
|
|
186
|
+
)
|
|
187
|
+
return {token.key: {operator: date_value}}
|
|
188
|
+
|
|
189
|
+
# DatesAfterQuery has type="datesAfter" and date attribute
|
|
190
|
+
if (
|
|
191
|
+
hasattr(token, "type")
|
|
192
|
+
and getattr(token, "type", None) == "datesAfter"
|
|
193
|
+
and hasattr(token, "date")
|
|
194
|
+
):
|
|
195
|
+
date_value = token.date
|
|
196
|
+
if (
|
|
197
|
+
hasattr(token, "value_type") and token.value_type == DatastoreValueType.date
|
|
198
|
+
) and isinstance(date_value, str):
|
|
199
|
+
date_value = datetime.fromisoformat(date_value.replace("Z", "+00:00"))
|
|
200
|
+
operator = (
|
|
201
|
+
"$gte"
|
|
202
|
+
if (hasattr(token, "options") and token.options.equal_to_and_after)
|
|
203
|
+
else "$gt"
|
|
204
|
+
)
|
|
205
|
+
return {token.key: {operator: date_value}}
|
|
206
|
+
|
|
207
|
+
raise ValueError(f"Unhandled query token {token}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def to_mongo(query: list[QueryTokens]) -> list[dict[str, Any]]:
|
|
211
|
+
"""Convert a query list to MongoDB aggregation pipeline stages."""
|
|
212
|
+
if not query:
|
|
213
|
+
return [{"$match": {}}]
|
|
214
|
+
# Pass the list directly to handle_mongo_query, which will process it as an array
|
|
215
|
+
match_query = handle_mongo_query(query)
|
|
216
|
+
return [{"$match": match_query}]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def format_for_mongo(data: Mapping[str, Any]) -> dict[str, Any]:
|
|
220
|
+
"""Format data for MongoDB storage, converting dates and other types."""
|
|
221
|
+
result = dict(data)
|
|
222
|
+
# Convert datetime objects (they're already in the right format for MongoDB)
|
|
223
|
+
# In the TypeScript version, this iterates over model properties to find Datetime types
|
|
224
|
+
# For Python, we'll preserve datetime objects as-is (MongoDB handles them natively)
|
|
225
|
+
# and convert string ISO dates if they're clearly datetime values
|
|
226
|
+
for key, value in result.items():
|
|
227
|
+
if isinstance(value, datetime):
|
|
228
|
+
result[key] = value
|
|
229
|
+
return result
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""MongoDB backend implementation for InLayers models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from box import Box
|
|
10
|
+
from in_layers.core.models.protocols import (
|
|
11
|
+
InLayersModel,
|
|
12
|
+
ModelSearch,
|
|
13
|
+
ModelSearchResult,
|
|
14
|
+
PrimaryKeyType,
|
|
15
|
+
SortOrder,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from in_layers.data.protocols import MongoBackendConfig
|
|
19
|
+
|
|
20
|
+
from .libs import (
|
|
21
|
+
format_for_mongo,
|
|
22
|
+
get_collection_name_for_model,
|
|
23
|
+
to_mongo,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MongoBackend:
|
|
28
|
+
"""MongoDB backend implementation."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config: MongoBackendConfig):
|
|
31
|
+
self.__config = config
|
|
32
|
+
self.__client: Any = None
|
|
33
|
+
self.__db: Any = None
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def create_unique_connection_string(config: MongoBackendConfig) -> str:
|
|
37
|
+
"""Create a unique connection string from config."""
|
|
38
|
+
host = config.host
|
|
39
|
+
port = config.port
|
|
40
|
+
username = config.username
|
|
41
|
+
password = config.password
|
|
42
|
+
database = config.database
|
|
43
|
+
|
|
44
|
+
if username and password:
|
|
45
|
+
return f"mongodb://{username}:{password}@{host}:{port}/{database}"
|
|
46
|
+
return f"mongodb://{host}:{port}/{database}"
|
|
47
|
+
|
|
48
|
+
def __connect(self) -> None:
|
|
49
|
+
"""Connect to MongoDB (private method)."""
|
|
50
|
+
from pymongo import MongoClient # noqa: PLC0415
|
|
51
|
+
|
|
52
|
+
connection_string = self.create_unique_connection_string(
|
|
53
|
+
{
|
|
54
|
+
"host": self.__config.host,
|
|
55
|
+
"port": self.__config.port,
|
|
56
|
+
"username": self.__config.username,
|
|
57
|
+
"password": self.__config.password,
|
|
58
|
+
"database": self.__config.database,
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
self.__client = MongoClient(connection_string)
|
|
62
|
+
self.__db = self.__client[self.__config.database]
|
|
63
|
+
|
|
64
|
+
def __disconnect(self) -> None:
|
|
65
|
+
"""Disconnect from MongoDB (private method)."""
|
|
66
|
+
if self.__client:
|
|
67
|
+
self.__client.close()
|
|
68
|
+
self.__client = None
|
|
69
|
+
self.__db = None
|
|
70
|
+
|
|
71
|
+
def __ensure_connected(self) -> None:
|
|
72
|
+
"""Ensure MongoDB connection is established."""
|
|
73
|
+
if self.__db is None:
|
|
74
|
+
self.__connect()
|
|
75
|
+
|
|
76
|
+
def create(self, model: InLayersModel, data: Mapping) -> Mapping:
|
|
77
|
+
"""Create a new document in MongoDB."""
|
|
78
|
+
self.__ensure_connected()
|
|
79
|
+
|
|
80
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
81
|
+
collection = self.__db[collection_name]
|
|
82
|
+
payload = dict(data)
|
|
83
|
+
formatted = format_for_mongo(payload)
|
|
84
|
+
|
|
85
|
+
pk_name = model.get_primary_key_name()
|
|
86
|
+
pk_value = formatted.get(pk_name)
|
|
87
|
+
if pk_value is None:
|
|
88
|
+
# Generate a simple ID - in production you might want UUID
|
|
89
|
+
|
|
90
|
+
pk_value = str(uuid4())
|
|
91
|
+
formatted[pk_name] = pk_value
|
|
92
|
+
|
|
93
|
+
# Use _id as MongoDB's primary key, mapping from model's primary key
|
|
94
|
+
insert_data = {**formatted, "_id": pk_value}
|
|
95
|
+
collection.insert_one(insert_data)
|
|
96
|
+
# Return without _id
|
|
97
|
+
result = {k: v for k, v in insert_data.items() if k != "_id"}
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
def retrieve(self, model: InLayersModel, id: PrimaryKeyType) -> Mapping | None:
|
|
101
|
+
"""Retrieve a document by ID."""
|
|
102
|
+
self.__ensure_connected()
|
|
103
|
+
|
|
104
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
105
|
+
collection = self.__db[collection_name]
|
|
106
|
+
doc = collection.find_one({"_id": id})
|
|
107
|
+
if not doc:
|
|
108
|
+
return None
|
|
109
|
+
# Remove _id and return
|
|
110
|
+
result = {k: v for k, v in doc.items() if k != "_id"}
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
def update(
|
|
114
|
+
self, model: InLayersModel, id: PrimaryKeyType, data: Mapping
|
|
115
|
+
) -> Mapping:
|
|
116
|
+
"""Update a document by ID."""
|
|
117
|
+
self.__ensure_connected()
|
|
118
|
+
|
|
119
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
120
|
+
collection = self.__db[collection_name]
|
|
121
|
+
|
|
122
|
+
# Check if document exists
|
|
123
|
+
existing = collection.find_one({"_id": id})
|
|
124
|
+
if not existing:
|
|
125
|
+
raise KeyError(f"Instance with id {id!r} not found")
|
|
126
|
+
|
|
127
|
+
payload = dict(data)
|
|
128
|
+
formatted = format_for_mongo(payload)
|
|
129
|
+
|
|
130
|
+
# Ensure primary key field remains consistent
|
|
131
|
+
pk_name = model.get_primary_key_name()
|
|
132
|
+
formatted[pk_name] = id
|
|
133
|
+
|
|
134
|
+
# Update with _id mapping
|
|
135
|
+
update_data = {**formatted, "_id": id}
|
|
136
|
+
collection.update_one({"_id": id}, {"$set": update_data})
|
|
137
|
+
|
|
138
|
+
# Return without _id
|
|
139
|
+
result = {k: v for k, v in update_data.items() if k != "_id"}
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
def delete(self, model: InLayersModel, id: PrimaryKeyType) -> None:
|
|
143
|
+
"""Delete a document by ID."""
|
|
144
|
+
self.__ensure_connected()
|
|
145
|
+
|
|
146
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
147
|
+
collection = self.__db[collection_name]
|
|
148
|
+
collection.delete_one({"_id": id})
|
|
149
|
+
|
|
150
|
+
def search(self, model: InLayersModel, query: ModelSearch) -> ModelSearchResult:
|
|
151
|
+
"""Search for documents matching the query."""
|
|
152
|
+
self.__ensure_connected()
|
|
153
|
+
|
|
154
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
155
|
+
collection = self.__db[collection_name]
|
|
156
|
+
|
|
157
|
+
# Build aggregation pipeline
|
|
158
|
+
pipeline = []
|
|
159
|
+
|
|
160
|
+
# Add match stage if there's a query
|
|
161
|
+
if query.query:
|
|
162
|
+
mongo_query = to_mongo(query.query)
|
|
163
|
+
pipeline.extend(mongo_query)
|
|
164
|
+
else:
|
|
165
|
+
pipeline.append({"$match": {}})
|
|
166
|
+
|
|
167
|
+
# Add sort stage if needed
|
|
168
|
+
if query.sort:
|
|
169
|
+
sort_direction = 1 if query.sort.order == SortOrder.asc else -1
|
|
170
|
+
pipeline.append({"$sort": {query.sort.key: sort_direction}})
|
|
171
|
+
|
|
172
|
+
# Add limit stage if needed
|
|
173
|
+
if query.take:
|
|
174
|
+
pipeline.append({"$limit": query.take})
|
|
175
|
+
|
|
176
|
+
# Execute aggregation
|
|
177
|
+
results = list(collection.aggregate(pipeline))
|
|
178
|
+
instances = [{k: v for k, v in doc.items() if k != "_id"} for doc in results]
|
|
179
|
+
|
|
180
|
+
return Box(instances=instances, page=query.page)
|
|
181
|
+
|
|
182
|
+
def bulk_insert(self, model: InLayersModel, data: list[Mapping]) -> None:
|
|
183
|
+
"""Bulk insert documents."""
|
|
184
|
+
from pymongo.operations import UpdateOne # noqa: PLC0415
|
|
185
|
+
|
|
186
|
+
self.__ensure_connected()
|
|
187
|
+
|
|
188
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
189
|
+
collection = self.__db[collection_name]
|
|
190
|
+
pk_name = model.get_primary_key_name()
|
|
191
|
+
|
|
192
|
+
# Prepare bulk write operations
|
|
193
|
+
operations = []
|
|
194
|
+
for item in data:
|
|
195
|
+
payload = dict(item)
|
|
196
|
+
formatted = format_for_mongo(payload)
|
|
197
|
+
|
|
198
|
+
pk_value = formatted.get(pk_name)
|
|
199
|
+
if pk_value is None:
|
|
200
|
+
pk_value = str(uuid4())
|
|
201
|
+
formatted[pk_name] = pk_value
|
|
202
|
+
|
|
203
|
+
doc = {**formatted, "_id": pk_value}
|
|
204
|
+
operations.append(
|
|
205
|
+
UpdateOne(
|
|
206
|
+
{"_id": pk_value},
|
|
207
|
+
{"$set": doc},
|
|
208
|
+
upsert=True,
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if operations:
|
|
213
|
+
collection.bulk_write(operations)
|
|
214
|
+
|
|
215
|
+
def bulk_delete(self, model: InLayersModel, ids: list[PrimaryKeyType]) -> None:
|
|
216
|
+
"""Bulk delete documents by IDs."""
|
|
217
|
+
self.__ensure_connected()
|
|
218
|
+
|
|
219
|
+
collection_name = get_collection_name_for_model(model.get_model_definition())
|
|
220
|
+
collection = self.__db[collection_name]
|
|
221
|
+
collection.delete_many({"_id": {"$in": ids}})
|
|
222
|
+
|
|
223
|
+
def dispose(self) -> None:
|
|
224
|
+
"""Clean up resources."""
|
|
225
|
+
self.__disconnect()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SupportedBackend(Enum):
|
|
7
|
+
MongoDB = "mongodb"
|
|
8
|
+
DynamoDB = "dynamodb"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MongoBackendConfig(Protocol):
|
|
12
|
+
type: SupportedBackend
|
|
13
|
+
host: str
|
|
14
|
+
port: int | None
|
|
15
|
+
username: str | None
|
|
16
|
+
password: str | None
|
|
17
|
+
database: str | None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DynamoDBBackendConfig(Protocol):
|
|
21
|
+
type: SupportedBackend
|
|
22
|
+
region: str | None
|
|
23
|
+
endpoint_url: str | None
|
|
24
|
+
aws_access_key_id: str | None
|
|
25
|
+
aws_secret_access_key: str | None
|
|
26
|
+
boto3: Any | None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
BackendConfig = MongoBackendConfig | DynamoDBBackendConfig
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DataNamespace(Enum):
|
|
33
|
+
root = "in_layers_data"
|
|
34
|
+
backends = "in_layers_data_backends"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InLayersDataConfig(Protocol):
|
|
38
|
+
default: BackendConfig
|
|
39
|
+
model_to_backend: Mapping[str, BackendConfig] | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WithInLayersDataConfig(Protocol):
|
|
43
|
+
config: InLayersDataConfig
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from in_layers.core.models.protocols import BackendProtocol, ModelDefinition
|
|
4
|
+
|
|
5
|
+
from .backends.dynamodb.services import DynamoDBBackend
|
|
6
|
+
from .backends.mongodb.services import MongoBackend
|
|
7
|
+
from .protocols import (
|
|
8
|
+
BackendConfig,
|
|
9
|
+
SupportedBackend,
|
|
10
|
+
WithInLayersDataConfig,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _ExpectedContext(Protocol):
|
|
15
|
+
config: WithInLayersDataConfig
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InLayersDataServices:
|
|
19
|
+
def __init__(self, context: _ExpectedContext):
|
|
20
|
+
self.__context = context
|
|
21
|
+
self.__backend_by_unique_key = {}
|
|
22
|
+
self.__backends = None
|
|
23
|
+
|
|
24
|
+
def __initialize_backend(self, config: BackendConfig) -> BackendProtocol:
|
|
25
|
+
if config.type == SupportedBackend.MongoDB:
|
|
26
|
+
unique = MongoBackend.create_unique_connection_string(config)
|
|
27
|
+
if unique in self.__backend_by_unique_key:
|
|
28
|
+
return self.__backend_by_unique_key[unique]
|
|
29
|
+
backend = MongoBackend(config)
|
|
30
|
+
self.__backend_by_unique_key[unique] = backend
|
|
31
|
+
return backend
|
|
32
|
+
elif config.type == SupportedBackend.DynamoDB:
|
|
33
|
+
unique = DynamoDBBackend.create_unique_connection_string(config)
|
|
34
|
+
if unique in self.__backend_by_unique_key:
|
|
35
|
+
return self.__backend_by_unique_key[unique]
|
|
36
|
+
backend = DynamoDBBackend(config)
|
|
37
|
+
self.__backend_by_unique_key[unique] = backend
|
|
38
|
+
return backend
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"Unsupported backend type: {config.type}")
|
|
41
|
+
|
|
42
|
+
def __initialize_backends(self):
|
|
43
|
+
if self.__backends is not None:
|
|
44
|
+
return
|
|
45
|
+
config = self.__context.config.simple_models
|
|
46
|
+
default_backend_config = config.default
|
|
47
|
+
model_to_backend_config = getattr(config, "model_to_backend", {}) or {}
|
|
48
|
+
|
|
49
|
+
self.__backends = {"default": self.__initialize_backend(default_backend_config)}
|
|
50
|
+
for model_key, backend_config in model_to_backend_config.items():
|
|
51
|
+
backend_instance = self.__initialize_backend(backend_config)
|
|
52
|
+
self.__backends[model_key] = backend_instance
|
|
53
|
+
|
|
54
|
+
def __get_backend_for_model(self, meta: ModelDefinition) -> BackendProtocol:
|
|
55
|
+
self.__initialize_backends()
|
|
56
|
+
model_key_full = f"{meta.domain}.{meta.plural_name}"
|
|
57
|
+
if model_key_full in self.__backends:
|
|
58
|
+
return self.__backends[model_key_full]
|
|
59
|
+
if meta.domain in self.__backends:
|
|
60
|
+
return self.__backends[meta.domain]
|
|
61
|
+
return self.__backends["default"]
|
|
62
|
+
|
|
63
|
+
def get_model_backend(self, model_definition: ModelDefinition) -> BackendProtocol:
|
|
64
|
+
backend = self.__get_backend_for_model(model_definition)
|
|
65
|
+
if not backend:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"No backend found for model {model_definition.domain}.{model_definition.plural_name}"
|
|
68
|
+
)
|
|
69
|
+
return backend
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create(context: _ExpectedContext) -> InLayersDataServices:
|
|
73
|
+
return InLayersDataServices(context)
|