@malloydata/malloy-tests 0.0.435 → 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 +2 -2
- package/package.json +11 -11
- package/src/core/persist.spec.ts +275 -3
- package/src/databases/all/db_filter_expressions.spec.ts +7 -0
- 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 +128 -14
package/CONTEXT.md
CHANGED
|
@@ -40,7 +40,7 @@ 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
|
|
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
44
|
|
|
45
45
|
### Consumer-contract canary (`test/consumer-canary/`)
|
|
46
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).
|
|
@@ -177,7 +177,7 @@ All cross-database tests (`test/src/databases/all/`) reference tables as `malloy
|
|
|
177
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` |
|
|
178
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) |
|
|
179
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 |
|
|
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
|
|
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 |
|
|
181
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 |
|
|
182
182
|
| BigQuery | Pre-loaded manually in `malloydata-org` project | (none) | No loader script in repo. Data assumed to exist. |
|
|
183
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) |
|
package/package.json
CHANGED
|
@@ -20,16 +20,16 @@
|
|
|
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-sqlserver": "0.0.
|
|
28
|
-
"@malloydata/db-trino": "0.0.
|
|
29
|
-
"@malloydata/malloy": "0.0.
|
|
30
|
-
"@malloydata/malloy-tag": "0.0.
|
|
31
|
-
"@malloydata/render": "0.0.
|
|
32
|
-
"@malloydata/render-validator": "0.0.
|
|
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",
|
|
33
33
|
"events": "^3.3.0",
|
|
34
34
|
"jsdom": "^22.1.0",
|
|
35
35
|
"luxon": "^3.7.2",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"overrides": {
|
|
44
44
|
"typescript": "^6.0.3"
|
|
45
45
|
},
|
|
46
|
-
"version": "0.0.
|
|
46
|
+
"version": "0.0.436"
|
|
47
47
|
}
|
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
|
});
|
|
@@ -697,6 +697,13 @@ describe.each(runtimes.runtimeList)('filter expressions %s', (dbName, db) => {
|
|
|
697
697
|
run: range + { where: t ~ f'2001' }
|
|
698
698
|
`).toMatchRows(range, inRange);
|
|
699
699
|
});
|
|
700
|
+
test('date in month, in a query time zone east of UTC', async () => {
|
|
701
|
+
// Midnight starting a month in Tokyo is still the previous day in UTC
|
|
702
|
+
const range = mkDateRange('2001-01-01', '2001-02-01');
|
|
703
|
+
await expect(`
|
|
704
|
+
run: range + { timezone: 'Asia/Tokyo'; where: t ~ f'2001-01' }
|
|
705
|
+
`).toMatchRows(range, inRange);
|
|
706
|
+
});
|
|
700
707
|
test('2 days ago', async () => {
|
|
701
708
|
nowIs('2001-01-15 12:00:00');
|
|
702
709
|
const rangeQuery = mkRangeQuery(
|
|
@@ -527,12 +527,13 @@ describe.each(runtimes.runtimeList)('%s', (databaseName, runtime) => {
|
|
|
527
527
|
source: a is ${databaseName}.table('malloytest.aircraft_models') extend { where: aircraft_model_code ? '0270202' }
|
|
528
528
|
|
|
529
529
|
run: a -> {
|
|
530
|
-
group_by:
|
|
531
|
-
|
|
530
|
+
group_by:
|
|
531
|
+
boolean_1 is pick 'yes' when sql_boolean("\${seats} > 20") else 'no'
|
|
532
|
+
boolean_2 is pick 'yes' when sql_boolean("\${engines} = 2") else 'no'
|
|
532
533
|
}
|
|
533
534
|
`).toMatchResult(testModel, {
|
|
534
|
-
boolean_1:
|
|
535
|
-
boolean_2:
|
|
535
|
+
boolean_1: 'yes',
|
|
536
|
+
boolean_2: 'no',
|
|
536
537
|
});
|
|
537
538
|
});
|
|
538
539
|
|
|
@@ -1067,12 +1068,12 @@ describe.each(runtimes.runtimeList)('%s', (databaseName, runtime) => {
|
|
|
1067
1068
|
dimension: fot is (false) or (true)
|
|
1068
1069
|
} -> {
|
|
1069
1070
|
select:
|
|
1070
|
-
no_paren is false and fot
|
|
1071
|
-
paren is false and (fot)
|
|
1071
|
+
no_paren is pick 'yes' when false and fot else 'no'
|
|
1072
|
+
paren is pick 'yes' when false and (fot) else 'no'
|
|
1072
1073
|
}`
|
|
1073
1074
|
).toMatchResult(testModel, {
|
|
1074
|
-
paren:
|
|
1075
|
-
no_paren:
|
|
1075
|
+
paren: 'no',
|
|
1076
|
+
no_paren: 'no',
|
|
1076
1077
|
});
|
|
1077
1078
|
});
|
|
1078
1079
|
});
|
|
@@ -51,8 +51,6 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
51
51
|
const expressionModel = x.expressionModel;
|
|
52
52
|
const runtime = x.runtime;
|
|
53
53
|
const testModel = wrapTestModel(runtime, modelText(databaseName));
|
|
54
|
-
const dbTrue = runtime.dialect.resultBoolean(true);
|
|
55
|
-
const dbFalse = runtime.dialect.resultBoolean(false);
|
|
56
54
|
const funcTestGeneral = async (
|
|
57
55
|
expr: string,
|
|
58
56
|
type: 'group_by' | 'aggregate',
|
|
@@ -124,14 +122,6 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
124
122
|
await funcTestMultiple(
|
|
125
123
|
["concat('foo', 'bar')", 'foobar'],
|
|
126
124
|
["concat(1, 'bar')", '1bar'],
|
|
127
|
-
[
|
|
128
|
-
"concat('cons', true)",
|
|
129
|
-
databaseName === 'postgres'
|
|
130
|
-
? 'const'
|
|
131
|
-
: databaseName === 'mysql'
|
|
132
|
-
? 'cons1'
|
|
133
|
-
: 'construe',
|
|
134
|
-
],
|
|
135
125
|
["concat('foo', @2003)", 'foo2003-01-01'],
|
|
136
126
|
[
|
|
137
127
|
"concat('foo', @2003-01-01 12:00:00)",
|
|
@@ -142,6 +132,20 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
142
132
|
['concat()', '']
|
|
143
133
|
);
|
|
144
134
|
});
|
|
135
|
+
it.when(
|
|
136
|
+
runtime.dialect.booleanType !== 'none' &&
|
|
137
|
+
!brokenIn('trino', databaseName) &&
|
|
138
|
+
!brokenIn('presto', databaseName)
|
|
139
|
+
)(`works with a boolean - ${databaseName}`, async () => {
|
|
140
|
+
await funcTest(
|
|
141
|
+
"concat('cons', true)",
|
|
142
|
+
databaseName === 'postgres'
|
|
143
|
+
? 'const'
|
|
144
|
+
: databaseName === 'mysql'
|
|
145
|
+
? 'cons1'
|
|
146
|
+
: 'construe'
|
|
147
|
+
);
|
|
148
|
+
});
|
|
145
149
|
});
|
|
146
150
|
|
|
147
151
|
describe('round', () => {
|
|
@@ -624,16 +628,13 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
624
628
|
# test.debug
|
|
625
629
|
run: state_facts -> {
|
|
626
630
|
group_by: state
|
|
627
|
-
calculate: lag_val is
|
|
631
|
+
calculate: lag_val is
|
|
632
|
+
pick 'now' when lag(@2011-11-11 11:11:11, 1, now).year = now.year else 'then'
|
|
628
633
|
}`
|
|
629
634
|
)
|
|
630
635
|
.run();
|
|
631
|
-
expect(result.data.path(0, 'lag_val').value).toBe(
|
|
632
|
-
|
|
633
|
-
);
|
|
634
|
-
expect(result.data.path(1, 'lag_val').value).toBe(
|
|
635
|
-
runtime.dialect.resultBoolean(false)
|
|
636
|
-
);
|
|
636
|
+
expect(result.data.path(0, 'lag_val').value).toBe('now');
|
|
637
|
+
expect(result.data.path(1, 'lag_val').value).toBe('then');
|
|
637
638
|
});
|
|
638
639
|
});
|
|
639
640
|
|
|
@@ -855,32 +856,45 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
855
856
|
: "'+inf'::number";
|
|
856
857
|
it.when(databaseName !== 'mysql')(`works - ${databaseName}`, async () => {
|
|
857
858
|
await funcTestMultiple(
|
|
858
|
-
[`is_inf(${inf})`,
|
|
859
|
-
['is_inf(100)',
|
|
860
|
-
['is_inf(null)',
|
|
859
|
+
[`pick 'yes' when is_inf(${inf}) else 'no'`, 'yes'],
|
|
860
|
+
["pick 'yes' when is_inf(100) else 'no'", 'no'],
|
|
861
|
+
["pick 'yes' when is_inf(null) else 'no'", 'no']
|
|
861
862
|
);
|
|
862
863
|
});
|
|
864
|
+
// A null input is false, not null
|
|
865
|
+
it.when(databaseName !== 'mysql' && runtime.dialect.booleanType !== 'none')(
|
|
866
|
+
`is false for null - ${databaseName}`,
|
|
867
|
+
async () => {
|
|
868
|
+
await funcTest('is_inf(null)', runtime.dialect.resultBoolean(false));
|
|
869
|
+
}
|
|
870
|
+
);
|
|
863
871
|
});
|
|
864
872
|
describe('is_nan', () => {
|
|
865
873
|
it.when(databaseName !== 'mysql')(`works - ${databaseName}`, async () => {
|
|
866
874
|
await funcTestMultiple(
|
|
867
|
-
["is_nan('NaN'::number)",
|
|
868
|
-
['is_nan(100)',
|
|
869
|
-
['is_nan(null)',
|
|
875
|
+
["pick 'yes' when is_nan('NaN'::number) else 'no'", 'yes'],
|
|
876
|
+
["pick 'yes' when is_nan(100) else 'no'", 'no'],
|
|
877
|
+
["pick 'yes' when is_nan(null) else 'no'", 'no']
|
|
870
878
|
);
|
|
871
879
|
});
|
|
880
|
+
it.when(databaseName !== 'mysql' && runtime.dialect.booleanType !== 'none')(
|
|
881
|
+
`is false for null - ${databaseName}`,
|
|
882
|
+
async () => {
|
|
883
|
+
await funcTest('is_nan(null)', runtime.dialect.resultBoolean(false));
|
|
884
|
+
}
|
|
885
|
+
);
|
|
872
886
|
});
|
|
873
887
|
describe('greatest', () => {
|
|
874
888
|
it(`works - ${databaseName}`, async () => {
|
|
875
889
|
await funcTestMultiple(
|
|
876
890
|
['greatest(1, 10, -100)', 10],
|
|
877
891
|
[
|
|
878
|
-
'greatest(@2003, @2004, @1994) = @2004',
|
|
879
|
-
|
|
892
|
+
"pick 'yes' when greatest(@2003, @2004, @1994) = @2004 else 'no'",
|
|
893
|
+
'yes',
|
|
880
894
|
],
|
|
881
895
|
[
|
|
882
|
-
'greatest(@2023-05-26 11:58:00, @2023-05-26 11:59:00) = @2023-05-26 11:59:00',
|
|
883
|
-
|
|
896
|
+
"pick 'yes' when greatest(@2023-05-26 11:58:00, @2023-05-26 11:59:00) = @2023-05-26 11:59:00 else 'no'",
|
|
897
|
+
'yes',
|
|
884
898
|
],
|
|
885
899
|
["greatest('a', 'b')", 'b'],
|
|
886
900
|
['greatest(1, null, 0)', null],
|
|
@@ -892,13 +906,10 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
892
906
|
it(`works - ${databaseName}`, async () => {
|
|
893
907
|
await funcTestMultiple(
|
|
894
908
|
['least(1, 10, -100)', -100],
|
|
909
|
+
["pick 'yes' when least(@2003, @2004, @1994) = @1994 else 'no'", 'yes'],
|
|
895
910
|
[
|
|
896
|
-
'least(@
|
|
897
|
-
|
|
898
|
-
],
|
|
899
|
-
[
|
|
900
|
-
'least(@2023-05-26 11:58:00, @2023-05-26 11:59:00) = @2023-05-26 11:58:00',
|
|
901
|
-
runtime.dialect.resultBoolean(true),
|
|
911
|
+
"pick 'yes' when least(@2023-05-26 11:58:00, @2023-05-26 11:59:00) = @2023-05-26 11:58:00 else 'no'",
|
|
912
|
+
'yes',
|
|
902
913
|
],
|
|
903
914
|
["least('a', 'b')", 'a'],
|
|
904
915
|
['least(1, null, 0)', null],
|
|
@@ -929,36 +940,48 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
929
940
|
it(`works - ${databaseName}`, async () => {
|
|
930
941
|
await funcTestMultiple(
|
|
931
942
|
[
|
|
932
|
-
"starts_with('hello world', 'hello')",
|
|
933
|
-
|
|
943
|
+
"pick 'yes' when starts_with('hello world', 'hello') else 'no'",
|
|
944
|
+
'yes',
|
|
934
945
|
],
|
|
935
|
-
[
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
],
|
|
939
|
-
["starts_with(null, 'world')", runtime.dialect.resultBoolean(false)],
|
|
940
|
-
[
|
|
941
|
-
"starts_with('hello world', null)",
|
|
942
|
-
runtime.dialect.resultBoolean(false),
|
|
943
|
-
]
|
|
946
|
+
["pick 'yes' when starts_with('hello world', 'world') else 'no'", 'no'],
|
|
947
|
+
["pick 'yes' when starts_with(null, 'world') else 'no'", 'no'],
|
|
948
|
+
["pick 'yes' when starts_with('hello world', null) else 'no'", 'no']
|
|
944
949
|
);
|
|
945
950
|
});
|
|
951
|
+
it.when(runtime.dialect.booleanType !== 'none')(
|
|
952
|
+
`is false for null - ${databaseName}`,
|
|
953
|
+
async () => {
|
|
954
|
+
await funcTestMultiple(
|
|
955
|
+
["starts_with(null, 'world')", runtime.dialect.resultBoolean(false)],
|
|
956
|
+
[
|
|
957
|
+
"starts_with('hello world', null)",
|
|
958
|
+
runtime.dialect.resultBoolean(false),
|
|
959
|
+
]
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
);
|
|
946
963
|
});
|
|
947
964
|
describe('ends_with', () => {
|
|
948
965
|
it(`works - ${databaseName}`, async () => {
|
|
949
966
|
await funcTestMultiple(
|
|
950
|
-
[
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
]
|
|
954
|
-
[
|
|
955
|
-
"ends_with('hello world', 'hello')",
|
|
956
|
-
runtime.dialect.resultBoolean(false),
|
|
957
|
-
],
|
|
958
|
-
["ends_with(null, 'world')", runtime.dialect.resultBoolean(false)],
|
|
959
|
-
["ends_with('hello world', null)", runtime.dialect.resultBoolean(false)]
|
|
967
|
+
["pick 'yes' when ends_with('hello world', 'world') else 'no'", 'yes'],
|
|
968
|
+
["pick 'yes' when ends_with('hello world', 'hello') else 'no'", 'no'],
|
|
969
|
+
["pick 'yes' when ends_with(null, 'world') else 'no'", 'no'],
|
|
970
|
+
["pick 'yes' when ends_with('hello world', null) else 'no'", 'no']
|
|
960
971
|
);
|
|
961
972
|
});
|
|
973
|
+
it.when(runtime.dialect.booleanType !== 'none')(
|
|
974
|
+
`is false for null - ${databaseName}`,
|
|
975
|
+
async () => {
|
|
976
|
+
await funcTestMultiple(
|
|
977
|
+
["ends_with(null, 'world')", runtime.dialect.resultBoolean(false)],
|
|
978
|
+
[
|
|
979
|
+
"ends_with('hello world', null)",
|
|
980
|
+
runtime.dialect.resultBoolean(false),
|
|
981
|
+
]
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
);
|
|
962
985
|
});
|
|
963
986
|
describe('trim', () => {
|
|
964
987
|
it(`trim works - ${databaseName}`, async () => {
|
|
@@ -1001,14 +1024,17 @@ expressionModels.forEach((x, databaseName) => {
|
|
|
1001
1024
|
// There are around a billion values that rand() can be, so if this
|
|
1002
1025
|
// test fails, most likely something is broken. Otherwise, you're the lucky
|
|
1003
1026
|
// one in a billion!
|
|
1004
|
-
await funcTest(
|
|
1027
|
+
await funcTest(
|
|
1028
|
+
"pick 'same' when rand() = rand() else 'differ'",
|
|
1029
|
+
'differ'
|
|
1030
|
+
);
|
|
1005
1031
|
});
|
|
1006
1032
|
});
|
|
1007
1033
|
describe('pi', () => {
|
|
1008
1034
|
it(`is pi - ${databaseName}`, async () => {
|
|
1009
1035
|
await funcTest(
|
|
1010
|
-
'abs(pi() - 3.141592653589793) < 0.0000000000001',
|
|
1011
|
-
|
|
1036
|
+
"pick 'yes' when abs(pi() - 3.141592653589793) < 0.0000000000001 else 'no'",
|
|
1037
|
+
'yes'
|
|
1012
1038
|
);
|
|
1013
1039
|
});
|
|
1014
1040
|
});
|
|
@@ -11,8 +11,11 @@ import '@malloydata/malloy/test/matchers';
|
|
|
11
11
|
const runtimes = new RuntimeList(databasesFromEnvironmentOr(allDatabases));
|
|
12
12
|
|
|
13
13
|
describe.each(runtimes.runtimeList)('New matchers for %s', (db, runtime) => {
|
|
14
|
+
// A dialect without boolean values has no boolean column
|
|
15
|
+
const booleans = runtime.dialect.booleanType !== 'none';
|
|
16
|
+
|
|
14
17
|
describe('mkTestModel and TV', () => {
|
|
15
|
-
test('basic types with type inference', async () => {
|
|
18
|
+
test.when(booleans)('basic types with type inference', async () => {
|
|
16
19
|
const tm = mkTestModel(runtime, {
|
|
17
20
|
data: [
|
|
18
21
|
{t_int: 1, t_string: 'a', t_bool: true},
|
|
@@ -155,16 +158,19 @@ describe.each(runtimes.runtimeList)('New matchers for %s', (db, runtime) => {
|
|
|
155
158
|
});
|
|
156
159
|
});
|
|
157
160
|
|
|
158
|
-
test(
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
161
|
+
test.when(booleans)(
|
|
162
|
+
'plain booleans work with dialect boolean handling',
|
|
163
|
+
async () => {
|
|
164
|
+
const tm = mkTestModel(runtime, {
|
|
165
|
+
data: [{a: true, b: false}],
|
|
166
|
+
});
|
|
167
|
+
// Booleans are handled by dialect.resultBoolean() in the matcher
|
|
168
|
+
await expect('run: data -> { select: * }').toMatchResult(tm, {
|
|
169
|
+
a: true,
|
|
170
|
+
b: false,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
);
|
|
168
174
|
});
|
|
169
175
|
|
|
170
176
|
describe('test.debug tag', () => {
|
|
@@ -24,20 +24,26 @@ describe.each(runtimes.runtimeList)('%s', (databaseName, runtime) => {
|
|
|
24
24
|
}`
|
|
25
25
|
);
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// A dialect without boolean values cannot group by one
|
|
28
|
+
it.when(runtime.dialect.booleanType !== 'none')(
|
|
29
|
+
`boolean type - ${databaseName}`,
|
|
30
|
+
async () => {
|
|
31
|
+
await expect(`
|
|
29
32
|
run: models-> {
|
|
30
33
|
group_by: big is seats >=20
|
|
31
34
|
aggregate: model_count is count()
|
|
32
35
|
}
|
|
33
36
|
`).toMatchResult(orderByModel, {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
big: false,
|
|
38
|
+
model_count: 58451,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
);
|
|
38
42
|
|
|
39
|
-
it(
|
|
40
|
-
|
|
43
|
+
it.when(runtime.dialect.booleanType !== 'none')(
|
|
44
|
+
`boolean in pipeline - ${databaseName}`,
|
|
45
|
+
async () => {
|
|
46
|
+
await expect(`
|
|
41
47
|
run: models->{
|
|
42
48
|
group_by:
|
|
43
49
|
manufacturer,
|
|
@@ -48,10 +54,11 @@ describe.each(runtimes.runtimeList)('%s', (databaseName, runtime) => {
|
|
|
48
54
|
aggregate: model_count is model_count.sum()
|
|
49
55
|
}
|
|
50
56
|
`).toMatchResult(orderByModel, {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
57
|
+
big: false,
|
|
58
|
+
model_count: 58500,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
);
|
|
55
62
|
|
|
56
63
|
it(`filtered measures in model are aggregates #352 - ${databaseName}`, async () => {
|
|
57
64
|
await expect(`
|
|
@@ -15,8 +15,11 @@ describe.each(runtimes.runtimeList)('TestSelect for %s', (db, runtime) => {
|
|
|
15
15
|
const ts = new TestSelect(runtime.dialect);
|
|
16
16
|
const testModel = wrapTestModel(runtime, '');
|
|
17
17
|
|
|
18
|
+
// A dialect without boolean values has no boolean column
|
|
19
|
+
const booleans = runtime.dialect.booleanType !== 'none';
|
|
20
|
+
|
|
18
21
|
// Basic Type Tests
|
|
19
|
-
test(`${db} inferred basic types`, async () => {
|
|
22
|
+
test.when(booleans)(`${db} inferred basic types`, async () => {
|
|
20
23
|
const sql = ts.generate(
|
|
21
24
|
{t_int: 1, t_string: 'a', t_bool: true, t_float: 1.5},
|
|
22
25
|
{t_int: 2, t_string: 'b', t_bool: false, t_float: 2.5}
|
|
@@ -27,7 +30,7 @@ describe.each(runtimes.runtimeList)('TestSelect for %s', (db, runtime) => {
|
|
|
27
30
|
]);
|
|
28
31
|
});
|
|
29
32
|
|
|
30
|
-
test(`${db} explicit type hints`, async () => {
|
|
33
|
+
test.when(booleans)(`${db} explicit type hints`, async () => {
|
|
31
34
|
const sql = ts.generate({
|
|
32
35
|
t_int: ts.mk_int(1),
|
|
33
36
|
t_float: ts.mk_float(1.5),
|
|
@@ -274,11 +274,11 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
274
274
|
});
|
|
275
275
|
});
|
|
276
276
|
|
|
277
|
-
test.when(
|
|
277
|
+
test.when(timestamptz)(
|
|
278
278
|
'extract from timestamptz without query timezone',
|
|
279
279
|
async () => {
|
|
280
280
|
// TIMESTAMPTZ representing midnight UTC
|
|
281
|
-
// Without query timezone, extract should happen in UTC
|
|
281
|
+
// Without query timezone, extract should happen in UTC
|
|
282
282
|
// Expected: hour = 0, day = 20
|
|
283
283
|
await expect(
|
|
284
284
|
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
@@ -291,6 +291,80 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
291
291
|
}
|
|
292
292
|
);
|
|
293
293
|
|
|
294
|
+
// 20:00 on the 30th in Mexico City is 02:00 on the 31st in UTC. With no
|
|
295
|
+
// query time zone the value is read in UTC, not in the zone it was written in.
|
|
296
|
+
describe('timestamptz written in another zone', () => {
|
|
297
|
+
const tzModel = () =>
|
|
298
|
+
mkTestModel(runtime, {
|
|
299
|
+
tzdata: [
|
|
300
|
+
{
|
|
301
|
+
tstz: TV.timestamptz('2020-01-30 20:00:00[America/Mexico_City]'),
|
|
302
|
+
same_instant: TV.timestamp('2020-01-31 02:00:00'),
|
|
303
|
+
same_digits: TV.timestamp('2020-01-30 20:00:00'),
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test.when(timestamptz)('extract', async () => {
|
|
309
|
+
await expect(
|
|
310
|
+
'run: tzdata -> { select: h is hour(tstz), d is day(tstz) }'
|
|
311
|
+
).toMatchResult(tzModel(), {h: 2, d: 31});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test.when(timestamptz)('truncate', async () => {
|
|
315
|
+
await expect('run: tzdata -> { select: d is tstz.day }').toMatchResult(
|
|
316
|
+
tzModel(),
|
|
317
|
+
{d: '2020-01-31T00:00:00.000Z'}
|
|
318
|
+
);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test.when(timestamptz)('add a month', async () => {
|
|
322
|
+
// Jan 31 + 1 month is Feb 29; Jan 30 + 1 month in Mexico City is Mar 1 UTC
|
|
323
|
+
await expect(
|
|
324
|
+
'run: tzdata -> { select: next is tstz + 1 month }'
|
|
325
|
+
).toMatchResult(tzModel(), {next: '2020-02-29T02:00:00.000Z'});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test.when(timestamptz)('cast to date', async () => {
|
|
329
|
+
await expect('run: tzdata -> { select: d is tstz::date }').toMatchResult(
|
|
330
|
+
tzModel(),
|
|
331
|
+
{d: '2020-01-31'}
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test.when(timestamptz)('cast keeps the instant', async () => {
|
|
336
|
+
await expect(`
|
|
337
|
+
run: tzdata -> {
|
|
338
|
+
select:
|
|
339
|
+
ts is tstz::timestamp
|
|
340
|
+
same is pick 'yes' when tstz::timestamp = tstz else 'no'
|
|
341
|
+
back is pick 'yes' when same_instant::timestamptz = tstz else 'no'
|
|
342
|
+
}
|
|
343
|
+
`).toMatchResult(tzModel(), {
|
|
344
|
+
ts: '2020-01-31T02:00:00.000Z',
|
|
345
|
+
same: 'yes',
|
|
346
|
+
back: 'yes',
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test.when(timestamptz)('compare to timestamp', async () => {
|
|
351
|
+
await expect(`
|
|
352
|
+
run: tzdata -> {
|
|
353
|
+
select:
|
|
354
|
+
eq_instant is pick 'yes' when tstz = same_instant else 'no'
|
|
355
|
+
eq_digits is pick 'yes' when tstz = same_digits else 'no'
|
|
356
|
+
gt_digits is pick 'yes' when tstz > same_digits else 'no'
|
|
357
|
+
lt_digits is pick 'yes' when same_digits < tstz else 'no'
|
|
358
|
+
}
|
|
359
|
+
`).toMatchResult(tzModel(), {
|
|
360
|
+
eq_instant: 'yes',
|
|
361
|
+
eq_digits: 'no',
|
|
362
|
+
gt_digits: 'yes',
|
|
363
|
+
lt_digits: 'yes',
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
294
368
|
describe('date truncation', () => {
|
|
295
369
|
test('date trunc day', async () => {
|
|
296
370
|
const eq = sqlEq('t_date.day', '@2021-02-24');
|
|
@@ -526,16 +600,16 @@ describe.each(runtimes.runtimeList)('%s date and time', (dbName, runtime) => {
|
|
|
526
600
|
});
|
|
527
601
|
await expect(`
|
|
528
602
|
run: fourTimes -> { select:
|
|
529
|
-
isBefore is tbefore ? @2026-01-01
|
|
530
|
-
isFirst is tfirst ? @2026-01-01
|
|
531
|
-
isLast is tlast ? @2026-01-01
|
|
532
|
-
isAfter is tafter ? @2026-01-01
|
|
603
|
+
isBefore is pick 'in' when tbefore ? @2026-01-01 else 'out'
|
|
604
|
+
isFirst is pick 'in' when tfirst ? @2026-01-01 else 'out'
|
|
605
|
+
isLast is pick 'in' when tlast ? @2026-01-01 else 'out'
|
|
606
|
+
isAfter is pick 'in' when tafter ? @2026-01-01 else 'out'
|
|
533
607
|
}
|
|
534
608
|
`).toMatchResult(tm, {
|
|
535
|
-
isBefore:
|
|
536
|
-
isFirst:
|
|
537
|
-
isLast:
|
|
538
|
-
isAfter:
|
|
609
|
+
isBefore: 'out',
|
|
610
|
+
isFirst: 'in',
|
|
611
|
+
isLast: 'in',
|
|
612
|
+
isAfter: 'out',
|
|
539
613
|
});
|
|
540
614
|
});
|
|
541
615
|
});
|
|
@@ -730,6 +804,7 @@ describe.each(runtimes.runtimeList)('%s: tz literals', (dbName, runtime) => {
|
|
|
730
804
|
|
|
731
805
|
describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
732
806
|
const testModel = wrapTestModel(runtime, '');
|
|
807
|
+
const timestamptz = runtime.dialect.hasTimestamptz;
|
|
733
808
|
const ts = new TestSelect(runtime.dialect);
|
|
734
809
|
const selectMidnight = `"""${ts.generate({
|
|
735
810
|
utc_midnight_ts: ts.mk_timestamp('2020-02-20 00:00:00'),
|
|
@@ -762,7 +837,7 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
762
837
|
).toMatchResult(testModel, {mex_midnight: 18, mex_day: 19});
|
|
763
838
|
});
|
|
764
839
|
|
|
765
|
-
test.when(
|
|
840
|
+
test.when(timestamptz)(
|
|
766
841
|
'extract from timestamptz with query timezone',
|
|
767
842
|
async () => {
|
|
768
843
|
// TIMESTAMPTZ representing midnight UTC
|
|
@@ -792,6 +867,16 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
792
867
|
).toMatchResult(testModel, {mex_day: mex_19.toJSDate()});
|
|
793
868
|
});
|
|
794
869
|
|
|
870
|
+
test('truncate hour in a zone a half hour off UTC', async () => {
|
|
871
|
+
// 02:10 in Kolkata is 20:40 UTC, and the Kolkata hour starts at 20:30
|
|
872
|
+
await expect(
|
|
873
|
+
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
874
|
+
timezone: 'Asia/Kolkata'
|
|
875
|
+
select: kol_hour is @2020-01-31 02:10:00.hour
|
|
876
|
+
}`
|
|
877
|
+
).toMatchResult(testModel, {kol_hour: '2020-01-30T20:30:00.000Z'});
|
|
878
|
+
});
|
|
879
|
+
|
|
795
880
|
test('truncate week', async () => {
|
|
796
881
|
// the 19th in mexico is a wednesday, so trunc to the 15th
|
|
797
882
|
const mex_19 = LuxonDateTime.fromISO('2020-02-19T00:00:00', {zone});
|
|
@@ -814,18 +899,15 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
814
899
|
}`
|
|
815
900
|
).toMatchResult(testModel, {mex_date: '2020-02-19'});
|
|
816
901
|
});
|
|
817
|
-
test.when(
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
await expect(
|
|
821
|
-
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
902
|
+
test.when(timestamptz)('cast timestamptz to date', async () => {
|
|
903
|
+
await expect(
|
|
904
|
+
`run: ${dbName}.sql("SELECT 1 as x") -> {
|
|
822
905
|
timezone: '${zone}'
|
|
823
906
|
extend: { dimension: utc_tstz is @2020-02-20 00:00:00[UTC]::timestamptz }
|
|
824
907
|
select: mex_date is utc_tstz::date
|
|
825
908
|
}`
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
);
|
|
909
|
+
).toMatchResult(testModel, {mex_date: '2020-02-19'});
|
|
910
|
+
});
|
|
829
911
|
test('cast date to timestamp', async () => {
|
|
830
912
|
await expect(
|
|
831
913
|
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
@@ -842,31 +924,95 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
842
924
|
}`
|
|
843
925
|
).toMatchResult(testModel, {d2020: '2020-02-20'});
|
|
844
926
|
});
|
|
845
|
-
test.when(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
await expect(
|
|
849
|
-
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
927
|
+
test.when(timestamptz)('cast date to timestamptz', async () => {
|
|
928
|
+
await expect(
|
|
929
|
+
`run: ${dbName}.sql(${selectMidnight}) -> {
|
|
850
930
|
timezone: '${zone}'
|
|
851
931
|
select: mex_date is date_2020::timestamptz
|
|
852
932
|
}`
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
);
|
|
933
|
+
).toMatchResult(testModel, {mex_date: zone_2020.toJSDate()});
|
|
934
|
+
});
|
|
856
935
|
|
|
857
|
-
test.when(
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
936
|
+
test.when(timestamptz)('compare timestamptz to range', async () => {
|
|
937
|
+
const tzModel = mkTestModel(runtime, {
|
|
938
|
+
tzdata: [
|
|
939
|
+
{updated: TV.timestamptz('2020-01-15 00:00:00[America/Mexico_City]')},
|
|
940
|
+
],
|
|
941
|
+
});
|
|
942
|
+
await expect(
|
|
943
|
+
"run: tzdata -> { select: cmp is pick 'in' when updated ? @2020-01 else 'out' }"
|
|
944
|
+
).toMatchResult(tzModel, {cmp: 'in'});
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
// 11:00 on the 31st in Tokyo is 20:00 on the 30th in Mexico City. With a
|
|
948
|
+
// query time zone the value is read there, not in the zone it was written in.
|
|
949
|
+
describe('timestamptz written in another zone', () => {
|
|
950
|
+
const tzModel = () =>
|
|
951
|
+
mkTestModel(runtime, {
|
|
861
952
|
tzdata: [
|
|
862
|
-
{
|
|
953
|
+
{
|
|
954
|
+
tstz: TV.timestamptz('2020-01-31 11:00:00[Asia/Tokyo]'),
|
|
955
|
+
same_instant: TV.timestamp('2020-01-31 02:00:00'),
|
|
956
|
+
},
|
|
863
957
|
],
|
|
864
958
|
});
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
959
|
+
|
|
960
|
+
test.when(timestamptz)('extract', async () => {
|
|
961
|
+
await expect(`
|
|
962
|
+
run: tzdata -> {
|
|
963
|
+
timezone: '${zone}'
|
|
964
|
+
select: h is hour(tstz), d is day(tstz)
|
|
965
|
+
}
|
|
966
|
+
`).toMatchResult(tzModel(), {h: 20, d: 30});
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test.when(timestamptz)('truncate', async () => {
|
|
970
|
+
const mex_30 = LuxonDateTime.fromISO('2020-01-30T00:00:00', {zone});
|
|
971
|
+
await expect(`
|
|
972
|
+
run: tzdata -> {
|
|
973
|
+
timezone: '${zone}'
|
|
974
|
+
select: d is tstz.day
|
|
975
|
+
}
|
|
976
|
+
`).toMatchResult(tzModel(), {d: mex_30.toJSDate()});
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
test.when(timestamptz)('add a month', async () => {
|
|
980
|
+
// Jan 30 + 1 month is Feb 29 in Mexico City; Jan 31 + 1 month in Tokyo
|
|
981
|
+
// or UTC is a day earlier
|
|
982
|
+
const mex_29 = LuxonDateTime.fromISO('2020-02-29T20:00:00', {zone});
|
|
983
|
+
await expect(`
|
|
984
|
+
run: tzdata -> {
|
|
985
|
+
timezone: '${zone}'
|
|
986
|
+
select: next is tstz + 1 month
|
|
987
|
+
}
|
|
988
|
+
`).toMatchResult(tzModel(), {next: mex_29.toJSDate()});
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
test.when(timestamptz)('cast to date', async () => {
|
|
992
|
+
await expect(`
|
|
993
|
+
run: tzdata -> {
|
|
994
|
+
timezone: '${zone}'
|
|
995
|
+
select: d is tstz::date
|
|
996
|
+
}
|
|
997
|
+
`).toMatchResult(tzModel(), {d: '2020-01-30'});
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
test.when(timestamptz)('cast keeps the instant', async () => {
|
|
1001
|
+
await expect(`
|
|
1002
|
+
run: tzdata -> {
|
|
1003
|
+
timezone: '${zone}'
|
|
1004
|
+
select:
|
|
1005
|
+
ts is tstz::timestamp
|
|
1006
|
+
same is pick 'yes' when tstz::timestamp = tstz else 'no'
|
|
1007
|
+
back is pick 'yes' when same_instant::timestamptz = tstz else 'no'
|
|
1008
|
+
}
|
|
1009
|
+
`).toMatchResult(tzModel(), {
|
|
1010
|
+
ts: '2020-01-31T02:00:00.000Z',
|
|
1011
|
+
same: 'yes',
|
|
1012
|
+
back: 'yes',
|
|
1013
|
+
});
|
|
1014
|
+
});
|
|
1015
|
+
});
|
|
870
1016
|
|
|
871
1017
|
// Test for timezone rendering issue with nested queries
|
|
872
1018
|
test.when(runtime.supportsNesting)(
|
|
@@ -946,16 +1092,16 @@ describe.each(runtimes.runtimeList)('%s: query tz', (dbName, runtime) => {
|
|
|
946
1092
|
timezone: 'Europe/Dublin'
|
|
947
1093
|
select:
|
|
948
1094
|
june1_correct,
|
|
949
|
-
month_ok is by_month = june1_correct, by_month
|
|
950
|
-
day_ok is by_day = june1_correct, by_day
|
|
951
|
-
hour_ok is by_hour = june1_correct, by_hour
|
|
952
|
-
quarter_ok is by_quarter = august1_correct, by_quarter
|
|
1095
|
+
month_ok is pick 'yes' when by_month = june1_correct else 'no', by_month
|
|
1096
|
+
day_ok is pick 'yes' when by_day = june1_correct else 'no', by_day
|
|
1097
|
+
hour_ok is pick 'yes' when by_hour = june1_correct else 'no', by_hour
|
|
1098
|
+
quarter_ok is pick 'yes' when by_quarter = august1_correct else 'no', by_quarter
|
|
953
1099
|
}`
|
|
954
1100
|
).toMatchResult(testModel, {
|
|
955
|
-
month_ok:
|
|
956
|
-
day_ok:
|
|
957
|
-
hour_ok:
|
|
958
|
-
quarter_ok:
|
|
1101
|
+
month_ok: 'yes',
|
|
1102
|
+
day_ok: 'yes',
|
|
1103
|
+
hour_ok: 'yes',
|
|
1104
|
+
quarter_ok: 'yes',
|
|
959
1105
|
});
|
|
960
1106
|
});
|
|
961
1107
|
});
|
|
@@ -94,23 +94,82 @@ describe('SQL Server', () => {
|
|
|
94
94
|
`).toEqualResult(tm, [{n: 51}]);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
test('a declared boolean
|
|
97
|
+
test('a declared boolean in a result column is refused, naming the value to write', async () => {
|
|
98
|
+
await expect(
|
|
99
|
+
runtime
|
|
100
|
+
.loadQuery(
|
|
101
|
+
`run: sqlserver.table('malloytest.state_facts') extend {
|
|
102
|
+
dimension: big is airport_count > 500
|
|
103
|
+
} -> { group_by: big; aggregate: n is count() }`
|
|
104
|
+
)
|
|
105
|
+
.run()
|
|
106
|
+
).rejects.toThrow(
|
|
107
|
+
"'sqlserver' does not support a boolean as a result column; write the value, pick 'yes' when big else 'no'"
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('a declared boolean is a condition in where: and having:', async () => {
|
|
112
|
+
await expect(`
|
|
113
|
+
run: sqlserver.table('malloytest.state_facts') extend {
|
|
114
|
+
dimension: big is airport_count > 500
|
|
115
|
+
measure: n is count()
|
|
116
|
+
} -> {
|
|
117
|
+
where: big
|
|
118
|
+
aggregate: n
|
|
119
|
+
having: n > 1
|
|
120
|
+
}
|
|
121
|
+
`).toEqualResult(tm, [{n: 12}]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('not, and a comparison true on NULL, follow Malloy rather than SQL', async () => {
|
|
125
|
+
await expect(`
|
|
126
|
+
run: sqlserver.sql('SELECT CAST(NULL AS INT) AS n, 1 AS one') -> {
|
|
127
|
+
select:
|
|
128
|
+
not_null is pick 'yes' when not (n = 1) else 'no'
|
|
129
|
+
ne_null is pick 'yes' when n != 1 else 'no'
|
|
130
|
+
not_one is pick 'yes' when not (one = 1) else 'no'
|
|
131
|
+
ne_one is pick 'yes' when one != 1 else 'no'
|
|
132
|
+
}
|
|
133
|
+
`).toEqualResult(tm, [
|
|
134
|
+
{not_null: 'yes', ne_null: 'yes', not_one: 'no', ne_one: 'no'},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('a comparison picks', async () => {
|
|
98
139
|
await expect(`
|
|
99
140
|
run: sqlserver.table('malloytest.state_facts') -> {
|
|
100
|
-
group_by:
|
|
101
|
-
big is airport_count > 500
|
|
102
|
-
c_state is starts_with(state, 'C')
|
|
141
|
+
group_by: size is pick 'big' when airport_count > 500 else 'small'
|
|
103
142
|
aggregate: n is count()
|
|
104
|
-
order_by:
|
|
143
|
+
order_by: size
|
|
105
144
|
}
|
|
106
145
|
`).toEqualResult(tm, [
|
|
107
|
-
{
|
|
108
|
-
{
|
|
109
|
-
{big: false, c_state: true, n: 2},
|
|
110
|
-
{big: false, c_state: false, n: 37},
|
|
146
|
+
{size: 'big', n: 12},
|
|
147
|
+
{size: 'small', n: 39},
|
|
111
148
|
]);
|
|
112
149
|
});
|
|
113
150
|
|
|
151
|
+
test('a boolean function is a condition', async () => {
|
|
152
|
+
await expect(`
|
|
153
|
+
run: sqlserver.table('malloytest.state_facts') -> {
|
|
154
|
+
where: starts_with(state, 'C') and not ends_with(state, 'A')
|
|
155
|
+
select: state
|
|
156
|
+
order_by: state
|
|
157
|
+
}
|
|
158
|
+
`).toEqualResult(tm, [{state: 'CO'}, {state: 'CT'}]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('sql_boolean is a condition', async () => {
|
|
162
|
+
await expect(`
|
|
163
|
+
##! experimental { sql_functions }
|
|
164
|
+
run: sqlserver.table('malloytest.state_facts') extend {
|
|
165
|
+
dimension: big is sql_boolean("\${airport_count} > 500")
|
|
166
|
+
} -> {
|
|
167
|
+
where: big
|
|
168
|
+
aggregate: n is count()
|
|
169
|
+
}
|
|
170
|
+
`).toEqualResult(tm, [{n: 12}]);
|
|
171
|
+
});
|
|
172
|
+
|
|
114
173
|
test('a cast to boolean is refused', async () => {
|
|
115
174
|
await expect(
|
|
116
175
|
runtime
|
|
@@ -123,7 +182,7 @@ describe('SQL Server', () => {
|
|
|
123
182
|
).rejects.toThrow(/boolean/);
|
|
124
183
|
});
|
|
125
184
|
|
|
126
|
-
test('starts_with and
|
|
185
|
+
test('starts_with, ends_with and is_nan filter an aggregate', async () => {
|
|
127
186
|
await expect(`
|
|
128
187
|
run: sqlserver.table('malloytest.state_facts') -> {
|
|
129
188
|
aggregate:
|
|
@@ -211,13 +270,13 @@ describe('SQL Server', () => {
|
|
|
211
270
|
test('a constant dimension groups with the others', async () => {
|
|
212
271
|
await expect(`
|
|
213
272
|
run: sqlserver.table('malloytest.state_facts') -> {
|
|
214
|
-
group_by: tag is 'facts',
|
|
273
|
+
group_by: tag is 'facts', size is pick 'big' when airport_count > 500 else 'small'
|
|
215
274
|
aggregate: n is count()
|
|
216
|
-
order_by:
|
|
275
|
+
order_by: size
|
|
217
276
|
}
|
|
218
277
|
`).toEqualResult(tm, [
|
|
219
|
-
{tag: 'facts',
|
|
220
|
-
{tag: 'facts',
|
|
278
|
+
{tag: 'facts', size: 'big', n: 12},
|
|
279
|
+
{tag: 'facts', size: 'small', n: 39},
|
|
221
280
|
]);
|
|
222
281
|
});
|
|
223
282
|
|
|
@@ -363,6 +422,61 @@ describe('SQL Server', () => {
|
|
|
363
422
|
});
|
|
364
423
|
});
|
|
365
424
|
|
|
425
|
+
describe('a datetimeoffset', () => {
|
|
426
|
+
// 12:34:56 at -05:00 is 17:34:56 UTC; 22:34:56 at -05:00 is 03:34:56 UTC the next day
|
|
427
|
+
const noon = "CAST('2024-03-20 12:34:56 -05:00' AS DATETIMEOFFSET)";
|
|
428
|
+
const night = "CAST('2024-03-20 22:34:56 -05:00' AS DATETIMEOFFSET)";
|
|
429
|
+
|
|
430
|
+
test('is an instant', async () => {
|
|
431
|
+
await expect(`
|
|
432
|
+
run: sqlserver.sql("SELECT ${noon} AS t") -> {
|
|
433
|
+
select: t, utc is t::timestamp
|
|
434
|
+
}
|
|
435
|
+
`).toMatchResult(tm, {
|
|
436
|
+
t: new Date('2024-03-20T17:34:56Z'),
|
|
437
|
+
utc: new Date('2024-03-20T17:34:56Z'),
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('reads its clock at UTC without a query time zone', async () => {
|
|
442
|
+
await expect(`
|
|
443
|
+
run: sqlserver.sql("SELECT ${night} AS t") -> {
|
|
444
|
+
select: h is hour(t), d is t.day, dt is t::date
|
|
445
|
+
}
|
|
446
|
+
`).toMatchResult(tm, {
|
|
447
|
+
h: 3,
|
|
448
|
+
d: new Date('2024-03-21T00:00:00Z'),
|
|
449
|
+
dt: '2024-03-21',
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('compares as an instant', async () => {
|
|
454
|
+
await expect(`
|
|
455
|
+
run: sqlserver.sql("SELECT ${noon} AS t") -> {
|
|
456
|
+
where: t > @2024-03-20 15:00:00
|
|
457
|
+
aggregate: n is count()
|
|
458
|
+
}
|
|
459
|
+
`).toMatchResult(tm, {n: 1});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test('offsets by a calendar unit at UTC', async () => {
|
|
463
|
+
await expect(`
|
|
464
|
+
run: sqlserver.sql("SELECT CAST('2024-01-30 22:34:56 -05:00' AS DATETIMEOFFSET) AS t") -> {
|
|
465
|
+
select: next is t + 1 month
|
|
466
|
+
}
|
|
467
|
+
`).toMatchResult(tm, {next: new Date('2024-02-29T03:34:56Z')});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('measures between two offsets as instants', async () => {
|
|
471
|
+
await expect(`
|
|
472
|
+
run: sqlserver.sql("""
|
|
473
|
+
SELECT CAST('2024-03-20 12:00:00 +00:00' AS DATETIMEOFFSET) AS a,
|
|
474
|
+
CAST('2024-03-20 12:00:00 -05:00' AS DATETIMEOFFSET) AS b
|
|
475
|
+
""") -> { select: h is hours(a to b) }
|
|
476
|
+
`).toMatchResult(tm, {h: 5});
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
366
480
|
describe('joins', () => {
|
|
367
481
|
test('sums each side of a fan-out once', async () => {
|
|
368
482
|
await expect(`
|