Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@itwin/core-backend",
"comment": "Added tests and documentation for the new ECSQL `IS` / `IS NOT` operator (null-safe comparison between value expressions).",
"type": "none"
Comment thread
aruniverse marked this conversation as resolved.
}
],
"packageName": "@itwin/core-backend"
}
2 changes: 1 addition & 1 deletion core/backend/src/test/ecdb/ConcurrentQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("ConcurrentQuery", () => {
expect(resp.stats.cpuTime).to.be.closeTo(1000970, 500000);
expect(resp.stats.totalTime).to.be.closeTo(1001, 100);
expect(resp.stats.memUsed).to.be.closeTo(2, 3);
expect(resp.stats.prepareTime).to.be.closeTo(0, 2);
expect(resp.stats.prepareTime).to.be.closeTo(0, 20);
Comment thread
hl662 marked this conversation as resolved.
Outdated
db.close();
});

Expand Down
146 changes: 145 additions & 1 deletion core/backend/src/test/ecdb/ECSqlStatement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { assert } from "chai";
import { DbResult, Guid, GuidString, Id64, Id64String } from "@itwin/core-bentley";
import { NavigationValue, QueryBinder, QueryOptions, QueryOptionsBuilder, QueryRowFormat } from "@itwin/core-common";
import { NavigationBindingValue, NavigationValue, QueryBinder, QueryOptions, QueryOptionsBuilder, QueryRowFormat } from "@itwin/core-common";
import { Point2d, Point3d, Range3d, XAndY, XYAndZ } from "@itwin/core-geometry";
import { _nativeDb, ECDb, ECEnumValue, ECSqlColumnInfo, ECSqlInsertResult, ECSqlStatement, ECSqlValue, ECSqlWriteStatement, SnapshotDb } from "../../core-backend";
import { IModelTestUtils } from "../IModelTestUtils";
Expand Down Expand Up @@ -3594,5 +3594,149 @@ describe("ECSqlStatement", () => {
testECSqlWithBinders(11, "SELECT * from test.Child where Friends = ? and Parent = ?", "Test.ParentHasChildren", "Test.ChildHasFriends", true, "", false);
});
});

