json-object-mapper 2.0.0__tar.gz → 2.1.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.
- {json_object_mapper-2.0.0/src/json_object_mapper.egg-info → json_object_mapper-2.1.0}/PKG-INFO +32 -1
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/README.md +31 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/pyproject.toml +11 -2
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/__init__.py +2 -1
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/core.py +50 -2
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/helpers.py +12 -8
- json_object_mapper-2.1.0/src/json_object_mapper/py.typed +0 -0
- json_object_mapper-2.1.0/src/json_object_mapper/query.py +149 -0
- json_object_mapper-2.1.0/src/json_object_mapper/queryable.py +80 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0/src/json_object_mapper.egg-info}/PKG-INFO +32 -1
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/SOURCES.txt +7 -1
- json_object_mapper-2.1.0/tests/test_query_engine.py +165 -0
- json_object_mapper-2.1.0/tests/test_query_parser.py +70 -0
- json_object_mapper-2.1.0/tests/test_queryable_list.py +79 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/LICENSE +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/setup.cfg +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/setup.py +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper/exceptions.py +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/dependency_links.txt +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/top_level.txt +0 -0
- {json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/tests/test_json_object_mapper.py +0 -0
{json_object_mapper-2.0.0/src/json_object_mapper.egg-info → json_object_mapper-2.1.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json-object-mapper
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults
|
|
5
5
|
Author-email: Kobby Owen <dev@kobbyowen.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -37,12 +37,41 @@ print(obj.to_json(indent=2))
|
|
|
37
37
|
|
|
38
38
|
- Attribute-style access (`obj.key`) for dict keys
|
|
39
39
|
- Recursive wrapping for nested dicts and lists
|
|
40
|
+
- Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
|
|
41
|
+
- ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
|
|
40
42
|
- Read-only mode (immutability enforced)
|
|
41
43
|
- Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
|
|
42
44
|
- **New:** `set_path()` / `del_path()` for dot paths
|
|
43
45
|
- **New:** `default_factory` + `autocreate_missing` for safe defaults
|
|
44
46
|
- Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
|
|
45
47
|
|
|
48
|
+
## Release Notes
|
|
49
|
+
|
|
50
|
+
### v2.1.0
|
|
51
|
+
|
|
52
|
+
- Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
|
|
53
|
+
- Added JSONPath-like query API:
|
|
54
|
+
- `obj.query(expression, first=False, default=None)`
|
|
55
|
+
- `obj.exists(expression)`
|
|
56
|
+
- `obj.count(expression)`
|
|
57
|
+
- `obj.compile(expression)`
|
|
58
|
+
- Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
|
|
59
|
+
- Isolated query implementation into a dedicated module for maintainability.
|
|
60
|
+
- Added ORM-style list querying through `QueryableList`:
|
|
61
|
+
- `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
|
|
62
|
+
- Supports nested field filters such as `profile__age__gt=20`.
|
|
63
|
+
- Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
|
|
64
|
+
- Added strict type-checking support:
|
|
65
|
+
- mypy configuration in project metadata
|
|
66
|
+
- `py.typed` marker for typed package support
|
|
67
|
+
- type fixes in mapper and queryable helpers
|
|
68
|
+
- CI improvements:
|
|
69
|
+
- Ruff linting
|
|
70
|
+
- mypy checks
|
|
71
|
+
- multi-version unit tests
|
|
72
|
+
- build and package validation
|
|
73
|
+
- Publishing workflow configured for PyPI release automation.
|
|
74
|
+
|
|
46
75
|
## Install
|
|
47
76
|
|
|
48
77
|
```bash
|
|
@@ -102,6 +131,8 @@ except AttributeError:
|
|
|
102
131
|
```bash
|
|
103
132
|
python -m pip install -e .
|
|
104
133
|
python -m unittest discover -s tests -v
|
|
134
|
+
venv/bin/ruff check .
|
|
135
|
+
venv/bin/mypy .
|
|
105
136
|
```
|
|
106
137
|
|
|
107
138
|
## Publishing
|
|
@@ -22,12 +22,41 @@ print(obj.to_json(indent=2))
|
|
|
22
22
|
|
|
23
23
|
- Attribute-style access (`obj.key`) for dict keys
|
|
24
24
|
- Recursive wrapping for nested dicts and lists
|
|
25
|
+
- Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
|
|
26
|
+
- ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
|
|
25
27
|
- Read-only mode (immutability enforced)
|
|
26
28
|
- Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
|
|
27
29
|
- **New:** `set_path()` / `del_path()` for dot paths
|
|
28
30
|
- **New:** `default_factory` + `autocreate_missing` for safe defaults
|
|
29
31
|
- Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
|
|
30
32
|
|
|
33
|
+
## Release Notes
|
|
34
|
+
|
|
35
|
+
### v2.1.0
|
|
36
|
+
|
|
37
|
+
- Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
|
|
38
|
+
- Added JSONPath-like query API:
|
|
39
|
+
- `obj.query(expression, first=False, default=None)`
|
|
40
|
+
- `obj.exists(expression)`
|
|
41
|
+
- `obj.count(expression)`
|
|
42
|
+
- `obj.compile(expression)`
|
|
43
|
+
- Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
|
|
44
|
+
- Isolated query implementation into a dedicated module for maintainability.
|
|
45
|
+
- Added ORM-style list querying through `QueryableList`:
|
|
46
|
+
- `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
|
|
47
|
+
- Supports nested field filters such as `profile__age__gt=20`.
|
|
48
|
+
- Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
|
|
49
|
+
- Added strict type-checking support:
|
|
50
|
+
- mypy configuration in project metadata
|
|
51
|
+
- `py.typed` marker for typed package support
|
|
52
|
+
- type fixes in mapper and queryable helpers
|
|
53
|
+
- CI improvements:
|
|
54
|
+
- Ruff linting
|
|
55
|
+
- mypy checks
|
|
56
|
+
- multi-version unit tests
|
|
57
|
+
- build and package validation
|
|
58
|
+
- Publishing workflow configured for PyPI release automation.
|
|
59
|
+
|
|
31
60
|
## Install
|
|
32
61
|
|
|
33
62
|
```bash
|
|
@@ -87,6 +116,8 @@ except AttributeError:
|
|
|
87
116
|
```bash
|
|
88
117
|
python -m pip install -e .
|
|
89
118
|
python -m unittest discover -s tests -v
|
|
119
|
+
venv/bin/ruff check .
|
|
120
|
+
venv/bin/mypy .
|
|
90
121
|
```
|
|
91
122
|
|
|
92
123
|
## Publishing
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "json-object-mapper"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.1.0"
|
|
8
8
|
description = "Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -27,6 +27,9 @@ include-package-data = true
|
|
|
27
27
|
[tool.setuptools.packages.find]
|
|
28
28
|
where = ["src"]
|
|
29
29
|
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
json_object_mapper = ["py.typed"]
|
|
32
|
+
|
|
30
33
|
[tool.pytest.ini_options]
|
|
31
34
|
addopts = "-q"
|
|
32
35
|
|
|
@@ -45,4 +48,10 @@ per-file-ignores = { "tests/*" = ["PLR2004"] } # allow magic numbers in tests
|
|
|
45
48
|
[tool.ruff.format] # only used if you run "ruff format"
|
|
46
49
|
quote-style = "double"
|
|
47
50
|
indent-style = "space"
|
|
48
|
-
skip-magic-trailing-comma = false
|
|
51
|
+
skip-magic-trailing-comma = false
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
python_version = "3.9"
|
|
55
|
+
files = ["src", "tests"]
|
|
56
|
+
mypy_path = "src"
|
|
57
|
+
exclude = '(^build/|^dist/|^venv/|^\.venv/|^\.mypy_cache/|^setup\.py$)'
|
|
@@ -26,6 +26,7 @@ from .helpers import (
|
|
|
26
26
|
traverse_parent,
|
|
27
27
|
wrap_value,
|
|
28
28
|
)
|
|
29
|
+
from .query import evaluate_query, evaluate_tokens, tokenize
|
|
29
30
|
|
|
30
31
|
JSONScalar = Union[str, int, float, bool, None]
|
|
31
32
|
JSONType = Union[JSONScalar, "JSONObjectMapper", List["JSONObjectMapper"], Dict[str, Any]]
|
|
@@ -121,7 +122,10 @@ class JSONObjectMapper:
|
|
|
121
122
|
raise AttributeError("Cannot set attributes on a list")
|
|
122
123
|
if not is_identifier(attribute_name):
|
|
123
124
|
raise AttributeError(f"'{attribute_name}' is not a valid attribute name")
|
|
124
|
-
self.__json
|
|
125
|
+
data = self.__json
|
|
126
|
+
if not isinstance(data, dict):
|
|
127
|
+
raise AttributeError("Cannot set attributes on a non-dict root")
|
|
128
|
+
data[attribute_name] = value
|
|
125
129
|
|
|
126
130
|
# ----------------------------- mapping access -----------------------------
|
|
127
131
|
|
|
@@ -135,7 +139,18 @@ class JSONObjectMapper:
|
|
|
135
139
|
def __setitem__(self, key: Union[int, str], value: Any) -> None:
|
|
136
140
|
if self.__readonly:
|
|
137
141
|
raise AttributeError("Mapper is read-only")
|
|
138
|
-
self.__json
|
|
142
|
+
data = self.__json
|
|
143
|
+
if isinstance(data, dict):
|
|
144
|
+
if not isinstance(key, str):
|
|
145
|
+
raise TypeError("Dict roots require string keys")
|
|
146
|
+
data[key] = value
|
|
147
|
+
return
|
|
148
|
+
if isinstance(data, list):
|
|
149
|
+
if not isinstance(key, int):
|
|
150
|
+
raise TypeError("List roots require integer indexes")
|
|
151
|
+
data[key] = value
|
|
152
|
+
return
|
|
153
|
+
raise TypeError("Unsupported root type for item assignment")
|
|
139
154
|
|
|
140
155
|
def __iter__(self) -> Iterator:
|
|
141
156
|
return iter(self.__json)
|
|
@@ -165,6 +180,39 @@ class JSONObjectMapper:
|
|
|
165
180
|
return (self._wrap(value) for value in self.__json.values())
|
|
166
181
|
raise TypeError("values() only valid for dict roots")
|
|
167
182
|
|
|
183
|
+
# -------------------------------- query ops --------------------------------
|
|
184
|
+
|
|
185
|
+
def query(self, expression: str, *, first: bool = False, default: Any = None) -> Any:
|
|
186
|
+
results = self._evaluate_query(expression)
|
|
187
|
+
if not results:
|
|
188
|
+
if default is not None:
|
|
189
|
+
return default
|
|
190
|
+
return None if first else []
|
|
191
|
+
return results[0] if first else results
|
|
192
|
+
|
|
193
|
+
def exists(self, expression: str) -> bool:
|
|
194
|
+
return bool(self._evaluate_query(expression))
|
|
195
|
+
|
|
196
|
+
def count(self, expression: str) -> int:
|
|
197
|
+
return len(self._evaluate_query(expression))
|
|
198
|
+
|
|
199
|
+
def compile(self, expression: str):
|
|
200
|
+
tokens = tokenize(expression)
|
|
201
|
+
|
|
202
|
+
def runner(obj: "JSONObjectMapper") -> List[Any]:
|
|
203
|
+
if not isinstance(obj, JSONObjectMapper):
|
|
204
|
+
raise TypeError("Compiled query expects a JSONObjectMapper instance")
|
|
205
|
+
raw_data = object.__getattribute__(obj, "_JSONObjectMapper__json")
|
|
206
|
+
return evaluate_tokens(raw_data, tokens, obj._wrap)
|
|
207
|
+
|
|
208
|
+
return runner
|
|
209
|
+
|
|
210
|
+
def _evaluate_query(self, expression: str) -> List[Any]:
|
|
211
|
+
return evaluate_query(self.__json, expression, self._wrap)
|
|
212
|
+
|
|
213
|
+
def _evaluate_tokens(self, tokens: List[Any]) -> List[Any]:
|
|
214
|
+
return evaluate_tokens(self.__json, tokens, self._wrap)
|
|
215
|
+
|
|
168
216
|
# -------------------------------- path ops --------------------------------
|
|
169
217
|
|
|
170
218
|
def get_path(self, path: str, default: Any = None) -> Any:
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from typing import Any, Callable, List, Optional
|
|
3
3
|
|
|
4
|
+
from .queryable import QueryableList
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
def is_identifier(key: str) -> bool:
|
|
6
8
|
return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key))
|
|
@@ -132,12 +134,14 @@ def wrap_value(
|
|
|
132
134
|
_no_copy=True,
|
|
133
135
|
)
|
|
134
136
|
if isinstance(value, list):
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
return QueryableList(
|
|
138
|
+
[
|
|
139
|
+
(
|
|
140
|
+
wrap_value(item, readonly, default_factory, autocreate_missing, factory_object)
|
|
141
|
+
if isinstance(item, (dict, list))
|
|
142
|
+
else item
|
|
143
|
+
)
|
|
144
|
+
for item in value
|
|
145
|
+
]
|
|
146
|
+
)
|
|
143
147
|
return value
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import Any, Callable, List, Tuple
|
|
3
|
+
|
|
4
|
+
QUERY_OPERATORS = ("==", "!=", ">=", "<=", ">", "<")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def tokenize(expr: str) -> List[Any]:
|
|
8
|
+
tokens: List[Any] = []
|
|
9
|
+
index = 0
|
|
10
|
+
source = expr.strip()
|
|
11
|
+
|
|
12
|
+
while index < len(source):
|
|
13
|
+
char = source[index]
|
|
14
|
+
if char == ".":
|
|
15
|
+
index += 1
|
|
16
|
+
continue
|
|
17
|
+
|
|
18
|
+
if char == "[":
|
|
19
|
+
close = source.find("]", index)
|
|
20
|
+
if close == -1:
|
|
21
|
+
raise ValueError(f"Unclosed bracket in query expression: {expr!r}")
|
|
22
|
+
content = source[index + 1 : close].strip()
|
|
23
|
+
if content == "*":
|
|
24
|
+
tokens.append("*")
|
|
25
|
+
elif content.startswith("?"):
|
|
26
|
+
condition = content[1:].strip()
|
|
27
|
+
if not condition:
|
|
28
|
+
raise ValueError(f"Empty filter condition in query expression: {expr!r}")
|
|
29
|
+
tokens.append({"filter": condition})
|
|
30
|
+
else:
|
|
31
|
+
try:
|
|
32
|
+
tokens.append(int(content))
|
|
33
|
+
except ValueError as error:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"Unsupported bracket token [{content}] in {expr!r}"
|
|
36
|
+
) from error
|
|
37
|
+
index = close + 1
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
start = index
|
|
41
|
+
while index < len(source) and source[index] not in ".[":
|
|
42
|
+
index += 1
|
|
43
|
+
name = source[start:index].strip()
|
|
44
|
+
if name:
|
|
45
|
+
tokens.append(name)
|
|
46
|
+
|
|
47
|
+
return tokens
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def evaluate_query(root: Any, expression: str, wrap: Callable[[Any], Any]) -> List[Any]:
|
|
51
|
+
return evaluate_tokens(root, tokenize(expression), wrap)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def evaluate_tokens(root: Any, tokens: List[Any], wrap: Callable[[Any], Any]) -> List[Any]:
|
|
55
|
+
results: List[Any] = [root]
|
|
56
|
+
for token in tokens:
|
|
57
|
+
next_results: List[Any] = []
|
|
58
|
+
for item in results:
|
|
59
|
+
if isinstance(token, str):
|
|
60
|
+
if token == "*":
|
|
61
|
+
if isinstance(item, list):
|
|
62
|
+
next_results.extend(item)
|
|
63
|
+
elif isinstance(item, dict):
|
|
64
|
+
next_results.extend(item.values())
|
|
65
|
+
continue
|
|
66
|
+
if isinstance(item, dict) and token in item:
|
|
67
|
+
next_results.append(item[token])
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if isinstance(token, int):
|
|
71
|
+
if isinstance(item, list) and 0 <= token < len(item):
|
|
72
|
+
next_results.append(item[token])
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
if isinstance(token, dict) and "filter" in token:
|
|
76
|
+
if isinstance(item, list):
|
|
77
|
+
try:
|
|
78
|
+
next_results.extend(apply_filter(item, token["filter"]))
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
continue
|
|
82
|
+
results = next_results
|
|
83
|
+
|
|
84
|
+
return [wrap(value) for value in results]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_condition(condition: str) -> Tuple[str, str, Any]:
|
|
88
|
+
text = condition.strip()
|
|
89
|
+
for operator in QUERY_OPERATORS:
|
|
90
|
+
if operator in text:
|
|
91
|
+
left, right = text.split(operator, 1)
|
|
92
|
+
field = left.strip()
|
|
93
|
+
value_text = right.strip()
|
|
94
|
+
if not field or not value_text:
|
|
95
|
+
raise ValueError(f"Invalid filter condition: {condition!r}")
|
|
96
|
+
return field, operator, coerce_condition_value(value_text)
|
|
97
|
+
raise ValueError(f"Unsupported filter condition: {condition!r}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def coerce_condition_value(value: str) -> Any:
|
|
101
|
+
result: Any = value
|
|
102
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
103
|
+
value.startswith("'") and value.endswith("'")
|
|
104
|
+
):
|
|
105
|
+
result = value[1:-1]
|
|
106
|
+
elif re.fullmatch(r"-?\d+", value):
|
|
107
|
+
result = int(value)
|
|
108
|
+
elif re.fullmatch(r"-?\d+\.\d+", value):
|
|
109
|
+
result = float(value)
|
|
110
|
+
else:
|
|
111
|
+
lowered = value.lower()
|
|
112
|
+
if lowered == "true":
|
|
113
|
+
result = True
|
|
114
|
+
elif lowered == "false":
|
|
115
|
+
result = False
|
|
116
|
+
elif lowered == "null":
|
|
117
|
+
result = None
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def compare(left: Any, operator: str, right: Any) -> bool:
|
|
122
|
+
operations = {
|
|
123
|
+
"==": lambda a, b: a == b,
|
|
124
|
+
"!=": lambda a, b: a != b,
|
|
125
|
+
">": lambda a, b: a > b,
|
|
126
|
+
"<": lambda a, b: a < b,
|
|
127
|
+
">=": lambda a, b: a >= b,
|
|
128
|
+
"<=": lambda a, b: a <= b,
|
|
129
|
+
}
|
|
130
|
+
fn = operations.get(operator)
|
|
131
|
+
if fn is None:
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
return fn(left, right)
|
|
135
|
+
except TypeError:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def apply_filter(items: List[Any], condition: str) -> List[Any]:
|
|
140
|
+
field, operator, expected = parse_condition(condition)
|
|
141
|
+
matches: List[Any] = []
|
|
142
|
+
for item in items:
|
|
143
|
+
if not isinstance(item, dict):
|
|
144
|
+
continue
|
|
145
|
+
if field not in item:
|
|
146
|
+
continue
|
|
147
|
+
if compare(item[field], operator, expected):
|
|
148
|
+
matches.append(item)
|
|
149
|
+
return matches
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from typing import Any, Dict, Iterable
|
|
2
|
+
|
|
3
|
+
OPS = {"eq", "ne", "gt", "lt", "gte", "lte"}
|
|
4
|
+
_COUNT_SENTINEL = object()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class QueryableList(list):
|
|
8
|
+
"""List wrapper with ORM-like helper methods."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, data: Iterable[Any]):
|
|
11
|
+
super().__init__(data)
|
|
12
|
+
|
|
13
|
+
def filter(self, **conditions: Any) -> "QueryableList":
|
|
14
|
+
results = [item for item in self if _matches(item, conditions)]
|
|
15
|
+
return QueryableList(results)
|
|
16
|
+
|
|
17
|
+
def get(self, **conditions: Any) -> Any:
|
|
18
|
+
results = self.filter(**conditions)
|
|
19
|
+
if len(results) == 0:
|
|
20
|
+
raise ValueError("No matching item found")
|
|
21
|
+
if len(results) > 1:
|
|
22
|
+
raise ValueError("Multiple items found")
|
|
23
|
+
return results[0]
|
|
24
|
+
|
|
25
|
+
def first(self) -> Any:
|
|
26
|
+
return self[0] if self else None
|
|
27
|
+
|
|
28
|
+
def last(self) -> Any:
|
|
29
|
+
return self[-1] if self else None
|
|
30
|
+
|
|
31
|
+
def count(self, value: Any = _COUNT_SENTINEL, /) -> int:
|
|
32
|
+
if value is _COUNT_SENTINEL:
|
|
33
|
+
return len(self)
|
|
34
|
+
return super().count(value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _matches(item: Any, conditions: Dict[str, Any]) -> bool:
|
|
38
|
+
for key, expected in conditions.items():
|
|
39
|
+
field_parts = key.split("__")
|
|
40
|
+
if len(field_parts) > 1 and field_parts[-1] in OPS:
|
|
41
|
+
op = field_parts[-1]
|
|
42
|
+
field_path = "__".join(field_parts[:-1])
|
|
43
|
+
else:
|
|
44
|
+
op = "eq"
|
|
45
|
+
field_path = key
|
|
46
|
+
|
|
47
|
+
actual = _get_nested_attr(item, field_path)
|
|
48
|
+
if not _compare(actual, op, expected):
|
|
49
|
+
return False
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_nested_attr(obj: Any, path: str) -> Any:
|
|
54
|
+
value = obj
|
|
55
|
+
for part in path.split("__"):
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
if isinstance(value, dict):
|
|
59
|
+
value = value.get(part)
|
|
60
|
+
continue
|
|
61
|
+
value = getattr(value, part, None)
|
|
62
|
+
return value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _compare(a: Any, op: str, b: Any) -> bool:
|
|
66
|
+
operations = {
|
|
67
|
+
"eq": lambda left, right: left == right,
|
|
68
|
+
"ne": lambda left, right: left != right,
|
|
69
|
+
"gt": lambda left, right: left > right,
|
|
70
|
+
"lt": lambda left, right: left < right,
|
|
71
|
+
"gte": lambda left, right: left >= right,
|
|
72
|
+
"lte": lambda left, right: left <= right,
|
|
73
|
+
}
|
|
74
|
+
fn = operations.get(op)
|
|
75
|
+
if fn is None:
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
return fn(a, b)
|
|
79
|
+
except TypeError:
|
|
80
|
+
return False
|
{json_object_mapper-2.0.0 → json_object_mapper-2.1.0/src/json_object_mapper.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: json-object-mapper
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Attribute-style access wrapper for JSON-like data with dot-path set/delete and safe defaults
|
|
5
5
|
Author-email: Kobby Owen <dev@kobbyowen.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -37,12 +37,41 @@ print(obj.to_json(indent=2))
|
|
|
37
37
|
|
|
38
38
|
- Attribute-style access (`obj.key`) for dict keys
|
|
39
39
|
- Recursive wrapping for nested dicts and lists
|
|
40
|
+
- Query engine (`query`, `exists`, `count`, `compile`) with wildcard and filter support
|
|
41
|
+
- ORM-like list helpers via `QueryableList` (`filter`, `get`, `first`, `last`, `count`)
|
|
40
42
|
- Read-only mode (immutability enforced)
|
|
41
43
|
- Dot/bracket path lookups (`obj.get_path("a.b[0].c")`)
|
|
42
44
|
- **New:** `set_path()` / `del_path()` for dot paths
|
|
43
45
|
- **New:** `default_factory` + `autocreate_missing` for safe defaults
|
|
44
46
|
- Utility methods: `to_dict`, `to_json`, `from_json`, `merge`
|
|
45
47
|
|
|
48
|
+
## Release Notes
|
|
49
|
+
|
|
50
|
+
### v2.1.0
|
|
51
|
+
|
|
52
|
+
- Renamed import path to `json_object_mapper` and aligned package metadata for the new distribution name.
|
|
53
|
+
- Added JSONPath-like query API:
|
|
54
|
+
- `obj.query(expression, first=False, default=None)`
|
|
55
|
+
- `obj.exists(expression)`
|
|
56
|
+
- `obj.count(expression)`
|
|
57
|
+
- `obj.compile(expression)`
|
|
58
|
+
- Implemented query features: property access, nested access, list indexing, wildcards, nested wildcard flattening, and basic filters (`==`, `!=`, `>`, `<`, `>=`, `<=`).
|
|
59
|
+
- Isolated query implementation into a dedicated module for maintainability.
|
|
60
|
+
- Added ORM-style list querying through `QueryableList`:
|
|
61
|
+
- `filter(...)`, `get(...)`, `first()`, `last()`, `count()`
|
|
62
|
+
- Supports nested field filters such as `profile__age__gt=20`.
|
|
63
|
+
- Expanded test coverage significantly across mapper, query parser, query engine, and queryable list behavior (including deep nested JSON scenarios and edge cases).
|
|
64
|
+
- Added strict type-checking support:
|
|
65
|
+
- mypy configuration in project metadata
|
|
66
|
+
- `py.typed` marker for typed package support
|
|
67
|
+
- type fixes in mapper and queryable helpers
|
|
68
|
+
- CI improvements:
|
|
69
|
+
- Ruff linting
|
|
70
|
+
- mypy checks
|
|
71
|
+
- multi-version unit tests
|
|
72
|
+
- build and package validation
|
|
73
|
+
- Publishing workflow configured for PyPI release automation.
|
|
74
|
+
|
|
46
75
|
## Install
|
|
47
76
|
|
|
48
77
|
```bash
|
|
@@ -102,6 +131,8 @@ except AttributeError:
|
|
|
102
131
|
```bash
|
|
103
132
|
python -m pip install -e .
|
|
104
133
|
python -m unittest discover -s tests -v
|
|
134
|
+
venv/bin/ruff check .
|
|
135
|
+
venv/bin/mypy .
|
|
105
136
|
```
|
|
106
137
|
|
|
107
138
|
## Publishing
|
{json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/SOURCES.txt
RENAMED
|
@@ -6,8 +6,14 @@ src/json_object_mapper/__init__.py
|
|
|
6
6
|
src/json_object_mapper/core.py
|
|
7
7
|
src/json_object_mapper/exceptions.py
|
|
8
8
|
src/json_object_mapper/helpers.py
|
|
9
|
+
src/json_object_mapper/py.typed
|
|
10
|
+
src/json_object_mapper/query.py
|
|
11
|
+
src/json_object_mapper/queryable.py
|
|
9
12
|
src/json_object_mapper.egg-info/PKG-INFO
|
|
10
13
|
src/json_object_mapper.egg-info/SOURCES.txt
|
|
11
14
|
src/json_object_mapper.egg-info/dependency_links.txt
|
|
12
15
|
src/json_object_mapper.egg-info/top_level.txt
|
|
13
|
-
tests/test_json_object_mapper.py
|
|
16
|
+
tests/test_json_object_mapper.py
|
|
17
|
+
tests/test_query_engine.py
|
|
18
|
+
tests/test_query_parser.py
|
|
19
|
+
tests/test_queryable_list.py
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from json_object_mapper import JSONObjectMapper
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestQueryEngine(unittest.TestCase):
|
|
7
|
+
def setUp(self):
|
|
8
|
+
self.data = {
|
|
9
|
+
"users": [
|
|
10
|
+
{
|
|
11
|
+
"name": "Kobby",
|
|
12
|
+
"age": 29,
|
|
13
|
+
"skills": ["python", "aws"],
|
|
14
|
+
"score": 91.5,
|
|
15
|
+
"active": True,
|
|
16
|
+
"nickname": None,
|
|
17
|
+
"profile": {
|
|
18
|
+
"preferences": {
|
|
19
|
+
"theme": "dark",
|
|
20
|
+
"notifications": {"email": True, "sms": False},
|
|
21
|
+
},
|
|
22
|
+
"addresses": [
|
|
23
|
+
{"city": "Accra", "geo": {"lat": 5.6037, "lng": -0.187}},
|
|
24
|
+
{"city": "Kumasi", "geo": {"lat": 6.6885, "lng": -1.6244}},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
"projects": [
|
|
28
|
+
{
|
|
29
|
+
"name": "atlas",
|
|
30
|
+
"repos": [
|
|
31
|
+
{"name": "api", "stars": 120},
|
|
32
|
+
{"name": "worker", "stars": 55},
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "pulse",
|
|
37
|
+
"repos": [{"name": "dashboard", "stars": 80}],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "Ama",
|
|
43
|
+
"age": 22,
|
|
44
|
+
"skills": ["design"],
|
|
45
|
+
"score": 88.0,
|
|
46
|
+
"active": False,
|
|
47
|
+
"nickname": "A",
|
|
48
|
+
"profile": {
|
|
49
|
+
"preferences": {
|
|
50
|
+
"theme": "light",
|
|
51
|
+
"notifications": {"email": False, "sms": True},
|
|
52
|
+
},
|
|
53
|
+
"addresses": [{"city": "Tamale", "geo": {"lat": 9.4075, "lng": -0.8533}}],
|
|
54
|
+
},
|
|
55
|
+
"projects": [
|
|
56
|
+
{
|
|
57
|
+
"name": "canvas",
|
|
58
|
+
"repos": [{"name": "ui-kit", "stars": 65}],
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "Jo",
|
|
64
|
+
"skills": [],
|
|
65
|
+
"active": True,
|
|
66
|
+
"profile": {"preferences": {"theme": "dark"}},
|
|
67
|
+
"projects": [],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
"teams": {
|
|
71
|
+
"backend": [{"name": "Kobby"}],
|
|
72
|
+
"design": [{"name": "Ama"}],
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
self.obj = JSONObjectMapper(self.data)
|
|
76
|
+
|
|
77
|
+
def test_query_wildcard_names(self):
|
|
78
|
+
self.assertEqual(self.obj.query("users[*].name"), ["Kobby", "Ama", "Jo"])
|
|
79
|
+
|
|
80
|
+
def test_query_filter(self):
|
|
81
|
+
self.assertEqual(self.obj.query("users[?age > 25].name"), ["Kobby"])
|
|
82
|
+
|
|
83
|
+
def test_query_nested_wildcard_flatten(self):
|
|
84
|
+
self.assertEqual(self.obj.query("users[*].skills[*]"), ["python", "aws", "design"])
|
|
85
|
+
|
|
86
|
+
def test_query_first(self):
|
|
87
|
+
self.assertEqual(self.obj.query("users[0].name", first=True), "Kobby")
|
|
88
|
+
|
|
89
|
+
def test_query_default(self):
|
|
90
|
+
self.assertEqual(self.obj.query("invalid.path", default=[]), [])
|
|
91
|
+
|
|
92
|
+
def test_exists(self):
|
|
93
|
+
self.assertTrue(self.obj.exists("users[0].name"))
|
|
94
|
+
|
|
95
|
+
def test_count(self):
|
|
96
|
+
self.assertEqual(self.obj.count("users[*]"), 3)
|
|
97
|
+
|
|
98
|
+
def test_compile(self):
|
|
99
|
+
compiled = self.obj.compile("users[*].name")
|
|
100
|
+
self.assertEqual(compiled(self.obj), ["Kobby", "Ama", "Jo"])
|
|
101
|
+
|
|
102
|
+
def test_query_safe_missing_branch(self):
|
|
103
|
+
self.assertEqual(self.obj.query("users[*].profile.preferences.locale.timezone"), [])
|
|
104
|
+
|
|
105
|
+
def test_query_out_of_range_index(self):
|
|
106
|
+
self.assertEqual(self.obj.query("users[99].name"), [])
|
|
107
|
+
|
|
108
|
+
def test_query_filter_string_equals(self):
|
|
109
|
+
self.assertEqual(self.obj.query("users[?name == 'Ama'].name"), ["Ama"])
|
|
110
|
+
|
|
111
|
+
def test_query_filter_not_equals(self):
|
|
112
|
+
self.assertEqual(self.obj.query("users[?name != 'Ama'].name"), ["Kobby", "Jo"])
|
|
113
|
+
|
|
114
|
+
def test_query_filter_boolean(self):
|
|
115
|
+
self.assertEqual(self.obj.query("users[?active == true].name"), ["Kobby", "Jo"])
|
|
116
|
+
|
|
117
|
+
def test_query_filter_null(self):
|
|
118
|
+
self.assertEqual(self.obj.query("users[?nickname == null].name"), ["Kobby"])
|
|
119
|
+
|
|
120
|
+
def test_query_filter_float(self):
|
|
121
|
+
self.assertEqual(self.obj.query("users[?score >= 90.0].name"), ["Kobby"])
|
|
122
|
+
|
|
123
|
+
def test_query_invalid_filter_is_safe(self):
|
|
124
|
+
self.assertEqual(self.obj.query("users[?age ~~ 10].name"), [])
|
|
125
|
+
|
|
126
|
+
def test_query_root_dict_wildcard(self):
|
|
127
|
+
self.assertEqual(self.obj.query("teams.*[*].name"), ["Kobby", "Ama"])
|
|
128
|
+
|
|
129
|
+
def test_query_first_without_default(self):
|
|
130
|
+
self.assertIsNone(self.obj.query("users[99].name", first=True))
|
|
131
|
+
|
|
132
|
+
def test_query_first_with_default(self):
|
|
133
|
+
self.assertEqual(self.obj.query("users[99].name", first=True, default="missing"), "missing")
|
|
134
|
+
|
|
135
|
+
def test_exists_false(self):
|
|
136
|
+
self.assertFalse(self.obj.exists("users[?age < 0].name"))
|
|
137
|
+
|
|
138
|
+
def test_compile_rejects_non_mapper(self):
|
|
139
|
+
compiled = self.obj.compile("users[*].name")
|
|
140
|
+
with self.assertRaises(TypeError):
|
|
141
|
+
compiled({"users": []})
|
|
142
|
+
|
|
143
|
+
def test_compile_reusable_on_another_mapper(self):
|
|
144
|
+
compiled = self.obj.compile("users[*].name")
|
|
145
|
+
another = JSONObjectMapper({"users": [{"name": "Esi"}]})
|
|
146
|
+
self.assertEqual(compiled(another), ["Esi"])
|
|
147
|
+
|
|
148
|
+
def test_query_deep_property_path(self):
|
|
149
|
+
self.assertEqual(self.obj.query("users[0].profile.preferences.theme"), ["dark"])
|
|
150
|
+
|
|
151
|
+
def test_query_deep_index_access(self):
|
|
152
|
+
self.assertEqual(self.obj.query("users[0].profile.addresses[1].geo.lat"), [6.6885])
|
|
153
|
+
|
|
154
|
+
def test_query_multi_wildcard_deep_flatten(self):
|
|
155
|
+
self.assertEqual(
|
|
156
|
+
self.obj.query("users[*].projects[*].repos[*].name"),
|
|
157
|
+
["api", "worker", "dashboard", "ui-kit"],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def test_query_filter_then_deep_path(self):
|
|
161
|
+
self.assertEqual(self.obj.query("users[?age >= 25].profile.preferences.theme"), ["dark"])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
unittest.main()
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from json_object_mapper.query import (
|
|
4
|
+
apply_filter,
|
|
5
|
+
coerce_condition_value,
|
|
6
|
+
compare,
|
|
7
|
+
parse_condition,
|
|
8
|
+
tokenize,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestQueryParser(unittest.TestCase):
|
|
13
|
+
def test_tokenize_index_and_property(self):
|
|
14
|
+
self.assertEqual(tokenize("users[0].name"), ["users", 0, "name"])
|
|
15
|
+
|
|
16
|
+
def test_tokenize_wildcard(self):
|
|
17
|
+
self.assertEqual(tokenize("users[*].name"), ["users", "*", "name"])
|
|
18
|
+
|
|
19
|
+
def test_tokenize_filter(self):
|
|
20
|
+
self.assertEqual(tokenize("users[?age > 25]"), ["users", {"filter": "age > 25"}])
|
|
21
|
+
|
|
22
|
+
def test_parse_condition(self):
|
|
23
|
+
self.assertEqual(parse_condition("age >= 25"), ("age", ">=", 25))
|
|
24
|
+
|
|
25
|
+
def test_apply_filter(self):
|
|
26
|
+
items = [
|
|
27
|
+
{"name": "Kobby", "age": 29},
|
|
28
|
+
{"name": "Ama", "age": 22},
|
|
29
|
+
]
|
|
30
|
+
self.assertEqual(apply_filter(items, "age > 25"), [{"name": "Kobby", "age": 29}])
|
|
31
|
+
|
|
32
|
+
def test_tokenize_unclosed_bracket_raises(self):
|
|
33
|
+
with self.assertRaises(ValueError):
|
|
34
|
+
tokenize("users[0")
|
|
35
|
+
|
|
36
|
+
def test_tokenize_empty_filter_raises(self):
|
|
37
|
+
with self.assertRaises(ValueError):
|
|
38
|
+
tokenize("users[?]")
|
|
39
|
+
|
|
40
|
+
def test_tokenize_unsupported_bracket_token_raises(self):
|
|
41
|
+
with self.assertRaises(ValueError):
|
|
42
|
+
tokenize("users[abc]")
|
|
43
|
+
|
|
44
|
+
def test_parse_condition_with_string_value(self):
|
|
45
|
+
self.assertEqual(parse_condition("name == 'Ama'"), ("name", "==", "Ama"))
|
|
46
|
+
|
|
47
|
+
def test_parse_condition_with_float_value(self):
|
|
48
|
+
self.assertEqual(parse_condition("score >= 91.5"), ("score", ">=", 91.5))
|
|
49
|
+
|
|
50
|
+
def test_parse_condition_with_bool_and_null(self):
|
|
51
|
+
self.assertEqual(parse_condition("active == true"), ("active", "==", True))
|
|
52
|
+
self.assertEqual(parse_condition("nickname == null"), ("nickname", "==", None))
|
|
53
|
+
|
|
54
|
+
def test_parse_condition_invalid_operator_raises(self):
|
|
55
|
+
with self.assertRaises(ValueError):
|
|
56
|
+
parse_condition("age ~~ 10")
|
|
57
|
+
|
|
58
|
+
def test_coerce_condition_value_negative_int(self):
|
|
59
|
+
self.assertEqual(coerce_condition_value("-42"), -42)
|
|
60
|
+
|
|
61
|
+
def test_compare_type_error_is_false(self):
|
|
62
|
+
self.assertFalse(compare("abc", ">", 10))
|
|
63
|
+
|
|
64
|
+
def test_apply_filter_skips_non_dict_items(self):
|
|
65
|
+
items = [{"age": 10}, "invalid", 2, {"age": 30}]
|
|
66
|
+
self.assertEqual(apply_filter(items, "age >= 20"), [{"age": 30}])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
if __name__ == "__main__":
|
|
70
|
+
unittest.main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from json_object_mapper import JSONObjectMapper, QueryableList
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestQueryableList(unittest.TestCase):
|
|
7
|
+
def setUp(self):
|
|
8
|
+
self.data = {
|
|
9
|
+
"users": [
|
|
10
|
+
{"name": "Kobby", "age": 29, "profile": {"age": 29, "city": "Accra"}},
|
|
11
|
+
{"name": "Ama", "age": 22, "profile": {"age": 22, "city": "Tamale"}},
|
|
12
|
+
{"name": "Kobby", "age": 35, "profile": {"age": 35, "city": "Kumasi"}},
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
self.obj = JSONObjectMapper(self.data)
|
|
16
|
+
|
|
17
|
+
def test_users_is_queryable_list(self):
|
|
18
|
+
self.assertIsInstance(self.obj.users, QueryableList)
|
|
19
|
+
|
|
20
|
+
def test_filter_eq(self):
|
|
21
|
+
results = self.obj.users.filter(name="Ama")
|
|
22
|
+
self.assertEqual(results.count(), 1)
|
|
23
|
+
self.assertEqual(results.first().name, "Ama")
|
|
24
|
+
|
|
25
|
+
def test_filter_operator_gt(self):
|
|
26
|
+
results = self.obj.users.filter(age__gt=25)
|
|
27
|
+
self.assertEqual([item.name for item in results], ["Kobby", "Kobby"])
|
|
28
|
+
|
|
29
|
+
def test_filter_operator_lte(self):
|
|
30
|
+
results = self.obj.users.filter(age__lte=29)
|
|
31
|
+
self.assertEqual([item.name for item in results], ["Kobby", "Ama"])
|
|
32
|
+
|
|
33
|
+
def test_filter_multiple_conditions(self):
|
|
34
|
+
results = self.obj.users.filter(name="Kobby", age__gte=30)
|
|
35
|
+
self.assertEqual(results.count(), 1)
|
|
36
|
+
self.assertEqual(results.first().age, 35)
|
|
37
|
+
|
|
38
|
+
def test_filter_chainable(self):
|
|
39
|
+
result = self.obj.users.filter(age__gt=20).filter(name="Kobby")
|
|
40
|
+
self.assertIsInstance(result, QueryableList)
|
|
41
|
+
self.assertEqual([item.age for item in result], [29, 35])
|
|
42
|
+
|
|
43
|
+
def test_get_single(self):
|
|
44
|
+
result = self.obj.users.get(name="Ama")
|
|
45
|
+
self.assertEqual(result.age, 22)
|
|
46
|
+
|
|
47
|
+
def test_get_no_match_raises(self):
|
|
48
|
+
with self.assertRaises(ValueError):
|
|
49
|
+
self.obj.users.get(name="Missing")
|
|
50
|
+
|
|
51
|
+
def test_get_multiple_match_raises(self):
|
|
52
|
+
with self.assertRaises(ValueError):
|
|
53
|
+
self.obj.users.get(name="Kobby")
|
|
54
|
+
|
|
55
|
+
def test_first_last(self):
|
|
56
|
+
self.assertEqual(self.obj.users.first().name, "Kobby")
|
|
57
|
+
self.assertEqual(self.obj.users.last().name, "Kobby")
|
|
58
|
+
|
|
59
|
+
def test_count(self):
|
|
60
|
+
self.assertEqual(self.obj.users.count(), 3)
|
|
61
|
+
|
|
62
|
+
def test_nested_field_support(self):
|
|
63
|
+
results = self.obj.users.filter(profile__age__gt=30)
|
|
64
|
+
self.assertEqual(results.count(), 1)
|
|
65
|
+
self.assertEqual(results.first().name, "Kobby")
|
|
66
|
+
|
|
67
|
+
def test_nested_field_missing_is_safe(self):
|
|
68
|
+
results = self.obj.users.filter(profile__country="GH")
|
|
69
|
+
self.assertEqual(results.count(), 0)
|
|
70
|
+
|
|
71
|
+
def test_queryable_list_on_nested_list(self):
|
|
72
|
+
mapped = JSONObjectMapper({"groups": [{"members": [{"name": "A"}, {"name": "B"}]}]})
|
|
73
|
+
members = mapped.groups[0].members
|
|
74
|
+
self.assertIsInstance(members, QueryableList)
|
|
75
|
+
self.assertEqual(members.filter(name="B").first().name, "B")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{json_object_mapper-2.0.0 → json_object_mapper-2.1.0}/src/json_object_mapper.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|