@malloydata/malloy-tests 0.0.434 → 0.0.436
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 +5 -2
- package/mssql/connection_string.ts +19 -3
- package/mssql/mssql_start.sh +19 -13
- package/mssql/mssql_stop.sh +2 -2
- package/package.json +12 -11
- package/src/core/experimental-dialects.spec.ts +125 -41
- package/src/core/persist.spec.ts +275 -3
- package/src/databases/all/db_filter_expressions.spec.ts +7 -0
- package/src/databases/all/db_index.spec.ts +4 -2
- package/src/databases/all/expr.spec.ts +9 -8
- package/src/databases/all/functions.spec.ts +83 -57
- package/src/databases/all/new-matchers.spec.ts +17 -11
- package/src/databases/all/orderby.spec.ts +19 -12
- package/src/databases/all/test-select.spec.ts +5 -2
- package/src/databases/all/time.spec.ts +191 -45
- package/src/databases/sqlserver/sqlserver.spec.ts +509 -0
- package/src/runtimes.ts +49 -10
- package/src/test-select.ts +10 -14
- package/tsconfig.json +3 -0
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`; run the shared suite on one with `MALLOY_DATABASE=<name>` (`ci-<name>`) and expect failures. What CI runs for an experimental dialect is decided per dialect; see [adding-a-new-database.md](../packages/malloy/src/doc/adding-a-new-database.md). 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
|
|
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. In CI, `db-sqlserver.yaml` runs the same script on SQL Server 2017, the oldest the dialect supports |
|
|
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
|
|
8
|
-
* mssql_start.sh
|
|
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
|
-
|
|
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
|
}
|
package/mssql/mssql_start.sh
CHANGED
|
@@ -6,9 +6,17 @@
|
|
|
6
6
|
set -e
|
|
7
7
|
|
|
8
8
|
SCRIPTDIR=$(cd $(dirname $0); pwd)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
31
|
+
echo "MSSQL running on port $MSSQL_PORT"
|
|
24
32
|
exit 0
|
|
25
33
|
fi
|
|
26
34
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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"
|
package/mssql/mssql_stop.sh
CHANGED
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": "
|
|
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.
|
|
24
|
-
"@malloydata/db-duckdb": "0.0.
|
|
25
|
-
"@malloydata/db-postgres": "0.0.
|
|
26
|
-
"@malloydata/db-snowflake": "0.0.
|
|
27
|
-
"@malloydata/db-
|
|
28
|
-
"@malloydata/
|
|
29
|
-
"@malloydata/malloy
|
|
30
|
-
"@malloydata/
|
|
31
|
-
"@malloydata/render
|
|
23
|
+
"@malloydata/db-bigquery": "0.0.436",
|
|
24
|
+
"@malloydata/db-duckdb": "0.0.436",
|
|
25
|
+
"@malloydata/db-postgres": "0.0.436",
|
|
26
|
+
"@malloydata/db-snowflake": "0.0.436",
|
|
27
|
+
"@malloydata/db-sqlserver": "0.0.436",
|
|
28
|
+
"@malloydata/db-trino": "0.0.436",
|
|
29
|
+
"@malloydata/malloy": "0.0.436",
|
|
30
|
+
"@malloydata/malloy-tag": "0.0.436",
|
|
31
|
+
"@malloydata/render": "0.0.436",
|
|
32
|
+
"@malloydata/render-validator": "0.0.436",
|
|
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.
|
|
46
|
+
"version": "0.0.436"
|
|
46
47
|
}
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* SPDX-License-Identifier: MIT
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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('
|
|
89
|
-
await
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
180
|
+
await connection.close();
|
|
97
181
|
registerDialect(new DuckDBDialect());
|
|
98
182
|
});
|
|
99
183
|
});
|
package/src/core/persist.spec.ts
CHANGED
|
@@ -14,6 +14,9 @@ import type {
|
|
|
14
14
|
} from '@malloydata/malloy';
|
|
15
15
|
import {
|
|
16
16
|
ConnectionRuntime,
|
|
17
|
+
MalloyConfig,
|
|
18
|
+
Manifest,
|
|
19
|
+
Runtime,
|
|
17
20
|
SingleConnectionRuntime,
|
|
18
21
|
EMPTY_BUILD_MANIFEST,
|
|
19
22
|
} from '@malloydata/malloy';
|
|
@@ -2656,9 +2659,9 @@ describe('source persistence', () => {
|
|
|
2656
2659
|
});
|
|
2657
2660
|
|
|
2658
2661
|
it('a given with an inline default keeps both sides on one key', async () => {
|
|
2659
|
-
// The other half of the rule: `resolvedGivens` also changes the SQL,
|
|
2660
|
-
//
|
|
2661
|
-
//
|
|
2662
|
+
// The other half of the rule: `resolvedGivens` also changes the SQL, so
|
|
2663
|
+
// both sides bind givens through the one function, inline folding
|
|
2664
|
+
// included. Fold on one side only and this throws — the builder emits
|
|
2662
2665
|
// `'admin'='admin'` where the compiler folds the same expression.
|
|
2663
2666
|
const model = `${PERSIST_ANNOTATION}
|
|
2664
2667
|
##! experimental.givens
|
|
@@ -2802,4 +2805,273 @@ describe('source persistence', () => {
|
|
|
2802
2805
|
expect(sql).toContain('LIMIT 7');
|
|
2803
2806
|
});
|
|
2804
2807
|
});
|
|
2808
|
+
|
|
2809
|
+
describe('a persisted source that reads a given', () => {
|
|
2810
|
+
// The value is in the SQL that is hashed, and both sides compile that SQL
|
|
2811
|
+
// under the same values, so a query running under other values computes
|
|
2812
|
+
// another key and does not find the table.
|
|
2813
|
+
const GIVEN_MODEL = `
|
|
2814
|
+
##! experimental { persistence givens }
|
|
2815
|
+
given: CARRIER :: string is "WN"
|
|
2816
|
+
${FLIGHTS_SOURCE}
|
|
2817
|
+
|
|
2818
|
+
#@ persist
|
|
2819
|
+
source: by_carrier is flights -> {
|
|
2820
|
+
where: carrier = $CARRIER
|
|
2821
|
+
group_by: carrier
|
|
2822
|
+
aggregate: flight_count is count()
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
query: q is by_carrier -> { select: * }
|
|
2826
|
+
`;
|
|
2827
|
+
|
|
2828
|
+
/** The no-default form, with a second source that reads no given. */
|
|
2829
|
+
const SUPPLIED_MODEL = `
|
|
2830
|
+
##! experimental { persistence givens }
|
|
2831
|
+
given: CARRIER :: string
|
|
2832
|
+
${FLIGHTS_SOURCE}
|
|
2833
|
+
|
|
2834
|
+
#@ persist
|
|
2835
|
+
source: carrier_stats is flights -> {
|
|
2836
|
+
group_by: carrier
|
|
2837
|
+
aggregate: flight_count is count()
|
|
2838
|
+
}
|
|
2839
|
+
|
|
2840
|
+
#@ persist
|
|
2841
|
+
source: by_carrier is flights -> {
|
|
2842
|
+
where: carrier = $CARRIER
|
|
2843
|
+
group_by: carrier
|
|
2844
|
+
aggregate: flight_count is count()
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
query: q is by_carrier -> { select: * }
|
|
2848
|
+
`;
|
|
2849
|
+
|
|
2850
|
+
async function onlyTarget(
|
|
2851
|
+
rt: Runtime,
|
|
2852
|
+
modelText: string,
|
|
2853
|
+
givens?: Record<string, string>
|
|
2854
|
+
): Promise<BuildTarget> {
|
|
2855
|
+
const model = await rt.loadModel(modelText).getModel();
|
|
2856
|
+
const {connections, unbuildable} = await rt.getBuildTargets(
|
|
2857
|
+
model,
|
|
2858
|
+
givens ? {givens} : undefined
|
|
2859
|
+
);
|
|
2860
|
+
expect(unbuildable).toEqual([]);
|
|
2861
|
+
const targets = connections.flatMap(c => c.targets);
|
|
2862
|
+
expect(targets).toHaveLength(1);
|
|
2863
|
+
return targets[0];
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
it('is built for the default, and a query under the default finds it', async () => {
|
|
2867
|
+
const manifest = createManifest();
|
|
2868
|
+
const rt = runtimeWithManifest(manifest);
|
|
2869
|
+
const target = await onlyTarget(rt, GIVEN_MODEL);
|
|
2870
|
+
expect(target.readsGivens).toBe(true);
|
|
2871
|
+
expect(target.sql).toContain("'WN'");
|
|
2872
|
+
addManifestEntry(manifest, target.buildId, 'wn_table');
|
|
2873
|
+
|
|
2874
|
+
const query = rt.loadModel(GIVEN_MODEL).loadQueryByName('q');
|
|
2875
|
+
expect(await query.getSQL()).toContain('wn_table');
|
|
2876
|
+
expect(await query.getSQL({givens: {CARRIER: 'WN'}})).toContain(
|
|
2877
|
+
'wn_table'
|
|
2878
|
+
);
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
it('a query under another value does not find it, and runs live', async () => {
|
|
2882
|
+
const manifest = createManifest();
|
|
2883
|
+
const rt = runtimeWithManifest(manifest);
|
|
2884
|
+
const target = await onlyTarget(rt, GIVEN_MODEL);
|
|
2885
|
+
addManifestEntry(manifest, target.buildId, 'wn_table');
|
|
2886
|
+
|
|
2887
|
+
const sql = await rt
|
|
2888
|
+
.loadModel(GIVEN_MODEL)
|
|
2889
|
+
.loadQueryByName('q')
|
|
2890
|
+
.getSQL({givens: {CARRIER: 'AA'}});
|
|
2891
|
+
expect(sql).not.toContain('wn_table');
|
|
2892
|
+
expect(sql).toContain("'AA'");
|
|
2893
|
+
});
|
|
2894
|
+
|
|
2895
|
+
it('under a strict manifest the miss says the source reads givens', async () => {
|
|
2896
|
+
const manifest = createManifest();
|
|
2897
|
+
manifest.strict = true;
|
|
2898
|
+
const rt = runtimeWithManifest(manifest);
|
|
2899
|
+
const target = await onlyTarget(rt, GIVEN_MODEL);
|
|
2900
|
+
addManifestEntry(manifest, target.buildId, 'wn_table');
|
|
2901
|
+
|
|
2902
|
+
await expect(
|
|
2903
|
+
rt
|
|
2904
|
+
.loadModel(GIVEN_MODEL)
|
|
2905
|
+
.loadQueryByName('q')
|
|
2906
|
+
.getSQL({givens: {CARRIER: 'AA'}})
|
|
2907
|
+
).rejects.toThrow(/strict manifest.*reads givens/s);
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
it('a build under another value is another table, and each query finds its own', async () => {
|
|
2911
|
+
const manifest = createManifest();
|
|
2912
|
+
const rt = runtimeWithManifest(manifest);
|
|
2913
|
+
const wn = await onlyTarget(rt, GIVEN_MODEL);
|
|
2914
|
+
const aa = await onlyTarget(rt, GIVEN_MODEL, {CARRIER: 'AA'});
|
|
2915
|
+
expect(aa.buildId).not.toBe(wn.buildId);
|
|
2916
|
+
expect(aa.sql).toContain("'AA'");
|
|
2917
|
+
addManifestEntry(manifest, wn.buildId, 'wn_table');
|
|
2918
|
+
addManifestEntry(manifest, aa.buildId, 'aa_table');
|
|
2919
|
+
|
|
2920
|
+
const query = rt.loadModel(GIVEN_MODEL).loadQueryByName('q');
|
|
2921
|
+
expect(await query.getSQL()).toContain('wn_table');
|
|
2922
|
+
expect(await query.getSQL({givens: {CARRIER: 'AA'}})).toContain(
|
|
2923
|
+
'aa_table'
|
|
2924
|
+
);
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
it('the rows a query reads are the rows for its own value', async () => {
|
|
2928
|
+
const rwConn = new DuckDBConnection({
|
|
2929
|
+
name: tstDB,
|
|
2930
|
+
databasePath: ':memory:',
|
|
2931
|
+
workingDirectory: 'test/data/duckdb',
|
|
2932
|
+
});
|
|
2933
|
+
try {
|
|
2934
|
+
const flightsPath = 'test/data/malloytest-parquet/flights.parquet';
|
|
2935
|
+
const modelText = GIVEN_MODEL.replace(
|
|
2936
|
+
FLIGHTS_SOURCE,
|
|
2937
|
+
`source: flights is ${tstDB}.table('${flightsPath}')`
|
|
2938
|
+
);
|
|
2939
|
+
const rt = new SingleConnectionRuntime({
|
|
2940
|
+
connection: rwConn,
|
|
2941
|
+
urlReader: testFileSpace,
|
|
2942
|
+
});
|
|
2943
|
+
const manifest = new Manifest();
|
|
2944
|
+
const build = async (name: string, givens?: Record<string, string>) => {
|
|
2945
|
+
const target = await onlyTarget(rt, modelText, givens);
|
|
2946
|
+
await rwConn.runSQL(
|
|
2947
|
+
`CREATE TABLE ${name} AS ${target.buildSQL(manifest.buildManifest)}`
|
|
2948
|
+
);
|
|
2949
|
+
manifest.update(target.buildId, {tableName: name});
|
|
2950
|
+
};
|
|
2951
|
+
await build('wn_table');
|
|
2952
|
+
await build('aa_table', {CARRIER: 'AA'});
|
|
2953
|
+
rt.buildManifest = manifest.buildManifest;
|
|
2954
|
+
|
|
2955
|
+
const query = rt.loadModel(modelText).loadQueryByName('q');
|
|
2956
|
+
const carriers = async (givens?: Record<string, string>) => {
|
|
2957
|
+
const result = await query.run(givens ? {givens} : undefined);
|
|
2958
|
+
return result.data.toObject().map(row => row['carrier']);
|
|
2959
|
+
};
|
|
2960
|
+
expect(await carriers()).toEqual(['WN']);
|
|
2961
|
+
expect(await carriers({CARRIER: 'AA'})).toEqual(['AA']);
|
|
2962
|
+
expect(await query.getSQL({givens: {CARRIER: 'AA'}})).toContain(
|
|
2963
|
+
'aa_table'
|
|
2964
|
+
);
|
|
2965
|
+
} finally {
|
|
2966
|
+
await rwConn.close();
|
|
2967
|
+
}
|
|
2968
|
+
});
|
|
2969
|
+
|
|
2970
|
+
it('with no default and no value for the build it is unbuildable, and the rest is planned', async () => {
|
|
2971
|
+
const model = await tstRuntime.loadModel(SUPPLIED_MODEL).getModel();
|
|
2972
|
+
const {connections, unbuildable} =
|
|
2973
|
+
await tstRuntime.getBuildTargets(model);
|
|
2974
|
+
|
|
2975
|
+
expect(
|
|
2976
|
+
connections.flatMap(c => c.targets).map(t => t.sources[0].name)
|
|
2977
|
+
).toEqual(['carrier_stats']);
|
|
2978
|
+
expect(unbuildable.map(u => u.source.name)).toEqual(['by_carrier']);
|
|
2979
|
+
const [{problem}] = unbuildable;
|
|
2980
|
+
expect(problem.code).toBe('runtime-persist-given-no-value');
|
|
2981
|
+
expect(problem.message).toMatch(/by_carrier@.*reads given 'CARRIER'/);
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
it('a query over the unbuildable source runs live under a manifest of other tables', async () => {
|
|
2985
|
+
const manifest = createManifest();
|
|
2986
|
+
const rt = runtimeWithManifest(manifest);
|
|
2987
|
+
const model = await rt.loadModel(SUPPLIED_MODEL).getModel();
|
|
2988
|
+
const {connections} = await rt.getBuildTargets(model);
|
|
2989
|
+
const [stats] = connections.flatMap(c => c.targets);
|
|
2990
|
+
addManifestEntry(manifest, stats.buildId, 'carrier_stats_table');
|
|
2991
|
+
|
|
2992
|
+
const sql = await rt
|
|
2993
|
+
.loadModel(SUPPLIED_MODEL)
|
|
2994
|
+
.loadQueryByName('q')
|
|
2995
|
+
.getSQL({givens: {CARRIER: 'WN'}});
|
|
2996
|
+
expect(sql).toContain("'WN'");
|
|
2997
|
+
expect(sql).not.toContain('carrier_stats_table');
|
|
2998
|
+
});
|
|
2999
|
+
|
|
3000
|
+
it('a value supplied to the build is a value', async () => {
|
|
3001
|
+
const target = await onlyTarget(
|
|
3002
|
+
tstRuntime,
|
|
3003
|
+
SUPPLIED_MODEL.replace(/#@ persist\s+source: carrier_stats[^}]*}/, ''),
|
|
3004
|
+
{CARRIER: 'WN'}
|
|
3005
|
+
);
|
|
3006
|
+
expect(target.sql).toContain("'WN'");
|
|
3007
|
+
});
|
|
3008
|
+
|
|
3009
|
+
it("the Runtime's givens are the build's givens", async () => {
|
|
3010
|
+
const rt = new Runtime({
|
|
3011
|
+
connections: tstRuntime.connections,
|
|
3012
|
+
urlReader: testFileSpace,
|
|
3013
|
+
givens: {CARRIER: 'UA'},
|
|
3014
|
+
});
|
|
3015
|
+
const target = await onlyTarget(
|
|
3016
|
+
rt,
|
|
3017
|
+
SUPPLIED_MODEL.replace(/#@ persist\s+source: carrier_stats[^}]*}/, '')
|
|
3018
|
+
);
|
|
3019
|
+
expect(target.sql).toContain("'UA'");
|
|
3020
|
+
});
|
|
3021
|
+
|
|
3022
|
+
it('a finalized given cannot be supplied to a build', async () => {
|
|
3023
|
+
const config = new MalloyConfig(
|
|
3024
|
+
{finalizeGivens: ['CARRIER']},
|
|
3025
|
+
{configURL: 'file:///finalized/malloy-config.json'}
|
|
3026
|
+
);
|
|
3027
|
+
const rt = new Runtime({
|
|
3028
|
+
config,
|
|
3029
|
+
connections: tstRuntime.connections,
|
|
3030
|
+
urlReader: testFileSpace,
|
|
3031
|
+
givens: {CARRIER: 'WN'},
|
|
3032
|
+
});
|
|
3033
|
+
const model = await rt.loadModel(GIVEN_MODEL).getModel();
|
|
3034
|
+
await expect(
|
|
3035
|
+
rt.getBuildTargets(model, {givens: {CARRIER: 'AA'}})
|
|
3036
|
+
).rejects.toThrow(/'CARRIER'.*finalized/);
|
|
3037
|
+
});
|
|
3038
|
+
});
|
|
3039
|
+
|
|
3040
|
+
describe('a build and its virtualMap', () => {
|
|
3041
|
+
const VIRTUAL_MODEL = `${PERSIST_ANNOTATION}
|
|
3042
|
+
##! experimental.virtual_source
|
|
3043
|
+
type: ff is { carrier :: string }
|
|
3044
|
+
source: vf is ${tstDB}.virtual('vflights')::ff
|
|
3045
|
+
|
|
3046
|
+
#@ persist
|
|
3047
|
+
source: by_carrier is vf -> { group_by: carrier }
|
|
3048
|
+
|
|
3049
|
+
run: by_carrier -> { select: * }
|
|
3050
|
+
`;
|
|
3051
|
+
const virtualMap: VirtualMap = new Map([
|
|
3052
|
+
[tstDB, new Map([['vflights', 'malloytest.flights']])],
|
|
3053
|
+
]);
|
|
3054
|
+
|
|
3055
|
+
it('a virtual source with no entry is unbuildable, not a failed plan', async () => {
|
|
3056
|
+
const model = await tstRuntime.loadModel(VIRTUAL_MODEL).getModel();
|
|
3057
|
+
const {connections, unbuildable} =
|
|
3058
|
+
await tstRuntime.getBuildTargets(model);
|
|
3059
|
+
expect(connections).toEqual([]);
|
|
3060
|
+
expect(unbuildable.map(u => u.problem.code)).toEqual([
|
|
3061
|
+
'runtime-virtual-map-missing',
|
|
3062
|
+
]);
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
it('a virtualMap given to the build keys and builds the table', async () => {
|
|
3066
|
+
const model = await tstRuntime.loadModel(VIRTUAL_MODEL).getModel();
|
|
3067
|
+
const {connections, unbuildable} = await tstRuntime.getBuildTargets(
|
|
3068
|
+
model,
|
|
3069
|
+
{virtualMap}
|
|
3070
|
+
);
|
|
3071
|
+
expect(unbuildable).toEqual([]);
|
|
3072
|
+
const [target] = connections[0].targets;
|
|
3073
|
+
expect(target.sql).toContain('malloytest.flights');
|
|
3074
|
+
expect(target.buildSQL(createManifest())).toContain('malloytest.flights');
|
|
3075
|
+
});
|
|
3076
|
+
});
|
|
2805
3077
|
});
|