describe("IS / IS NOT operators between operands", () => {

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.

This adds another feature-sized block to an already sprawling 3.7k-line catch-all test file. I think there's a code-judo move here: split this into a focused ECSQL IS-operator test module with small helpers for schema setup/inserts/count assertions, and put parser/normalization coverage next to the existing IS NULL / IS (type) cases in ECSqlAst.test.ts. That would make the new behavior easier to find and avoid continuing to grow this file as the default home for unrelated ECSQL coverage.

Nambot 🤖 (powered by GPT-5.5)

it("compares primitive operands with null-safe semantics", async () => {
using ecdb = ECDbTestHelper.createECDb(outDir, "isOperatorNullSafe.ecdb",
`<ECSchema schemaName="TestSchema" alias="ts" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1">
<ECEntityClass typeName="Foo" modifier="None">
<ECProperty propertyName="S1" typeName="string"/>
<ECProperty propertyName="S2" typeName="string"/>
<ECProperty propertyName="P1" typeName="point3d"/>
</ECEntityClass>
</ECSchema>`);
assert.isTrue(ecdb.isOpen);

const insert = (ecsql: string) => {
const res = ecdb.withCachedWriteStatement(ecsql, (stmt: ECSqlWriteStatement) => stmt.stepForInsert());
assert.equal(res.status, DbResult.BE_SQLITE_DONE);
};
// (S1, S2): equal-nonnull, different, both-null, one-null, the-other-null
insert("INSERT INTO ts.Foo(S1,S2) VALUES('a','a')");
insert("INSERT INTO ts.Foo(S1,S2) VALUES('a','b')");
insert("INSERT INTO ts.Foo(S1,S2) VALUES(NULL,NULL)");
insert("INSERT INTO ts.Foo(S1,S2) VALUES(NULL,'b')");
insert("INSERT INTO ts.Foo(S1,S2) VALUES('a',NULL)");
ecdb.saveChanges();

// null-safe equality: matches ('a','a') and (NULL,NULL)
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS S2"), 2);
// null-safe inequality: matches ('a','b'), (NULL,'b'), ('a',NULL)
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS NOT S2"), 3);
// contrast: regular '=' treats NULL comparisons as unknown -> only ('a','a')
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 = S2"), 1);
// NULL literal operand on either side keeps the existing IS NULL / IS NOT NULL behavior
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS NULL"), 2);
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS NOT NULL"), 3);
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE NULL IS S2"), 2);

// The operands may be ANY value expression, not just a property or the NULL literal.
// String literal operand: matches rows where S1 IS 'a' (null-safe).
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS 'a'"), 3);
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS NOT 'a'"), 2);
// Function call operand (LOWER(S2) equals S2 for these lowercase values).
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS LOWER(S2)"), 2);
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS NOT LOWER(S2)"), 3);
// Function call on the left-hand side.
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE LOWER(S1) IS S2"), 2);
// A parenthesized unqualified name is a value expression here, not an IS (ClassName) type predicate:
// `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 when the parenthesized name resolves to an ECClass, as in
// the `ECClassId IS (ts.Foo)` regression check below.
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS (S2)"), 2);
Comment thread
aruniverse marked this conversation as resolved.
// Parameter operand, bound to a value and to NULL (null-safe either way).
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS ?", new QueryBinder().bindString(1, "a")), 3);
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS ?", new QueryBinder().bindNull(1)), 2);
// Regression: the IS (ClassName) type predicate is unaffected (all rows are ts.Foo).
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE ECClassId IS (ts.Foo)"), 5);

// operands of incompatible types are rejected (string vs point)
let threw = false;

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.

This negative assertion is too loose: any exception makes the test pass, including unrelated failures from schema setup, query wrapping, or a typo. Can we assert the specific prepare failure/message/status instead, like the explicit failure checks elsewhere in this file? The test should prove that incompatible IS operands are rejected for the intended reason, not just that something threw.

Nambot 🤖 (powered by GPT-5.5)

try {
await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE S1 IS P1");
} catch {
threw = true;
}
assert.isTrue(threw, "IS between incompatible types (string vs point) should fail to prepare");
});

it("expands point operands column-wise (IS joins with AND, IS NOT with OR)", async () => {
using ecdb = ECDbTestHelper.createECDb(outDir, "isOperatorPoints.ecdb",
`<ECSchema schemaName="TestSchema" alias="ts" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1">
<ECEntityClass typeName="Foo" modifier="None">
<ECProperty propertyName="P1" typeName="point3d"/>
<ECProperty propertyName="P2" typeName="point3d"/>
</ECEntityClass>
</ECSchema>`);
assert.isTrue(ecdb.isOpen);

const insertPoints = (p1?: Point3d, p2?: Point3d) => {
ecdb.withCachedWriteStatement("INSERT INTO ts.Foo(P1,P2) VALUES(?,?)", (stmt: ECSqlWriteStatement) => {
if (p1) stmt.bindPoint3d(1, p1); else stmt.bindNull(1);
if (p2) stmt.bindPoint3d(2, p2); else stmt.bindNull(2);
assert.equal(stmt.stepForInsert().status, DbResult.BE_SQLITE_DONE);
});
};
insertPoints(new Point3d(1, 2, 3), new Point3d(1, 2, 3)); // all columns equal
insertPoints(new Point3d(1, 2, 3), new Point3d(1, 2, 9)); // differ in Z only
insertPoints(undefined, undefined); // both NULL (all columns NULL)
insertPoints(new Point3d(1, 2, 3), undefined); // one operand NULL
ecdb.saveChanges();

// IS is true only when every column matches (null-safe): equal point + both-null
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE P1 IS P2"), 2);
// IS NOT is true when any column differs: differ-in-Z + one-null
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Foo WHERE P1 IS NOT P2"), 2);
});

it("expands navigation operands column-wise over Id and RelECClassId", async () => {
using ecdb = ECDbTestHelper.createECDb(outDir, "isOperatorNav.ecdb",
`<ECSchema schemaName="TestSchema" alias="ts" version="01.00.00" xmlns="http://www.bentley.com/schemas/Bentley.ECXML.3.1">
<ECEntityClass typeName="Parent" modifier="None">
<ECProperty propertyName="Name" typeName="string"/>
</ECEntityClass>
<ECEntityClass typeName="Child" modifier="None">
<ECNavigationProperty propertyName="ParentA" relationshipName="ParentAOwnsChildren" direction="Backward"/>
<ECNavigationProperty propertyName="ParentB" relationshipName="ParentBRefsChildren" direction="Backward"/>
</ECEntityClass>
<ECRelationshipClass typeName="ParentAOwnsChildren" strength="embedding" modifier="None">
<Source multiplicity="(0..1)" roleLabel="owns" polymorphic="true"><Class class="Parent"/></Source>
<Target multiplicity="(0..*)" roleLabel="owned by" polymorphic="true"><Class class="Child"/></Target>
</ECRelationshipClass>
<ECRelationshipClass typeName="ParentBRefsChildren" strength="referencing" modifier="None">
<Source multiplicity="(0..1)" roleLabel="refs" polymorphic="true"><Class class="Parent"/></Source>
<Target multiplicity="(0..*)" roleLabel="ref by" polymorphic="true"><Class class="Child"/></Target>
</ECRelationshipClass>
</ECSchema>`);
assert.isTrue(ecdb.isOpen);

const parentId = ecdb.withCachedWriteStatement("INSERT INTO ts.Parent(Name) VALUES('P')", (stmt: ECSqlWriteStatement) => {
const res = stmt.stepForInsert();
assert.equal(res.status, DbResult.BE_SQLITE_DONE);
return res.id!;
});
const parentA: NavigationBindingValue = { id: parentId, relClassName: "TestSchema.ParentAOwnsChildren" };
const parentB: NavigationBindingValue = { id: parentId, relClassName: "TestSchema.ParentBRefsChildren" };

const insertChild = (a?: NavigationBindingValue, b?: NavigationBindingValue) => {
ecdb.withCachedWriteStatement("INSERT INTO ts.Child(ParentA,ParentB) VALUES(?,?)", (stmt: ECSqlWriteStatement) => {
if (a) stmt.bindNavigation(1, a); else stmt.bindNull(1);
if (b) stmt.bindNavigation(2, b); else stmt.bindNull(2);
assert.equal(stmt.stepForInsert().status, DbResult.BE_SQLITE_DONE);
});
};
insertChild(undefined, undefined); // both nav values fully NULL
insertChild(parentA, undefined); // only ParentA set
insertChild(parentA, parentB); // same target Id but different RelECClassId
ecdb.saveChanges();

// IS requires both Id AND RelECClassId to match: only the both-NULL child qualifies.
// The (parentA, parentB) child points at the same parent Id but via different
// relationship classes, so its RelECClassId values differ and it is not "equal".
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Child WHERE ParentA IS ParentB"), 1);
// IS NOT matches when either Id OR RelECClassId differs: the other two children.
assert.equal(await queryCount(ecdb, "SELECT ECInstanceId FROM ts.Child WHERE ParentA IS NOT ParentB"), 2);
});
});
});

