@malloydata/malloy-tests 0.0.261-dev250410215229 → 0.0.261-dev250410224545

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.261-dev250410215229",
25
- "@malloydata/db-duckdb": "^0.0.261-dev250410215229",
26
- "@malloydata/db-postgres": "^0.0.261-dev250410215229",
27
- "@malloydata/db-snowflake": "^0.0.261-dev250410215229",
28
- "@malloydata/db-trino": "^0.0.261-dev250410215229",
29
- "@malloydata/malloy": "^0.0.261-dev250410215229",
30
- "@malloydata/malloy-tag": "^0.0.261-dev250410215229",
31
- "@malloydata/render": "^0.0.261-dev250410215229",
24
+ "@malloydata/db-bigquery": "^0.0.261-dev250410224545",
25
+ "@malloydata/db-duckdb": "^0.0.261-dev250410224545",
26
+ "@malloydata/db-postgres": "^0.0.261-dev250410224545",
27
+ "@malloydata/db-snowflake": "^0.0.261-dev250410224545",
28
+ "@malloydata/db-trino": "^0.0.261-dev250410224545",
29
+ "@malloydata/malloy": "^0.0.261-dev250410224545",
30
+ "@malloydata/malloy-tag": "^0.0.261-dev250410224545",
31
+ "@malloydata/render": "^0.0.261-dev250410224545",
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.261-dev250410215229"
41
+ "version": "0.0.261-dev250410224545"
42
42
  }
@@ -41,6 +41,23 @@ async function getError<T>(fn: () => Promise<T>) {
41
41
  }
42
42
 
