querymodelling 0.0.17__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.
Files changed (63) hide show
  1. querymodelling-0.0.18/.vscode/launch.json +18 -0
  2. querymodelling-0.0.18/HISTORY.md +64 -0
  3. {querymodelling-0.0.17 → querymodelling-0.0.18}/PKG-INFO +211 -59
  4. querymodelling-0.0.18/README.md +243 -0
  5. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/_version.py +3 -3
  6. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/sql_filter.py +14 -8
  7. querymodelling-0.0.18/querymodelling/sql_query/__init__.py +154 -0
  8. querymodelling-0.0.18/querymodelling/sql_query/access.py +159 -0
  9. querymodelling-0.0.18/querymodelling/sql_query/builder.py +416 -0
  10. querymodelling-0.0.18/querymodelling/sql_query/conditions.py +151 -0
  11. querymodelling-0.0.18/querymodelling/sql_query/expressions.py +179 -0
  12. querymodelling-0.0.18/querymodelling/sql_query/factory.py +113 -0
  13. querymodelling-0.0.18/querymodelling/sql_query/functions.py +216 -0
  14. querymodelling-0.0.18/querymodelling/sql_query/mapping.py +145 -0
  15. querymodelling-0.0.18/querymodelling/sql_query/query.py +481 -0
  16. querymodelling-0.0.18/querymodelling/sql_query/references.py +225 -0
  17. querymodelling-0.0.18/querymodelling/sql_query/results.py +151 -0
  18. querymodelling-0.0.18/querymodelling/sql_query/scope.py +87 -0
  19. querymodelling-0.0.18/querymodelling/sql_query/unions.py +88 -0
  20. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/PKG-INFO +211 -59
  21. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/SOURCES.txt +19 -1
  22. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/scm_file_list.json +19 -1
  23. querymodelling-0.0.18/querymodelling.egg-info/scm_version.json +8 -0
  24. querymodelling-0.0.18/test.py +337 -0
  25. querymodelling-0.0.18/tests/sql_access_test.py +398 -0
  26. {querymodelling-0.0.17 → querymodelling-0.0.18}/tests/sql_filter_test.py +4 -0
  27. querymodelling-0.0.18/tests/sql_query_test.py +604 -0
  28. querymodelling-0.0.18/tests/sql_result_test.py +205 -0
  29. querymodelling-0.0.17/HISTORY.md +0 -50
  30. querymodelling-0.0.17/README.md +0 -91
  31. querymodelling-0.0.17/querymodelling.egg-info/scm_version.json +0 -8
  32. {querymodelling-0.0.17 → querymodelling-0.0.18}/.github/workflows/run-publish.yml +0 -0
  33. {querymodelling-0.0.17 → querymodelling-0.0.18}/.github/workflows/run-tests.yml +0 -0
  34. {querymodelling-0.0.17 → querymodelling-0.0.18}/.gitignore +0 -0
  35. {querymodelling-0.0.17 → querymodelling-0.0.18}/.readthedocs.yaml +0 -0
  36. {querymodelling-0.0.17 → querymodelling-0.0.18}/.vscode/extensions.json +0 -0
  37. {querymodelling-0.0.17 → querymodelling-0.0.18}/.vscode/settings.json +0 -0
  38. {querymodelling-0.0.17 → querymodelling-0.0.18}/AUTHORS.rst +0 -0
  39. {querymodelling-0.0.17 → querymodelling-0.0.18}/LICENSE +0 -0
  40. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/Makefile +0 -0
  41. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/conf.py +0 -0
  42. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/index.rst +0 -0
  43. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/make.bat +0 -0
  44. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/requirements.txt +0 -0
  45. {querymodelling-0.0.17 → querymodelling-0.0.18}/docs/user/install.rst +0 -0
  46. {querymodelling-0.0.17 → querymodelling-0.0.18}/pyproject.toml +0 -0
  47. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/__helper__.py +0 -0
  48. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/__init__.py +0 -0
  49. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/__version__.py +0 -0
  50. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/base.py +0 -0
  51. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/fields.py +0 -0
  52. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/model.py +0 -0
  53. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/pydantic.py +0 -0
  54. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/redis_om.py +0 -0
  55. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling/sql.py +0 -0
  56. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/dependency_links.txt +0 -0
  57. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/requires.txt +0 -0
  58. {querymodelling-0.0.17 → querymodelling-0.0.18}/querymodelling.egg-info/top_level.txt +0 -0
  59. {querymodelling-0.0.17 → querymodelling-0.0.18}/setup.cfg +0 -0
  60. {querymodelling-0.0.17 → querymodelling-0.0.18}/setup.py +0 -0
  61. {querymodelling-0.0.17 → querymodelling-0.0.18}/tests/__init__.py +0 -0
  62. {querymodelling-0.0.17 → querymodelling-0.0.18}/tests/__main__.py +0 -0
  63. {querymodelling-0.0.17 → 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.17
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,84 +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
- ### Typed SQL filter models
228
227
 
229
- `SqlFilterModel` generates a nested, fully typed filter model (incl. JSON
230
- schema) from a model and compiles it into a SQLAlchemy where clause.
228
+ ### Typed SQL query models
231
229
 
232
- ```python
233
- from sqlmodel import SQLModel, Field, select
234
- from querymodelling.sql_filter import SqlFilterModel
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.
235
234
 
236
- class ExampleModel(SQLModel, table=True):
237
- id: int | None = Field(default=None, primary_key=True)
238
- name: str
239
- description: str
235
+ ```python
236
+ from sqlmodel import Session
237
+ from querymodelling.sql_query import SqlQueryModel
240
238
 
241
- ExampleFilter = SqlFilterModel(ExampleModel)
239
+ BookQuery = SqlQueryModel(Book, models=[Author, Review])
242
240
 
243
- query = ExampleFilter.model_validate({
244
- "and": {
245
- "name": {"contains": "wurst", "startswith": "bump"},
246
- "description": {"lower": {"contains": "test"}},
247
- },
248
- "or": [{"id": {"in": [1, 2]}}, {"name": {"length": {"gt": 3}}}],
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
+ ]}
249
261
  })
