Skip to content

ECSQL: support IS / IS NOT operator between operands (null-safe comparison) - #9440

Open
khanaffan wants to merge 14 commits into
masterfrom
affank/ecsql-is-operator
Open

ECSQL: support IS / IS NOT operator between operands (null-safe comparison)#9440
khanaffan wants to merge 14 commits into
masterfrom
affank/ecsql-is-operator

Conversation

@khanaffan

@khanaffan khanaffan commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

imodel-native: iTwin/imodel-native#1476

closes: #9439

This pull request adds support for using the IS and IS NOT operators between two operands in ECSQL, extending their previous usage (which was limited to comparisons with NULL, boolean literals, or type predicates). Now, these operators can be used for null-safe comparisons between properties, including multi-column types like points and navigation properties, with appropriate column-wise expansion. The changes include parser, lexer, and documentation updates, as well as comprehensive tests to verify the new behavior.

ECSQL Syntax and Semantics Enhancements:

  • Added support for IS and IS NOT between two operands (e.g., prop1 IS [NOT] prop2), with null-safe comparison semantics consistent with SQLite. For multi-column operands, IS joins per-column comparisons with AND, and IS NOT with OR.
  • Updated the ECSQL version to 2.0.4.0 in documentation, code, and tests to reflect the new syntax support.

khanaffan and others added 2 commits June 24, 2026 09:08
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
khanaffan and others added 4 commits June 24, 2026 12:08
…us value expressions and update related documentation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add IS / IS NOT operator cases (string literal, IS NOT null-safe, integer
literal, function call, and parameter binding) to the markdown-based ECSQL
test runner in Comparison.ecsql.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@khanaffan
khanaffan marked this pull request as ready for review June 24, 2026 16:45
@khanaffan
khanaffan requested review from a team as code owners June 24, 2026 16:45

@aruniverse aruniverse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review (automated) — 4 inline findings below. One is a blocker (result-table column mismatch that will fail the .ecsql.md test runner) and should be fixed before merge. The others are low/informational.

Comment thread core/backend/src/test/ecsql/queries/Comparison.ecsql.md Outdated
Comment thread core/backend/src/test/ecdb/ECSqlStatement.test.ts
Comment thread docs/learning/ECSqlReference/Operators.md Outdated

@aruniverse aruniverse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two related follow-ups on the disambiguation note wording — same single-word fix needed in both NextVersion.md and Operators.md. The sqlbison.y grammar comment in imodel-native#1476 is already accurate; these two docs just need to match it.

Comment thread docs/changehistory/NextVersion.md Outdated

The ECSQL `IS` and `IS NOT` operators can now be used between two operands — for example `prop1 IS [NOT] prop2`, where each operand may be any value expression: a property, the `NULL` literal, a constant, a parameter, a function call, an arithmetic expression, etc. These map to SQLite's **null-safe** comparison operators, so `NULL IS NULL` is `TRUE` and `1 IS NULL` is `FALSE`, unlike `=`/`<>` which treat a `NULL` operand as _unknown_.

Previously `IS` / `IS NOT` only supported the right-hand operands `NULL`, the boolean literals `TRUE`/`FALSE`/`UNKNOWN`, and the [ECClass type predicate](../learning/ECSqlReference/ECClassFilter.md) (`IS (ClassName)`). Those forms still take precedence — a right-hand operand that is exactly `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized `(ClassName)`, keeps its original meaning.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same 'qualified' gap as Operators.md — unqualified class names were never type predicates

"a right-hand operand that is exactly NULL/TRUE/FALSE/UNKNOWN, or a parenthesized (ClassName), keeps its original meaning"

The grammar requires a qualified name (schema.Class), an ONLY/ALL prefix, or a comma-separated list to route to the type-predicate path. A bare unqualified name like IS (MyClass) reduces as a value_exp (null-safe comparison) regardless of whether MyClass is a real ECClass — it was never reachable as a type predicate.

Suggested fix:

or a parenthesized *qualified* class name (e.g. `(schema.Class)`), an `ONLY`/`ALL`-prefixed form, or a comma-separated list keeps its original meaning. A single unqualified name in parentheses is always parsed as a value expression.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Partially addressed — the qualified keyword was added and the wording for unqualified names is correct. However, the IS (alias.prop) qualified-name fallback (added in imodel-native commit 593ff15eb9) is not mentioned here. A qualified name that doesn't resolve to a class — e.g. S1 IS (Foo.S2) or Status IS (ts.Status.Active) — previously failed with 'class not found' and now works as a null-safe comparison. This is the most developer-visible part of the latest commit and should appear in the changelog. New inline comment posted on line 185 with a suggested addition.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 0e701b9. The note now reads "a parenthesized qualified class name such as (bis.Element)" and clarifies that a parenthesized unqualified name like (prop2) is read as a value expression. This matches the sqlbison.y comment in imodel-native#1476.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in 782b6e6f87. NextVersion.md now distinguishes qualified class predicates from unqualified value expressions, documents the qualified non-class fallback such as (alias.prop), and states that the class/type-predicate reading wins for qualified collisions.


Both operands must be type-compatible, following the same rules as `=` and `<>`: comparable primitive types (for example two strings, or numeric types compared with each other) or composite types of the same shape (`Point2d` with `Point2d`, a navigation property with a navigation property), with the `NULL` literal allowed against any type. Comparing unrelated types — for example a `string` against a `Point3d` — is rejected when the statement is prepared.

> Note: `IS [NOT]` is also used by the unrelated [ECClass filter](./ECClassFilter.md) predicate (`<classId> IS [NOT] (<class-name>, ...)`) and by the boolean truth tests `IS [NOT] TRUE`/`FALSE`/`UNKNOWN`. Those forms take precedence: a right-hand operand that is exactly `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized `(ClassName)`, keeps its original meaning. Write such a value expression without the outer parentheses (for example `x IS y + 1` rather than `x IS (y + 1)`) when it could be mistaken for a type predicate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'qualified' missing — same issue as NextVersion.md line 185

