@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 CHANGED
@@ -21,14 +21,14 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@jest/globals": "^29.4.3",
24
- "@malloydata/db-bigquery": "0.0.309",
25
- "@malloydata/db-duckdb": "0.0.309",
26
- "@malloydata/db-postgres": "0.0.309",
27
- "@malloydata/db-snowflake": "0.0.309",
28
- "@malloydata/db-trino": "0.0.309",
29
- "@malloydata/malloy": "0.0.309",
30
- "@malloydata/malloy-tag": "0.0.309",
31
- "@malloydata/render": "0.0.309",
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.309"
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
@@ -0,0 +1,7 @@
1
+ #! /bin/bash
2
+
3
+ # clear tmp files
4
+ rm -rf .tmp
5
+
6
+ # stop container
7
+ docker rm -f postgres-malloy
@@ -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
+ */