ga4-export-fixer 0.1.6-dev.0 → 0.1.6-dev.10
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 +58 -0
- package/defaultConfig.js +85 -0
- package/helpers.js +36 -7
- package/index.js +13 -14
- package/inputValidation.js +267 -0
- package/package.json +5 -2
- package/preOperations.js +1 -1
- package/tables/ga4EventsEnhanced.js +5 -64
- package/utils.js +1 -243
package/README.md
CHANGED
|
@@ -220,6 +220,64 @@ All fields are optional except `sourceTable`. Default values are applied automat
|
|
|
220
220
|
|
|
221
221
|
Date fields (`dateRangeStart`, `dateRangeEnd`, etc.) accept string dates in `YYYYMMDD` or `YYYY-MM-DD` format, or BigQuery SQL expressions (e.g. `'current_date()'`, `'date(2026, 1, 1)'`).
|
|
222
222
|
|
|
223
|
+
### Building on top of the ga4_events_enhanced table
|
|
224
|
+
|
|
225
|
+
**`definitions/ga4/ga4_sessions.sqlx`**
|
|
226
|
+
```javascript
|
|
227
|
+
config {
|
|
228
|
+
type: "incremental",
|
|
229
|
+
description: "GA4 sessions table",
|
|
230
|
+
schema: "ga4_export_fixer",
|
|
231
|
+
bigquery: {
|
|
232
|
+
partitionBy: "event_date",
|
|
233
|
+
clusterBy: ['session_id', 'data_is_final'],
|
|
234
|
+
},
|
|
235
|
+
tags: ['ga4_export_fixer']
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
js {
|
|
239
|
+
const { setPreOperations, helpers } = require('ga4-export-fixer');
|
|
240
|
+
|
|
241
|
+
const config = {
|
|
242
|
+
self: self(),
|
|
243
|
+
incremental: incremental(),
|
|
244
|
+
test: false,
|
|
245
|
+
testConfig: {
|
|
246
|
+
dateRangeStart: 'current_date()-1',
|
|
247
|
+
dateRangeEnd: 'current_date()',
|
|
248
|
+
},
|
|
249
|
+
preOperations: {
|
|
250
|
+
dateRangeStartFullRefresh: 'date(2000, 1, 1)',
|
|
251
|
+
dateRangeEnd: 'current_date()',
|
|
252
|
+
//incrementalStartOverride: undefined,
|
|
253
|
+
//incrementalEndOverride: undefined,
|
|
254
|
+
//numberOfPreviousDaysToScan: 10,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
select
|
|
260
|
+
event_date,
|
|
261
|
+
session_id,
|
|
262
|
+
user_pseudo_id,
|
|
263
|
+
any_value(session_traffic_source_last_click.cross_channel_campaign) as session_traffic_source,
|
|
264
|
+
any_value(landing_page) as landing_page,
|
|
265
|
+
current_datetime() as row_inserted_timestamp,
|
|
266
|
+
min(data_is_final) as data_is_final
|
|
267
|
+
from
|
|
268
|
+
${ref('ga4_events_enhanced_298233330')}
|
|
269
|
+
where
|
|
270
|
+
${helpers.incrementalDateFilter(config)}
|
|
271
|
+
group by
|
|
272
|
+
event_date,
|
|
273
|
+
session_id,
|
|
274
|
+
user_pseudo_id
|
|
275
|
+
|
|
276
|
+
pre_operations {
|
|
277
|
+
${setPreOperations(config)}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
223
281
|
### Helpers
|
|
224
282
|
|
|
225
283
|
The helpers contain templates for common SQL expressions. The functions are referenced by **ga4EventsEnhanced** but can also be imported as utility functions for working with GA4 data.
|
package/defaultConfig.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/*
|
|
2
|
+
These are the configuration defaults that can be extended.
|
|
3
|
+
|
|
4
|
+
For example, load the defaults in ga4EventsEnhanced.js and then extend them with whatever is psecific to the table.
|
|
5
|
+
After that, extend the configuration further with the user's configuration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
The base configuration. Input config validation should always check these fields.
|
|
10
|
+
*/
|
|
11
|
+
const baseConfig = {
|
|
12
|
+
self: undefined,
|
|
13
|
+
incremental: undefined,
|
|
14
|
+
test: false,
|
|
15
|
+
testConfig: {
|
|
16
|
+
dateRangeStart: 'current_date()-1',
|
|
17
|
+
dateRangeEnd: 'current_date()',
|
|
18
|
+
},
|
|
19
|
+
preOperations: {
|
|
20
|
+
dateRangeStartFullRefresh: 'date(2000, 1, 1)',
|
|
21
|
+
dateRangeEnd: 'current_date()',
|
|
22
|
+
// incrementalStartOverride and incrementalEndOverride are used to override the date range start and end for incremental refresh
|
|
23
|
+
// this is useful if you want to re-process only a specific date range
|
|
24
|
+
incrementalStartOverride: undefined,
|
|
25
|
+
incrementalEndOverride: undefined,
|
|
26
|
+
numberOfPreviousDaysToScan: 10,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
The default configuration for the GA4 Events Enhanced table.
|
|
32
|
+
*/
|
|
33
|
+
const ga4EventsEnhancedConfig = {
|
|
34
|
+
...baseConfig,
|
|
35
|
+
sourceTable: undefined,
|
|
36
|
+
sourceTableType: 'GA4_EXPORT', // used with pre operations to detect if ga4 export specific pre operations are needed
|
|
37
|
+
// optional but recommended
|
|
38
|
+
schemaLock: undefined,
|
|
39
|
+
// only used with js tables
|
|
40
|
+
// dataformTableConfig: {},
|
|
41
|
+
// optional
|
|
42
|
+
includedExportTypes: {
|
|
43
|
+
daily: true,
|
|
44
|
+
intraday: true,
|
|
45
|
+
fresh: false,
|
|
46
|
+
},
|
|
47
|
+
timezone: 'Etc/UTC',
|
|
48
|
+
customTimestampParam: undefined,
|
|
49
|
+
dataIsFinal: {
|
|
50
|
+
detectionMethod: 'EXPORT_TYPE', // or 'DAY_THRESHOLD'
|
|
51
|
+
dayThreshold: 4 // only used if detectionMethod is 'DAY_THRESHOLD'
|
|
52
|
+
},
|
|
53
|
+
// number of additional days to take in for taking into account sessions that overlap days
|
|
54
|
+
bufferDays: 1,
|
|
55
|
+
// these parameters are excluded by default because they've been made available in other columns
|
|
56
|
+
defaultExcludedEventParams: [
|
|
57
|
+
'page_location',
|
|
58
|
+
'ga_session_id',
|
|
59
|
+
//'custom_event_timestamp', // removed if customTimestampParam is used
|
|
60
|
+
],
|
|
61
|
+
excludedEventParams: [],
|
|
62
|
+
eventParamsToColumns: [
|
|
63
|
+
//{name: 'page_location', type: 'string', columnName: 'page_location2'},
|
|
64
|
+
],
|
|
65
|
+
sessionParams: [],
|
|
66
|
+
defaultExcludedEvents: [],
|
|
67
|
+
// session_start and first_visit are excluded via the excludedEvents array
|
|
68
|
+
// this allows the user to include them if needed
|
|
69
|
+
excludedEvents: [
|
|
70
|
+
'session_start',
|
|
71
|
+
'first_visit'
|
|
72
|
+
],
|
|
73
|
+
defaultExcludedColumns: [
|
|
74
|
+
'event_dimensions', // legacy column, not needed
|
|
75
|
+
'traffic_source', // renamed to user_traffic_source
|
|
76
|
+
'session_id'
|
|
77
|
+
],
|
|
78
|
+
// exclude these columns when extracting raw data from the export tables
|
|
79
|
+
excludedColumns: [],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
baseConfig,
|
|
84
|
+
ga4EventsEnhancedConfig,
|
|
85
|
+
};
|
package/helpers.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const constants = require('./constants');
|
|
2
|
+
const { baseConfig } = require('./defaultConfig');
|
|
2
3
|
|
|
3
4
|
/*
|
|
4
5
|
Unnesting parameters
|
|
@@ -248,19 +249,29 @@ const ga4ExportDateFilters = (config) => {
|
|
|
248
249
|
* @param {Object} [config.preOperations] - Contains full refresh date range values.
|
|
249
250
|
* @returns {string} - SQL condition string to filter the query by date range.
|
|
250
251
|
*/
|
|
251
|
-
const
|
|
252
|
+
const incrementalDateFilter = (config) => {
|
|
252
253
|
const setDateRange = (start, end) => {
|
|
253
254
|
return `(event_date >= ${start} and event_date <= ${end})`;
|
|
254
255
|
};
|
|
255
256
|
|
|
257
|
+
// test mode
|
|
256
258
|
if (config.test) {
|
|
257
|
-
|
|
259
|
+
const testStart = config?.testConfig?.dateRangeStart || baseConfig.testConfig.dateRangeStart;
|
|
260
|
+
const testEnd = config?.testConfig?.dateRangeEnd || baseConfig.testConfig.dateRangeEnd;
|
|
261
|
+
|
|
262
|
+
return setDateRange(testStart, testEnd);
|
|
258
263
|
}
|
|
264
|
+
|
|
265
|
+
// incremental mode
|
|
259
266
|
if (config.incremental) {
|
|
260
267
|
return setDateRange(constants.DATE_RANGE_START_VARIABLE, constants.DATE_RANGE_END_VARIABLE);
|
|
261
268
|
}
|
|
262
|
-
|
|
263
|
-
|
|
269
|
+
|
|
270
|
+
// full refresh mode
|
|
271
|
+
const fullRefreshStart = config?.preOperations?.dateRangeStartFullRefresh || baseConfig.preOperations.dateRangeStartFullRefresh;
|
|
272
|
+
const fullRefreshEnd = config?.preOperations?.dateRangeEnd || baseConfig.preOperations.dateRangeEnd;
|
|
273
|
+
|
|
274
|
+
return setDateRange(fullRefreshStart, fullRefreshEnd);
|
|
264
275
|
};
|
|
265
276
|
|
|
266
277
|
/*
|
|
@@ -552,8 +563,14 @@ Aggregation
|
|
|
552
563
|
* // => SQL expression for the last user_id by event_timestamp.
|
|
553
564
|
*/
|
|
554
565
|
const aggregateValue = (column, aggregateType, timestampColumn) => {
|
|
555
|
-
if (typeof column === 'undefined'
|
|
556
|
-
throw new Error("aggregateValue: 'column'
|
|
566
|
+
if (typeof column === 'undefined') {
|
|
567
|
+
throw new Error("aggregateValue: 'column' is a required parameter and must be defined.");
|
|
568
|
+
}
|
|
569
|
+
if (typeof aggregateType === 'undefined') {
|
|
570
|
+
throw new Error("aggregateValue: 'aggregateType' is a required parameter and must be defined.");
|
|
571
|
+
}
|
|
572
|
+
if ((aggregateType === 'first' || aggregateType === 'last') && typeof timestampColumn === 'undefined') {
|
|
573
|
+
throw new Error(`aggregateValue: 'timestampColumn' is required when aggregateType is '${aggregateType}'.`);
|
|
557
574
|
}
|
|
558
575
|
|
|
559
576
|
if (aggregateType === 'max') {
|
|
@@ -587,6 +604,17 @@ const aggregateValue = (column, aggregateType, timestampColumn) => {
|
|
|
587
604
|
throw new Error(`aggregateValue: Unsupported aggregateType '${aggregateType}'. Supported values are 'max', 'min', 'first', 'last', and 'any'.`);
|
|
588
605
|
};
|
|
589
606
|
|
|
607
|
+
// perform aggregations on an array of values
|
|
608
|
+
const aggregateValues = (values) => {
|
|
609
|
+
if (Array.isArray(values)) {
|
|
610
|
+
return values.map(value => {
|
|
611
|
+
const sqlExpression = aggregateValue(value.column, value.aggregateType, value.timestampColumn)
|
|
612
|
+
return `${sqlExpression}${value.alias ? ` as ${value.alias}` : ''}`;
|
|
613
|
+
}).join(',\n ');
|
|
614
|
+
}
|
|
615
|
+
throw new Error("aggregateValues: 'values' must be an array of objects with 'column', 'aggregateType', and 'timestampColumn' properties.");
|
|
616
|
+
};
|
|
617
|
+
|
|
590
618
|
/*
|
|
591
619
|
Ecommerce
|
|
592
620
|
*/
|
|
@@ -737,6 +765,7 @@ module.exports = {
|
|
|
737
765
|
unnestEventParam,
|
|
738
766
|
sessionId,
|
|
739
767
|
aggregateValue,
|
|
768
|
+
aggregateValues,
|
|
740
769
|
fixEcommerceStruct,
|
|
741
770
|
isFinalData,
|
|
742
771
|
ga4ExportDateFilter,
|
|
@@ -744,7 +773,7 @@ module.exports = {
|
|
|
744
773
|
filterEventParams,
|
|
745
774
|
aggregateSessionParams,
|
|
746
775
|
excludeNullSessionParams,
|
|
747
|
-
|
|
776
|
+
incrementalDateFilter,
|
|
748
777
|
extractPageDetails,
|
|
749
778
|
extractUrlHostname,
|
|
750
779
|
extractUrlPath,
|
package/index.js
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
const helpers = require('./helpers.js');
|
|
2
2
|
const ga4EventsEnhanced = require('./tables/ga4EventsEnhanced.js');
|
|
3
3
|
const preOperations = require('./preOperations.js');
|
|
4
|
+
const { validateBaseConfig, validateEnhancedEventsConfig } = require('./inputValidation.js');
|
|
5
|
+
const { mergeSQLConfigurations } = require('./utils.js');
|
|
6
|
+
const { baseConfig } = require('./defaultConfig.js');
|
|
4
7
|
|
|
5
8
|
// export setPreOperations with default configuration for usage with downstream tables
|
|
6
|
-
const setPreOperations = (
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
dateRangeEnd: 'current_date()',
|
|
10
|
-
numberOfPreviousDaysToScan: 10,
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
const config = {
|
|
14
|
-
self,
|
|
15
|
-
incremental,
|
|
16
|
-
preOperations: {...defaultPreOperationsConfig, ...preOperationsConfig}
|
|
17
|
-
};
|
|
9
|
+
const setPreOperations = (config) => {
|
|
10
|
+
// merge the input config with the defaults
|
|
11
|
+
const mergedConfig = mergeSQLConfigurations(baseConfig, config);
|
|
18
12
|
|
|
19
|
-
|
|
13
|
+
// do input validation on the merged config
|
|
14
|
+
validateBaseConfig(mergedConfig);
|
|
15
|
+
|
|
16
|
+
return preOperations.setPreOperations(mergedConfig);
|
|
20
17
|
};
|
|
21
18
|
|
|
22
19
|
module.exports = {
|
|
23
20
|
helpers,
|
|
24
21
|
ga4EventsEnhanced,
|
|
25
|
-
setPreOperations
|
|
22
|
+
setPreOperations,
|
|
23
|
+
validateBaseConfig,
|
|
24
|
+
validateEnhancedEventsConfig
|
|
26
25
|
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
const { isDataformTableReferenceObject } = require('./utils.js');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates the base configuration fields shared across all table types.
|
|
5
|
+
* These correspond to the fields defined in baseConfig (defaultConfig.js):
|
|
6
|
+
* self, incremental, test, testConfig, and preOperations.
|
|
7
|
+
*
|
|
8
|
+
* @param {Object} config - The merged configuration object to validate.
|
|
9
|
+
* @throws {Error} If any base configuration value is invalid or missing.
|
|
10
|
+
*/
|
|
11
|
+
const validateBaseConfig = (config) => {
|
|
12
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
13
|
+
throw new Error(`config must be a non-null object. Received: ${JSON.stringify(config)}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// self - required, must be valid format
|
|
17
|
+
if (config.test !== true) {
|
|
18
|
+
if (typeof config.self !== 'string' || !config.self.trim() || !/^`[^`]+`$/.test(config.self.trim())) {
|
|
19
|
+
throw new Error(`config.self is required when config.test !== true and must be a non-empty string in format '\`project.dataset.table\`' (using the ref() function). Received: ${JSON.stringify(config.self)}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// incremental - required, must be boolean
|
|
24
|
+
if (typeof config.incremental !== 'boolean') {
|
|
25
|
+
throw new Error(`config.incremental must be a boolean. Received: ${JSON.stringify(config.incremental)}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// test - optional; when defined, must be a boolean
|
|
29
|
+
if (typeof config.test !== 'undefined' && typeof config.test !== 'boolean') {
|
|
30
|
+
throw new Error(`config.test must be a boolean when defined. Received: ${JSON.stringify(config.test)}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// testConfig - optional; when included, must be an object with optional dateRangeStart and dateRangeEnd
|
|
34
|
+
if (typeof config.testConfig !== 'undefined') {
|
|
35
|
+
if (!config.testConfig || typeof config.testConfig !== 'object' || Array.isArray(config.testConfig)) {
|
|
36
|
+
throw new Error(`config.testConfig must be an object when included. Received: ${JSON.stringify(config.testConfig)}`);
|
|
37
|
+
}
|
|
38
|
+
if (config.testConfig.dateRangeStart !== undefined && (typeof config.testConfig.dateRangeStart !== 'string' || !config.testConfig.dateRangeStart.trim())) {
|
|
39
|
+
throw new Error(`config.testConfig.dateRangeStart must be a non-empty string (SQL date expression) when provided. Received: ${JSON.stringify(config.testConfig.dateRangeStart)}`);
|
|
40
|
+
}
|
|
41
|
+
if (config.testConfig.dateRangeEnd !== undefined && (typeof config.testConfig.dateRangeEnd !== 'string' || !config.testConfig.dateRangeEnd.trim())) {
|
|
42
|
+
throw new Error(`config.testConfig.dateRangeEnd must be a non-empty string (SQL date expression) when provided. Received: ${JSON.stringify(config.testConfig.dateRangeEnd)}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// preOperations - required
|
|
47
|
+
if (config.preOperations === undefined) {
|
|
48
|
+
throw new Error("config.preOperations is required.");
|
|
49
|
+
}
|
|
50
|
+
if (!config.preOperations || typeof config.preOperations !== 'object' || Array.isArray(config.preOperations)) {
|
|
51
|
+
throw new Error(`config.preOperations must be an object. Received: ${JSON.stringify(config.preOperations)}`);
|
|
52
|
+
}
|
|
53
|
+
if (config.preOperations.numberOfPreviousDaysToScan === undefined) {
|
|
54
|
+
throw new Error("config.preOperations.numberOfPreviousDaysToScan is required.");
|
|
55
|
+
}
|
|
56
|
+
const v = config.preOperations.numberOfPreviousDaysToScan;
|
|
57
|
+
if (typeof v !== 'number' || isNaN(v) || !Number.isInteger(v) || v < 0) {
|
|
58
|
+
throw new Error(`config.preOperations.numberOfPreviousDaysToScan must be a non-negative integer. Received: ${JSON.stringify(v)}`);
|
|
59
|
+
}
|
|
60
|
+
if (config.preOperations.dateRangeStartFullRefresh === undefined || config.preOperations.dateRangeStartFullRefresh === null) {
|
|
61
|
+
throw new Error("config.preOperations.dateRangeStartFullRefresh is required.");
|
|
62
|
+
}
|
|
63
|
+
if (typeof config.preOperations.dateRangeStartFullRefresh !== 'string' || !config.preOperations.dateRangeStartFullRefresh.trim()) {
|
|
64
|
+
throw new Error(`config.preOperations.dateRangeStartFullRefresh must be a non-empty string (SQL date expression). Received: ${JSON.stringify(config.preOperations.dateRangeStartFullRefresh)}`);
|
|
65
|
+
}
|
|
66
|
+
if (config.preOperations.dateRangeEnd === undefined || config.preOperations.dateRangeEnd === null) {
|
|
67
|
+
throw new Error("config.preOperations.dateRangeEnd is required.");
|
|
68
|
+
}
|
|
69
|
+
if (typeof config.preOperations.dateRangeEnd !== 'string' || !config.preOperations.dateRangeEnd.trim()) {
|
|
70
|
+
throw new Error(`config.preOperations.dateRangeEnd must be a non-empty string (SQL date expression). Received: ${JSON.stringify(config.preOperations.dateRangeEnd)}`);
|
|
71
|
+
}
|
|
72
|
+
if (config.preOperations.incrementalStartOverride !== undefined && config.preOperations.incrementalStartOverride !== null && config.preOperations.incrementalStartOverride !== '') {
|
|
73
|
+
if (typeof config.preOperations.incrementalStartOverride !== 'string' || !config.preOperations.incrementalStartOverride.trim()) {
|
|
74
|
+
throw new Error(`config.preOperations.incrementalStartOverride must be a non-empty string when provided. Received: ${JSON.stringify(config.preOperations.incrementalStartOverride)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (config.preOperations.incrementalEndOverride !== undefined && config.preOperations.incrementalEndOverride !== null && config.preOperations.incrementalEndOverride !== '') {
|
|
78
|
+
if (typeof config.preOperations.incrementalEndOverride !== 'string' || !config.preOperations.incrementalEndOverride.trim()) {
|
|
79
|
+
throw new Error(`config.preOperations.incrementalEndOverride must be a non-empty string when provided. Received: ${JSON.stringify(config.preOperations.incrementalEndOverride)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validates a GA4 export fixer configuration object.
|
|
86
|
+
* Validation is performed on mergedConfig (default values merged with user input).
|
|
87
|
+
* All fields are required in the merged config; optional fields are only optional for user input
|
|
88
|
+
* and receive their values from the default configuration during merge.
|
|
89
|
+
*
|
|
90
|
+
* @param {Object} config - The merged configuration object to validate.
|
|
91
|
+
* @throws {Error} If any configuration value is invalid or missing.
|
|
92
|
+
*/
|
|
93
|
+
const validateEnhancedEventsConfig = (config) => {
|
|
94
|
+
try {
|
|
95
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
96
|
+
throw new Error(`config must be a non-null object. Received: ${JSON.stringify(config)}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// base config fields (self, incremental, test, testConfig, preOperations)
|
|
100
|
+
validateBaseConfig(config);
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
Rest of the validations are related to ga4_events_enhanced table specific fields
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
// sourceTable - required; string or Dataform table reference
|
|
107
|
+
if (config.sourceTable === undefined || config.sourceTable === null) {
|
|
108
|
+
throw new Error("config.sourceTable is required. Provide a Dataform table reference (using the ref() function) or a string in format '`project.dataset.table`'.");
|
|
109
|
+
}
|
|
110
|
+
if (isDataformTableReferenceObject(config.sourceTable)) {
|
|
111
|
+
// Valid Dataform reference
|
|
112
|
+
} else if (typeof config.sourceTable === 'string') {
|
|
113
|
+
if (!config.sourceTable.trim()) {
|
|
114
|
+
throw new Error("config.sourceTable must be a non-empty string. Received empty string.");
|
|
115
|
+
}
|
|
116
|
+
if (!/^`[^\.]+\.[^\.]+\.[^\.]+`$/.test(config.sourceTable.trim())) {
|
|
117
|
+
throw new Error(`config.sourceTable must be in the format '\`project.dataset.table\`' (with backticks). Received: ${JSON.stringify(config.sourceTable)}`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`config.sourceTable must be a Dataform table reference object or a string in format '\`project.dataset.table\`'. Received: ${JSON.stringify(config.sourceTable)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// schemaLock - optional; must be undefined or a string in "YYYYMMDD" format (e.g., "20260101")
|
|
124
|
+
if (typeof config.schemaLock !== 'undefined') {
|
|
125
|
+
if (typeof config.schemaLock !== 'string' || !/^\d{8}$/.test(config.schemaLock)) {
|
|
126
|
+
throw new Error(`config.schemaLock must be a string in "YYYYMMDD" format (e.g., "20260101"). Received: ${JSON.stringify(config.schemaLock)}`);
|
|
127
|
+
}
|
|
128
|
+
// Must be a valid date
|
|
129
|
+
const year = parseInt(config.schemaLock.slice(0, 4), 10);
|
|
130
|
+
const month = parseInt(config.schemaLock.slice(4, 6), 10);
|
|
131
|
+
const day = parseInt(config.schemaLock.slice(6, 8), 10);
|
|
132
|
+
const date = new Date(year, month - 1, day);
|
|
133
|
+
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
134
|
+
throw new Error(`config.schemaLock must be a valid date. Received: ${JSON.stringify(config.schemaLock)}`);
|
|
135
|
+
}
|
|
136
|
+
// Must be at least 20241009
|
|
137
|
+
if (config.schemaLock < "20241009") {
|
|
138
|
+
throw new Error(`config.schemaLock must be a date string equal to or greater than "20241009". Received: ${JSON.stringify(config.schemaLock)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// includedExportTypes - required
|
|
143
|
+
if (typeof config.includedExportTypes === 'undefined') {
|
|
144
|
+
throw new Error("config.includedExportTypes is required.");
|
|
145
|
+
}
|
|
146
|
+
if (!config.includedExportTypes || typeof config.includedExportTypes !== 'object' || Array.isArray(config.includedExportTypes)) {
|
|
147
|
+
throw new Error(`config.includedExportTypes must be an object. Received: ${JSON.stringify(config.includedExportTypes)}`);
|
|
148
|
+
}
|
|
149
|
+
for (const key of ['daily', 'intraday']) {
|
|
150
|
+
if (!(key in config.includedExportTypes)) {
|
|
151
|
+
throw new Error(`config.includedExportTypes.${key} is required.`);
|
|
152
|
+
}
|
|
153
|
+
if (typeof config.includedExportTypes[key] !== 'boolean') {
|
|
154
|
+
throw new Error(`config.includedExportTypes.${key} must be a boolean. Received: ${JSON.stringify(config.includedExportTypes[key])}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!config.includedExportTypes.daily && !config.includedExportTypes.intraday) {
|
|
158
|
+
throw new Error("At least one of config.includedExportTypes.daily or config.includedExportTypes.intraday must be true.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// timezone - required
|
|
162
|
+
if (typeof config.timezone === 'undefined') {
|
|
163
|
+
throw new Error("config.timezone is required.");
|
|
164
|
+
}
|
|
165
|
+
if (typeof config.timezone !== 'string' || !config.timezone.trim()) {
|
|
166
|
+
throw new Error(`config.timezone must be a non-empty string (e.g. 'Etc/UTC', 'Europe/Helsinki'). Received: ${JSON.stringify(config.timezone)}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// customTimestampParam - optional; must be undefined or a non-empty string
|
|
170
|
+
if (typeof config.customTimestampParam !== 'undefined') {
|
|
171
|
+
if (typeof config.customTimestampParam !== 'string' || !config.customTimestampParam.trim()) {
|
|
172
|
+
throw new Error(`config.customTimestampParam must be a non-empty string when provided. Received: ${JSON.stringify(config.customTimestampParam)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// dataIsFinal - required
|
|
177
|
+
if (typeof config.dataIsFinal === 'undefined') {
|
|
178
|
+
throw new Error("config.dataIsFinal is required.");
|
|
179
|
+
}
|
|
180
|
+
if (typeof config.dataIsFinal !== 'object' || Array.isArray(config.dataIsFinal)) {
|
|
181
|
+
throw new Error(`config.dataIsFinal must be an object. Received: ${JSON.stringify(config.dataIsFinal)}`);
|
|
182
|
+
}
|
|
183
|
+
if (typeof config.dataIsFinal.detectionMethod === 'undefined') {
|
|
184
|
+
throw new Error("config.dataIsFinal.detectionMethod is required.");
|
|
185
|
+
}
|
|
186
|
+
if (typeof config.dataIsFinal.detectionMethod !== 'string' || (config.dataIsFinal.detectionMethod !== 'EXPORT_TYPE' && config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD')) {
|
|
187
|
+
throw new Error(`config.dataIsFinal.detectionMethod must be 'EXPORT_TYPE' or 'DAY_THRESHOLD'. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
|
|
191
|
+
typeof config.dataIsFinal.dayThreshold === 'undefined'
|
|
192
|
+
) {
|
|
193
|
+
throw new Error("config.dataIsFinal.dayThreshold is required when detectionMethod is 'DAY_THRESHOLD'.");
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
|
|
197
|
+
(typeof config.dataIsFinal.dayThreshold !== 'number' || !Number.isInteger(config.dataIsFinal.dayThreshold) || config.dataIsFinal.dayThreshold < 0)
|
|
198
|
+
) {
|
|
199
|
+
throw new Error(`config.dataIsFinal.dayThreshold must be a non-negative integer. Received: ${JSON.stringify(config.dataIsFinal.dayThreshold)}`);
|
|
200
|
+
}
|
|
201
|
+
// EXPORT_TYPE detection relies on daily export metadata; intraday-only requires DAY_THRESHOLD instead.
|
|
202
|
+
if (
|
|
203
|
+
config.includedExportTypes.intraday &&
|
|
204
|
+
!config.includedExportTypes.daily &&
|
|
205
|
+
config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD'
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(`config.dataIsFinal.detectionMethod must be 'DAY_THRESHOLD' when only intraday export is enabled (config.includedExportTypes.daily is false). A dayThreshold of 1 is recommended for intraday-only configurations. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// bufferDays - required
|
|
211
|
+
if (typeof config.bufferDays !== 'number' || !Number.isInteger(config.bufferDays) || config.bufferDays < 0) {
|
|
212
|
+
throw new Error(`config.bufferDays must be a non-negative integer. Received: ${JSON.stringify(config.bufferDays)}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Array fields - all required
|
|
216
|
+
const stringArrayKeys = ['defaultExcludedEventParams', 'excludedEventParams', 'sessionParams', 'defaultExcludedEvents', 'excludedEvents', 'excludedColumns'];
|
|
217
|
+
for (const key of stringArrayKeys) {
|
|
218
|
+
if (config[key] === undefined) {
|
|
219
|
+
throw new Error(`config.${key} is required.`);
|
|
220
|
+
}
|
|
221
|
+
if (!Array.isArray(config[key])) {
|
|
222
|
+
throw new Error(`config.${key} must be an array. Received: ${JSON.stringify(config[key])}`);
|
|
223
|
+
}
|
|
224
|
+
for (let i = 0; i < config[key].length; i++) {
|
|
225
|
+
if (typeof config[key][i] !== 'string' || !config[key][i].trim()) {
|
|
226
|
+
throw new Error(`config.${key}[${i}] must be a non-empty string. Received: ${JSON.stringify(config[key][i])}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// eventParamsToColumns - required
|
|
232
|
+
if (config.eventParamsToColumns === undefined) {
|
|
233
|
+
throw new Error("config.eventParamsToColumns is required.");
|
|
234
|
+
}
|
|
235
|
+
if (!Array.isArray(config.eventParamsToColumns)) {
|
|
236
|
+
throw new Error(`config.eventParamsToColumns must be an array. Received: ${JSON.stringify(config.eventParamsToColumns)}`);
|
|
237
|
+
}
|
|
238
|
+
const validEventParamTypes = ['string', 'int', 'int64', 'double', 'float', 'float64'];
|
|
239
|
+
for (let i = 0; i < config.eventParamsToColumns.length; i++) {
|
|
240
|
+
const item = config.eventParamsToColumns[i];
|
|
241
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
242
|
+
throw new Error(`config.eventParamsToColumns[${i}] must be an object with 'name' and 'type' properties. Received: ${JSON.stringify(item)}`);
|
|
243
|
+
}
|
|
244
|
+
if (!item.name || typeof item.name !== 'string' || !item.name.trim()) {
|
|
245
|
+
throw new Error(`config.eventParamsToColumns[${i}].name must be a non-empty string. Received: ${JSON.stringify(item.name)}`);
|
|
246
|
+
}
|
|
247
|
+
if (item.type !== undefined && item.type !== null) {
|
|
248
|
+
if (!validEventParamTypes.includes(item.type)) {
|
|
249
|
+
throw new Error(`config.eventParamsToColumns[${i}].type must be one of: ${validEventParamTypes.join(', ')}. Received: ${JSON.stringify(item.type)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (item.columnName !== undefined && item.columnName !== null && item.columnName !== '') {
|
|
253
|
+
if (typeof item.columnName !== 'string' || !item.columnName.trim()) {
|
|
254
|
+
throw new Error(`config.eventParamsToColumns[${i}].columnName must be a non-empty string when provided. Received: ${JSON.stringify(item.columnName)}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch (e) {
|
|
259
|
+
e.message = `Config validation: ${e.message}`;
|
|
260
|
+
throw e;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
module.exports = {
|
|
265
|
+
validateBaseConfig,
|
|
266
|
+
validateEnhancedEventsConfig
|
|
267
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ga4-export-fixer",
|
|
3
|
-
"version": "0.1.6-dev.
|
|
3
|
+
"version": "0.1.6-dev.10",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
"utils.js",
|
|
10
10
|
"preOperations.js",
|
|
11
11
|
"constants.js",
|
|
12
|
-
"tables"
|
|
12
|
+
"tables",
|
|
13
|
+
"inputValidation.js",
|
|
14
|
+
"defaultConfig.js",
|
|
15
|
+
"config.js"
|
|
13
16
|
],
|
|
14
17
|
"scripts": {
|
|
15
18
|
"test": "node tests/ga4EventsEnhanced.test.js",
|
package/preOperations.js
CHANGED
|
@@ -1,72 +1,13 @@
|
|
|
1
1
|
const helpers = require('../helpers.js');
|
|
2
2
|
const utils = require('../utils.js');
|
|
3
|
+
const inputValidation = require('../inputValidation.js');
|
|
3
4
|
const constants = require('../constants.js');
|
|
4
5
|
const preOperations = require('../preOperations.js');
|
|
6
|
+
const { ga4EventsEnhancedConfig } = require('../defaultConfig.js'); // config defaults
|
|
5
7
|
|
|
6
8
|
// default configuration for the GA4 Events Enhanced table
|
|
7
9
|
const defaultConfig = {
|
|
8
|
-
|
|
9
|
-
sourceTable: undefined,
|
|
10
|
-
sourceTableType: 'GA4_EXPORT', // used with pre operations to detect if ga4 export specific pre operations are needed
|
|
11
|
-
self: undefined,
|
|
12
|
-
incremental: undefined,
|
|
13
|
-
// optional but recommended
|
|
14
|
-
schemaLock: undefined,
|
|
15
|
-
// only used with js tables
|
|
16
|
-
// dataformTableConfig: {},
|
|
17
|
-
// optional
|
|
18
|
-
includedExportTypes: {
|
|
19
|
-
daily: true,
|
|
20
|
-
intraday: true,
|
|
21
|
-
fresh: false,
|
|
22
|
-
},
|
|
23
|
-
timezone: 'Etc/UTC',
|
|
24
|
-
customTimestampParam: undefined,
|
|
25
|
-
dataIsFinal: {
|
|
26
|
-
detectionMethod: 'EXPORT_TYPE', // or 'DAY_THRESHOLD'
|
|
27
|
-
dayThreshold: 4 // only used if detectionMethod is 'DAY_THRESHOLD'
|
|
28
|
-
},
|
|
29
|
-
test: false,
|
|
30
|
-
testConfig: {
|
|
31
|
-
dateRangeStart: 'current_date()-1',
|
|
32
|
-
dateRangeEnd: 'current_date()',
|
|
33
|
-
},
|
|
34
|
-
// number of additional days to take in for taking into account sessions that overlap days
|
|
35
|
-
bufferDays: 1,
|
|
36
|
-
preOperations: {
|
|
37
|
-
dateRangeStartFullRefresh: 'date(2000, 1, 1)',
|
|
38
|
-
dateRangeEnd: 'current_date()',
|
|
39
|
-
// incrementalStartOverride and incrementalEndOverride are used to override the date range start and end for incremental refresh
|
|
40
|
-
// this is useful if you want to re-process only a specific date range
|
|
41
|
-
incrementalStartOverride: undefined,
|
|
42
|
-
incrementalEndOverride: undefined,
|
|
43
|
-
numberOfPreviousDaysToScan: 10,
|
|
44
|
-
},
|
|
45
|
-
// these parameters are excluded by default because they've been made available in other columns
|
|
46
|
-
defaultExcludedEventParams: [
|
|
47
|
-
'page_location',
|
|
48
|
-
'ga_session_id',
|
|
49
|
-
//'custom_event_timestamp', // removed if customTimestampParam is used
|
|
50
|
-
],
|
|
51
|
-
excludedEventParams: [],
|
|
52
|
-
eventParamsToColumns: [
|
|
53
|
-
//{name: 'page_location', type: 'string', columnName: 'page_location2'},
|
|
54
|
-
],
|
|
55
|
-
sessionParams: [],
|
|
56
|
-
defaultExcludedEvents: [],
|
|
57
|
-
// session_start and first_visit are excluded via the excludedEvents array
|
|
58
|
-
// this allows the user to include them if needed
|
|
59
|
-
excludedEvents: [
|
|
60
|
-
'session_start',
|
|
61
|
-
'first_visit'
|
|
62
|
-
],
|
|
63
|
-
defaultExcludedColumns: [
|
|
64
|
-
'event_dimensions', // legacy column, not needed
|
|
65
|
-
'traffic_source', // renamed to user_traffic_source
|
|
66
|
-
'session_id'
|
|
67
|
-
],
|
|
68
|
-
// exclude these columns when extracting raw data from the export tables
|
|
69
|
-
excludedColumns: [],
|
|
10
|
+
...ga4EventsEnhancedConfig,
|
|
70
11
|
};
|
|
71
12
|
|
|
72
13
|
// List the columns in the order they should be in the final table
|
|
@@ -207,7 +148,7 @@ const generateEnhancedEventsSQL = (config) => {
|
|
|
207
148
|
const mergedConfig = utils.mergeSQLConfigurations(defaultConfig, config);
|
|
208
149
|
|
|
209
150
|
// validate the config and throw an error if it's invalid
|
|
210
|
-
|
|
151
|
+
inputValidation.validateEnhancedEventsConfig(mergedConfig);
|
|
211
152
|
|
|
212
153
|
if (!mergedConfig.sourceTable || typeof mergedConfig.sourceTable !== 'string' || mergedConfig.sourceTable.trim() === '') {
|
|
213
154
|
throw new Error("generateEnhancedEventsSQL: 'sourceTable' is a required parameter in config and must be a non-empty string.");
|
|
@@ -341,7 +282,7 @@ ${excludedEventsSQL}`,
|
|
|
341
282
|
condition: 'using(session_id)'
|
|
342
283
|
}
|
|
343
284
|
],
|
|
344
|
-
where: helpers.
|
|
285
|
+
where: helpers.incrementalDateFilter(mergedConfig)
|
|
345
286
|
};
|
|
346
287
|
|
|
347
288
|
const steps = [
|
package/utils.js
CHANGED
|
@@ -404,247 +404,6 @@ const processDate = (dateInput) => {
|
|
|
404
404
|
throw new Error(`processDate: Unsupported date input format: ${JSON.stringify(dateInput)}. Expected formats are: YYYYMMDD, YYYY-MM-DD, or BigQuery SQL statement.`);
|
|
405
405
|
};
|
|
406
406
|
|
|
407
|
-
/**
|
|
408
|
-
* Validates a GA4 export fixer configuration object.
|
|
409
|
-
* Validation is performed on mergedConfig (default values merged with user input).
|
|
410
|
-
* All fields are required in the merged config; optional fields are only optional for user input
|
|
411
|
-
* and receive their values from the default configuration during merge.
|
|
412
|
-
*
|
|
413
|
-
* @param {Object} config - The merged configuration object to validate.
|
|
414
|
-
* @throws {Error} If any configuration value is invalid or missing.
|
|
415
|
-
*/
|
|
416
|
-
const validateConfig = (config) => {
|
|
417
|
-
try {
|
|
418
|
-
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
419
|
-
throw new Error(`config must be a non-null object. Received: ${JSON.stringify(config)}`);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// sourceTable - required; string or Dataform table reference
|
|
423
|
-
if (config.sourceTable === undefined || config.sourceTable === null) {
|
|
424
|
-
throw new Error("config.sourceTable is required. Provide a Dataform table reference (using the ref() function) or a string in format '`project.dataset.table`'.");
|
|
425
|
-
}
|
|
426
|
-
if (isDataformTableReferenceObject(config.sourceTable)) {
|
|
427
|
-
// Valid Dataform reference
|
|
428
|
-
} else if (typeof config.sourceTable === 'string') {
|
|
429
|
-
if (!config.sourceTable.trim()) {
|
|
430
|
-
throw new Error("config.sourceTable must be a non-empty string. Received empty string.");
|
|
431
|
-
}
|
|
432
|
-
if (!/^`[^\.]+\.[^\.]+\.[^\.]+`$/.test(config.sourceTable.trim())) {
|
|
433
|
-
throw new Error(`config.sourceTable must be in the format '\`project.dataset.table\`' (with backticks). Received: ${JSON.stringify(config.sourceTable)}`);
|
|
434
|
-
}
|
|
435
|
-
} else {
|
|
436
|
-
throw new Error(`config.sourceTable must be a Dataform table reference object or a string in format '\`project.dataset.table\`'. Received: ${JSON.stringify(config.sourceTable)}`);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// self - required when using Dataform; must be valid format
|
|
440
|
-
// config.self is required when config.test === true and must be a non-empty string in format '\`project.dataset.table\`' (using the ref() function)
|
|
441
|
-
if (config.test !== true) {
|
|
442
|
-
if (typeof config.self !== 'string' || !config.self.trim() || !/^`[^`]+`$/.test(config.self.trim())) {
|
|
443
|
-
throw new Error(`config.self is required when config.test === true and must be a non-empty string in format '\`project.dataset.table\`' (using the ref() function). Received: ${JSON.stringify(config.self)}`);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// incremental - required when using Dataform; must be boolean
|
|
448
|
-
if (typeof config.incremental !== 'boolean') {
|
|
449
|
-
throw new Error(`config.incremental must be a boolean. Received: ${JSON.stringify(config.incremental)}`);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// schemaLock - optional; must be undefined or a string in "YYYYMMDD" format (e.g., "20260101")
|
|
453
|
-
if (typeof config.schemaLock !== 'undefined') {
|
|
454
|
-
if (typeof config.schemaLock !== 'string' || !/^\d{8}$/.test(config.schemaLock)) {
|
|
455
|
-
throw new Error(`config.schemaLock must be a string in "YYYYMMDD" format (e.g., "20260101"). Received: ${JSON.stringify(config.schemaLock)}`);
|
|
456
|
-
}
|
|
457
|
-
// Must be a valid date
|
|
458
|
-
const year = parseInt(config.schemaLock.slice(0, 4), 10);
|
|
459
|
-
const month = parseInt(config.schemaLock.slice(4, 6), 10);
|
|
460
|
-
const day = parseInt(config.schemaLock.slice(6, 8), 10);
|
|
461
|
-
const date = new Date(year, month - 1, day);
|
|
462
|
-
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
|
463
|
-
throw new Error(`config.schemaLock must be a valid date. Received: ${JSON.stringify(config.schemaLock)}`);
|
|
464
|
-
}
|
|
465
|
-
// Must be at least 20241009
|
|
466
|
-
if (config.schemaLock < "20241009") {
|
|
467
|
-
throw new Error(`config.schemaLock must be a date string equal to or greater than "20241009". Received: ${JSON.stringify(config.schemaLock)}`);
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
// includedExportTypes - required
|
|
472
|
-
if (typeof config.includedExportTypes === 'undefined') {
|
|
473
|
-
throw new Error("config.includedExportTypes is required.");
|
|
474
|
-
}
|
|
475
|
-
if (!config.includedExportTypes || typeof config.includedExportTypes !== 'object' || Array.isArray(config.includedExportTypes)) {
|
|
476
|
-
throw new Error(`config.includedExportTypes must be an object. Received: ${JSON.stringify(config.includedExportTypes)}`);
|
|
477
|
-
}
|
|
478
|
-
for (const key of ['daily', 'intraday']) {
|
|
479
|
-
if (!(key in config.includedExportTypes)) {
|
|
480
|
-
throw new Error(`config.includedExportTypes.${key} is required.`);
|
|
481
|
-
}
|
|
482
|
-
if (typeof config.includedExportTypes[key] !== 'boolean') {
|
|
483
|
-
throw new Error(`config.includedExportTypes.${key} must be a boolean. Received: ${JSON.stringify(config.includedExportTypes[key])}`);
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
if (!config.includedExportTypes.daily && !config.includedExportTypes.intraday) {
|
|
487
|
-
throw new Error("At least one of config.includedExportTypes.daily or config.includedExportTypes.intraday must be true.");
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// timezone - required
|
|
491
|
-
if (typeof config.timezone === 'undefined') {
|
|
492
|
-
throw new Error("config.timezone is required.");
|
|
493
|
-
}
|
|
494
|
-
if (typeof config.timezone !== 'string' || !config.timezone.trim()) {
|
|
495
|
-
throw new Error(`config.timezone must be a non-empty string (e.g. 'Etc/UTC', 'Europe/Helsinki'). Received: ${JSON.stringify(config.timezone)}`);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// customTimestampParam - optional; must be undefined or a non-empty string
|
|
499
|
-
if (typeof config.customTimestampParam !== 'undefined') {
|
|
500
|
-
if (typeof config.customTimestampParam !== 'string' || !config.customTimestampParam.trim()) {
|
|
501
|
-
throw new Error(`config.customTimestampParam must be a non-empty string when provided. Received: ${JSON.stringify(config.customTimestampParam)}`);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// dataIsFinal - required
|
|
506
|
-
if (typeof config.dataIsFinal === 'undefined') {
|
|
507
|
-
throw new Error("config.dataIsFinal is required.");
|
|
508
|
-
}
|
|
509
|
-
if (typeof config.dataIsFinal !== 'object' || Array.isArray(config.dataIsFinal)) {
|
|
510
|
-
throw new Error(`config.dataIsFinal must be an object. Received: ${JSON.stringify(config.dataIsFinal)}`);
|
|
511
|
-
}
|
|
512
|
-
if (typeof config.dataIsFinal.detectionMethod === 'undefined') {
|
|
513
|
-
throw new Error("config.dataIsFinal.detectionMethod is required.");
|
|
514
|
-
}
|
|
515
|
-
if (typeof config.dataIsFinal.detectionMethod !== 'string' || (config.dataIsFinal.detectionMethod !== 'EXPORT_TYPE' && config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD')) {
|
|
516
|
-
throw new Error(`config.dataIsFinal.detectionMethod must be 'EXPORT_TYPE' or 'DAY_THRESHOLD'. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
|
|
517
|
-
}
|
|
518
|
-
if (
|
|
519
|
-
config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
|
|
520
|
-
typeof config.dataIsFinal.dayThreshold === 'undefined'
|
|
521
|
-
) {
|
|
522
|
-
throw new Error("config.dataIsFinal.dayThreshold is required when detectionMethod is 'DAY_THRESHOLD'.");
|
|
523
|
-
}
|
|
524
|
-
if (
|
|
525
|
-
config.dataIsFinal.detectionMethod === 'DAY_THRESHOLD' &&
|
|
526
|
-
(typeof config.dataIsFinal.dayThreshold !== 'number' || !Number.isInteger(config.dataIsFinal.dayThreshold) || config.dataIsFinal.dayThreshold < 0)
|
|
527
|
-
) {
|
|
528
|
-
throw new Error(`config.dataIsFinal.dayThreshold must be a non-negative integer. Received: ${JSON.stringify(config.dataIsFinal.dayThreshold)}`);
|
|
529
|
-
}
|
|
530
|
-
// EXPORT_TYPE detection relies on daily export metadata; intraday-only requires DAY_THRESHOLD instead.
|
|
531
|
-
if (
|
|
532
|
-
config.includedExportTypes.intraday &&
|
|
533
|
-
!config.includedExportTypes.daily &&
|
|
534
|
-
config.dataIsFinal.detectionMethod !== 'DAY_THRESHOLD'
|
|
535
|
-
) {
|
|
536
|
-
throw new Error(`config.dataIsFinal.detectionMethod must be 'DAY_THRESHOLD' when only intraday export is enabled (config.includedExportTypes.daily is false). A dayThreshold of 1 is recommended for intraday-only configurations. Received: ${JSON.stringify(config.dataIsFinal.detectionMethod)}`);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
// test - optional; when defined, must be a boolean
|
|
540
|
-
if (typeof config.test !== 'undefined' && typeof config.test !== 'boolean') {
|
|
541
|
-
throw new Error(`config.test must be a boolean when defined. Received: ${JSON.stringify(config.test)}`);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// testConfig - optional; when included, must be an object with optional dateRangeStart and dateRangeEnd
|
|
545
|
-
if (typeof config.testConfig !== 'undefined') {
|
|
546
|
-
if (!config.testConfig || typeof config.testConfig !== 'object' || Array.isArray(config.testConfig)) {
|
|
547
|
-
throw new Error(`config.testConfig must be an object when included. Received: ${JSON.stringify(config.testConfig)}`);
|
|
548
|
-
}
|
|
549
|
-
if (config.testConfig.dateRangeStart !== undefined && (typeof config.testConfig.dateRangeStart !== 'string' || !config.testConfig.dateRangeStart.trim())) {
|
|
550
|
-
throw new Error(`config.testConfig.dateRangeStart must be a non-empty string (SQL date expression) when provided. Received: ${JSON.stringify(config.testConfig.dateRangeStart)}`);
|
|
551
|
-
}
|
|
552
|
-
if (config.testConfig.dateRangeEnd !== undefined && (typeof config.testConfig.dateRangeEnd !== 'string' || !config.testConfig.dateRangeEnd.trim())) {
|
|
553
|
-
throw new Error(`config.testConfig.dateRangeEnd must be a non-empty string (SQL date expression) when provided. Received: ${JSON.stringify(config.testConfig.dateRangeEnd)}`);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// bufferDays - required
|
|
558
|
-
if (typeof config.bufferDays !== 'number' || !Number.isInteger(config.bufferDays) || config.bufferDays < 0) {
|
|
559
|
-
throw new Error(`config.bufferDays must be a non-negative integer. Received: ${JSON.stringify(config.bufferDays)}`);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
// preOperations - required
|
|
563
|
-
if (config.preOperations === undefined) {
|
|
564
|
-
throw new Error("config.preOperations is required.");
|
|
565
|
-
}
|
|
566
|
-
if (!config.preOperations || typeof config.preOperations !== 'object' || Array.isArray(config.preOperations)) {
|
|
567
|
-
throw new Error(`config.preOperations must be an object. Received: ${JSON.stringify(config.preOperations)}`);
|
|
568
|
-
}
|
|
569
|
-
if (config.preOperations.numberOfPreviousDaysToScan === undefined) {
|
|
570
|
-
throw new Error("config.preOperations.numberOfPreviousDaysToScan is required.");
|
|
571
|
-
}
|
|
572
|
-
const v = config.preOperations.numberOfPreviousDaysToScan;
|
|
573
|
-
if (typeof v !== 'number' || isNaN(v) || !Number.isInteger(v) || v < 0) {
|
|
574
|
-
throw new Error(`config.preOperations.numberOfPreviousDaysToScan must be a non-negative integer. Received: ${JSON.stringify(v)}`);
|
|
575
|
-
}
|
|
576
|
-
if (config.preOperations.dateRangeStartFullRefresh === undefined || config.preOperations.dateRangeStartFullRefresh === null) {
|
|
577
|
-
throw new Error("config.preOperations.dateRangeStartFullRefresh is required.");
|
|
578
|
-
}
|
|
579
|
-
if (typeof config.preOperations.dateRangeStartFullRefresh !== 'string' || !config.preOperations.dateRangeStartFullRefresh.trim()) {
|
|
580
|
-
throw new Error(`config.preOperations.dateRangeStartFullRefresh must be a non-empty string (SQL date expression). Received: ${JSON.stringify(config.preOperations.dateRangeStartFullRefresh)}`);
|
|
581
|
-
}
|
|
582
|
-
if (config.preOperations.dateRangeEnd === undefined || config.preOperations.dateRangeEnd === null) {
|
|
583
|
-
throw new Error("config.preOperations.dateRangeEnd is required.");
|
|
584
|
-
}
|
|
585
|
-
if (typeof config.preOperations.dateRangeEnd !== 'string' || !config.preOperations.dateRangeEnd.trim()) {
|
|
586
|
-
throw new Error(`config.preOperations.dateRangeEnd must be a non-empty string (SQL date expression). Received: ${JSON.stringify(config.preOperations.dateRangeEnd)}`);
|
|
587
|
-
}
|
|
588
|
-
if (config.preOperations.incrementalStartOverride !== undefined && config.preOperations.incrementalStartOverride !== null && config.preOperations.incrementalStartOverride !== '') {
|
|
589
|
-
if (typeof config.preOperations.incrementalStartOverride !== 'string' || !config.preOperations.incrementalStartOverride.trim()) {
|
|
590
|
-
throw new Error(`config.preOperations.incrementalStartOverride must be a non-empty string when provided. Received: ${JSON.stringify(config.preOperations.incrementalStartOverride)}`);
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
if (config.preOperations.incrementalEndOverride !== undefined && config.preOperations.incrementalEndOverride !== null && config.preOperations.incrementalEndOverride !== '') {
|
|
594
|
-
if (typeof config.preOperations.incrementalEndOverride !== 'string' || !config.preOperations.incrementalEndOverride.trim()) {
|
|
595
|
-
throw new Error(`config.preOperations.incrementalEndOverride must be a non-empty string when provided. Received: ${JSON.stringify(config.preOperations.incrementalEndOverride)}`);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Array fields - all required
|
|
600
|
-
const stringArrayKeys = ['defaultExcludedEventParams', 'excludedEventParams', 'sessionParams', 'defaultExcludedEvents', 'excludedEvents', 'excludedColumns'];
|
|
601
|
-
for (const key of stringArrayKeys) {
|
|
602
|
-
if (config[key] === undefined) {
|
|
603
|
-
throw new Error(`config.${key} is required.`);
|
|
604
|
-
}
|
|
605
|
-
if (!Array.isArray(config[key])) {
|
|
606
|
-
throw new Error(`config.${key} must be an array. Received: ${JSON.stringify(config[key])}`);
|
|
607
|
-
}
|
|
608
|
-
for (let i = 0; i < config[key].length; i++) {
|
|
609
|
-
if (typeof config[key][i] !== 'string' || !config[key][i].trim()) {
|
|
610
|
-
throw new Error(`config.${key}[${i}] must be a non-empty string. Received: ${JSON.stringify(config[key][i])}`);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// eventParamsToColumns - required
|
|
616
|
-
if (config.eventParamsToColumns === undefined) {
|
|
617
|
-
throw new Error("config.eventParamsToColumns is required.");
|
|
618
|
-
}
|
|
619
|
-
if (!Array.isArray(config.eventParamsToColumns)) {
|
|
620
|
-
throw new Error(`config.eventParamsToColumns must be an array. Received: ${JSON.stringify(config.eventParamsToColumns)}`);
|
|
621
|
-
}
|
|
622
|
-
const validEventParamTypes = ['string', 'int', 'int64', 'double', 'float', 'float64'];
|
|
623
|
-
for (let i = 0; i < config.eventParamsToColumns.length; i++) {
|
|
624
|
-
const item = config.eventParamsToColumns[i];
|
|
625
|
-
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
626
|
-
throw new Error(`config.eventParamsToColumns[${i}] must be an object with 'name' and 'type' properties. Received: ${JSON.stringify(item)}`);
|
|
627
|
-
}
|
|
628
|
-
if (!item.name || typeof item.name !== 'string' || !item.name.trim()) {
|
|
629
|
-
throw new Error(`config.eventParamsToColumns[${i}].name must be a non-empty string. Received: ${JSON.stringify(item.name)}`);
|
|
630
|
-
}
|
|
631
|
-
if (item.type !== undefined && item.type !== null) {
|
|
632
|
-
if (!validEventParamTypes.includes(item.type)) {
|
|
633
|
-
throw new Error(`config.eventParamsToColumns[${i}].type must be one of: ${validEventParamTypes.join(', ')}. Received: ${JSON.stringify(item.type)}`);
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
if (item.columnName !== undefined && item.columnName !== null && item.columnName !== '') {
|
|
637
|
-
if (typeof item.columnName !== 'string' || !item.columnName.trim()) {
|
|
638
|
-
throw new Error(`config.eventParamsToColumns[${i}].columnName must be a non-empty string when provided. Received: ${JSON.stringify(item.columnName)}`);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
} catch (e) {
|
|
643
|
-
e.message = `Config validation: ${e.message}`;
|
|
644
|
-
throw e;
|
|
645
|
-
}
|
|
646
|
-
};
|
|
647
|
-
|
|
648
407
|
module.exports = {
|
|
649
408
|
mergeUniqueArrays,
|
|
650
409
|
mergeSQLConfigurations,
|
|
@@ -653,6 +412,5 @@ module.exports = {
|
|
|
653
412
|
isDataformTableReferenceObject,
|
|
654
413
|
setDataformContext,
|
|
655
414
|
selectOtherColumns,
|
|
656
|
-
processDate
|
|
657
|
-
validateConfig
|
|
415
|
+
processDate
|
|
658
416
|
};
|