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.
@@ -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,7 @@
1
+ """Namespace package for in_layers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pkgutil
6
+
7
+ __path__ = pkgutil.extend_path(__path__, __name__) # type: ignore[name-defined]
@@ -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
+ ]
@@ -0,0 +1,5 @@
1
+ """DynamoDB backend for InLayers models."""
2
+
3
+ from .services import DynamoDBBackend
4
+
5
+ __all__ = ["DynamoDBBackend"]
@@ -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()
@@ -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)