@malloydata/malloy-tests 0.0.309 → 0.0.311
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/package.json +9 -9
- package/postgres/postgres_start.sh +41 -0
- package/postgres/postgres_stop.sh +7 -0
- package/src/databases/all/db_filter_expressions.spec.ts +43 -0
- package/src/databases/all/nomodel.spec.ts +55 -0
- package/src/databases/all/test-select.spec.ts +297 -0
- package/src/databases/duckdb/nested_source_table.spec.ts +0 -3
- package/src/test-select.ts +602 -0
package/package.json
CHANGED
|
@@ -21,14 +21,14 @@
|
|
|
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.311",
|
|
25
|
+
"@malloydata/db-duckdb": "0.0.311",
|
|
26
|
+
"@malloydata/db-postgres": "0.0.311",
|
|
27
|
+
"@malloydata/db-snowflake": "0.0.311",
|
|
28
|
+
"@malloydata/db-trino": "0.0.311",
|
|
29
|
+
"@malloydata/malloy": "0.0.311",
|
|
30
|
+
"@malloydata/malloy-tag": "0.0.311",
|
|
31
|
+
"@malloydata/render": "0.0.311",
|
|
32
32
|
"events": "^3.3.0",
|
|
33
33
|
"jsdom": "^22.1.0",
|
|
34
34
|
"luxon": "^2.4.0",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@types/jsdom": "^21.1.1",
|
|
39
39
|
"@types/luxon": "^2.4.0"
|
|
40
40
|
},
|
|
41
|
-
"version": "0.0.
|
|
41
|
+
"version": "0.0.311"
|
|
42
42
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#! /bin/bash
|
|
2
|
+
#
|
|
3
|
+
# Setup postgres as a docker container
|
|
4
|
+
#
|
|
5
|
+
set -e
|
|
6
|
+
|
|
7
|
+
rm -rf .tmp
|
|
8
|
+
mkdir .tmp
|
|
9
|
+
|
|
10
|
+
# run docker
|
|
11
|
+
SCRIPTDIR=$(cd $(dirname $0); pwd)
|
|
12
|
+
DATADIR=$(dirname $SCRIPTDIR)/data/postgres
|
|
13
|
+
|
|
14
|
+
// set these in your enviornment
|
|
15
|
+
export PGHOST=localhost
|
|
16
|
+
export PGPORT=5432
|
|
17
|
+
export PGUSER=root
|
|
18
|
+
export PGPASSWORD=postgres
|
|
19
|
+
|
|
20
|
+
docker run -p 5432:5432 -d -v $DATADIR:/init_data \
|
|
21
|
+
--name postgres-malloy \
|
|
22
|
+
-e POSTGRES_USER=root -e POSTGRES_PASSWORD=postgres \
|
|
23
|
+
--health-cmd pg_isready \
|
|
24
|
+
--health-interval 10s \
|
|
25
|
+
--health-timeout 5s \
|
|
26
|
+
--health-retries 5 \
|
|
27
|
+
-d postgres
|
|
28
|
+
|
|
29
|
+
CONTAINER_NAME="postgres-malloy"
|
|
30
|
+
|
|
31
|
+
echo "Waiting for container $CONTAINER_NAME to become healthy..."
|
|
32
|
+
|
|
33
|
+
while [ "$(docker inspect -f {{.State.Health.Status}} $CONTAINER_NAME)" != "healthy" ]; do
|
|
34
|
+
sleep 2; # Adjust the sleep duration as needed
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
echo "Container $CONTAINER_NAME is now healthy!"
|
|
38
|
+
|
|
39
|
+
# configure
|
|
40
|
+
echo CREATE EXTENSION tsm_system_rows\; | psql
|
|
41
|
+
gunzip -c ${DATADIR}/malloytest-postgres.sql.gz | psql
|
|
@@ -183,6 +183,21 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
183
183
|
select: nm
|
|
184
184
|
}`).malloyResultMatches(abc, got('xback'));
|
|
185
185
|
});
|
|
186
|
+
test('string or with pipe', async () => {
|
|
187
|
+
await expect(`
|
|
188
|
+
run: abc -> {
|
|
189
|
+
where: s ~ f'abc | def'
|
|
190
|
+
select: nm; order_by: nm asc
|
|
191
|
+
}`).malloyResultMatches(abc, got('abc,def'));
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('string and with semicolon', async () => {
|
|
195
|
+
await expect(`
|
|
196
|
+
run: abc -> {
|
|
197
|
+
where: s ~ f'%b% ; %c'
|
|
198
|
+
select: nm; order_by: nm asc
|
|
199
|
+
}`).malloyResultMatches(abc, got('abc'));
|
|
200
|
+
});
|
|
186
201
|
});
|
|
187
202
|
|
|
188
203
|
describe('numeric filter expressions', () => {
|
|
@@ -319,6 +334,13 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
319
334
|
select: n; order_by: n asc
|
|
320
335
|
}`).malloyResultMatches(nums, [{n: 0}, {n: 1}]);
|
|
321
336
|
});
|
|
337
|
+
test('not <=1', async () => {
|
|
338
|
+
await expect(`
|
|
339
|
+
run: nums -> {
|
|
340
|
+
where: n ~ f'not <=1'
|
|
341
|
+
select: n; order_by: n asc
|
|
342
|
+
}`).malloyResultMatches(nums, [{n: 2}, {n: 3}, {n: 4}]);
|
|
343
|
+
});
|
|
322
344
|
});
|
|
323
345
|
|
|
324
346
|
const testBoolean = db.dialect.booleanType === 'supported';
|
|
@@ -365,6 +387,27 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
365
387
|
select: t; order_by: t asc
|
|
366
388
|
}`).malloyResultMatches(facts, [{t: 'false'}, {t: 'true'}]);
|
|
367
389
|
});
|
|
390
|
+
test.when(testBoolean)('not true', async () => {
|
|
391
|
+
await expect(`
|
|
392
|
+
run: facts -> {
|
|
393
|
+
where: b ~ f'not true'
|
|
394
|
+
select: t; order_by: t asc
|
|
395
|
+
}`).malloyResultMatches(facts, [{t: 'false'}, {t: 'null'}]);
|
|
396
|
+
});
|
|
397
|
+
test.when(testBoolean)('not false', async () => {
|
|
398
|
+
await expect(`
|
|
399
|
+
run: facts -> {
|
|
400
|
+
where: b ~ f'not false'
|
|
401
|
+
select: t; order_by: t asc
|
|
402
|
+
}`).malloyResultMatches(facts, [{t: 'true'}]);
|
|
403
|
+
});
|
|
404
|
+
test.when(testBoolean)('not =false', async () => {
|
|
405
|
+
await expect(`
|
|
406
|
+
run: facts -> {
|
|
407
|
+
where: b ~ f'not =false'
|
|
408
|
+
select: t; order_by: t asc
|
|
409
|
+
}`).malloyResultMatches(facts, [{t: 'null'}, {t: 'true'}]);
|
|
410
|
+
});
|
|
368
411
|
test.when(testBoolean)('empty boolean filter', async () => {
|
|
369
412
|
await expect(`
|
|
370
413
|
run: facts -> {
|
|
@@ -274,6 +274,33 @@ runtimes.runtimeMap.forEach((runtime, databaseName) => {
|
|
|
274
274
|
});
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
+
it(`symmetric sum and average large - ${databaseName}`, async () => {
|
|
278
|
+
await expect(`
|
|
279
|
+
source: a is ${databaseName}.table('malloytest.airports') extend {
|
|
280
|
+
primary_key: code
|
|
281
|
+
dimension: big_elevation is elevation * 100000
|
|
282
|
+
measure:
|
|
283
|
+
total_elevation is elevation.sum()
|
|
284
|
+
average_elevation is floor(elevation.avg())
|
|
285
|
+
total_big_elevation is big_elevation.sum()
|
|
286
|
+
average_big_elevation is floor(big_elevation.avg())
|
|
287
|
+
}
|
|
288
|
+
query: two_rows is ${databaseName}.table('malloytest.state_facts') -> {select: state; limit: 2}
|
|
289
|
+
source: b is two_rows extend {
|
|
290
|
+
join_cross: a on 1=1
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
run: b -> {aggregate: a.total_elevation, a.average_elevation, a.total_big_elevation, a.average_big_elevation}
|
|
294
|
+
// run: two_rows
|
|
295
|
+
|
|
296
|
+
`).malloyResultMatches(runtime, {
|
|
297
|
+
total_elevation: 22629146,
|
|
298
|
+
average_elevation: 1143,
|
|
299
|
+
total_big_elevation: 2262914600000,
|
|
300
|
+
average_big_elevation: 114329035,
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
277
304
|
it(`limit - provided - ${databaseName}`, async () => {
|
|
278
305
|
// a cross join produces a Many to Many result.
|
|
279
306
|
// symmetric aggregate are needed on both sides of the join
|
|
@@ -919,6 +946,34 @@ SELECT row_to_json(finalStage) as row FROM __stage0 AS finalStage`);
|
|
|
919
946
|
`).malloyResultMatches(runtime, {'fun.t1': 52});
|
|
920
947
|
});
|
|
921
948
|
|
|
949
|
+
// not sure this works on all dialect.
|
|
950
|
+
it("stage names don't conflict- ${databaseName}", async () => {
|
|
951
|
+
await expect(`
|
|
952
|
+
source: airports is ${databaseName}.table('malloytest.state_facts') extend {
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
query: st0 is airports -> {
|
|
956
|
+
select: state
|
|
957
|
+
} -> {
|
|
958
|
+
select: *
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
query: st1 is airports -> {
|
|
962
|
+
select: state
|
|
963
|
+
} -> {
|
|
964
|
+
select: *
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
query: u is ${databaseName}.sql("""SELECT * FROM %{st0 } as x UNION ALL %{st1 }""") -> {
|
|
968
|
+
select: *
|
|
969
|
+
}
|
|
970
|
+
// # test.debug
|
|
971
|
+
run: u -> {
|
|
972
|
+
aggregate: c is count()
|
|
973
|
+
}
|
|
974
|
+
`).malloyResultMatches(runtime, {c: 102});
|
|
975
|
+
});
|
|
976
|
+
|
|
922
977
|
const sql1234 = `${databaseName}.sql('SELECT 1 as ${q`a`}, 2 as ${q`b`} UNION ALL SELECT 3, 4')`;
|
|
923
978
|
|
|
924
979
|
it(`sql as source - ${databaseName}`, async () => {
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright Contributors to the Malloy project
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {RuntimeList, allDatabases} from '../../runtimes';
|
|
7
|
+
import '../../util/db-jest-matchers';
|
|
8
|
+
import {databasesFromEnvironmentOr} from '../../util';
|
|
9
|
+
import {TestSelect} from '../../test-select';
|
|
10
|
+
|
|
11
|
+
const runtimes = new RuntimeList(databasesFromEnvironmentOr(allDatabases));
|
|
12
|
+
|
|
13
|
+
describe.each(runtimes.runtimeList)('TestSelect for %s', (db, runtime) => {
|
|
14
|
+
const d = runtime.dialect;
|
|
15
|
+
const ts = new TestSelect(runtime.dialect);
|
|
16
|
+
|
|
17
|
+
// Basic Type Tests
|
|
18
|
+
test(`${db} inferred basic types`, async () => {
|
|
19
|
+
const sql = ts.generate(
|
|
20
|
+
{t_int: 1, t_string: 'a', t_bool: d.resultBoolean(true), t_float: 1.5},
|
|
21
|
+
{t_int: 2, t_string: 'b', t_bool: d.resultBoolean(false), t_float: 2.5}
|
|
22
|
+
);
|
|
23
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
24
|
+
{t_int: 1, t_string: 'a', t_bool: d.resultBoolean(true), t_float: 1.5},
|
|
25
|
+
{t_int: 2, t_string: 'b', t_bool: d.resultBoolean(false), t_float: 2.5},
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test(`${db} explicit type hints`, async () => {
|
|
30
|
+
const sql = ts.generate({
|
|
31
|
+
t_int: ts.mk_int(1),
|
|
32
|
+
t_float: ts.mk_float(1.5),
|
|
33
|
+
t_string: ts.mk_string('hello'),
|
|
34
|
+
t_bool: ts.mk_bool(true),
|
|
35
|
+
});
|
|
36
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
37
|
+
{
|
|
38
|
+
t_int: 1,
|
|
39
|
+
t_float: 1.5,
|
|
40
|
+
t_string: 'hello',
|
|
41
|
+
t_bool: d.resultBoolean(true),
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test(`${db} NULL handling with typed nulls`, async () => {
|
|
47
|
+
const sql = ts.generate({
|
|
48
|
+
t_int: ts.mk_int(null),
|
|
49
|
+
t_string: ts.mk_string(null),
|
|
50
|
+
t_bool: ts.mk_bool(null),
|
|
51
|
+
t_float: ts.mk_float(null),
|
|
52
|
+
});
|
|
53
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
54
|
+
{t_int: null, t_string: null, t_bool: null, t_float: null},
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test(`${db} mixed nulls and values`, async () => {
|
|
59
|
+
const sql = ts.generate(
|
|
60
|
+
{a: 1, b: ts.mk_int(null), c: 'hello'},
|
|
61
|
+
{a: ts.mk_int(null), b: 2, c: 'world'},
|
|
62
|
+
{a: 3, b: 4, c: ts.mk_string(null)}
|
|
63
|
+
);
|
|
64
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
65
|
+
{a: 1, b: null, c: 'hello'},
|
|
66
|
+
{a: null, b: 2, c: 'world'},
|
|
67
|
+
{a: 3, b: 4, c: null},
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Cast Behavior Tests
|
|
72
|
+
test(`${db} float casting`, async () => {
|
|
73
|
+
const sql = ts.generate({
|
|
74
|
+
f1: ts.mk_float(1.0),
|
|
75
|
+
f2: ts.mk_float(2.5),
|
|
76
|
+
});
|
|
77
|
+
// Verify SQL contains CAST for floats
|
|
78
|
+
expect(sql).toContain('CAST');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test(`${db} integer no unnecessary cast`, async () => {
|
|
82
|
+
const sql = ts.generate({
|
|
83
|
+
i1: ts.mk_int(1),
|
|
84
|
+
i2: ts.mk_int(2),
|
|
85
|
+
});
|
|
86
|
+
// Check that integers don't get unnecessary CAST (only in column alias)
|
|
87
|
+
const castCount = (sql.match(/CAST/g) || []).length;
|
|
88
|
+
expect(castCount).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Edge Cases
|
|
92
|
+
test(`${db} single row`, async () => {
|
|
93
|
+
const sql = ts.generate({a: 1, b: 'test'});
|
|
94
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
95
|
+
{a: 1, b: 'test'},
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Array Tests - Inferred
|
|
100
|
+
test(`${db} inferred arrays`, async () => {
|
|
101
|
+
const sql = ts.generate({
|
|
102
|
+
string_array: ['a', 'b', 'c'],
|
|
103
|
+
number_array: [1, 2, 3],
|
|
104
|
+
});
|
|
105
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
106
|
+
{string_array: ['a', 'b', 'c'], number_array: [1, 2, 3]},
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Array Tests - Explicit mk_array
|
|
111
|
+
test(`${db} explicit array creation with mk_array`, async () => {
|
|
112
|
+
const sql = ts.generate({
|
|
113
|
+
string_arr: ts.mk_array(['a', 'b', 'c']),
|
|
114
|
+
number_arr: ts.mk_array([1, 2, 3]),
|
|
115
|
+
typed_arr: ts.mk_array([ts.mk_int(1), ts.mk_int(2), ts.mk_int(3)]),
|
|
116
|
+
});
|
|
117
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
118
|
+
{
|
|
119
|
+
string_arr: ['a', 'b', 'c'],
|
|
120
|
+
number_arr: [1, 2, 3],
|
|
121
|
+
typed_arr: [1, 2, 3],
|
|
122
|
+
},
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// mysql and postgres don't have literal records that Malloy can read
|
|
127
|
+
// so when reading a record, it will just see json
|
|
128
|
+
const testRecords = db !== 'mysql' && db !== 'postgres';
|
|
129
|
+
|
|
130
|
+
describe('tests involving records', () => {
|
|
131
|
+
// Record Tests - Inferred
|
|
132
|
+
test.when(testRecords)(`${db} simple inferred records`, async () => {
|
|
133
|
+
const sql = ts.generate({
|
|
134
|
+
person: {name: 'Alice', age: 30},
|
|
135
|
+
});
|
|
136
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(
|
|
137
|
+
runtime,
|
|
138
|
+
{
|
|
139
|
+
'person/name': 'Alice',
|
|
140
|
+
'person/age': 30,
|
|
141
|
+
}
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test.when(testRecords)(`${db} nested inferred records`, async () => {
|
|
146
|
+
const sql = ts.generate({
|
|
147
|
+
user: {
|
|
148
|
+
name: 'Bob',
|
|
149
|
+
address: {
|
|
150
|
+
street: '123 Main',
|
|
151
|
+
city: 'Boston',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
await expect(`run: ${db}.sql("""${sql}""")`).matchesRows(runtime, {
|
|
156
|
+
user: {name: 'Bob', address: {street: '123 Main', city: 'Boston'}},
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Record Tests - Explicit mk_record
|
|
161
|
+
test.when(testRecords)(
|
|
162
|
+
`${db} explicit record creation with mk_record`,
|
|
163
|
+
async () => {
|
|
164
|
+
const sql = ts.generate({
|
|
165
|
+
person: ts.mk_record({
|
|
166
|
+
name: ts.mk_string('Alice'),
|
|
167
|
+
age: ts.mk_int(30),
|
|
168
|
+
active: ts.mk_bool(true),
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
await expect(`run: ${db}.sql("""${sql}""")`).matchesRows(runtime, {
|
|
172
|
+
person: {name: 'Alice', age: 30, active: d.resultBoolean(true)},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
test.when(testRecords)(`${db} nested records with mk_record`, async () => {
|
|
178
|
+
const sql = ts.generate({
|
|
179
|
+
user: ts.mk_record({
|
|
180
|
+
name: ts.mk_string('Bob'),
|
|
181
|
+
address: ts.mk_record({
|
|
182
|
+
street: ts.mk_string('123 Main'),
|
|
183
|
+
city: ts.mk_string('Boston'),
|
|
184
|
+
zip: ts.mk_int(12345),
|
|
185
|
+
}),
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
await expect(`run: ${db}.sql("""${sql}""")`).matchesRows(runtime, {
|
|
189
|
+
user: {
|
|
190
|
+
name: 'Bob',
|
|
191
|
+
address: {street: '123 Main', city: 'Boston', zip: 12345},
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test.when(testRecords)(`${db} records with arrays`, async () => {
|
|
197
|
+
const sql = ts.generate({
|
|
198
|
+
data: {
|
|
199
|
+
name: 'Test',
|
|
200
|
+
values: [1, 2, 3],
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
await expect(`run: ${db}.sql("""${sql}""")`).matchesRows(runtime, {
|
|
204
|
+
data: {name: 'Test', values: [1, 2, 3]},
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Array of Records Tests - Inferred
|
|
209
|
+
test.when(testRecords)(`${db} inferred repeated records`, async () => {
|
|
210
|
+
const sql = ts.generate({
|
|
211
|
+
items: [
|
|
212
|
+
{sku: 'ABC', qty: 2},
|
|
213
|
+
{sku: 'DEF', qty: 3},
|
|
214
|
+
],
|
|
215
|
+
});
|
|
216
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(
|
|
217
|
+
runtime,
|
|
218
|
+
[
|
|
219
|
+
{
|
|
220
|
+
items: [
|
|
221
|
+
{sku: 'ABC', qty: 2},
|
|
222
|
+
{sku: 'DEF', qty: 3},
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Array of Records Tests - Explicit
|
|
230
|
+
test.when(testRecords)(
|
|
231
|
+
`${db} array of records with mk_array and mk_record`,
|
|
232
|
+
async () => {
|
|
233
|
+
const sql = ts.generate({
|
|
234
|
+
items: ts.mk_array([
|
|
235
|
+
ts.mk_record({sku: ts.mk_string('ABC'), qty: ts.mk_int(2)}),
|
|
236
|
+
ts.mk_record({sku: ts.mk_string('DEF'), qty: ts.mk_int(3)}),
|
|
237
|
+
]),
|
|
238
|
+
});
|
|
239
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(
|
|
240
|
+
runtime,
|
|
241
|
+
[
|
|
242
|
+
{
|
|
243
|
+
items: [
|
|
244
|
+
{sku: 'ABC', qty: 2},
|
|
245
|
+
{sku: 'DEF', qty: 3},
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
]
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Date/Time Tests
|
|
255
|
+
test(`${db} date literals`, async () => {
|
|
256
|
+
const sql = ts.generate({
|
|
257
|
+
d1: ts.mk_date('2024-01-15'),
|
|
258
|
+
d2: ts.mk_date('2024-12-31'),
|
|
259
|
+
});
|
|
260
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
261
|
+
{d1: new Date('2024-01-15'), d2: new Date('2024-12-31')},
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test.when(db !== 'presto' && db !== 'trino')(
|
|
266
|
+
`${db} timestamp literals`,
|
|
267
|
+
async () => {
|
|
268
|
+
const sql = ts.generate({
|
|
269
|
+
ts1: ts.mk_timestamp('2024-01-15 10:30:00'),
|
|
270
|
+
ts2: ts.mk_timestamp('2024-12-31 23:59:59'),
|
|
271
|
+
});
|
|
272
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(
|
|
273
|
+
runtime,
|
|
274
|
+
[
|
|
275
|
+
{
|
|
276
|
+
ts1: new Date('2024-01-15T10:30:00Z'),
|
|
277
|
+
ts2: new Date('2024-12-31T23:59:59Z'),
|
|
278
|
+
},
|
|
279
|
+
]
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
test(`${db} null dates and timestamps`, async () => {
|
|
285
|
+
const sql = ts.generate({
|
|
286
|
+
d: ts.mk_date(null),
|
|
287
|
+
ts: ts.mk_timestamp(null),
|
|
288
|
+
});
|
|
289
|
+
await expect(`run: ${db}.sql("""${sql}""")`).malloyResultMatches(runtime, [
|
|
290
|
+
{d: null, ts: null},
|
|
291
|
+
]);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
afterAll(async () => {
|
|
296
|
+
await runtimes.closeAll();
|
|
297
|
+
});
|
|
@@ -21,9 +21,6 @@
|
|
|
21
21
|
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
-
/* eslint-disable no-console */
|
|
26
|
-
|
|
27
24
|
import '../../util/db-jest-matchers';
|
|
28
25
|
import {RuntimeList} from '../../runtimes';
|
|
29
26
|
import {describeIfDatabaseAvailable} from '../../util';
|
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright Contributors to the Malloy project
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
Dialect,
|
|
8
|
+
QueryInfo,
|
|
9
|
+
AtomicTypeDef,
|
|
10
|
+
ArrayLiteralNode,
|
|
11
|
+
RecordLiteralNode,
|
|
12
|
+
Expr,
|
|
13
|
+
TypecastExpr,
|
|
14
|
+
FieldDef,
|
|
15
|
+
} from '@malloydata/malloy';
|
|
16
|
+
|
|
17
|
+
import {constantExprToSQL, mkFieldDef} from '@malloydata/malloy';
|
|
18
|
+
|
|
19
|
+
interface TypedValue {
|
|
20
|
+
expr: Expr;
|
|
21
|
+
malloyType: AtomicTypeDef;
|
|
22
|
+
needsCast?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Valid value types for inferrable types
|
|
26
|
+
type PrimitiveValue = string | number | boolean | null;
|
|
27
|
+
|
|
28
|
+
// Test data value types
|
|
29
|
+
type TestValue =
|
|
30
|
+
| PrimitiveValue
|
|
31
|
+
| TestValue[]
|
|
32
|
+
| {[key: string]: TestValue}
|
|
33
|
+
| TypedValue;
|
|
34
|
+
|
|
35
|
+
const nullExpr: Expr = {node: 'null'};
|
|
36
|
+
|
|
37
|
+
interface TestDataRow {
|
|
38
|
+
[columnName: string]: TestValue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* TestSelect - Generate dialect-specific SQL test data from JavaScript objects
|
|
43
|
+
*
|
|
44
|
+
* TestSelect provides a simple, type-safe way to generate SQL SELECT statements
|
|
45
|
+
* that produce test data across different SQL dialects. It handles dialect-specific
|
|
46
|
+
* differences in literal syntax, type casting, and complex types (arrays/records).
|
|
47
|
+
*
|
|
48
|
+
* @example Basic usage with inferred types
|
|
49
|
+
* ```typescript
|
|
50
|
+
* const ts = new TestSelect(dialect);
|
|
51
|
+
* const sql = ts.generate(
|
|
52
|
+
* {id: 1, name: "Alice", active: true},
|
|
53
|
+
* {id: 2, name: "Bob", active: false}
|
|
54
|
+
* );
|
|
55
|
+
* // Generates: SELECT 1 AS "id", 'Alice' AS "name", true AS "active"
|
|
56
|
+
* // UNION ALL SELECT 2, 'Bob', false
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* @example Explicit type hints for precision
|
|
60
|
+
* ```typescript
|
|
61
|
+
* const sql = ts.generate({
|
|
62
|
+
* id: ts.mk_int(1),
|
|
63
|
+
* score: ts.mk_float(95.5), // Forces float type (gets CAST)
|
|
64
|
+
* name: ts.mk_string("Test"),
|
|
65
|
+
* active: ts.mk_bool(true),
|
|
66
|
+
* created: ts.mk_timestamp('2024-01-15 10:30:00'),
|
|
67
|
+
* birthday: ts.mk_date('1990-05-20')
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example Handling NULL values with proper typing
|
|
72
|
+
* ```typescript
|
|
73
|
+
* const sql = ts.generate({
|
|
74
|
+
* id: ts.mk_int(1),
|
|
75
|
+
* email: ts.mk_string(null), // Typed NULL - generates CAST(NULL AS VARCHAR)
|
|
76
|
+
* score: ts.mk_float(null) // Typed NULL - generates CAST(NULL AS FLOAT)
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* @example Arrays and records (for dialects that support them)
|
|
81
|
+
* ```typescript
|
|
82
|
+
* const sql = ts.generate({
|
|
83
|
+
* tags: ['red', 'blue', 'green'], // Inferred array
|
|
84
|
+
* scores: ts.mk_array([85, 90, 95]), // Explicit array
|
|
85
|
+
* address: {street: '123 Main', city: 'NYC'}, // Inferred record
|
|
86
|
+
* user: ts.mk_record({ // Explicit record
|
|
87
|
+
* name: ts.mk_string('Alice'),
|
|
88
|
+
* age: ts.mk_int(30)
|
|
89
|
+
* })
|
|
90
|
+
* });
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* @example Arrays of records (repeated records in Malloy)
|
|
94
|
+
* ```typescript
|
|
95
|
+
* const sql = ts.generate({
|
|
96
|
+
* orders: [
|
|
97
|
+
* {item: 'Widget', qty: 5, price: 10.00},
|
|
98
|
+
* {item: 'Gadget', qty: 2, price: 25.00}
|
|
99
|
+
* ]
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* Type inference rules:
|
|
104
|
+
* - JavaScript number → INTEGER if whole number, FLOAT if decimal
|
|
105
|
+
* - JavaScript string → VARCHAR/TEXT (dialect decides)
|
|
106
|
+
* - JavaScript boolean → BOOLEAN
|
|
107
|
+
* - JavaScript Date → TIMESTAMP
|
|
108
|
+
* - JavaScript array → ARRAY (if supported by dialect)
|
|
109
|
+
* - JavaScript object → RECORD/STRUCT (if supported by dialect)
|
|
110
|
+
* - null/undefined → defaults to STRING type (use mk_* functions for typed nulls)
|
|
111
|
+
*
|
|
112
|
+
* CAST behavior:
|
|
113
|
+
* - NULLs always get CAST to ensure proper typing
|
|
114
|
+
* - Floats always get CAST to ensure they're treated as floating point
|
|
115
|
+
* - Other types generally don't need CAST (dialect handles conversion)
|
|
116
|
+
*
|
|
117
|
+
* Important notes:
|
|
118
|
+
* - First row determines column types when using inference
|
|
119
|
+
* - Use mk_* functions for nulls in first row to ensure correct types
|
|
120
|
+
* - Not all dialects support arrays and records
|
|
121
|
+
* - Timestamps are generated in UTC by default
|
|
122
|
+
* - Large integers can be passed as strings: mk_int('9223372036854775807')
|
|
123
|
+
*
|
|
124
|
+
* @param dialect - The SQL dialect to generate for (from Malloy)
|
|
125
|
+
* @param queryTimezone - Timezone for timestamp literals (default: 'UTC')
|
|
126
|
+
*/
|
|
127
|
+
export class TestSelect {
|
|
128
|
+
private qi: QueryInfo;
|
|
129
|
+
|
|
130
|
+
constructor(
|
|
131
|
+
private dialect: Dialect,
|
|
132
|
+
queryTimezone = 'UTC'
|
|
133
|
+
) {
|
|
134
|
+
this.qi = {queryTimezone};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ============= Type hint methods =============
|
|
138
|
+
|
|
139
|
+
mk_int(value: number | string | null): TypedValue {
|
|
140
|
+
const malloyType: AtomicTypeDef = {type: 'number', numberType: 'integer'};
|
|
141
|
+
|
|
142
|
+
if (value === null) {
|
|
143
|
+
const castExpr: TypecastExpr = {
|
|
144
|
+
node: 'cast',
|
|
145
|
+
e: nullExpr,
|
|
146
|
+
dstType: {type: 'number', numberType: 'integer'},
|
|
147
|
+
safe: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
expr: castExpr,
|
|
152
|
+
malloyType,
|
|
153
|
+
needsCast: true,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
expr: {
|
|
159
|
+
node: 'numberLiteral',
|
|
160
|
+
literal: String(value),
|
|
161
|
+
},
|
|
162
|
+
malloyType,
|
|
163
|
+
needsCast: false,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
mk_float(value: number | string | null): TypedValue {
|
|
168
|
+
const malloyType: AtomicTypeDef = {type: 'number', numberType: 'float'};
|
|
169
|
+
|
|
170
|
+
if (value === null) {
|
|
171
|
+
const castExpr: TypecastExpr = {
|
|
172
|
+
node: 'cast',
|
|
173
|
+
e: nullExpr,
|
|
174
|
+
dstType: {type: 'number', numberType: 'float'},
|
|
175
|
+
safe: false,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
expr: castExpr,
|
|
180
|
+
malloyType,
|
|
181
|
+
needsCast: true,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
expr: {
|
|
187
|
+
node: 'numberLiteral',
|
|
188
|
+
literal: String(value),
|
|
189
|
+
},
|
|
190
|
+
malloyType,
|
|
191
|
+
needsCast: true, // floats always need cast
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
mk_string(value: string | null): TypedValue {
|
|
196
|
+
const malloyType: AtomicTypeDef = {type: 'string'};
|
|
197
|
+
|
|
198
|
+
if (value === null) {
|
|
199
|
+
const castExpr: TypecastExpr = {
|
|
200
|
+
node: 'cast',
|
|
201
|
+
e: nullExpr,
|
|
202
|
+
dstType: {type: 'string'},
|
|
203
|
+
safe: false,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
expr: castExpr,
|
|
208
|
+
malloyType,
|
|
209
|
+
needsCast: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
expr: {
|
|
215
|
+
node: 'stringLiteral',
|
|
216
|
+
literal: value,
|
|
217
|
+
},
|
|
218
|
+
malloyType,
|
|
219
|
+
needsCast: false,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
mk_bool(value: boolean | null): TypedValue {
|
|
224
|
+
const malloyType: AtomicTypeDef = {type: 'boolean'};
|
|
225
|
+
|
|
226
|
+
if (value === null) {
|
|
227
|
+
const castExpr: TypecastExpr = {
|
|
228
|
+
node: 'cast',
|
|
229
|
+
e: nullExpr,
|
|
230
|
+
dstType: {type: 'boolean'},
|
|
231
|
+
safe: false,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
expr: castExpr,
|
|
236
|
+
malloyType,
|
|
237
|
+
needsCast: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
expr: value ? {node: 'true'} : {node: 'false'},
|
|
243
|
+
malloyType,
|
|
244
|
+
needsCast: false,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
mk_date(value: string | null): TypedValue {
|
|
249
|
+
const malloyType: AtomicTypeDef = {type: 'date'};
|
|
250
|
+
|
|
251
|
+
if (value === null) {
|
|
252
|
+
const castExpr: TypecastExpr = {
|
|
253
|
+
node: 'cast',
|
|
254
|
+
e: nullExpr,
|
|
255
|
+
dstType: {type: 'date'},
|
|
256
|
+
safe: false,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
expr: castExpr,
|
|
261
|
+
malloyType,
|
|
262
|
+
needsCast: true,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
expr: {
|
|
268
|
+
node: 'timeLiteral',
|
|
269
|
+
literal: value,
|
|
270
|
+
typeDef: {type: 'date'},
|
|
271
|
+
},
|
|
272
|
+
malloyType,
|
|
273
|
+
needsCast: false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
mk_timestamp(value: string | null): TypedValue {
|
|
278
|
+
const malloyType: AtomicTypeDef = {type: 'timestamp'};
|
|
279
|
+
|
|
280
|
+
if (value === null) {
|
|
281
|
+
const castExpr: TypecastExpr = {
|
|
282
|
+
node: 'cast',
|
|
283
|
+
e: nullExpr,
|
|
284
|
+
dstType: {type: 'timestamp'},
|
|
285
|
+
safe: false,
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
expr: castExpr,
|
|
290
|
+
malloyType,
|
|
291
|
+
needsCast: true,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
expr: {
|
|
297
|
+
node: 'timeLiteral',
|
|
298
|
+
literal: value,
|
|
299
|
+
typeDef: {type: 'timestamp'},
|
|
300
|
+
},
|
|
301
|
+
malloyType,
|
|
302
|
+
needsCast: false,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
mk_array(values: TestValue[]): TypedValue {
|
|
307
|
+
if (values.length === 0) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
'Cannot create empty array - need at least one element to infer type'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Convert all values to TypedValues
|
|
314
|
+
const typedValues = values.map(v => this.toTypedValue(v));
|
|
315
|
+
const firstElement = typedValues[0];
|
|
316
|
+
|
|
317
|
+
// Check if it's an array of records (repeated record)
|
|
318
|
+
if (firstElement.malloyType.type === 'record') {
|
|
319
|
+
// For repeated records, Malloy uses a special structure
|
|
320
|
+
const recordType = firstElement.malloyType;
|
|
321
|
+
if (!('fields' in recordType)) {
|
|
322
|
+
throw new Error('Record type missing fields');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const arrayExpr: ArrayLiteralNode = {
|
|
326
|
+
node: 'arrayLiteral',
|
|
327
|
+
kids: {values: typedValues.map(tv => tv.expr)},
|
|
328
|
+
typeDef: {
|
|
329
|
+
type: 'array',
|
|
330
|
+
elementTypeDef: {type: 'record_element'},
|
|
331
|
+
fields: recordType.fields,
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
expr: arrayExpr,
|
|
337
|
+
malloyType: {
|
|
338
|
+
type: 'array',
|
|
339
|
+
elementTypeDef: {type: 'record_element'},
|
|
340
|
+
fields: recordType.fields,
|
|
341
|
+
},
|
|
342
|
+
needsCast: false,
|
|
343
|
+
};
|
|
344
|
+
} else {
|
|
345
|
+
// For basic arrays (non-record elements)
|
|
346
|
+
const elementType = firstElement.malloyType;
|
|
347
|
+
|
|
348
|
+
const arrayExpr: ArrayLiteralNode = {
|
|
349
|
+
node: 'arrayLiteral',
|
|
350
|
+
kids: {values: typedValues.map(tv => tv.expr)},
|
|
351
|
+
typeDef: {type: 'array', elementTypeDef: elementType},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
return {
|
|
355
|
+
expr: arrayExpr,
|
|
356
|
+
malloyType: {type: 'array', elementTypeDef: elementType},
|
|
357
|
+
needsCast: false,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
mk_record(value: Record<string, TestValue>): TypedValue {
|
|
363
|
+
const kids: Record<string, Expr> = {};
|
|
364
|
+
const fields: FieldDef[] = []; // Explicitly type the array
|
|
365
|
+
|
|
366
|
+
for (const [key, val] of Object.entries(value)) {
|
|
367
|
+
const typed = this.toTypedValue(val);
|
|
368
|
+
fields.push(mkFieldDef(typed.malloyType, key));
|
|
369
|
+
kids[key] = typed.expr;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const recordExpr: RecordLiteralNode = {
|
|
373
|
+
node: 'recordLiteral',
|
|
374
|
+
kids,
|
|
375
|
+
typeDef: {type: 'record', fields},
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
expr: recordExpr,
|
|
380
|
+
malloyType: {type: 'record', fields},
|
|
381
|
+
needsCast: false,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============= Main generation method =============
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Generate SQL from test data rows
|
|
389
|
+
*/
|
|
390
|
+
generate(...rows: TestDataRow[]): string {
|
|
391
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
392
|
+
throw new Error('generate() requires a non-empty array of rows');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Collect all column names from all rows (preserving order from first occurrence)
|
|
396
|
+
const columnList: string[] = [];
|
|
397
|
+
const columnSet = new Set<string>();
|
|
398
|
+
for (const row of rows) {
|
|
399
|
+
for (const colName of Object.keys(row)) {
|
|
400
|
+
if (!columnSet.has(colName)) {
|
|
401
|
+
columnList.push(colName);
|
|
402
|
+
columnSet.add(colName);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const needsOrdering = rows.length > 1;
|
|
408
|
+
// Two reasons to quote a column name, neither matter here:
|
|
409
|
+
// 1) snowflake uppercases unquoted names
|
|
410
|
+
// 2) it is a reserved word in the dialect
|
|
411
|
+
const rowIdColumn = '__ts_n__';
|
|
412
|
+
|
|
413
|
+
// Generate SELECT statements
|
|
414
|
+
const selects = rows.map((row, idx) => {
|
|
415
|
+
const fields: string[] = [];
|
|
416
|
+
|
|
417
|
+
for (const colName of columnList) {
|
|
418
|
+
const value = row[colName] ?? null;
|
|
419
|
+
const typedValue = this.toTypedValue(value);
|
|
420
|
+
const sql = this.exprToSQL(typedValue.expr);
|
|
421
|
+
|
|
422
|
+
if (idx === 0) {
|
|
423
|
+
// First row: include column aliases and explicit casts if needed
|
|
424
|
+
const quotedName = this.dialect.sqlMaybeQuoteIdentifier(colName);
|
|
425
|
+
if (typedValue.needsCast) {
|
|
426
|
+
const sqlType = this.dialect.malloyTypeToSQLType(
|
|
427
|
+
typedValue.malloyType
|
|
428
|
+
);
|
|
429
|
+
fields.push(`CAST(${sql} AS ${sqlType}) AS ${quotedName}`);
|
|
430
|
+
} else {
|
|
431
|
+
fields.push(`${sql} AS ${quotedName}`);
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
// Subsequent rows: just the values in same order
|
|
435
|
+
fields.push(sql);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Add row ID at the end if we have multiple rows
|
|
440
|
+
if (needsOrdering) {
|
|
441
|
+
if (idx === 0) {
|
|
442
|
+
fields.push(`${idx} AS ${rowIdColumn}`);
|
|
443
|
+
} else {
|
|
444
|
+
fields.push(`${idx}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return `SELECT ${fields.join(', ')}`;
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
// Single row: just return the SELECT
|
|
452
|
+
if (!needsOrdering) {
|
|
453
|
+
return selects[0] + '\n';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Multiple rows: double wrap - inner for sorting, outer for column selection
|
|
457
|
+
const quotedColumns = columnList
|
|
458
|
+
.map(col => this.dialect.sqlMaybeQuoteIdentifier(col))
|
|
459
|
+
.join(', ');
|
|
460
|
+
const innerQuery = selects.join('\nUNION ALL ');
|
|
461
|
+
|
|
462
|
+
// Generate ORDER BY based on dialect preference
|
|
463
|
+
let orderByClause: string;
|
|
464
|
+
if (this.dialect.orderByClause === 'ordinal') {
|
|
465
|
+
// ORDER BY position (column count + 1 since row_id is last)
|
|
466
|
+
orderByClause = `ORDER BY ${columnList.length + 1}`;
|
|
467
|
+
} else if (this.dialect.orderByClause === 'output_name') {
|
|
468
|
+
// ORDER BY column name
|
|
469
|
+
orderByClause = `ORDER BY ${rowIdColumn}`;
|
|
470
|
+
} else {
|
|
471
|
+
// ORDER BY expression - just use column name (qualified would be t_sorted.__ts_row_id__)
|
|
472
|
+
orderByClause = `ORDER BY ${rowIdColumn}`;
|
|
473
|
+
}
|
|
474
|
+
// Presto/Trino ignores ORDER BY on a subquery without LIMIT
|
|
475
|
+
orderByClause += ` LIMIT ${rows.length}`;
|
|
476
|
+
|
|
477
|
+
const sql = `SELECT ${quotedColumns}\nFROM (\n SELECT *\n FROM (\n${innerQuery}\n ) AS t_sorted\n ${orderByClause}\n) AS t_result\n`;
|
|
478
|
+
|
|
479
|
+
return sql;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ============= Private helper methods =============
|
|
483
|
+
|
|
484
|
+
private toTypedValue(value: TestValue): TypedValue {
|
|
485
|
+
// If already typed, return it
|
|
486
|
+
if (this.isTypedValue(value)) {
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// Handle null/undefined
|
|
491
|
+
if (value === null) {
|
|
492
|
+
return {
|
|
493
|
+
expr: nullExpr,
|
|
494
|
+
malloyType: {type: 'sql native'}, // to ensure a cast happens
|
|
495
|
+
needsCast: true,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Infer from JavaScript type
|
|
500
|
+
if (typeof value === 'string') {
|
|
501
|
+
return this.mk_string(value);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (typeof value === 'boolean') {
|
|
505
|
+
return this.mk_bool(value);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (typeof value === 'number') {
|
|
509
|
+
if (Number.isInteger(value)) {
|
|
510
|
+
return this.mk_int(value);
|
|
511
|
+
} else {
|
|
512
|
+
return this.mk_float(value);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (Array.isArray(value)) {
|
|
517
|
+
return this.mk_array(value);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (typeof value === 'object' && Object.keys(value).length > 0) {
|
|
521
|
+
return this.mk_record(value);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
throw new Error(`Cannot convert value to TypedValue: ${value}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
private exprToSQL(expr: Expr): string {
|
|
528
|
+
const result = constantExprToSQL(expr, this.dialect, {});
|
|
529
|
+
if (result.sql) {
|
|
530
|
+
return result.sql;
|
|
531
|
+
}
|
|
532
|
+
if (result.error) {
|
|
533
|
+
throw new Error(`Failed to generate SQL: ${result.error}`);
|
|
534
|
+
}
|
|
535
|
+
throw new Error(`Error in SQL generation for ${JSON.stringify(expr)}`);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private isTypedValue(value: unknown): value is TypedValue {
|
|
539
|
+
return (
|
|
540
|
+
value !== null &&
|
|
541
|
+
typeof value === 'object' &&
|
|
542
|
+
'expr' in value &&
|
|
543
|
+
'malloyType' in value
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/*
|
|
549
|
+
Example usage:
|
|
550
|
+
|
|
551
|
+
const testSelect = new TestSelect(dialect);
|
|
552
|
+
|
|
553
|
+
const userSQL = testSelect.generate([
|
|
554
|
+
{
|
|
555
|
+
id: testSelect.mk_int(1),
|
|
556
|
+
name: "bob",
|
|
557
|
+
email: "bob@example.com",
|
|
558
|
+
score: testSelect.mk_float(85.5),
|
|
559
|
+
is_active: testSelect.mk_bool(true),
|
|
560
|
+
created_at: testSelect.mk_timestamp('2024-01-15 14:30:00'),
|
|
561
|
+
signup_date: testSelect.mk_date('2024-01-15'),
|
|
562
|
+
tags: ["admin", "user"], // Inferred as array
|
|
563
|
+
settings: { // Inferred as record
|
|
564
|
+
theme: "dark",
|
|
565
|
+
notifications: true
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
id: testSelect.mk_int(2),
|
|
570
|
+
name: "alice",
|
|
571
|
+
email: testSelect.mk_string(null), // Typed NULL
|
|
572
|
+
score: testSelect.mk_float(92.0),
|
|
573
|
+
is_active: testSelect.mk_bool(false),
|
|
574
|
+
created_at: testSelect.mk_timestamp('2024-01-16 09:00:00'),
|
|
575
|
+
signup_date: testSelect.mk_date('2024-01-16'),
|
|
576
|
+
tags: ["user"],
|
|
577
|
+
settings: {
|
|
578
|
+
theme: "light",
|
|
579
|
+
notifications: false
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
const orderSQL = testSelect.generate([
|
|
585
|
+
{
|
|
586
|
+
id: 1, // Inferred as integer
|
|
587
|
+
user_id: testSelect.mk_int(1),
|
|
588
|
+
amount: 99.99, // Inferred as float
|
|
589
|
+
status: "pending",
|
|
590
|
+
items: [
|
|
591
|
+
{sku: "ABC", qty: 2, price: 10.00},
|
|
592
|
+
{sku: "DEF", qty: 1, price: 20.00}
|
|
593
|
+
]
|
|
594
|
+
}
|
|
595
|
+
]);
|
|
596
|
+
|
|
597
|
+
// Use in Malloy:
|
|
598
|
+
const malloySource = `
|
|
599
|
+
source: users is duckdb.sql("""${userSQL}""")
|
|
600
|
+
source: orders is duckdb.sql("""${orderSQL}""")
|
|
601
|
+
`;
|
|
602
|
+
*/
|