AxiomQuery 0.1.0__py3-none-any.whl

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.
axiom_query/parser.py ADDED
@@ -0,0 +1,102 @@
1
+ """Parse frontend JSON domain expressions into QuerySpec AST nodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from axiom_query.ast import And, Bool, Condition, Not, Or, QuerySpec
8
+ from axiom_query.operators import Op
9
+
10
+
11
+ def parse_domain(raw: Any) -> QuerySpec:
12
+ """Parse a frontend domain expression into a QuerySpec AST."""
13
+ from axiom_query.errors import QueryError
14
+
15
+ if raw is None:
16
+ return Bool(True)
17
+ if isinstance(raw, list):
18
+ return _parse_list(raw)
19
+ if isinstance(raw, dict):
20
+ return _parse_dict(raw)
21
+ raise QueryError(
22
+ "INVALID_DOMAIN",
23
+ f"Domain must be a list or dict, got {type(raw).__name__}",
24
+ )
25
+
26
+
27
+ def _parse_list(items: list) -> QuerySpec:
28
+ if not items:
29
+ return Bool(True)
30
+
31
+ specs = [_parse_item(item) for item in items]
32
+ result = specs[0]
33
+ for s in specs[1:]:
34
+ result = And(left=result, right=s)
35
+ return result
36
+
37
+
38
+ def _parse_item(item: Any) -> QuerySpec:
39
+ from axiom_query.errors import QueryError
40
+
41
+ if isinstance(item, (list, tuple)) and len(item) == 3:
42
+ field_path, op_str, value = item
43
+ if not isinstance(field_path, str):
44
+ raise QueryError(
45
+ "INVALID_DOMAIN",
46
+ f"Field path must be a string, got {type(field_path).__name__}",
47
+ )
48
+ try:
49
+ op = Op.from_str(str(op_str))
50
+ except ValueError:
51
+ raise QueryError("INVALID_DOMAIN", f"Unknown operator: {op_str!r}")
52
+ return Condition(field_path=field_path, operator=op, value=value)
53
+
54
+ if isinstance(item, dict):
55
+ return _parse_dict(item)
56
+
57
+ raise QueryError(
58
+ "INVALID_DOMAIN",
59
+ f"Each condition must be [field, op, value] or a logical dict, got {type(item).__name__}",
60
+ )
61
+
62
+
63
+ def _parse_dict(d: dict) -> QuerySpec:
64
+ from axiom_query.errors import QueryError
65
+
66
+ if len(d) != 1:
67
+ raise QueryError(
68
+ "INVALID_DOMAIN",
69
+ "Logical dict must have exactly one key: 'and', 'or', or 'not'",
70
+ )
71
+ key = next(iter(d))
72
+ val = d[key]
73
+
74
+ if key == "and":
75
+ if not isinstance(val, list) or len(val) < 2:
76
+ raise QueryError(
77
+ "INVALID_DOMAIN", "'and' requires a list of at least 2 items"
78
+ )
79
+ specs = [_parse_item(item) for item in val]
80
+ result = specs[0]
81
+ for s in specs[1:]:
82
+ result = And(left=result, right=s)
83
+ return result
84
+
85
+ if key == "or":
86
+ if not isinstance(val, list) or len(val) < 2:
87
+ raise QueryError(
88
+ "INVALID_DOMAIN", "'or' requires a list of at least 2 items"
89
+ )
90
+ specs = [_parse_item(item) for item in val]
91
+ result = specs[0]
92
+ for s in specs[1:]:
93
+ result = Or(left=result, right=s)
94
+ return result
95
+
96
+ if key == "not":
97
+ return Not(operand=_parse_item(val))
98
+
99
+ raise QueryError(
100
+ "INVALID_DOMAIN",
101
+ f"Unknown logical key: {key!r}. Use 'and', 'or', or 'not'.",
102
+ )
axiom_query/py.typed ADDED
File without changes
axiom_query/schema.py ADDED
@@ -0,0 +1,55 @@
1
+ """ModelSchema — derives table/column/child metadata from SA ORM models via inspect()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from sqlalchemy import Column, Table
8
+ from sqlalchemy import inspect as sa_inspect
9
+ from sqlalchemy.orm import RelationshipDirection
10
+
11
+
12
+ @dataclass
13
+ class ChildSchema:
14
+ name: str
15
+ table: Table
16
+ fk_field: str
17
+ columns: dict[str, Column]
18
+
19
+
20
+ @dataclass
21
+ class ModelSchema:
22
+ model_class: type
23
+ table: Table
24
+ columns: dict[str, Column]
25
+ children: dict[str, ChildSchema] = field(default_factory=dict)
26
+
27
+
28
+ def derive_schema(model_class: type) -> ModelSchema:
29
+ """Derive a ModelSchema from a SA ORM model class using inspect()."""
30
+ mapper = sa_inspect(model_class)
31
+ table = mapper.local_table
32
+ columns = {col.key: col for col in mapper.columns}
33
+
34
+ children: dict[str, ChildSchema] = {}
35
+ for rel_name, rel in mapper.relationships.items():
36
+ if rel.direction == RelationshipDirection.ONETOMANY:
37
+ child_table = rel.mapper.local_table
38
+ child_columns = {col.key: col for col in rel.mapper.columns}
39
+ # Find the FK column on the child table via synchronize_pairs
40
+ # synchronize_pairs: list of (parent_col, child_col) tuples
41
+ _, child_fk_col = next(iter(rel.synchronize_pairs))
42
+ fk_field = child_fk_col.key
43
+ children[rel_name] = ChildSchema(
44
+ name=rel_name,
45
+ table=child_table,
46
+ fk_field=fk_field,
47
+ columns=child_columns,
48
+ )
49
+
50
+ return ModelSchema(
51
+ model_class=model_class,
52
+ table=table,
53
+ columns=columns,
54
+ children=children,
55
+ )
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.4
2
+ Name: AxiomQuery
3
+ Version: 0.1.0
4
+ Summary: Specification-based query and aggregation engine for SQLAlchemy 2.0 ORM models
5
+ Project-URL: Source Code, https://github.com/Axiom-Dev-Labs/AxiomQuery
6
+ Project-URL: Bug Tracker, https://github.com/Axiom-Dev-Labs/AxiomQuery/issues
7
+ Project-URL: Changelog, https://github.com/Axiom-Dev-Labs/AxiomQuery/blob/main/CHANGELOG.md
8
+ Author: Axiom Contributors
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Axiom Contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: domain,filter,groupby,orm,query,specification,sqlalchemy
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Topic :: Database
38
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
39
+ Classifier: Typing :: Typed
40
+ Requires-Python: >=3.12
41
+ Requires-Dist: sqlalchemy>=2.0
42
+ Description-Content-Type: text/markdown
43
+
44
+ # AxiomQuery
45
+
46
+ Specification-based query and aggregation engine for SQLAlchemy 2.0 ORM models.
47
+
48
+ Define filters as composable data — JSON lists, dicts, or Python AST nodes — and execute them against any ORM model without writing raw SQL.
49
+
50
+ ---
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install AxiomQuery
56
+ ```
57
+
58
+ Requires Python 3.12+ and SQLAlchemy 2.0+.
59
+
60
+ ---
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ from axiom_query import QueryEngine
66
+
67
+ engine = QueryEngine(Order) # inspect() once — no DB connection at construction
68
+
69
+ with Session(db) as session:
70
+ # list
71
+ records = engine.list(session, domain=[["status", "=", "CONFIRMED"]])
72
+
73
+ # read_group
74
+ groups, total = engine.read_group(
75
+ session,
76
+ groupby=["status"],
77
+ aggregates=["__count", "total:sum"],
78
+ )
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Domain filter syntax
84
+
85
+ A **domain** is a JSON-serialisable expression compiled to a WHERE clause at query time.
86
+
87
+ ### Condition tuple
88
+
89
+ ```python
90
+ [field_path, operator, value]
91
+ ```
92
+
93
+ | Operator | Meaning |
94
+ |----------|---------|
95
+ | `=` `!=` `>` `<` `>=` `<=` | Comparison |
96
+ | `in` `not in` | Membership (value is a list) |
97
+ | `like` `ilike` | Pattern match (`%` wildcard) |
98
+ | `is_null` | Null check (value is `True`/`False`) |
99
+
100
+ ### Logical composition
101
+
102
+ ```python
103
+ # AND — list of conditions (implicit)
104
+ [["status", "=", "CONFIRMED"], ["total", ">", 100]]
105
+
106
+ # AND — explicit
107
+ {"and": [["status", "=", "CONFIRMED"], ["total", ">", 100]]}
108
+
109
+ # OR
110
+ {"or": [["status", "=", "DRAFT"], ["status", "=", "CANCELLED"]]}
111
+
112
+ # NOT
113
+ {"not": ["status", "=", "CANCELLED"]}
114
+
115
+ # Combined — list mixes plain conditions with logical dicts
116
+ [
117
+ {"or": [["status", "=", "CONFIRMED"], ["status", "=", "DRAFT"]]},
118
+ {"not": ["total", "=", 0]},
119
+ ]
120
+ ```
121
+
122
+ ### Child field (EXISTS subquery)
123
+
124
+ Filter parent records by a child relationship field using dot notation. O2M relationships are automatically detected via `inspect()`.
125
+
126
+ ```python
127
+ # Orders that have at least one line with quantity > 2
128
+ engine.list(session, domain=[["lines.quantity", ">", 2]])
129
+ ```
130
+
131
+ ---
132
+
133
+ ## `list()` — filtered records
134
+
135
+ ```python
136
+ records = engine.list(
137
+ session,
138
+ domain=None, # domain expression or None (all records)
139
+ limit=None, # max records to return
140
+ offset=None, # records to skip
141
+ order_by=None, # [["field", "asc|desc"], ...]
142
+ )
143
+ # returns list[ORM model instances]
144
+ ```
145
+
146
+ ---
147
+
148
+ ## `read_group()` — grouped aggregation
149
+
150
+ ```python
151
+ groups, total = engine.read_group(
152
+ session,
153
+ groupby=["status", "created_at:month"], # field or field:granularity
154
+ aggregates=["__count", "total:sum"], # __count or field:func
155
+ domain=None, # WHERE filter
156
+ having=None, # HAVING filter on aggregate aliases
157
+ order_by=None, # [["alias", "asc|desc"], ...]
158
+ limit=None,
159
+ offset=None,
160
+ )
161
+ # returns (list[dict], int) — each dict includes a __domain key
162
+ ```
163
+
164
+ **Aggregate functions:** `count` `sum` `avg` `min` `max`
165
+
166
+ **Date granularities:** `day` `week` `month` `quarter` `year`
167
+
168
+ **Child aggregate** (LEFT JOIN):
169
+
170
+ ```python
171
+ engine.read_group(session, groupby=["status"], aggregates=["lines.quantity:sum"])
172
+ ```
173
+
174
+ **`__domain` drill-down** — each group result includes a `__domain` ready to pass back to `list()`:
175
+
176
+ ```python
177
+ groups, _ = engine.read_group(session, groupby=["status"], aggregates=["__count"])
178
+ for group in groups:
179
+ records = engine.list(session, domain=group["__domain"])
180
+ ```
181
+
182
+ ---
183
+
184
+ ## Async API
185
+
186
+ Prefix any method with `a` and pass an `AsyncSession`:
187
+
188
+ ```python
189
+ engine = QueryEngine(Order)
190
+
191
+ async with AsyncSession(db) as session:
192
+ records = await engine.alist(session, domain=[["status", "=", "CONFIRMED"]])
193
+ groups, total = await engine.aread_group(session, groupby=["status"], aggregates=["__count"])
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Schema derivation
199
+
200
+ `QueryEngine` derives its schema from `inspect(model_class)` at construction time — no separate descriptor needed:
201
+
202
+ - **Columns** → from `mapper.columns`
203
+ - **Child relations** → O2M relationships (`RelationshipDirection.ONETOMANY`) become filterable child entities
204
+ - **FK column** → resolved from `rel.synchronize_pairs`
205
+
206
+ ---
207
+
208
+ ## Error handling
209
+
210
+ Invalid field paths and unsupported operators raise `QueryError` before hitting the database:
211
+
212
+ ```python
213
+ from axiom_query import QueryError
214
+
215
+ try:
216
+ engine.list(session, domain=[["unknown_field", "=", "x"]])
217
+ except QueryError as e:
218
+ print(e.code, e.message) # INVALID_FILTER_FIELD No field 'unknown_field' ...
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Examples
224
+
225
+ Self-contained runnable examples in [`examples/`](examples/):
226
+
227
+ ```bash
228
+ python examples/example_sync.py
229
+ python examples/example_async.py
230
+ ```
231
+
232
+ Both cover: simple filters, AND / OR / NOT, combined nesting, child EXISTS filtering, pagination, `read_group` with domain / date granularity / child aggregation / HAVING, and `__domain` drill-down.
@@ -0,0 +1,16 @@
1
+ axiom_query/__init__.py,sha256=Pe09GG6TA3lCwVxzkUfiracTrdRzwQ3FlDnS2fiEa9E,512
2
+ axiom_query/aggregation.py,sha256=vCYhKXjxg8aHe43SJamZewV-GbyCYDWNElR8-G9mskc,2816
3
+ axiom_query/aggregation_parser.py,sha256=6mtt72Cr8UlmemO6PDELdRpBJMtNI0N0XNpNXfXQx4w,5930
4
+ axiom_query/ast.py,sha256=KWaI5QD9WlW-A7GinIH-KkSaz2vEST4Vx2Kf80GJYj4,1211
5
+ axiom_query/compiler.py,sha256=jfhctH64i0fIVBdtde97-dLOgp39Miuh_mp57uX_g6Y,7326
6
+ axiom_query/compiler_aggregate.py,sha256=ksmnO8PypGDc08OdNM7N_1s4aL5E_ePH6eniqKqvJFw,11965
7
+ axiom_query/engine.py,sha256=NWPVqgbolCpXgjBhJVZzpIfFPqD_u86WkQVPHVZ_j54,6765
8
+ axiom_query/errors.py,sha256=OeID6aSz11fr4cPdy6w46vj0Kf8wDC4g7yTd0pT9_dY,312
9
+ axiom_query/operators.py,sha256=iUryrmDXscvt9tmKaD9A9y6kAONu8RxLbi577csmlyQ,684
10
+ axiom_query/parser.py,sha256=IcWPmePL2O9zKTpMJp07XFueVFmFQ60bHz3oAXk7TfQ,3008
11
+ axiom_query/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ axiom_query/schema.py,sha256=BFsWa2smz0mLeKDjqv-tapTW0nLO8gDJw1gHEOq3am8,1703
13
+ axiomquery-0.1.0.dist-info/METADATA,sha256=VAahceYfepjBmZPu8eWZmg8GrBl8Tdcd5q0_OG4-MSE,7057
14
+ axiomquery-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
15
+ axiomquery-0.1.0.dist-info/licenses/LICENSE,sha256=zZPLjk0vKhf5_He43a8zvG1zbw0fbB_A7R2tdWaMLyo,1075
16
+ axiomquery-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Axiom Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.