@malloydata/malloy-tests 0.0.309 → 0.0.310

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.310",
25
+ "@malloydata/db-duckdb": "0.0.310",
26
+ "@malloydata/db-postgres": "0.0.310",
27
+ "@malloydata/db-snowflake": "0.0.310",
28
+ "@malloydata/db-trino": "0.0.310",
29
+ "@malloydata/malloy": "0.0.310",
30
+ "@malloydata/malloy-tag": "0.0.310",
31
+ "@malloydata/render": "0.0.310",
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.310"
42
42
  }
@@ -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
+ */