|
| 1 | +"""Native numeric grouping/window evidence, not generated aggregation acceptance.""" |
| 2 | +from copy import deepcopy |
| 3 | +import json |
| 4 | +from types import SimpleNamespace |
| 5 | + |
| 6 | +import aiosqlite |
| 7 | +import pytest |
| 8 | + |
| 9 | +from test_trace_chain import RecordingTransport |
| 10 | +from teaql.core.expr import Expr, begin_with |
| 11 | +from teaql.core.meta import EntityDescriptor, PropertyDescriptor, RelationDescriptor |
| 12 | +from teaql.core.query import SelectQuery |
| 13 | +from teaql.core.value import DataType |
| 14 | +from teaql.data_service import QueryRequest |
| 15 | +from teaql.provider.sqlite import SimpleSchemaProvider |
| 16 | +from teaql.provider.sqlite.dialect import SqliteDialect |
| 17 | +from teaql.runtime import RuntimeModule |
| 18 | +from teaql.runtime.context import SqlLogOptions |
| 19 | +from teaql.sql.executor import SqlDataServiceExecutor |
| 20 | + |
| 21 | + |
| 22 | +OPERANDS = ('%NUMERIC_FIRST_', '%NUMERIC_SECOND_') |
| 23 | + |
| 24 | + |
| 25 | +async def numeric_fixture(tmp_path, monkeypatch, logging): |
| 26 | + monkeypatch.delenv('TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS', raising=False) |
| 27 | + module, schema = RuntimeModule.new(), SimpleSchemaProvider() |
| 28 | + for name, fields in ( |
| 29 | + ('NumericRoot', ()), ('NumericParent', ('root_id',)), |
| 30 | + ('NumericItem', ('parent_id', 'bucket', 'private_name', 'public_name'))): |
| 31 | + descriptor = EntityDescriptor(name).table_name(name.lower()) |
| 32 | + descriptor.property(PropertyDescriptor('id', DataType.I64).is_id().log_policy('plain')) |
| 33 | + descriptor.property(PropertyDescriptor('version', DataType.I64).is_version().log_policy('plain')) |
| 34 | + for field in fields: |
| 35 | + descriptor.property(PropertyDescriptor(field, |
| 36 | + DataType.Text if field.endswith('_name') else DataType.I64).log_policy('plain')) |
| 37 | + descriptor.audit_mask_fields(['private_name'] if name == 'NumericItem' else []) |
| 38 | + if name == 'NumericRoot': |
| 39 | + descriptor.relation(RelationDescriptor('parents', 'NumericParent') |
| 40 | + .local('id').foreign('root_id').many()) |
| 41 | + elif name == 'NumericParent': |
| 42 | + descriptor.relation(RelationDescriptor('items', 'NumericItem') |
| 43 | + .local('id').foreign('parent_id').many()) |
| 44 | + module.entity(descriptor) |
| 45 | + schema.register_entity(descriptor) |
| 46 | + context = module.into_context() |
| 47 | + entries, raw = [], [] |
| 48 | + context.set_diagnostic_sql_log_sink(SimpleNamespace(write=entries.append)) |
| 49 | + if not logging: |
| 50 | + context.with_sql_log_options(SqlLogOptions.disabled()) |
| 51 | + record = context._record_metadata_log |
| 52 | + |
| 53 | + def observe(metadata, **kwargs): |
| 54 | + raw.append(deepcopy(metadata)) |
| 55 | + return record(metadata, **kwargs) |
| 56 | + |
| 57 | + monkeypatch.setattr(context, '_record_metadata_log', observe) |
| 58 | + transport = RecordingTransport(str(tmp_path / 'numeric.sqlite')) |
| 59 | + service = SqlDataServiceExecutor(SqliteDialect(), transport, schema) |
| 60 | + async with aiosqlite.connect(transport.db_path) as db: |
| 61 | + await db.execute('CREATE TABLE numericroot (id INTEGER PRIMARY KEY, version INTEGER)') |
| 62 | + await db.execute('CREATE TABLE numericparent ' |
| 63 | + '(id INTEGER PRIMARY KEY, version INTEGER, root_id INTEGER)') |
| 64 | + await db.execute('CREATE TABLE numericitem (id INTEGER PRIMARY KEY, version INTEGER, ' |
| 65 | + 'parent_id INTEGER, bucket INTEGER, private_name TEXT, public_name TEXT)') |
| 66 | + await db.execute('INSERT INTO numericroot VALUES (1, 1)') |
| 67 | + await db.executemany('INSERT INTO numericparent VALUES (?, 1, 1)', [(1,), (2,), (3,)]) |
| 68 | + for index, operand in enumerate(OPERANDS): |
| 69 | + rows = [(identity + index * 100, 1, parent, bucket, |
| 70 | + operand + str(identity), operand + str(identity)) |
| 71 | + for identity, parent, bucket in ( |
| 72 | + (11, 1, 10), (12, 1, 10), (13, 1, 20), |
| 73 | + (21, 2, 10), (22, 2, 30), (23, 2, 30))] |
| 74 | + await db.executemany('INSERT INTO numericitem VALUES (?, ?, ?, ?, ?, ?)', rows) |
| 75 | + await db.commit() |
| 76 | + return context, service, transport, entries, raw |
| 77 | + |
| 78 | + |
| 79 | +def assert_path(nodes, root, relations): |
| 80 | + assert [node.kind for node in nodes] == [ |
| 81 | + 'operation', 'request', *['relation'] * len(relations), 'provider', 'sql'] |
| 82 | + assert [node.name for node in nodes] == [root, root, *relations, 'sqlite', 'select'] |
| 83 | + assert not any(node.kind == 'relation' and node.name in ('bucket', 'n', 'parent_id') |
| 84 | + for node in nodes) |
| 85 | + |
| 86 | + |
| 87 | +def assert_observations(request, before, transport, entries, raw, logging, field, operand, |
| 88 | + root, routes, counts, expected_params): |
| 89 | + assert request.query == before and request.query.trace_chain == [] |
| 90 | + assert len(transport.reads) == len(raw) == len(routes) |
| 91 | + assert [item.result_count for item in raw] == counts |
| 92 | + assert [[value.val for value in query.params] for query in transport.reads] == expected_params |
| 93 | + assert [item.parameters for item in raw] == [list(query.params) for query in transport.reads] |
| 94 | + assert all((item.comment, item.purpose) == (request.intent.comment, request.intent.purpose) |
| 95 | + for item in raw) |
| 96 | + for item, relations in zip(raw, routes): |
| 97 | + assert_path(item.trace_chain, root, relations) |
| 98 | + assert len(entries) == (len(raw) if logging else 0) |
| 99 | + for entry, relations in zip(entries, routes): |
| 100 | + assert_path(entry.trace_path, root, relations) |
| 101 | + if field == 'private_name': |
| 102 | + assert operand not in repr(entry) |
| 103 | + assert entry.comment == request.intent.comment.replace(operand, '[REDACTED]') |
| 104 | + assert entry.purpose == request.intent.purpose.replace(operand, '[REDACTED]') |
| 105 | + else: |
| 106 | + assert entry.comment == request.intent.comment and entry.purpose == request.intent.purpose |
| 107 | + print('NUMERIC_OBSERVATION ' + json.dumps({ |
| 108 | + 'root': root, 'logging': logging, 'field': field, 'routes': routes, 'counts': counts, |
| 109 | + 'sql': [query.sql for query in transport.reads], 'parameters': expected_params, |
| 110 | + 'safeIntent': [(entry.comment, entry.purpose) for entry in entries], |
| 111 | + })) |
| 112 | + |
| 113 | + |
| 114 | +async def independent(context, service, entries, logging): |
| 115 | + reason = 'independent ' + ' and '.join(OPERANDS) |
| 116 | + result = await service.query(context, QueryRequest(SelectQuery('NumericRoot').project('id').limit(1), |
| 117 | + _comment=reason, _purpose='no numeric query provenance')) |
| 118 | + assert [row['id'] for row in result.rows] == [1] |
| 119 | + assert_path(result.metadata.trace_chain, 'NumericRoot', []) |
| 120 | + if logging: |
| 121 | + assert entries[-1].comment == reason |
| 122 | + assert entries[-1].purpose == 'no numeric query provenance' |
| 123 | + |
| 124 | + |
| 125 | +@pytest.mark.asyncio |
| 126 | +@pytest.mark.parametrize('logging', [False, True]) |
| 127 | +@pytest.mark.parametrize('field', ['private_name', 'public_name']) |
| 128 | +@pytest.mark.parametrize('shape', ['group', 'window', 'grouped-window']) |
| 129 | +async def test_root_numeric_grouping_and_partition_keep_real_sql_semantics( |
| 130 | + tmp_path, monkeypatch, logging, field, shape): |
| 131 | + context, service, transport, entries, raw = await numeric_fixture(tmp_path, monkeypatch, logging) |
| 132 | + for index, operand in enumerate(OPERANDS): |
| 133 | + entries.clear(); raw.clear(); transport.reads.clear() |
| 134 | + minimum = 15 if index == 0 else 25 |
| 135 | + query = SelectQuery('NumericItem').filter(begin_with(field, operand)) |
| 136 | + if shape == 'window': |
| 137 | + query.project('id', 'parent_id', 'bucket').partition_by_field('bucket').order_asc('id').limit(1) |
| 138 | + else: |
| 139 | + query.group_by('parent_id', 'bucket').count('n').having(Expr.gte('bucket', minimum)) |
| 140 | + query.order_desc('bucket').limit(10 if shape == 'group' else 1) |
| 141 | + if shape == 'grouped-window': |
| 142 | + query.partition_by_field('parent_id') |
| 143 | + request = QueryRequest(query, _comment='inspect ' + operand, _purpose='justify ' + operand) |
| 144 | + before = deepcopy(request.query) |
| 145 | + result = await service.query(context, request) |
| 146 | + if shape == 'window': |
| 147 | + assert sorted((row['id'], row['parent_id'], row['bucket']) for row in result.rows) == [ |
| 148 | + (11 + index * 100, 1, 10), (13 + index * 100, 1, 20), (22 + index * 100, 2, 30)] |
| 149 | + else: |
| 150 | + expected = [(1, 20, 1), (2, 30, 2)] if index == 0 else [(2, 30, 2)] |
| 151 | + assert sorted((row['parent_id'], row['bucket'], row['n']) for row in result.rows) == expected |
| 152 | + sql = transport.reads[0].sql |
| 153 | + assert ('ROW_NUMBER()' in sql) == (shape != 'group') |
| 154 | + assert ('GROUP BY' in sql and 'HAVING' in sql) == (shape != 'window') |
| 155 | + assert transport.reads[0].parameter_log_policies == [ |
| 156 | + 'masked' if field == 'private_name' else 'plain', *(['plain'] if shape != 'window' else [])] |
| 157 | + assert_observations(request, before, transport, entries, raw, logging, field, operand, |
| 158 | + 'NumericItem', [[]], [len(result.rows)], [[operand + '%'] + ([] if shape == 'window' else [minimum])]) |
| 159 | + await independent(context, service, entries, logging) |
| 160 | + |
| 161 | + |
| 162 | +@pytest.mark.asyncio |
| 163 | +@pytest.mark.parametrize('logging', [False, True]) |
| 164 | +@pytest.mark.parametrize('field', ['private_name', 'public_name']) |
| 165 | +@pytest.mark.parametrize('plan', ['window', 'probe']) |
| 166 | +async def test_loaded_grouped_relation_keeps_ancestors_membership_and_future_privacy( |
| 167 | + tmp_path, monkeypatch, logging, field, plan): |
| 168 | + context, service, transport, entries, raw = await numeric_fixture(tmp_path, monkeypatch, logging) |
| 169 | + for index, operand in enumerate(OPERANDS): |
| 170 | + entries.clear(); raw.clear(); transport.reads.clear() |
| 171 | + minimum = 15 if index == 0 else 25 |
| 172 | + child = (SelectQuery('NumericItem').filter(begin_with(field, operand)) |
| 173 | + .group_by('parent_id', 'bucket').count('n').having(Expr.gte('bucket', minimum)) |
| 174 | + .order_desc('bucket').limit(1).top_n_probe_parent_threshold(0 if plan == 'window' else 10)) |
| 175 | + parents = (SelectQuery('NumericParent').project('id').order_asc('id').limit(3) |
| 176 | + .top_n_probe_parent_threshold(0).relation_query('items', child)) |
| 177 | + query = SelectQuery('NumericRoot').project('id').limit(1).relation_query('parents', parents) |
| 178 | + request = QueryRequest(query, _comment='inspect future ' + operand, _purpose='justify future ' + operand) |
| 179 | + before = deepcopy(request.query) |
| 180 | + result = await service.query(context, request) |
| 181 | + assert len(result.rows) == 1 |
| 182 | + loaded = result.rows[0]['parents'] |
| 183 | + assert [parent['id'] for parent in loaded] == [1, 2, 3] |
| 184 | + assert {parent['id']: [(row['parent_id'], row['bucket'], row['n']) for row in parent['items']] |
| 185 | + for parent in loaded} == { |
| 186 | + 1: [(1, 20, 1)] if index == 0 else [], 2: [(2, 30, 2)], 3: []} |
| 187 | + assert all('__teaql_partition_rank' not in row for parent in loaded for row in parent['items']) |
| 188 | + child_reads = transport.reads[2:] |
| 189 | + assert all('GROUP BY' in item.sql and 'HAVING' in item.sql for item in child_reads) |
| 190 | + assert all(('ROW_NUMBER()' in item.sql) == (plan == 'window') for item in child_reads) |
| 191 | + expected_params = [[], [1]] + ( |
| 192 | + [[operand + '%', 1, 2, 3, minimum]] if plan == 'window' else |
| 193 | + [[operand + '%', parent, minimum] for parent in (1, 2, 3)]) |
| 194 | + counts = [1, 3] + ([2 if index == 0 else 1] if plan == 'window' else [1 if index == 0 else 0, 1, 0]) |
| 195 | + assert_observations(request, before, transport, entries, raw, logging, field, operand, |
| 196 | + 'NumericRoot', [[], ['parents']] + [['parents', 'items']] * len(child_reads), counts, expected_params) |
| 197 | + await independent(context, service, entries, logging) |
0 commit comments