114 changes: 114 additions & 0 deletions core/backend/src/test/ecsql/queries/Comparison.ecsql.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,117 @@ SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, i, l, d, b, dt, s, bi
| 0x1b | AllProperties:TestElement | 107 | 1007 | 7.1 | true | 2010-01-01T11:11:11.000 | str7 | BIN(11,21,31,34,53,21,14,14,55,22) | {"X": 1111.11, "Y": 2222.22} | {"X": -111.11, "Y": -222.22, "Z": -333.33} |
| 0x1c | AllProperties:TestElement | 108 | 1008 | 8.1 | true | 2017-01-01T00:00:00.000 | str8 | BIN(1,2,3) | {"X": 1.034, "Y": 2.034} | {"X": -1, "Y": 2.3, "Z": 3.0001} |
| 0x1d | AllProperties:TestElement | 109 | 1009 | 9.1 | true | 2010-01-01T11:11:11.000 | str9 | BIN(11,21,31,34,53,21,14,14,55,22) | {"X": 1111.11, "Y": 2222.22} | {"X": -111.11, "Y": -222.22, "Z": -333.33} |

# IS operator with string literal

- dataset: AllProperties.bim

The `IS` operator performs a null-safe comparison between two value expressions. Here the right-hand operand is a string literal, so this matches the rows whose `NullProp` equals `'NotNull'`.

```sql
SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, NullProp FROM aps.TestElement WHERE NullProp IS 'NotNull'
```

| className | accessString | generated | index | jsonName | name | extendedType | typeName | type | originPropertyName |
| ------------------------- | ------------ | --------- | ----- | --------- | ------------ | ------------ | -------- | ------ | ------------------ |
| | ECInstanceId | false | 0 | id | ECInstanceId | Id | long | Id | ECInstanceId |
| | ClassName | true | 1 | className | ClassName | undefined | string | String | undefined |
| AllProperties:TestElement | NullProp | false | 2 | nullProp | NullProp | undefined | string | String | NullProp |

| ECInstanceId | ClassName | NullProp |
| ------------ | ------------------------- | -------- |
| 0x15 | AllProperties:TestElement | NotNull |
| 0x17 | AllProperties:TestElement | NotNull |
| 0x19 | AllProperties:TestElement | NotNull |
| 0x1b | AllProperties:TestElement | NotNull |
| 0x1d | AllProperties:TestElement | NotNull |

# IS NOT operator with string literal (null-safe)

- dataset: AllProperties.bim