250
- statement = query.apply_sql(select(ExampleModel))
251
- # or: select(ExampleModel).where(query.to_sql_expression())
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
+ }
252
333
  ```
253
334
 
254
- * Fields of the filter object are combined with `AND`.
255
- * `and` / `or` accept an object (its conditions are combined) or a list of
256
- objects (each object is combined with `AND`, the list with the operator).
257
- `not` negates an object. Everything can be nested.
258
- * Operators per type (default): `eq`, `ne`, `is_null`, `in`, `not_in`
259
- (scalars), `gt`, `gte`, `lt`, `lte`, `between` (numbers, dates, times),
260
- `contains`, `icontains`, `startswith`, `istartswith`, `endswith`,
261
- `iendswith`, `like`, `not_like`, `ilike` (strings).
262
- * SQL functions per type (default): `lower`, `upper`, `trim`, `length`
263
- (strings), `abs` (numbers), `date`, `year`, `month`, `day`, `hour`,
264
- `minute` (dates / times). Functions return a filter of their result type,
265
- e.g. `{"name": {"length": {"gt": 3}}}`.
266
-
267
- Everything is built from generators and can be extended or restricted:
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:
268
342
 
269
343
  ```python
270
- from querymodelling.sql_filter import (
271
- SqlOperator, SqlFunction, default_sql_filter_mapping, for_types, without
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
272
348
  )
273
349
 
274
- def parity(annotation):
275
- yield SqlOperator(
276
- "even", lambda column, value: (column % 2 == 0) == value,
277
- value_type=lambda annotation: bool)
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()))
278
355
 
279
- mapping = default_sql_filter_mapping(name_prefix="Custom")
280
- mapping.operator_generators = [
281
- without(generator, "like", "ilike")
282
- for generator in mapping.operator_generators
356
+ mapping = default_sql_expression_mapping()
357
+ mapping.function_generators = [
358
+ without(generator, "cast") for generator in mapping.function_generators
283
359
  ]
284
- mapping.add_operators(for_types(parity, int, exclude=(bool,)))
360
+ mapping.add_functions(instr)
285
361
 
286
- ExampleFilter = SqlFilterModel(ExampleModel, mapping=mapping)
362
+ BookQuery = SqlQueryModel(Book, models=[Author], mapping=mapping)
287
363
  ```
288
364
 
289
- * operator generators `(annotation) -> Iterable[SqlOperator]`
290
- * function generators `(annotation) -> Iterable[SqlFunction]`
291
- * field generators `(context, model, name, field_info, annotation) ->
292
- Iterable[SqlFilterField]` (`field_generators=...`, e.g. virtual fields
293
- based on SQL expressions)
294
- * logical generators `(model) -> Iterable[SqlLogicalOperator]`
295
- (`logical_generators=...`)
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
296
372
 
297
- The filter can be used within existing query models as well:
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).
298
395
 
299
396
  ```python
300
- from querymodelling.pydantic import QueryField
301
- from querymodelling.sql_filter import sql_filter_expression
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
+ ...
302
435
 
303
- class ExampleQuery(PageQuery, BaseModel):
304
- filter: ExampleFilter | None = QueryField(
305
- sql_filter_expression, default=None)
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
306
440
  ```
307
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.17'
22
- __version_tuple__ = version_tuple = (0, 0, 17)
21
+ __version__ = version = '0.0.18'
22
+ __version_tuple__ = version_tuple = (0, 0, 18)
23
23
 
24
- __commit_id__ = commit_id = 'g9261b3e64'
24
+ __commit_id__ = commit_id = 'gff99dd942'