oplnk-python-utils 1.3.0__tar.gz → 1.5.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.
Files changed (49) hide show
  1. oplnk_python_utils-1.5.0/CHANGELOG.md +272 -0
  2. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/LICENSE +21 -21
  3. oplnk_python_utils-1.5.0/MANIFEST.in +10 -0
  4. oplnk_python_utils-1.5.0/PKG-INFO +1039 -0
  5. oplnk_python_utils-1.5.0/README.md +997 -0
  6. oplnk_python_utils-1.5.0/docs/audit/2026-10-audit.md +1062 -0
  7. oplnk_python_utils-1.5.0/docs/migration-1.4.md +111 -0
  8. oplnk_python_utils-1.5.0/docs/query-contract.md +133 -0
  9. oplnk_python_utils-1.5.0/docs/query-sql-adapter.md +195 -0
  10. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils/__init__.py +14 -13
  11. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils/cache/__init__.py +35 -29
  12. oplnk_python_utils-1.5.0/oplnk_python_utils/cache/cache_utils.py +1959 -0
  13. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils/core/__init__.py +9 -13
  14. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils/core/data.py +82 -58
  15. oplnk_python_utils-1.5.0/oplnk_python_utils/core/parsing.py +402 -0
  16. oplnk_python_utils-1.5.0/oplnk_python_utils/core/serialization.py +74 -0
  17. oplnk_python_utils-1.5.0/oplnk_python_utils/py.typed +0 -0
  18. oplnk_python_utils-1.5.0/oplnk_python_utils/query/__init__.py +17 -0
  19. oplnk_python_utils-1.5.0/oplnk_python_utils/query/adapter.py +72 -0
  20. oplnk_python_utils-1.5.0/oplnk_python_utils/query/compiler.py +813 -0
  21. oplnk_python_utils-1.5.0/oplnk_python_utils/query/constants.py +118 -0
  22. oplnk_python_utils-1.5.0/oplnk_python_utils/query/errors.py +42 -0
  23. oplnk_python_utils-1.5.0/oplnk_python_utils/query/executor.py +238 -0
  24. oplnk_python_utils-1.5.0/oplnk_python_utils/query/query_builder.py +882 -0
  25. oplnk_python_utils-1.5.0/oplnk_python_utils/query/sql/__init__.py +15 -0
  26. oplnk_python_utils-1.5.0/oplnk_python_utils/query/sql/adapter.py +47 -0
  27. oplnk_python_utils-1.5.0/oplnk_python_utils/query/sql/compiler.py +371 -0
  28. oplnk_python_utils-1.5.0/oplnk_python_utils/query/sql/mapping.py +111 -0
  29. oplnk_python_utils-1.5.0/oplnk_python_utils/query/support.py +200 -0
  30. oplnk_python_utils-1.5.0/oplnk_python_utils.egg-info/PKG-INFO +1039 -0
  31. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils.egg-info/SOURCES.txt +17 -3
  32. oplnk_python_utils-1.5.0/oplnk_python_utils.egg-info/requires.txt +20 -0
  33. oplnk_python_utils-1.5.0/pyproject.toml +76 -0
  34. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/setup.cfg +4 -4
  35. oplnk_python_utils-1.3.0/MANIFEST.in +0 -6
  36. oplnk_python_utils-1.3.0/PKG-INFO +0 -806
  37. oplnk_python_utils-1.3.0/README.md +0 -764
  38. oplnk_python_utils-1.3.0/oplnk_python_utils/cache/cache_utils.py +0 -447
  39. oplnk_python_utils-1.3.0/oplnk_python_utils/core/parsing.py +0 -193
  40. oplnk_python_utils-1.3.0/oplnk_python_utils/core/serialization.py +0 -32
  41. oplnk_python_utils-1.3.0/oplnk_python_utils/query/__init__.py +0 -9
  42. oplnk_python_utils-1.3.0/oplnk_python_utils/query/query_builder.py +0 -1523
  43. oplnk_python_utils-1.3.0/oplnk_python_utils/reports/__init__.py +0 -3
  44. oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info/PKG-INFO +0 -806
  45. oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info/not-zip-safe +0 -1
  46. oplnk_python_utils-1.3.0/oplnk_python_utils.egg-info/requires.txt +0 -9
  47. oplnk_python_utils-1.3.0/setup.py +0 -42
  48. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils.egg-info/dependency_links.txt +0 -0
  49. {oplnk_python_utils-1.3.0 → oplnk_python_utils-1.5.0}/oplnk_python_utils.egg-info/top_level.txt +0 -0
