ga4-export-fixer 0.7.1-dev.0 → 0.8.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -2
- package/tables/ga4EventsEnhanced/index.js +101 -89
- package/utils.js +125 -28
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ga4-export-fixer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-dev.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"createTable.js"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "node tests/ga4EventsEnhanced.test.js && node tests/assertions.test.js && node tests/mergeSQLConfigurations.test.js && node tests/preOperations.test.js && node tests/documentation.test.js && node tests/inputValidation.test.js && node tests/createTable.test.js",
|
|
20
|
+
"test": "node tests/ga4EventsEnhanced.test.js && node tests/assertions.test.js && node tests/mergeSQLConfigurations.test.js && node tests/preOperations.test.js && node tests/documentation.test.js && node tests/inputValidation.test.js && node tests/createTable.test.js && node tests/queryBuilder.test.js",
|
|
21
21
|
"test:summary": "node tests/testRunner.js",
|
|
22
22
|
"test:docs": "node tests/documentation.test.js",
|
|
23
23
|
"test:preops": "node tests/preOperations.test.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"test:validation": "node tests/inputValidation.test.js",
|
|
27
27
|
"test:assertions": "node tests/assertions.test.js",
|
|
28
28
|
"test:createTable": "node tests/createTable.test.js",
|
|
29
|
+
"test:queryBuilder": "node tests/queryBuilder.test.js",
|
|
29
30
|
"test:integration": "node tests/integration/integration.test.js",
|
|
30
31
|
"release:dev": "./scripts/release-dev.sh",
|
|
31
32
|
"readme": "node scripts/updateReadme.js",
|
|
@@ -106,9 +106,9 @@ const getFinalColumnOrder = (eventDataStep, sessionDataStep) => {
|
|
|
106
106
|
// Construct the columns object: key is column name, value is {step.name}.{column}
|
|
107
107
|
const columnOrder = {};
|
|
108
108
|
for (const col of finalColumnOrder) {
|
|
109
|
-
if (sessionDataStep?.columns?.hasOwnProperty(col) && sessionDataStep.columns[col] !== undefined) {
|
|
109
|
+
if (sessionDataStep?.select?.columns?.hasOwnProperty(col) && sessionDataStep.select.columns[col] !== undefined) {
|
|
110
110
|
columnOrder[col] = `${sessionDataStep.name}.${col}`;
|
|
111
|
-
} else if (eventDataStep?.columns?.hasOwnProperty(col) && eventDataStep.columns[col] !== undefined) {
|
|
111
|
+
} else if (eventDataStep?.select?.columns?.hasOwnProperty(col) && eventDataStep.select.columns[col] !== undefined) {
|
|
112
112
|
columnOrder[col] = `${eventDataStep.name}.${col}`;
|
|
113
113
|
}
|
|
114
114
|
}
|
|
@@ -200,46 +200,48 @@ const _generateEnhancedEventsSQL = (mergedConfig) => {
|
|
|
200
200
|
// initial step: extract data from the export tables
|
|
201
201
|
const eventDataStep = {
|
|
202
202
|
name: 'event_data',
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
203
|
+
select: {
|
|
204
|
+
columns: {
|
|
205
|
+
// exclude default export columns that are not needed
|
|
206
|
+
// do this first so that the columns defined later are not excluded
|
|
207
|
+
...getExcludedColumns(),
|
|
208
|
+
// date and time
|
|
209
|
+
event_date: helpers.eventDate,
|
|
210
|
+
event_datetime: `extract(datetime from timestamp_micros(${helpers.getEventTimestampMicros(mergedConfig.customTimestampParam)}) at time zone '${mergedConfig.timezone}')`,
|
|
211
|
+
event_timestamp: 'event_timestamp',
|
|
212
|
+
event_custom_timestamp: mergedConfig.customTimestampParam ? helpers.getEventTimestampMicros(mergedConfig.customTimestampParam) : undefined,
|
|
213
|
+
// event name
|
|
214
|
+
event_name: 'event_name',
|
|
215
|
+
// identifiers
|
|
216
|
+
session_id: helpers.sessionId,
|
|
217
|
+
user_pseudo_id: 'user_pseudo_id',
|
|
218
|
+
user_id: 'user_id',
|
|
219
|
+
// page
|
|
220
|
+
page_location: helpers.unnestEventParam('page_location', 'string'),
|
|
221
|
+
page: helpers.extractPageDetails(),
|
|
222
|
+
// event parameters and user properties
|
|
223
|
+
...promotedEventParameters(),
|
|
224
|
+
event_params: helpers.filterEventParams(mergedConfig.excludedEventParams, 'exclude'),
|
|
225
|
+
user_properties: 'user_properties',
|
|
226
|
+
// traffic source
|
|
227
|
+
collected_traffic_source: 'collected_traffic_source',
|
|
228
|
+
session_traffic_source_last_click: 'session_traffic_source_last_click',
|
|
229
|
+
user_traffic_source: 'traffic_source',
|
|
230
|
+
// ecommerce
|
|
231
|
+
ecommerce: helpers.fixEcommerceStruct('ecommerce'),
|
|
232
|
+
items: 'items',
|
|
233
|
+
_item_list_attribution_row_id: itemListAttribution ? helpers.itemListAttributionRowId(ecommerceEventsFilter) : undefined,
|
|
234
|
+
// flag if the data is "final" and is not expected to change anymore
|
|
235
|
+
data_is_final: helpers.isFinalData(mergedConfig.dataIsFinal.detectionMethod, mergedConfig.dataIsFinal.dayThreshold),
|
|
236
|
+
export_type: helpers.getGa4ExportType('_table_suffix'),
|
|
237
|
+
// prep columns for later steps
|
|
238
|
+
entrances: helpers.unnestEventParam('entrances', 'int'),
|
|
239
|
+
session_params_prep: mergedConfig.sessionParams.length > 0 ? helpers.filterEventParams(mergedConfig.sessionParams, 'include') : undefined,
|
|
240
|
+
// include all other columns from the export data
|
|
241
|
+
get '[sql]other_columns'() {
|
|
242
|
+
const definedColumns = Object.keys(this);
|
|
243
|
+
return `* except (${definedColumns.filter(column => helpers.isGa4ExportColumn(column)).join(', ')})`;
|
|
244
|
+
},
|
|
243
245
|
},
|
|
244
246
|
},
|
|
245
247
|
from: mergedConfig.sourceTable,
|
|
@@ -250,18 +252,20 @@ ${excludedEventsSQL}`,
|
|
|
250
252
|
// Do session-level data aggregation
|
|
251
253
|
const sessionDataStep = {
|
|
252
254
|
name: 'session_data',
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
255
|
+
select: {
|
|
256
|
+
columns: {
|
|
257
|
+
session_id: 'session_id',
|
|
258
|
+
user_id: helpers.aggregateValue('user_id', 'last', timestampColumn),
|
|
259
|
+
merged_user_id: `ifnull(${helpers.aggregateValue('user_id', 'last', timestampColumn)}, any_value(user_pseudo_id))`,
|
|
260
|
+
session_params: helpers.aggregateSessionParams(mergedConfig.sessionParams, 'session_params_prep', timestampColumn),
|
|
261
|
+
session_traffic_source_last_click: helpers.aggregateValue('session_traffic_source_last_click', 'first', timestampColumn),
|
|
262
|
+
session_first_traffic_source: `array_agg(collected_traffic_source order by ${timestampColumn} limit 1)[safe_offset(0)]`, // don't ignore nulls
|
|
263
|
+
landing_page: helpers.aggregateValue(`if(entrances > 0, page, null)`, 'first', timestampColumn),
|
|
264
|
+
},
|
|
261
265
|
},
|
|
262
266
|
from: 'event_data',
|
|
263
267
|
where: `session_id is not null`,
|
|
264
|
-
|
|
268
|
+
'group by': 'session_id',
|
|
265
269
|
};
|
|
266
270
|
|
|
267
271
|
// item list attribution CTEs:
|
|
@@ -277,11 +281,13 @@ ${excludedEventsSQL}`,
|
|
|
277
281
|
|
|
278
282
|
const attributionStep = {
|
|
279
283
|
name: 'item_list_attribution',
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
select: {
|
|
285
|
+
columns: {
|
|
286
|
+
'_item_list_attribution_row_id': '_item_list_attribution_row_id',
|
|
287
|
+
'event_name': 'event_name',
|
|
288
|
+
'item': 'item',
|
|
289
|
+
'_item_list_attr': attrExpr,
|
|
290
|
+
},
|
|
285
291
|
},
|
|
286
292
|
from: 'event_data, unnest(items) as item',
|
|
287
293
|
where: `event_name in (${ecommerceEventsFilter})`,
|
|
@@ -289,18 +295,20 @@ ${excludedEventsSQL}`,
|
|
|
289
295
|
|
|
290
296
|
const dataStep = {
|
|
291
297
|
name: 'item_list_data',
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
298
|
+
select: {
|
|
299
|
+
columns: {
|
|
300
|
+
'_item_list_attribution_row_id': '_item_list_attribution_row_id',
|
|
301
|
+
'items': `array_agg(
|
|
295
302
|
(select as struct item.* replace(
|
|
296
303
|
coalesce(if(${passthroughEvents}, item.item_list_name, _item_list_attr.item_list_name), '(not set)') as item_list_name,
|
|
297
304
|
coalesce(if(${passthroughEvents}, item.item_list_id, _item_list_attr.item_list_id), '(not set)') as item_list_id,
|
|
298
305
|
coalesce(if(${passthroughEvents}, item.item_list_index, _item_list_attr.item_list_index)) as item_list_index
|
|
299
306
|
))
|
|
300
307
|
)`,
|
|
308
|
+
},
|
|
301
309
|
},
|
|
302
310
|
from: 'item_list_attribution',
|
|
303
|
-
|
|
311
|
+
'group by': '_item_list_attribution_row_id',
|
|
304
312
|
};
|
|
305
313
|
|
|
306
314
|
return [attributionStep, dataStep];
|
|
@@ -318,42 +326,46 @@ ${excludedEventsSQL}`,
|
|
|
318
326
|
// Join event_data and session_data, include additional logic
|
|
319
327
|
const finalStep = {
|
|
320
328
|
name: 'final',
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
329
|
+
select: {
|
|
330
|
+
columns: {
|
|
331
|
+
// get the most important columns in the correct order
|
|
332
|
+
...finalColumnOrder,
|
|
333
|
+
...itemListOverrides,
|
|
334
|
+
// get the rest of the event_data columns
|
|
335
|
+
'[sql]event_data': utils.selectOtherColumns(
|
|
336
|
+
eventDataStep,
|
|
337
|
+
Object.keys(finalColumnOrder),
|
|
338
|
+
[
|
|
339
|
+
'entrances',
|
|
340
|
+
mergedConfig.sessionParams.length > 0 ? 'session_params_prep' : undefined,
|
|
341
|
+
'data_is_final',
|
|
342
|
+
'export_type',
|
|
343
|
+
...itemListExcludedColumns,
|
|
344
|
+
]
|
|
345
|
+
),
|
|
346
|
+
// get the rest of the session_data columns
|
|
347
|
+
'[sql]session_data': utils.selectOtherColumns(
|
|
348
|
+
sessionDataStep,
|
|
349
|
+
Object.keys(finalColumnOrder),
|
|
350
|
+
[]
|
|
351
|
+
),
|
|
352
|
+
// include additional columns
|
|
353
|
+
row_inserted_timestamp: 'current_timestamp()',
|
|
354
|
+
data_is_final: 'data_is_final',
|
|
355
|
+
export_type: 'export_type',
|
|
356
|
+
},
|
|
347
357
|
},
|
|
348
358
|
from: 'event_data',
|
|
349
|
-
|
|
359
|
+
joins: [
|
|
350
360
|
...(itemListSteps ? [{
|
|
361
|
+
type: 'left',
|
|
351
362
|
table: 'item_list_data',
|
|
352
|
-
|
|
363
|
+
on: 'using(_item_list_attribution_row_id)'
|
|
353
364
|
}] : []),
|
|
354
365
|
{
|
|
366
|
+
type: 'left',
|
|
355
367
|
table: 'session_data',
|
|
356
|
-
|
|
368
|
+
on: 'using(session_id)'
|
|
357
369
|
}
|
|
358
370
|
],
|
|
359
371
|
where: helpers.incrementalDateFilter(mergedConfig)
|
package/utils.js
CHANGED
|
@@ -20,15 +20,34 @@ const mergeUniqueArrays = (...arrays) => {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Build SQL query from an array of steps with support for CTEs.
|
|
23
|
-
*
|
|
24
|
-
* Each step
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
23
|
+
*
|
|
24
|
+
* Each step is one of two shapes — structured (clause-keyed) or raw (`{name, query}`).
|
|
25
|
+
*
|
|
26
|
+
* STRUCTURED SHAPE:
|
|
27
|
+
* { name, select, from, joins?, where?, 'group by'?, having?, qualify?, 'order by'?, limit? }
|
|
28
|
+
*
|
|
29
|
+
* - name: CTE name (required for non-final steps)
|
|
30
|
+
* - select: Either a string (raw select list) or { columns?: {alias: expr}, sql?: string }
|
|
31
|
+
* - columns: alias -> expression map. `key === value` skips the alias;
|
|
32
|
+
* keys starting with `[sql]` emit raw values with no alias; `undefined` values are filtered out.
|
|
33
|
+
* - sql: optional raw column-list tail appended after columns
|
|
34
|
+
* - from: Source table/CTE (string)
|
|
35
|
+
* - joins: Either an array of { type, table, on? } or a string fallback
|
|
36
|
+
* - type: 'left', 'inner', 'cross', 'right', 'full'
|
|
37
|
+
* - on omitted for cross joins
|
|
38
|
+
* - where, having, qualify, 'order by', 'group by', limit: string clauses (limit may be a number)
|
|
39
|
+
*
|
|
40
|
+
* Clauses are emitted in canonical SQL order regardless of input key order.
|
|
41
|
+
*
|
|
42
|
+
* RAW SHAPE:
|
|
43
|
+
* { name, query }
|
|
44
|
+
*
|
|
45
|
+
* - name: CTE name
|
|
46
|
+
* - query: Entire CTE body as raw SQL, emitted verbatim
|
|
47
|
+
*
|
|
48
|
+
* Detection: a step is raw iff it has a top-level `query` key. Mixing raw and structured
|
|
49
|
+
* keys within a single step throws.
|
|
50
|
+
*
|
|
32
51
|
* @param {Array<Object>} steps - Array of step objects defining the query structure
|
|
33
52
|
* @returns {string} Generated SQL query
|
|
34
53
|
*/
|
|
@@ -62,7 +81,7 @@ const queryBuilder = (steps) => {
|
|
|
62
81
|
.join('\n');
|
|
63
82
|
};
|
|
64
83
|
|
|
65
|
-
// Helper function to turn step.columns into SQL string
|
|
84
|
+
// Helper function to turn step.select.columns into SQL string
|
|
66
85
|
const columnsToSQL = (columns) => {
|
|
67
86
|
return Object.entries(columns)
|
|
68
87
|
// exclude all columns that have been explicitly set to undefined
|
|
@@ -83,37 +102,115 @@ const queryBuilder = (steps) => {
|
|
|
83
102
|
.join(',\n' + pad);
|
|
84
103
|
};
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
// Renderer for the SELECT clause. Accepts a string (sugar for {sql: <string>})
|
|
106
|
+
// or an object with columns and/or sql fields.
|
|
107
|
+
const renderSelect = (value) => {
|
|
108
|
+
const v = typeof value === 'string' ? { sql: value } : value;
|
|
109
|
+
const hasColumns = v.columns !== undefined && Object.keys(v.columns).length > 0;
|
|
110
|
+
const hasSql = typeof v.sql === 'string' && v.sql.length > 0;
|
|
111
|
+
if (!hasColumns && !hasSql) {
|
|
112
|
+
throw new Error('queryBuilder: select must include at least one of `columns` or `sql`');
|
|
94
113
|
}
|
|
114
|
+
const parts = [];
|
|
115
|
+
if (hasColumns) parts.push(columnsToSQL(v.columns));
|
|
116
|
+
if (hasSql) parts.push(reindent(v.sql, INDENT));
|
|
117
|
+
return `select\n${pad}${parts.join(',\n' + pad)}`;
|
|
118
|
+
};
|
|
95
119
|
|
|
96
|
-
|
|
97
|
-
|
|
120
|
+
// Renderer for the JOINS clause. Accepts an array of {type, table, on}
|
|
121
|
+
// entries (rendered in array order) or a string fallback.
|
|
122
|
+
const renderJoins = (value) => {
|
|
123
|
+
if (typeof value === 'string') {
|
|
124
|
+
return reindent(value, 0);
|
|
98
125
|
}
|
|
126
|
+
return value
|
|
127
|
+
.map(j => j.type === 'cross'
|
|
128
|
+
? `cross join\n${pad}${j.table}`
|
|
129
|
+
: `${j.type} join\n${pad}${j.table} ${j.on}`)
|
|
130
|
+
.join('\n');
|
|
131
|
+
};
|
|
99
132
|
|
|
100
|
-
|
|
101
|
-
|
|
133
|
+
// Renderer factory for inline string clauses (where, having, group by, ...).
|
|
134
|
+
// Coerces non-strings (e.g. limit: 100) via String().
|
|
135
|
+
const renderInline = (keyword) => (value) =>
|
|
136
|
+
`${keyword}\n${pad}${reindent(String(value), INDENT)}`;
|
|
137
|
+
|
|
138
|
+
// Registry of clause renderers. Declaration order is the canonical SQL order
|
|
139
|
+
// — clauses are always emitted in this order regardless of input key order.
|
|
140
|
+
const CLAUSE_RENDERERS = [
|
|
141
|
+
{ key: 'select', render: renderSelect },
|
|
142
|
+
{ key: 'from', render: renderInline('from') },
|
|
143
|
+
{ key: 'joins', render: renderJoins },
|
|
144
|
+
{ key: 'where', render: renderInline('where') },
|
|
145
|
+
{ key: 'group by', render: renderInline('group by') },
|
|
146
|
+
{ key: 'having', render: renderInline('having') },
|
|
147
|
+
{ key: 'qualify', render: renderInline('qualify') },
|
|
148
|
+
{ key: 'order by', render: renderInline('order by') },
|
|
149
|
+
{ key: 'limit', render: renderInline('limit') },
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
const STRUCTURED_KEYS = new Set(['name', ...CLAUSE_RENDERERS.map(c => c.key)]);
|
|
153
|
+
const RAW_KEYS = new Set(['name', 'query']);
|
|
154
|
+
|
|
155
|
+
const validateStep = (step) => {
|
|
156
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) {
|
|
157
|
+
throw new Error(`queryBuilder: each step must be a non-null object, received: ${JSON.stringify(step)}`);
|
|
102
158
|
}
|
|
159
|
+
const isRaw = 'query' in step;
|
|
160
|
+
const allowed = isRaw ? RAW_KEYS : STRUCTURED_KEYS;
|
|
161
|
+
const allowedList = [...allowed].map(k => `\`${k}\``).join(', ');
|
|
162
|
+
const stepLabel = step.name ? `\`${step.name}\`` : '<unnamed>';
|
|
163
|
+
for (const key of Object.keys(step)) {
|
|
164
|
+
if (!allowed.has(key)) {
|
|
165
|
+
if (isRaw && STRUCTURED_KEYS.has(key) && key !== 'name') {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`queryBuilder: step ${stepLabel} has both \`query\` (raw shape) and \`${key}\` (structured key). ` +
|
|
168
|
+
`Raw and structured shapes are mutually exclusive within a single step. ` +
|
|
169
|
+
`Allowed raw-shape keys: ${allowedList}.`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
throw new Error(
|
|
173
|
+
`queryBuilder: unknown key \`${key}\` in ${isRaw ? 'raw' : 'structured'} step ${stepLabel}. ` +
|
|
174
|
+
`Allowed keys: ${allowedList}.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (isRaw) {
|
|
179
|
+
if (typeof step.query !== 'string' || step.query.length === 0) {
|
|
180
|
+
throw new Error(`queryBuilder: raw step ${stepLabel} requires a non-empty \`query\` string`);
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
if (step.select === undefined) {
|
|
184
|
+
throw new Error(`queryBuilder: structured step ${stepLabel} requires \`select\``);
|
|
185
|
+
}
|
|
186
|
+
if (step.from === undefined) {
|
|
187
|
+
throw new Error(`queryBuilder: structured step ${stepLabel} requires \`from\``);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
103
191
|
|
|
104
|
-
|
|
192
|
+
const renderStep = (step) => {
|
|
193
|
+
validateStep(step);
|
|
194
|
+
if ('query' in step) {
|
|
195
|
+
// Raw shape: emit body verbatim, normalized to col 0 of the step.
|
|
196
|
+
return reindent(step.query, 0);
|
|
197
|
+
}
|
|
198
|
+
return CLAUSE_RENDERERS
|
|
199
|
+
.filter(c => step[c.key] !== undefined)
|
|
200
|
+
.map(c => c.render(step[c.key]))
|
|
201
|
+
.join('\n');
|
|
105
202
|
};
|
|
106
203
|
|
|
107
204
|
if (steps.length === 1) {
|
|
108
|
-
return
|
|
205
|
+
return renderStep(steps[0]);
|
|
109
206
|
}
|
|
110
207
|
|
|
111
208
|
const ctes = steps.slice(0, -1).map(step => {
|
|
112
|
-
const body = indentBlock(
|
|
209
|
+
const body = indentBlock(renderStep(step), INDENT);
|
|
113
210
|
return `${step.name} as (\n${body}\n)`;
|
|
114
211
|
});
|
|
115
212
|
const lastStep = steps[steps.length - 1];
|
|
116
|
-
return `with ${ctes.join(',\n')}\n${
|
|
213
|
+
return `with ${ctes.join(',\n')}\n${renderStep(lastStep)}`;
|
|
117
214
|
};
|
|
118
215
|
|
|
119
216
|
/**
|
|
@@ -373,14 +470,14 @@ const mergeDataformTableConfigurations = (defaultConfig, inputConfig = {}) => {
|
|
|
373
470
|
*
|
|
374
471
|
* This utility is helpful when joining tables/CTEs to avoid selecting duplicate or already-present columns.
|
|
375
472
|
*
|
|
376
|
-
* @param {Object} step -
|
|
473
|
+
* @param {Object} step - A queryBuilder structured step containing a `name` (CTE/table alias) and a `select.columns` object.
|
|
377
474
|
* @param {string[]} [alreadyDefinedColumns=[]] - Columns that have already been defined and should be excluded from selection.
|
|
378
475
|
* @param {string[]} [excludedColumns=[]] - Additional columns to explicitly exclude from selection.
|
|
379
476
|
* @returns {string|undefined} A SQL select string (e.g. 'stepName.*' or 'stepName.* except (col1, col2)'), or undefined if all columns are excluded.
|
|
380
477
|
*/
|
|
381
478
|
const selectOtherColumns = (step, alreadyDefinedColumns = [], excludedColumns = []) => {
|
|
382
479
|
const stepName = step.name;
|
|
383
|
-
const stepColumns = Object.keys(step.columns);
|
|
480
|
+
const stepColumns = Object.keys(step.select.columns);
|
|
384
481
|
|
|
385
482
|
// Determine which columns to exclude: those already defined or explicitly excluded
|
|
386
483
|
const exceptColumns = stepColumns.filter(
|