querymodelling 0.0.16__tar.gz → 0.0.18__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.
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.github/workflows/run-tests.yml +1 -1
- querymodelling-0.0.18/.vscode/launch.json +18 -0
- querymodelling-0.0.18/HISTORY.md +64 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/PKG-INFO +234 -1
- querymodelling-0.0.18/README.md +243 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/_version.py +3 -3
- querymodelling-0.0.18/querymodelling/sql_filter.py +814 -0
- querymodelling-0.0.18/querymodelling/sql_query/__init__.py +154 -0
- querymodelling-0.0.18/querymodelling/sql_query/access.py +159 -0
- querymodelling-0.0.18/querymodelling/sql_query/builder.py +416 -0
- querymodelling-0.0.18/querymodelling/sql_query/conditions.py +151 -0
- querymodelling-0.0.18/querymodelling/sql_query/expressions.py +179 -0
- querymodelling-0.0.18/querymodelling/sql_query/factory.py +113 -0
- querymodelling-0.0.18/querymodelling/sql_query/functions.py +216 -0
- querymodelling-0.0.18/querymodelling/sql_query/mapping.py +145 -0
- querymodelling-0.0.18/querymodelling/sql_query/query.py +481 -0
- querymodelling-0.0.18/querymodelling/sql_query/references.py +225 -0
- querymodelling-0.0.18/querymodelling/sql_query/results.py +151 -0
- querymodelling-0.0.18/querymodelling/sql_query/scope.py +87 -0
- querymodelling-0.0.18/querymodelling/sql_query/unions.py +88 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/PKG-INFO +234 -1
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/SOURCES.txt +21 -1
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/scm_file_list.json +21 -1
- querymodelling-0.0.18/querymodelling.egg-info/scm_version.json +8 -0
- querymodelling-0.0.18/test.py +337 -0
- querymodelling-0.0.18/tests/sql_access_test.py +398 -0
- querymodelling-0.0.18/tests/sql_filter_test.py +362 -0
- querymodelling-0.0.18/tests/sql_query_test.py +604 -0
- querymodelling-0.0.18/tests/sql_result_test.py +205 -0
- querymodelling-0.0.16/HISTORY.md +0 -43
- querymodelling-0.0.16/README.md +0 -10
- querymodelling-0.0.16/querymodelling.egg-info/scm_version.json +0 -8
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.github/workflows/run-publish.yml +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.gitignore +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.readthedocs.yaml +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.vscode/extensions.json +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/.vscode/settings.json +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/AUTHORS.rst +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/LICENSE +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/Makefile +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/conf.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/index.rst +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/make.bat +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/requirements.txt +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/docs/user/install.rst +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/pyproject.toml +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/__helper__.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/__init__.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/__version__.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/base.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/fields.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/model.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/pydantic.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/redis_om.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling/sql.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/dependency_links.txt +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/requires.txt +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/querymodelling.egg-info/top_level.txt +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/setup.cfg +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/setup.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/tests/__init__.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/tests/__main__.py +0 -0
- {querymodelling-0.0.16 → querymodelling-0.0.18}/tests/basic_test.py +0 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.2.0",
|
|
3
|
+
"configurations": [
|
|
4
|
+
{
|
|
5
|
+
"name": "Python: test.py",
|
|
6
|
+
"type": "debugpy",
|
|
7
|
+
"request": "launch",
|
|
8
|
+
"program": "${workspaceFolder}/test.py",
|
|
9
|
+
"cwd": "${workspaceFolder}",
|
|
10
|
+
"python": "${workspaceFolder}/.venv/bin/python",
|
|
11
|
+
"env": {
|
|
12
|
+
"PYTHONPATH": "${workspaceFolder}"
|
|
13
|
+
},
|
|
14
|
+
"console": "integratedTerminal",
|
|
15
|
+
"justMyCode": false
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Release History
|
|
2
|
+
===============
|
|
3
|
+
|
|
4
|
+
0.0.13
|
|
5
|
+
----------
|
|
6
|
+
|
|
7
|
+
- sql_filter: add typed, nested and generator based SQL filter models
|
|
8
|
+
(``SqlFilterModel``) with ``and`` / ``or`` / ``not``, typed operators,
|
|
9
|
+
SQL functions and JSON schema support
|
|
10
|
+
- sql_query: add typed, generator based SQL query models
|
|
11
|
+
(``SqlQueryModel``) with ``fields`` (field names, SQL functions,
|
|
12
|
+
aliases), ``where`` and ``group_by`` incl. typed result models
|
|
13
|
+
- sql_query: ``group_by`` accepts aliases of ``fields`` and an empty
|
|
14
|
+
list (no grouping)
|
|
15
|
+
- sql_query: joins, subqueries (``exists``, ``in``, scalar) and conditions
|
|
16
|
+
as typed expressions (``{"eq": [a, b]}``), fields are referenced by
|
|
17
|
+
``{"model", "field"}``, plain JSON values are constants
|
|
18
|
+
- sql_query: access rules of models (usage, rows) and fields per user
|
|
19
|
+
(``SqlModelAccess``, ``SqlFieldAccess``) and query models per user
|
|
20
|
+
(``SqlQueryFactory``)
|
|
21
|
+
- sql_query: result models and schemas of validated queries copy the
|
|
22
|
+
information of the selected fields, functions define their nullability
|
|
23
|
+
and result field (``Nullability``, ``inherit``, ``describe``)
|
|
24
|
+
|
|
25
|
+
0.0.12 (2025-10-27)
|
|
26
|
+
|
|
27
|
+
- sql: add support for filtering boolean fields
|
|
28
|
+
|
|
29
|
+
0.0.11 (2025-09-22)
|
|
30
|
+
------------------
|
|
31
|
+
|
|
32
|
+
- sql: adding support for query supplements
|
|
33
|
+
|
|
34
|
+
0.0.8 (2025-03-12)
|
|
35
|
+
------------------
|
|
36
|
+
|
|
37
|
+
- redis_om: add support
|
|
38
|
+
|
|
39
|
+
0.0.6 (2025-03-04)
|
|
40
|
+
------------------
|
|
41
|
+
|
|
42
|
+
- sql: remove dot name alias again
|
|
43
|
+
since they are producing a lot of side effects
|
|
44
|
+
|
|
45
|
+
0.0.5 (2025-02-28)
|
|
46
|
+
------------------
|
|
47
|
+
|
|
48
|
+
- sql: add name field to meta data
|
|
49
|
+
|
|
50
|
+
0.0.4 (2025-02-11)
|
|
51
|
+
------------------
|
|
52
|
+
|
|
53
|
+
- sql: support for enums
|
|
54
|
+
|
|
55
|
+
0.0.2 (2024-01-15)
|
|
56
|
+
------------------
|
|
57
|
+
|
|
58
|
+
- support of dot name alias
|
|
59
|
+
- use a separate field for each operator
|
|
60
|
+
|
|
61
|
+
0.0.1 (2024-01-13)
|
|
62
|
+
------------------
|
|
63
|
+
|
|
64
|
+
- Birth
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: querymodelling
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.18
|
|
4
4
|
Summary: build consistent api query models for fastapi
|
|
5
5
|
Author: George Haddad
|
|
6
6
|
Author-email: George Haddad <georgeh87@live.de>
|
|
@@ -224,3 +224,236 @@ At least you will need to have the pydantic package installed. Of course it make
|
|
|
224
224
|
|
|
225
225
|
## Example
|
|
226
226
|
|
|
227
|
+
|
|
228
|
+
### Typed SQL query models
|
|
229
|
+
|
|
230
|
+
`SqlQueryModel` generates a fully typed query model (incl. JSON schema)
|
|
231
|
+
with `joins`, `fields`, `where`, `group_by` and subqueries, which is compiled
|
|
232
|
+
into a SQLAlchemy select statement. Only the registered models can be
|
|
233
|
+
queried.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
from sqlmodel import Session
|
|
237
|
+
from querymodelling.sql_query import SqlQueryModel
|
|
238
|
+
|
|
239
|
+
BookQuery = SqlQueryModel(Book, models=[Author, Review])
|
|
240
|
+
|
|
241
|
+
query = BookQuery.model_validate({
|
|
242
|
+
"joins": [
|
|
243
|
+
{
|
|
244
|
+
"model": "author",
|
|
245
|
+
"type": "left",
|
|
246
|
+
"on": {"eq": [
|
|
247
|
+
{"model": "book", "field": "author_id"},
|
|
248
|
+
{"model": "author", "field": "id"}
|
|
249
|
+
]}
|
|
250
|
+
}
|
|
251
|
+
],
|
|
252
|
+
"fields": [
|
|
253
|
+
{"model": "book", "field": "id"},
|
|
254
|
+
{"model": "author", "field": "id", "alias": "author_id"},
|
|
255
|
+
{"lower": {"model": "author", "field": "name"}, "alias": "author_name"}
|
|
256
|
+
],
|
|
257
|
+
"where": {"and": [
|
|
258
|
+
{"eq": [{"model": "book", "field": "id"}, 123]},
|
|
259
|
+
{"startswith": [{"model": "author", "field": "name"}, "M"]}
|
|
260
|
+
]}
|
|
261
|
+
})
|
|
262
|
+
statement = query.to_select() # SQLAlchemy select with labeled columns
|
|
263
|
+
rows = query.fetch(session) # list of typed result rows
|
|
264
|
+
RowModel = query.result_model() # typed row, without executing the query
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```sql
|
|
268
|
+
SELECT book.id AS id, author.id AS author_id, lower(author.name) AS author_name
|
|
269
|
+
FROM book LEFT OUTER JOIN author ON book.author_id = author.id
|
|
270
|
+
WHERE book.id = 123 AND author.name LIKE 'M' || '%'
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**One rule:** a plain JSON value (string, number, boolean) is always a
|
|
274
|
+
constant, an object is an expression identified by its keys:
|
|
275
|
+
|
|
276
|
+
| Object | Expression |
|
|
277
|
+
|---|---|
|
|
278
|
+
| `{"model": "book", "field": "id"}` | field (`"as"` selects a joined instance) |
|
|
279
|
+
| `{"model": "review", "fields": [...], "where": ...}` | subquery (no `field`) |
|
|
280
|
+
| `{"lower": ...}`, `{"eq": [...]}` | function (its only key besides `alias`) |
|
|
281
|
+
| `{"ref": "author_name"}` | alias of `fields` (`group_by` only) |
|
|
282
|
+
|
|
283
|
+
* **Query**: `model` (defaults to the base model), `as`, `joins`, `fields`
|
|
284
|
+
(all fields of the queried model if not set), `where`, `group_by`.
|
|
285
|
+
Subqueries have the same structure.
|
|
286
|
+
* **Joins**: `{"model", "as", "type", "on"}` with `type` `inner` (default),
|
|
287
|
+
`left` or `full`. `on` may reference the queried model and the previous
|
|
288
|
+
joins. A model joined twice needs `as`, it is referenced by
|
|
289
|
+
`{"model": "author", "as": "reviewer", "field": "name"}`. Fields of the
|
|
290
|
+
outer side of an outer join are `Optional` in the result model.
|
|
291
|
+
* **Conditions** (`where`, `on`) are functions returning `bool`:
|
|
292
|
+
`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `in`, `not_in`
|
|
293
|
+
(`{"in": [x, [1, 2]]}` or `{"in": [x, <subquery>]}`), `contains`,
|
|
294
|
+
`icontains`, `startswith`, `istartswith`, `endswith`, `iendswith`, `like`,
|
|
295
|
+
`not_like`, `ilike`, `is_null`, `is_not_null`, `and`, `or`, `not` and
|
|
296
|
+
`exists` (`{"exists": <subquery>}`).
|
|
297
|
+
* **Functions**: `lower`, `upper`, `trim`, `length`, `concat`, `substring`,
|
|
298
|
+
`replace`, `abs`, `round`, `ceil`, `floor`, `add`, `sub`, `mul`, `div`,
|
|
299
|
+
`date`, `year`, `month`, `day`, `hour`, `minute`, `coalesce`, `nullif`,
|
|
300
|
+
`cast` (`{"cast": [x, "string"]}`) and the aggregates `count`
|
|
301
|
+
(`{"count": "*"}` counts all rows), `count_distinct`, `sum`, `avg`, `min`,
|
|
302
|
+
`max`.
|
|
303
|
+
* **Subqueries** used as value or in `in` return exactly one field, its type
|
|
304
|
+
is the type of the expression. Subqueries may reference the models of the
|
|
305
|
+
outer queries, inner models shadow outer ones with the same name (use
|
|
306
|
+
`as`).
|
|
307
|
+
* **Typing**: all expressions are typed per result type, e.g.
|
|
308
|
+
`{"lower": <int field>}` or `{"eq": [<int field>, "x"]}` are rejected by
|
|
309
|
+
the JSON schema and the validation.
|
|
310
|
+
* **Grouping**: fields outside of aggregate functions must be part of
|
|
311
|
+
`group_by`, aggregates must not be nested and are not allowed in `where`,
|
|
312
|
+
`on` and `group_by`. An empty `group_by` means no grouping.
|
|
313
|
+
* **Limits**: `max_joins` (default 5) per (sub)query and `max_depth`
|
|
314
|
+
(default 3) of nested subqueries.
|
|
315
|
+
|
|
316
|
+
```json
|
|
317
|
+
{
|
|
318
|
+
"joins": [{"model": "author", "type": "left", "on": {"eq": [
|
|
319
|
+
{"model": "book", "field": "author_id"}, {"model": "author", "field": "id"}
|
|
320
|
+
]}}],
|
|
321
|
+
"fields": [
|
|
322
|
+
{"lower": {"model": "author", "field": "name"}, "alias": "author_name"},
|
|
323
|
+
{"count": "*", "alias": "books"},
|
|
324
|
+
{"model": "review", "fields": [{"avg": {"model": "review", "field": "rating"}}],
|
|
325
|
+
"where": {"eq": [{"model": "review", "field": "book_id"}, {"model": "book", "field": "id"}]},
|
|
326
|
+
"alias": "avg_rating"}
|
|
327
|
+
],
|
|
328
|
+
"where": {"exists": {"model": "review", "where": {"eq": [
|
|
329
|
+
{"model": "review", "field": "book_id"}, {"model": "book", "field": "id"}
|
|
330
|
+
]}}},
|
|
331
|
+
"group_by": [{"ref": "author_name"}, {"model": "book", "field": "id"}]
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Models are registered by their table name (`models=[Author]`) or explicitly
|
|
336
|
+
(`models={"writer": Author}`). `sources` overrides the table of a model,
|
|
337
|
+
`include_fields` / `exclude_fields` restrict the fields (a list for the base
|
|
338
|
+
model or a mapping of model names to lists).
|
|
339
|
+
|
|
340
|
+
Functions and conditions are provided by generators `(annotation) ->
|
|
341
|
+
Iterable[SqlExpressionFunction]` which are called for every known type:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
from sqlalchemy import Integer, func
|
|
345
|
+
from querymodelling.sql_filter import without
|
|
346
|
+
from querymodelling.sql_query import (
|
|
347
|
+
Expr, SqlExpressionFunction, default_sql_expression_mapping
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def instr(annotation):
|
|
351
|
+
if annotation is str:
|
|
352
|
+
yield SqlExpressionFunction(
|
|
353
|
+
"instr", int, (Expr(str), Expr(str)),
|
|
354
|
+
lambda value, search: func.instr(value, search, type_=Integer()))
|
|
355
|
+
|
|
356
|
+
mapping = default_sql_expression_mapping()
|
|
357
|
+
mapping.function_generators = [
|
|
358
|
+
without(generator, "cast") for generator in mapping.function_generators
|
|
359
|
+
]
|
|
360
|
+
mapping.add_functions(instr)
|
|
361
|
+
|
|
362
|
+
BookQuery = SqlQueryModel(Book, models=[Author], mapping=mapping)
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Argument placeholders are `Expr(*types)` (an expression of the types, any
|
|
366
|
+
type if empty), `Many(type)` (a list or a subquery, see `in`) and `Query()`
|
|
367
|
+
(a subquery, see `exists`). Referenceable fields are provided by reference
|
|
368
|
+
generators (`reference_generators=...`, e.g. virtual fields based on SQL
|
|
369
|
+
expressions).
|
|
370
|
+
|
|
371
|
+
#### Result schema
|
|
372
|
+
|
|
373
|
+
The result of a validated query is known without executing it:
|
|
374
|
+
`query.result_model()` is the typed model of a result row,
|
|
375
|
+
`query.result_schema()` the JSON schema of the result rows
|
|
376
|
+
(`result_schema(many=False)` of a single row).
|
|
377
|
+
|
|
378
|
+
* Fields copy the information of their source field: type, `title`,
|
|
379
|
+
`description`, `examples`, `json_schema_extra`, `deprecated` and the
|
|
380
|
+
constraints (`max_length`, `ge`, ...). They may be `NULL` if their field is
|
|
381
|
+
optional or on the outer side of an outer join.
|
|
382
|
+
* Constants are of their type and never `NULL`.
|
|
383
|
+
* Functions define their result: `result_type`, `nullable` and
|
|
384
|
+
`result_field`:
|
|
385
|
+
* `nullable` (`Nullability`): `PROPAGATE` (default, `NULL` if any argument
|
|
386
|
+
may be `NULL`), `COALESCE` (if all arguments may be), `ALWAYS` (e.g.
|
|
387
|
+
`sum`, `avg`, `min`, `max`, `nullif`, `div`) or `NEVER` (e.g. `count`,
|
|
388
|
+
`is_null`, `exists`), `True` / `False` for always / never.
|
|
389
|
+
* `result_field`: field information from the arguments, `inherit()` (all
|
|
390
|
+
of the argument, e.g. `min`, `max`, `nullif`) or `describe()` (only its
|
|
391
|
+
title and description, e.g. `lower`, `round`, `avg`, `coalesce`).
|
|
392
|
+
Without a rule the result has no field information.
|
|
393
|
+
* Subqueries are the result of their single field, `NULL` if they may return
|
|
394
|
+
no row (an aggregate without `group_by` always returns one row).
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
SqlExpressionFunction(
|
|
398
|
+
"initials", str, (Expr(str),), lambda value: func.substr(value, 1, 1),
|
|
399
|
+
nullable=Nullability.PROPAGATE, result_field=describe(),
|
|
400
|
+
)
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
#### Access rules
|
|
404
|
+
|
|
405
|
+
Models and fields can be restricted per access context - any object of the
|
|
406
|
+
application, e.g. the user. Without rules everything is allowed. Rules are
|
|
407
|
+
fixed values or callables `(context) -> flags | bool` receiving the context
|
|
408
|
+
directly, `None` if no context is given. The declarations are generic in the
|
|
409
|
+
type of the context (`None` if not given, `SqlFieldAccess[User]`,
|
|
410
|
+
`SqlQueryFactory[User]`), `SqlModelAccess` also in the model of the rows
|
|
411
|
+
rule (`SqlModelAccess[User, Self]` within the class body, the model defaults
|
|
412
|
+
to `Any`).
|
|
413
|
+
|
|
414
|
+
```python
|
|
415
|
+
from typing import Annotated, Self
|
|
416
|
+
from querymodelling.sql_query import (
|
|
417
|
+
INVISIBLE, FieldUsage, ModelUsage, SqlFieldAccess, SqlModelAccess,
|
|
418
|
+
SqlQueryFactory
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
class Book(SQLModel, table=True):
|
|
422
|
+
__sql_access__ = SqlModelAccess[User, Self](
|
|
423
|
+
# rows of the user's tenant only - in every position
|
|
424
|
+
rows=lambda user, book: book.tenant_id == user.tenant_id,
|
|
425
|
+
)
|
|
426
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
427
|
+
title: str
|
|
428
|
+
cost: Annotated[float, SqlFieldAccess[User](lambda user: (
|
|
429
|
+
FieldUsage.ALL if user.is_admin else FieldUsage.AGGREGATE))]
|
|
430
|
+
tenant_id: Annotated[int, SqlFieldAccess(INVISIBLE)]
|
|
431
|
+
|
|
432
|
+
class Salary(SQLModel, table=True):
|
|
433
|
+
__sql_access__ = SqlModelAccess[User](usage=lambda user: user.is_admin)
|
|
434
|
+
...
|
|
435
|
+
|
|
436
|
+
queries = SqlQueryFactory[User](Book, models=[Author, Salary])
|
|
437
|
+
queries.schema(user) # JSON schema of the user
|
|
438
|
+
query = queries.validate(data, user) # validated for the user
|
|
439
|
+
query.fetch(session) # rows rules applied for the user
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
* Fields: `FieldUsage.SELECT` (`fields`), `FILTER` (`where`, `on`), `GROUP`
|
|
443
|
+
(`group_by`), `AGGREGATE` (within aggregate functions), `ALL` / `NONE`
|
|
444
|
+
(`VISIBLE` / `INVISIBLE`), declared by `Annotated[..., SqlFieldAccess(rule)]`
|
|
445
|
+
or `SqlModelAccess(fields={"name": rule})`.
|
|
446
|
+
* Models: `ModelUsage.QUERY` (queried model), `JOIN`, `SUBQUERY`, declared
|
|
447
|
+
by `__sql_access__ = SqlModelAccess(usage=rule)`.
|
|
448
|
+
* `rows`: `(context, entity) -> condition` restricts the rows of a model
|
|
449
|
+
wherever it is used (queried model, joins incl. outer joins, subqueries),
|
|
450
|
+
the model is replaced by a derived table of the permitted rows.
|
|
451
|
+
* `SqlQueryModel(..., access={"book": SqlModelAccess(...)})` overrides the
|
|
452
|
+
declared rules, `SqlQueryModel(..., context=user)` evaluates them for a
|
|
453
|
+
context.
|
|
454
|
+
* Models and fields without any access are removed from the query model and
|
|
455
|
+
its JSON schema (and are reported as unknown), partial access is checked
|
|
456
|
+
by the validation. `SqlQueryFactory` caches the query models per
|
|
457
|
+
permission signature: contexts with the same permissions share them.
|
|
458
|
+
* Partial access may still reveal values: `FILTER` by comparisons,
|
|
459
|
+
`AGGREGATE` by `min` / `max` of a single row.
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# querymodelling
|
|
2
|
+
build consistent api query models for fastapi
|
|
3
|
+
|
|
4
|
+
## Requirements
|
|
5
|
+
At least you will need to have the pydantic package installed. Of course it make sense to have some kind of service supporting query models like fast api. Currently sql models are implemented - but feel free to create new ones.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
## Example
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Typed SQL query models
|
|
13
|
+
|
|
14
|
+
`SqlQueryModel` generates a fully typed query model (incl. JSON schema)
|
|
15
|
+
with `joins`, `fields`, `where`, `group_by` and subqueries, which is compiled
|
|
16
|
+
into a SQLAlchemy select statement. Only the registered models can be
|
|
17
|
+
queried.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from sqlmodel import Session
|
|
21
|
+
from querymodelling.sql_query import SqlQueryModel
|
|
22
|
+
|
|
23
|
+
BookQuery = SqlQueryModel(Book, models=[Author, Review])
|
|
24
|
+
|
|
25
|
+
query = BookQuery.model_validate({
|
|
26
|
+
"joins": [
|
|
27
|
+
{
|
|
28
|
+
"model": "author",
|
|
29
|
+
"type": "left",
|
|
30
|
+
"on": {"eq": [
|
|
31
|
+
{"model": "book", "field": "author_id"},
|
|
32
|
+
{"model": "author", "field": "id"}
|
|
33
|
+
]}
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"fields": [
|
|
37
|
+
{"model": "book", "field": "id"},
|
|
38
|
+
{"model": "author", "field": "id", "alias": "author_id"},
|
|
39
|
+
{"lower": {"model": "author", "field": "name"}, "alias": "author_name"}
|
|
40
|
+
],
|
|
41
|
+
"where": {"and": [
|
|
42
|
+
{"eq": [{"model": "book", "field": "id"}, 123]},
|
|
43
|
+
{"startswith": [{"model": "author", "field": "name"}, "M"]}
|
|
44
|
+
]}
|
|
45
|
+
})
|
|
46
|
+
statement = query.to_select() # SQLAlchemy select with labeled columns
|
|
47
|
+
rows = query.fetch(session) # list of typed result rows
|
|
48
|
+
RowModel = query.result_model() # typed row, without executing the query
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```sql
|
|
52
|
+
SELECT book.id AS id, author.id AS author_id, lower(author.name) AS author_name
|
|
53
|
+
FROM book LEFT OUTER JOIN author ON book.author_id = author.id
|
|
54
|
+
WHERE book.id = 123 AND author.name LIKE 'M' || '%'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**One rule:** a plain JSON value (string, number, boolean) is always a
|
|
58
|
+
constant, an object is an expression identified by its keys:
|
|
59
|
+
|
|
60
|
+
| Object | Expression |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `{"model": "book", "field": "id"}` | field (`"as"` selects a joined instance) |
|
|
63
|
+
| `{"model": "review", "fields": [...], "where": ...}` | subquery (no `field`) |
|
|
64
|
+
| `{"lower": ...}`, `{"eq": [...]}` | function (its only key besides `alias`) |
|
|
65
|
+
| `{"ref": "author_name"}` | alias of `fields` (`group_by` only) |
|
|
66
|
+
|
|
67
|
+
* **Query**: `model` (defaults to the base model), `as`, `joins`, `fields`
|
|
68
|
+
(all fields of the queried model if not set), `where`, `group_by`.
|
|
69
|
+
Subqueries have the same structure.
|
|
70
|
+
* **Joins**: `{"model", "as", "type", "on"}` with `type` `inner` (default),
|
|
71
|
+
`left` or `full`. `on` may reference the queried model and the previous
|
|
72
|
+
joins. A model joined twice needs `as`, it is referenced by
|
|
73
|
+
`{"model": "author", "as": "reviewer", "field": "name"}`. Fields of the
|
|
74
|
+
outer side of an outer join are `Optional` in the result model.
|
|
75
|
+
* **Conditions** (`where`, `on`) are functions returning `bool`:
|
|
76
|
+
`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `in`, `not_in`
|
|
77
|
+
(`{"in": [x, [1, 2]]}` or `{"in": [x, <subquery>]}`), `contains`,
|
|
78
|
+
`icontains`, `startswith`, `istartswith`, `endswith`, `iendswith`, `like`,
|
|
79
|
+
`not_like`, `ilike`, `is_null`, `is_not_null`, `and`, `or`, `not` and
|
|
80
|
+
`exists` (`{"exists": <subquery>}`).
|
|
81
|
+
* **Functions**: `lower`, `upper`, `trim`, `length`, `concat`, `substring`,
|
|
82
|
+
`replace`, `abs`, `round`, `ceil`, `floor`, `add`, `sub`, `mul`, `div`,
|
|
83
|
+
`date`, `year`, `month`, `day`, `hour`, `minute`, `coalesce`, `nullif`,
|
|
84
|
+
`cast` (`{"cast": [x, "string"]}`) and the aggregates `count`
|
|
85
|
+
(`{"count": "*"}` counts all rows), `count_distinct`, `sum`, `avg`, `min`,
|
|
86
|
+
`max`.
|
|
87
|
+
* **Subqueries** used as value or in `in` return exactly one field, its type
|
|
88
|
+
is the type of the expression. Subqueries may reference the models of the
|
|
89
|
+
outer queries, inner models shadow outer ones with the same name (use
|
|
90
|
+
`as`).
|
|
91
|
+
* **Typing**: all expressions are typed per result type, e.g.
|
|
92
|
+
`{"lower": <int field>}` or `{"eq": [<int field>, "x"]}` are rejected by
|
|
93
|
+
the JSON schema and the validation.
|
|
94
|
+
* **Grouping**: fields outside of aggregate functions must be part of
|
|
95
|
+
`group_by`, aggregates must not be nested and are not allowed in `where`,
|
|
96
|
+
`on` and `group_by`. An empty `group_by` means no grouping.
|
|
97
|
+
* **Limits**: `max_joins` (default 5) per (sub)query and `max_depth`
|
|
98
|
+
(default 3) of nested subqueries.
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"joins": [{"model": "author", "type": "left", "on": {"eq": [
|
|
103
|
+
{"model": "book", "field": "author_id"}, {"model": "author", "field": "id"}
|
|
104
|
+
]}}],
|
|
105
|
+
"fields": [
|
|
106
|
+
{"lower": {"model": "author", "field": "name"}, "alias": "author_name"},
|
|
107
|
+
{"count": "*", "alias": "books"},
|
|
108
|
+
{"model": "review", "fields": [{"avg": {"model": "review", "field": "rating"}}],
|
|
109
|
+
"where": {"eq": [{"model": "review", "field": "book_id"}, {"model": "book", "field": "id"}]},
|
|
110
|
+
"alias": "avg_rating"}
|
|
111
|
+
],
|
|
112
|
+
"where": {"exists": {"model": "review", "where": {"eq": [
|
|
113
|
+
{"model": "review", "field": "book_id"}, {"model": "book", "field": "id"}
|
|
114
|
+
]}}},
|
|
115
|
+
"group_by": [{"ref": "author_name"}, {"model": "book", "field": "id"}]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Models are registered by their table name (`models=[Author]`) or explicitly
|
|
120
|
+
(`models={"writer": Author}`). `sources` overrides the table of a model,
|
|
121
|
+
`include_fields` / `exclude_fields` restrict the fields (a list for the base
|
|
122
|
+
model or a mapping of model names to lists).
|
|
123
|
+
|
|
124
|
+
Functions and conditions are provided by generators `(annotation) ->
|
|
125
|
+
Iterable[SqlExpressionFunction]` which are called for every known type:
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
from sqlalchemy import Integer, func
|
|
129
|
+
from querymodelling.sql_filter import without
|
|
130
|
+
from querymodelling.sql_query import (
|
|
131
|
+
Expr, SqlExpressionFunction, default_sql_expression_mapping
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def instr(annotation):
|
|
135
|
+
if annotation is str:
|
|
136
|
+
yield SqlExpressionFunction(
|
|
137
|
+
"instr", int, (Expr(str), Expr(str)),
|
|
138
|
+
lambda value, search: func.instr(value, search, type_=Integer()))
|
|
139
|
+
|
|
140
|
+
mapping = default_sql_expression_mapping()
|
|
141
|
+
mapping.function_generators = [
|
|
142
|
+
without(generator, "cast") for generator in mapping.function_generators
|
|
143
|
+
]
|
|
144
|
+
mapping.add_functions(instr)
|
|
145
|
+
|
|
146
|
+
BookQuery = SqlQueryModel(Book, models=[Author], mapping=mapping)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Argument placeholders are `Expr(*types)` (an expression of the types, any
|
|
150
|
+
type if empty), `Many(type)` (a list or a subquery, see `in`) and `Query()`
|
|
151
|
+
(a subquery, see `exists`). Referenceable fields are provided by reference
|
|
152
|
+
generators (`reference_generators=...`, e.g. virtual fields based on SQL
|
|
153
|
+
expressions).
|
|
154
|
+
|
|
155
|
+
#### Result schema
|
|
156
|
+
|
|
157
|
+
The result of a validated query is known without executing it:
|
|
158
|
+
`query.result_model()` is the typed model of a result row,
|
|
159
|
+
`query.result_schema()` the JSON schema of the result rows
|
|
160
|
+
(`result_schema(many=False)` of a single row).
|
|
161
|
+
|
|
162
|
+
* Fields copy the information of their source field: type, `title`,
|
|
163
|
+
`description`, `examples`, `json_schema_extra`, `deprecated` and the
|
|
164
|
+
constraints (`max_length`, `ge`, ...). They may be `NULL` if their field is
|
|
165
|
+
optional or on the outer side of an outer join.
|
|
166
|
+
* Constants are of their type and never `NULL`.
|
|
167
|
+
* Functions define their result: `result_type`, `nullable` and
|
|
168
|
+
`result_field`:
|
|
169
|
+
* `nullable` (`Nullability`): `PROPAGATE` (default, `NULL` if any argument
|
|
170
|
+
may be `NULL`), `COALESCE` (if all arguments may be), `ALWAYS` (e.g.
|
|
171
|
+
`sum`, `avg`, `min`, `max`, `nullif`, `div`) or `NEVER` (e.g. `count`,
|
|
172
|
+
`is_null`, `exists`), `True` / `False` for always / never.
|
|
173
|
+
* `result_field`: field information from the arguments, `inherit()` (all
|
|
174
|
+
of the argument, e.g. `min`, `max`, `nullif`) or `describe()` (only its
|
|
175
|
+
title and description, e.g. `lower`, `round`, `avg`, `coalesce`).
|
|
176
|
+
Without a rule the result has no field information.
|
|
177
|
+
* Subqueries are the result of their single field, `NULL` if they may return
|
|
178
|
+
no row (an aggregate without `group_by` always returns one row).
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
SqlExpressionFunction(
|
|
182
|
+
"initials", str, (Expr(str),), lambda value: func.substr(value, 1, 1),
|
|
183
|
+
nullable=Nullability.PROPAGATE, result_field=describe(),
|
|
184
|
+
)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
#### Access rules
|
|
188
|
+
|
|
189
|
+
Models and fields can be restricted per access context - any object of the
|
|
190
|
+
application, e.g. the user. Without rules everything is allowed. Rules are
|
|
191
|
+
fixed values or callables `(context) -> flags | bool` receiving the context
|
|
192
|
+
directly, `None` if no context is given. The declarations are generic in the
|
|
193
|
+
type of the context (`None` if not given, `SqlFieldAccess[User]`,
|
|
194
|
+
`SqlQueryFactory[User]`), `SqlModelAccess` also in the model of the rows
|
|
195
|
+
rule (`SqlModelAccess[User, Self]` within the class body, the model defaults
|
|
196
|
+
to `Any`).
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
from typing import Annotated, Self
|
|
200
|
+
from querymodelling.sql_query import (
|
|
201
|
+
INVISIBLE, FieldUsage, ModelUsage, SqlFieldAccess, SqlModelAccess,
|
|
202
|
+
SqlQueryFactory
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
class Book(SQLModel, table=True):
|
|
206
|
+
__sql_access__ = SqlModelAccess[User, Self](
|
|
207
|
+
# rows of the user's tenant only - in every position
|
|
208
|
+
rows=lambda user, book: book.tenant_id == user.tenant_id,
|
|
209
|
+
)
|
|
210
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
211
|
+
title: str
|
|
212
|
+
cost: Annotated[float, SqlFieldAccess[User](lambda user: (
|
|
213
|
+
FieldUsage.ALL if user.is_admin else FieldUsage.AGGREGATE))]
|
|
214
|
+
tenant_id: Annotated[int, SqlFieldAccess(INVISIBLE)]
|
|
215
|
+
|
|
216
|
+
class Salary(SQLModel, table=True):
|
|
217
|
+
__sql_access__ = SqlModelAccess[User](usage=lambda user: user.is_admin)
|
|
218
|
+
...
|
|
219
|
+
|
|
220
|
+
queries = SqlQueryFactory[User](Book, models=[Author, Salary])
|
|
221
|
+
queries.schema(user) # JSON schema of the user
|
|
222
|
+
query = queries.validate(data, user) # validated for the user
|
|
223
|
+
query.fetch(session) # rows rules applied for the user
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
* Fields: `FieldUsage.SELECT` (`fields`), `FILTER` (`where`, `on`), `GROUP`
|
|
227
|
+
(`group_by`), `AGGREGATE` (within aggregate functions), `ALL` / `NONE`
|
|
228
|
+
(`VISIBLE` / `INVISIBLE`), declared by `Annotated[..., SqlFieldAccess(rule)]`
|
|
229
|
+
or `SqlModelAccess(fields={"name": rule})`.
|
|
230
|
+
* Models: `ModelUsage.QUERY` (queried model), `JOIN`, `SUBQUERY`, declared
|
|
231
|
+
by `__sql_access__ = SqlModelAccess(usage=rule)`.
|
|
232
|
+
* `rows`: `(context, entity) -> condition` restricts the rows of a model
|
|
233
|
+
wherever it is used (queried model, joins incl. outer joins, subqueries),
|
|
234
|
+
the model is replaced by a derived table of the permitted rows.
|
|
235
|
+
* `SqlQueryModel(..., access={"book": SqlModelAccess(...)})` overrides the
|
|
236
|
+
declared rules, `SqlQueryModel(..., context=user)` evaluates them for a
|
|
237
|
+
context.
|
|
238
|
+
* Models and fields without any access are removed from the query model and
|
|
239
|
+
its JSON schema (and are reported as unknown), partial access is checked
|
|
240
|
+
by the validation. `SqlQueryFactory` caches the query models per
|
|
241
|
+
permission signature: contexts with the same permissions share them.
|
|
242
|
+
* Partial access may still reveal values: `FILTER` by comparisons,
|
|
243
|
+
`AGGREGATE` by `min` / `max` of a single row.
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
21
|
+
__version__ = version = '0.0.18'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 18)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gff99dd942'
|