Skip to content

A boolean is a condition on a dialect without boolean values - #3147

Merged
mtoy-googly-moogly merged 14 commits into
malloydata:mainfrom
housejester:jde/sqlserver-boolean-form
Oct 21, 2026
Merged

mtoy-googly-moogly merged 14 commits into
malloydata:mainfrom
housejester:jde/sqlserver-boolean-form

Conversation

@housejester

@housejester housejester commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

On a dialect with booleanType: 'none', which is SQL Server, a boolean is a condition and never a value. Where a program needs a boolean value, the translator reports an error at that expression and names the value to write instead. This is the design in packages/malloy/src/doc/boolean-none.md (#3166). The first commit is #3146, which this PR includes.

What compiles

  • A boolean wherever SQL wants a condition: where:, having:, a join on:, pick … when, a filtered aggregate, and the operands of and, or, not.
  • A declared boolean field. dimension: is_late is dep_delay > 15 is a condition, and where: is_late writes WHERE dep_delay > 15. The translator's CASE … THEN 1 ELSE 0 END rewrite of a declared boolean (field-declaration.ts) is gone.
  • A boolean function, written as a condition, as the SQL Server dialect already does.
  • true and false, written (1=1) and (1=0).

not, !=, !~

Malloy's negative forms are true where SQL would say NULL. Two methods on the base Dialect, keyed on booleanType and not meant to be overridden, spell them: sqlNot and sqlNullIsTrue. Every other dialect keeps COALESCE.

Malloy SQL on a 'none' dialect
not c (CASE WHEN c THEN 1 ELSE 0 END = 0)
a != b, a !~ b (CASE WHEN NOT (c) THEN 1 ELSE 0 END = 0), where c is the positive comparison

Translator errors

One check, booleanValueError in lang/ast/expressions/boolean-value.ts, called where a boolean would become a value. It logs the error and hands back an error-typed value, which the caller returns, so the enclosing expressions stay quiet and one mistake is one error, at the expression which made it. The sites:

  • a result column: select:, group_by:, aggregate:, calculate: and index:, whether a declaration or a reference, and a boolean field named as the whole view (m -> is_late, m -> by_carrier + is_late);
  • is null and is not null with a boolean operand;
  • any comparison with a boolean operand on either side (is_active = 1, size > is_small), plain, partial, or in an alternation including a boolean among the choices of an in list; one check before the equality() / compare() dispatch covers all but the in list;
  • ?? with a boolean operand;
  • a pick or case whose result is a boolean, and a boolean case subject;
  • a boolean matched with a filter expression;
  • a boolean as a function argument;
  • a cast to or from boolean.

The message is 'sqlserver' does not support a boolean as a result column; write the value, pick 'yes' when is_late else 'no', with the expression the user wrote in the hint where there is one. select: * and index: * leave a boolean field out with a warning that names it by its full path (o.is_late for o.*); a measure is never named, since * never included one.

Tests

  • lang/test/boolean-none.spec.ts: each condition site translates, each value site is refused by its error code at the marked expression, a nested mistake logs one error, select: * warns, and the same model on a dialect with booleans translates. The lang harness gains a _mssql_ connection, whose table has no boolean column since nothing read from a SQL Server schema is one, and makeModelFunc shifts a ${…} mark past its prefix so the location check works with one.
  • dialect.spec.ts pins the two spellings on both kinds of dialect; the SQL Server spec runs a declared boolean in where: and having:, not and != on NULL, a boolean function as a condition, sql_boolean, and the refusal of a declared boolean in group_by:.
  • 17 cross-database tests asserted a fact through a comparison in a result column (rand, pi, greatest, least, lag, starts_with, ends_with, is_inf, is_nan, sql_boolean, parenthesisation, and six date, timestamp and timezone comparisons). They now assert the same fact through a pick, on every dialect; what each asserts is unchanged. A pick cannot tell false from NULL, so starts_with, ends_with, is_inf and is_nan, which are COALESCEd to false on purpose, keep a null-input-is-false test where booleanType !== 'none'. Four harness tests that build a boolean column, two that group by one, and concat of a boolean literal are gated the same way.

What changes, and what does not

Generated SQL compared against main on duckdb, postgres, bigquery, snowflake, trino, presto, mysql and databricks over a 68-query corpus covering every site above: identical on all eight.

On SQL Server 2022 and 2017, every query that failed with server error 4145, a boolean where a condition is expected, now passes, and no other test changes state. What remains is nesting, regular expressions, arrays and records, all()/exclude(), and a few engine facts (a FLOAT holds no NaN; GREATEST ignores NULL; a bit reads back as a number), each its own conversation.

James Estes and others added 3 commits October 14, 2026 19:16
The `none` filter, the empty filter and a contradictory string filter
compile to a constant, and so does `in` over an empty or null given. The
constants were the keywords true and false written as strings, which a
dialect without boolean values cannot run; the literals true and false
already go through sqlBoolean, and now these do too.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Estes <james.estes@credibledata.com>
… wants

SQL has two places for a boolean, a condition (WHERE, ON, CASE WHEN, the
operands of AND, OR and NOT) and a value, and on a dialect with no boolean
type they do not mix: a comparison is legal only as a condition, a bit
only as a value. The compiler carries the wanted form down the expression
tree and crosses the boundary through the dialect, spells a literal in the
wanted form, and writes Malloy's NULL rules for not, != and !~ through the
dialect too. The translator no longer wraps a declared boolean in CASE. On
every dialect with a boolean type the generated SQL is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Estes <james.estes@credibledata.com>
resultBoolean returns 1/0 for every booleanType but 'supported'; the
matcher converted a number back to a boolean only for 'simulated'.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: James Estes <james.estes@credibledata.com>
@housejester housejester changed the title fix(compiler): a boolean is written as the condition or the value SQL wants The compiler writes a boolean as the condition or the value SQL wants Oct 15, 2026
James Estes and others added 3 commits October 19, 2026 17:21
…es needs a value

On a dialect with booleanType 'none' a boolean is a condition and never a
value. A boolean as a result column, a function argument, a null test, in
??, in a cast, compared with = or !=, matched with a filter expression or
as the result of a pick is an error at that expression, naming the value
to write instead; select: * leaves a boolean field out with a warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
…t boolean values

Nothing converts a condition into a 1/0 or back: a declared boolean is its
comparison, and the translator refuses a boolean where a value is wanted.
Malloy's not, != and !~ are true where SQL says NULL; Dialect.sqlNot and
sqlNullIsTrue spell that, as a condition on a dialect with booleanType
'none' and as today's COALESCE everywhere else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
A comparison selected as a result column is a boolean value, which a
dialect without boolean values refuses. The tests whose subject was the
comparison now assert it through a pick on every dialect; the tests whose
subject is a boolean column are gated on booleanType.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
@housejester housejester changed the title The compiler writes a boolean as the condition or the value SQL wants A boolean is a condition on a dialect without boolean values Oct 19, 2026
@mtoy-googly-moogly

Copy link
Copy Markdown
Collaborator

This is a fun one, I was afraid the "no boolean value" would be a huge stress to the malloy internals but it is actually coming out pretty clean. Claude suggested some areas which are not quite done yet and here's what we came up with.

I thought about making a WN, but instead the doc for this design is now on main as packages/malloy/src/doc/boolean-none.md (#3166), so please merge main first.

Places a boolean still becomes a value without an error

  1. A boolean field named as the whole view. run: m -> big translates with no error and writes SELECT base."ai">5 as "big" …, and so do m -> v + big and a boolean measure, m -> anyn. That shorthand is built in reference-view.ts and doesn't pass through the new checks. Could it refuse there too, with a test in boolean-none.spec.ts?

  2. Ordering comparisons between booleans. where: big > false writes WHERE (base."ai">5)>(1=0); only = and != are refused. Plain comparisons, partials (big ? > false) and alternations all reach ExpressionDef.apply in expression-def.ts, which dispatches to equality() / compare(), so one refusal of two boolean operands before that dispatch would cover all of them and replace the check in equality(). For tests, one ordering operator in each form is enough: big > false, big ? > false, and an alternation like big ? > false | < true. (The design listed only = and !=, so this one is a gap in the design, not in your implementation; please update the list in boolean-none.md to say any comparison between two booleans.)

  3. A boolean case subject. case big when true then 1 else 0 end writes CASE (base."ai">5) WHEN (1=1) …. It's built in case.ts and doesn't go through the comparison path. Please refuse a boolean subject there; no test needed.

Error messages

  1. One mistake can log more than one error, and the later one gives the wrong advice. group_by: x is pick big when ai > 1 else big reports "as the result of a pick", then "as a result column; write the value, pick 'yes' when pick big when ai > 1 else big else 'no'". refuseBooleanValue logs but the expression stays boolean, so each enclosing check refuses it again. If the refusal produced an error-typed value (the loggedErrorExpr pattern) and callers returned it, the usual error-cascade suppression would leave one error at the place the mistake was made.

  2. The select: * warning:

    • It runs before measures are filtered out, so a boolean measure gets "'anyn' is left out of '*'" even though * never includes measures.
    • For select: o.* the hint says pick 'yes' when big, which names the base source's big, not o.big.
    • wildcard-omits-boolean takes a pre-built string, and its two call sites already differ: index: * has no hint. Could it be parameterized like boolean-value-unsupported, say {dialect, wildcard, name} with one formatter in parse-log.ts? Building the hint from the full path there fixes o.* too.
  3. The hint for is null / is not null and ?? ("a boolean here is a condition, which is false when unknown") explains the rule but doesn't say what to write. Every other refusal does. Something like "test the condition itself; unknown already tests as false" would tell the reader (often an AI) that is_small ?? false is just is_small.

Tests

  1. In boolean-none.spec.ts, could the refusal tests mark the offending expression with ${…} in the template, as composite-field-usage.spec.ts does? toLog then checks where each error lands, not only its text.

  2. In functions.spec.ts, the pick rewrite of starts_with, ends_with, is_inf and is_nan is fine to keep on every dialect, but it can no longer tell false from NULL. Those functions are COALESCE(…, false) on purpose, and the old null-input cases were the only thing that would catch a dialect override losing the COALESCE. Please add a test that runs only where booleanType !== 'none' and checks that a null input returns false.

  3. The concat('cons', true) case uses a conditional spread with as [string, string] to skip SQL Server. Moving it into its own it.when(runtime.dialect.booleanType !== 'none') test removes the cast and matches how the file gates other tests.

Docs

  1. packages/malloy/src/model/CONTEXT.md: now that boolean-none.md exists, could the new paragraph cover just the compiler side (sqlNot / sqlNullIsTrue, nothing converted to 1/0) and link to the doc, without the list of refused sites? That list is already missing case, filter-expression matching, index: and alternation, and it would keep drifting.

  2. packages/malloy/src/lang/test/CONTEXT.md still says "Four connections"; please add _mssql_.

@housejester
housejester marked this pull request as ready for review October 20, 2026 16:45
James Estes and others added 4 commits October 20, 2026 10:58
…iew or case subject

The refusal returns an error-typed value which its caller returns, so an
enclosing expression stays quiet and one mistake logs one error, at the
expression which made it. Two booleans compared with any operator, a
boolean field named as the whole view, and a boolean case subject are
refused too. The wildcard warning names a joined field by its path and
never a measure; the null-test hint says what to write.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
…alue

A pick cannot tell false from NULL, and starts_with, ends_with, is_inf and
is_nan are COALESCEd to false on purpose. The concat of a boolean is its own
gated test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
…t it

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
MySQL hands a boolean back as 0 or 1, so the null-input tests compare
through resultBoolean as the tests they replace did, and the concat of a
boolean keeps the trino and presto gate the inline case had.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
@housejester

Copy link
Copy Markdown
Contributor Author

All eleven are in, on top of a merge of main, and CI is green.

Places a boolean still becomes a value without an error

  1. Refused in reference-view.ts for a boolean dimension or measure named as the whole view, including as a refinement (m -> v + big). Three cases in boolean-none.spec.ts.

  2. One check, booleanCompare, runs ahead of the equality() / compare() dispatch in expression-def.ts and replaces the one in equality(), so plain, partial and alternation forms all land on it. Tests for big > false, big ? > false and big ? > false | < true. The doc's list now says any comparison between two booleans.

  3. A boolean case subject is refused in case.ts.

Error messages

  1. refuseBooleanValue is now booleanValueError: it logs and returns an error-typed value, and every caller returns it. group_by: x is pick big when ai > 1 else big logs one error, at the pick. One more place needed to go quiet on an error-typed expression: the "Filter expression must have boolean value" check in filters.ts.

  2. The warning is parameterized {dialect, wildcard, name} with one formatter in parse-log.ts. The name is the full path, so o.* says o.big. The check now sits inside the scalar filter, so a measure is never named. index: * gets the same hint.

  3. The hint for is null / is not null and ?? is "test the condition itself; unknown already tests as false".

Tests

  1. Each refusal marks its expression with ${…}. That showed makeModelFunc never shifted a mark past its prefix, so the location check could not work with one; test-translator.ts now shifts it.

  2. starts_with, ends_with, is_inf and is_nan each have a null-input-is-false test gated on booleanType !== 'none', compared through resultBoolean as the originals were.

  3. concat('cons', true) is its own it.when test, under the same trino and presto gate the inline case had.

Docs

  1. model/CONTEXT.md keeps the compiler side and links boolean-none.md; the list of refused sites is gone.

  2. lang/test/CONTEXT.md says five connections and lists _mssql_.

One question on the doc. It says the translator-errors list covers every case, and items 1 and 3 add two it does not name: a boolean field as the whole view, and a boolean case subject. I kept the edit to the comparison line you asked for; would you like those two added as well?

@mtoy-googly-moogly

Copy link
Copy Markdown
Collaborator

I'm ok with not updating the "spec" .. those two don't need mentioning. i'm in final review stages, will merge if it clears.

@mtoy-googly-moogly

Copy link
Copy Markdown
Collaborator

CI passed and I had Claude do a clean re-review, and, annoyingly, it found more things. We are close, I am sorry to keep doing this but I do want these changes.

  1. A boolean compared with something that isn't a boolean. booleanCompare refuses only when both sides are boolean, and = / > don't type-check their operands, so where: big = 1 translates and writes WHERE (base."ai">5)=1, which SQL Server rejects ("Incorrect syntax near '='"). ai = big, big > 0, astr != big, null = big and big ? 1 get through the same way. is_active = 1 is exactly what a SQL Server user will write. Please refuse a comparison when either side is a boolean (and the other isn't already an error), with a test, and change the doc's comparison bullet to "any comparison with a boolean operand". This one is on us for asking for the narrower rule last round.

  2. where: -astr is now silent on every dialect. filters.ts no longer logs "Filter expression must have boolean value" for an error-typed expression, which assumes the error was already logged. ExprMinus (expr-minus.ts) returns errorFor('negate requires number') without logging, so run: a -> { where: -astr; … } translates with no error on DuckDB and writes WHERE false. On main it reports the filter error. Please keep the filters.ts change and have ExprMinus log its error (loggedErrorExpr), with a test.

  3. A raw function call skips the argument check. In expr-func.ts the boolean-argument loop runs after the isRaw path has returned, so group_by: x is foo!number(big) writes foo((base."ai">5)) with no error. Please run the same check on the raw path.

  4. boolean-none.md. The comparison bullet now covers alternations, so the last bullet ("An alternation with a boolean on the left") is a duplicate; please drop it. The closing "The error suggests making the value explicit with a pick" is only true for some refusals (is null, ??, comparisons, filter matches and casts suggest something else); something like "Each error says what to write instead." would be accurate.

  5. Hints in one place. CONDITION_HINT and CHOICE_HINT are shared, but three hints are still written out more than once: "test the condition itself, or its not" (expr-alternation-tree.ts, expression-def.ts), "write the value, pick 1 when … else 0" (expr-cast.ts, expr-func.ts), and the result-column "write the value, pick 'yes' when … else 'no'" (field-declaration.ts, field-references.ts, reference-view.ts, and inside the wildcard-omits-boolean formatter). Could every repeated hint live in boolean-value.ts, the result-column one as a function of the source text, with wildcard-omits-boolean taking a hint parameter so that sentence is written once? Hints used in only one place can stay inline.

  6. Tests check the error code, not the wording. In boolean-none.spec.ts, most refusals match message text. With the ${…} marks now checking where each error lands, could they use error('boolean-value-unsupported') plus the mark, keeping full-text checks only where the hint itself is the point (the pick 'yes' when … hints for group_by:, select: and select: *)?

James Estes and others added 2 commits October 20, 2026 12:20
…a raw function

Any comparison with a boolean operand, including a boolean among the
choices of an in-list, is refused at the boolean side. The raw function
path checks its arguments as the overloaded path does. Unary minus logs
its type error, so a filter on it no longer goes quiet. The hints live
in one module, and the refusal tests check the error code and where it
landed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T1vFteHTo61YyRRVVrJAn4
Signed-off-by: James Estes <james.estes@credibledata.com>
@housejester

Copy link
Copy Markdown
Contributor Author

All six are in, and since each pass has found a site of the same shape, I went looking for more before pushing.

  1. booleanCompare refuses when either operand is a boolean, logged at the boolean side, so big = 1, ai = big, null = big, astr != big and big ? > 1 are all refused; tests for each. The doc's bullet says any comparison with a boolean operand.

  2. ExprMinus logs expression-type-error through loggedErrorExpr and the filters.ts change stays; a test in expressions.spec.ts. It keeps typeIn rather than typeCheck: the operand's expression type is not the point there (-rank() in a calculate: must still work, and did not with typeCheck), and an error-typed operand must still infer as a number for the error-cascade tests.

  3. The raw path in expr-func.ts runs the same argument check before it builds the call; foo!number(big) is refused, with a test.

  4. The alternation bullet is gone and the closing line is "Each error says what to write instead."

  5. boolean-value.ts holds CONDITION_HINT, COMPARE_HINT, NUMBER_HINT, CHOICE_HINT and resultColumnHint(src); wildcard-omits-boolean takes a hint. The three hints used once stay inline.

  6. The refusal tests check error('boolean-value-unsupported') plus the mark; the full text stays on the group_by:, select: and wildcard cases, where the hint is the point.

Looking for more of the same

I probed about forty more expressions on the _mssql_ harness, and compiled the ones that translated to SQL with a schema-only connection to see where the boolean landed.

One more hole, same rule as item 1 by a different path: the choices of an in list. where: ai ? 1 | big wrote IN (1, (ai>5)), because the alternation's in-list path checked only the tested value. It now checks every choice too; the test failed before the change and passes after. It is in the same commit as item 1.

Item 2 is one site, not a class. Of the fifteen places the translator returns an error value without loggedErrorExpr, every other one logs first or sits behind a typeCheck that logs. It also closes a pre-existing gap: group_by: x is -astr was silent on main too, since a declaration never logs for an error-typed expression.

Ruled out, each already handled somewhere:

  • a boolean parameter: where: p writes (1=1) / (1=0), and p = true is refused by item 1;
  • having: on a dimension-only boolean becomes a WHERE condition, as on every dialect;
  • max(big), min(big), coalesce(big, false), nullif(big, false) fail overload resolution before any of this;
  • join_one: … with big fails the primary-key type check;
  • drill: big = true is refused by item 1;
  • order_by: and a second-stage reference to a boolean are refused as output columns or undefined; calculate: … { order_by: big } is already an error;
  • a source-level dimension: x is is_active = 1 is refused at definition.

One thing I noticed that is not about booleans: calculate: … { partition_by: d } where d is a dimension not in the output is an internal compiler error on every dialect. Pre-existing; I left it alone.

@mtoy-googly-moogly

Copy link
Copy Markdown
Collaborator

Thanks for the thorough pass, and for chasing down the in-list hole yourself.

One last request. ExprMinus rebuilds an error-typed operand as a number, so the expressions around it complain again: where: -(-astr) logs "'unary minus' Can't use type string" and then "Filter expression must have boolean value", where main logs one error. where: -(big ?? false) does the same after the boolean refusal. Could ExprMinus return the operand unchanged when its type is error, with a test that where: -(-astr) logs one error?

After that I'll land it. There are more places a boolean can still slip through as a value, and we'll close those in a follow-up of our own instead of another round here.

ExprMinus rebuilt an error-typed operand as a number, so whatever used the
result complained again; it now returns the operand unchanged, and the
error-type-inference table says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HXm9dCuXFseFX4FS2DK1My
Signed-off-by: James Estes <james.estes@credibledata.com>
@housejester

Copy link
Copy Markdown
Contributor Author

Done in 80ff7df: ExprMinus returns an error-typed operand unchanged, with the where: -(-astr) test in expressions.spec.ts and where: -(big ?? false) in boolean-none.spec.ts, both run first and failing with the extra filter error. One knock-on: the error-type-inference table in parse.spec.ts had -err inferring number, which is the rebuild you flagged, so that entry now reads error. Binary arithmetic on an error operand still infers number, unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants