Skip to content

Commit cbfd29e

Browse files
Finalize NULL related decisions (#2089)
* add test suite for null protected operations * fix test for dbs with NULLS keyword * fix mysql not and snowflake test * make IS NULL and IS NOT NULL official * make = NULL and != NULL warn * fix tests IS NULL change broke * allow partial null comparison * add tests for partial is null * add tests for warning on = null
1 parent f7fdbdb commit cbfd29e

File tree

20 files changed

+245
-146
lines changed

20 files changed

+245
-146
lines changed

demo/malloy-duckdb-wasm/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ source: airports is duckdb,table('airports.parquet') extend {
7373
heliport_count is airport_count {where: fac_type = 'HELIPORT'}
7474
7575
view: by_state is {
76-
where: state != null
76+
where: state is not null
7777
group_by: state
7878
aggregate: airport_count
7979
}

packages/malloy-db-snowflake/src/snowflake_connection.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('db:Snowflake', () => {
6666
.loadModel("source: aircraft is snowflake.table('malloytest.aircraft')")
6767
.loadQuery(
6868
`run: aircraft -> {
69-
where: state != null
69+
where: state is not null
7070
aggregate: cnt is count()
7171
group_by: state}`
7272
)

packages/malloy/src/dialect/dialect.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,13 +173,14 @@ export abstract class Dialect {
173173
// MYSQL doesn't have full join, maybe others.
174174
supportsFullJoin = true;
175175

176-
nativeBoolean = true;
177-
178176
// Can have arrays of arrays
179177
nestedArrays = true;
180178
// An array or record will reveal type of contents on schema read
181179
compoundObjectInSchema = true;
182180

181+
// No true boolean type, e.g. true=1 and false=0, set this to true
182+
booleanAsNumbers = false;
183+
183184
abstract getDialectFunctionOverrides(): {
184185
[name: string]: DialectFunctionOverloadDef[];
185186
};

packages/malloy/src/dialect/mysql/mysql.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export class MySQLDialect extends Dialect {
107107
defaultDecimalType = 'DECIMAL';
108108
udfPrefix = 'ms_temp.__udf';
109109
hasFinalStage = false;
110-
// TODO: this may not be enough for lager casts.
110+
// TODO: this may not be enough for larger casts.
111111
stringTypeName = 'VARCHAR(255)';
112112
divisionIsInteger = true;
113113
supportsSumDistinctFunction = true;
@@ -121,13 +121,13 @@ export class MySQLDialect extends Dialect {
121121
supportsQualify = false;
122122
supportsNesting = true;
123123
experimental = false;
124-
nativeBoolean = false;
125124
supportsFullJoin = false;
126125
supportsPipelinesInViews = false;
127126
readsNestedData = false;
128127
supportsComplexFilteredSources = false;
129128
supportsArraysInData = false;
130129
compoundObjectInSchema = false;
130+
booleanAsNumbers = true;
131131

132132
malloyTypeToSQLType(malloyType: AtomicTypeDef): string {
133133
switch (malloyType.type) {

packages/malloy/src/lang/ast/expressions/expr-not.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ export class ExprNot extends Unary {
3636

3737
getExpression(fs: FieldSpace): ExprValue {
3838
const notThis = this.expr.getExpression(fs);
39+
if (fs.dialectObj()?.booleanAsNumbers) {
40+
if (this.legalChildTypes.find(t => t.type === 'number') === undefined) {
41+
this.legalChildTypes.push(TDU.numberT);
42+
}
43+
}
3944
const doNot = this.typeCheck(this.expr, notThis);
4045
return {
4146
...notThis,

packages/malloy/src/lang/ast/expressions/expr-null.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,79 @@
2121
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
2222
*/
2323

24+
import {BinaryMalloyOperator, FieldSpace} from '..';
2425
import {ExprValue, literalExprValue} from '../types/expr-value';
25-
import {ExpressionDef} from '../types/expression-def';
26+
import {ATNodeType, ExpressionDef} from '../types/expression-def';
27+
28+
function doIsNull(fs: FieldSpace, op: string, expr: ExpressionDef): ExprValue {
29+
const nullCmp = expr.getExpression(fs);
30+
nullCmp.type = 'boolean';
31+
nullCmp.value = {
32+
node: op === '=' ? 'is-null' : 'is-not-null',
33+
e: nullCmp.value,
34+
};
35+
return nullCmp;
36+
}
2637

2738
export class ExprNULL extends ExpressionDef {
2839
elementType = 'NULL';
40+
2941
getExpression(): ExprValue {
3042
return literalExprValue({
3143
dataType: {type: 'null'},
3244
value: {node: 'null'},
3345
});
3446
}
47+
48+
apply(
49+
fs: FieldSpace,
50+
op: BinaryMalloyOperator,
51+
left: ExpressionDef
52+
): ExprValue {
53+
if (op === '!=' || op === '=') {
54+
return doIsNull(fs, op, left);
55+
}
56+
return super.apply(fs, op, left, true);
57+
}
58+
}
59+
60+
export class PartialIsNull extends ExpressionDef {
61+
elementType = '<=> NULL';
62+
constructor(readonly op: '=' | '!=') {
63+
super();
64+
}
65+
66+
apply(fs: FieldSpace, op: string, expr: ExpressionDef): ExprValue {
67+
return doIsNull(fs, this.op, expr);
68+
}
69+
70+
requestExpression(_fs: FieldSpace): ExprValue | undefined {
71+
return undefined;
72+
}
73+
74+
getExpression(_fs: FieldSpace): ExprValue {
75+
return this.loggedErrorExpr(
76+
'partial-as-value',
77+
'Partial null check does not have a value'
78+
);
79+
}
80+
81+
atNodeType(): ATNodeType {
82+
return ATNodeType.Partial;
83+
}
84+
}
85+
86+
export class ExprIsNull extends ExpressionDef {
87+
elementType = 'is null';
88+
constructor(
89+
readonly expr: ExpressionDef,
90+
readonly op: '=' | '!='
91+
) {
92+
super();
93+
this.has({expr});
94+
}
95+
96+
getExpression(fs: FieldSpace): ExprValue {
97+
return doIsNull(fs, this.op, this.expr);
98+
}
3599
}

packages/malloy/src/lang/ast/types/expression-def.ts

Lines changed: 13 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -300,25 +300,6 @@ function regexEqual(left: ExprValue, right: ExprValue): Expr | undefined {
300300
return undefined;
301301
}
302302

303-
function nullCompare(
304-
left: ExprValue,
305-
op: string,
306-
right: ExprValue
307-
): Expr | undefined {
308-
const not = op === '!=' || op === '!~';
309-
const nullCmp = not ? 'is-not-null' : 'is-null';
310-
if (left.type === 'null' || right.type === 'null') {
311-
if (left.type !== 'null') {
312-
return {node: nullCmp, e: left.value};
313-
}
314-
if (right.type !== 'null') {
315-
return {node: nullCmp, e: right.value};
316-
}
317-
return {node: not ? 'false' : 'true'};
318-
}
319-
return undefined;
320-
}
321-
322303
function equality(
323304
fs: FieldSpace,
324305
left: ExpressionDef,
@@ -349,30 +330,21 @@ function equality(
349330
kids: {left: lhs.value, right: rhs.value},
350331
};
351332

352-
if (lhs.type !== 'error' && rhs.type !== 'error') {
353-
switch (op) {
354-
case '~':
355-
case '!~': {
356-
if (lhs.type !== 'string' || rhs.type !== 'string') {
357-
let regexCmp = regexEqual(lhs, rhs);
358-
if (regexCmp) {
359-
if (op[0] === '!') {
360-
regexCmp = {node: 'not', e: {...regexCmp}};
361-
}
362-
} else {
363-
throw new TypeMismatch(
364-
"Incompatible types for match('~') operator"
365-
);
366-
}
367-
value = regexCmp;
333+
if (
334+
lhs.type !== 'error' &&
335+
rhs.type !== 'error' &&
336+
(op === '~' || op === '!~')
337+
) {
338+
if (lhs.type !== 'string' || rhs.type !== 'string') {
339+
let regexCmp = regexEqual(lhs, rhs);
340+
if (regexCmp) {
341+
if (op[0] === '!') {
342+
regexCmp = {node: 'not', e: {...regexCmp}};
368343
}
369-
break;
370-
}
371-
case '=':
372-
case '!=': {
373-
value = nullCompare(lhs, op, rhs) ?? value;
374-
break;
344+
} else {
345+
throw new TypeMismatch("Incompatible types for match('~') operator");
375346
}
347+
value = regexCmp;
376348
}
377349
}
378350

packages/malloy/src/lang/grammar/MalloyParser.g4

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,7 @@ fieldExpr
602602
| fieldExpr BAR partialAllowedFieldExpr # exprOrTree
603603
| fieldExpr compareOp fieldExpr # exprCompare
604604
| fieldExpr NOT? LIKE fieldExpr # exprWarnLike
605-
| fieldExpr IS NOT? NULL # exprWarnNullCmp
605+
| fieldExpr IS NOT? NULL # exprNullCheck
606606
| fieldExpr NOT? IN OPAREN fieldExprList CPAREN # exprWarnIn
607607
| fieldExpr QMARK partialAllowedFieldExpr # exprApply
608608
| NOT fieldExpr # exprNot
@@ -624,9 +624,19 @@ fieldExpr
624624
| ungroup OPAREN fieldExpr (COMMA fieldName)* CPAREN # exprUngroup
625625
;
626626

627+
partialCompare
628+
: compareOp fieldExpr
629+
;
630+
631+
partialTest
632+
: partialCompare
633+
| IS NOT? NULL
634+
;
635+
627636
partialAllowedFieldExpr
628-
: OPAREN compareOp? fieldExpr CPAREN
629-
| compareOp? fieldExpr
637+
: partialTest
638+
| OPAREN partialTest CPAREN
639+
| fieldExpr
630640
;
631641

632642
fieldExprList

packages/malloy/src/lang/malloy-to-ast.ts

Lines changed: 50 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,21 +1273,40 @@ export class MalloyToAST
12731273
return this.astAt(new ast.ExprCoalesce(left, right), pcx);
12741274
}
12751275

1276+
visitPartialCompare(pcx: parse.PartialCompareContext): ast.PartialCompare {
1277+
const partialOp = pcx.compareOp().text;
1278+
if (ast.isComparison(partialOp)) {
1279+
return this.astAt(
1280+
new ast.PartialCompare(partialOp, this.getFieldExpr(pcx.fieldExpr())),
1281+
pcx
1282+
);
1283+
}
1284+
throw this.internalError(
1285+
pcx,
1286+
`partial comparison '${partialOp}' not recognized`
1287+
);
1288+
}
1289+
1290+
visitPartialTest(pcx: parse.PartialTestContext): ast.ExpressionDef {
1291+
const cmp = pcx.partialCompare();
1292+
if (cmp) {
1293+
return this.visitPartialCompare(cmp);
1294+
}
1295+
return this.astAt(new ast.PartialIsNull(pcx.NOT() ? '!=' : '='), pcx);
1296+
}
1297+
12761298
visitPartialAllowedFieldExpr(
12771299
pcx: parse.PartialAllowedFieldExprContext
12781300
): ast.ExpressionDef {
1279-
const fieldExpr = this.getFieldExpr(pcx.fieldExpr());
1280-
const partialOp = pcx.compareOp()?.text;
1281-
if (partialOp) {
1282-
if (ast.isComparison(partialOp)) {
1283-
return this.astAt(new ast.PartialCompare(partialOp, fieldExpr), pcx);
1284-
}
1285-
throw this.internalError(
1286-
pcx,
1287-
`partial comparison '${partialOp}' not recognized`
1288-
);
1301+
const exprCx = pcx.fieldExpr();
1302+
if (exprCx) {
1303+
return this.getFieldExpr(exprCx);
12891304
}
1290-
return fieldExpr;
1305+
const partialCx = pcx.partialTest();
1306+
if (partialCx) {
1307+
return this.visitPartialTest(partialCx);
1308+
}
1309+
throw this.internalError(pcx, 'impossible partial');
12911310
}
12921311

12931312
visitExprString(pcx: parse.ExprStringContext): ast.ExprString {
@@ -1348,6 +1367,24 @@ export class MalloyToAST
13481367
const left = this.getFieldExpr(pcx.fieldExpr(0));
13491368
const right = this.getFieldExpr(pcx.fieldExpr(1));
13501369
if (ast.isEquality(op)) {
1370+
const wholeRange = this.parseInfo.rangeFromContext(pcx);
1371+
if (right instanceof ast.ExprNULL) {
1372+
if (op === '=') {
1373+
this.warnWithReplacement(
1374+
'sql-is-null',
1375+
"Use 'is null' to check for NULL instead of '= null'",
1376+
wholeRange,
1377+
`${this.getSourceCode(pcx.fieldExpr(0))} is null`
1378+
);
1379+
} else if (op === '!=') {
1380+
this.warnWithReplacement(
1381+
'sql-is-not-null',
1382+
"Use 'is not null' to check for NULL instead of '!= null'",
1383+
wholeRange,
1384+
`${this.getSourceCode(pcx.fieldExpr(0))} is not null`
1385+
);
1386+
}
1387+
}
13511388
return this.astAt(new ast.ExprEquality(left, op, right), pcx);
13521389
} else if (ast.isComparison(op)) {
13531390
return this.astAt(new ast.ExprCompare(left, op, right), pcx);
@@ -2124,29 +2161,10 @@ export class MalloyToAST
21242161
);
21252162
}
21262163

2127-
visitExprWarnNullCmp(pcx: parse.ExprWarnNullCmpContext): ast.ExprCompare {
2128-
let op: ast.CompareMalloyOperator = '=';
2164+
visitExprNullCheck(pcx: parse.ExprNullCheckContext): ast.ExprIsNull {
21292165
const expr = pcx.fieldExpr();
2130-
const wholeRange = this.parseInfo.rangeFromContext(pcx);
2131-
if (pcx.NOT()) {
2132-
op = '!=';
2133-
this.warnWithReplacement(
2134-
'sql-is-not-null',
2135-
"Use '!= NULL' to check for NULL instead of 'IS NOT NULL'",
2136-
wholeRange,
2137-
`${this.getSourceCode(expr)} != null`
2138-
);
2139-
} else {
2140-
this.warnWithReplacement(
2141-
'sql-is-null',
2142-
"Use '= NULL' to check for NULL instead of 'IS NULL'",
2143-
wholeRange,
2144-
`${this.getSourceCode(expr)} = null`
2145-
);
2146-
}
2147-
const nullExpr = new ast.ExprNULL();
21482166
return this.astAt(
2149-
new ast.ExprCompare(this.getFieldExpr(expr), op, nullExpr),
2167+
new ast.ExprIsNull(this.getFieldExpr(expr), pcx.NOT() ? '!=' : '='),
21502168
pcx
21512169
);
21522170
}

0 commit comments

Comments
 (0)