Skip to content

Commit 79e6360

Browse files
author
Philip Z
committed
fix: retain grouped window clauses and numeric trace evidence (#43)
Signed-off-by: Philip Z <hello@teaql.io>
1 parent 94e8697 commit 79e6360

2 files changed

Lines changed: 208 additions & 8 deletions

File tree

‎src/teaql/sql/dialect.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,17 @@ def compile_select_sql(self, entity: EntityDescriptor, query: SelectQuery, param
161161
if where_parts:
162162
sql += " WHERE " + " AND ".join(where_parts)
163163

164+
# Window ranks apply to the grouped, HAVING-filtered result. Returning
165+
# the partition wrapper first silently collapses aggregate rows and
166+
# drops HAVING bindings on both root and loaded-relation queries.
167+
if query.group_by_items:
168+
group_by = ", ".join(self.column_sql(entity, field) for field in query.group_by_items)
169+
sql += f" GROUP BY {group_by}"
170+
171+
if query.having_expr is not None:
172+
having_sql = self.compile_expr(entity, query.having_expr, params)
173+
sql += f" HAVING {having_sql}"
174+
164175
if partitioned:
165176
rank = self.quote_ident("__teaql_partition_rank")
166177
predicates = [f"{rank} > {query.slice.offset}"]
@@ -169,14 +180,6 @@ def compile_select_sql(self, entity: EntityDescriptor, query: SelectQuery, param
169180
alias = self.quote_ident("__teaql_partitioned")
170181
return f"SELECT * FROM ({sql}) AS {alias} WHERE {' AND '.join(predicates)} ORDER BY {rank}"
171182

172-
if query.group_by_items:
173-
group_by = ", ".join(self.column_sql(entity, field) for field in query.group_by_items)
174-
sql += f" GROUP BY {group_by}"
175-
176-
if query.having_expr is not None:
177-
having_sql = self.compile_expr(entity, query.having_expr, params)
178-
sql += f" HAVING {having_sql}"
179-
180183
if query.order_by_items:
181184
order_by = ", ".join(self.order_by_sql(entity, order, params) for order in query.order_by_items)
182185
sql += f" ORDER BY {order_by}"
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

Comments
 (0)