Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions pydough/unqualified/qualification.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
SubCollection,
WindowCall,
)
from pydough.qdag.collections.calculate import Calculate
from pydough.types import PyDoughType
from pydough.utilities import ExplodeSpec

Expand Down Expand Up @@ -638,6 +639,11 @@ def qualify_access(
typ: PyDoughType = context.get_expr(name).pydough_type
return self.builder.build_reference(context, name, typ)
else:
# Accessing `child.name`: forbid reaching a down-streamed
# (inherited) term through a child collection.
self.verify_not_inherited_through_child(
unqualified, unqualified_parent, qualified_parent, context, name
)
# Otherwise, the access is a reference to a scalar attribute of
# a child collection node of the current context. Add this new
# child to the list of children, unless already present, then
Expand All @@ -652,6 +658,96 @@ def qualify_access(
children, ref_num, name
)

def names_defined_in_child_chain(
self,
qualified_node: PyDoughCollectionQDAG,
context: PyDoughCollectionQDAG,
) -> set[str]:
"""
Walks the qualified chain of a child access up to (but not including)
the context the child is evaluated in, collecting every term name
defined by a CALCULATE that is part of the child chain itself.

Args:
`qualified_node`: the qualified collection the term is accessed from.
`context`: the context the child chain is attached to; the walk
stops here so CALCULATEs from the enclosing context are ignored.

Returns:
The set of term names defined by CALCULATEs within the child chain.
"""
defined: set[str] = set()
node: PyDoughCollectionQDAG | None = qualified_node
while (
node is not None
and node is not context
and not isinstance(node, GlobalContext)
):
if isinstance(node, Calculate):
defined.update(name for name in node.calc_term_values.keys())
# Operators (CALCULATE, WHERE, SINGULAR, ...) step back through
# their preceding context; child accesses step up to their parent.
if node.preceding_context is not None:
node = node.preceding_context
else:
node = node.ancestor_context
return defined

def verify_not_inherited_through_child(
self,
unqualified: UnqualifiedAccess,
unqualified_parent: UnqualifiedNode,
qualified_parent: PyDoughCollectionQDAG,
context: PyDoughCollectionQDAG,
name: str,
) -> None:
"""
Verifies that an expression accessed as `child.name` is not a term
that the child only has because it was down-streamed from the context
the child is being evaluated in. For example, in
`customers.CALCULATE(cust_name=name).orders.CALCULATE(x=customer.cust_name)`
`customer` only sees `cust_name` because it inherits it from the
enclosing context, so the access is invalid (use `cust_name` directly).

Accesses are still valid when the term comes from the child's own
data, e.g. a CALCULATE inside the child expression
(`customer.CALCULATE(customer_name=name).orders.customer_name`) or
inside the data of a PARTITION child (`pricing_collection.price`).

Raises:
`PyDoughUnqualifiedException` if the term is inherited from the
enclosing context through the child collection.
"""
# Only terms the child inherits (not its own columns/calcs) matter.
is_inherited: bool = (
name in qualified_parent.ancestral_mapping
or name in qualified_parent.inherited_downstreamed_terms
)
if not is_inherited or name in qualified_parent.calc_terms:
return

# Only invalid if the term comes from the enclosing context. If the
# context itself does not inherit it, it originated inside the
# child's own data (e.g. a PARTITION child's partitioned data).
context_inherits: bool = (
name in context.ancestral_mapping
or name in context.inherited_downstreamed_terms
)
if not context_inherits:
return

# The child chain may redefine (shadow) the same name; then the
# value comes from inside the child, so it is valid.
if name in self.names_defined_in_child_chain(qualified_parent, context):
return

raise PyDoughUnqualifiedException(
f"Cannot access inherited term {name!r} through child collection "
f"{qualified_parent.standalone_string!r} in {unqualified!r}. "
f"Terms down-streamed from an ancestor CALCULATE can only be "
f"referenced directly, e.g. {name!r}."
)

def qualify_calculate(
self,
unqualified: UnqualifiedCalculate,
Expand Down
13 changes: 12 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3210,6 +3210,12 @@ def mock_server_setup():

# Wait until server is ready
for _ in range(20):
if proc.poll() is not None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated changes to the mock server setup error handling. I was already using port 8000 but didn't get the correct error message

output, _ = proc.communicate()
raise RuntimeError(
f"Mock server exited with code {proc.returncode}:\n{output}"
)

try:
r: httpx.Response = httpx.get(server_url + "/health", timeout=1)
if r.status_code == 200:
Expand All @@ -3218,7 +3224,12 @@ def mock_server_setup():
time.sleep(0.5)
else:
proc.terminate()
raise RuntimeError("Mock server failed to start")
try:
output, _ = proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
output, _ = proc.communicate()
raise RuntimeError(f"Mock server failed to start:\n{output}")

yield server_url

Expand Down
55 changes: 55 additions & 0 deletions tests/test_pipeline_tpch_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,11 @@
order_info_per_priority,
order_quarter_test,
orders_versus_first_orders,
parent_calc_access_error,
part_reduced_size,
partition_by_child_reference,
partition_by_literal,
partition_child_inherited_term,
partition_key_name_collision,
partition_self_reference,
parts_quantity_increase_95_96,
Expand All @@ -155,6 +157,7 @@
regional_suppliers_percentile,
replace_non_string_arg,
richest_customer_per_region,
shadowed_calc_term,
simple_cross_1,
simple_cross_2,
simple_cross_3,
Expand Down Expand Up @@ -6818,6 +6821,47 @@
),
id="partition_key_name_collision",
),
pytest.param(
PyDoughPandasTest(
shadowed_calc_term,
"TPCH",
lambda: pd.DataFrame(
{
"key": [1, 2, 3, 4, 5],
"x": [
"23-644-998-4944",
"20-715-308-7926",
"25-884-345-1592",
"20-500-807-1549",
"30-452-969-2072",
],
}
),
"shadowed_calc_term",
),
id="shadowed_calc_term",
),
pytest.param(
PyDoughPandasTest(
partition_child_inherited_term,
"TPCH",
lambda: pd.DataFrame(
{
"rkey": [0, 1, 2, 3, 4],
"region_name": [
"AFRICA",
"AMERICA",
"ASIA",
"EUROPE",
"MIDDLE EAST",
],
"n_nations": [5, 5, 5, 5, 5],
}
),
"partition_child_inherited_term",
),
id="partition_child_inherited_term",
),
pytest.param(
PyDoughPandasTest(
chained_partition_transformed_key,
Expand Down Expand Up @@ -7676,6 +7720,17 @@ def test_pipeline_e2e_simple_week(
),
id="isin_mixed_literal_list",
),
pytest.param(
parent_calc_access_error,
None,
re.escape(
"Cannot access inherited term 'cust_name' through child collection "
"'customer' in customer.cust_name. Terms down-streamed from an "
"ancestor CALCULATE can only be referenced directly, "
"e.g. 'cust_name'."
),
id="parent_calc_access_error",
),
],
)
def test_pipeline_e2e_errors(
Expand Down
5 changes: 5 additions & 0 deletions tests/test_plan_refsols/partition_child_inherited_term.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
ROOT(columns=[('rkey', r_regionkey), ('region_name', max_r_name), ('n_nations', n_rows)], orderings=[(r_regionkey):asc_first])
AGGREGATE(keys={'r_regionkey': r_regionkey}, aggregations={'max_r_name': MAX(r_name), 'n_rows': COUNT()})
JOIN(condition=t0.r_regionkey == t1.n_regionkey, type=INNER, cardinality=PLURAL_FILTER, reverse_cardinality=PLURAL_FILTER, columns={'r_name': t0.r_name, 'r_regionkey': t0.r_regionkey})
SCAN(table=tpch.REGION, columns={'r_name': r_name, 'r_regionkey': r_regionkey})
SCAN(table=tpch.NATION, columns={'n_regionkey': n_regionkey})
7 changes: 7 additions & 0 deletions tests/test_plan_refsols/shadowed_calc_term.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
ROOT(columns=[('key', o_orderkey), ('x', max_c_phone)], orderings=[(o_orderkey):asc_first], limit=5:numeric)
JOIN(condition=t0.o_custkey == t1.c_custkey, type=LEFT, cardinality=SINGULAR_FILTER, reverse_cardinality=PLURAL_FILTER, columns={'max_c_phone': t1.max_c_phone, 'o_orderkey': t0.o_orderkey})
SCAN(table=tpch.ORDERS, columns={'o_custkey': o_custkey, 'o_orderkey': o_orderkey})
AGGREGATE(keys={'c_custkey': c_custkey}, aggregations={'max_c_phone': MAX(c_phone)})
JOIN(condition=t0.c_custkey == t1.o_custkey, type=INNER, cardinality=PLURAL_FILTER, reverse_cardinality=SINGULAR_ACCESS, columns={'c_custkey': t0.c_custkey, 'c_phone': t0.c_phone})
SCAN(table=tpch.CUSTOMER, columns={'c_custkey': c_custkey, 'c_phone': c_phone})
SCAN(table=tpch.ORDERS, columns={'o_custkey': o_custkey})
57 changes: 57 additions & 0 deletions tests/test_pydough_functions/simple_pydough_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3967,6 +3967,63 @@ def partition_key_name_collision():
)


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add another test for PARTITION-child with context_inherits being False as written in the comments in the code.

        Accesses are still valid when the term comes from the child's own
        data, e.g. a CALCULATE inside the child expression
        (`customer.CALCULATE(customer_name=name).orders.customer_name`) or
        inside the data of a PARTITION child (`pricing_collection.price`).

