@malloydata/malloy-tests 0.0.308 → 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.
|
|
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.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.
|
|
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';
|
|
@@ -120,6 +120,19 @@ describe('Postgres tests', () => {
|
|
|
120
120
|
).malloyResultMatches(runtime, {abc: 'a', abc3: 'a3'});
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
+
it('can compute symmetric aggregates on double precisions numbers', async () => {
|
|
124
|
+
await expect(`source: values is postgres.sql("""
|
|
125
|
+
SELECT 1::DOUBLE PRECISION as val, 1 as id
|
|
126
|
+
""") extend { measure: total_value is val.sum() }
|
|
127
|
+
source: thing is postgres.sql(""" SELECT 1 as id """) extend {
|
|
128
|
+
join_one: values on values.id = id
|
|
129
|
+
}
|
|
130
|
+
run: thing -> {
|
|
131
|
+
group_by: id
|
|
132
|
+
aggregate: tenx is 10 * values.total_value
|
|
133
|
+
}
|
|
134
|
+
`).malloyResultMatches(runtime, {tenx: 10});
|
|
135
|
+
});
|
|
123
136
|
describe('time', () => {
|
|
124
137
|
const zone = 'America/Mexico_City'; // -06:00 no DST
|
|
125
138
|
const zone_2020 = DateTime.fromObject(
|
|
@@ -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
|
+
*/
|