@malloydata/malloy-tests 0.0.434 → 0.0.435

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/CONTEXT.md CHANGED
@@ -40,6 +40,8 @@ Tests that run against **all** supported databases to ensure consistent behavior
40
40
 
41
41
  These tests are particularly important for verifying that Malloy's abstraction works correctly across all supported SQL dialects.
42
42
 
43
+ An experimental dialect is not in `allDatabases`. CI runs only its smoke job, `ci-<name>-smoketest`, which selects the dialect's own jest project and must stay green; the shared suite is run by hand with `MALLOY_DATABASE=<name>` (`ci-<name>`) and is allowed to be red until the dialect leaves experimental. A follow-up that implements a feature moves its shared-suite tests into the green set by flipping the capability flag they are gated on. The files carry no `##! experimental.dialect.<name>` lines: `runtimeFor` in `runtimes.ts` calls `acceptExperimental()` on the dialect under test once per process, which is the only place that acknowledgment is waived.
44
+
43
45
  ### Consumer-contract canary (`test/consumer-canary/`)
44
46
  Not a normal test — it consumes the *built* `@malloydata/*` packages the way a downstream app does (esbuild bundle + plain ts-jest, no babel) to catch native/ESM leaks that malloy's own CI is blind to. Run locally with `npm run test-consumer-canary` (it builds first). See [`test/consumer-canary/CONTEXT.md`](consumer-canary/CONTEXT.md).
45
47
 
@@ -99,7 +101,7 @@ npm run build-duckdb-db # Creates test/data/duckdb/duckdb_test.db
99
101
 
100
102
  This creates a local DuckDB database file populated with test data.
101
103
 
102
- ### PostgreSQL, MySQL, Trino, Presto
104
+ ### PostgreSQL, MySQL, Trino, Presto, SQL Server
103
105
  These databases require Docker containers to be running.
104
106
 
105
107
  **Starting database containers:**
@@ -108,6 +110,7 @@ Each database has a startup script in the test directory:
108
110
  - `test/mysql/mysql_start.sh`
109
111
  - `test/trino/trino_start.sh`
110
112
  - `test/presto/presto_start.sh`
113
+ - `test/mssql/mssql_start.sh`
111
114
 
112
115
  These scripts start Docker containers with appropriate test configurations and data.
113
116
 