e.g. something like the pricing_collection.price case mentioned above. Right now only the "shadowed" and "plain error" branches are tested.

def parent_calc_access_error():
"""
Test that a child collection's CALCULATE cannot reference a term
defined in a parent collection's CALCULATE, and that doing so raises a
descriptive error message instead of silently qualifying and producing an
error later.
"""
# 'top' calculates 'cust_name' on customers
top = customers.CALCULATE(cust_name=name)

# Referencing 'customer.cust_name' from orders causes a lowering error
result = top.orders.CALCULATE(
correct_usage=cust_name, incorrect_usage=customer.cust_name
)
return result


def shadowed_calc_term():
"""
The child `customer.CALCULATE(cust_name=phone).orders` redefines `cust_name`
as `phone`, and `.orders.cust_name` then reads it. Within that child, the nearest
definition is the inner one, so the value really comes from the child's own
data.
"""
return (
customers.CALCULATE(cust_name=name)
.orders.CALCULATE(
key,
x=MAX(customer.CALCULATE(cust_name=phone).orders.cust_name),
)
.TOP_K(5, by=key.ASC())
)


def partition_child_inherited_term():
"""
Pair every region with its nations via CROSS, partition the pairs by
region, and aggregate a term (`region_name`) that was down-streamed from
the regions CALCULATE *inside the partitioned data*. The PARTITION
context only exposes `rkey`, so accessing `nations.region_name` through
the partition child is valid (context_inherits is False).
"""

