Skip to content

A boolean is coerced between condition and value in the compiler - #5

Open
housejester wants to merge 16 commits into
mainfrom
jde/boolean-coercion
Open

housejester wants to merge 16 commits into
mainfrom
jde/boolean-coercion

Conversation

@housejester

@housejester housejester commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

On a dialect with booleanType: 'none', the expression compiler now tracks whether each edge of the compiled expression tree wants a value or a condition, and asks the dialect to coerce where they differ: a condition where a value is wanted becomes CASE WHEN c THEN 1 WHEN NOT (c) THEN 0 END, a value where a condition is wanted becomes v = 1. On a dialect with boolean values both hooks are the identity, so nothing changes there.

In the boolean_coercion experiment the translator stops refusing a boolean in a value position, so a model written for DuckDB runs unchanged on SQL Server; the result reader already shows the 1/0 as true/false. Without the experiment the refusals from malloydata#3147 stay as they are. The pass runs either way, which is what closes the two holes in malloydata#3171 that the translator could never see: all() of a boolean measure and a boolean primary key are expressions the compiler builds itself.

What a boolean compiles to, with the experiment on:

Malloy SQL on SQL Server
group_by: big CASE WHEN ai>5 THEN 1 WHEN NOT (ai>5) THEN 0 END as "big"
where: big WHERE (ai>5)
having: all(anyn) HAVING (MAX(CASE WHEN … END) OVER () = 1)
primary_key: big, then pk.count() COUNT(DISTINCT (CASE WHEN … END))
a second stage's where: big WHERE (base."big" = 1)
where: pick big when ai > 1 else false WHERE (CASE WHEN (ai>1) THEN (CASE … END) ELSE … END = 1)
where: big ~ f'not true' WHERE (CASE WHEN (ai>5) THEN 1 ELSE 0 END = 0)
sql_number("""CASE WHEN ${big} THEN 1 ELSE 0 END""") left as written

Two things stay refused because there is no value to make: a cast to boolean, and (unchanged from today) arithmetic or a time operation on a boolean. having: all(x) compiles but SQL Server rejects a windowed function in HAVING for any x; that needs its own compilation and is out of scope.

Testing: boolean-coercion.spec.ts in lang/test checks the generated SQL for each shape on the test dialect, and test/src/databases/sqlserver/boolean-coercion.spec.ts runs them on a live SQL Server. Every shape from the fuzz corpus behind malloydata#3171, plus every refusal case from malloydata#3147, was also executed against SQL Server 2022; everything boolean-specific runs and returns the DuckDB result, and what remains failing is either a generic type error the translator already gives or a SQL Server limitation unrelated to booleans.

James Estes and others added 16 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>
…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>
…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>
…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>
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>
On a dialect without boolean values, the expression compiler tracks whether
each edge wants a value or a condition and asks the dialect to coerce where
they differ: a condition becomes CASE WHEN c THEN 1 WHEN NOT (c) THEN 0 END,
a value becomes v = 1. In the boolean_coercion experiment the translator no
longer refuses a boolean in a value position.

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>
A CASE is a value, so the branches of a pick or case are compiled as
values and the whole is tested with = 1 where a condition is wanted. SQL
the author wrote in a sql_ function is left as written. A boolean filter
expression on a dialect without boolean values is compiled in condition
form. A cast to boolean stays refused, since there is no value to make.

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>
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.

1 participant