@@ -174,7 +177,7 @@ All cross-database tests (`test/src/databases/all/`) reference tables as `malloy
174
177
  | DuckDB | DuckDB `CREATE TABLE AS SELECT FROM read_parquet()` | `test/duckdb/load_test_data.sh` (wraps `load_test_data.ts`) | Run via `sh test/duckdb/load_test_data.sh` (or `npm run build-duckdb-db`). Creates `test/data/duckdb/duckdb_test.db` |
175
178
  | PostgreSQL | DuckDB's `postgres` extension: `ATTACH` the server, `CREATE TABLE AS SELECT FROM read_parquet()` | `test/postgres/load_test_data.sh` (wraps `load_test_data.ts`) | Run by `test/postgres/postgres_start.sh` and by CI against a running server; connects with the `PG*` variables. No `ga_sample` (no anonymous record type) |
176
179
  | MySQL | DuckDB's `mysql` extension, likewise | `test/mysql/load_test_data.sh` (wraps `load_test_data.ts`) | Run by `test/mysql/mysql_start.sh`; connects with the `MYSQL_*` variables. No `ga_sample`, and `alltypes` without its array columns |
177
- | SQL Server | DuckDB's community `mssql` extension, likewise | `test/mssql/load_test_data.sh` (wraps `load_test_data.ts`) | Run by `test/mssql/mssql_start.sh`; connects as the test runtime does (`sa`, localhost:1433). Waits for the server itself. Same table set as MySQL. Not in CI |
180
+ | SQL Server | DuckDB's community `mssql` extension, likewise | `test/mssql/load_test_data.sh` (wraps `load_test_data.ts`) | Run by `test/mssql/mssql_start.sh`, which sets the `MSSQL_*` variables for the loader; the tests and both SQL Server test runtimes (`sqlserver`, `mssql_via_duckdb`) read the same variables from their own environment. Waits for the server itself. Same table set as MySQL. `db-sqlserver.yaml` runs on dispatch only while the dialect is experimental |
178
181
  | Trino/Presto | The hive connector reads the parquet in place: each file is mounted into the container and declared as an external table | `test/trino/hive_ddl.ts`, `test/trino/hive.properties`, `test/presto/hive.properties` | `trino_start.sh` / `presto_start.sh` derive the DDL from the parquet schemas at container start. The hive metastore lowercases names, so a table with nested columns (`ga_sample`) is a `_hive` table plus a view that casts the nested field names back; top-level column names stay lowercase, since Trino lowercases those in every connector |
179
182
  | BigQuery | Pre-loaded manually in `malloydata-org` project | (none) | No loader script in repo. Data assumed to exist. |
180
183
  | Snowflake | SQL: `PUT` local parquet → stage, `COPY INTO` table | `test/snowflake/load_test_data.sh` (wraps `load_test_data.sql`) | Run via `sh test/snowflake/load_test_data.sh` (needs the `snowsql` CLI) |
@@ -4,9 +4,25 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The SQL Server the mssql tests and loader connect to: the container
8
- * mssql_start.sh runs, with the password it sets.
7
+ * The SQL Server the mssql tests and loader connect to, from the MSSQL_*
8
+ * variables mssql_start.sh exports. The test runtimes read the same
9
+ * variables through SQLServerExecutor.getConnectionOptionsFromEnv.
9
10
  */
11
+ function required(name: string): string {
12
+ const value = process.env[name];
13
+ if (!value) {
14
+ throw new Error(`${name} is not set; see test/mssql/mssql_start.sh`);
15
+ }
16
+ return value;
17
+ }
18
+
19
+ /** The ADO-style string DuckDB's mssql extension takes in ATTACH */
10
20
  export function mssqlConnectionString(database: string): string {
11
- return `Server=localhost;Port=1433;Database=${database};User Id=sa;Password=Malloy_Test_123;TrustServerCertificate=true`;
21
+ const host = required('MSSQL_HOST');
22
+ const port = required('MSSQL_PORT');
23
+ const user = required('MSSQL_USER');
24
+ const password = required('MSSQL_PASSWORD');
25
+ const trust = process.env['MSSQL_TRUST_SERVER_CERTIFICATE'] === 'true';
26
+ // `Server=host,port`: the extension reads no separate Port key
27
+ return `Server=${host},${port};Database=${database};User Id=${user};Password=${password};TrustServerCertificate=${trust}`;
12
28
  }
@@ -6,9 +6,17 @@
6
6
  set -e
7
7
 
8
8
  SCRIPTDIR=$(cd $(dirname $0); pwd)
9
- CONTAINER_NAME="mssql-malloy"
10
- # Must match test/mssql/connection_string.ts
11
- SA_PASSWORD="Malloy_Test_123"
9
+ # MSSQL_IMAGE and MSSQL_CONTAINER select a server version to run beside the default
10
+ CONTAINER_NAME="${MSSQL_CONTAINER:-mssql-malloy}"
11
+ # The loader and the tests read MSSQL_*; this script is the only place a
12
+ # default is written. Export the same values to run the tests by hand.
13
+ export MSSQL_HOST="${MSSQL_HOST:-localhost}"
14
+ export MSSQL_PORT="${MSSQL_PORT:-1433}"
15
+ export MSSQL_TRUST_SERVER_CERTIFICATE="${MSSQL_TRUST_SERVER_CERTIFICATE:-true}"
16
+ export MSSQL_USER="${MSSQL_USER:-sa}"
17
+ export MSSQL_PASSWORD="${MSSQL_PASSWORD:-Malloy_Test_123}"
18
+ export MSSQL_DATABASE="${MSSQL_DATABASE:-malloytest}"
19
+ SA_PASSWORD="$MSSQL_PASSWORD"
12
20
 
13
21
  # Check for existing container
14
22
  if docker container inspect "$CONTAINER_NAME" > /dev/null 2>&1; then
@@ -20,24 +28,21 @@ if docker container inspect "$CONTAINER_NAME" > /dev/null 2>&1; then
20
28
  docker start "$CONTAINER_NAME"
21
29
  # The loader waits for the server; reloading is cheap
22
30
  sh "$SCRIPTDIR/load_test_data.sh"
23
- echo "MSSQL running on port 1433"
31
+ echo "MSSQL running on port $MSSQL_PORT"
24
32
  exit 0
25
33
  fi
26
34
 
27
- # Detect architecture — Azure SQL Edge for ARM64, SQL Server for x64
28
- ARCH=$(uname -m)
29
- if [ "$ARCH" = "arm64" ] || [ "$ARCH" = "aarch64" ]; then
30
- IMAGE="mcr.microsoft.com/azure-sql-edge:latest"
31
- else
32
- IMAGE="mcr.microsoft.com/mssql/server:2022-latest"
33
- fi
35
+ # SQL Server 2022 by default; MSSQL_IMAGE picks another version, 2017 being
36
+ # the oldest the sqlserver dialect runs on. The x64 image runs under emulation
37
+ # on ARM64 Macs.
38
+ IMAGE="${MSSQL_IMAGE:-mcr.microsoft.com/mssql/server:2022-latest}"
34
39
 
35
40
  echo "Starting $CONTAINER_NAME ($IMAGE)..."
36
41
  docker run -d \
37
42
  --name "$CONTAINER_NAME" \
38
43
  -e "ACCEPT_EULA=Y" \
39
44
  -e "MSSQL_SA_PASSWORD=$SA_PASSWORD" \
40
- -p 1433:1433 \
45
+ -p "$MSSQL_PORT:1433" \
41
46
  "$IMAGE"
42
47
 
43
48
  # The loader waits for the server. A container without its tables must not
@@ -47,4 +52,5 @@ echo "Loading test data..."
47
52
  sh "$SCRIPTDIR/load_test_data.sh"
48
53
  trap - ERR
49
54
 
50
- echo "MSSQL running on port 1433, database: malloytest"
55
+ echo "MSSQL running on port $MSSQL_PORT, database: $MSSQL_DATABASE"
56
+ echo "Tests need the same MSSQL_HOST/MSSQL_PORT/MSSQL_USER/MSSQL_PASSWORD/MSSQL_DATABASE in their environment"
@@ -1,4 +1,4 @@
1
1
  #!/bin/bash
2
2
 
3
- # stop and remove container
4
- docker rm -f mssql-malloy
3
+ # stop and remove the container mssql_start.sh started
4
+ docker rm -f "${MSSQL_CONTAINER:-mssql-malloy}"
package/package.json CHANGED
@@ -14,21 +14,22 @@
14
14
  "scripts": {
15
15
  "lint": "eslint '**/*.ts{,x}'",
16
16
  "lint-fix": "eslint '**/*.ts{,x}' --fix",
17
- "test": "jest --config=../jest.config.js",
17
+ "test": "echo 'Read the Testing section of the repo-root CONTEXT.md for information on testing in this monorepo'; exit 1",
18
18
  "build": "node ../node_modules/typescript/bin/tsc --build",
19
19
  "clean": "node ../node_modules/typescript/bin/tsc --build --clean && rm -f tsconfig.tsbuildinfo",
20
20
  "malloyc": "ts-node ../scripts/malloy-to-json"
21
21
  },
22
22
  "dependencies": {
23
- "@malloydata/db-bigquery": "0.0.434",
24
- "@malloydata/db-duckdb": "0.0.434",
25
- "@malloydata/db-postgres": "0.0.434",
26
- "@malloydata/db-snowflake": "0.0.434",
27
- "@malloydata/db-trino": "0.0.434",
28
- "@malloydata/malloy": "0.0.434",
29
- "@malloydata/malloy-tag": "0.0.434",
30
- "@malloydata/render": "0.0.434",
31
- "@malloydata/render-validator": "0.0.434",
23
+ "@malloydata/db-bigquery": "0.0.435",
24
+ "@malloydata/db-duckdb": "0.0.435",
25
+ "@malloydata/db-postgres": "0.0.435",
26
+ "@malloydata/db-snowflake": "0.0.435",
27
+ "@malloydata/db-sqlserver": "0.0.435",
28
+ "@malloydata/db-trino": "0.0.435",
29
+ "@malloydata/malloy": "0.0.435",
30
+ "@malloydata/malloy-tag": "0.0.435",
31
+ "@malloydata/render": "0.0.435",
32
+ "@malloydata/render-validator": "0.0.435",
32
33
  "events": "^3.3.0",
33
34
  "jsdom": "^22.1.0",
34
35
  "luxon": "^3.7.2",
@@ -42,5 +43,5 @@
42
43
  "overrides": {
43
44
  "typescript": "^6.0.3"
44
45
  },
45
- "version": "0.0.434"
46
+ "version": "0.0.435"
46
47
  }
@@ -3,13 +3,14 @@
3
3
  * SPDX-License-Identifier: MIT
4
4
  */
5
5
 
6
- import type {
7
- FetchSchemaOptions,
8
- MalloyError,
9
- SQLSourceDef,
6
+ import type {MalloyError} from '@malloydata/malloy';
7
+ import {
8
+ DuckDBDialect,
9
+ getDialect,
10
+ InMemoryURLReader,
11
+ registerDialect,
12
+ SingleConnectionRuntime,
10
13
  } from '@malloydata/malloy';
11
- import {DuckDBDialect, registerDialect} from '@malloydata/malloy';
12
- import {testRuntimeFor} from '../runtimes';
13
14
  import {DuckDBConnection} from '@malloydata/db-duckdb';
14
15
 
15
16
  const envDatabases = (
@@ -33,30 +34,24 @@ async function getError<T>(promise: Promise<T>): Promise<Error | undefined> {
33
34
  return undefined;
34
35
  }
35
36
 
37
+ /**
38
+ * A file acknowledges an experimental dialect with
39
+ * `##! experimental.dialect.NAME` wherever it takes one on: by naming a
40
+ * connection on it, or by importing a source or query that uses it. The
41
+ * acknowledgment is a pragma, so an importer gets no credit for the line in
42
+ * the file it imports.
43
+ */
36
44
  describe('experimental dialects', () => {
37
45
  const duckdbX = 'duckdb_experimental';
46
+ const flag = `##! experimental.dialect.${duckdbX}`;
47
+
38
48
  class DuckdbXConnection extends DuckDBConnection {
39
49
  name = duckdbX;
40
- public async festchSchemaFOrSQLSource(
41
- sqlRef: SQLSourceDef,
42
- options: FetchSchemaOptions
43
- ): Promise<
44
- | {structDef: SQLSourceDef; error?: undefined}
45
- | {error: string; structDef?: undefined}
46
- > {
47
- const result = await super.fetchSchemaForSQLStruct(sqlRef, options);
48
- if (result.error === undefined) {
49
- return {structDef: {...result.structDef, dialect: duckdbX}};
50
- }
51
- return result;
52
- }
53
50
  get dialectName(): string {
54
51
  return duckdbX;
55
52
  }
56
53
  }
57
54
 
58
- const connection = new DuckdbXConnection(duckdbX, ':memory:');
59
-
60
55
  class DuckdbXDialect extends DuckDBDialect {
61
56
  experimental = true;
62
57
  name = duckdbX;
@@ -65,35 +60,124 @@ describe('experimental dialects', () => {
65
60
  }
66
61
  }
67
62
 
63
+ const connection = new DuckdbXConnection(duckdbX, ':memory:');
68
64
  registerDialect(new DuckdbXDialect());
69
- const runtime = testRuntimeFor(connection);
70
- runtime.isTestRuntime = false; // Enables checking experimental dialects
71
-
72
- test('generate an error when used without experiment enabled', async () => {
73
- const error = await getError(
74
- runtime.getModel(`
75
- source: s is ${duckdbX}.sql('SELECT 1 as one')
76
- `)
65
+
66
+ const libURL = 'file:///lib.malloy';
67
+ const lib = [
68
+ flag,
69
+ `source: s is ${duckdbX}.sql('SELECT 1 as one')`,
70
+ 'query: q is s -> { select: one }',
71
+ ].join('\n');
72
+
73
+ function runtime(): SingleConnectionRuntime {
74
+ return new SingleConnectionRuntime({
75
+ connection,
76
+ urlReader: new InMemoryURLReader(new Map([[libURL, lib]])),
77
+ });
78
+ }
79
+
80
+ /** The one problem a refused compile reports, with the line it points at. */
81
+ async function refusal(
82
+ src: string
83
+ ): Promise<{message: string; line: number | undefined}> {
84
+ const error = await getError(runtime().getModel(src));
85
+ expect(error).toBeDefined();
86
+ const problems = (error as MalloyError).problems;
87
+ expect(problems).toHaveLength(1);
88
+ return {
89
+ message: problems[0].message,
90
+ line: problems[0].at?.range.start.line,
91
+ };
92
+ }
93
+
94
+ test('naming a connection on the dialect needs the flag', async () => {
95
+ const got = await refusal(
96
+ ['-- line zero', `source: s is ${duckdbX}.sql('SELECT 1 as one')`].join(
97
+ '\n'
98
+ )
77
99
  );
78
- expect(error).not.toBeUndefined();
79
- if (error !== undefined) {
80
- const problems = (error as MalloyError).problems;
81
- expect(problems.length).toBe(1);
82
- expect(problems[0].message).toContain(
83
- `##! experimental.dialect.${duckdbX}`
100
+ expect(got.message).toContain(flag);
101
+ expect(got.line).toBe(1);
102
+ });
103
+
104
+ test.each([['constructor'], ['__proto__'], ['toString']])(
105
+ 'a connection named %s is checked like any other',
106
+ async connName => {
107
+ const named = new DuckdbXConnection(duckdbX, ':memory:');
108
+ named.name = connName;
109
+ const error = await getError(
110
+ new SingleConnectionRuntime({connection: named}).getModel(
111
+ `source: s is ${connName}.sql('SELECT 1 as one')`
112
+ )
84
113
  );
114
+ expect(error).toBeDefined();
115
+ expect((error as MalloyError).problems[0].message).toContain(flag);
116
+ await named.close();
85
117
  }
118
+ );
119
+
120
+ test('the flag admits a connection on the dialect', async () => {
121
+ await runtime().getModel(
122
+ [flag, `source: s is ${duckdbX}.sql('SELECT 1 as one')`].join('\n')
123
+ );
86
124
  });
87
125
 
88
- test('does not generate an error when used with experiment enabled', async () => {
89
- await runtime.getModel(`
90
- ##! experimental.dialect.${duckdbX}
91
- source: s is ${duckdbX}.sql('SELECT 1 as one')
92
- `);
126
+ test('importing a source on the dialect needs the flag, reported at the import', async () => {
127
+ const got = await refusal(
128
+ ['-- line zero', `import "${libURL}"`, 'run: s -> { select: one }'].join(
129
+ '\n'
130
+ )
131
+ );
132
+ expect(got.message).toContain(flag);
133
+ expect(got.line).toBe(1);
134
+ });
135
+
136
+ test('importing only a query on the dialect needs the flag', async () => {
137
+ const got = await refusal(`import {q} from "${libURL}"`);
138
+ expect(got.message).toContain(flag);
139
+ });
140
+
141
+ test('the flag admits an import and use of the dialect', async () => {
142
+ const sql = await runtime()
143
+ .loadModel([flag, `import "${libURL}"`].join('\n'))
144
+ .loadQuery('run: s -> { select: one }')
145
+ .getSQL();
146
+ expect(sql).toContain('SELECT');
147
+ });
148
+
149
+ test('a query over a model needs nothing of its own', async () => {
150
+ const sql = await runtime()
151
+ .loadModel(
152
+ [flag, `source: s is ${duckdbX}.sql('SELECT 1 as one')`].join('\n')
153
+ )
154
+ .loadQuery('run: s -> { select: one }')
155
+ .getSQL();
156
+ expect(sql).toContain('SELECT');
157
+ });
158
+
159
+ test('an accepted dialect needs no flag anywhere', async () => {
160
+ // What the shared-suite harness does once per process for the dialect
161
+ // under test. Undone below by registering a fresh dialect object.
162
+ getDialect(duckdbX).acceptExperimental();
163
+ try {
164
+ const sql = await runtime()
165
+ .loadModel(
166
+ [
167
+ `import "${libURL}"`,
168
+ `source: t is ${duckdbX}.sql('SELECT 2 as two')`,
169
+ ].join('\n')
170
+ )
171
+ .loadQuery('run: s -> { select: one }')
172
+ .getSQL();
173
+ expect(sql).toContain('SELECT');
174
+ } finally {
175
+ registerDialect(new DuckdbXDialect());
176
+ }
93
177
  });
94
178
 
95
179
  afterAll(async () => {
96
- await runtime.connection.close();
180
+ await connection.close();
97
181
  registerDialect(new DuckDBDialect());
98
182
  });
99
183
  });
@@ -79,11 +79,13 @@ runtimes.runtimeMap.forEach((runtime, databaseName) => {
79
79
  `).toMatchResult(testModel, {fieldName: 'one', weight: 51});
80
80
  });
81
81
 
82
- // bigquery doesn't support row count based sampling.
82
+ // bigquery doesn't support row count based sampling; sqlserver's TABLESAMPLE
83
+ // returns whole pages, not the count asked for.
83
84
  test.when(
84
85
  databaseName !== 'bigquery' &&
85
86
  databaseName !== 'trino' &&
86
- databaseName !== 'presto'
87
+ databaseName !== 'presto' &&
88
+ databaseName !== 'sqlserver'
87
89
  )(`index rows count - ${databaseName}`, async () => {
88
90
  await expect(`
89
91
  run: ${databaseName}.table('malloytest.state_facts') extend {
@@ -0,0 +1,395 @@
1
+ /*
2
+ * Copyright Contributors to the Malloy project
3
+ * SPDX-License-Identifier: MIT
4
+ */
5
+
6
+ import {RuntimeList} from '../../runtimes';
7
+ import {describeIfDatabaseAvailable} from '../../util';
8
+ import '@malloydata/malloy/test/matchers';
9
+ import {wrapTestModel} from '@malloydata/malloy/test';
10
+
11
+ const [describe] = describeIfDatabaseAvailable(['sqlserver']);
12
+
13
+ /*
14
+ * The dialect is built up here in the order a query grows: a literal, a
15
+ * table, a filter, an ordering, a grouping, a join. Each block is what the
16
+ * next one stands on, and what the cross-dialect suite in ../all assumes.
17
+ */
18
+ describe('SQL Server', () => {
19
+ const runtimes = new RuntimeList(['sqlserver']);
20
+ const runtime = runtimes.runtimeMap.get('sqlserver');
21
+ if (runtime === undefined) {
22
+ throw new Error("Couldn't build runtime");
23
+ }
24
+ // A test runtime enables experimental dialects; no compiler flag is needed
25
+ const tm = wrapTestModel(runtime, '');
26
+
27
+ afterAll(async () => {
28
+ await runtimes.closeAll();
29
+ });
30
+
31
+ describe('a SQL block', () => {
32
+ test('selects a literal', async () => {
33
+ await expect(
34
+ 'run: sqlserver.sql("SELECT 1 AS n") -> { select: n }'
35
+ ).toMatchResult(tm, {n: 1});
36
+ });
37
+
38
+ test('carries string, date and timestamp values', async () => {
39
+ await expect(`
40
+ run: sqlserver.sql("""
41
+ SELECT N'héllo' AS s,
42
+ CAST('2021-02-24' AS DATE) AS d,
43
+ CAST('2021-02-24 03:05:06' AS DATETIME2) AS t
44
+ """) -> { select: s, d, t }
45
+ `).toMatchResult(tm, {
46
+ s: 'héllo',
47
+ d: new Date('2021-02-24T00:00:00Z'),
48
+ t: new Date('2021-02-24T03:05:06Z'),
49
+ });
50
+ });
51
+
52
+ test('carries a newline in a string literal', async () => {
53
+ await expect(`
54
+ run: sqlserver.sql("SELECT 1 AS n") -> { select: s is 'a\\nb' }
55
+ `).toMatchResult(tm, {s: 'a\nb'});
56
+ });
57
+
58
+ test('quotes a field named like a keyword', async () => {
59
+ await expect(`
60
+ run: sqlserver.sql('SELECT 1 AS "select"') -> {
61
+ select: \`select\`, \`create\` is \`select\` + 1
62
+ }
63
+ `).toMatchResult(tm, {select: 1, create: 2});
64
+ });
65
+ });
66
+
67
+ describe('a table', () => {
68
+ test('a boolean literal is a condition', async () => {
69
+ await expect(`
70
+ run: sqlserver.table('malloytest.state_facts') -> {
71
+ where: true
72
+ aggregate: n is count(), none is count() { where: false }
73
+ }
74
+ `).toEqualResult(tm, [{n: 51, none: 0}]);
75
+ });
76
+
77
+ test('a bit column is an integer', async () => {
78
+ await expect(`
79
+ run: sqlserver.table('malloytest.alltypes') -> {
80
+ select: t_bool_true, t_bool_false, t_bool_null
81
+ }
82
+ `).toEqualResult(tm, [
83
+ {t_bool_true: 1, t_bool_false: 0, t_bool_null: null},
84
+ ]);
85
+ });
86
+
87
+ test('a limit of zero is no limit', async () => {
88
+ // FETCH NEXT 0 ROWS is a parse error; the compiler writes no limit at all
89
+ await expect(`
90
+ run: sqlserver.table('malloytest.state_facts') -> {
91
+ select: state
92
+ limit: 0
93
+ } -> { aggregate: n is count() }
94
+ `).toEqualResult(tm, [{n: 51}]);
95
+ });
96
+
97
+ test('a declared boolean is a 0/1 value', async () => {
98
+ await expect(`
99
+ run: sqlserver.table('malloytest.state_facts') -> {
100
+ group_by:
101
+ big is airport_count > 500
102
+ c_state is starts_with(state, 'C')
103
+ aggregate: n is count()
104
+ order_by: big desc, c_state desc
105
+ }
106
+ `).toEqualResult(tm, [
107
+ {big: true, c_state: true, n: 1},
108
+ {big: true, c_state: false, n: 11},
109
+ {big: false, c_state: true, n: 2},
110
+ {big: false, c_state: false, n: 37},
111
+ ]);
112
+ });
113
+
114
+ test('a cast to boolean is refused', async () => {
115
+ await expect(
116
+ runtime
117
+ .loadQuery(
118
+ `run: sqlserver.table('malloytest.state_facts') -> {
119
+ select: b is airport_count::boolean
120
+ }`
121
+ )
122
+ .run()
123
+ ).rejects.toThrow(/boolean/);
124
+ });
125
+
126
+ test('starts_with and ends_with are conditions', async () => {
127
+ await expect(`
128
+ run: sqlserver.table('malloytest.state_facts') -> {
129
+ aggregate:
130
+ c_states is count() { where: starts_with(state, 'C') }
131
+ a_states is count() { where: ends_with(state, 'A') }
132
+ nan_counts is count() { where: is_nan(airport_count) }
133
+ }
134
+ `).toEqualResult(tm, [{c_states: 3, a_states: 8, nan_counts: 0}]);
135
+ });
136
+
137
+ test('reads a column', async () => {
138
+ await expect(`
139
+ run: sqlserver.table('malloytest.state_facts') -> {
140
+ select: state
141
+ where: state = 'CA'
142
+ }
143
+ `).toEqualResult(tm, [{state: 'CA'}]);
144
+ });
145
+
146
+ test('reads a bracket-quoted path', async () => {
147
+ await expect(`
148
+ run: sqlserver.table('[malloytest].[state_facts]') -> {
149
+ select: state
150
+ where: state = 'CA'
151
+ }
152
+ `).toEqualResult(tm, [{state: 'CA'}]);
153
+ });
154
+
155
+ test('reads a database-qualified path', async () => {
156
+ await expect(`
157
+ run: sqlserver.table('malloytest.malloytest.state_facts') -> {
158
+ aggregate: n is count()
159
+ }
160
+ `).toMatchResult(tm, {n: 51});
161
+ });
162
+
163
+ test('orders and limits', async () => {
164
+ await expect(`
165
+ run: sqlserver.table('malloytest.state_facts') -> {
166
+ select: state
167
+ order_by: state desc
168
+ limit: 2
169
+ }
170
+ `).toEqualResult(tm, [{state: 'WY'}, {state: 'WV'}]);
171
+ });
172
+
173
+ test('limits a stage that feeds another', async () => {
174
+ await expect(`
175
+ run: sqlserver.table('malloytest.state_facts') -> {
176
+ select: state
177
+ order_by: state desc
178
+ limit: 3
179
+ } -> {
180
+ select: state
181
+ order_by: state asc
182
+ }
183
+ `).toEqualResult(tm, [{state: 'WI'}, {state: 'WV'}, {state: 'WY'}]);
184
+ });
185
+
186
+ test('filters on a comparison', async () => {
187
+ await expect(`
188
+ run: sqlserver.table('malloytest.state_facts') -> {
189
+ aggregate: n is count()
190
+ where: airport_count > 500
191
+ }
192
+ `).toMatchResult(tm, {n: 12});
193
+ });
194
+
195
+ test('escapes a bracket in a LIKE pattern', async () => {
196
+ await expect(`
197
+ run: sqlserver.sql("""
198
+ SELECT s FROM (VALUES (N'a[b'), (N'ab'), (N'axb')) v(s)
199
+ """) -> { select: s; where: s ~ 'a[%' }
200
+ `).toEqualResult(tm, [{s: 'a[b'}]);
201
+ });
202
+ });
203
+
204
+ describe('aggregation', () => {
205
+ test('a group of only constants is one row', async () => {
206
+ await expect(`
207
+ run: sqlserver.table('malloytest.state_facts') -> { group_by: x is 1, y is 'a' }
208
+ `).toEqualResult(tm, [{x: 1, y: 'a'}]);
209
+ });
210
+
211
+ test('a constant dimension groups with the others', async () => {
212
+ await expect(`
213
+ run: sqlserver.table('malloytest.state_facts') -> {
214
+ group_by: tag is 'facts', big is airport_count > 500
215
+ aggregate: n is count()
216
+ order_by: big desc
217
+ }
218
+ `).toEqualResult(tm, [
219
+ {tag: 'facts', big: true, n: 12},
220
+ {tag: 'facts', big: false, n: 39},
221
+ ]);
222
+ });
223
+
224
+ test('counts without a group', async () => {
225
+ await expect(`
226
+ run: sqlserver.table('malloytest.state_facts') -> {
227
+ aggregate: state_count is count()
228
+ }
229
+ `).toMatchResult(tm, {state_count: 51});
230
+ });
231
+
232
+ test('groups by a column', async () => {
233
+ await expect(`
234
+ run: sqlserver.table('malloytest.state_facts') -> {
235
+ group_by: popular_name
236
+ aggregate: state_count is count()
237
+ order_by: state_count desc
238
+ }
239
+ `).toMatchResult(tm, {popular_name: 'Isabella'});
240
+ });
241
+
242
+ test('groups by an expression', async () => {
243
+ await expect(`
244
+ run: sqlserver.table('malloytest.state_facts') -> {
245
+ group_by: first is substr(state, 1, 1)
246
+ aggregate: state_count is count()
247
+ order_by: state_count desc, first
248
+ }
249
+ `).toMatchResult(tm, {first: 'M', state_count: 8});
250
+ });
251
+
252
+ test('orders and limits a grouped result', async () => {
253
+ await expect(`
254
+ run: sqlserver.table('malloytest.state_facts') -> {
255
+ group_by: popular_name
256
+ aggregate: state_count is count()
257
+ order_by: state_count desc, popular_name
258
+ limit: 2
259
+ }
260
+ `).toEqualResult(tm, [
261
+ {popular_name: 'Isabella', state_count: 24},
262
+ {popular_name: 'Sophia', state_count: 11},
263
+ ]);
264
+ });
265
+
266
+ test('orders a query used as a source', async () => {
267
+ await expect(`
268
+ source: by_name is sqlserver.table('malloytest.state_facts') -> {
269
+ group_by: popular_name
270
+ aggregate: state_count is count()
271
+ order_by: state_count desc
272
+ }
273
+ run: by_name -> {
274
+ select: popular_name
275
+ where: state_count > 10
276
+ order_by: popular_name
277
+ }
278
+ `).toEqualResult(tm, [
279
+ {popular_name: 'Isabella'},
280
+ {popular_name: 'Sophia'},
281
+ ]);
282
+ });
283
+
284
+ test('orders the last stage of a pipeline', async () => {
285
+ await expect(`
286
+ run: sqlserver.table('malloytest.state_facts') -> {
287
+ group_by: popular_name
288
+ aggregate: state_count is count()
289
+ } -> {
290
+ select: popular_name, state_count
291
+ order_by: state_count asc, popular_name desc
292
+ limit: 2
293
+ }
294
+ `).toEqualResult(tm, [
295
+ {popular_name: 'Madison', state_count: 3},
296
+ {popular_name: 'Ava', state_count: 3},
297
+ ]);
298
+ });
299
+
300
+ test('divides as a float', async () => {
301
+ await expect(`
302
+ run: sqlserver.sql("SELECT 7 AS a, 2 AS b") -> {
303
+ select: q is a / b
304
+ }
305
+ `).toMatchResult(tm, {q: 3.5});
306
+ });
307
+ });
308
+
309
+ describe('functions', () => {
310
+ test('pow computes in floating point', async () => {
311
+ await expect(`
312
+ run: sqlserver.sql("SELECT 1 AS n") -> {
313
+ select: root is round(pow(2, 0.5), 6), hundredth is pow(10, -2)
314
+ }
315
+ `).toEqualResult(tm, [{root: 1.414214, hundredth: 0.01}]);
316
+ });
317
+
318
+ test('length counts trailing spaces', async () => {
319
+ await expect(`
320
+ run: sqlserver.sql("SELECT 1 AS n") -> {
321
+ select:
322
+ padded is length('abc ')
323
+ back is substr('abc ', -2)
324
+ }
325
+ `).toEqualResult(tm, [{padded: 5, back: 'c '}]);
326
+ });
327
+ });
328
+
329
+ describe('time zones', () => {
330
+ test('converts a query time zone through its Windows name', async () => {
331
+ await expect(`
332
+ run: sqlserver.sql("SELECT CAST('2020-02-20 00:00:00' AS DATETIME2) AS t") -> {
333
+ timezone: 'America/Mexico_City'
334
+ select: mex_day is t.day, mex_hour is hour(t)
335
+ }
336
+ `).toMatchResult(tm, {
337
+ mex_day: new Date('2020-02-19T06:00:00Z'),
338
+ mex_hour: 18,
339
+ });
340
+ });
341
+
342
+ test('a zone is known by its current IANA name as well as its old one', async () => {
343
+ await expect(`
344
+ run: sqlserver.sql("SELECT CAST('2021-02-24 03:05:06' AS DATETIME2) AS t") -> {
345
+ timezone: 'Asia/Kolkata'
346
+ select: h is hour(t)
347
+ }
348
+ `).toEqualResult(tm, [{h: 8}]);
349
+ await expect(`
350
+ run: sqlserver.sql("SELECT CAST('2021-02-24 03:05:06' AS DATETIME2) AS t") -> {
351
+ timezone: 'Europe/Kyiv'
352
+ select: h is hour(t)
353
+ }
354
+ `).toEqualResult(tm, [{h: 5}]);
355
+ });
356
+
357
+ test('a literal written in a zone is that instant', async () => {
358
+ await expect(`
359
+ run: sqlserver.sql("SELECT 1 AS n") -> {
360
+ select: t is @2020-02-20 00:00:00[America/Mexico_City]
361
+ }
362
+ `).toMatchResult(tm, {t: new Date('2020-02-20T06:00:00Z')});
363
+ });
364
+ });
365
+
366
+ describe('joins', () => {
367
+ test('sums each side of a fan-out once', async () => {
368
+ await expect(`
369
+ source: facts is sqlserver.table('malloytest.state_facts')
370
+ run: facts extend { join_cross: other is facts on other.state = 'CA' | 'NY' } -> {
371
+ aggregate:
372
+ row_count is count(concat(state, other.state))
373
+ left_sum is airport_count.sum()
374
+ right_sum is other.airport_count.sum()
375
+ }
376
+ `).toMatchResult(tm, {row_count: 102, left_sum: 19701, right_sum: 1560});
377
+ });
378
+
379
+ test('joins and aggregates symmetrically', async () => {
380
+ await expect(`
381
+ source: carriers is sqlserver.table('malloytest.carriers') extend {
382
+ primary_key: code
383
+ }
384
+ source: flights is sqlserver.table('malloytest.flights') extend {
385
+ join_one: carriers with carrier
386
+ }
387
+ run: flights -> {
388
+ group_by: carriers.nickname
389
+ aggregate: flight_count is count(), carrier_count is carriers.count()
390
+ order_by: flight_count desc
391
+ }
392
+ `).toMatchResult(tm, {nickname: 'Southwest', carrier_count: 1});
393
+ });
394
+ });
395
+ });
package/src/runtimes.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  InMemoryURLReader,
17
17
  InMemoryModelCache,
18
18
  CacheManager,
19
+ getDialect,
19
20
  } from '@malloydata/malloy';
20
21
  import {createTestRuntime} from '@malloydata/malloy/test';
21
22
  import {BigQueryConnection} from '@malloydata/db-bigquery';
@@ -31,6 +32,10 @@ import {
31
32
  MySQLExecutor,
32
33
  } from '@malloydata/db-mysql/src/mysql_connection';
33
34
  import {DatabricksConnection} from '@malloydata/db-databricks/src/databricks_connection';
35
+ import {
36
+ SQLServerConnection,
37
+ SQLServerExecutor,
38
+ } from '@malloydata/db-sqlserver/src/sqlserver_connection';
34
39
  import {EventEmitter} from 'events';
35
40
 
36
41
  export class SnowflakeTestConnection extends SnowflakeConnection {
@@ -144,6 +149,21 @@ export class DuckDBWASMTestConnection extends DuckDBWASMConnection {
144
149
  }
145
150
  }
146
151
 
152
+ export class SQLServerTestConnection extends SQLServerConnection {
153
+ public async runSQL(
154
+ sqlCommand: string,
155
+ options?: RunSQLOptions
156
+ ): Promise<MalloyQueryData> {
157
+ try {
158
+ return await super.runSQL(sqlCommand, options);
159
+ } catch (e) {
160
+ // eslint-disable-next-line no-console
161
+ console.log(`Error in SQL:\n ${sqlCommand}`);
162
+ throw e;
163
+ }
164
+ }
165
+ }
166
+
147
167
  export class TestCacheManager extends CacheManager {
148
168
  constructor(readonly _modelCache: ModelCache) {
149
169
  super(_modelCache);
@@ -203,15 +223,26 @@ export function runtimeFor(dbName: string): SingleConnectionRuntime {
203
223
  );
204
224
  break;
205
225
  case 'mssql_via_duckdb':
206
- connection = new DuckDBTestConnection({
207
- name: dbName,
208
- additionalExtensions: ['mssql'],
209
- setupSQL: [
210
- // Must match test/mssql/connection_string.ts
211
- "ATTACH 'Server=localhost;Port=1433;Database=malloytest;User Id=sa;Password=Malloy_Test_123;TrustServerCertificate=true' AS msdb (TYPE mssql)",
212
- 'USE msdb.malloytest',
213
- ].join(';\n'),
214
- });
226
+ {
227
+ const s = SQLServerExecutor.getConnectionOptionsFromEnv();
228
+ if (s.server === undefined) {
229
+ throw new Error(
230
+ 'MSSQL_HOST is not set; see test/mssql/mssql_start.sh'
231
+ );
232
+ }
233
+ connection = new DuckDBTestConnection({
234
+ name: dbName,
235
+ additionalExtensions: ['mssql'],
236
+ setupSQL: [
237
+ // The same string test/mssql/connection_string.ts builds for the
238
+ // loader, written here because packages/malloy-render type-checks
239
+ // this file alone and admits no import beside it. `Server=host,port`:
240
+ // the extension reads no separate Port key.
241
+ `ATTACH 'Server=${s.server},${s.port ?? 1433};Database=${s.database};User Id=${s.user};Password=${s.password};TrustServerCertificate=${s.trustServerCertificate ?? false}' AS msdb (TYPE mssql)`,
242
+ `USE msdb.${s.database}`,
243
+ ].join(';\n'),
244
+ });
245
+ }
215
246
  break;
216
247
  case 'motherduck':
217
248
  connection = new DuckDBTestConnection({
@@ -247,6 +278,12 @@ export function runtimeFor(dbName: string): SingleConnectionRuntime {
247
278
  TrinoExecutor.getConnectionOptionsFromEnv(dbName) // they share configs.
248
279
  );
249
280
  break;
281
+ case 'sqlserver':
282
+ connection = new SQLServerTestConnection(
283
+ dbName,
284
+ SQLServerExecutor.getConnectionOptionsFromEnv()
285
+ );
286
+ break;
250
287
  case 'databricks':
251
288
  {
252
289
  const warehouseId = process.env['DATABRICKS_WAREHOUSE_ID'];
@@ -265,6 +302,9 @@ export function runtimeFor(dbName: string): SingleConnectionRuntime {
265
302
  default:
266
303
  throw new Error(`Unknown runtime "${dbName}`);
267
304
  }
305
+ // The shared suites carry no `##! experimental.dialect.NAME` lines. A
306
+ // dialect under test is accepted once, for the whole process, instead.
307
+ getDialect(connection.dialectName).acceptExperimental();
268
308
  return testRuntimeFor(connection);
269
309
  } catch (error) {
270
310
  throw new Error(
@@ -314,7 +354,6 @@ export class RuntimeList {
314
354
  database instanceof SingleConnectionRuntime
315
355
  ? database
316
356
  : runtimeFor(database);
317
- rt.isTestRuntime = true;
318
357
  this.runtimeMap.set(rt.connection.name, rt);
319
358
  this.runtimeList.push([rt.connection.name, rt]);
320
359
  }
@@ -521,21 +521,17 @@ export class TestSelect {
521
521
  const innerQuery = selects.join('\nUNION ALL ');
522
522
 
523
523
  // Generate ORDER BY based on dialect preference
524
- let orderByClause: string;
525
- if (this.dialect.orderByClause === 'ordinal') {
526
- // ORDER BY position (column count + 1 since row_id is last)
527
- orderByClause = `ORDER BY ${columnList.length + 1}`;
528
- } else if (this.dialect.orderByClause === 'output_name') {
529
- // ORDER BY column name
530
- orderByClause = `ORDER BY ${rowIdColumn}`;
531
- } else {
532
- // ORDER BY expression - just use column name (qualified would be t_sorted.__ts_row_id__)
533
- orderByClause = `ORDER BY ${rowIdColumn}`;
534
- }
535
- // Presto/Trino ignores ORDER BY on a subquery without LIMIT
536
- orderByClause += ` LIMIT ${rows.length}`;
524
+ const orderTerm =
525
+ this.dialect.orderByClause === 'ordinal'
526
+ ? `${columnList.length + 1}`
527
+ : rowIdColumn;
528
+ // Presto/Trino ignores ORDER BY on a subquery without a row limit
529
+ const orderByClause = this.dialect.sqlOrderByLimit(
530
+ [orderTerm],
531
+ rows.length
532
+ );
537
533
 
538
- const sql = `SELECT ${quotedColumns}\nFROM (\n SELECT *\n FROM (\n${innerQuery}\n ) AS t_sorted\n ${orderByClause}\n) AS t_result\n`;
534
+ const sql = `SELECT ${quotedColumns}\nFROM (\n SELECT *\n FROM (\n${innerQuery}\n ) AS t_sorted\n ${orderByClause}) AS t_result\n`;
539
535
 
540
536
  return sql;
541
537
  }
package/tsconfig.json CHANGED
@@ -34,6 +34,9 @@
34
34
  {
35
35
  "path": "../packages/malloy-db-snowflake"
36
36
  },
37
+ {
38
+ "path": "../packages/malloy-db-sqlserver"
39
+ },
37
40
  {
38
41
  "path": "../packages/malloy-db-trino"
39
42
  },