Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
510 changes: 280 additions & 230 deletions datajunction-query/uv.lock

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Add reaggregate column to noderevision

Revision ID: rg0001reaggregate
Revises: cm0003dropowner
Create Date: 2026-08-24 00:00:00.000000+00:00
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "rg0001reaggregate"
down_revision = "cm0003dropowner"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"noderevision",
sa.Column("reaggregate", sa.JSON(), nullable=True),
)


def downgrade():
op.drop_column("noderevision", "reaggregate")
31 changes: 31 additions & 0 deletions datajunction-server/datajunction_server/api/cubes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
_reorder_partition_column_last,
build_combiner_sql_from_preaggs,
)
from datajunction_server.construction.build_v3.cube_matcher import (
validate_cube_reaggregate_materialization,
)
from datajunction_server.construction.build_v3.cte import strip_role_suffix
from datajunction_server.construction.dimensions import build_dimensions_from_cube_query
from datajunction_server.database.materialization import Materialization
Expand Down Expand Up @@ -201,6 +204,32 @@ def _build_metrics_spec(
return metrics


async def _validate_cube_reaggregate_materialization(
session: AsyncSession,
cube: Node,
) -> None:
"""
Validate materialization safety using full metric decomposition.
"""
if not cube.current: # pragma: no cover
return

from datajunction_server.construction.build_v3.builder import setup_build_context

ctx = await setup_build_context(
session=session,
metrics=cube.current.cube_node_metrics,
dimensions=cube.current.cube_node_dimensions,
filters=cube.current.cube_filters or None,
dialect=Dialect.SPARK,
use_materialized=False,
)
validate_cube_reaggregate_materialization(
cube.current,
decomposed_metrics=ctx.decomposed_metrics,
)


@router.get("/cubes", name="Get all Cubes")
async def get_all_cubes(
*,
Expand Down Expand Up @@ -288,6 +317,7 @@ async def cube_materialization_info(
message=f"Cube node `{name}` does not exist.",
http_status_code=404,
)
await _validate_cube_reaggregate_materialization(session, node)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this part first call the pre-check cube_matcher._metric_graph_has_reaggregate (since it's already used in other APIs like in find_matching_cube)?

temporal_partitions = node.current.temporal_partition_columns() # type: ignore
if len(temporal_partitions) != 1:
raise DJInvalidInputException(
Expand Down Expand Up @@ -520,6 +550,7 @@ async def materialize_cube(
message=f"Cube '{name}' has no current revision",
http_status_code=HTTPStatus.NOT_FOUND,
)
await _validate_cube_reaggregate_materialization(session, node)

cube_tps = cube_revision.temporal_partition_columns()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,15 @@
MetricComponent as MetricComponent_,
)
from datajunction_server.models.node import MetricDirection as MetricDirection_
from datajunction_server.models.reaggregate import (
DimensionReaggregateRule as DimensionReaggregateRule_,
ReaggregateSpec as ReaggregateSpec_,
ReaggregationFunction as ReaggregationFunction_,
)

MetricDirection = strawberry.enum(MetricDirection_)
Aggregability = strawberry.enum(Aggregability_)
ReaggregationFunction = strawberry.enum(ReaggregationFunction_)


@strawberry.type
Expand All @@ -32,6 +38,17 @@ class Unit:
abbreviation: str | None


@strawberry.experimental.pydantic.type(
model=DimensionReaggregateRule_,
all_fields=True,
)
class DimensionReaggregateRule: ...


@strawberry.experimental.pydantic.type(model=ReaggregateSpec_, all_fields=True)
class ReaggregateSpec: ...


@strawberry.experimental.pydantic.type(model=AggregationRule_, all_fields=True)
class AggregationRule: ...

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
)
from datajunction_server.api.graphql.scalars.metricmetadata import (
DecomposedMetric,
DimensionReaggregateRule,
MetricMetadata,
ReaggregateSpec,
)
from datajunction_server.api.graphql.scalars.user import User
from datajunction_server.api.graphql.utils import extract_fields
Expand All @@ -45,6 +47,7 @@
from datajunction_server.models.node import NodeMode as NodeMode_
from datajunction_server.models.node import NodeStatus as NodeStatus_
from datajunction_server.models.node import NodeType as NodeType_
from datajunction_server.models.reaggregate import parse_reaggregate_spec
from datajunction_server.sql.parsing.backends.antlr4 import ast, parse

NodeType = strawberry.enum(NodeType_)
Expand Down Expand Up @@ -410,6 +413,28 @@ def materializations(
# Only metrics will have these fields
required_dimensions: list[Column] | None = None

@strawberry.field
def reaggregate(self, root: DBNodeRevision) -> ReaggregateSpec | None:
"""
Metric reaggregation declaration.
"""
if root.type != NodeType.METRIC:
return None
spec = parse_reaggregate_spec(root.reaggregate)
if not spec:
return None
return ReaggregateSpec(
fn=spec.fn, # type: ignore
weight=spec.weight,
rules=[
DimensionReaggregateRule(
dimension=rule.dimension,
fn=rule.fn, # type: ignore
)
for rule in spec.rules
],
)

@strawberry.field
def primary_key(self, root: DBNodeRevision) -> list[str]:
"""
Expand Down
25 changes: 25 additions & 0 deletions datajunction-server/datajunction_server/api/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum Aggregability {
type AggregationRule {
type: Aggregability!
level: [String!]
reaggregate: DimensionReaggregateRule
}

type Attribute {
Expand Down Expand Up @@ -149,6 +150,11 @@ type DimensionLink {
defaultValue: String
}

type DimensionReaggregateRule {
dimension: String!
fn: ReaggregationFunction!
}

type Engine {
name: String!
version: String!
Expand Down Expand Up @@ -397,6 +403,7 @@ type NodeRevision {
dimensionLinks: [DimensionLink!]!
availability: AvailabilityState
materializations: [MaterializationConfig!]
reaggregate: ReaggregateSpec
primaryKey: [String!]!
metricMetadata: MetricMetadata
isDerivedMetric: Boolean!
Expand Down Expand Up @@ -716,6 +723,24 @@ type Query {
listNamespaces: [Namespace!]!
}

type ReaggregateSpec {
fn: ReaggregationFunction
weight: String
rules: [DimensionReaggregateRule!]!
}

enum ReaggregationFunction {
AUTO
NONE
SUM
AVG
WEIGHTED_AVG
LAST_VALUE
FIRST_VALUE
MIN
MAX
}

type SemanticEntity {
name: String!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
)
from datajunction_server.construction.build_v3.decomposition import (
decompose_and_group_metrics,
missing_reaggregate_dimensions,
)
from datajunction_server.construction.build_v3.dimensions import parse_dimension_ref
from datajunction_server.construction.build_v3.filters import (
Expand Down Expand Up @@ -320,6 +321,14 @@ async def setup_build_context(

# Add dimensions referenced in metric expressions (e.g., LAG ORDER BY)
add_dimensions_from_metric_expressions(ctx, ctx.decomposed_metrics)
output_dimensions_after_expression_scan = list(ctx.dimensions)
internal_reaggregate_dimensions = missing_reaggregate_dimensions(
ctx.decomposed_metrics.values(),
output_dimensions_after_expression_scan,
)
for dimension in internal_reaggregate_dimensions:
if dimension not in ctx.dimensions:
ctx.dimensions.append(dimension)

# A second load_nodes pass is needed when either:
# 1. metric expressions introduced dimension nodes not yet in ctx.nodes, OR

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If this is just a parent-column protected dimension (e.g., it's not the fully qualified node name like v3.order_details.order_date but just order_date), then it validates cleanly here but fails at query time:

POST /nodes/metric/
{"query": "SELECT SUM(line_total) FROM v3.order_details",
 "reaggregate": {"rules": [{"dimension": "order_date", "fn": "last_value"}]}}
  -> 201, status: valid

GET /sql/metrics/v3/?metrics=v3.balance&dimensions=v3.product.category
  -> 422 "Reference `order_date` is not fully qualified. Use the `node.column` form..."

Should this just reject a non-fully-qualified name (and I think the UI might need to change based on that as well)?

Expand All @@ -333,8 +342,15 @@ async def setup_build_context(
}
missing_dim_nodes = dim_roots_after - ctx.nodes.keys()
internally_added_roots = dim_roots_after - dim_roots_before_load
if missing_dim_nodes or internally_added_roots:
await load_nodes(ctx)
try:
if (
missing_dim_nodes
or internally_added_roots
or internal_reaggregate_dimensions
):
await load_nodes(ctx)
finally:
ctx.dimensions = output_dimensions_after_expression_scan

# Classify filters into dimension filters (WHERE) and metric filters (HAVING)
# This MUST happen AFTER all nodes are loaded so we can correctly identify
Expand Down
Loading
Loading