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
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export default [
"web-common/src/components/modal/*.js",
"web-common/src/features/dashboards/url-state/filters/expression.js", // generated by nearley
"web-common/src/features/dashboards/url-state/time-ranges/rill-time.js", // generated by nearley
"web-common/src/features/dashboards/ephemeral-measures/measure-expression.js", // generated by nearley
"web-local/build/*",
"web-local/playwright-report/*",
"web-local/playwright/*",
Expand Down
16 changes: 16 additions & 0 deletions proto/rill/ui/v1/dashboard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ message DashboardState {

// Per-measure conditional formatting (heatmap / data bar) for pivot cells.
repeated PivotConditionalFormat pivot_conditional_formatting = 44;

// Ephemeral measures defined for the explore.
repeated EphemeralMeasure ephemeral_measures = 45;
}

message DashboardTimeRange {
Expand All @@ -184,6 +187,19 @@ message PivotElement {
}
}

// An ephemeral measure defined ad-hoc for an explore dashboard,
// derived from existing metrics view measures via an arithmetic expression.
message EphemeralMeasure {
// Query alias, e.g. "profit". Must not collide with metrics view field names.
string name = 1;
// Display name shown in the UI, e.g. "Profit".
string display_name = 2;
// Arithmetic expression over existing measure names, e.g. "revenue - cost".
string expression = 3;
// Optional format preset for rendering values.
string format_preset = 4;
}

// Conditional formatting applied to a measure's cells in a pivot table.
message PivotConditionalFormat {
string measure = 1;
Expand Down
10 changes: 5 additions & 5 deletions runtime/canvas/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -592,28 +592,28 @@ func isEncodedTimeDimension(mv *runtimev1.MetricsViewSpec, fieldName string) boo
return ok && v != int32(runtimev1.TimeGrain_TIME_GRAIN_UNSPECIFIED)
}

// ephemeralMeasureNames extracts and validates the optional "ephemeral_measures" renderer property.
// ephemeralMeasureNames extracts and validates the optional "adhoc_measures" renderer property.
// Each entry defines an ephemeral measure derived from existing measures via an arithmetic expression;
// the returned set contains the names that may be referenced alongside the metrics view's own measures.
func ephemeralMeasureNames(props map[string]any, mvn string, mv *runtimev1.MetricsViewSpec) (map[string]bool, error) {
raw, ok := props["ephemeral_measures"]
raw, ok := props["adhoc_measures"]
if !ok || raw == nil {
return nil, nil
}
list, ok := raw.([]any)
if !ok {
return nil, errors.New("renderer property 'ephemeral_measures' must be an array")
return nil, errors.New("renderer property 'adhoc_measures' must be an array")
}
names := make(map[string]bool, len(list))
for _, item := range list {
entry, ok := item.(map[string]any)
if !ok {
return nil, errors.New("entries in 'ephemeral_measures' must be objects with 'name' and 'expression'")
return nil, errors.New("entries in 'adhoc_measures' must be objects with 'name' and 'expression'")
}
name, _ := entry["name"].(string)
expression, _ := entry["expression"].(string)
if name == "" || expression == "" {
return nil, errors.New("entries in 'ephemeral_measures' must have a non-empty 'name' and 'expression'")
return nil, errors.New("entries in 'adhoc_measures' must have a non-empty 'name' and 'expression'")
}
// Mirror metricsview.AST.checkNameForComputedField, which also rejects the time dimension.
// It is often absent from mv.Dimensions, so checking it here surfaces the collision at parse time rather than at query time.
Expand Down
20 changes: 10 additions & 10 deletions runtime/canvas/component_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,7 @@ type: component
kpi_grid:
metrics_view: mv1
measures: [y, profit]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -995,7 +995,7 @@ leaderboard:
metrics_view: mv1
measures: [profit]
dimensions: [foo]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1010,7 +1010,7 @@ type: component
table:
metrics_view: mv1
columns: [foo, y, profit]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1026,7 +1026,7 @@ pivot:
metrics_view: mv1
measures: [profit]
row_dimensions: [foo]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1047,7 +1047,7 @@ bar_chart:
field: profit
type: quantitative
fields: [y, profit]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1067,7 +1067,7 @@ pie_chart:
color:
field: foo
type: nominal
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand All @@ -1087,7 +1087,7 @@ heatmap:
color:
field: profit
type: quantitative
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand Down Expand Up @@ -1119,7 +1119,7 @@ type: component
kpi_grid:
metrics_view: mv1
measures: [profit]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: sum(y)
Expand All @@ -1135,7 +1135,7 @@ type: component
kpi_grid:
metrics_view: mv1
measures: [profit]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - unknown
Expand All @@ -1151,7 +1151,7 @@ type: component
kpi_grid:
metrics_view: mv1
measures: [missing]
ephemeral_measures:
adhoc_measures:
- name: profit
display_name: Profit
expression: y - z
Expand Down
20 changes: 20 additions & 0 deletions runtime/drivers/clickhouse/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,26 @@ func (d *dialect) CastToDataType(typ runtimev1.Type_Code) (string, error) {
}
}

// OrderByExpression and OrderByAliasExpression place NULLs last regardless of the sort direction,
// matching the behavior of the DuckDB dialect.
func (d *dialect) OrderByExpression(name string, desc bool) string {
res := d.EscapeIdentifier(name)
if desc {
res += " DESC"
}
res += " NULLS LAST"
return res
}

func (d *dialect) OrderByAliasExpression(name string, desc bool) string {
res := d.EscapeAlias(name)
if desc {
res += " DESC"
}
res += " NULLS LAST"
return res
}

func (d *dialect) JoinOnExpression(lhs, rhs string) string {
return fmt.Sprintf("isNotDistinctFrom(%s, %s)", lhs, rhs)
}
Expand Down
3 changes: 2 additions & 1 deletion runtime/drivers/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,8 +275,9 @@ func (b *BaseDialect) CastToDataType(typ runtimev1.Type_Code) (string, error) {
}
}

// SafeDivideExpression returns a division that yields NULL instead of an error, infinity or NaN when the denominator is zero.
func (b *BaseDialect) SafeDivideExpression(numExpr, denExpr string) string {
return fmt.Sprintf("(%s)/CAST(%s AS DOUBLE)", numExpr, denExpr)
return fmt.Sprintf("(%s)/NULLIF(CAST(%s AS DOUBLE), 0)", numExpr, denExpr)
}

func (b *BaseDialect) OrderByExpression(name string, desc bool) string {
Expand Down
31 changes: 26 additions & 5 deletions runtime/metricsview/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp
}

if qm.Compute.ComparisonValue != nil {
m, err := a.LookupMeasure(qm.Compute.ComparisonValue.Measure, visible)
m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonValue.Measure, visible)
if err != nil {
return nil, err
}
Expand All @@ -513,7 +513,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp
}

