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/README.md +703 -665
- package/package.json +4 -2
- package/tables/ga4EventsEnhanced/config.js +73 -70
- package/tables/ga4EventsEnhanced/index.js +124 -93
- package/tables/ga4EventsEnhanced/validation.js +234 -209
- package/utils.js +125 -28
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(
|