From 2b26b845c0e923c4f5d8fc8b132c3b3fa7d2ad67 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 6 Sep 2026 23:04:31 +0800 Subject: [PATCH 1/4] feat: add local dynamic search warnings and scoped Expr composition (#19) --- src/teaql/core/dynamic_search.py | 134 +++++++++++++++++++++++++++ tests/core/test_dynamic_search.py | 66 +++++++++++++ tests/provider/sqlite/test_sqlite.py | 52 +++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/teaql/core/dynamic_search.py create mode 100644 tests/core/test_dynamic_search.py diff --git a/src/teaql/core/dynamic_search.py b/src/teaql/core/dynamic_search.py new file mode 100644 index 0000000..d600987 --- /dev/null +++ b/src/teaql/core/dynamic_search.py @@ -0,0 +1,134 @@ +"""Local UI-search input; not a permissive TFP decoder or authorization policy.""" +import json +import logging +import math +import re +from copy import deepcopy +from datetime import date +from .expr import Expr +from .query import OrderBy + +_LOG = logging.getLogger(__name__) +_OPS = {'$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$notIn', '$contains'} + + +def _reject_json_constant(_): + raise ValueError('Non-standard JSON number') + + +def _warning(warning): + _LOG.warning('%s entity=%s clause=%s fieldPath=%s', warning['code'], + warning['entity'], warning['clause'], warning['fieldPath']) + + +def normalize_dynamic_search(source, entity, models, warn=_warning, max_clauses=100): + """models and limits come from trusted setup, never the submitted JSON.""" + if type(max_clauses) is not int or max_clauses < 1: + raise ValueError('Invalid search limit') + if entity not in models: + raise ValueError('Unknown search entity') + try: + value = json.loads(source, parse_constant=_reject_json_constant) if isinstance(source, str) else source + except (ValueError, TypeError): + raise ValueError('Dynamic search requires valid JSON') from None + if not isinstance(value, dict) or set(value) - {'filter', 'orderBy'}: + raise ValueError('Unsupported dynamic search input or control') + filters, orders = value.get('filter', {}), value.get('orderBy', []) + if not isinstance(filters, dict) or not isinstance(orders, list): + raise ValueError('Invalid search filter or ordering') + if len(filters) + len(orders) > max_clauses: + raise ValueError('Dynamic search exceeds clause limit') + result, warnings = {'filter': {}, 'orderBy': []}, [] + + def field_type(path): + if not isinstance(path, str): + raise ValueError('Invalid search field path') + parts = path.split('.') + if len(parts) > 16 or any(not p or p.startswith('$') or p in + {'__proto__', 'prototype', 'constructor'} for p in parts): + raise ValueError('Invalid search field path') + model = models[entity] + for part in parts[:-1]: + target = model['relations'].get(part) + if target is None: + return None + if target not in models: + raise ValueError('Invalid trusted search relation metadata') + model = models[target] + return model['fields'].get(parts[-1]) + + def missing(path, clause): + warnings.append(dict(code='DYNAMIC_SEARCH_UNKNOWN_FIELD', entity=entity, + clause=clause, fieldPath=path)) + + def scalar(item, kind): + if item is None: + return + valid = False + if kind in ('integer', 'timestamp'): + valid = type(item) is int and abs(item) <= 9007199254740991 + elif kind == 'number': + valid = type(item) in (int, float) and math.isfinite(item) + elif kind == 'string': + valid = isinstance(item, str) + elif kind == 'boolean': + valid = type(item) is bool + elif kind == 'decimal': + valid = (isinstance(item, str) and re.fullmatch(r'[+-]?\d+(?:\.\d+)?', item) is not None + or type(item) in (int, float) and math.isfinite(item)) + elif kind == 'date' and isinstance(item, str) and re.fullmatch(r'\d{4}-\d{2}-\d{2}', item): + try: + valid = date.fromisoformat(item).isoformat() == item + except ValueError: + pass + if not valid: + raise ValueError('Invalid value for known search field') + + for path, predicate in filters.items(): + predicate = predicate if isinstance(predicate, dict) else {'$eq': predicate} + if len(predicate) != 1 or next(iter(predicate)) not in _OPS: + raise ValueError('Unsupported or malformed dynamic search operator') + operator, item = next(iter(predicate.items())) + if operator in ('$in', '$notIn') and (not isinstance(item, list) or len(item) > 1000): + raise ValueError('Invalid or oversized search value list') + kind = field_type(path) + if kind is None: + missing(path, 'FILTER') + continue + if operator == '$contains' and kind != 'string': + raise ValueError('String operator requires a string field') + if isinstance(item, list): + if operator not in ('$in', '$notIn'): + raise ValueError('Unexpected search value list') + for element in item: + scalar(element, kind) + else: + scalar(item, kind) + result['filter'][path] = deepcopy(predicate) + for order in orders: + if (not isinstance(order, dict) or set(order) != {'field', 'direction'} + or order['direction'] not in ('asc', 'desc')): + raise ValueError('Invalid dynamic search ordering') + if field_type(order['field']) is None: + missing(order['field'], 'ORDER_BY') + else: + result['orderBy'].append(dict(order)) + for warning in warnings: + warn(dict(warning)) + return result, warnings + + +def merge_dynamic_search(base, source, models, filter_binding, order_binding, warn=_warning): + """Bindings are trusted native-API adapters; preserve the original scoped request.""" + search, warnings = normalize_dynamic_search(source, base.entity, models, lambda _: None) + filters = [filter_binding(path, predicate) for path, predicate in search['filter'].items()] + orders = [order_binding(order['field'], order['direction']) for order in search['orderBy']] + if any(not isinstance(expr, Expr) for expr in filters) or any(not isinstance(order, OrderBy) for order in orders): + raise ValueError('Invalid trusted search binding') + query = deepcopy(base) + for expr in filters: + query.filter_expr = Expr.new_and(query.filter_expr, expr) if query.filter_expr is not None else expr + query.order_by_items.extend(orders) + for warning in warnings: + warn(dict(warning)) + return query, warnings diff --git a/tests/core/test_dynamic_search.py b/tests/core/test_dynamic_search.py new file mode 100644 index 0000000..1195c31 --- /dev/null +++ b/tests/core/test_dynamic_search.py @@ -0,0 +1,66 @@ +import json +import pytest +from teaql.core.dynamic_search import normalize_dynamic_search, merge_dynamic_search +from teaql.core.query import SelectQuery, OrderBy +from teaql.core.expr import Expr + +MODELS = { + 'Order': {'fields': {'id': 'integer', 'name': 'string', 'amount': 'decimal', 'tenant': 'integer'}, + 'relations': {'customer': 'Customer'}}, + 'Customer': {'fields': {'name': 'string'}, 'relations': {}}, +} + + +def test_unknown_clauses_are_atomic_and_value_free(): + recorded = [] + search, warnings = normalize_dynamic_search({'filter': { + 'removed': 'SECRET', 'missing.name': 'SECRET', 'customer.removed': 'SECRET', + 'name': 'valid', 'customer.name': {'$eq': 'Ada'}}, + 'orderBy': [{'field': 'removed', 'direction': 'asc'}, {'field': 'id', 'direction': 'desc'}]}, + 'Order', MODELS, recorded.append) + assert search['filter'] == {'name': {'$eq': 'valid'}, 'customer.name': {'$eq': 'Ada'}} + assert search['orderBy'] == [{'field': 'id', 'direction': 'desc'}] + assert len(warnings) == 4 + assert all(w['code'] == 'DYNAMIC_SEARCH_UNKNOWN_FIELD' and w['entity'] == 'Order' for w in warnings) + assert 'SECRET' not in json.dumps(recorded) + + +@pytest.mark.parametrize('source', ['{', '[]', 'null', '{} {}', '{"tenant":1}', '{"hardLimit":1}', + '{"filter":{"removed":NaN}}', + {'filter': {'name': {'$invented': 1}}}, + {'filter': {'id': True}}, {'filter': {'id': 1.5}}, + {'filter': {'amount': 'NaN'}}]) +def test_fatal_inputs_are_not_schema_drift(source): + with pytest.raises(ValueError): + normalize_dynamic_search(source, 'Order', MODELS) + + +def test_native_composition_preserves_scope_limit_and_order(): + base = SelectQuery('Order').filter(Expr.eq('tenant', 1)).limit(10) + base.order_by_items.append(OrderBy.asc('id')) + original = repr(base) + query, warnings = merge_dynamic_search(base, {'filter': {'name': 'valid', 'removed': 'secret'}}, MODELS, + lambda path, predicate: Expr.eq(path, predicate['$eq']), + lambda path, direction: OrderBy.asc(path) if direction == 'asc' else OrderBy.desc(path), lambda _: None) + assert query.filter_expr == Expr.new_and(base.filter_expr, Expr.eq('name', 'valid')) + assert query.slice == base.slice + assert query.order_by_items == base.order_by_items + assert repr(base) == original + assert len(warnings) == 1 + + +def test_date_timestamp_decimal_validation(): + models = {'Entry': {'fields': {'date': 'date', 'created': 'timestamp', 'amount': 'decimal'}, 'relations': {}}} + search, _ = normalize_dynamic_search({'filter': {'date': '2024-02-29', 'created': 1709164800000, + 'amount': '9007199254740993.01'}}, 'Entry', models) + assert search['filter']['amount'] == {'$eq': '9007199254740993.01'} + for filters in [{'date': '2025-02-29'}, {'created': '2024-02-29'}]: + with pytest.raises(ValueError): + normalize_dynamic_search({'filter': filters}, 'Entry', models) + + +def test_fatal_sibling_does_not_publish_warnings(): + warnings = [] + with pytest.raises(ValueError): + normalize_dynamic_search({'filter': {'removed': 'secret', 'id': 'bad'}}, 'Order', MODELS, warnings.append) + assert warnings == [] diff --git a/tests/provider/sqlite/test_sqlite.py b/tests/provider/sqlite/test_sqlite.py index 9be9a60..9dd8c51 100644 --- a/tests/provider/sqlite/test_sqlite.py +++ b/tests/provider/sqlite/test_sqlite.py @@ -44,6 +44,58 @@ def __init__(self, name, ptype, is_id=False, is_version=False): def is_id(self): return self.is_id_val def is_version(self): return self.is_version_val + +@pytest.mark.asyncio +async def test_dynamic_search_preserves_scoped_sql_and_nested_filter(temp_db): + from teaql.core.dynamic_search import merge_dynamic_search + from teaql.core.expr import Expr + from teaql.core.query import OrderBy + provider = SimpleSchemaProvider() + module = RuntimeModule.new() + for name in ('Order', 'Customer'): + descriptor = MockEntityDescriptor(name) + descriptor.properties = [ + MockPropertyDescriptor('id', DataType.I64, is_id=True), + MockPropertyDescriptor('version', DataType.I64, is_version=True), + MockPropertyDescriptor('name', DataType.Text), + MockPropertyDescriptor('tenant', DataType.I64), + ] + if name == 'Order': + descriptor.properties.append(MockPropertyDescriptor('customer', DataType.I64)) + provider.register_entity(descriptor) + module.entity(descriptor) + service = create_sqlite_service(temp_db, provider) + context = module.into_context().with_schema_provider(service) + await context.ensure_schema() + for tenant in (1, 2): + for name in ('Customer', 'Order'): + command = InsertCommand(name).value('id', tenant).value('version', 1).value('tenant', tenant).value('name', 'Ada') + if name == 'Order': + command.value('customer', tenant) + command.trace_chain = [TraceNode(comment='seed dynamic search fixture')] + await service.mutate(context, MutationRequest(command)) + models = { + 'Order': {'fields': {'name': 'string', 'id': 'integer'}, 'relations': {'customer': 'Customer'}}, + 'Customer': {'fields': {'name': 'string'}, 'relations': {}}, + } + base = SelectQuery('Order').filter(Expr.eq('tenant', 1)).limit(10).order_asc('id') + original = repr(base) + def bind(path, predicate): + if path == 'customer.name': + child = SelectQuery('Customer').projects(['id']).filter( + Expr.new_and(Expr.eq('tenant', 1), Expr.eq('name', predicate['$eq']))) + return in_subquery(column('customer'), 'Customer', child) + return Expr.eq(path, predicate['$eq']) + query, warnings = merge_dynamic_search(base, {'filter': { + 'removed': 'SECRET', 'missing.name': 'SECRET', 'customer.removed': 'SECRET', + 'customer.name': 'Ada', 'name': 'Ada'}, 'orderBy': [{'field': 'removed', 'direction': 'desc'}]}, + models, bind, lambda path, direction: OrderBy.asc(path), lambda _: None) + rows = (await service.query(context, QueryRequest(query).comment('what: scoped search').purpose('why: conformance'))).rows + assert len(rows) == 1 and rows[0]['tenant'] == 1 + assert len(warnings) == 4 and 'SECRET' not in repr(warnings) + assert repr(base) == original + assert query.slice == base.slice and query.order_by_items == base.order_by_items + @pytest.fixture def schema_provider(): provider = SimpleSchemaProvider() From 29395507b33dc262242409c0911082524ba52a35 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 6 Sep 2026 23:49:16 +0800 Subject: [PATCH 2/4] fix: align dynamic search numeric lexical boundaries (#19) --- src/teaql/core/dynamic_search.py | 5 +++-- tests/core/test_dynamic_search.py | 7 +++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/teaql/core/dynamic_search.py b/src/teaql/core/dynamic_search.py index d600987..0dea5b6 100644 --- a/src/teaql/core/dynamic_search.py +++ b/src/teaql/core/dynamic_search.py @@ -66,7 +66,8 @@ def scalar(item, kind): return valid = False if kind in ('integer', 'timestamp'): - valid = type(item) is int and abs(item) <= 9007199254740991 + valid = (type(item) in (int, float) and abs(item) <= 9007199254740991 + and math.isfinite(item) and item == int(item)) elif kind == 'number': valid = type(item) in (int, float) and math.isfinite(item) elif kind == 'string': @@ -74,7 +75,7 @@ def scalar(item, kind): elif kind == 'boolean': valid = type(item) is bool elif kind == 'decimal': - valid = (isinstance(item, str) and re.fullmatch(r'[+-]?\d+(?:\.\d+)?', item) is not None + valid = (isinstance(item, str) and re.fullmatch(r'[+-]?[0-9]+(?:\.[0-9]+)?', item) is not None or type(item) in (int, float) and math.isfinite(item)) elif kind == 'date' and isinstance(item, str) and re.fullmatch(r'\d{4}-\d{2}-\d{2}', item): try: diff --git a/tests/core/test_dynamic_search.py b/tests/core/test_dynamic_search.py index 1195c31..76f6fe3 100644 --- a/tests/core/test_dynamic_search.py +++ b/tests/core/test_dynamic_search.py @@ -64,3 +64,10 @@ def test_fatal_sibling_does_not_publish_warnings(): with pytest.raises(ValueError): normalize_dynamic_search({'filter': {'removed': 'secret', 'id': 'bad'}}, 'Order', MODELS, warnings.append) assert warnings == [] + + +def test_shared_json_number_and_decimal_lexical_boundaries(): + result, _ = normalize_dynamic_search('{"filter":{"id":1.0}}', 'Order', MODELS) + assert result['filter']['id'] == {'$eq': 1.0} + with pytest.raises(ValueError): + normalize_dynamic_search({'filter': {'amount': '١٢'}}, 'Order', MODELS) From 4ce210890af719503f0084d370ef42f2825ae39e Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 7 Sep 2026 10:45:08 +0800 Subject: [PATCH 3/4] fix(generated): project relation subqueries on key columns --- examples/school-management/app/main.py | 37 +++++++++++++++++++ .../requests/platform_request.py | 21 +++++++---- .../requests/school_request.py | 21 +++++++---- .../requests/school_type_request.py | 21 +++++++---- 4 files changed, 76 insertions(+), 24 deletions(-) diff --git a/examples/school-management/app/main.py b/examples/school-management/app/main.py index fb70dc1..9225aad 100644 --- a/examples/school-management/app/main.py +++ b/examples/school-management/app/main.py @@ -12,6 +12,41 @@ from runtime_module import GENERATED_RUNTIME_MODULE from teaql.data_service import SQLiteTeaQLClient from teaql.runtime import UserContext +from teaql.core.dynamic_search import normalize_dynamic_search + + +async def verify_dynamic_search(context): + models = { + "School": {"fields": {"name": "string"}, "relations": {"platform": "Platform"}}, + "Platform": {"fields": {"name": "string"}, "relations": {}}, + } + populated = {"filter": {"name": "Riverside Primary School", "platform.name": "Deployment Campus", + "removed": "SECRET_VALUE", "platform.removed": "SECRET_VALUE"}, + "orderBy": [{"field": "removed", "direction": "asc"}]} + for authorized_platform in (1, 2): + for search_input in (populated, {}): + # Authorization remains present even when the entire search form is absent. + request = Q.schools().with_name_is("Riverside Primary School").with_platform_matching( + Q.platforms().with_id_is(authorized_platform)) + search, warnings = normalize_dynamic_search(search_input, "School", models, lambda _: None) + for field, predicate in search["filter"].items(): + value = predicate.get("$eq") + if not isinstance(value, str): + raise ValueError("Demo binding supports string equality only") + if field == "name": + request = request.with_name_is(value) + elif field == "platform.name": + request = request.with_platform_matching(Q.platforms().with_name_is(value)) + else: + raise ValueError("Missing trusted demo binding") + rows = await (request.order_by_id_descending().limit(2) + .comment("what: generated School dynamic search") + .purpose("why: retain related authorization with stale or absent search fields") + .execute_for_list(context)) + assert len(rows) == (1 if authorized_platform == 1 else 0) + assert len(warnings) == (3 if search_input is populated else 0) + assert "SECRET_VALUE" not in str(warnings) + print("PASS Python generated School dynamic search: independent related scope and typed Q bindings") async def main() -> None: @@ -70,6 +105,8 @@ async def main() -> None: school.update_active(True) await school.audit_as("Create Riverside Primary School").save(context) + await verify_dynamic_search(context) + loaded = await (Q.schools().with_id_is(school.id) .select_platform_with(Q.platforms_minimal().select_name().select_base_url()) .select_school_type_with(Q.school_types_minimal().select_name().select_code().select_display_order()) diff --git a/examples/school-management/requests/platform_request.py b/examples/school-management/requests/platform_request.py index 7f9c3d5..3f59c85 100644 --- a/examples/school-management/requests/platform_request.py +++ b/examples/school-management/requests/platform_request.py @@ -10,6 +10,7 @@ ) from models.platform import Platform from typing import Protocol +from copy import deepcopy class QuerySelection(Protocol): query: SelectQuery @@ -545,13 +546,15 @@ def have_no_school_types(self): return self.without_school_type_list_matching(SchoolTypeRequest()) def with_school_type_list_matching(self, child_request): - self.query.and_filter(in_subquery(column("id"), "SchoolType", child_request.query)) - child_request.query._projection = ["platform"] + child_query = deepcopy(child_request.query) + child_query.projection = ["platform"] + self.query.and_filter(in_subquery(column("id"), "SchoolType", child_query)) return self def without_school_type_list_matching(self, child_request): - self.query.and_filter(not_in_subquery(column("id"), "SchoolType", child_request.query)) - child_request.query._projection = ["platform"] + child_query = deepcopy(child_request.query) + child_query.projection = ["platform"] + self.query.and_filter(not_in_subquery(column("id"), "SchoolType", child_query)) return self def have_schools(self): from requests.school_request import SchoolRequest @@ -562,13 +565,15 @@ def have_no_schools(self): return self.without_school_list_matching(SchoolRequest()) def with_school_list_matching(self, child_request): - self.query.and_filter(in_subquery(column("id"), "School", child_request.query)) - child_request.query._projection = ["platform"] + child_query = deepcopy(child_request.query) + child_query.projection = ["platform"] + self.query.and_filter(in_subquery(column("id"), "School", child_query)) return self def without_school_list_matching(self, child_request): - self.query.and_filter(not_in_subquery(column("id"), "School", child_request.query)) - child_request.query._projection = ["platform"] + child_query = deepcopy(child_request.query) + child_query.projection = ["platform"] + self.query.and_filter(not_in_subquery(column("id"), "School", child_query)) return self def count_school_types(self): return self.count_school_types_as("count_school_types") diff --git a/examples/school-management/requests/school_request.py b/examples/school-management/requests/school_request.py index 2454599..ddd0216 100644 --- a/examples/school-management/requests/school_request.py +++ b/examples/school-management/requests/school_request.py @@ -10,6 +10,7 @@ ) from models.school import School from typing import Protocol +from copy import deepcopy class QuerySelection(Protocol): query: SelectQuery @@ -127,13 +128,15 @@ def select_school_type_with(self, child_request): self.query.relation_query("school_type", child_request.query) return self def with_platform_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(in_subquery(column("platform"), "Platform", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(in_subquery(column("platform"), "Platform", child_query)) return self def without_platform_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_query)) return self def have_platform(self): @@ -144,13 +147,15 @@ def have_no_platform(self): self.query.and_filter(is_null(column("platform"))) return self def with_school_type_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(in_subquery(column("school_type"), "SchoolType", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(in_subquery(column("school_type"), "SchoolType", child_query)) return self def without_school_type_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(not_in_subquery(column("school_type"), "SchoolType", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("school_type"), "SchoolType", child_query)) return self def have_school_type(self): diff --git a/examples/school-management/requests/school_type_request.py b/examples/school-management/requests/school_type_request.py index 60953c2..7c816b1 100644 --- a/examples/school-management/requests/school_type_request.py +++ b/examples/school-management/requests/school_type_request.py @@ -10,6 +10,7 @@ ) from models.school_type import SchoolType from typing import Protocol +from copy import deepcopy class QuerySelection(Protocol): query: SelectQuery @@ -105,13 +106,15 @@ def select_platform_with(self, child_request): self.query.relation_query("platform", child_request.query) return self def with_platform_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(in_subquery(column("platform"), "Platform", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(in_subquery(column("platform"), "Platform", child_query)) return self def without_platform_matching(self, child_request): - child_request.query._projection = ["id"] - self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_request.query)) + child_query = deepcopy(child_request.query) + child_query.projection = ["id"] + self.query.and_filter(not_in_subquery(column("platform"), "Platform", child_query)) return self def have_platform(self): @@ -555,13 +558,15 @@ def have_no_schools(self): return self.without_school_list_matching(SchoolRequest()) def with_school_list_matching(self, child_request): - self.query.and_filter(in_subquery(column("id"), "School", child_request.query)) - child_request.query._projection = ["school_type"] + child_query = deepcopy(child_request.query) + child_query.projection = ["school_type"] + self.query.and_filter(in_subquery(column("id"), "School", child_query)) return self def without_school_list_matching(self, child_request): - self.query.and_filter(not_in_subquery(column("id"), "School", child_request.query)) - child_request.query._projection = ["school_type"] + child_query = deepcopy(child_request.query) + child_query.projection = ["school_type"] + self.query.and_filter(not_in_subquery(column("id"), "School", child_query)) return self def count_schools(self): return self.count_schools_as("count_schools") From af905c3cbc90a3d3679b85632bdbd53e72004758 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Mon, 7 Sep 2026 11:11:14 +0800 Subject: [PATCH 4/4] fix(query): preserve filters when including deleted rows --- src/teaql/core/query.py | 44 +++++++++++++++++++++++++++++++++++++++- tests/core/test_query.py | 11 ++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/teaql/core/query.py b/src/teaql/core/query.py index f63c6e4..66d7f8c 100644 --- a/src/teaql/core/query.py +++ b/src/teaql/core/query.py @@ -2,7 +2,8 @@ from typing import List, Optional, Any, Dict from copy import deepcopy from dataclasses import dataclass, field -from .expr import Expr, ExprBuilder +from .expr import (AndExpr, BinaryExpr, BinaryOp, ColumnExpr, Expr, ExprBuilder, + ValueExpr) from .mutation import TraceNode class SortDirection(Enum): @@ -101,6 +102,32 @@ class ObjectGroupBy: storage_field: str query: 'SelectQuery' + +def _is_default_live_filter(expr: Optional[Expr]) -> bool: + return (isinstance(expr, BinaryExpr) + and isinstance(expr.left, ColumnExpr) + and expr.left.name == "version" + and expr.op is BinaryOp.Gte + and isinstance(expr.right, ValueExpr) + and expr.right.value.val == 1) + + +def _remove_default_live_filter(expr: Optional[Expr]) -> Optional[Expr]: + if expr is None or _is_default_live_filter(expr): + return None + if isinstance(expr, AndExpr): + retained = [] + for child in expr.exprs: + child = _remove_default_live_filter(child) + if child is not None: + retained.append(child) + if not retained: + return None + if len(retained) == 1: + return retained[0] + return AndExpr(retained) + return expr + @dataclass class FacetRequest: name: str @@ -257,6 +284,21 @@ def and_filter(self, expr: Expr) -> 'SelectQuery': else: self.filter_expr = expr return self + + def with_deleted_rows(self) -> 'SelectQuery': + """Remove only TeaQL's implicit live-row predicate. + + Generated requests add ``version >= 1`` during construction. Deletion + helpers must not reach into private fields or discard application-owned + filters when opting into tombstones. + """ + self.filter_expr = _remove_default_live_filter(self.filter_expr) + return self + + def deleted_rows_only(self) -> 'SelectQuery': + self.with_deleted_rows() + self.and_filter(Expr.lte("version", -1)) + return self def order_asc(self, field: str) -> 'SelectQuery': self.order_by_items.append(OrderBy.asc(field)) diff --git a/tests/core/test_query.py b/tests/core/test_query.py index a25c5d6..5916a84 100644 --- a/tests/core/test_query.py +++ b/tests/core/test_query.py @@ -1,5 +1,6 @@ import pytest from teaql.core.query import SelectQuery, OrderBy, SortDirection +from teaql.core.expr import eq, gte def test_select_query_builder(): query = SelectQuery.new("User") @@ -33,3 +34,13 @@ def test_id_set_pagination_is_explicit_local_and_validated(): SelectQuery.new("Order").optimize_pagination_with_id_set_config("orders", 0, 1) with pytest.raises(ValueError): SelectQuery.new("Order").optimize_pagination_with_id_set_config("orders", 30, 0) + + +def test_deleted_rows_preserves_application_filters(): + query = SelectQuery.new("Order").and_filter(gte("version", 1)).and_filter(eq("tenant", 7)) + query.with_deleted_rows() + assert query.filter_expr is not None + assert "tenant" in repr(query.filter_expr) + query.deleted_rows_only() + assert "version" in repr(query.filter_expr) + assert "tenant" in repr(query.filter_expr)