@malloydata/malloy 0.0.326 → 0.0.327
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/dist/api/core.js +2 -0
- package/dist/api/index.d.ts +1 -0
- package/dist/api/index.js +1 -0
- package/dist/api/row_data_utils.d.ts +30 -0
- package/dist/api/row_data_utils.js +87 -0
- package/dist/api/util.js +20 -38
- package/dist/dialect/dialect.d.ts +36 -0
- package/dist/dialect/dialect.js +28 -1
- package/dist/dialect/duckdb/duckdb.d.ts +2 -1
- package/dist/dialect/duckdb/duckdb.js +12 -3
- package/dist/dialect/mysql/mysql.js +18 -4
- package/dist/dialect/pg_impl.d.ts +1 -0
- package/dist/dialect/pg_impl.js +2 -0
- package/dist/dialect/postgres/postgres.js +4 -1
- package/dist/dialect/snowflake/snowflake.d.ts +2 -1
- package/dist/dialect/snowflake/snowflake.js +37 -15
- package/dist/dialect/standardsql/standardsql.d.ts +2 -1
- package/dist/dialect/standardsql/standardsql.js +9 -3
- package/dist/dialect/trino/trino.js +10 -2
- package/dist/lang/ast/expressions/expr-avg.d.ts +5 -0
- package/dist/lang/ast/expressions/expr-avg.js +9 -0
- package/dist/lang/ast/expressions/expr-coalesce.js +6 -0
- package/dist/lang/ast/expressions/expr-number.d.ts +14 -1
- package/dist/lang/ast/expressions/expr-number.js +71 -6
- package/dist/lang/ast/expressions/expr-props.d.ts +1 -1
- package/dist/lang/ast/expressions/pick-when.js +10 -3
- package/dist/lang/ast/types/expression-def.js +36 -2
- package/dist/lang/parse-log.d.ts +1 -0
- package/dist/lang/test/test-translator.js +2 -0
- package/dist/malloy.d.ts +23 -2
- package/dist/malloy.js +204 -41
- package/dist/model/malloy_types.d.ts +2 -2
- package/dist/model/query_model_impl.js +5 -1
- package/dist/test/cellsToObject.d.ts +6 -0
- package/dist/test/cellsToObject.js +111 -0
- package/dist/test/index.d.ts +7 -0
- package/dist/test/index.js +16 -20
- package/dist/test/matchers.d.ts +10 -0
- package/dist/test/matchers.js +17 -0
- package/dist/test/resultMatchers.d.ts +42 -0
- package/dist/test/resultMatchers.js +722 -0
- package/dist/test/runQuery.d.ts +31 -0
- package/dist/test/runQuery.js +67 -0
- package/dist/test/test-models.d.ts +77 -0
- package/dist/test/test-models.js +319 -0
- package/dist/test/test-values.d.ts +66 -0
- package/dist/test/test-values.js +208 -0
- package/dist/to_stable.js +3 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +9 -5
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/*
|
|
3
|
+
* Copyright Contributors to the Malloy project
|
|
4
|
+
* SPDX-License-Identifier: MIT
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const __1 = require("..");
|
|
8
|
+
const util_1 = require("util");
|
|
9
|
+
const cellsToObject_1 = require("./cellsToObject");
|
|
10
|
+
function errInfo(e) {
|
|
11
|
+
var _a;
|
|
12
|
+
let err = '';
|
|
13
|
+
const trace = (_a = e.stack) !== null && _a !== void 0 ? _a : '';
|
|
14
|
+
if (e.message && !trace.includes(e.message)) {
|
|
15
|
+
err = `ERROR: ${e.message}\n`;
|
|
16
|
+
}
|
|
17
|
+
if (e.stack) {
|
|
18
|
+
err += `STACK: ${e.stack}\n`;
|
|
19
|
+
}
|
|
20
|
+
return err;
|
|
21
|
+
}
|
|
22
|
+
function errorLogToString(src, msgs) {
|
|
23
|
+
let lovely = '';
|
|
24
|
+
let lineNo = 0;
|
|
25
|
+
for (const line of src.split('\n')) {
|
|
26
|
+
lovely += ` | ${line}\n`;
|
|
27
|
+
for (const entry of msgs) {
|
|
28
|
+
if (entry.at) {
|
|
29
|
+
if (entry.at.range.start.line === lineNo) {
|
|
30
|
+
const charFrom = entry.at.range.start.character;
|
|
31
|
+
lovely += `!!!!! ${' '.repeat(charFrom)}^ ${entry.message}\n`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
lineNo += 1;
|
|
36
|
+
}
|
|
37
|
+
return lovely;
|
|
38
|
+
}
|
|
39
|
+
function looseEqual(a, b) {
|
|
40
|
+
if (a === b) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
if (typeof a === 'number' && typeof b === 'bigint') {
|
|
44
|
+
try {
|
|
45
|
+
return BigInt(a) === b;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (typeof a === 'bigint' && typeof b === 'number') {
|
|
52
|
+
try {
|
|
53
|
+
return a === BigInt(b);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// MySQL infers booleans as BIGINT in UNION contexts
|
|
60
|
+
if (typeof a === 'boolean' && typeof b === 'bigint') {
|
|
61
|
+
return a === (b !== BigInt(0));
|
|
62
|
+
}
|
|
63
|
+
if (typeof a === 'bigint' && typeof b === 'boolean') {
|
|
64
|
+
return (a !== BigInt(0)) === b;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
async function runQueryInternal(tm, src) {
|
|
69
|
+
let query;
|
|
70
|
+
let queryTestTag = undefined;
|
|
71
|
+
try {
|
|
72
|
+
query = tm.model.loadQuery(src);
|
|
73
|
+
const queryTags = (await query.getPreparedQuery()).tagParse().tag;
|
|
74
|
+
queryTestTag = queryTags.tag('test');
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
// Add line numbers, helpful if failure is a compiler error
|
|
78
|
+
const queryText = src
|
|
79
|
+
.split('\n')
|
|
80
|
+
.map((line, index) => `${(index + 1).toString().padStart(4)}: ${line}`)
|
|
81
|
+
.join('\n');
|
|
82
|
+
return {
|
|
83
|
+
fail: {
|
|
84
|
+
pass: false,
|
|
85
|
+
message: () => `Could not prepare query to run:\n${queryText}\n\n${errInfo(e)}`,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const result = await query.run();
|
|
91
|
+
// Use wrapResult to normalize data across all databases
|
|
92
|
+
// This handles BigQuery timestamp wrappers, MySQL boolean 0/1, etc.
|
|
93
|
+
const malloyResult = __1.API.util.wrapResult(result);
|
|
94
|
+
if (!malloyResult.data) {
|
|
95
|
+
return {
|
|
96
|
+
fail: { pass: false, message: () => 'Query returned no data' },
|
|
97
|
+
query,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// Return both raw cells+schema (for schema-aware matching) and converted objects (for debug output)
|
|
101
|
+
const dataObjects = (0, cellsToObject_1.cellsToObjects)(malloyResult.data, malloyResult.schema);
|
|
102
|
+
return {
|
|
103
|
+
data: malloyResult.data,
|
|
104
|
+
schema: malloyResult.schema,
|
|
105
|
+
dataObjects,
|
|
106
|
+
queryTestTag,
|
|
107
|
+
query,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
const cleanSrc = src.replace(/^\n+/m, '').trimEnd();
|
|
112
|
+
let failMsg = `QUERY RUN FAILED:\n${cleanSrc}`;
|
|
113
|
+
if (e instanceof __1.MalloyError) {
|
|
114
|
+
failMsg = `Error in query compilation\n${errorLogToString(src, e.problems)}`;
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
try {
|
|
118
|
+
failMsg += `\nSQL: ${await query.getSQL()}\n`;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
failMsg += '\nSQL FOR QUERY COULD NOT BE COMPUTED\n';
|
|
122
|
+
}
|
|
123
|
+
failMsg += errInfo(e);
|
|
124
|
+
}
|
|
125
|
+
return { fail: { pass: false, message: () => failMsg }, query };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function humanReadable(thing) {
|
|
129
|
+
return (0, util_1.inspect)(thing, { breakLength: 72, depth: Infinity });
|
|
130
|
+
}
|
|
131
|
+
function matchFail(path, expected, actual) {
|
|
132
|
+
return {
|
|
133
|
+
pass: false,
|
|
134
|
+
path,
|
|
135
|
+
expected: humanReadable(expected),
|
|
136
|
+
actual: humanReadable(actual),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function matchFailStr(path, expected, actual) {
|
|
140
|
+
return { pass: false, path, expected, actual };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get the type kind from a FieldInfo.
|
|
144
|
+
* Returns undefined for joins and views (which have schemas, not types).
|
|
145
|
+
*/
|
|
146
|
+
function getTypeKind(fieldInfo) {
|
|
147
|
+
if (fieldInfo.kind === 'join' || fieldInfo.kind === 'view') {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return fieldInfo.type.kind;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Convert a cell to a plain JS value for error messages.
|
|
154
|
+
*/
|
|
155
|
+
function cellToValue(cell) {
|
|
156
|
+
switch (cell.kind) {
|
|
157
|
+
case 'null_cell':
|
|
158
|
+
return null;
|
|
159
|
+
case 'string_cell':
|
|
160
|
+
return cell.string_value;
|
|
161
|
+
case 'number_cell':
|
|
162
|
+
return cell.number_value;
|
|
163
|
+
case 'big_number_cell':
|
|
164
|
+
return BigInt(cell.number_value);
|
|
165
|
+
case 'boolean_cell':
|
|
166
|
+
return cell.boolean_value;
|
|
167
|
+
case 'date_cell':
|
|
168
|
+
return cell.date_value;
|
|
169
|
+
case 'timestamp_cell':
|
|
170
|
+
return cell.timestamp_value;
|
|
171
|
+
case 'json_cell':
|
|
172
|
+
return JSON.parse(cell.json_value);
|
|
173
|
+
case 'sql_native_cell':
|
|
174
|
+
return JSON.parse(cell.sql_native_value);
|
|
175
|
+
case 'array_cell':
|
|
176
|
+
return cell.array_value.map(cellToValue);
|
|
177
|
+
case 'record_cell':
|
|
178
|
+
return cell.record_value.map(cellToValue);
|
|
179
|
+
default:
|
|
180
|
+
return `<unknown cell: ${cell.kind}>`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Compare a cell against an expected value.
|
|
185
|
+
* Uses schema type info for intelligent date/timestamp comparison.
|
|
186
|
+
* @param mode - 'partial' allows extra fields in records, 'exact' requires exact match
|
|
187
|
+
*/
|
|
188
|
+
function matchCell(cell, fieldInfo, expected, path, dialect, mode) {
|
|
189
|
+
// Handle null
|
|
190
|
+
if (cell.kind === 'null_cell') {
|
|
191
|
+
if (expected === null) {
|
|
192
|
+
return { pass: true };
|
|
193
|
+
}
|
|
194
|
+
return matchFail(path, expected, null);
|
|
195
|
+
}
|
|
196
|
+
// If expected is null but cell is not null
|
|
197
|
+
if (expected === null) {
|
|
198
|
+
return matchFail(path, null, cellToValue(cell));
|
|
199
|
+
}
|
|
200
|
+
const typeKind = getTypeKind(fieldInfo);
|
|
201
|
+
// Handle date fields with schema-aware comparison
|
|
202
|
+
if (cell.kind === 'date_cell' && typeKind === 'date_type') {
|
|
203
|
+
const actualDateStr = cell.date_value;
|
|
204
|
+
// Extract just the date portion (YYYY-MM-DD) from ISO string
|
|
205
|
+
const actualDate = actualDateStr.split('T')[0];
|
|
206
|
+
if (typeof expected === 'string') {
|
|
207
|
+
// If expected is a date string like 'YYYY-MM-DD', compare directly
|
|
208
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(expected)) {
|
|
209
|
+
if (actualDate === expected) {
|
|
210
|
+
return { pass: true };
|
|
211
|
+
}
|
|
212
|
+
return matchFailStr(path, expected, actualDate);
|
|
213
|
+
}
|
|
214
|
+
// If expected is a full ISO string, compare as-is
|
|
215
|
+
if (actualDateStr === expected) {
|
|
216
|
+
return { pass: true };
|
|
217
|
+
}
|
|
218
|
+
return matchFailStr(path, expected, actualDateStr);
|
|
219
|
+
}
|
|
220
|
+
if (expected instanceof Date) {
|
|
221
|
+
const actualDateObj = new Date(actualDateStr);
|
|
222
|
+
if (expected.getTime() === actualDateObj.getTime()) {
|
|
223
|
+
return { pass: true };
|
|
224
|
+
}
|
|
225
|
+
return matchFailStr(path, expected.toISOString(), actualDateObj.toISOString());
|
|
226
|
+
}
|
|
227
|
+
return matchFail(path, expected, actualDate);
|
|
228
|
+
}
|
|
229
|
+
// Handle timestamp fields with schema-aware comparison
|
|
230
|
+
if (cell.kind === 'timestamp_cell' &&
|
|
231
|
+
(typeKind === 'timestamp_type' || typeKind === 'timestamptz_type')) {
|
|
232
|
+
const actualTsStr = cell.timestamp_value;
|
|
233
|
+
if (typeof expected === 'string') {
|
|
234
|
+
// Compare as timestamps (parse both and compare times)
|
|
235
|
+
const actualDate = new Date(actualTsStr);
|
|
236
|
+
const expectedDate = new Date(expected);
|
|
237
|
+
if (!isNaN(expectedDate.getTime())) {
|
|
238
|
+
if (actualDate.getTime() === expectedDate.getTime()) {
|
|
239
|
+
return { pass: true };
|
|
240
|
+
}
|
|
241
|
+
return matchFailStr(path, expectedDate.toISOString(), actualDate.toISOString());
|
|
242
|
+
}
|
|
243
|
+
// If expected is not a valid date string, compare as strings
|
|
244
|
+
if (actualTsStr === expected) {
|
|
245
|
+
return { pass: true };
|
|
246
|
+
}
|
|
247
|
+
return matchFailStr(path, expected, actualTsStr);
|
|
248
|
+
}
|
|
249
|
+
if (expected instanceof Date) {
|
|
250
|
+
const actualDate = new Date(actualTsStr);
|
|
251
|
+
if (expected.getTime() === actualDate.getTime()) {
|
|
252
|
+
return { pass: true };
|
|
253
|
+
}
|
|
254
|
+
return matchFailStr(path, expected.toISOString(), actualDate.toISOString());
|
|
255
|
+
}
|
|
256
|
+
return matchFail(path, expected, actualTsStr);
|
|
257
|
+
}
|
|
258
|
+
// Handle boolean cells
|
|
259
|
+
if (cell.kind === 'boolean_cell') {
|
|
260
|
+
const actual = cell.boolean_value;
|
|
261
|
+
if (typeof expected === 'boolean') {
|
|
262
|
+
if (actual === expected) {
|
|
263
|
+
return { pass: true };
|
|
264
|
+
}
|
|
265
|
+
return matchFail(path, expected, actual);
|
|
266
|
+
}
|
|
267
|
+
return matchFail(path, expected, actual);
|
|
268
|
+
}
|
|
269
|
+
// Handle string cells
|
|
270
|
+
if (cell.kind === 'string_cell') {
|
|
271
|
+
const actual = cell.string_value;
|
|
272
|
+
if (actual === expected) {
|
|
273
|
+
return { pass: true };
|
|
274
|
+
}
|
|
275
|
+
return matchFail(path, expected, actual);
|
|
276
|
+
}
|
|
277
|
+
// Handle number cells
|
|
278
|
+
if (cell.kind === 'number_cell') {
|
|
279
|
+
let actual = cell.number_value;
|
|
280
|
+
// Handle simulated booleans (MySQL returns 1/0 for booleans)
|
|
281
|
+
if (typeof expected === 'boolean' &&
|
|
282
|
+
dialect.booleanType === 'simulated' &&
|
|
283
|
+
typeof actual === 'number') {
|
|
284
|
+
actual = actual !== 0;
|
|
285
|
+
}
|
|
286
|
+
if (looseEqual(actual, expected)) {
|
|
287
|
+
return { pass: true };
|
|
288
|
+
}
|
|
289
|
+
return matchFail(path, expected, actual);
|
|
290
|
+
}
|
|
291
|
+
// Handle big number cells
|
|
292
|
+
if (cell.kind === 'big_number_cell') {
|
|
293
|
+
const actual = BigInt(cell.number_value);
|
|
294
|
+
if (looseEqual(actual, expected)) {
|
|
295
|
+
return { pass: true };
|
|
296
|
+
}
|
|
297
|
+
// Also allow matching against string representation of the number
|
|
298
|
+
if (typeof expected === 'string' && expected === cell.number_value) {
|
|
299
|
+
return { pass: true };
|
|
300
|
+
}
|
|
301
|
+
return matchFail(path, expected, actual);
|
|
302
|
+
}
|
|
303
|
+
// Handle JSON cells
|
|
304
|
+
if (cell.kind === 'json_cell') {
|
|
305
|
+
const actual = JSON.parse(cell.json_value);
|
|
306
|
+
return matchValue(actual, expected, path, dialect, mode);
|
|
307
|
+
}
|
|
308
|
+
// Handle SQL native cells
|
|
309
|
+
if (cell.kind === 'sql_native_cell') {
|
|
310
|
+
const actual = JSON.parse(cell.sql_native_value);
|
|
311
|
+
return matchValue(actual, expected, path, dialect, mode);
|
|
312
|
+
}
|
|
313
|
+
// Handle array cells
|
|
314
|
+
if (cell.kind === 'array_cell') {
|
|
315
|
+
if (!Array.isArray(expected)) {
|
|
316
|
+
return matchFailStr(path, 'array', `expected non-array: ${humanReadable(expected)}`);
|
|
317
|
+
}
|
|
318
|
+
const actualArray = cell.array_value;
|
|
319
|
+
if (actualArray.length !== expected.length) {
|
|
320
|
+
return matchFailStr(path, `${expected.length} elements`, `${actualArray.length} elements`);
|
|
321
|
+
}
|
|
322
|
+
// Get element type info
|
|
323
|
+
let elementFieldInfo;
|
|
324
|
+
if (fieldInfo.kind === 'join') {
|
|
325
|
+
// For joins, the schema describes the element type
|
|
326
|
+
elementFieldInfo = fieldInfo;
|
|
327
|
+
}
|
|
328
|
+
else if (fieldInfo.kind === 'view') {
|
|
329
|
+
// Views have schema but no type
|
|
330
|
+
return matchFailStr(path, 'array type', 'view fieldInfo');
|
|
331
|
+
}
|
|
332
|
+
else if (fieldInfo.type.kind === 'array_type') {
|
|
333
|
+
elementFieldInfo = {
|
|
334
|
+
kind: 'dimension',
|
|
335
|
+
name: 'element',
|
|
336
|
+
type: fieldInfo.type.element_type,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
else {
|
|
340
|
+
return matchFailStr(path, 'array type', `unexpected type: ${fieldInfo.type.kind}`);
|
|
341
|
+
}
|
|
342
|
+
for (let i = 0; i < expected.length; i++) {
|
|
343
|
+
const result = matchCell(actualArray[i], elementFieldInfo, expected[i], `${path}[${i}]`, dialect, mode);
|
|
344
|
+
if (!result.pass) {
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { pass: true };
|
|
349
|
+
}
|
|
350
|
+
// Handle record cells (from joins or nested queries)
|
|
351
|
+
if (cell.kind === 'record_cell') {
|
|
352
|
+
if (typeof expected !== 'object' || expected === null) {
|
|
353
|
+
return matchFailStr(path, 'object', `expected non-object: ${humanReadable(expected)}`);
|
|
354
|
+
}
|
|
355
|
+
// Get field schema based on fieldInfo type
|
|
356
|
+
let fields;
|
|
357
|
+
let getChildFieldInfo;
|
|
358
|
+
if (fieldInfo.kind === 'join') {
|
|
359
|
+
fields = fieldInfo.schema.fields;
|
|
360
|
+
getChildFieldInfo = i => fieldInfo.schema.fields[i];
|
|
361
|
+
}
|
|
362
|
+
else if (fieldInfo.kind === 'dimension' &&
|
|
363
|
+
fieldInfo.type.kind === 'record_type') {
|
|
364
|
+
const recordType = fieldInfo.type;
|
|
365
|
+
fields = recordType.fields;
|
|
366
|
+
getChildFieldInfo = i => ({
|
|
367
|
+
kind: 'dimension',
|
|
368
|
+
name: recordType.fields[i].name,
|
|
369
|
+
type: recordType.fields[i].type,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
else {
|
|
373
|
+
return matchFailStr(path, 'record type', `unexpected fieldInfo: ${fieldInfo.kind}`);
|
|
374
|
+
}
|
|
375
|
+
// Build a map of field names to indices
|
|
376
|
+
const fieldIndexMap = new Map();
|
|
377
|
+
for (let i = 0; i < fields.length; i++) {
|
|
378
|
+
fieldIndexMap.set(fields[i].name, i);
|
|
379
|
+
}
|
|
380
|
+
const expectedObj = expected;
|
|
381
|
+
// For exact mode, check that keys match exactly
|
|
382
|
+
if (mode === 'exact') {
|
|
383
|
+
const expectedKeys = Object.keys(expectedObj).sort();
|
|
384
|
+
const actualKeys = fields.map(f => f.name).sort();
|
|
385
|
+
for (const key of actualKeys) {
|
|
386
|
+
if (!expectedKeys.includes(key)) {
|
|
387
|
+
return matchFailStr(path, `no field '${key}'`, `unexpected field '${key}'`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
for (const key of expectedKeys) {
|
|
391
|
+
if (!actualKeys.includes(key)) {
|
|
392
|
+
return matchFailStr(path, `field '${key}'`, `missing field '${key}'`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Check expected keys
|
|
397
|
+
for (const [key, expectedValue] of Object.entries(expectedObj)) {
|
|
398
|
+
const fieldIndex = fieldIndexMap.get(key);
|
|
399
|
+
if (fieldIndex === undefined) {
|
|
400
|
+
return matchFailStr(path, `field '${key}'`, 'field not found in schema');
|
|
401
|
+
}
|
|
402
|
+
const childCell = cell.record_value[fieldIndex];
|
|
403
|
+
const childFieldInfo = getChildFieldInfo(fieldIndex);
|
|
404
|
+
const result = matchCell(childCell, childFieldInfo, expectedValue, `${path}.${key}`, dialect, mode);
|
|
405
|
+
if (!result.pass) {
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return { pass: true };
|
|
410
|
+
}
|
|
411
|
+
// Fallback for unknown cell types
|
|
412
|
+
return matchFailStr(path, humanReadable(expected), `unknown cell kind: ${cell.kind}`);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Compare two plain values.
|
|
416
|
+
* Used for JSON/SQL native values that have already been parsed.
|
|
417
|
+
* @param mode - 'partial' allows extra fields in objects, 'exact' requires exact match
|
|
418
|
+
*/
|
|
419
|
+
function matchValue(actual, expected, path, dialect, mode) {
|
|
420
|
+
// Handle null
|
|
421
|
+
if (expected === null) {
|
|
422
|
+
if (actual === null) {
|
|
423
|
+
return { pass: true };
|
|
424
|
+
}
|
|
425
|
+
return matchFail(path, null, actual);
|
|
426
|
+
}
|
|
427
|
+
// Handle primitives
|
|
428
|
+
if (typeof expected === 'string' ||
|
|
429
|
+
typeof expected === 'number' ||
|
|
430
|
+
typeof expected === 'boolean' ||
|
|
431
|
+
typeof expected === 'bigint') {
|
|
432
|
+
if (looseEqual(actual, expected)) {
|
|
433
|
+
return { pass: true };
|
|
434
|
+
}
|
|
435
|
+
return matchFail(path, expected, actual);
|
|
436
|
+
}
|
|
437
|
+
// Handle Date objects
|
|
438
|
+
if (expected instanceof Date) {
|
|
439
|
+
if (actual === null) {
|
|
440
|
+
return matchFailStr(path, expected.toISOString(), 'null');
|
|
441
|
+
}
|
|
442
|
+
let actualDate;
|
|
443
|
+
if (actual instanceof Date) {
|
|
444
|
+
actualDate = actual;
|
|
445
|
+
}
|
|
446
|
+
else if (typeof actual === 'string') {
|
|
447
|
+
actualDate = new Date(actual);
|
|
448
|
+
if (isNaN(actualDate.getTime())) {
|
|
449
|
+
return matchFailStr(path, expected.toISOString(), `'${actual}' (not a valid date)`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
return matchFailStr(path, expected.toISOString(), humanReadable(actual));
|
|
454
|
+
}
|
|
455
|
+
if (expected.getTime() === actualDate.getTime()) {
|
|
456
|
+
return { pass: true };
|
|
457
|
+
}
|
|
458
|
+
return matchFailStr(path, expected.toISOString(), actualDate.toISOString());
|
|
459
|
+
}
|
|
460
|
+
// Handle arrays
|
|
461
|
+
if (Array.isArray(expected)) {
|
|
462
|
+
if (!Array.isArray(actual)) {
|
|
463
|
+
return matchFailStr(path, `array with ${expected.length} elements`, `${typeof actual}`);
|
|
464
|
+
}
|
|
465
|
+
if (actual.length !== expected.length) {
|
|
466
|
+
return matchFailStr(path, `${expected.length} elements`, `${actual.length} elements`);
|
|
467
|
+
}
|
|
468
|
+
for (let i = 0; i < expected.length; i++) {
|
|
469
|
+
const result = matchValue(actual[i], expected[i], `${path}[${i}]`, dialect, mode);
|
|
470
|
+
if (!result.pass) {
|
|
471
|
+
return result;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return { pass: true };
|
|
475
|
+
}
|
|
476
|
+
// Handle objects
|
|
477
|
+
if (typeof expected === 'object') {
|
|
478
|
+
if (typeof actual !== 'object' ||
|
|
479
|
+
actual === null ||
|
|
480
|
+
Array.isArray(actual)) {
|
|
481
|
+
return matchFailStr(path, 'object', humanReadable(actual));
|
|
482
|
+
}
|
|
483
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
484
|
+
const actualKeys = Object.keys(actual).sort();
|
|
485
|
+
// For exact mode, check that keys match exactly
|
|
486
|
+
if (mode === 'exact') {
|
|
487
|
+
for (const key of actualKeys) {
|
|
488
|
+
if (!expectedKeys.includes(key)) {
|
|
489
|
+
return matchFailStr(path, `no field '${key}'`, `unexpected field '${key}'`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const key of expectedKeys) {
|
|
493
|
+
if (!actualKeys.includes(key)) {
|
|
494
|
+
return matchFailStr(path, `field '${key}'`, `missing field '${key}'`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
499
|
+
const actualValue = actual[key];
|
|
500
|
+
const result = matchValue(actualValue, value, `${path}.${key}`, dialect, mode);
|
|
501
|
+
if (!result.pass) {
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return { pass: true };
|
|
506
|
+
}
|
|
507
|
+
return matchFailStr(path, 'supported type', `unsupported: ${typeof expected}`);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Check if last argument is an options object.
|
|
511
|
+
*/
|
|
512
|
+
function isOptions(arg) {
|
|
513
|
+
if (typeof arg !== 'object' || arg === null) {
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
const keys = Object.keys(arg);
|
|
517
|
+
// Options object only has 'debug' key
|
|
518
|
+
return keys.length === 1 && keys[0] === 'debug';
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Shared implementation for all result matching.
|
|
522
|
+
* @param mode - 'partial' for toMatchResult/toMatchRows, 'exact' for toEqualResult
|
|
523
|
+
* @param strictRowCount - If true, requires exact row count match
|
|
524
|
+
*/
|
|
525
|
+
async function matchImpl(querySrc, tm, expectedRows, options, mode, strictRowCount, jestUtils) {
|
|
526
|
+
var _a;
|
|
527
|
+
querySrc = querySrc.trimEnd().replace(/^\n*/, '');
|
|
528
|
+
const { fail, data, schema, dataObjects, queryTestTag, query } = await runQueryInternal(tm, querySrc);
|
|
529
|
+
if (fail)
|
|
530
|
+
return fail;
|
|
531
|
+
if (!data || !schema || !dataObjects) {
|
|
532
|
+
return {
|
|
533
|
+
pass: false,
|
|
534
|
+
message: () => 'runQuery returned no data and no errors',
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const fails = [];
|
|
538
|
+
const debug = options.debug || (queryTestTag === null || queryTestTag === void 0 ? void 0 : queryTestTag.has('debug'));
|
|
539
|
+
// Data is an array_cell containing record_cells
|
|
540
|
+
if (data.kind !== 'array_cell') {
|
|
541
|
+
return {
|
|
542
|
+
pass: false,
|
|
543
|
+
message: () => `Expected array_cell at root, got ${data.kind}`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
const rows = data.array_value;
|
|
547
|
+
// Create root fieldInfo for schema navigation
|
|
548
|
+
const rootFieldInfo = {
|
|
549
|
+
kind: 'join',
|
|
550
|
+
name: 'root',
|
|
551
|
+
relationship: 'one',
|
|
552
|
+
schema,
|
|
553
|
+
};
|
|
554
|
+
// Collect row-level issues for unified output
|
|
555
|
+
const rowCountMismatch = strictRowCount && rows.length !== expectedRows.length
|
|
556
|
+
? `Expected ${expectedRows.length} rows, got ${rows.length}`
|
|
557
|
+
: !strictRowCount &&
|
|
558
|
+
expectedRows.length > 0 &&
|
|
559
|
+
rows.length < expectedRows.length
|
|
560
|
+
? `Expected at least ${expectedRows.length} rows, got ${rows.length}`
|
|
561
|
+
: null;
|
|
562
|
+
// Track per-row mismatches: row index -> list of field issues
|
|
563
|
+
const rowIssues = new Map();
|
|
564
|
+
// Check empty match {} means "at least one row"
|
|
565
|
+
if (mode === 'partial' &&
|
|
566
|
+
expectedRows.length === 1 &&
|
|
567
|
+
Object.keys(expectedRows[0]).length === 0) {
|
|
568
|
+
if (rows.length === 0) {
|
|
569
|
+
fails.push('Expected at least one row, got 0');
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
// Compare each expected row using schema-aware cell matching
|
|
574
|
+
const rowsToCheck = Math.min(rows.length, expectedRows.length);
|
|
575
|
+
for (let i = 0; i < rowsToCheck; i++) {
|
|
576
|
+
const matchResult = matchCell(rows[i], rootFieldInfo, expectedRows[i], `Row ${i}`, tm.dialect, mode);
|
|
577
|
+
if (!matchResult.pass) {
|
|
578
|
+
// Extract field name from path like "Row 0.fieldname" or "Row 0.nested.field"
|
|
579
|
+
const pathMatch = (_a = matchResult.path) === null || _a === void 0 ? void 0 : _a.match(/^Row \d+\.(.+)$/);
|
|
580
|
+
const fieldPath = pathMatch ? pathMatch[1] : matchResult.path;
|
|
581
|
+
const issue = jestUtils.EXPECTED_COLOR(` Expected ${fieldPath}: ${matchResult.expected}`);
|
|
582
|
+
if (!rowIssues.has(i)) {
|
|
583
|
+
rowIssues.set(i, []);
|
|
584
|
+
}
|
|
585
|
+
rowIssues.get(i).push(issue);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Build DATA DIFFERENCES section if there are any issues
|
|
590
|
+
if (rowCountMismatch || rowIssues.size > 0) {
|
|
591
|
+
const diffLines = ['DATA DIFFERENCES'];
|
|
592
|
+
if (rowCountMismatch) {
|
|
593
|
+
diffLines.push(` ${rowCountMismatch}`);
|
|
594
|
+
}
|
|
595
|
+
// Show each row with its issues
|
|
596
|
+
const maxRowToShow = Math.max(rows.length, expectedRows.length);
|
|
597
|
+
for (let i = 0; i < maxRowToShow; i++) {
|
|
598
|
+
if (i < rows.length) {
|
|
599
|
+
const rowData = humanReadable(dataObjects[i]);
|
|
600
|
+
const issues = rowIssues.get(i);
|
|
601
|
+
const isExtra = i >= expectedRows.length;
|
|
602
|
+
if (issues || isExtra) {
|
|
603
|
+
// Red if there are issues or it's an extra row (use ! for non-colored output)
|
|
604
|
+
diffLines.push(jestUtils.RECEIVED_COLOR(` ${i}! ${rowData}`));
|
|
605
|
+
if (issues) {
|
|
606
|
+
for (const issue of issues) {
|
|
607
|
+
diffLines.push(issue);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
else {
|
|
612
|
+
// Row matched - green
|
|
613
|
+
diffLines.push(jestUtils.EXPECTED_COLOR(` ${i}: ${rowData}`));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
// Missing row - show what was expected (use ! for non-colored output)
|
|
618
|
+
diffLines.push(jestUtils.RECEIVED_COLOR(` ${i}! (missing)`));
|
|
619
|
+
if (i < expectedRows.length) {
|
|
620
|
+
diffLines.push(jestUtils.EXPECTED_COLOR(` Expected: ${humanReadable(expectedRows[i])}`));
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
fails.push(diffLines.join('\n'));
|
|
625
|
+
}
|
|
626
|
+
if (debug && fails.length === 0) {
|
|
627
|
+
fails.push('Test forced failure (# test.debug)');
|
|
628
|
+
fails.push(jestUtils.RECEIVED_COLOR(`Result: ${humanReadable(dataObjects)}`));
|
|
629
|
+
}
|
|
630
|
+
if (fails.length > 0) {
|
|
631
|
+
if (debug) {
|
|
632
|
+
fails.unshift(`Result Data: ${humanReadable(dataObjects)}`);
|
|
633
|
+
}
|
|
634
|
+
const fromSQL = query
|
|
635
|
+
? 'SQL Generated:\n ' + (await query.getSQL()).split('\n').join('\n ')
|
|
636
|
+
: 'SQL Missing';
|
|
637
|
+
const failMsg = `QUERY:\n${querySrc}\n\n${fromSQL}\n\n${fails.join('\n')}`;
|
|
638
|
+
return { pass: false, message: () => failMsg };
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
pass: true,
|
|
642
|
+
message: () => mode === 'exact'
|
|
643
|
+
? 'All rows matched expected results exactly'
|
|
644
|
+
: 'All rows matched expected results',
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
expect.extend({
|
|
648
|
+
async toMatchResult(querySrc, tm, ...rowsOrOptions) {
|
|
649
|
+
// Parse args - last might be options
|
|
650
|
+
let options = {};
|
|
651
|
+
let expectedRows;
|
|
652
|
+
if (rowsOrOptions.length > 0 &&
|
|
653
|
+
isOptions(rowsOrOptions[rowsOrOptions.length - 1])) {
|
|
654
|
+
options = rowsOrOptions[rowsOrOptions.length - 1];
|
|
655
|
+
expectedRows = rowsOrOptions.slice(0, -1);
|
|
656
|
+
}
|
|
657
|
+
else {
|
|
658
|
+
expectedRows = rowsOrOptions;
|
|
659
|
+
}
|
|
660
|
+
return matchImpl(querySrc, tm, expectedRows, options, 'partial', false, this.utils);
|
|
661
|
+
},
|
|
662
|
+
async toMatchRows(querySrc, tm, expectedRows, options = {}) {
|
|
663
|
+
return matchImpl(querySrc, tm, expectedRows, options, 'partial', true, this.utils);
|
|
664
|
+
},
|
|
665
|
+
async toEqualResult(querySrc, tm, expectedRows, options = {}) {
|
|
666
|
+
return matchImpl(querySrc, tm, expectedRows, options, 'exact', true, this.utils);
|
|
667
|
+
},
|
|
668
|
+
/**
|
|
669
|
+
* Navigate a dotted path through an object, taking the first element of arrays.
|
|
670
|
+
* For path 'a.b.c', navigates: obj -> obj.a (or obj.a[0] if array) -> .b -> .c
|
|
671
|
+
*/
|
|
672
|
+
toHavePath(received, paths) {
|
|
673
|
+
const fails = [];
|
|
674
|
+
for (const [path, expected] of Object.entries(paths)) {
|
|
675
|
+
const segments = path.split('.');
|
|
676
|
+
let current = received;
|
|
677
|
+
for (const segment of segments) {
|
|
678
|
+
if (current === null || current === undefined) {
|
|
679
|
+
fails.push(`Path '${path}': cannot navigate through null/undefined`);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
// If current is an array, take first element then access property
|
|
683
|
+
if (Array.isArray(current)) {
|
|
684
|
+
if (current.length === 0) {
|
|
685
|
+
fails.push(`Path '${path}': empty array at '${segment}'`);
|
|
686
|
+
current = undefined;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
current = current[0];
|
|
690
|
+
}
|
|
691
|
+
if (typeof current === 'object' && current !== null) {
|
|
692
|
+
current = current[segment];
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
fails.push(`Path '${path}': cannot access '${segment}' on ${typeof current}`);
|
|
696
|
+
current = undefined;
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
// Final value might be in an array too
|
|
701
|
+
if (Array.isArray(current) && current.length > 0) {
|
|
702
|
+
current = current[0];
|
|
703
|
+
}
|
|
704
|
+
if (!looseEqual(current, expected)) {
|
|
705
|
+
fails.push(`Path '${path}':`);
|
|
706
|
+
fails.push(this.utils.EXPECTED_COLOR(` Expected: ${humanReadable(expected)}`));
|
|
707
|
+
fails.push(this.utils.RECEIVED_COLOR(` Received: ${humanReadable(current)}`));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (fails.length > 0) {
|
|
711
|
+
return {
|
|
712
|
+
pass: false,
|
|
713
|
+
message: () => fails.join('\n'),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
pass: true,
|
|
718
|
+
message: () => 'All paths matched expected values',
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
//# sourceMappingURL=resultMatchers.js.map
|