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", () => {
Comment thread
hl662 marked this conversation as resolved.
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;
Comment thread
hl662 marked this conversation as resolved.
Outdated
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