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 CHANGED
@@ -163,7 +163,7 @@ Include the package in the package.json file in your Dataform repository.
163
163
  {
164
164
  "dependencies": {
165
165
  "@dataform/core": "3.0.42",
166
- "ga4-export-fixer": "0.7.0"
166
+ "ga4-export-fixer": "0.7.1"
167
167
  }
168
168
  }
169
169
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ga4-export-fixer",
3
- "version": "0.7.1-dev.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
- columns: {
204
- // exclude default export columns that are not needed
205
- // do this first so that the columns defined later are not excluded
206
- ...getExcludedColumns(),
207
- // date and time
208
- event_date: helpers.eventDate,
209
- event_datetime: `extract(datetime from timestamp_micros(${helpers.getEventTimestampMicros(mergedConfig.customTimestampParam)}) at time zone '${mergedConfig.timezone}')`,
210
- event_timestamp: 'event_timestamp',
211
- event_custom_timestamp: mergedConfig.customTimestampParam ? helpers.getEventTimestampMicros(mergedConfig.customTimestampParam) : undefined,
212
- // event name
213
- event_name: 'event_name',
214
- // identifiers
215
- session_id: helpers.sessionId,
216
- user_pseudo_id: 'user_pseudo_id',
217
- user_id: 'user_id',
218
- // page
219
- page_location: helpers.unnestEventParam('page_location', 'string'),
220
- page: helpers.extractPageDetails(),
221
- // event parameters and user properties
222
- ...promotedEventParameters(),
223
- event_params: helpers.filterEventParams(mergedConfig.excludedEventParams, 'exclude'),
224
- user_properties: 'user_properties',
225
- // traffic source
226
- collected_traffic_source: 'collected_traffic_source',
227
- session_traffic_source_last_click: 'session_traffic_source_last_click',
228
- user_traffic_source: 'traffic_source',
229
- // ecommerce
230
- ecommerce: helpers.fixEcommerceStruct('ecommerce'),
231
- items: 'items',
232
- _item_list_attribution_row_id: itemListAttribution ? helpers.itemListAttributionRowId(ecommerceEventsFilter) : undefined,
233
- // flag if the data is "final" and is not expected to change anymore
234
- data_is_final: helpers.isFinalData(mergedConfig.dataIsFinal.detectionMethod, mergedConfig.dataIsFinal.dayThreshold),
235
- export_type: helpers.getGa4ExportType('_table_suffix'),
236
- // prep columns for later steps
237
- entrances: helpers.unnestEventParam('entrances', 'int'),
238
- session_params_prep: mergedConfig.sessionParams.length > 0 ? helpers.filterEventParams(mergedConfig.sessionParams, 'include') : undefined,
239
- // include all other columns from the export data
240
- get '[sql]other_columns'() {
241
- const definedColumns = Object.keys(this);
242
- return `* except (${definedColumns.filter(column => helpers.isGa4ExportColumn(column)).join(', ')})`;
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
- columns: {
254
- session_id: 'session_id',
255
- user_id: helpers.aggregateValue('user_id', 'last', timestampColumn),
256
- merged_user_id: `ifnull(${helpers.aggregateValue('user_id', 'last', timestampColumn)}, any_value(user_pseudo_id))`,
257
- session_params: helpers.aggregateSessionParams(mergedConfig.sessionParams, 'session_params_prep', timestampColumn),
258
- session_traffic_source_last_click: helpers.aggregateValue('session_traffic_source_last_click', 'first', timestampColumn),
259
- session_first_traffic_source: `array_agg(collected_traffic_source order by ${timestampColumn} limit 1)[safe_offset(0)]`, // don't ignore nulls
260
- landing_page: helpers.aggregateValue(`if(entrances > 0, page, null)`, 'first', timestampColumn),
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
- groupBy: ['session_id']
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
- columns: {
281
- '_item_list_attribution_row_id': '_item_list_attribution_row_id',
282
- 'event_name': 'event_name',
283
- 'item': 'item',
284
- '_item_list_attr': attrExpr,
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
- columns: {
293
- '_item_list_attribution_row_id': '_item_list_attribution_row_id',
294
- 'items': `array_agg(
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
- groupBy: ['_item_list_attribution_row_id'],
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
- columns: {
322
- // get the most important columns in the correct order
323
- ...finalColumnOrder,
324
- ...itemListOverrides,
325
- // get the rest of the event_data columns
326
- '[sql]event_data': utils.selectOtherColumns(
327
- eventDataStep,
328
- Object.keys(finalColumnOrder),
329
- [
330
- 'entrances',
331
- mergedConfig.sessionParams.length > 0 ? 'session_params_prep' : undefined,
332
- 'data_is_final',
333
- 'export_type',
334
- ...itemListExcludedColumns,
335
- ]
336
- ),
337
- // get the rest of the session_data columns
338
- '[sql]session_data': utils.selectOtherColumns(
339
- sessionDataStep,
340
- Object.keys(finalColumnOrder),
341
- []
342
- ),
343
- // include additional columns
344
- row_inserted_timestamp: 'current_timestamp()',
345
- data_is_final: 'data_is_final',
346
- export_type: 'export_type',
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
- leftJoin: [
359
+ joins: [
350
360
  ...(itemListSteps ? [{
361
+ type: 'left',
351
362
  table: 'item_list_data',
352
- condition: 'using(_item_list_attribution_row_id)'
363
+ on: 'using(_item_list_attribution_row_id)'
353
364
  }] : []),
354
365
  {
366
+ type: 'left',
355
367
  table: 'session_data',
356
- condition: 'using(session_id)'
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 should have:
25
- * - name: CTE name (required for CTEs)
26
- * - columns: Object with column definitions { alias: 'expression' }
27
- * - from: Source table/CTE
28
- * - where: Optional WHERE clause
29
- * - groupBy: Optional array of GROUP BY columns
30
- * - leftJoin: Optional array of join definitions { table, condition }
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
- const selectSQL = (step) => {
87
- const parts = [`select\n${pad}${columnsToSQL(step.columns)}`];
88
- parts.push(`from\n${pad}${step.from}`);
89
-
90
- if (step.leftJoin) {
91
- step.leftJoin.forEach(join => {
92
- parts.push(`left join\n${pad}${join.table} ${join.condition}`);
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
- if (step.where) {
97
- parts.push(`where\n${pad}${reindent(step.where, INDENT)}`);
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
- if (step.groupBy) {
101
- parts.push(`group by\n${pad}${step.groupBy.join(', ')}`);
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
- return parts.join('\n');
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 selectSQL(steps[0]);
205
+ return renderStep(steps[0]);
109
206
  }
110
207
 
111
208
  const ctes = steps.slice(0, -1).map(step => {
112
- const body = indentBlock(selectSQL(step), INDENT);
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${selectSQL(lastStep)}`;
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 - The step object containing a `name` (CTE/table alias) and a `columns` object.
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(