@@ -0,0 +1,272 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.5.0] - 2026-10-26
6
+
7
+ ### Added
8
+
9
+ - `oplnk_python_utils.query.sql`, an optional SQLAlchemy Core compiler and executor for the query contract (Q2a/Q2b/Q2d). `SQLQueryCompiler` builds the data/count selects and `SQLQueryAdapter.fetch()`/`fetch_sync()` execute them against a SQLAlchemy session; Q2d adds `$or`/`$and`, the full text operator family, the `$notIn` alias and SQL null/exists semantics (`$null`, `$notNull`, `$exists`, `$notExists`). SQL Server is the first dialect; install it with `oplnk-python-utils[sql]`. The Mongo path is unchanged and does not import SQLAlchemy.
10
+
11
+ ### Performance
12
+
13
+ - The query engine's processing time is back to parity with 1.3.0 and faster in most operations: the package-only geometric mean went from 1.71x to 0.71x, with 9 of 12 benchmark cases at parity or faster. Relation specs and field-path normalization are memoized, the filter helpers became module functions instead of per-call closures, operator membership uses sets, `$or` no longer re-enters the full builder per field, and pagination copies only the stages it replaces. Measured with the dedicated suites in `benchmarks/` (repository tooling, never published); end to end against a real MongoDB the database is 95-102% of a request and no case regressed.
14
+
15
+ ### Migration Notes
16
+
17
+ - No breaking changes: the Mongo contract is 1.4.0's. A service coming from 1.3.0 faces only the changes listed under `[1.4.0]`; `python -m tools.migrate` (now targeting 1.5.0) and `docs/migration-1.4.md` are the procedure. A service already on 1.4.0 only moves its pin.
18
+ - The SQL compiler and adapter are optional: install them with `oplnk-python-utils[sql]`; the base package never imports SQLAlchemy.
19
+
20
+ ## [1.4.0] - 2026-10-26
21
+
22
+ ### Breaking changes
23
+
24
+ Upgrading needs these changes or checks in a service. The codemod in `tools/migrate/` applies the automatic ones (see the migration guide).
25
+
26
+ - Python 3.10 or later (`Requires-Python: >=3.10`). On 3.8 and 3.9 installers choose an older release, which also fails on import there.
27
+ - `fastapi>=0.104` and `pymongo>=4.6`: a service that pins older versions gets a resolver conflict. Every current consumer pins fastapi 0.104.1 or 0.110.0 and pymongo 4.6.1 or 4.6.2.
28
+ - `import oplnk_python_utils.reports` raises `ModuleNotFoundError`.
29
+ - The package ships `py.typed`: services that run mypy or pyright may see new type errors from the library's annotations.
30
+ - No `setup.py`: an editable install of the library needs pip 21.3 or later.
31
+ - `QueryBuilder` answers these client mistakes with HTTP 400 instead of 500: malformed parameters (`filters[n]=4&filters[n][$eq]=5`, `?0=x`), `filters[status]=active`, `$or`/`$and` that is not a list of conditions, `filters` that is not an object, `pagination[page]` or `pagination[pageSize]` set to `0`, a negative number or `abc`, `pagination=5`, a page too large for MongoDB, `sort[0]=name` and `distinct[0]=spec&distinct[1]=spec.color` (A-03, A-04, A-06).
32
+ - These queries answered 200 and now answer 400: an invalid filter path (`filters[na me][$eq]=x`, `filters[spec.$size][$eq]=x`, `filters[spec][si ze][$eq]=x`, also inside `$or`/`$and`), `$between` without exactly 2 values, an invalid sort item next to valid ones, an invalid `fields` path or `distinct` field, an empty `distinct` list, `distinct[0]=spec.color&distinct[1]=spec`, `pagination[pageSize]=2.5` (was a page of 2) and `pagination[page]=true` (was page 1) (A-04, A-06).
33
+ - Every HTTP 500 from `read_query` and `get_query_response` has the detail `Internal server error`. The original message is only in the logs (A-03).
34
+ - The builder methods (`build_filter_pipeline`, `build_sort_pipeline`, `build_fields_pipeline`, `build_distinct_pipeline`, `build_pagination_pipeline`, `make_aggregation_query`, `make_count_aggregation_query`) raise `QueryValidationError` instead of skipping invalid items (A-04).
35
+ - A relation whose field has no `_id` (neither `_id` nor `_ids` in the name) and that declares no collection raises `ValueError` when the `QueryBuilder` is created (a service's `CRUD(...)`, usually at import), naming the relation and field: `Relation 'Users' cannot be looked up: its field 'person' does not end with '_id' or '_ids' and it declares no collection (fourth element)`. Such a relation could never be populated (1.3.0: `UnboundLocalError`, HTTP 500). No current service declares one (A-07, C-19).
36
+ - `debug=True` and the distinct diagnostics no longer print to stdout. They are English DEBUG records of `oplnk_python_utils.query.query_builder`, visible only when DEBUG logging is configured (A-08, C-23).
37
+ - `QueryBuilder` filters accept only the operators the README documents, plus `$nin` as an alias of `$notIn`, whether or not protected fields are configured (A-01, D10). Any other operator, anywhere in `filters` (inside `$or`/`$and`, under a nested field and inside another operator's value too), answers 400 `Operator '<operator>' is not allowed in filters`. Queries that answered 200, 500 or a different 400 and now answer 400 include, for example, `$regex` (with or without `$options`), `$all`, `$size`, `$mod`, `$type`, `$bitsAllSet`, `$not`, `$elemMatch`, `$text`, `$nor`, unknown operators such as `$foo`, an operator under a nested field or inside a value (`filters[spec][size][$regex]=x`, `filters[name][$eq][$regex]=x`, `filters[tags][$listContains][$regex]=x`), and, with the protection turned off, `$expr` (which could match every document), `$where`, `$function` and `$accumulator`. A service that passes such an operator in a pre-parsed `query` must use a documented one (for example `$containsi` instead of `$regex`).
38
+ - A path segment that starts with `$` answers 400 instead of 500 (A-01). In `filters`, an allowed operator in place of a field gets `Invalid field path '<path>' in filters` (`filters[$eq][$eq]=1`, also inside `$or`/`$and`), and any other `$` segment gets the operator detail (`filters[$][$eq]=1` answers `Operator '$' is not allowed in filters`, `filters[a][$b][$eq]=1` answers `Operator '$b' is not allowed in filters`). `sort`, `fields` and `distinct` answer `Invalid field path '<path>' in <parameter>` (`sort[0]=$name:asc`). `$or` or `$and` given as a field's operator (`filters[name][$or]=x`) answers 400 `Invalid filter on 'name': $or is not a field operator`. A populate name that contains `$` is ignored like any unknown populate name (`populate[0]=$foo&fields[0]=name` answered 500).
39
+ - `distinct` reads array notation as dot notation, like `filters`, `sort` and `fields`: `distinct[0]=shelf[0]` returns the rows of `distinct[0]=shelf.0` (`{"_id": {"shelf_0": …}, "shelf": {"0": …}}`) instead of a literal `"shelf[0]"` key and the group key `shelf_0_`.
40
+ - `validate_field_path` returns `False` for a path that starts with `$` (`"$name"`, `"$$ROOT"`), and `build_aggregation_field_expression` raises `ValueError` (`Invalid field path: $name`) for it instead of returning it unchanged (A-01). A service that calls them with an expression must pass the field path instead (`"name"` still gives `"$name"`).
41
+ - `validate_field_path` returns `False` for a path with an empty segment (`a..b`, `.a`, `a.`), and `build_aggregation_field_expression` raises `ValueError` for it. In `QueryBuilder`, `fields`, `sort`, `distinct` and populate fields with such a path answer 400 instead of a MongoDB 500, and `filters` with one (`filters[a..b][$eq]=1`, also inside `$or`/`$and`) answer 400 instead of 200 with nothing matched (D9).
42
+ - These `QueryBuilder` queries answered 200 and now answer 400 (D9, D10): a sort order other than `asc` or `desc` in any case (`sort[0]=name:1`, `sort[0]=name:xyz`, which sorted descending); an operator inside another operator's value, at any depth (`filters[stock][$eq][$gt]=3`, which ran as `$gt`, `filters[name][$ne][$eq]=x`, `filters[spec][$eq][size][$eq]=L`), with the detail `Invalid filter on '<field>': operator '<operator>' is not allowed inside the value of <operator>`, and `Invalid filter on '<field>': operator '<operator>' is not allowed inside the nested field <field>.<name>` when the level is a nested field rather than an operator (`filters[spec][size][0][$eq]=L`); a `populate[...][fields]` item that is not a valid field path, such as `na me` (`Invalid field path 'na me' in populate[users][fields]`); and a query parameter nested more than 32 levels of brackets or pre-parsed `filters` nested more than 32 levels (`Invalid query parameter 'filters': it nests more than 32 levels of brackets`, `'filters' nests more than 32 levels`).
43
+ - These answered 500 and now answer 400 (D9): overlapping `fields` or populate fields (`fields[0]=spec&fields[1]=spec.color` answers `Invalid field 'spec.color' in fields: it overlaps another field`); a `$` path in `populate[...][fields]` (`populate[users][fields][0]=$where`); a populate name that is a list or an object (`populate[0][0]=x` answers `Invalid populate name ['x']: expected a relation name`); a `populate` that is neither a list nor an object (`populate=users`, `populate=1`) together with `fields` (`'populate' must be a list or an object, got 'users'`; without `fields` it is still ignored); an operator inside a value that MongoDB rejected (`filters[name][$eq][$contains]=x`, `filters[status][$in][0][$eq]=active`); and filters nested about 200 levels or more, or keys nested about 600 levels or more, which raised `RecursionError`.
44
+ - `parse_params_to_dict` raises `ValueError` for a key nested more than `MAX_NESTING_DEPTH` (32) levels of brackets.
45
+
46
+ ### Security
47
+
48
+ - User password hashes and Active Directory data no longer leave `QueryBuilder` (audit findings C-01 and C-02). `PROTECTED_USER_FIELDS` (`hashed_password`, `active_directory`) are removed from every lookup and from the main query, even when requested through `fields` or `populate[...][fields]`.
49
+ - Filtering, sorting or requesting distinct values on a protected field returns HTTP 400 instead of running the query, which closes the `totalCount` oracle on related user fields.
50
+ - Filters accept only the documented operators, whether or not protected fields are configured (A-01): the expression operators `$expr`, `$where`, `$function` and `$accumulator`, which can read any field, and `$regex`, which ran a pattern of the client's choice, return HTTP 400, also inside `$or`/`$and` or inside another operator's value.
51
+
52
+ ### Added
53
+
54
+ - `PROTECTED_USER_FIELDS`, exported from `oplnk_python_utils.query`, and the `QueryBuilder.protected_user_fields` class attribute to override it per consumer.
55
+ - `get_or_set_cache(..., tags=None)`: a newly stored key is added to `tag:<tag>` for each tag, as `cache_response` does, so `invalidate_tag` deletes it. Only a confirmed write is tagged.
56
+ - `ainvalidate_tag(redis_client, tag)` and `aget_or_set_cache(...)`, exported from `oplnk_python_utils.cache`: async versions with the same parameters and return values. They take an async client (`redis.asyncio.Redis`) or a sync one, whose commands then run off the event loop; `aget_or_set_cache` awaits a `fetch_func` that returns an awaitable (D1, B-07).
57
+ - A WARNING `Redis client for <host>:<port> has no socket timeout; …`, logged once per client when an endpoint is decorated with a sync client that has no `socket_timeout` (B-01).
58
+ - `oplnk_python_utils/py.typed` (PEP 561): mypy and pyright now read the library's type hints instead of treating it as untyped.
59
+ - `cache_response` and the `cache_factory` decorator take keyword-only `can_clear`, `clear_cache_interval` and `single_flight` (D5, B-06, B-21). `can_clear(request)`, sync or async, decides whether a request's `?clear_cache` may purge; a refused request is served as if it had no `clear_cache`, its `QueryBuilder` count included (no count purge, and a cached count may be served); a `can_clear` that raises fails the request. `clear_cache_interval` (seconds, default 0: no limit, at most 9223090561878065, the largest `PX` Redis accepts) opts in to at most one purge per collection per interval, across processes; when Redis answers a purge's invalidation with an error (such as WRONGTYPE, OOM or READONLY), the purge deletes the `tag-purge:<collection>` key it set, by a script and only while the key still holds that request's own value, so the next `?clear_cache` purges; after a connection or timeout error, or on a server that refuses the cache's scripts, the key expires with the interval. `single_flight` (default True) lets concurrent misses on one key share one run of the endpoint. An invalid `can_clear` or `clear_cache_interval` raises `ValueError` when decorating.
60
+ - The `?clear_cache` purge limit is opt-in (D5): by default every `?clear_cache` purges, as in 1.3.0. With `clear_cache_interval` set, a purge first sets the Redis key `tag-purge:<collection>` to a random value of its own with `SET NX PX` (one more round trip per purge), and at most one purge per collection runs per interval, across processes. A purge refused by the limit invalidates nothing, but skips its own cached entry, runs the endpoint and stores its response under its own key; other entries of the collection, such as the grid a "Refresh" button refetches without `clear_cache`, may then be served up to the interval old. In that request `QueryBuilder` does not purge `<prefix>:query:count:*` either, and replaces only its own cached count with a fresh one. A Redis error while claiming the slot is logged as `Redis error during purge rate limit: <error>` (`extra` `cache_tag`) and the request runs uncached. `?clear_cache` stays open to every caller unless `can_clear` is given; `read_query` called outside a decorated request purges its counts as before.
61
+ - `cache_response` and the `cache_factory` decorator take keyword-only `methods`, `vary_on` and `key_builder` (D6, D7, B-04). `methods` lists the HTTP methods to cache, replacing the default `["GET", "HEAD"]`; `vary_on` adds request header values (such as `Authorization` or a tenant header) to the key; `key_builder(request) -> str` replaces the key material. Header values and the builder's string are hashed into the key. Invalid options raise `ValueError` at decoration time; a `key_builder` that returns anything but a str raises `TypeError` in the request. `build_cache_key` takes `vary_on` too.
62
+ - `ainvalidate_cache_keys(redis_client, pattern)`, exported from `oplnk_python_utils.cache`: async `invalidate_cache_keys`, with the same pattern rules and return value (the number of keys deleted, or -1 after a logged Redis error). It takes an async client (`redis.asyncio.Redis`), or a sync one whose commands run in the cache's thread pool. The `TypeError` that `invalidate_cache_keys` raises for an async client now names it.
63
+ - `cache_response(..., retry_delay=0.1)`, and `max_retries`/`retry_delay` on the decorator that `cache_factory` returns (B-17).
64
+ - DEBUG logs `Cache hit for key <key>` and `Cache miss for key <key>` from `oplnk_python_utils.cache.cache_utils`, with `extra` fields `cache_key` and `cache_hit` (B-23).
65
+ - `count_cache_ttl` on `QueryBuilder.read_query` and `get_query_response`: the seconds a cached `totalCount` lives. It defaults to 1800 (30 minutes, as before); `None` keeps the count until it is invalidated (B-03). It takes the same values as `ttl` (see Changed); anything else raises `ValueError` before any query runs, with or without the cache.
66
+ - `QueryValidationError`, a `ValueError` exported from `oplnk_python_utils.query`. The `QueryBuilder` builder methods raise it for a client mistake: a malformed parameter, an invalid field path, sort item or filter, or invalid pagination. `read_query` and `get_query_response` answer it with HTTP 400 and its message as the detail (A-03, A-04).
67
+ - `QueryBuilder.max_page_size` (default `None`, no maximum). Set it in a subclass and a larger `pagination[pageSize]` gets 400 `pagination[pageSize] must be at most <max>, got <n>`; the default page size of 10 is capped at it (A-06).
68
+ - `$nin`, an alias of `$notIn`: a single value is a one-item list, so `filters[status][$nin]=active` works instead of answering 500 (D10).
69
+ - `RESERVED_AUDIT_FIELDS`, exported from `oplnk_python_utils.query` next to `PROTECTED_USER_FIELDS` (C-22).
70
+ - `MAX_NESTING_DEPTH` (32) in `oplnk_python_utils.core.parsing`: the most bracket levels one query parameter, and `QueryBuilder` filters, may nest.
71
+ - `tools/migrate`, a codemod that moves a backend from 1.3 or earlier to 1.4.0. It lives in this repository and is not in the package. `python -m tools.migrate <repo>` lists the changes, `--check` exits 1 when changes are pending, and `--write` applies them, only on a clean git working tree; every change and flag is reported as `path:line` with its rule id, in the console, Markdown (`--report`) or JSON (`--json`). `--write` replaces each file atomically, never reads or writes through a symlink, checks the report paths before changing anything, and keeps writing the other files when one fails (exit 3, listed as `ERROR <path>: <reason>`). See "Migrating to 1.4" in the README.
72
+ - The codemod's rules, run in this order: `D6-methods` adds `methods=[...]` to a cache decorator on a POST, PUT, PATCH or DELETE route; `D4-ttl-zero` turns `ttl=0` into `use_cache=False`; `D1-ainvalidate` turns an `invalidate_tag(...)` statement in an `async def` into `await ainvalidate_tag(...)`; `REQ-bump` pins `oplnk-python-utils==1.4.0` in `requirements*.txt`; `DEV-fakeredis-lua` turns `fakeredis` into `fakeredis[lua]`. They also flag what they cannot change safely, such as non-literal TTLs, redis-py below 5.0 and Python below 3.10.
73
+ - The codemod's flag rules report what it cannot rewrite safely: `D10-operators` (a literal filter with an operator 1.4.0 answers with 400), `D12-relations` (a relation whose field has no `_id`), `C07-reports`, `C01-lookup-override`, `B01-redis-timeout`, `C11-parse-params`, `A04-builders` and `A08-debug`. `D1-ainvalidate` also awaits `invalidate_cache_keys` as `ainvalidate_cache_keys` and removes a sync name from its import once nothing uses it. `REQ-bump` flags `fastapi` below 0.104 and `pymongo` below 4.6, flags a pin it cannot compare (`==1.5.0rc1`, `redis~=5`) instead of changing it or failing, and notes the populate changes for a pin before 1.3.0. `D6-methods` treats a decorator as a route only for a router created with `APIRouter(...)` or `FastAPI(...)` in the module (so `@mock.patch(...)` is not rewritten, only flagged) and flags `api_route(methods=[b"POST"])`. Reports are written atomically, and a report path that is a symlink is refused (exit 2). `D1-ainvalidate` keeps a sync name in an import when another module of the repository imports it from that module, or when `__all__` lists it; `D12-relations` reads only modules that import the library or a `QueryBuilder` subclass of the repository; writing a new report does not touch the process umask.
74
+
75
+ ### Changed
76
+
77
+ - `get_query_response` re-raises `HTTPException` unchanged instead of wrapping it in a 500.
78
+ - Python 3.10 or later is required (`Requires-Python: >=3.10`, was `>=3.8`). 3.8 and 3.9 never worked: the code uses `match`/`case` and `X | None` at runtime, so the package installed there and failed on import (C-06). The classifiers list 3.10 to 3.13.
79
+ - The dependency floors rise to versions CI tests: `fastapi>=0.104` (was `>=0.65.0`) and `pymongo>=4.6` (was `>=3.0.0`); `redis>=5.0` is unchanged. All three are still required (C-08).
80
+ - `starlette>=0.27` is a declared dependency: the cache imports it directly. fastapi 0.104 already requires starlette 0.27.x, so installs resolve as before. The fallback import of pydantic's `ValidationError` for FastAPI before 0.100 is removed.
81
+ - Packaging moves from `setup.py` to `pyproject.toml` (setuptools, PEP 621, license expression `MIT`); the version is still read from `oplnk_python_utils.__version__`. The wheel and the sdist ship only the `oplnk_python_utils` package, never `tests` or `tools`, and the sdist now includes `CHANGELOG.md` (C-09).
82
+ - The dev extras add `libcst`, for the migration codemod in `tools/migrate/`.
83
+ - CI builds the sdist and the wheel, runs `twine check`, checks the wheel contents, installs the wheel on Python 3.10, and runs the suite on the dependency floors (Python 3.10, fastapi 0.104.1, pymongo 4.6.1, redis 5.0.4).
84
+ - Concurrent cache misses on the same key in one process run the endpoint once; the other requests wait and get the stored response (B-21). When the first run stores nothing (an error, a cancellation, a `Response`, a value the `response_model` rejects, or a Redis error or invalidation that stops the store), each waiting request runs the endpoint itself. `single_flight=False` restores one run per request.
85
+ - An async `key_builder` raises `TypeError` when the endpoint is decorated (`key_builder must be a sync function returning a str, got async …: it is called without await`); it used to fail every request with a 500.
86
+ - The `QueryBuilder` WARNING `Query count cache skipped: <client class> is an async Redis client and the count cache needs a sync one` is logged once per client instead of once per query.
87
+ - Only GET and HEAD are cached by default (D6, B-14). Any other method runs the endpoint every time, encoded as with `use_cache=False`, and never contacts Redis, `?clear_cache=true` included. Read-only POST routes must pass `methods=["POST"]` to keep their cache; until then they run uncached with the same responses. Known consumers: the 4 POST summary routes of establishment-backend (`fuel_supply/endpoints.py`, decorated with `cache_factory` directly) and the 3 POST routes in core's `settings/endpoints.py`.
88
+ - A repeated query parameter keeps every value, in order, in the cache key (B-13); only keys of requests that repeat a parameter change. The body now counts in the key for every method except GET and HEAD, not only for POST and PUT.
89
+ - A decorated endpoint returns its value as a cache hit returns it (ObjectId as its hex string, through JSON, so int dict keys become strings): on a miss, with `use_cache=False`, for methods that are not cached and for direct calls (B-11, B-19). Under a route's `response_model`, the encoded form is returned only when the model sends it as it sends the endpoint's own value, or when the model rejects only the raw value (an ObjectId for a `str` field); otherwise the value itself is returned. HTTP output therefore matches 1.3.0's uncached output, and ObjectId fields no longer answer 500. `get_or_set_cache` and `aget_or_set_cache` return a miss in the same form as a hit (B-19).
90
+ - The cache stores the value after the route's `response_model` filtering, so Redis no longer keeps fields the response leaves out (B-24). When the model's output does not validate back to itself, the unfiltered value is cached as in 1.3.0, with a WARNING once per endpoint; output the model rejects is not cached (FastAPI still answers 500). This reads `request.scope["route"]` and `fastapi.routing.serialize_response`, verified on FastAPI 0.104.1, 0.110.0 and 0.159.1. Entries written by 1.3.0 keep their unfiltered values until they expire or are invalidated.
91
+ - With a sync Redis client, `cache_response` runs every Redis command, the `?clear_cache` purge included, in the cache's own pool of up to 32 threads, separate from anyio's pool that runs sync dependencies; retry waits are awaited. The library still sets no socket timeout (B-01).
92
+ - `invalidate_tag`, `invalidate_cache_keys` and `get_or_set_cache` raise `TypeError` for an async client before any Redis call (use `ainvalidate_tag` or `aget_or_set_cache`). `QueryBuilder` counts without the cache for an async client and logs one WARNING `Query count cache skipped: <client class> is an async Redis client and the count cache needs a sync one`; with `clear_cache=true` it used to return 500 (D1, B-07).
93
+ - `invalidate_tag` returns the number of tagged keys it deleted (it returned `None`), or -1 when a Redis error stops it. `invalidate_cache_keys` returns -1 instead of 0 when a Redis error stops it (D3).
94
+ - `ttl` on `cache_response`, `cache_factory`, `get_or_set_cache` and `aget_or_set_cache`, and `count_cache_ttl` on `QueryBuilder`, accept a positive int (seconds), a positive `datetime.timedelta` (whole seconds, at least 1) or `None` (no expiry). `0`, a negative number, a float, a bool or a string raises `ValueError` (`ttl must be a positive int (seconds), a positive timedelta or None, got …`) when the endpoint is decorated or before `get_or_set_cache` touches Redis; for `get_or_set_cache` the check is new. `ttl=0` used to return a 500 after a write endpoint had run; use `use_cache=False` for an endpoint that must not be cached (D4, B-05, C-18).
95
+ - `max_retries` must be a positive int and `retry_delay` a non-negative finite number, on `get_or_set_cache`, `cache_response` and `cache_factory`. Other values raise `ValueError` when decorating, or before `get_or_set_cache` touches Redis. `max_retries=0` used to disable the cache silently (B-17).
96
+ - The library retries a Redis connection or timeout error only when the client has no retry policy of its own (`retry` with a non-zero count, `retry_on_error` or `retry_on_timeout`); redis-py 8's `Redis()` retries 10 times by itself. `max_retries` counts attempts per operation. Retries wait `retry_delay`, then twice as long each time, plus up to 50% jitter, at most 2 seconds per wait and 5 seconds in total per operation. Errors Redis answers (OOM, READONLY, WRONGTYPE) are not retried (C-05, B-17).
97
+ - After the first failed Redis operation in a request served by `cache_response`, no cache call of that request contacts Redis again (the decorator, `get_or_set_cache`, `aget_or_set_cache` and the `QueryBuilder` count, including calls from threads the request's sync code runs in): no write or tag after a failed read, and no tag after a failed write. Outside a decorated request, each call stands alone. A failed `clear_cache` skips the cache and returns fresh data (D2).
98
+ - One WARNING per failing call instead of one per operation, with the stable text `Redis error during <operation>: <error>` (`<operation>` is `cache read`, `cache store`, `cache invalidation` or `tag invalidation`; `extra` fields `cache_operation`, `cache_attempts`, `cache_error_type`, and `cache_key`, `cache_pattern` or `cache_tag`). It replaces `Redis unavailable after N attempts: …`, `Redis connection error during cache invalidation: …`, `… during tag invalidation: …` and `… during cache clear: …` (B-23).
99
+ - The decorator tags an entry only after a confirmed write, like `get_or_set_cache`.
100
+ - Tagged cache entries are stored by one Lua script: the entry, its membership in every `tag:<tag>` set and each set's TTL, all or nothing, so a failure no longer leaves an entry stored without its remaining tags. The Redis server must be 4.0 or later (as `UNLINK` already required) and allow `EVAL` and `EVALSHA` (ACL category `@scripting`); if it rejects them, the cache logs one WARNING per client, `Redis rejected the cache's scripts (<error>); this client now stores and invalidates tagged entries with separate commands, which are not atomic`, and goes on with separate commands (D8, B-09).
101
+ - Each tag has a generation counter, the key `tag-gen:<tag>` (it never expires), which `invalidate_tag`, `ainvalidate_tag` and `?clear_cache=true` bump. A cached call reads its tags' generations with the entry and does not store what it fetched if one of its tags was invalidated meanwhile, so a read racing a write no longer leaves a stale entry for the whole TTL (B-09).
102
+ - Tag sets expire: `tag:<tag>` lives as long as its longest-lived entry (an entry without a TTL leaves it without one), and each store drops up to two members whose entries are gone, so junk query parameters no longer grow tag sets without bound. Tag sets written by earlier versions keep no TTL until their next invalidation (B-08).
103
+ - `invalidate_tag` deletes in one script, with `UNLINK` in batches of 1,000 keys, instead of one `DEL` per key (B-08).
104
+ - A cache read is one `GET` (one `MGET` with the tags' generations) instead of `EXISTS` plus `GET`; a miss with tags costs one read and one store round trip instead of two plus one per tag, once the server has the script (the first store on a server also sends `EVAL`) (B-20).
105
+ - A cluster client's errors (`RedisClusterException`, which is not a `RedisError`) are handled like any Redis error: logged, and the call runs uncached. Redis Cluster clients are not supported: tagged reads and the scripts use keys from several slots.
106
+ - `invalidate_cache_keys` with a pattern that has no wildcard (escaped ones included) deletes that key with one `UNLINK` instead of scanning the keyspace (B-20).
107
+ - The dev extras use `fakeredis[lua]`, so the tests run the cache's Lua scripts.
108
+ - The `redis` requirement rises from `>=3.0.0` to `>=5.0` (the retry detection needs 4.4.0; CI tests 5.0.4 and 5.2.1).
109
+ - A query whose `pipeline_count`, or a pre-parsed `query`, holds a value the cache id cannot encode (bson `Decimal128`, `Regex`) is counted without the cache and logs a warning. The data `pipeline` is not part of the count key, so a `Decimal128` in it no longer turns the cache off.
110
+ - `invalidate_tag(redis, <collection>)`, and therefore a decorated endpoint's `?clear_cache=true`, also deletes that collection's cached query counts.
111
+ - A populated relation whose lookup finds nothing keeps its id field next to the null alias, as audit fields already did: `{"establishment": null, "establishment_id": "<id>"}`. A found relation still drops the id unless `fields` lists it, and a list of ids (`*_ids`) is still dropped (C-04).
112
+ - A relation declared with a collection that finds nothing is null under the collection key (`suppliers: null`) instead of the field name (`supplier: null`) (D-08).
113
+ - The count pipeline looks up only the relations that `populate` requests and that the filters or distinct fields read, with the same lookups as the data (D-04). When a data `pipeline` is given without `pipeline_count`, the count also looks up, as in 1.3.0, the relations whose name roots a filter key, so lookups that the `pipeline` adds itself still count. `make_count_aggregation_query` takes the new keyword-only argument `lookup_filtered_relations` (default `False`) for this. `get_query_response` passes it only when it is true, so an override with the 1.3.0 signature `(self, query, base_pipeline=None)` keeps working, except for a data `pipeline` given without `pipeline_count`.
114
+ - `make_count_aggregation_query` no longer uses `get_count_lookup_relations`, and uses `get_count_lookup_relation` only for a data `pipeline` given without `pipeline_count` (an empty `pipeline_count` counts as none); both remain for subclasses.
115
+ - Text operators with a numeric-looking value match numeric fields through MongoDB `$expr` (`$regexMatch`, `$toString`), which needs MongoDB 4.2 or later and cannot use an index. Numeric arrays match only by exact value (C-20).
116
+ - `infer_type` converts only canonical ASCII numbers: `"007"`, `"1e5"`, `"+5"`, `".5"`, `"nan"`, `"inf"` and similar values now stay strings (C-13).
117
+ - ISO-8601 datetimes with an offset become timezone-aware: `Date.toISOString()` values come back aware (UTC) instead of naive, and `…Z` values without milliseconds and `±HH:MM` values are now datetimes. Values without an offset stay strings, and a text operator given such a value matches its ISO-8601 text written with `Z`, `+00:00` or `-00:00` and any number of fraction digits (C-13).
118
+ - `parse_key_string_to_list("tags[]")` returns `["tags", ""]`, which `parse_list_to_dict` builds as a list (C-12).
119
+ - A repeated query key or `key[]` gives a list, like indexed keys (C-12). `$lt`, `$lte`, `$gt`, `$gte` and the text operators take one value, so a list given to them (repeated, indexed or in a pre-parsed `query`) uses its last item, as repeated keys did before; so do pagination, `populate[x]` flags and `clear_cache`. Every other operator gets the whole list: `$eq`, `$ne`, `$listContains` and `$listNotContains` compare with the array, so `filters[status][$eq]=a&filters[status][$eq]=b` matches a field equal to `["a", "b"]` where 1.3.0 used `"b"`. `populate`, `sort`, `fields` and `distinct` keep every value.
120
+ - `read_query` and `get_query_response` answer a client mistake with HTTP 400 and a clear detail, and any other error with HTTP 500 and the detail `Internal server error`. The original error and its traceback go to `logger.exception` on `oplnk_python_utils.query.query_builder` as `Query failed: <error>`, so MongoDB and driver messages no longer reach the client (A-03). `HTTPException`s, such as the protected-field 400s, pass through unchanged.
121
+ - `QueryBuilder` diagnostics use the module logger instead of `print`/`pprint`. `debug=True` logs its dumps as English DEBUG records: the input, the parsed query, the data and count pipelines, the count cache key, the results and the timings. `build_distinct_pipeline` logs DEBUG records instead of printing on every call. Nothing is written to stdout (A-08, C-23).
122
+ - With `debug=True`, the `Data query returned <n> documents; first: <document>` record shows the first document as the client gets it, after `sanitize_result`, and follows `Count query returned`; what a subclass removes in `sanitize_result` no longer reaches the logs (R1-001).
123
+ - Sort orders are case-insensitive: `sort[0]=name:ASC` sorts ascending; it sorted descending (D9).
124
+ - A plain-string `fields` keeps a populated id only when it names that id: `populate[0]=creation_user&fields=creation_user_info.full_name` no longer returns the raw `creation_user` id, as the list form `fields[0]=…` did not.
125
+ - The `debug=True` record `Data query returned <n> documents; first: …` counts the documents of the response, after `sanitize_result`.
126
+ - With `fields`, a populate name that is not a string is skipped from the projection with the WARNING `Skipping populate name <name> in the fields projection: it is not a string`.
127
+ - `build_distinct_pipeline` no longer falls back to a `$replaceRoot` pipeline. Its `pipeline` argument is unused and kept for compatibility.
128
+
129
+ ### Removed
130
+
131
+ - The empty `oplnk_python_utils.reports` package, and `reports` from `oplnk_python_utils.__all__` (C-07). `import oplnk_python_utils.reports` now raises `ModuleNotFoundError`; the package never held any code.
132
+ - `setup.py`. An editable install (`pip install -e .`) needs pip 21.3 or later; building from source needs setuptools 77 or later, which pip installs in its isolated build environment.
133
+
134
+ ### Fixed
135
+
136
+ - `read_query(query=...)` no longer removes `clear_cache` from the caller's dict.
137
+ - `clear_cache` means the same in the decorator and in `QueryBuilder` (B-18): `true`, `1`, `yes` and `on`, in any case, turn it on; `false`, `0`, `no`, `off`, any other value and no value leave it off, and a repeated parameter uses its last value. `clear_cache=no`, `off`, `y` or `2` no longer purge `QueryBuilder`'s cached counts, `clear_cache=y` no longer purges in the decorator, and `clear_cache=on` now does.
138
+ - Decorating an endpoint no longer fails when evaluating a string annotation raises an error other than `NameError` or `AttributeError`; the cache uses the unevaluated annotations and logs the error at DEBUG.
139
+ - An endpoint whose `Request` parameter has another name, or that has none, no longer answers 500 (B-10); establishment's `POST /fuel_supply/summary/establishment/all` also needs `methods=["POST"]` to be cached.
140
+ - `use_cache=False` encodes like the cached path, so ObjectId responses no longer answer 500 (B-11).
141
+ - Without the cache (`use_cache=False`, a method that is not cached, or a direct call without a `Request`, which now runs uncached instead of raising), an endpoint whose route has a `response_model` gets back its own value when the model cannot read the encoded form back, as in 1.3.0 (a `field_serializer`, a `serialization_alias`, a required `Field(exclude=True)`). A `key_builder` now runs before `?clear_cache=true` purges, so one that raises leaves the cache untouched.
142
+ - An endpoint that returns a `Response` (`JSONResponse`, `StreamingResponse`, …) keeps its status, headers and body, and is never cached (B-12).
143
+ - `?ids=a&ids=b` and `?ids=b` no longer share an entry (B-13).
144
+ - Sync `def` endpoints work and are cached; they run in a worker thread, as FastAPI runs them (B-15).
145
+ - A hit and a miss return the same Python value (B-19).
146
+ - The README write examples no longer cache writes or claim automatic invalidation; they call `ainvalidate_tag` in the write handler (C-18).
147
+ - `get_or_set_cache` and `aget_or_set_cache` with `clear_cache=True` delete only their own key when it contains `*`, `?`, `[`, `]` or `\`, and `QueryBuilder`'s `clear_cache=true` deletes only the counts under its own `cache_key_prefix` when the prefix contains them (B-25).
148
+ - A Redis outage or a stalled Redis no longer freezes the event loop in the `cache_response` decorator with a sync client (B-01). A `QueryBuilder` count cache called from an async endpoint still runs on the caller's thread, so give the client `socket_timeout`.
149
+ - With an async client, `?clear_cache=true` is served instead of returning 500 (B-07).
150
+ - Async clients are detected by type: a sync client from a module whose name contains "async", and a `MagicMock`, take the sync path (B-16, C-25).
151
+ - A Redis error other than a connection or timeout error (out of memory, READONLY after a failover, a tag key of the wrong type) no longer turns a cached endpoint, `get_or_set_cache`, `invalidate_tag`, `invalidate_cache_keys` or a `QueryBuilder` count into an exception or an HTTP 500 after the work has run. It is logged and the result is returned, and the Redis error text no longer reaches clients through the count path (B-05).
152
+ - With a client that retries by itself, an outage no longer multiplies the library's attempts by the client's. With redis-py 8.1's default client and Redis unreachable, a cached request dropped from 35 s to 4.8 s (C-05).
153
+ - Queries that differ only in `pipeline_count`, in collection, in whether a data `pipeline` comes without `pipeline_count`, or in the flags of a compiled regex (`re.compile("^n")` and `re.compile("^n", re.I)`) no longer share one cached `totalCount`, even with the same `cache_key_prefix`, including the default `""`. The content of the data `pipeline` is not part of the key, since the count never runs it, so per-request pipelines share one count. Count keys change once on upgrade (B-02).
154
+ - Cached query counts are cleared by `invalidate_tag`, so `totalCount` matches the data after a write. Each count is registered under the tag `db_collection.name` with every `<collection_prefix>_` removed, the name consumer CRUD classes pass to `invalidate_tag` (B-03).
155
+ - Populating `creation_user` or `modification_user` no longer fails with HTTP 500 when a document holds a value that is not an ObjectId, such as `"system"`; that document gets `*_info: null` and keeps its value (D-07).
156
+ - `totalCount` counts the documents the data returns when filters read a related document: the count runs the same lookups as the data, for the relations in `populate`. Filters on an alias (`origin`), a collection key (`suppliers`), an audit alias (`creation_user_info`) or a field left out by `populate[...][fields]` now count what the data returns, and a filter on a relation that is not populated gives 0, like the data (D-04).
157
+ - `populate[x]=false`, and a populate name that adds no lookup, leave the document as stored: no null `x` or `x_info`, and the id is kept (D-06).
158
+ - `totalCount` of a `distinct` on a populated relation's field, with or without filters, counts the groups the data returns (D-04).
159
+ - A relation declared with a collection (`["Supplier", "supplier_id", "inv", "suppliers"]`) is populated only under the key the lookup writes (`suppliers`): no extra `supplier: null`, and a `fields` projection keeps it (D-08).
160
+ - A relation field with `_id` inside a word (`user_identity_id`) looks up `user_identity` instead of `userentity`; only a trailing `_id` or `_ids` is removed (A-07).
161
+ - A relation whose field has no `_id` but declares a collection (establishment's `("Users", "person", None, "users_id", "person")`) is populated: `populate[0]=person` reads the field as one id of that collection instead of answering HTTP 500, and a `fields` projection keeps the key the lookup writes. When declared it logs one WARNING of `oplnk_python_utils.query.query_builder`: `Relation 'Users': field 'person' does not end with '_id' or '_ids'; populate[person] reads it as one id of collection 'users' and writes the document under 'person'`. The id field is kept when the lookup writes under another key; when it writes under the field itself, the document replaces the id, and an id that matches no document drops the key. A relation set on `relations` after creation that cannot be looked up still answers HTTP 500 and logs `Relation field '<field>' must end with '_id' or '_ids', or its relation must declare a collection` (A-07).
162
+ - The README no longer shows the "special Users relation" `["Users", "person", "", ""]`, whose handling was removed in 1.3.0 and which now fails when declared (C-19).
163
+ - A `fields` path under a populate name, an alias or an audit alias (`populate[0]=establishment&fields[0]=name&fields[1]=establishment.name`, `origin.name`, `creation_user_info.full_name`) no longer fails with HTTP 500 `Path collision`; the populated object holds only the requested path, and protected fields stay removed.
164
+ - Several operators on one field all apply (`filters[x][$gte]=3&filters[x][$lte]=8` keeps both bounds), including nested bracket paths and one path written in both dot and bracket notation. A repeated operator is combined with `$and` (D-03).
165
+ - `$notContains` and `$notContainsi` outside `$or` exclude the matching documents instead of returning them (D-01).
166
+ - `$nei` inside `$or` with a numeric value excludes that value instead of matching every document (D-02).
167
+ - Text operators with a numeric-looking value match string fields by text and numbers by their decimal text, as MongoDB `$toString` writes it: a number equal to the value matches only when the value is that text, so `$containsi=10.0` does not match 10, `$startsWith=05` does not match 5, and `$notContains=10.0` does not exclude 10. `$eqi` and `$nei` compare numbers by value. Words such as `inf` are text, so `$eqi=inf` is anchored (C-20).
168
+ - A plain-string `sort`, `fields` or `distinct` is treated as one item instead of being read one character at a time, and an empty sort list leaves the order unchanged instead of failing with 500 (A-05).
169
+ - A distinct result whose only group is all null, including nested fields, returns no rows and `totalCount` 0; on a paginated result the check uses `totalCount`, so a page holding only the null group keeps it (D-05).
170
+ - Bracket paths three or more levels deep (`filters[a][b][c][$eq]=x`) filter on `a.b.c` instead of matching nothing (D-03).
171
+ - A `$or` item with several fields or operators applies all of them with AND; only the first one was used (D-03).
172
+ - `parse_params_to_dict` builds lists by index: `filters[$or][0][a]=x&filters[$or][0][b]=y` means `(a AND b)`, and `sort[n]` and `$in[n]` follow the index rather than the key order (C-11).
173
+ - `parse_params_to_dict` keeps every value of `key[]=a&key[]=b`, and of repeated keys when given `QueryParams`; a single `key[]=a` gives `["a"]` (C-12).
174
+ - `parse_params_to_dict` raises `ValueError` for non-mapping input, a top-level index key (`?0=x`) and keys that give one key two shapes (including `key=a` next to `key[0][b]=c` or `key[0][0]=c`, in any order), instead of `TypeError`, `AttributeError` or a mixed list (C-17). Through `read_query` a malformed query string is an HTTP 400 with the parser's message, and `params` that is not a mapping, a caller bug, is an HTTP 500 (A-03).
175
+ - `pagination[page]` and `pagination[pageSize]` are cast to integers, so `$limit` no longer receives a string (part of A-06).
176
+ - `merge_dicts` no longer modifies or aliases its inputs (C-14).
177
+ - `normalize` handles mixed key types, sorts sets and frozensets into lists and normalizes dicts inside tuples. Cache ids for such inputs change once (C-15).
178
+ - `datetime_serializer` returns a new structure, converts `date`, `time`, `Decimal`, `ObjectId`, tuples and NaN/infinity (to `None`), and raises `ValueError` on cyclic data. A finite `Decimal` beyond the float range also becomes `None`. Naive datetimes keep their naive ISO form (C-16).
179
+ - An invalid filter answers HTTP 400 instead of being dropped, which used to return broader results than asked for, or instead of failing with 500 (A-04). That covers a field path with a space, or with a `$` that is not at the start, at any depth and inside `$or`/`$and`; a field given a plain value instead of operators; `$between` without exactly 2 values; `$or`/`$and` that is not a non-empty list of conditions; and `filters` that is not an object.
180
+ - An invalid sort item, `fields` path or `distinct` field answers 400 instead of being dropped with a `print`. A `distinct` with no field, or with overlapping fields such as `spec` and `spec.color`, answers 400 instead of an empty result, a malformed result or a 500 (A-04).
181
+ - `pagination[page]` and `pagination[pageSize]` must be integers of at least 1. Zero, negative, non-numeric, fractional and boolean values, a `pagination` that is not an object, and values too large for MongoDB answer 400 instead of 500 or a silently truncated page (A-06).
182
+ - A plain-string `populate[<name>][fields]` selects that one field, for relations and for the audit fields, instead of being ignored. Protected fields are still removed (A-05).
183
+ - A text search whose value is an integer beyond int64 (such as `filters[code][$containsi]=99999999999999999999`) searches it as text instead of failing with 500 on MongoDB's 8-byte limit or Python's 4,300-digit `int()` limit (A-03).
184
+
185
+ ## [1.3.0] - 2025-01-XX
186
+
187
+ ### Added
188
+
189
+ - Robust error handling for Redis connection failures with graceful degradation
190
+ - Automatic retry mechanism for Redis operations (configurable via `max_retries` parameter)
191
+ - Support for both synchronous and asynchronous Redis clients with auto-detection
192
+ - New optional parameters: `max_retries` and `retry_delay` in `get_or_set_cache()`
193
+ - New optional parameter: `max_retries` in `cache_response()` decorator
194
+ - Comprehensive logging for Redis connection errors and cache failures
195
+
196
+ ### Changed
197
+
198
+ - `get_or_set_cache()` now continues execution without caching if Redis is unavailable
199
+ - `cache_response()` decorator now handles Redis failures gracefully, returning uncached responses
200
+ - `invalidate_cache_keys()` returns 0 instead of raising exceptions when Redis is unavailable
201
+ - `invalidate_tag()` fails silently when Redis is unavailable
202
+ - All cache operations now log warnings instead of crashing the application
203
+
204
+ ### Fixed
205
+
206
+ - Fixed application crashes when Redis connection is refused or unavailable
207
+ - Fixed unhandled `ConnectionError` exceptions in cache operations
208
+
209
+ ### Migration Notes
210
+
211
+ - No breaking changes - fully backward compatible
212
+ - Applications will now continue working even if Redis is down (without caching)
213
+ - Optional retry parameters can be added to existing code for better resilience
214
+
215
+ ## [1.2.1] - 2025-01-15
216
+
217
+ ### Added
218
+
219
+ - Automatic sanitization of original ID fields when populate is used (Strapi-like behavior)
220
+ - Helper methods `_get_populated_fields_list()` and `_get_id_field_for_relation()` for field management
221
+ - Support for controlling ID field visibility via `fields` parameter when using populate
222
+ - For audit fields (creation_user, modification_user), the populated object comes with `*_info` alias
223
+
224
+ ### Changed
225
+
226
+ - Audit field aliases: `creation_user` → `creation_user_info`, `modification_user` → `modification_user_info`
227
+ - ID fields are automatically removed only when the field is populated AND not explicitly requested in `fields`
228
+ - ID fields are shown normally when populate is not used for that field
229
+ - `sanitize_result()` now handles field sanitization intelligently based on populate and fields parameters
230
+ - `build_fields_pipeline()` now automatically includes populated field aliases when using `fields` parameter
231
+
232
+ ### Fixed
233
+
234
+ - Fixed bug where populated fields (especially audit fields) were not included in projection when `fields` parameter was specified
235
+
236
+ ### Behavior Rules
237
+
238
+ - **No populate**: ID fields shown as-is from database (e.g., `creation_user: "user_id_string"`)
239
+ - **With populate, no fields request**: ID field automatically removed, only populated object shown (e.g., `creation_user_info: {...}`)
240
+ - **With populate + explicit fields request**: Both ID and populated object shown
241
+
242
+ ### Migration Notes
243
+
244
+ - Audit field aliases use `*_info` suffix (creation_user_info, modification_user_info)
245
+ - For audit fields, original ID is excluded by default when using populate
246
+ - For audit fields, use `"fields": ["creation_user"]` to show both ID and `creation_user_info`
247
+ - For normal relations, explicitly include the field in `fields` parameter to show both ID and populated object
248
+
249
+ ## [1.2.0] - 2024-11-20
250
+
251
+ ### Added
252
+
253
+ - Reserved audit fields system for automatic population of `creation_user` and `modification_user`
254
+ - New method `get_reserved_field_lookup()` for handling reserved fields
255
+ - Automatic $ifNull protection for array fields (\_ids) to prevent null errors
256
+
257
+ ### Fixed
258
+
259
+ - Removed hardcoded "Users" case that incorrectly searched for "$person" field
260
+ - Removed duplicate unwind in get_lookup_relation() that caused redundant operations
261
+ - Fixed inconsistent use of alias variable in unwind operations
262
+
263
+ ### Changed
264
+
265
+ - get_query_lookup_relations() now checks for reserved fields before normal relations
266
+ - All relation lookups now use consistent $ifNull protection for arrays
267
+
268
+ ### Migration Notes
269
+
270
+ - No breaking changes - fully backward compatible
271
+ - Reserved fields (creation_user, modification_user) now work automatically with populate
272
+ - Projects can remove manual definitions of Users relations for creation_user/modification_user
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 OpenLink SpA
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.
1
+ MIT License
2
+
3
+ Copyright (c) 2025 OpenLink SpA
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.
@@ -0,0 +1,10 @@
1
+ include README.md
2
+ include CHANGELOG.md
3
+ include LICENSE
4
+ # the upgrade procedure ships with the sdist so it is readable from a download
5
+ include docs/migration-1.4.md
6
+ include docs/audit/2026-10-audit.md
7
+ include docs/query-contract.md
8
+ include docs/query-sql-adapter.md
9
+ # setuptools adds tests/test*.py by default; the suite stays in the repository
10
+ prune tests