"a right-hand operand that is exactly NULL/TRUE/FALSE/UNKNOWN, or a parenthesized (ClassName), keeps its original meaning"

The note correctly warns about the disambiguation, but omits the key constraint: the type-predicate path requires a qualified class name (schema.Class). An unqualified name like IS (MyClass) — even if MyClass is a real ECClass — has always resolved as a value expression. The sqlbison.y comment (imodel-native#1476) already says this precisely; this note should match it.

Additionally, the example x IS y + 1 vs x IS (y + 1) doesn't illustrate the real disambiguation risk — (y + 1) cannot be parsed as a type predicate regardless of parentheses. The genuine risk is a qualified identifier: IS (schema.myProp) where schema.myProp looks like a class reference. Optionally reword to:

> Note: … Those forms take precedence: a right-hand operand that is exactly
> `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, a **qualified** class name in parentheses
> (e.g. `(schema.Class)`), an `ONLY`/`ALL`-prefixed form, or a comma-separated list
> keeps its original meaning. A single unqualified name in parentheses (e.g. `(MyProp)`)
> is always a value expression, even if a class by that name exists. To avoid ambiguity,
> write qualified names without outer parentheses (e.g. `x IS schema.prop` rather than
> `x IS (schema.prop)`).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ECSQL does not support unqualified class names. So I am not sure why we have to explicitly say qualified.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Partially addressedqualified was added and the Note is significantly improved. One remaining issue: the parenthetical "(if a name is both a class and a property, the property reading wins)" contradicts imodel-native's CHANGES.md which says "the class (type-predicate) reading wins" for the qualified case. Both can be reconciled in context (unqualified names are never classes in ECSQL, so 'property wins' is vacuously true for that sub-case), but a user reading Operators.md in isolation will take the parenthetical as the general rule and be wrong about qualified ambiguous names. New inline comment posted on line 78 with a rewrite suggestion.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up to my earlier note: you're right that an ECSQL class reference is always schema-qualified — but the reason the note now spells out "qualified" is the parenthesized-operand disambiguation. (bis.Element) takes the type-predicate path, whereas a parenthesized unqualified token like (MyProperty) parses as a value expression (the property). Calling out qualified vs unqualified is what makes that precise, so I incorporated it in 0e701b9 (Operators.md, NextVersion.md, and the test comment). Thanks for pushing on it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in 782b6e6f87. Operators.md now explains why qualification matters, treats unqualified (MyProperty) as a value expression, documents qualified non-class fallback, and preserves type-predicate precedence when a qualified name resolves to a class.

@hl662 hl662 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nambot 🤖 (powered by GPT-5.5)

Comment thread core/backend/src/test/ecdb/ECSqlStatement.test.ts
Comment thread core/backend/src/test/ecdb/ECSqlStatement.test.ts Outdated
Comment thread core/backend/src/test/ecdb/ConcurrentQuery.test.ts Outdated
@aruniverse aruniverse added this to the iTwin.js 5.12 milestone Jun 29, 2026
khanaffan and others added 2 commits June 29, 2026 15:54
… failure

The negative test for 'string IS point' previously caught any exception,
so unrelated failures (schema setup, typos) would pass it silently. Assert
the specific native type-mismatch message instead, proving the IS operator
is rejected for the intended reason.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@aruniverse aruniverse left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two documentation gaps found after reviewing the IS (alias.prop) semantic fallback added in imodel-native commit 593ff15eb9. Both are in the itwinjs-core changelog/reference docs and don't reflect the full new behavior.


Both operands must be type-compatible, following the same rules as `=` and `<>`: comparable primitive types (for example two strings, or numeric types compared with each other) or composite types of the same shape (`Point2d` with `Point2d`, a navigation property with a navigation property), with the `NULL` literal allowed against any type. Comparing unrelated types — for example a `string` against a `Point3d` — is rejected when the statement is prepared.

> Note: `IS [NOT]` is also used by the unrelated [ECClass filter](./ECClassFilter.md) predicate (`<classId> IS [NOT] (<class-name>, ...)`) and by the boolean truth tests `IS [NOT] TRUE`/`FALSE`/`UNKNOWN`. These keep their original meaning and take precedence only when the right-hand operand matches their shape: a bare `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized **qualified** class name — optionally with an `ONLY`/`ALL` prefix or written as a comma-separated list (for example `(bis.Element)`, `(ONLY bis.Element)`, or `(bis.Element, bis.Model)`). Any other parenthesized right-hand operand is a value expression: a parenthesized *unqualified* name such as `(MyProperty)` is read as that property (if a name is both a class and a property, the property reading wins), so `prop1 IS (prop2)` and `x IS (y + 1)` are null-safe value comparisons, while `ECClassId IS (bis.Element)` stays a type predicate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tie-breaking parenthetical says 'property reading wins' — contradicts iModelCore/ECDb/CHANGES.md which says 'class reading wins'

Current text in this Note:

"a parenthesized unqualified name such as (MyProperty) is read as that property (if a name is both a class and a property, the property reading wins))"

The imodel-native CHANGES.md (commit 593ff15eb9) says the opposite for the qualified case:

"When a parenthesized qualified name is both a class and a valid property path, the class (type-predicate) reading wins."

Both statements can be technically reconciled (unqualified names are never classes in ECSQL, so 'property wins' is vacuously correct for unqualified names), but a reader who encounters the Operators.md parenthetical will take it as the general rule — and it's wrong for qualified names.

The parenthetical should either be removed or replaced with the correct general rule. Suggested rewrite:

Any other parenthesized right-hand operand is a value expression:
a parenthesized *unqualified* name such as `(MyProperty)` is read as that property (ECSQL
class names must always be schema-qualified, so there is no ambiguity here);
a parenthesized *qualified* name such as `(alias.prop)` that does **not** resolve to a class
is likewise a null-safe value comparison — if it **does** resolve to a class, the
type-predicate reading wins.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 782b6e6f87 — reworded the tie-break: ECSQL class names are always schema-qualified so "property wins" was vacuously true for unqualified names, and I now explicitly state that when a qualified name resolves to a class, the type-predicate reading takes precedence (matching imodel-native CHANGES.md).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in 782b6e6f87. The incorrect general "property reading wins" parenthetical was removed. The note now explicitly says that a qualified name resolving to a class takes the type-predicate reading, matching TryParseParenthesizedNameAsValueExp and native CHANGES.md.

Comment thread docs/changehistory/NextVersion.md Outdated

The ECSQL `IS` and `IS NOT` operators can now be used between two operands — for example `prop1 IS [NOT] prop2`, where each operand may be any value expression: a property, the `NULL` literal, a constant, a parameter, a function call, an arithmetic expression, etc. These map to SQLite's **null-safe** comparison operators, so `NULL IS NULL` is `TRUE` and `1 IS NULL` is `FALSE`, unlike `=`/`<>` which treat a `NULL` operand as _unknown_.

Previously `IS` / `IS NOT` only supported the right-hand operands `NULL`, the boolean literals `TRUE`/`FALSE`/`UNKNOWN`, and the [ECClass type predicate](../learning/ECSqlReference/ECClassFilter.md) (`IS (ClassName)`). Those forms still take precedence — a right-hand operand that is exactly `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized **qualified** class name such as `(bis.Element)` (optionally with an `ONLY`/`ALL` prefix or a comma-separated list), keeps its original meaning. A parenthesized *unqualified* name such as `(prop2)` is instead read as a value expression, so `prop1 IS (prop2)` is a null-safe comparison.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing: the IS (alias.prop) qualified-name fallback is not mentioned here

The current text only covers unqualified names:

"A parenthesized unqualified name such as (prop2) is instead read as a value expression, so prop1 IS (prop2) is a null-safe comparison."

However, imodel-native commit 593ff15eb9 specifically added support for qualified names that don't resolve to a class — e.g. S1 IS (Foo.S2), Status IS (ts.Status.Active), f.S1 IS (f.Info.Code). These were previously rejected with a 'class not found' error. A user upgrading who writes x IS (alias.prop) would find no changelog mention that this is now valid.

Suggested addition after the existing unqualified-name sentence:

A parenthesized *qualified* name (e.g. `(alias.prop)` or `(ts.Status.Active)`) that does not
resolve to a known ECClass is also treated as a null-safe value expression instead of failing
with 'class not found'. When a qualified name is both a valid class and a valid property path,
the type-predicate (class) reading takes precedence.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 782b6e6f87 — added a sentence to NextVersion.md noting that a parenthesized qualified name not resolving to a known ECClass (e.g. (alias.prop), (ts.Status.Active)) is now treated as a null-safe value expression instead of failing with "class not found", and that the type-predicate reading still wins if the qualified name does resolve to a class.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in 782b6e6f87. NextVersion.md now calls out (alias.prop) / (ts.Status.Active) fallback behavior and the class-wins tie-break, so the late native behavior is represented in the user-facing change history.

@mergify

mergify Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

This pull request is now in conflicts. Could you fix it @khanaffan? 🙏
To fixup this pull request, you can check out it locally. See documentation: https://help.github.com/articles/checking-out-pull-requests-locally/

khanaffan and others added 2 commits July 7, 2026 14:51
…S operator

- Operators.md: fix parenthetical that incorrectly implied 'property reading wins' is the general rule; ECSQL class names are always schema-qualified, and for qualified names that resolve to a class the type-predicate reading takes precedence.
- Operators.md/NextVersion.md: document that a parenthesized qualified name not resolving to a known ECClass (e.g. (alias.prop)) is treated as a null-safe value expression instead of failing with 'class not found'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mergify

mergify Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

This pull request is now in conflicts. Could you fix it @khanaffan? 🙏
To fixup this pull request, you can check out it locally. See documentation: https://help.github.com/articles/checking-out-pull-requests-locally/


Both operands must be type-compatible, following the same rules as `=` and `<>`: comparable primitive types (for example two strings, or numeric types compared with each other) or composite types of the same shape (`Point2d` with `Point2d`, a navigation property with a navigation property), with the `NULL` literal allowed against any type. Comparing unrelated types — for example a `string` against a `Point3d` — is rejected when the statement is prepared.

> Note: `IS [NOT]` is also used by the unrelated [ECClass filter](./ECClassFilter.md) predicate (`<classId> IS [NOT] (<class-name>, ...)`) and by the boolean truth tests `IS [NOT] TRUE`/`FALSE`/`UNKNOWN`. These keep their original meaning and take precedence only when the right-hand operand matches their shape: a bare `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized **qualified** class name — optionally with an `ONLY`/`ALL` prefix or written as a comma-separated list (for example `(bis.Element)`, `(ONLY bis.Element)`, or `(bis.Element, bis.Model)`). Any other parenthesized right-hand operand is a value expression: a parenthesized *unqualified* name such as `(MyProperty)` is read as that property (ECSQL class names must always be schema-qualified, so there is no ambiguity here), so `prop1 IS (prop2)` and `x IS (y + 1)` are null-safe value comparisons. A parenthesized *qualified* name that does not resolve to a known ECClass — for example `(ts.Status.Active)` — is likewise read as a value expression, so `Status IS (ts.Status.Active)` is a null-safe comparison; if the qualified name does resolve to a class, the type-predicate reading takes precedence, e.g. `ECClassId IS (bis.Element)` stays a type predicate.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback is limited to dot-qualified names. The linked native implementation deliberately keeps schema:class / tablespace.schema:class colon forms as class-only syntax, even when class lookup fails. Please change "a parenthesized qualified name" to "a parenthesized dot-qualified name" here and in NextVersion.md, so the docs do not imply an unresolved colon form becomes a value expression.

// `S2` is a property, so `S1 IS (S2)` is the null-safe value comparison `S1 IS S2` and matches the same
// 2 rows. The type-predicate form is taken only for a *qualified* class name (e.g. `ts.Foo`), an ONLY/ALL
// prefix, or a comma-separated class list, as in the `ECClassId IS (ts.Foo)` regression check below; when a
// name is both a class and a property, the property (value-expression) reading wins.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still states the old general tie-break. For an unqualified token like S2, ECSQL class references are not possible, so it simply takes the property/value-expression reading. For a qualified name that is both a class and a property path, the parser checks the class first and the type-predicate reading wins—as the updated docs now correctly say. Please reword this sentence to preserve that distinction.

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.

ECSQL: support IS / IS NOT operator between operands (null-safe comparison)

3 participants