@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 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`. 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.
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 dispatch only while the dialect is experimental |
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.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",
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.435"
46
+ "version": "0.0.436"
47
47
  }
@@ -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, but
2660
- // only the compiler can produce one, so neither side resolves givens.
2661
- // Resolve on one side only and this throws — the builder emits
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: boolean_1 is sql_boolean("\${seats} > 20")
531
- group_by: boolean_2 is sql_boolean("\${engines} = 2")
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: true,
535
- boolean_2: false,
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: false,
1075
- no_paren: false,
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 lag(@2011-11-11 11:11:11, 1, now).year = now.year
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
- runtime.dialect.resultBoolean(true)
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})`, dbTrue],
859
- ['is_inf(100)', dbFalse],
860
- ['is_inf(null)', dbFalse]
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)", dbTrue],
868
- ['is_nan(100)', dbFalse],
869
- ['is_nan(null)', dbFalse]
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
- runtime.dialect.resultBoolean(true),
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
- runtime.dialect.resultBoolean(true),
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(@2003, @2004, @1994) = @1994',
897
- runtime.dialect.resultBoolean(true),
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
- runtime.dialect.resultBoolean(true),
943
+ "pick 'yes' when starts_with('hello world', 'hello') else 'no'",
944
+ 'yes',
934
945
  ],
935
- [
936
- "starts_with('hello world', 'world')",
937
- runtime.dialect.resultBoolean(false),
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
- "ends_with('hello world', 'world')",
952
- runtime.dialect.resultBoolean(true),
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('rand() = rand()', runtime.dialect.resultBoolean(false));
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
- runtime.dialect.resultBoolean(true)
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('plain booleans work with dialect boolean handling', async () => {
159
- const tm = mkTestModel(runtime, {
160
- data: [{a: true, b: false}],
161
- });
162
- // Booleans are handled by dialect.resultBoolean() in the matcher
163
- await expect('run: data -> { select: * }').toMatchResult(tm, {
164
- a: true,
165
- b: false,
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
- it(`boolean type - ${databaseName}`, async () => {
28
- await expect(`
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
- big: false,
35
- model_count: 58451,
36
- });
37
- });
37
+ big: false,
38
+ model_count: 58451,
39
+ });
40
+ }
41
+ );
38
42
 
39
- it(`boolean in pipeline - ${databaseName}`, async () => {
40
- await expect(`
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
- big: false,
52
- model_count: 58500,
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(runtime.dialect.hasTimestamptz)(
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 (or stored tz for Trino)
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: false,
536
- isFirst: true,
537
- isLast: true,
538
- isAfter: false,
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(runtime.dialect.hasTimestamptz)(
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(runtime.dialect.hasTimestamptz)(
818
- 'cast timestamptz to date',
819
- async () => {
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
- ).toMatchResult(testModel, {mex_date: '2020-02-19'});
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(runtime.dialect.hasTimestamptz)(
846
- 'cast date to timestamptz',
847
- async () => {
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
- ).toMatchResult(testModel, {mex_date: zone_2020.toJSDate()});
854
- }
855
- );
933
+ ).toMatchResult(testModel, {mex_date: zone_2020.toJSDate()});
934
+ });
856
935
 
857
- test.when(runtime.dialect.hasTimestamptz)(
858
- 'compare timestamptz to range',
859
- async () => {
860
- const tzModel = mkTestModel(runtime, {
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
- {updated: TV.timestamptz('2020-01-15 00:00:00[America/Mexico_City]')},
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
- await expect(
866
- 'run: tzdata -> { select: cmp is updated ? @2020-01 }'
867
- ).toMatchResult(tzModel, {cmp: true});
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: true,
956
- day_ok: true,
957
- hour_ok: true,
958
- quarter_ok: true,
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 is a 0/1 value', async () => {
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: big desc, c_state desc
143
+ order_by: size
105
144
  }
106
145
  `).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},
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 ends_with are conditions', async () => {
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', big is airport_count > 500
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: big desc
275
+ order_by: size
217
276
  }
218
277
  `).toEqualResult(tm, [
219
- {tag: 'facts', big: true, n: 12},
220
- {tag: 'facts', big: false, n: 39},
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(`