43
43
  runtimes.runtimeMap.forEach((runtime, databaseName) => {
44
+ it(`properly quotes nested field names in ${databaseName}`, async () => {
45
+ const one = runtime.dialect.sqlMaybeQuoteIdentifier('one');
46
+ await expect(`
47
+ run: ${databaseName}.sql(""" SELECT 1 as ${one} """) -> {
48
+ nest: foo is {
49
+ group_by: one
50
+ aggregate: \`#\` is count(one)
51
+ nest: deepfoo is {
52
+ group_by: one
53
+ aggregate: \`#\` is count(one)
54
+ }
55
+ }
56
+ }`).matchesRows(runtime, {
57
+ foo: [{'one': 1, '#': 1, 'deepfoo': [{'one': 1, '#': 1}]}],
58
+ });
59
+ });
60
+
44
61
  describe('warnings', () => {
45
62
  // NOTE: This test generates SQL errors on the console because of
46
63
  // a hard-coded console.log() in the duckdb-wasm worker
@@ -22,7 +22,6 @@ describe.each(runtimes.runtimeList)(
22
22
  throw new Error("Couldn't build runtime");
23
23
  }
24
24
  const presto = databaseName === 'presto';
25
-
26
25
  it(`runs an sql query - ${databaseName}`, async () => {
27
26
  await expect(
28
27
  `run: ${databaseName}.sql("SELECT 1 as n") -> { select: n }`
@@ -22,21 +22,16 @@
22
22
  * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
23
  */
24
24
 
25
- import type {
26
- ModelMaterializer,
27
- QueryMaterializer,
28
- Result,
29
- Runtime,
30
- LogMessage,
31
- } from '@malloydata/malloy';
32
- import {MalloyError, SingleConnectionRuntime, API} from '@malloydata/malloy';
33
- import type {Tag} from '@malloydata/malloy-tag';
25
+ import type {Result, Runtime} from '@malloydata/malloy';
26
+ import {SingleConnectionRuntime} from '@malloydata/malloy';
34
27
  import EventEmitter from 'events';
35
28
  import {inspect} from 'util';
36
-
37
- type ExpectedResultRow = Record<string, unknown>;
38
- type ExpectedResult = ExpectedResultRow | ExpectedResultRow[];
39
- type Runner = Runtime | ModelMaterializer;
29
+ import type {
30
+ ExpectedResult,
31
+ ExpectedResultRow,
32
+ TestRunner,
33
+ } from './db-matcher-support';
34
+ import {runQuery} from './db-matcher-support';
40
35
 
41
36
  interface ExpectedEvent {
42
37
  id: string;
@@ -74,9 +69,45 @@ declare global {
74
69
  * @param expected Key value pairs or array of key value pairs
75
70
  */
76
71
  malloyResultMatches(
77
- runtime: Runner,
72
+ runtime: TestRunner,
78
73
  matchVals: ExpectedResult
79
74
  ): Promise<R>;
75
+ /**
76
+ * Jest matcher for running a Malloy query, checks that each row
77
+ * contains the values matching the template. The argument list
78
+ * at the end will be matched for each row of the query.
79
+ *
80
+ * To see if the first row of a query contains a field called num with a value of 7
81
+ * ( a "runtime" can be a Runtime, or a Model from load/extend of a Model)
82
+ *
83
+ * await expect('run: ...').matchesRows(runtime, {num: 7});
84
+ *
85
+ * To see if the first two rows of a query contains a field called num with a values 7 and 8
86
+ *
87
+ * await expect('run: ...').matchesRows(runtime {num: 7}, {num:8});
88
+ *
89
+ * Every symbol in the expect match must be in the row, however there can be columns in the row
90
+ * which are not in the match.
91
+ *
92
+ * mtoy todo maybe this should be "debug_query()" instead of a tag ... ?
93
+ * In addition, the query is checked for the tags, preceed your run statement with ...
94
+ *
95
+ * * test.debug -- Force test failure, and the result data will be printed
96
+ *
97
+ * @param matchVals ... list of row objects containing key-value pairs
98
+ */
99
+ matchesRows(
100
+ runtime: TestRunner,
101
+ ...matchVals: ExpectedResultRow[]
102
+ ): Promise<R>;
103
+ /**
104
+ * Similar to matchesRows, argument is an array of rows, not a list of rows
105
+ * Output on a mismatch is a jest-diff
106
+ */
107
+ matchesResult(
108
+ runtime: TestRunner,
109
+ matchVals: ExpectedResultRow[]
110
+ ): Promise<R>;
80
111
  toEmitDuringCompile(
81
112
  runtime: Runtime,
82
113
  ...events: ExpectedEvent[]
@@ -114,7 +145,7 @@ expect.extend({
114
145
 
115
146
  async malloyResultMatches(
116
147
  querySrc: string,
117
- runtime: Runner,
148
+ runtime: TestRunner,
118
149
  shouldEqual: ExpectedResult
119
150
  ) {
120
151
  // TODO -- THIS IS NOT OK BUT I AM NOT FIXING IT NOW
@@ -131,48 +162,15 @@ expect.extend({
131
162
  }
132
163
  }
133
164
 
134
- let query: QueryMaterializer;
135
- let queryTestTag: Tag | undefined = undefined;
136
- try {
137
- query = runtime.loadQuery(querySrc);
138
- const queryTags = (await query.getPreparedQuery()).tagParse().tag;
139
- queryTestTag = queryTags.tag('test');
140
- } catch (e) {
141
- return {
142
- pass: false,
143
- message: () =>
144
- `Could not prepare query to run: ${e.message}\nQuery:\n${querySrc}`,
145
- };
146
- }
147
-
148
- let result: Result;
149
- try {
150
- result = await query.run();
151
- } catch (e) {
152
- let failMsg = `query.run failed: ${e.message}\n`;
153
- if (e instanceof MalloyError) {
154
- failMsg = `Error in query compilation\n${errorLogToString(
155
- querySrc,
156
- e.problems
157
- )}`;
158
- } else {
159
- try {
160
- failMsg += `SQL: ${await query.getSQL()}\n`;
161
- } catch (e2) {
162
- // we could not show the SQL for unknown reasons
163
- }
164
- failMsg += e.stack;
165
- }
166
- return {pass: false, message: () => failMsg};
167
- }
168
-
169
- try {
170
- API.util.wrapResult(result);
171
- } catch (error) {
165
+ const {fail, result, queryTestTag, query} = await runQuery({
166
+ runner: runtime,
167
+ src: querySrc,
168
+ });
169
+ if (fail) return fail;
170
+ if (!result) {
172
171
  return {
173
172
  pass: false,
174
- message: () =>
175
- `Result could not be wrapped into new style result: ${error}\n${error.stack}`,
173
+ message: () => 'runQuery returned no results and no errors',
176
174
  };
177
175
  }
178
176
 
@@ -229,11 +227,13 @@ expect.extend({
229
227
  }
230
228
 
231
229
  if (fails.length > 0) {
232
- const fromSQL = ' ' + (await query.getSQL()).split('\n').join('\n ');
233
230
  if (debugFail && !failedTest) {
234
231
  fails.push('\nTest forced failure (# test.debug)');
235
232
  }
236
- const failMsg = `SQL Generated:\n${fromSQL}\n${fails.join('\n')}`;
233
+ const fromSQL = query
234
+ ? 'SQL Generated:\n ' + (await query.getSQL()).split('\n').join('\n ')
235
+ : 'SQL Missing';
236
+ const failMsg = `${fromSQL}\n${fails.join('\n')}`;
237
237
  return {pass: false, message: () => failMsg};
238
238
  }
239
239
 
@@ -242,6 +242,126 @@ expect.extend({
242
242
  message: () => 'All rows matched expected results',
243
243
  };
244
244
  },
245
+
246
+ async matchesRows(
247
+ querySrc: string,
248
+ runtime: TestRunner,
249
+ ...expected: ExpectedResultRow[]
250
+ ) {
251
+ querySrc = querySrc.trimEnd().replace(/^\n*/, '');
252
+ const {fail, result, queryTestTag, query} = await runQuery({
253
+ runner: runtime,
254
+ src: querySrc,
255
+ });
256
+ if (fail) return fail;
257
+ if (!result) {
258
+ return {
259
+ pass: false,
260
+ message: () => 'runQuery returned no results and no errors',
261
+ };
262
+ }
263
+
264
+ const fails: string[] = [];
265
+ const got = result.data.toObject();
266
+ const expectStr = this.utils.EXPECTED_COLOR(humanReadable(expected));
267
+
268
+ if (!Array.isArray(got)) {
269
+ fails.push(`!!! Expected: ${expectStr}`);
270
+ fails.push(
271
+ `??? NonArray: ${this.utils.RECEIVED_COLOR(humanReadable(got))}`
272
+ );
273
+ } else {
274
+ // compare each row in the result to each row in the expectation
275
+ // This is more useful than a straight diff
276
+ const diffs: string[] = [];
277
+ let unMatched = false;
278
+ for (let expectNum = 0; expectNum < expected.length; expectNum += 1) {
279
+ const eStr = humanReadable(expected[expectNum]);
280
+ if (objectsMatch(got[expectNum], expected[expectNum])) {
281
+ diffs.push(` ${eStr}`);
282
+ } else {
283
+ diffs.push(this.utils.EXPECTED_COLOR(`<<< Expected: ${eStr}`));
284
+ diffs.push(
285
+ this.utils.RECEIVED_COLOR(
286
+ `>>> Received: ${humanReadable(got[expectNum])}`
287
+ )
288
+ );
289
+ unMatched = true;
290
+ }
291
+ }
292
+ if (unMatched) {
293
+ fails.push('ROWS:', ...diffs);
294
+ }
295
+ }
296
+
297
+ if (queryTestTag?.has('debug') && fails.length === 0) {
298
+ fails.push(
299
+ `\n${this.utils.RECEIVED_COLOR('Test forced failure (# test.debug)')}`
300
+ );
301
+ fails.push(`Received: ${this.utils.EXPECTED_COLOR(humanReadable(got))}`);
302
+ }
303
+
304
+ if (fails.length > 0) {
305
+ const fromSQL = query
306
+ ? 'SQL Generated:\n ' + (await query.getSQL()).split('\n').join('\n ')
307
+ : 'SQL Missing';
308
+ const failMsg = `QUERY:\n${querySrc}\n\n${fromSQL}\n${fails.join('\n')}`;
309
+ return {pass: false, message: () => failMsg};
310
+ }
311
+
312
+ return {pass: true, message: () => `Matched: ${expectStr}`};
313
+ },
314
+
315
+ async matchesResult(
316
+ querySrc: string,
317
+ runtime: TestRunner,
318
+ expected: ExpectedResultRow[]
319
+ ) {
320
+ querySrc = querySrc.trimEnd();
321
+ const {fail, result, queryTestTag, query} = await runQuery({
322
+ runner: runtime,
323
+ src: querySrc,
324
+ });
325
+ if (fail) return fail;
326
+ if (!result) {
327
+ return {
328
+ pass: false,
329
+ message: () => 'runQuery returned no results and no errors',
330
+ };
331
+ }
332
+
333
+ const fails: string[] = [];
334
+ const got = result.data.toObject();
335
+ const expectStr = humanReadable(expected);
336
+
337
+ if (!objectsMatch(got, expected)) {
338
+ fails.push(
339
+ 'RESULT:',
340
+ this.utils.diff(expectStr, humanReadable(got)) || ''
341
+ );
342
+ }
343
+
344
+ if (queryTestTag?.has('debug') && fails.length === 0) {
345
+ fails.push(
346
+ `\n${this.utils.RECEIVED_COLOR('Test forced failure (# test.debug)')}`
347
+ );
348
+ fails.push(`Received: ${this.utils.EXPECTED_COLOR(humanReadable(got))}`);
349
+ }
350
+
351
+ if (fails.length > 0) {
352
+ const fromSQL = query
353
+ ? 'SQL Generated:\n ' + (await query.getSQL()).split('\n').join('\n ')
354
+ : 'SQL Missing';
355
+ const failMsg = `QUERY:\n${querySrc}\n${fromSQL}\n${fails.join('\n')}`;
356
+ return {pass: false, message: () => failMsg};
357
+ }
358
+
359
+ return {
360
+ pass: true,
361
+ message: () => `Matched: ${this.utils.EXPECTED_COLOR(expectStr)}`,
362
+ };
363
+ },
364
+
245
365
  async toEmitDuringCompile(
246
366
  querySrc: string,
247
367
  runtime: Runtime,
@@ -318,45 +438,28 @@ async function toEmit(
318
438
  };
319
439
  }
320
440
 
321
- function errorLogToString(src: string, msgs: LogMessage[]) {
322
- let lovely = '';
323
- let lineNo = 0;
324
- for (const line of src.split('\n')) {
325
- lovely += ` | ${line}\n`;
326
- for (const entry of msgs) {
327
- if (entry.at) {
328
- if (entry.at.range.start.line === lineNo) {
329
- const charFrom = entry.at.range.start.character;
330
- lovely += `!!!!! ${' '.repeat(charFrom)}^ ${entry.message}\n`;
331
- }
332
- }
333
- }
334
- lineNo += 1;
335
- }
336
- return lovely;
337
- }
338
-
339
441
  function humanReadable(thing: unknown): string {
340
442
  return inspect(thing, {breakLength: 72, depth: Infinity});
341
443
  }
342
444
 
343
- // b is "expected"
344
- // a is "actual"
345
445
  // If expected is an object, all of the keys should also match,
346
446
  // but the expected is allowed to have other keys that are not matched
347
- function objectsMatch(a: unknown, b: unknown): boolean {
447
+ function objectsMatch(a: unknown, mustHave: unknown): boolean {
348
448
  if (
349
- typeof b === 'string' ||
350
- typeof b === 'number' ||
351
- typeof b === 'boolean' ||
352
- typeof b === 'bigint' ||
353
- b === undefined ||
354
- b === null
449
+ typeof mustHave === 'string' ||
450
+ typeof mustHave === 'number' ||
451
+ typeof mustHave === 'boolean' ||
452
+ typeof mustHave === 'bigint' ||
453
+ mustHave === undefined ||
454
+ mustHave === null
355
455
  ) {
356
- return b === a;
357
- } else if (Array.isArray(b)) {
456
+ return mustHave === a;
457
+ } else if (Array.isArray(mustHave)) {
358
458
  if (Array.isArray(a)) {
359
- return a.length === b.length && a.every((v, i) => objectsMatch(v, b[i]));
459
+ return (
460
+ a.length === mustHave.length &&
461
+ a.every((v, i) => objectsMatch(v, mustHave[i]))
462
+ );
360
463
  }
361
464
  return false;
362
465
  } else {
@@ -371,9 +474,9 @@ function objectsMatch(a: unknown, b: unknown): boolean {
371
474
  return false;
372
475
  }
373
476
  if (Array.isArray(a)) return false;
374
- const keys = Object.keys(b);
477
+ const keys = Object.keys(mustHave);
375
478
  for (const key of keys) {
376
- if (!objectsMatch(a[key], b[key])) {
479
+ if (!objectsMatch(a[key], mustHave[key])) {
377
480
  return false;
378
481
  }
379
482
  }
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import type {
9
+ Result,
10
+ Runtime,
11
+ ModelMaterializer,
12
+ QueryMaterializer,
13
+ LogMessage,
14
+ Dialect,
15
+ } from '@malloydata/malloy';
16
+ import {API, MalloyError} from '@malloydata/malloy';
17
+ import type {Tag} from '@malloydata/malloy-tag';
18
+
19
+ type JestMatcherResult = {
20
+ pass: boolean;
21
+ message: () => string;
22
+ };
23
+
24
+ export type ExpectedResultRow = Record<string, unknown>;
25
+ export type ExpectedResult = ExpectedResultRow | ExpectedResultRow[];
26
+ export type TestRunner = Runtime | ModelMaterializer;
27
+
28
+ export interface TestQuery {
29
+ runner: TestRunner;
30
+ src: string;
31
+ }
32
+
33
+ export function query(runner: TestRunner, querySource: string): TestQuery {
34
+ return {runner, src: querySource};
35
+ }
36
+
37
+ interface QueryRunResult {
38
+ fail: JestMatcherResult;
39
+ result: Result;
40
+ query: QueryMaterializer;
41
+ queryTestTag: Tag;
42
+ }
43
+
44
+ export async function runQuery(
45
+ tq: TestQuery
46
+ ): Promise<Partial<QueryRunResult>> {
47
+ let query: QueryMaterializer;
48
+ let queryTestTag: Tag | undefined = undefined;
49
+ try {
50
+ query = tq.runner.loadQuery(tq.src);
51
+ const queryTags = (await query.getPreparedQuery()).tagParse().tag;
52
+ queryTestTag = queryTags.tag('test');
53
+ } catch (e) {
54
+ // Add line numbers, helpful if failure is a compiler error
55
+ const queryText = tq.src
56
+ .split('\n')
57
+ .map((line, index) => `${(index + 1).toString().padStart(4)}: ${line}`)
58
+ .join('\n');
59
+ return {
60
+ fail: {
61
+ pass: false,
62
+ message: () =>
63
+ `Could not prepare query to run: ${e.message}\n\nQUERY:\n${queryText}`,
64
+ },
65
+ };
66
+ }
67
+
68
+ let result: Result;
69
+ try {
70
+ result = await query.run();
71
+ } catch (e) {
72
+ let failMsg = `QUERY RUN FAILED: ${tq.src}\nMESSAGE: ${e.message}\n`;
73
+ if (e instanceof MalloyError) {
74
+ failMsg = `Error in query compilation\n${errorLogToString(
75
+ tq.src,
76
+ e.problems
77
+ )}`;
78
+ } else {
79
+ try {
80
+ failMsg += `SQL: ${await query.getSQL()}\n`;
81
+ } catch (e2) {
82
+ failMsg += `SQL FOR FAILING QUERY COULD NOT BE COMPUTED: ${e2.message}\n`;
83
+ }
84
+ failMsg += e.stack;
85
+ }
86
+ return {fail: {pass: false, message: () => failMsg}, query};
87
+ }
88
+ try {
89
+ API.util.wrapResult(result);
90
+ } catch (error) {
91
+ return {
92
+ fail: {
93
+ pass: false,
94
+ message: () =>
95
+ `Result could not be wrapped into new style result: ${error}\n${error.stack}`,
96
+ },
97
+ };
98
+ }
99
+ return {result, queryTestTag, query};
100
+ }
101
+
102
+ function errorLogToString(src: string, msgs: LogMessage[]) {
103
+ let lovely = '';
104
+ let lineNo = 0;
105
+ for (const line of src.split('\n')) {
106
+ lovely += ` | ${line}\n`;
107
+ for (const entry of msgs) {
108
+ if (entry.at) {
109
+ if (entry.at.range.start.line === lineNo) {
110
+ const charFrom = entry.at.range.start.character;
111
+ lovely += `!!!!! ${' '.repeat(charFrom)}^ ${entry.message}\n`;
112
+ }
113
+ }
114
+ }
115
+ lineNo += 1;
116
+ }
117
+ return lovely;
118
+ }
119
+
120
+ type TL = 'timeLiteral';
121
+
122
+ function lit(d: Dialect, t: string, type: 'timestamp' | 'date'): string {
123
+ const typeDef: {type: 'timestamp' | 'date'} = {type};
124
+ const timeLiteral: TL = 'timeLiteral';
125
+ const n = {
126
+ node: timeLiteral,
127
+ typeDef,
128
+ literal: t,
129
+ };
130
+ return d.sqlLiteralTime({}, n);
131
+ }
132
+
133
+ type SQLDataType = 'string' | 'number' | 'timestamp' | 'date' | 'boolean';
134
+ type SQLRow = unknown[];
135
+
136
+ /**
137
+ * Create source built from the SQL for a series of
138
+ * SELECT ... UNION ALL SELECT statements which returns
139
+ * the passed data. This uses the dialect object to do
140
+ * all the quoting and type translation.
141
+ * @returns '${connectionName}.sql("""SELECT ..."""")'
142
+ */
143
+ export function mkSQLSource(
144
+ dialect: Dialect,
145
+ connectioName: string,
146
+ schema: Record<string, SQLDataType>,
147
+ ...dataRows: SQLRow[]
148
+ ): string {
149
+ const stmts: string[] = [];
150
+ for (const oneRow of dataRows) {
151
+ const outRow: string[] = [];
152
+ let colNum = 0;
153
+ for (const colName of Object.keys(schema)) {
154
+ const val = oneRow[colNum];
155
+ colNum += 1;
156
+ let valStr = `ERROR BAD TYPE FOR ${schema[colName]}: ${typeof val}`;
157
+ if (schema[colName] === 'string' && typeof val === 'string') {
158
+ valStr = dialect.sqlLiteralString(val);
159
+ } else if (schema[colName] === 'number' && typeof val === 'number') {
160
+ valStr = val.toString();
161
+ } else if (schema[colName] === 'boolean' && typeof val === 'boolean') {
162
+ valStr = val.toString();
163
+ } else if (val === null) {
164
+ valStr = 'NULL';
165
+ } else if (schema[colName] === 'timestamp' && typeof val === 'string') {
166
+ valStr = lit(dialect, val, 'timestamp');
167
+ } else if (schema[colName] === 'date' && typeof val === 'string') {
168
+ valStr = lit(dialect, val, 'date');
169
+ }
170
+ outRow.push(
171
+ stmts.length === 0
172
+ ? `${valStr} AS ${dialect.sqlMaybeQuoteIdentifier(colName)}`
173
+ : valStr
174
+ );
175
+ }
176
+ stmts.push(outRow.join(','));
177
+ }
178
+ return `${connectioName}.sql("""SELECT ${stmts.join(
179
+ '\nUNION ALL SELECT '
180
+ )}\n""")`;
181
+ }