if qm.Compute.ComparisonDelta != nil {
m, err := a.LookupMeasure(qm.Compute.ComparisonDelta.Measure, visible)
m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonDelta.Measure, visible)
if err != nil {
return nil, err
}
Expand All @@ -533,7 +533,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp
}

if qm.Compute.ComparisonRatio != nil {
m, err := a.LookupMeasure(qm.Compute.ComparisonRatio.Measure, visible)
m, err := a.resolveReferencedMeasure(qm.Compute.ComparisonRatio.Measure, visible)
if err != nil {
return nil, err
}
Expand All @@ -559,7 +559,7 @@ func (a *AST) ResolveMeasure(qm Measure, visible bool) (*runtimev1.MetricsViewSp
return nil, fmt.Errorf("totals not computed for %s", qm.Name)
}

m, err := a.LookupMeasure(qm.Compute.PercentOfTotal.Measure, visible)
m, err := a.resolveReferencedMeasure(qm.Compute.PercentOfTotal.Measure, visible)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -731,6 +731,27 @@ func (a *AST) LookupMeasure(name string, visible bool) (*runtimev1.MetricsViewSp
return nil, fmt.Errorf("measure %q not found", name)
}

// resolveReferencedMeasure resolves a measure referenced by name from another measure,
// such as the base measure of a comparison delta or a measure referenced by a derived measure.
// The referenced measure is either a measure in the metrics view or an expression measure defined in the same query,
// which lets ad-hoc measures support comparisons without being declared in the metrics view.
// Expression measures may only reference metrics view measures, so the recursion is at most one level deep.
func (a *AST) resolveReferencedMeasure(name string, visible bool) (*runtimev1.MetricsViewSpec_Measure, error) {
for _, m := range a.MetricsView.Measures {
if m.Name == name {
return a.LookupMeasure(name, visible)
}
}

for _, qm := range a.Query.Measures {
if qm.Name == name && qm.Compute != nil && qm.Compute.Expression != nil {
return a.ResolveMeasure(qm, visible)
}
}

return nil, fmt.Errorf("measure %q not found", name)
}

// GenerateIdentifier generates a unique table identifier for use in the AST.
func (a *AST) GenerateIdentifier() string {
tmp := fmt.Sprintf("t%d", a.nextIdentifier)
Expand Down Expand Up @@ -1065,7 +1086,7 @@ func (a *AST) addReferencedMeasuresToScope(n *SelectNode, referencedMeasures []s

for _, rm := range referencedMeasures {
// Note we pass visible==false because the measure won't be projected into the current node's SELECT list, only brought into scope for derived measures.
m, err := a.LookupMeasure(rm, false)
m, err := a.resolveReferencedMeasure(rm, false)
if err != nil {
return err
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@ func (e *Executor) rewritePercentOfTotals(ctx context.Context, qry *metricsview.
var measureIndices []int
for i, measure := range qry.Measures {
if measure.Compute != nil && measure.Compute.PercentOfTotal != nil {
measures = append(measures, metricsview.Measure{
Name: measure.Compute.PercentOfTotal.Measure,
})
// The referenced measure is usually a metrics view measure, but it may also be an expression measure defined in the same query,
// in which case the totals query must carry its expression compute.
totalOf := metricsview.Measure{Name: measure.Compute.PercentOfTotal.Measure}
if qm, ok := queryExpressionMeasure(qry, totalOf.Name); ok {
totalOf = qm
}
measures = append(measures, totalOf)
measureIndices = append(measureIndices, i)
}
}
Expand Down
16 changes: 16 additions & 0 deletions runtime/metricsview/executor/executor_rewrite_rollup.go
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,12 @@ func rollupEligible(rollup *runtimev1.MetricsViewSpec_Rollup, qry *metricsview.Q
return false, rejectComputedMeasure, nil
}
for _, refName := range refNames {
// A comparison compute may reference an expression measure defined in the same query.
// That measure is not in the rollup, but the measures it references may be, so check those instead.
// Its own rollup safety is checked when the loop reaches it.
if _, ok := queryExpressionMeasure(qry, refName); ok {
continue
}
if !rollupMeasures[strings.ToLower(refName)] {
return false, rejectMeasureMissing, nil
}
Expand Down Expand Up @@ -583,6 +589,16 @@ func rollupSafeReferencedMeasures(c *metricsview.MeasureCompute) ([]string, bool
return nil, false
}

// queryExpressionMeasure returns the expression measure with the given name defined in the query, if any.
func queryExpressionMeasure(qry *metricsview.Query, name string) (metricsview.Measure, bool) {
for _, qm := range qry.Measures {
if qm.Name == name && qm.Compute != nil && qm.Compute.Expression != nil {
return qm, true
}
}
return metricsview.Measure{}, false
}

// normalizeTimezone validates and normalizes a timezone string for comparison.
// It normalizes UTC variants (empty, "UTC", "Etc/UTC") to "UTC".
// Note: Go's time.LoadLocation preserves the input name, so aliases like "US/Eastern"
Expand Down
Loading
Loading