ga4-export-fixer 0.7.1 → 0.8.0-dev.2

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/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(