return (
regions.CALCULATE(rkey=key, region_name=name)
.CROSS(nations)
.WHERE(region_key == rkey)
.PARTITION(name="region_groups", by=rkey)
.CALCULATE(
rkey,
region_name=MAX(nations.region_name),
n_nations=COUNT(nations),
)
.ORDER_BY(rkey.ASC())
)


def chained_partition_transformed_key():
"""
Test for fixing the problem with chaining a second PARTITION on top of a
Expand Down
11 changes: 11 additions & 0 deletions tests/test_sql_refsols/partition_child_inherited_term_ansi.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
region.r_regionkey AS rkey,
MAX(region.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.region AS region
JOIN tpch.nation AS nation
ON nation.n_regionkey = region.r_regionkey
GROUP BY
1
ORDER BY
1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
region.r_regionkey AS rkey,
MAX(region.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.region AS region
JOIN tpch.nation AS nation
ON nation.n_regionkey = region.r_regionkey
GROUP BY
1
ORDER BY
1
11 changes: 11 additions & 0 deletions tests/test_sql_refsols/partition_child_inherited_term_duckdb.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
region.r_regionkey AS rkey,
MAX(region.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.region AS region
JOIN tpch.nation AS nation
ON nation.n_regionkey = region.r_regionkey
GROUP BY
1
ORDER BY
1 NULLS FIRST
11 changes: 11 additions & 0 deletions tests/test_sql_refsols/partition_child_inherited_term_mysql.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
REGION.r_regionkey AS rkey,
MAX(REGION.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.REGION AS REGION
JOIN tpch.NATION AS NATION
ON NATION.n_regionkey = REGION.r_regionkey
GROUP BY
1
ORDER BY
1
11 changes: 11 additions & 0 deletions tests/test_sql_refsols/partition_child_inherited_term_oracle.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
REGION.r_regionkey AS rkey,
MAX(REGION.r_name) AS region_name,
COUNT(*) AS n_nations
FROM TPCH.REGION REGION
JOIN TPCH.NATION NATION
ON NATION.n_regionkey = REGION.r_regionkey
GROUP BY
REGION.r_regionkey
ORDER BY
1 NULLS FIRST
11 changes: 11 additions & 0 deletions tests/test_sql_refsols/partition_child_inherited_term_postgres.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
region.r_regionkey AS rkey,
MAX(region.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.region AS region
JOIN tpch.nation AS nation
ON nation.n_regionkey = region.r_regionkey
GROUP BY
1
ORDER BY
1 NULLS FIRST
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
SELECT
region.r_regionkey AS rkey,
MAX(region.r_name) AS region_name,
COUNT(*) AS n_nations
FROM tpch.region AS region
JOIN tpch.nation AS nation
ON nation.n_regionkey = region.r_regionkey
GROUP BY
1
ORDER BY
1 NULLS FIRST
Loading
Loading