@malloydata/malloy-tests 0.0.323 → 0.0.325
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/CONTEXT.md +122 -0
- package/package.json +11 -11
- package/src/databases/all/db_filter_expressions.spec.ts +45 -30
- package/src/databases/all/orderby.spec.ts +22 -21
- package/src/databases/all/time.spec.ts +109 -41
- package/src/databases/duckdb-all/duckdb.spec.ts +12 -6
- package/src/databases/presto-trino/presto-trino.spec.ts +1 -1
- package/src/test-select.ts +38 -2
- package/src/util/db-jest-matchers.ts +27 -2
- package/src/util/db-matcher-support.ts +17 -14
package/CONTEXT.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Test Infrastructure
|
|
2
|
+
|
|
3
|
+
This directory contains the test infrastructure for Malloy, including cross-database tests, database-specific tests, and custom testing utilities.
|
|
4
|
+
|
|
5
|
+
## Test Organization
|
|
6
|
+
|
|
7
|
+
Tests are organized into several categories:
|
|
8
|
+
|
|
9
|
+
### Core Tests (`test/src/core/`)
|
|
10
|
+
Tests for core Malloy functionality that don't require database execution:
|
|
11
|
+
- AST generation
|
|
12
|
+
- IR generation
|
|
13
|
+
- Model semantics
|
|
14
|
+
- Type checking
|
|
15
|
+
- Error handling
|
|
16
|
+
|
|
17
|
+
### Database-Specific Tests (`test/src/databases/{database}/`)
|
|
18
|
+
Tests that verify database-specific behavior:
|
|
19
|
+
- `test/src/databases/bigquery/` - BigQuery-specific tests
|
|
20
|
+
- `test/src/databases/postgres/` - PostgreSQL-specific tests
|
|
21
|
+
- `test/src/databases/duckdb/` - DuckDB-specific tests
|
|
22
|
+
- etc.
|
|
23
|
+
|
|
24
|
+
Each database may have unique features, SQL syntax, or limitations that require specific testing.
|
|
25
|
+
|
|
26
|
+
### Cross-Database Tests (`test/src/databases/all/`)
|
|
27
|
+
Tests that run against **all** supported databases to ensure consistent behavior across dialects:
|
|
28
|
+
- Query semantics
|
|
29
|
+
- Data type handling
|
|
30
|
+
- Function behavior
|
|
31
|
+
- Join operations
|
|
32
|
+
- Aggregations
|
|
33
|
+
|
|
34
|
+
These tests are particularly important for verifying that Malloy's abstraction works correctly across all supported SQL dialects.
|
|
35
|
+
|
|
36
|
+
## Custom Test Utilities
|
|
37
|
+
|
|
38
|
+
### malloyResultMatches Matcher
|
|
39
|
+
Custom Jest matcher for comparing query results across different databases.
|
|
40
|
+
|
|
41
|
+
**Purpose:**
|
|
42
|
+
Different databases may format results slightly differently (date formatting, float precision, etc.). This matcher provides fuzzy comparison that accounts for these differences while still verifying semantic correctness.
|
|
43
|
+
|
|
44
|
+
**Usage:**
|
|
45
|
+
```typescript
|
|
46
|
+
expect(actualResult).malloyResultMatches(expectedResult);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**What it handles:**
|
|
50
|
+
- Float precision differences
|
|
51
|
+
- Date/timestamp format variations
|
|
52
|
+
- Null vs undefined equivalence
|
|
53
|
+
- Result ordering (when not semantically important)
|
|
54
|
+
|
|
55
|
+
## Database Setup
|
|
56
|
+
|
|
57
|
+
### DuckDB
|
|
58
|
+
DuckDB tests require building the test database:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm run build-duckdb-db # Creates test/data/duckdb/duckdb_test.db
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
This creates a local DuckDB database file populated with test data.
|
|
65
|
+
|
|
66
|
+
### PostgreSQL, MySQL, Trino, Presto
|
|
67
|
+
These databases require Docker containers to be running.
|
|
68
|
+
|
|
69
|
+
**Starting database containers:**
|
|
70
|
+
Each database has a startup script in the test directory:
|
|
71
|
+
- `test/postgres/postgres_start.sh`
|
|
72
|
+
- `test/mysql/mysql_start.sh`
|
|
73
|
+
- `test/trino/trino_start.sh`
|
|
74
|
+
- `test/presto/presto_start.sh`
|
|
75
|
+
|
|
76
|
+
These scripts start Docker containers with appropriate test configurations and data.
|
|
77
|
+
|
|
78
|
+
### BigQuery
|
|
79
|
+
BigQuery tests require:
|
|
80
|
+
- Valid GCP authentication
|
|
81
|
+
- Access to test datasets in BigQuery
|
|
82
|
+
- Proper environment variables set
|
|
83
|
+
|
|
84
|
+
BigQuery tests typically run only in CI environments with appropriate credentials.
|
|
85
|
+
|
|
86
|
+
### Snowflake
|
|
87
|
+
Snowflake tests require:
|
|
88
|
+
- Valid Snowflake account and credentials
|
|
89
|
+
- Access to test databases
|
|
90
|
+
- Proper environment variables set
|
|
91
|
+
|
|
92
|
+
## CI-Specific Test Commands
|
|
93
|
+
|
|
94
|
+
The CI system runs different test suites optimized for parallel execution:
|
|
95
|
+
|
|
96
|
+
- **`npm run ci-core`** - Core tests (no database required)
|
|
97
|
+
- **`npm run ci-duckdb`** - DuckDB-specific tests only
|
|
98
|
+
- **`npm run ci-bigquery`** - BigQuery-specific tests only
|
|
99
|
+
- **`npm run ci-postgres`** - PostgreSQL-specific tests only
|
|
100
|
+
|
|
101
|
+
These commands are optimized for CI and may not work correctly in local development environments.
|
|
102
|
+
|
|
103
|
+
## Test Data
|
|
104
|
+
|
|
105
|
+
Test data is organized by database and includes:
|
|
106
|
+
- Schema definitions
|
|
107
|
+
- Sample datasets
|
|
108
|
+
- Expected query results
|
|
109
|
+
- Edge cases and error conditions
|
|
110
|
+
|
|
111
|
+
Test data should be:
|
|
112
|
+
- Small enough for fast test execution
|
|
113
|
+
- Comprehensive enough to cover important cases
|
|
114
|
+
- Consistent across databases (where applicable)
|
|
115
|
+
|
|
116
|
+
## Important Notes
|
|
117
|
+
|
|
118
|
+
- Cross-database tests are critical for verifying Malloy's dialect abstraction
|
|
119
|
+
- Custom matchers help handle legitimate database differences
|
|
120
|
+
- Database setup scripts must be run before database-specific tests
|
|
121
|
+
- CI has access to more databases than typical development environments
|
|
122
|
+
- Test data should be committed to the repository (except for large binary files)
|
package/package.json
CHANGED
|
@@ -21,22 +21,22 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@jest/globals": "^29.4.3",
|
|
24
|
-
"@malloydata/db-bigquery": "0.0.
|
|
25
|
-
"@malloydata/db-duckdb": "0.0.
|
|
26
|
-
"@malloydata/db-postgres": "0.0.
|
|
27
|
-
"@malloydata/db-snowflake": "0.0.
|
|
28
|
-
"@malloydata/db-trino": "0.0.
|
|
29
|
-
"@malloydata/malloy": "0.0.
|
|
30
|
-
"@malloydata/malloy-tag": "0.0.
|
|
31
|
-
"@malloydata/render": "0.0.
|
|
24
|
+
"@malloydata/db-bigquery": "0.0.325",
|
|
25
|
+
"@malloydata/db-duckdb": "0.0.325",
|
|
26
|
+
"@malloydata/db-postgres": "0.0.325",
|
|
27
|
+
"@malloydata/db-snowflake": "0.0.325",
|
|
28
|
+
"@malloydata/db-trino": "0.0.325",
|
|
29
|
+
"@malloydata/malloy": "0.0.325",
|
|
30
|
+
"@malloydata/malloy-tag": "0.0.325",
|
|
31
|
+
"@malloydata/render": "0.0.325",
|
|
32
32
|
"events": "^3.3.0",
|
|
33
33
|
"jsdom": "^22.1.0",
|
|
34
|
-
"luxon": "^
|
|
34
|
+
"luxon": "^3.5.0",
|
|
35
35
|
"madge": "^6.0.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/jsdom": "^21.1.1",
|
|
39
|
-
"@types/luxon": "^
|
|
39
|
+
"@types/luxon": "^3.5.0"
|
|
40
40
|
},
|
|
41
|
-
"version": "0.0.
|
|
41
|
+
"version": "0.0.325"
|
|
42
42
|
}
|
|
@@ -9,6 +9,7 @@ import {RuntimeList, allDatabases} from '../../runtimes';
|
|
|
9
9
|
import '../../util/db-jest-matchers';
|
|
10
10
|
import {databasesFromEnvironmentOr} from '../../util';
|
|
11
11
|
import {DateTime as LuxonDateTime} from 'luxon';
|
|
12
|
+
import {Dialect} from '@malloydata/malloy';
|
|
12
13
|
|
|
13
14
|
const runtimes = new RuntimeList(databasesFromEnvironmentOr(allDatabases));
|
|
14
15
|
|
|
@@ -491,17 +492,25 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
491
492
|
|
|
492
493
|
describe('temporal filters', () => {
|
|
493
494
|
function tsLit(at: LuxonDateTime): string {
|
|
494
|
-
const typeDef: {type: 'timestamp' | 'date'} = {type: 'timestamp'};
|
|
495
|
-
const node: {node: 'timeLiteral'} = {node: 'timeLiteral'};
|
|
496
495
|
const timeStr = at.toUTC().toFormat(fTimestamp);
|
|
497
|
-
const
|
|
498
|
-
|
|
496
|
+
const node = Dialect.makeTimeLiteralNode(
|
|
497
|
+
db.dialect,
|
|
498
|
+
timeStr,
|
|
499
|
+
undefined,
|
|
500
|
+
undefined,
|
|
501
|
+
'timestamp'
|
|
502
|
+
);
|
|
503
|
+
return db.dialect.exprToSQL({}, node) || '';
|
|
499
504
|
}
|
|
500
505
|
function lit(t: string, type: 'timestamp' | 'date'): string {
|
|
501
|
-
const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
506
|
+
const node = Dialect.makeTimeLiteralNode(
|
|
507
|
+
db.dialect,
|
|
508
|
+
t,
|
|
509
|
+
undefined,
|
|
510
|
+
undefined,
|
|
511
|
+
type
|
|
512
|
+
);
|
|
513
|
+
return db.dialect.exprToSQL({}, node) || '';
|
|
505
514
|
}
|
|
506
515
|
|
|
507
516
|
const fTimestamp = 'yyyy-LL-dd HH:mm:ss';
|
|
@@ -1050,9 +1059,9 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
1050
1059
|
{n: 'z-null'}
|
|
1051
1060
|
);
|
|
1052
1061
|
});
|
|
1053
|
-
const tzTesting =
|
|
1062
|
+
const tzTesting = true;
|
|
1054
1063
|
describe('query time zone', () => {
|
|
1055
|
-
test.when(tzTesting)('
|
|
1064
|
+
test.when(tzTesting)('date literal in query time zone', async () => {
|
|
1056
1065
|
const rangeQuery = mkRangeQuery(
|
|
1057
1066
|
"f'2024-01-01'",
|
|
1058
1067
|
'2024-01-01 00:00:00',
|
|
@@ -1061,26 +1070,32 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
1061
1070
|
);
|
|
1062
1071
|
await expect(rangeQuery).malloyResultMatches(db, inRange);
|
|
1063
1072
|
});
|
|
1064
|
-
test.when(tzTesting)(
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
'America/Mexico_City'
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1073
|
+
test.when(tzTesting)(
|
|
1074
|
+
'timestamp literal today in query time zone',
|
|
1075
|
+
async () => {
|
|
1076
|
+
nowIs('2024-01-15 00:34:56', 'America/Mexico_City');
|
|
1077
|
+
const rangeQuery = mkRangeQuery(
|
|
1078
|
+
"f'today'",
|
|
1079
|
+
'2024-01-15 00:00:00',
|
|
1080
|
+
'2024-01-16 00:00:00',
|
|
1081
|
+
'America/Mexico_City'
|
|
1082
|
+
);
|
|
1083
|
+
await expect(rangeQuery).malloyResultMatches(db, inRange);
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
test.when(tzTesting)(
|
|
1087
|
+
'timestamp literal next week in query time zone',
|
|
1088
|
+
async () => {
|
|
1089
|
+
nowIs('2024-01-01 00:00:00', 'America/Mexico_City');
|
|
1090
|
+
const rangeQuery = mkRangeQuery(
|
|
1091
|
+
"f'next wednesday'",
|
|
1092
|
+
'2024-01-03 00:00:00',
|
|
1093
|
+
'2024-01-04 00:00:00',
|
|
1094
|
+
'America/Mexico_City'
|
|
1095
|
+
);
|
|
1096
|
+
await expect(rangeQuery).malloyResultMatches(db, inRange);
|
|
1097
|
+
}
|
|
1098
|
+
);
|
|
1084
1099
|
test.when(tzTesting)('day literal in query time zone', async () => {
|
|
1085
1100
|
const exactTimeModel = mkEqTime('2024-01-15 12:00:00');
|
|
1086
1101
|
await expect(`
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import {RuntimeList, allDatabases} from '../../runtimes';
|
|
26
26
|
import {databasesFromEnvironmentOr} from '../../util';
|
|
27
27
|
import '../../util/db-jest-matchers';
|
|
28
|
+
import {Dialect} from '@malloydata/malloy';
|
|
28
29
|
|
|
29
30
|
const runtimes = new RuntimeList(databasesFromEnvironmentOr(allDatabases));
|
|
30
31
|
|
|
@@ -277,33 +278,33 @@ describe.each(runtimes.runtimeList)('%s', (databaseName, runtime) => {
|
|
|
277
278
|
]);
|
|
278
279
|
}
|
|
279
280
|
);
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
}
|
|
281
|
+
const y2020Node = Dialect.makeTimeLiteralNode(
|
|
282
|
+
runtime.dialect,
|
|
283
|
+
'2020-01-01 00:00:00',
|
|
284
|
+
undefined,
|
|
285
|
+
undefined,
|
|
286
|
+
'timestamp'
|
|
287
287
|
);
|
|
288
|
+
const y2020 = runtime.dialect.exprToSQL({}, y2020Node) || '';
|
|
288
289
|
const d2020 = new Date('2020-01-01 00:00:00Z');
|
|
289
290
|
const d2022 = new Date('2022-01-01 00:00:00Z');
|
|
290
291
|
const d2025 = new Date('2025-01-01 00:00:00Z');
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
292
|
+
const y2025Node = Dialect.makeTimeLiteralNode(
|
|
293
|
+
runtime.dialect,
|
|
294
|
+
'2025-01-01 00:00:00',
|
|
295
|
+
undefined,
|
|
296
|
+
undefined,
|
|
297
|
+
'timestamp'
|
|
298
298
|
);
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
299
|
+
const y2025 = runtime.dialect.exprToSQL({}, y2025Node) || '';
|
|
300
|
+
const y2022Node = Dialect.makeTimeLiteralNode(
|
|
301
|
+
runtime.dialect,
|
|
302
|
+
'2022-01-01 00:00:00',
|
|
303
|
+
undefined,
|
|
304
|
+
undefined,
|
|
305
|
+
'timestamp'
|
|
306
306
|
);
|
|
307
|
+
const y2022 = runtime.dialect.exprToSQL({}, y2022Node) || '';
|
|
307
308
|
const times = `${databaseName}.sql("""
|
|
308
309
|
SELECT ${y2020} as ${q`t`}
|
|
309
310
|
UNION ALL SELECT ${y2025}
|
|
@@ -32,14 +32,24 @@ import {
|
|
|
32
32
|
} from '../../util';
|
|
33
33
|
import {DateTime as LuxonDateTime} from 'luxon';
|
|
34
34
|
import {API} from '@malloydata/malloy';
|
|
35
|
+
import {TestSelect} from '../../test-select';
|
|
35
36
|
|
|
36
37
|
const runtimes = new RuntimeList(databasesFromEnvironmentOr(allDatabases));
|
|
37
38
|
|
|
38
39
|
// MTOY todo look at this list for timezone problems, I know there are some
|
|
39
40
|
describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
41
|
+
const ts = new TestSelect(runtime.dialect);
|
|
42
|
+
const timestamptz = runtime.dialect.hasTimestamptz;
|
|
43
|
+
const timeSchema = {
|
|
44
|
+
t_date: ts.mk_date('2021-02-24'),
|
|
45
|
+
t_timestamp: ts.mk_timestamp('2021-02-24 03:05:06'),
|
|
46
|
+
};
|
|
47
|
+
if (timestamptz) {
|
|
48
|
+
timeSchema['t_timestamptz'] = ts.mk_timestamptz(
|
|
49
|
+
'2021-02-24 03:05:06 [UTC]'
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const timeSQL = ts.generate(timeSchema);
|
|
43
53
|
const sqlEq = mkSqlEqWith(runtime, dbName, {sql: timeSQL});
|
|
44
54
|
|
|
45
55
|
describe('interval measurement', () => {
|
|
@@ -179,6 +189,14 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
179
189
|
expect(await eq).isSqlEq();
|
|
180
190
|
});
|
|
181
191
|
|
|
192
|
+
test.when(timestamptz)('trunc timestamptz day', async () => {
|
|
193
|
+
await expect(`
|
|
194
|
+
run: ${dbName}.sql("""${timeSQL}""") -> {
|
|
195
|
+
select: result is t_timestamptz.day
|
|
196
|
+
}
|
|
197
|
+
`).malloyResultMatches(runtime, {result: '2021-02-24 00:00:00Z'});
|
|
198
|
+
});
|
|
199
|
+
|
|
182
200
|
test('trunc week', async () => {
|
|
183
201
|
const eq = sqlEq('t_timestamp.week', '@2021-02-21 00:00:00');
|
|
184
202
|
expect(await eq).isSqlEq();
|
|
@@ -247,6 +265,24 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
247
265
|
expect(await eq).isSqlEq();
|
|
248
266
|
});
|
|
249
267
|
});
|
|
268
|
+
|
|
269
|
+
test.when(runtime.dialect.hasTimestamptz)(
|
|
270
|
+
'extract from timestamptz without query timezone',
|
|
271
|
+
async () => {
|
|
272
|
+
// TIMESTAMPTZ representing midnight UTC
|
|
273
|
+
// Without query timezone, extract should happen in UTC (or stored tz for Trino)
|
|
274
|
+
// Expected: hour = 0, day = 20
|
|
275
|
+
await expect(
|
|
276
|
+
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
277
|
+
extend: { dimension: utc_tstz is @2020-02-20 00:00:00[UTC]::timestamptz }
|
|
278
|
+
select:
|
|
279
|
+
utc_hour is hour(utc_tstz)
|
|
280
|
+
utc_day is day(utc_tstz)
|
|
281
|
+
}`
|
|
282
|
+
).malloyResultMatches(runtime, {utc_hour: 0, utc_day: 20});
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
250
286
|
describe('date truncation', () => {
|
|
251
287
|
test('date trunc day', async () => {
|
|
252
288
|
const eq = sqlEq('t_date.day', '@2021-02-24');
|
|
@@ -473,9 +509,7 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
473
509
|
});
|
|
474
510
|
});
|
|
475
511
|
|
|
476
|
-
test
|
|
477
|
-
!brokenIn('trino', dbName) && !brokenIn('presto', dbName) /* mtoy */
|
|
478
|
-
)('dependant join dialect fragments', async () => {
|
|
512
|
+
test('dependant join dialect fragments', async () => {
|
|
479
513
|
await expect(`
|
|
480
514
|
source: timeData is ${dbName}.sql("""${timeSQL}""")
|
|
481
515
|
run: timeData -> {
|
|
@@ -631,9 +665,7 @@ const utc_2020 = LuxonDateTime.fromObject(
|
|
|
631
665
|
);
|
|
632
666
|
|
|
633
667
|
describe.each(runtimes.runtimeList)('%s: tz literals', (dbName, runtime) => {
|
|
634
|
-
test
|
|
635
|
-
!brokenIn('trino', dbName) && !brokenIn('presto', dbName) /* mtoy */
|
|
636
|
-
)(`${dbName} - default timezone is UTC`, async () => {
|
|
668
|
+
test(`${dbName} - default timezone is UTC`, async () => {
|
|
637
669
|
// this makes sure that the tests which use the test timezome are actually
|
|
638
670
|
// testing something ... file this under "abundance of caution". It
|
|
639
671
|
// really tests nothing, but I feel calmer with this here.
|
|
@@ -650,9 +682,7 @@ describe.each(runtimes.runtimeList)('%s: tz literals', (dbName, runtime) => {
|
|
|
650
682
|
expect(have.valueOf()).toEqual(utc_2020.valueOf());
|
|
651
683
|
});
|
|
652
684
|
|
|
653
|
-
test
|
|
654
|
-
!brokenIn('trino', dbName) && !brokenIn('presto', dbName) /* mtoy */
|
|
655
|
-
)('literal with zone name', async () => {
|
|
685
|
+
test('literal with zone name', async () => {
|
|
656
686
|
const query = runtime.loadQuery(
|
|
657
687
|
`
|
|
658
688
|
run: ${dbName}.sql("SELECT 1 as one") -> {
|
|
@@ -668,10 +698,12 @@ describe.each(runtimes.runtimeList)('%s: tz literals', (dbName, runtime) => {
|
|
|
668
698
|
});
|
|
669
699
|
|
|
670
700
|
describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
671
|
-
const
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
701
|
+
const ts = new TestSelect(runtime.dialect);
|
|
702
|
+
const selectMidnight = `"""${ts.generate({
|
|
703
|
+
utc_midnight_ts: ts.mk_timestamp('2020-02-20 00:00:00'),
|
|
704
|
+
date_2020: ts.mk_date('2020-02-20'),
|
|
705
|
+
})}"""`;
|
|
706
|
+
test('literal timestamps', async () => {
|
|
675
707
|
const query = runtime.loadQuery(
|
|
676
708
|
`
|
|
677
709
|
run: ${dbName}.sql("SELECT 1 as one") -> {
|
|
@@ -698,24 +730,37 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
698
730
|
).malloyResultMatches(runtime, {mex_midnight: 18, mex_day: 19});
|
|
699
731
|
});
|
|
700
732
|
|
|
701
|
-
test.when(
|
|
702
|
-
|
|
703
|
-
|
|
733
|
+
test.when(runtime.dialect.hasTimestamptz)(
|
|
734
|
+
'extract from timestamptz with query timezone',
|
|
735
|
+
async () => {
|
|
736
|
+
// TIMESTAMPTZ representing midnight UTC
|
|
737
|
+
// With query timezone America/Mexico_City (-06:00), midnight UTC = 6pm Feb 19
|
|
738
|
+
// Expected: hour = 18, day = 19
|
|
739
|
+
await expect(
|
|
740
|
+
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
741
|
+
timezone: '${zone}'
|
|
742
|
+
extend: { dimension: utc_tstz is @2020-02-20 00:00:00[UTC]::timestamptz }
|
|
743
|
+
select:
|
|
744
|
+
mex_hour is hour(utc_tstz)
|
|
745
|
+
mex_day is day(utc_tstz)
|
|
746
|
+
}`
|
|
747
|
+
).malloyResultMatches(runtime, {mex_hour: 18, mex_day: 19});
|
|
748
|
+
}
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
test('truncate day', async () => {
|
|
704
752
|
// At midnight in london it the 19th in Mexico, so that truncates to
|
|
705
753
|
// midnight on the 19th
|
|
706
754
|
const mex_19 = LuxonDateTime.fromISO('2020-02-19T00:00:00', {zone});
|
|
707
755
|
await expect(
|
|
708
|
-
`run: ${dbName}.sql(
|
|
756
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
709
757
|
timezone: '${zone}'
|
|
710
|
-
|
|
711
|
-
select: mex_day is utc_midnight.day
|
|
758
|
+
select: mex_day is utc_midnight_ts.day
|
|
712
759
|
}`
|
|
713
760
|
).malloyResultMatches(runtime, {mex_day: mex_19.toJSDate()});
|
|
714
761
|
});
|
|
715
762
|
|
|
716
|
-
test
|
|
717
|
-
!brokenIn('trino', dbName) && !brokenIn('presto', dbName) /* mtoy */
|
|
718
|
-
)('truncate week', async () => {
|
|
763
|
+
test('truncate week', async () => {
|
|
719
764
|
// the 19th in mexico is a wednesday, so trunc to the 15th
|
|
720
765
|
const mex_19 = LuxonDateTime.fromISO('2020-02-19T00:00:00', {zone});
|
|
721
766
|
// Find the sunday before then
|
|
@@ -729,30 +774,53 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
729
774
|
).malloyResultMatches(runtime, {mex_week: mex_sunday.toJSDate()});
|
|
730
775
|
});
|
|
731
776
|
|
|
732
|
-
test
|
|
733
|
-
!brokenIn('trino', dbName) && !brokenIn('presto', dbName) /* mtoy */
|
|
734
|
-
)('cast timestamp to date', async () => {
|
|
735
|
-
// At midnight in london it is the 19th in Mexico, so when we cast that
|
|
736
|
-
// to a date, it should be the 19th.
|
|
777
|
+
test('cast timestamp to date', async () => {
|
|
737
778
|
await expect(
|
|
738
|
-
`run: ${dbName}.sql(
|
|
779
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
739
780
|
timezone: '${zone}'
|
|
740
|
-
|
|
741
|
-
select: mex_day is day(utc_midnight::date)
|
|
781
|
+
select: mex_date is utc_midnight_ts::date
|
|
742
782
|
}`
|
|
743
|
-
).malloyResultMatches(runtime, {
|
|
783
|
+
).malloyResultMatches(runtime, {mex_date: '2020-02-19'});
|
|
744
784
|
});
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
785
|
+
test.when(runtime.dialect.hasTimestamptz)(
|
|
786
|
+
'cast timestamptz to date',
|
|
787
|
+
async () => {
|
|
788
|
+
await expect(
|
|
789
|
+
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
790
|
+
timezone: '${zone}'
|
|
791
|
+
extend: { dimension: utc_tstz is @2020-02-20 00:00:00[UTC]::timestamptz }
|
|
792
|
+
select: mex_date is utc_tstz::date
|
|
793
|
+
}`
|
|
794
|
+
).malloyResultMatches(runtime, {mex_date: '2020-02-19'});
|
|
795
|
+
}
|
|
796
|
+
);
|
|
797
|
+
test('cast date to timestamp', async () => {
|
|
749
798
|
await expect(
|
|
750
|
-
`run: ${dbName}.sql(
|
|
799
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
751
800
|
timezone: '${zone}'
|
|
752
|
-
select:
|
|
801
|
+
select: mex_date is date_2020::timestamp
|
|
753
802
|
}`
|
|
754
|
-
).malloyResultMatches(runtime, {
|
|
803
|
+
).malloyResultMatches(runtime, {mex_date: zone_2020.toJSDate()});
|
|
755
804
|
});
|
|
805
|
+
test('return date 2020-02-20', async () => {
|
|
806
|
+
await expect(
|
|
807
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
808
|
+
timezone: '${zone}'
|
|
809
|
+
select: d2020 is date_2020
|
|
810
|
+
}`
|
|
811
|
+
).malloyResultMatches(runtime, {d2020: '2020-02-20'});
|
|
812
|
+
});
|
|
813
|
+
test.when(runtime.dialect.hasTimestamptz)(
|
|
814
|
+
'cast date to timestamptz',
|
|
815
|
+
async () => {
|
|
816
|
+
await expect(
|
|
817
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
818
|
+
timezone: '${zone}'
|
|
819
|
+
select: mex_date is date_2020::timestamptz
|
|
820
|
+
}`
|
|
821
|
+
).malloyResultMatches(runtime, {mex_date: zone_2020.toJSDate()});
|
|
822
|
+
}
|
|
823
|
+
);
|
|
756
824
|
|
|
757
825
|
// Test for timezone rendering issue with nested queries
|
|
758
826
|
test.when(runtime.supportsNesting)(
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import {DateTime} from 'luxon';
|
|
25
|
-
import {RuntimeList} from '../../runtimes';
|
|
25
|
+
import {RuntimeList, runtimeFor} from '../../runtimes';
|
|
26
26
|
import '../../util/db-jest-matchers';
|
|
27
27
|
import {describeIfDatabaseAvailable} from '../../util';
|
|
28
28
|
|
|
@@ -118,11 +118,17 @@ describe.each(allDucks.runtimeList)('duckdb:%s', (dbName, runtime) => {
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
it('supports timezones', async () => {
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
// Use isolated connection to avoid affecting other tests
|
|
122
|
+
const isolatedRuntime = runtimeFor(dbName);
|
|
123
|
+
try {
|
|
124
|
+
await isolatedRuntime.connection.runSQL("SET TimeZone='CET'");
|
|
125
|
+
const result = await isolatedRuntime.connection.runSQL(
|
|
126
|
+
"SELECT current_setting('TimeZone')"
|
|
127
|
+
);
|
|
128
|
+
expect(result.rows[0]).toEqual({"current_setting('TimeZone')": 'CET'});
|
|
129
|
+
} finally {
|
|
130
|
+
await isolatedRuntime.connection.close();
|
|
131
|
+
}
|
|
126
132
|
});
|
|
127
133
|
|
|
128
134
|
it('supports varchars with parameters', async () => {
|
|
@@ -115,7 +115,7 @@ describe.each(runtimes.runtimeList)(
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it(`runs the date_parse function - ${databaseName}`, async () => {
|
|
118
|
-
const expected =
|
|
118
|
+
const expected = '2024-09-15T00:00:00Z';
|
|
119
119
|
|
|
120
120
|
await expect(`run: ${databaseName}.sql("SELECT 1 as n") -> {
|
|
121
121
|
select: x is date_parse('2024-09-15', '%Y-%m-%d')::date
|
package/src/test-select.ts
CHANGED
|
@@ -265,7 +265,7 @@ export class TestSelect {
|
|
|
265
265
|
|
|
266
266
|
return {
|
|
267
267
|
expr: {
|
|
268
|
-
node: '
|
|
268
|
+
node: 'dateLiteral',
|
|
269
269
|
literal: value,
|
|
270
270
|
typeDef: {type: 'date'},
|
|
271
271
|
},
|
|
@@ -294,7 +294,7 @@ export class TestSelect {
|
|
|
294
294
|
|
|
295
295
|
return {
|
|
296
296
|
expr: {
|
|
297
|
-
node: '
|
|
297
|
+
node: 'timestampLiteral',
|
|
298
298
|
literal: value,
|
|
299
299
|
typeDef: {type: 'timestamp'},
|
|
300
300
|
},
|
|
@@ -303,6 +303,42 @@ export class TestSelect {
|
|
|
303
303
|
};
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
mk_timestamptz(value: string | null): TypedValue {
|
|
307
|
+
const malloyType: AtomicTypeDef = {type: 'timestamptz'};
|
|
308
|
+
|
|
309
|
+
if (value === null) {
|
|
310
|
+
const castExpr: TypecastExpr = {
|
|
311
|
+
node: 'cast',
|
|
312
|
+
e: nullExpr,
|
|
313
|
+
dstType: {type: 'timestamptz'},
|
|
314
|
+
safe: false,
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
expr: castExpr,
|
|
319
|
+
malloyType,
|
|
320
|
+
needsCast: true,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const match = value.match(/^(.+?)\s*\[(.+?)\]$/);
|
|
325
|
+
if (!match) {
|
|
326
|
+
throw new Error(`Invalid timestamptz format: ${value}. Expected format: 'YYYY-MM-DD
|
|
327
|
+
HH:MM:SS [Timezone]'`);
|
|
328
|
+
}
|
|
329
|
+
const [, ts, timezone] = match;
|
|
330
|
+
return {
|
|
331
|
+
expr: {
|
|
332
|
+
node: 'timestamptzLiteral',
|
|
333
|
+
literal: ts.trim(),
|
|
334
|
+
typeDef: {type: 'timestamptz'},
|
|
335
|
+
timezone,
|
|
336
|
+
},
|
|
337
|
+
malloyType,
|
|
338
|
+
needsCast: false,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
306
342
|
mk_array(values: TestValue[]): TypedValue {
|
|
307
343
|
if (values.length === 0) {
|
|
308
344
|
throw new Error(
|
|
@@ -207,8 +207,33 @@ expect.extend({
|
|
|
207
207
|
}
|
|
208
208
|
const got = result.data.path(...resultPath).value;
|
|
209
209
|
const pGot = JSON.stringify(got);
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
|
|
211
|
+
let mustBe = value;
|
|
212
|
+
let actuallyGot = got;
|
|
213
|
+
|
|
214
|
+
// If the value is a Date this is some sort of temporal column.
|
|
215
|
+
// If the expected looks like a 'YYYY-MM-DD' value, then expect the
|
|
216
|
+
// Date to be YYYY-MM-DD 00:00:00Z
|
|
217
|
+
// When comparing Date values, we use getTime to verify in a safe way
|
|
218
|
+
// that the correct instant is returned.
|
|
219
|
+
if (got instanceof Date) {
|
|
220
|
+
actuallyGot = got.getTime();
|
|
221
|
+
mustBe = typeof value === 'string' ? new Date(value) : value;
|
|
222
|
+
if (mustBe instanceof Date) {
|
|
223
|
+
mustBe = mustBe.getTime();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// If expected is a date string like 'YYYY-MM-DD', compare as date strings
|
|
227
|
+
else if (
|
|
228
|
+
typeof value === 'string' &&
|
|
229
|
+
/^\d{4}-\d{2}-\d{2}$/.test(value)
|
|
230
|
+
) {
|
|
231
|
+
if (got instanceof Date) {
|
|
232
|
+
actuallyGot = got.toISOString().split('T')[0];
|
|
233
|
+
}
|
|
234
|
+
// mustBe stays as the string value
|
|
235
|
+
}
|
|
236
|
+
|
|
212
237
|
if (typeof mustBe === 'number' && typeof actuallyGot !== 'number') {
|
|
213
238
|
fails.push(`${expected} Got: Non Numeric '${pGot}'`);
|
|
214
239
|
} else if (!objectsMatch(actuallyGot, mustBe)) {
|
|
@@ -11,9 +11,8 @@ import type {
|
|
|
11
11
|
ModelMaterializer,
|
|
12
12
|
QueryMaterializer,
|
|
13
13
|
LogMessage,
|
|
14
|
-
Dialect,
|
|
15
14
|
} from '@malloydata/malloy';
|
|
16
|
-
import {API, MalloyError} from '@malloydata/malloy';
|
|
15
|
+
import {API, MalloyError, Dialect} from '@malloydata/malloy';
|
|
17
16
|
import type {Tag} from '@malloydata/malloy-tag';
|
|
18
17
|
|
|
19
18
|
type JestMatcherResult = {
|
|
@@ -130,20 +129,22 @@ function errorLogToString(src: string, msgs: LogMessage[]) {
|
|
|
130
129
|
return lovely;
|
|
131
130
|
}
|
|
132
131
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
typeDef,
|
|
141
|
-
literal: t,
|
|
142
|
-
};
|
|
143
|
-
return d.sqlLiteralTime({}, n);
|
|
132
|
+
function lit(
|
|
133
|
+
d: Dialect,
|
|
134
|
+
t: string,
|
|
135
|
+
type: 'timestamp' | 'timestamptz' | 'date'
|
|
136
|
+
): string {
|
|
137
|
+
const node = Dialect.makeTimeLiteralNode(d, t, undefined, undefined, type);
|
|
138
|
+
return d.exprToSQL({}, node) || '';
|
|
144
139
|
}
|
|
145
140
|
|
|
146
|
-
type SQLDataType =
|
|
141
|
+
type SQLDataType =
|
|
142
|
+
| 'string'
|
|
143
|
+
| 'number'
|
|
144
|
+
| 'timestamp'
|
|
145
|
+
| 'timestamptz'
|
|
146
|
+
| 'date'
|
|
147
|
+
| 'boolean';
|
|
147
148
|
type SQLRow = unknown[];
|
|
148
149
|
|
|
149
150
|
/**
|
|
@@ -177,6 +178,8 @@ export function mkSQLSource(
|
|
|
177
178
|
valStr = 'NULL';
|
|
178
179
|
} else if (schema[colName] === 'timestamp' && typeof val === 'string') {
|
|
179
180
|
valStr = lit(dialect, val, 'timestamp');
|
|
181
|
+
} else if (schema[colName] === 'timestamptz' && typeof val === 'string') {
|
|
182
|
+
valStr = lit(dialect, val, 'timestamptz');
|
|
180
183
|
} else if (schema[colName] === 'date' && typeof val === 'string') {
|
|
181
184
|
valStr = lit(dialect, val, 'date');
|
|
182
185
|
}
|