@@ -46,8 +46,9 @@ function aggFunction(agg: ReportAggregate["agg"], d: ReportDialect): string {
4646}
4747
4848/** One aggregate (Table C): the bare aggregate, the condition by dialect, the cast, then the
49- * Table D default around all of it. */
50- function aggregate ( a : ReportAggregate , d : ReportDialect ) : string {
49+ * Table D default around all of it. `factRef` is set under `@spine`: a column of @from that is
50+ * null only on the padding row a spine row with no facts gets. */
51+ function aggregate ( a : ReportAggregate , d : ReportDialect , factRef ?: string ) : string {
5152 const refs = a . refs . map ( ( r ) => ref ( r , d ) ) ;
5253 const c = a . filter === undefined ? undefined : cond ( a . filter , d ) ;
5354 const fn = aggFunction ( a . agg , d ) ;
@@ -73,7 +74,11 @@ function aggregate(a: ReportAggregate, d: ReportDialect): string {
7374 // (every row) passes". A comparison is a boolean on Postgres and 0 / 1 / NULL elsewhere, so
7475 // MAX / MIN of it is the OR / AND, and a row whose predicate is NULL is skipped as bool_or skips it.
7576 if ( c === undefined ) throw new Error ( `report-ddl-emit: @agg '${ a . agg } ' has neither @of nor a condition.` ) ;
76- sql = `${ fn } (${ c } )` ;
77+ // Under @spine the padding row's condition can still be true (`col IS NULL`), so only fact
78+ // rows are aggregated, and a spine row with no facts reads null as every other measure does.
79+ if ( factRef === undefined ) sql = `${ fn } (${ c } )` ;
80+ else if ( d === "postgres" ) sql = `${ fn } (${ c } ) FILTER (WHERE ${ ref ( factRef , d ) } IS NOT NULL)` ;
81+ else sql = `${ fn } (CASE WHEN ${ ref ( factRef , d ) } IS NOT NULL THEN ${ c } END)` ;
7782 } else {
7883 const x = refs [ 0 ] ! ;
7984 const distinct = a . distinct ? "DISTINCT " : "" ;
@@ -92,20 +97,20 @@ interface RenderedColumn {
9297 readonly grouped : boolean ;
9398}
9499
95- function column ( c : ReportColumn , d : ReportDialect ) : RenderedColumn {
100+ function column ( c : ReportColumn , d : ReportDialect , factRef : string | undefined ) : RenderedColumn {
96101 const alias = q ( c . dbColAlias , d ) ;
97102 switch ( c . kind ) {
98103 case "dimension" :
99104 return { expr : ref ( c . ref , d ) , alias, grouped : true } ;
100105 case "timeDimension" :
101106 return { expr : truncateToGrain ( ref ( c . ref , d ) , c . grain , c . temporal , d ) , alias, grouped : true } ;
102107 case "aggregate" :
103- return { expr : aggregate ( c . aggregate , d ) , alias, grouped : false } ;
108+ return { expr : aggregate ( c . aggregate , d , factRef ) , alias, grouped : false } ;
104109 case "ratio" : {
105110 // Each operand is its FULL Table C expression (condition and cast included), and its own
106111 // Table D default; the ratio's default wraps the whole quotient, which is always real.
107- const num = aggregate ( c . numerator , d ) ;
108- const den = aggregate ( c . denominator , d ) ;
112+ const num = aggregate ( c . numerator , d , factRef ) ;
113+ const den = aggregate ( c . denominator , d , factRef ) ;
109114 const top = d === "postgres" ? "NUMERIC" : d === "sqlite" ? "REAL" : undefined ;
110115 const quotient = `${ top === undefined ? num : `CAST(${ num } AS ${ top } )` } / NULLIF(${ den } , 0)` ;
111116 const dv = c . defaultValue === undefined ? undefined : { value : c . defaultValue , real : true } ;
@@ -180,8 +185,15 @@ function spineFrom(spec: ReportViewSpec, depth: number, options: ReportEmitOptio
180185
181186export function emitReportViewDdl ( spec : ReportViewSpec , options : ReportEmitOptions ) : string {
182187 const d = options . dialect ;
188+ // Under @spine, @from's side of the first hop's ON equality: non-null on every fact row the
189+ // join keeps, null on the padding row of a spine row with no facts.
190+ const first = spec . spineDepth === undefined ? undefined : spec . joinTree . joins [ 0 ] ;
191+ const factRef =
192+ first === undefined
193+ ? undefined
194+ : `${ spec . joinTree . baseAlias } .${ first . referenceHolder === "source" ? first . fkColumn : first . pkColumn } ` ;
183195 // Rendered once: a dimension's SELECT expression is its GROUP BY term, so they cannot differ.
184- const cols = spec . columns . map ( ( c ) => column ( c , d ) ) ;
196+ const cols = spec . columns . map ( ( c ) => column ( c , d , factRef ) ) ;
185197 const select = cols . map ( ( c ) => ` ${ c . expr } AS ${ c . alias } ` ) . join ( ",\n" ) ;
186198 const groupBy = cols . filter ( ( c ) => c . grouped ) . map ( ( c ) => c . expr ) ;
187199
0 commit comments