mirror of
https://github.com/Canner/WrenAI.git
synced 2026-08-30 18:00:36 +08:00
fix(memory): avoid identifier columns in aggregation seed queries (#2358)
This commit is contained in:
@@ -7,6 +7,10 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlglot
|
||||
import sqlglot.errors
|
||||
from sqlglot import exp
|
||||
|
||||
_NUMERIC_TYPES = {
|
||||
"int",
|
||||
"integer",
|
||||
@@ -31,11 +35,16 @@ def generate_seed_queries(manifest: dict) -> list[dict]:
|
||||
model["name"]: _prop_value(model, "dbtLayer", "dbt_layer")
|
||||
for model in manifest.get("models", [])
|
||||
}
|
||||
relationship_keys = _relationship_key_columns(manifest)
|
||||
|
||||
for model in manifest.get("models", []):
|
||||
if model_layers.get(model["name"]) == "raw":
|
||||
continue
|
||||
pairs.extend(_model_seeds(model))
|
||||
pairs.extend(
|
||||
_model_seeds(
|
||||
model, relationship_keys.get(_norm_ident(model["name"]), frozenset())
|
||||
)
|
||||
)
|
||||
|
||||
for rel in manifest.get("relationships", []):
|
||||
pair = _relationship_seed(rel, model_layers)
|
||||
@@ -45,9 +54,12 @@ def generate_seed_queries(manifest: dict) -> list[dict]:
|
||||
return pairs
|
||||
|
||||
|
||||
def _model_seeds(model: dict) -> list[dict]:
|
||||
def _model_seeds(
|
||||
model: dict, relationship_keys: frozenset[str] = frozenset()
|
||||
) -> list[dict]:
|
||||
name = model["name"]
|
||||
columns = model.get("columns", [])
|
||||
primary_keys = _primary_key_columns(model)
|
||||
pairs = []
|
||||
|
||||
# Template 1: basic listing
|
||||
@@ -62,24 +74,29 @@ def _model_seeds(model: dict) -> list[dict]:
|
||||
numeric_col = None
|
||||
group_col = None
|
||||
for col in columns:
|
||||
col_name = col["name"]
|
||||
norm_name = _norm_ident(col_name)
|
||||
col_type = (col.get("type") or "").split("(")[0].lower().strip()
|
||||
is_calc = col.get("isCalculated", False)
|
||||
is_pk = col["name"] == model.get("primaryKey")
|
||||
is_pk = norm_name in primary_keys
|
||||
# Identifiers are numeric by storage but not measures: summing a join
|
||||
# key (e.g. SUM(customer_id)) is semantically meaningless.
|
||||
is_identifier = is_pk or norm_name in relationship_keys or _is_id_like(col_name)
|
||||
|
||||
if (
|
||||
col_type in _NUMERIC_TYPES
|
||||
and not is_calc
|
||||
and not is_pk
|
||||
and not is_identifier
|
||||
and numeric_col is None
|
||||
):
|
||||
numeric_col = col["name"]
|
||||
numeric_col = col_name
|
||||
elif (
|
||||
col_type not in _NUMERIC_TYPES
|
||||
and not is_pk
|
||||
and not is_calc
|
||||
and group_col is None
|
||||
):
|
||||
group_col = col["name"]
|
||||
group_col = col_name
|
||||
|
||||
# Template 2a: simple aggregation
|
||||
if numeric_col:
|
||||
@@ -132,6 +149,59 @@ def _relationship_seed(rel: dict, model_layers: dict[str, str]) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _relationship_key_columns(manifest: dict) -> dict[str, frozenset[str]]:
|
||||
"""Map each model to the set of columns it exposes as a relationship key.
|
||||
|
||||
Relationship conditions (e.g. ``orders.customer_id = customers.customer_id``)
|
||||
are the manifest's own declaration of join keys, so we keep both sides out
|
||||
of aggregation seeds.
|
||||
"""
|
||||
accum: dict[str, set[str]] = {}
|
||||
for rel in manifest.get("relationships", []):
|
||||
condition = rel.get("condition") or ""
|
||||
try:
|
||||
tree = sqlglot.parse_one(condition)
|
||||
except sqlglot.errors.SqlglotError:
|
||||
continue
|
||||
for col in tree.find_all(exp.Column):
|
||||
if col.table and col.name:
|
||||
accum.setdefault(_norm_ident(col.table), set()).add(
|
||||
_norm_ident(col.name)
|
||||
)
|
||||
return {model: frozenset(cols) for model, cols in accum.items()}
|
||||
|
||||
|
||||
def _primary_key_columns(model: dict) -> frozenset[str]:
|
||||
"""Return primary key column names for string and composite-list PKs."""
|
||||
primary_key = model.get("primaryKey")
|
||||
if isinstance(primary_key, str):
|
||||
return frozenset([_norm_ident(primary_key)])
|
||||
if isinstance(primary_key, list):
|
||||
return frozenset(_norm_ident(str(part)) for part in primary_key if part)
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _norm_ident(name: str) -> str:
|
||||
"""Canonicalize an identifier for case-insensitive membership checks.
|
||||
|
||||
Primary-key, relationship-key and ``*_id`` matching all compare against a
|
||||
column name; normalizing keeps them consistent when a manifest mixes cases
|
||||
(e.g. a condition referencing ``ORDERS.CUSTKEY`` while the column is
|
||||
declared ``Custkey``). The original name is always used for generated SQL.
|
||||
"""
|
||||
return name.strip().lower()
|
||||
|
||||
|
||||
def _is_id_like(col_name: str) -> bool:
|
||||
"""Cheap heuristic for identifier columns not declared as relationships.
|
||||
|
||||
Case-insensitive: warehouses such as Snowflake/Oracle fold identifiers to
|
||||
upper case, so an undeclared ``CUSTOMER_ID`` must be caught too.
|
||||
"""
|
||||
lowered = _norm_ident(col_name)
|
||||
return lowered == "id" or lowered.endswith("_id")
|
||||
|
||||
|
||||
def _prop_value(obj: dict, *keys: str) -> str:
|
||||
props = obj.get("properties") or {}
|
||||
if not isinstance(props, dict):
|
||||
|
||||
@@ -4,12 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from wren.memory.seed_queries import SEED_TAG, generate_seed_queries
|
||||
from wren.memory.seed_queries import (
|
||||
SEED_TAG,
|
||||
_relationship_key_columns,
|
||||
generate_seed_queries,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _model(name: str, pk: str, columns: list[dict]) -> dict:
|
||||
def _model(name: str, pk: str | list[str], columns: list[dict]) -> dict:
|
||||
return {"name": name, "primaryKey": pk, "columns": columns}
|
||||
|
||||
|
||||
@@ -381,3 +385,384 @@ class TestGenerateSeedQueries:
|
||||
manifest = {"models": [_model("t", "id", [_col("id", "varchar")])]}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
assert "LIMIT 100" in pairs[0]["sql"]
|
||||
|
||||
|
||||
# ── relationship-key / identifier exclusion ───────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestIdentifierExclusion:
|
||||
"""Numeric identifier columns must not be picked as aggregation targets:
|
||||
SUM(customer_id) is semantically meaningless noise (MEM-001)."""
|
||||
|
||||
def test_relationship_key_not_aggregated(self):
|
||||
# customer_id is numeric and declared as a relationship key on orders.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"orders",
|
||||
"order_id",
|
||||
[
|
||||
_col("order_id", "int"),
|
||||
_col("customer_id", "int"),
|
||||
_col("amount", "double"),
|
||||
_col("status", "varchar"),
|
||||
],
|
||||
),
|
||||
_model(
|
||||
"customers",
|
||||
"customer_id",
|
||||
[_col("customer_id", "int"), _col("name", "varchar")],
|
||||
),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "orders_customers",
|
||||
"models": ["orders", "customers"],
|
||||
"condition": "orders.customer_id = customers.customer_id",
|
||||
}
|
||||
],
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
# No relationship-key aggregation on either side of the join.
|
||||
assert not any("SUM(customer_id)" in s for s in sqls)
|
||||
# The real metric is aggregated instead
|
||||
assert "SELECT SUM(amount) FROM orders" in sqls
|
||||
assert "SELECT status, SUM(amount) FROM orders GROUP BY 1" in sqls
|
||||
|
||||
def test_composite_primary_key_columns_not_aggregated(self):
|
||||
# Composite PKs are list-shaped in schema v4 MDL. Every PK member must
|
||||
# be treated as an identifier even when it is numeric and not *_id-like.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"store_sales",
|
||||
["ss_item_sk", "ss_ticket_number"],
|
||||
[
|
||||
_col("ss_item_sk", "int"),
|
||||
_col("ss_ticket_number", "int"),
|
||||
_col("sales_price", "decimal"),
|
||||
_col("status", "varchar"),
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
assert not any("SUM(ss_item_sk)" in s for s in sqls)
|
||||
assert not any("SUM(ss_ticket_number)" in s for s in sqls)
|
||||
assert "SELECT SUM(sales_price) FROM store_sales" in sqls
|
||||
assert "SELECT status, SUM(sales_price) FROM store_sales GROUP BY 1" in sqls
|
||||
|
||||
def test_id_like_column_not_aggregated_without_relationship(self):
|
||||
# user_id is numeric, not a PK, and not declared in any relationship —
|
||||
# the *_id naming heuristic should still exclude it.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"raw_orders",
|
||||
"id",
|
||||
[
|
||||
_col("id", "int"),
|
||||
_col("user_id", "int"),
|
||||
_col("amount", "double"),
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
assert not any("SUM(user_id)" in s for s in sqls)
|
||||
assert "SELECT SUM(amount) FROM raw_orders" in sqls
|
||||
|
||||
def test_column_named_id_not_aggregated(self):
|
||||
# A bare numeric "id" that is not the declared primaryKey.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"events",
|
||||
"event_pk",
|
||||
[
|
||||
_col("event_pk", "varchar"),
|
||||
_col("id", "bigint"),
|
||||
_col("duration", "int"),
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
assert not any("SUM(id)" in s for s in sqls)
|
||||
assert "SELECT SUM(duration) FROM events" in sqls
|
||||
|
||||
def test_relationship_key_only_model_has_no_aggregation(self):
|
||||
# When the sole numeric column is a relationship key, fall back to
|
||||
# listing only.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"order_items",
|
||||
"line_id",
|
||||
[
|
||||
_col("line_id", "varchar"),
|
||||
_col("order_id", "int"),
|
||||
_col("label", "varchar"),
|
||||
],
|
||||
),
|
||||
_model("orders", "order_id", [_col("order_id", "int")]),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "order_items_orders",
|
||||
"models": ["order_items", "orders"],
|
||||
"condition": "order_items.order_id = orders.order_id",
|
||||
}
|
||||
],
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
assert not any("SUM(" in s for s in sqls)
|
||||
|
||||
def test_quoted_relationship_condition_parsed(self):
|
||||
# Conditions may quote identifiers; relationship-key detection must
|
||||
# still work.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"orders",
|
||||
"order_id",
|
||||
[
|
||||
_col("order_id", "int"),
|
||||
_col("customer_id", "int"),
|
||||
_col("amount", "double"),
|
||||
],
|
||||
),
|
||||
_model("customers", "customer_id", [_col("customer_id", "int")]),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "orders_customers",
|
||||
"models": ["orders", "customers"],
|
||||
"condition": '"orders"."customer_id" = "customers"."customer_id"',
|
||||
}
|
||||
],
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
sqls = [p["sql"] for p in pairs]
|
||||
assert not any("SUM(customer_id)" in s for s in sqls)
|
||||
assert "SELECT SUM(amount) FROM orders" in sqls
|
||||
|
||||
def test_quoted_relationship_key_with_space_excluded(self):
|
||||
# sqlglot parses quoted identifiers with spaces; the old regex-based
|
||||
# extraction missed these relationship keys.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"orders",
|
||||
"order_pk",
|
||||
[
|
||||
_col("order_pk", "varchar"),
|
||||
_col("Customer Key", "int"),
|
||||
_col("amount", "double"),
|
||||
],
|
||||
),
|
||||
_model(
|
||||
"customers",
|
||||
"customer_pk",
|
||||
[
|
||||
_col("customer_pk", "varchar"),
|
||||
_col("Customer Key", "int"),
|
||||
],
|
||||
),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "orders_customers",
|
||||
"models": ["orders", "customers"],
|
||||
"condition": '"orders"."Customer Key" = "customers"."Customer Key"',
|
||||
}
|
||||
],
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(Customer Key)" in s for s in sqls)
|
||||
assert "SELECT SUM(amount) FROM orders" in sqls
|
||||
|
||||
def test_invalid_relationship_condition_skipped_for_key_extraction(self):
|
||||
manifest = {
|
||||
"relationships": [
|
||||
{
|
||||
"name": "broken_relationship",
|
||||
"models": ["orders", "customers"],
|
||||
"condition": "orders.customer_id =",
|
||||
},
|
||||
{
|
||||
"name": "valid_relationship",
|
||||
"models": ["orders", "customers"],
|
||||
"condition": "orders.customer_id = customers.customer_id",
|
||||
},
|
||||
]
|
||||
}
|
||||
assert _relationship_key_columns(manifest) == {
|
||||
"orders": frozenset({"customer_id"}),
|
||||
"customers": frozenset({"customer_id"}),
|
||||
}
|
||||
|
||||
def test_legitimate_metric_named_with_id_suffix_is_still_excluded(self):
|
||||
# Documented trade-off: the *_id heuristic also drops a would-be metric
|
||||
# like "household_id". Real metrics should avoid the _id suffix; the
|
||||
# noise reduction is worth this rare false-positive.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"households",
|
||||
"pk",
|
||||
[_col("pk", "varchar"), _col("household_id", "int")],
|
||||
)
|
||||
]
|
||||
}
|
||||
pairs = generate_seed_queries(manifest)
|
||||
assert not any("SUM(household_id)" in p["sql"] for p in pairs)
|
||||
|
||||
def test_uppercase_id_like_column_excluded(self):
|
||||
# Warehouses such as Snowflake/Oracle fold identifiers to upper case.
|
||||
# An undeclared CUSTOMER_ID (not a PK, not a relationship key) must
|
||||
# still be caught by the case-insensitive *_id heuristic.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"orders",
|
||||
"ORDER_PK",
|
||||
[
|
||||
_col("ORDER_PK", "varchar"),
|
||||
_col("CUSTOMER_ID", "int"),
|
||||
_col("AMOUNT", "double"),
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(CUSTOMER_ID)" in s for s in sqls)
|
||||
assert "SELECT SUM(AMOUNT) FROM orders" in sqls
|
||||
|
||||
def test_relationship_key_without_id_suffix_excluded(self):
|
||||
# A foreign key whose name does NOT end in _id (e.g. created_by) is only
|
||||
# caught via the relationship condition, not the naming heuristic — this
|
||||
# is the value the relationship-key parsing adds on top of *_id.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"documents",
|
||||
"doc_pk",
|
||||
[
|
||||
_col("doc_pk", "varchar"),
|
||||
_col("created_by", "int"),
|
||||
_col("word_count", "int"),
|
||||
],
|
||||
),
|
||||
_model("users", "id", [_col("id", "int")]),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "users_documents",
|
||||
"models": ["users", "documents"],
|
||||
"condition": "users.id = documents.created_by",
|
||||
}
|
||||
],
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(created_by)" in s for s in sqls)
|
||||
assert "SELECT SUM(word_count) FROM documents" in sqls
|
||||
|
||||
def test_composite_and_condition_parsed(self):
|
||||
# Composite join keys are emitted as an `AND`-joined condition; every
|
||||
# referenced column on both sides must be excluded from aggregation.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"a",
|
||||
"akey",
|
||||
[
|
||||
_col("akey", "varchar"),
|
||||
_col("x", "int"),
|
||||
_col("y", "int"),
|
||||
_col("val", "double"),
|
||||
],
|
||||
),
|
||||
_model("b", ["x", "y"], [_col("x", "int"), _col("y", "int")]),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "a_b",
|
||||
"models": ["a", "b"],
|
||||
"condition": "a.x = b.x AND a.y = b.y",
|
||||
}
|
||||
],
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(x)" in s or "SUM(y)" in s for s in sqls)
|
||||
assert "SELECT SUM(val) FROM a" in sqls
|
||||
|
||||
def test_identifier_matching_is_case_insensitive(self):
|
||||
# A non-*_id identifier (e.g. "Custkey") is only excluded via the PK /
|
||||
# relationship-key path. Those checks must be case-insensitive so a
|
||||
# manifest that mixes cases — PK "custkey", column "Custkey", condition
|
||||
# "ORDERS.CUSTKEY = ..." — still keeps it out of aggregation. The
|
||||
# generated SQL must preserve the original column case.
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"orders",
|
||||
"custkey", # PK declared lower-case
|
||||
[
|
||||
_col("Custkey", "int"), # column defined Title-case
|
||||
_col("totalprice", "double"),
|
||||
],
|
||||
),
|
||||
_model(
|
||||
"customer",
|
||||
"CUSTKEY",
|
||||
[_col("CUSTKEY", "int"), _col("name", "varchar")],
|
||||
),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "orders_customer",
|
||||
"models": ["orders", "customer"],
|
||||
"condition": "ORDERS.CUSTKEY = CUSTOMER.CUSTKEY",
|
||||
}
|
||||
],
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(Custkey)" in s for s in sqls)
|
||||
assert "SELECT SUM(totalprice) FROM orders" in sqls
|
||||
|
||||
def test_relationship_key_case_insensitive_isolated(self):
|
||||
# Isolates the relationship-key normalization path: "CreatedBy" is NOT a
|
||||
# PK and NOT *_id-like, so only the (case-insensitive) relationship-key
|
||||
# match can exclude it. The column is "CreatedBy" but the condition says
|
||||
# "DOCUMENTS.CREATEDBY" — without normalization this leaks SUM(CreatedBy).
|
||||
manifest = {
|
||||
"models": [
|
||||
_model(
|
||||
"documents",
|
||||
"doc_pk",
|
||||
[
|
||||
_col("doc_pk", "varchar"),
|
||||
_col("CreatedBy", "int"),
|
||||
_col("word_count", "int"),
|
||||
],
|
||||
),
|
||||
_model("users", "user_pk", [_col("user_pk", "int")]),
|
||||
],
|
||||
"relationships": [
|
||||
{
|
||||
"name": "users_documents",
|
||||
"models": ["users", "documents"],
|
||||
"condition": "DOCUMENTS.CREATEDBY = USERS.USER_PK",
|
||||
}
|
||||
],
|
||||
}
|
||||
sqls = [p["sql"] for p in generate_seed_queries(manifest)]
|
||||
assert not any("SUM(CreatedBy)" in s for s in sqls)
|
||||
assert "SELECT SUM(word_count) FROM documents" in sqls
|
||||
|
||||
Reference in New Issue
Block a user