Unlike `<>`, the `IS NOT` operator treats `NULL` as a comparable value, so the rows where `NullProp` is `NULL` (the even-indexed elements) are returned here. A `NullProp <> 'NotNull'` filter would exclude them because `NULL <> 'NotNull'` is _unknown_.

```sql
SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, NullProp FROM aps.TestElement WHERE NullProp IS NOT 'NotNull'
```

| className | accessString | generated | index | jsonName | name | extendedType | typeName | type | originPropertyName |
| ------------------------- | ------------ | --------- | ----- | --------- | ------------ | ------------ | -------- | ------ | ------------------ |
| | ECInstanceId | false | 0 | id | ECInstanceId | Id | long | Id | ECInstanceId |
| | ClassName | true | 1 | className | ClassName | undefined | string | String | undefined |
| AllProperties:TestElement | NullProp | false | 2 | nullProp | NullProp | undefined | string | String | NullProp |

| ECInstanceId | ClassName | NullProp |
| ------------ | ------------------------- | --------- |
| 0x14 | AllProperties:TestElement | undefined |
| 0x16 | AllProperties:TestElement | undefined |
| 0x18 | AllProperties:TestElement | undefined |
| 0x1a | AllProperties:TestElement | undefined |
| 0x1c | AllProperties:TestElement | undefined |

# IS operator with integer literal

- dataset: AllProperties.bim

The operands of `IS` may be any value expression, including a numeric literal.

```sql
SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, I FROM aps.TestElement WHERE I IS 104
```

| className | accessString | generated | index | jsonName | name | extendedType | typeName | type | originPropertyName |
| ------------------------ | ------------ | --------- | ----- | --------- | ------------ | ------------ | -------- | ------ | ------------------ |
| | ECInstanceId | false | 0 | id | ECInstanceId | Id | long | Id | ECInstanceId |
| | ClassName | true | 1 | className | ClassName | undefined | string | String | undefined |
| AllProperties:IPrimitive | i | false | 2 | i | i | undefined | int | Int | i |

| ECInstanceId | ClassName | i |
| ------------ | ------------------------- | --- |
| 0x18 | AllProperties:TestElement | 104 |

# IS operator with function call

- dataset: AllProperties.bim

The right-hand operand may also be a function call (here `LOWER('STR0')`, which evaluates to `str0`).

```sql
SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, S FROM aps.TestElement WHERE S IS LOWER('STR0')
```

| className | accessString | generated | index | jsonName | name | extendedType | typeName | type | originPropertyName |
| ------------------------ | ------------ | --------- | ----- | --------- | ------------ | ------------ | -------- | ------ | ------------------ |
| | ECInstanceId | false | 0 | id | ECInstanceId | Id | long | Id | ECInstanceId |
| | ClassName | true | 1 | className | ClassName | undefined | string | String | undefined |
| AllProperties:IPrimitive | s | false | 2 | s | s | undefined | string | String | s |

| ECInstanceId | ClassName | s |
| ------------ | ------------------------- | ---- |
| 0x14 | AllProperties:TestElement | str0 |

# IS operator with parameter binding

- dataset: AllProperties.bim

The right-hand operand may be a parameter. Bound to `'NotNull'`, this matches the same rows as the string-literal form above.

```sql
SELECT ECInstanceId, ec_classname(ECClassId) as ClassName, NullProp FROM aps.TestElement WHERE NullProp IS ?
```

- bindString 1, NotNull

| className | accessString | generated | index | jsonName | name | extendedType | typeName | type | originPropertyName |
| ------------------------- | ------------ | --------- | ----- | --------- | ------------ | ------------ | -------- | ------ | ------------------ |
| | ECInstanceId | false | 0 | id | ECInstanceId | Id | long | Id | ECInstanceId |
| | ClassName | true | 1 | className | ClassName | undefined | string | String | undefined |
| AllProperties:TestElement | NullProp | false | 2 | nullProp | NullProp | undefined | string | String | NullProp |

| ECInstanceId | ClassName | NullProp |
| ------------ | ------------------------- | -------- |
| 0x15 | AllProperties:TestElement | NotNull |
| 0x17 | AllProperties:TestElement | NotNull |
| 0x19 | AllProperties:TestElement | NotNull |
| 0x1b | AllProperties:TestElement | NotNull |
| 0x1d | AllProperties:TestElement | NotNull |
2 changes: 1 addition & 1 deletion core/backend/src/test/ecsql/queries/Misc.ecsql.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@ PRAGMA ecsql_ver

| ecsql_ver |
| --------- |
| 2.0.3.2 |
| 2.0.4.0 |

# Trying PRAGMA sqlite_sql with a simple select

Expand Down
Loading
Loading