Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
10 changes: 9 additions & 1 deletion iModelCore/ECDb/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ This document including important changes to syntax or file format.
| Module | Version |
| ------- | --------- |
| Profile | `4.0.0.5` |
| ECSQL | `2.0.3.2` |
| ECSQL | `2.0.4.0` |

## ## `06/23/2026`: Added `IS` / `IS NOT` operator between operands
* ECSql version change `2.0.3.2` -> `2.0.4.0`.
* The `IS` and `IS NOT` operators can now be used between two operands, e.g. `prop1 IS [NOT] prop2`. 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 (`NULL IS NULL` is true, `1 IS NULL` is false).
* Previously `IS` / `IS NOT` only supported the right-hand operands `NULL`, the boolean literals `TRUE`/`FALSE`/`UNKNOWN`, and the class type predicate `IS (ClassName)`. Those forms are unchanged: a right-hand operand that is exactly `NULL`/`TRUE`/`FALSE`/`UNKNOWN`, or a parenthesized name that resolves to a class — a qualified `(schema.Class)`, an `ONLY`/`ALL`-prefixed form, or a comma-separated list — keeps its original type-predicate meaning. A parenthesized name that does **not** resolve to a class, e.g. a property reference `(alias.prop)`, is parsed as an ordinary value expression (null-safe comparison), exactly as it would be without the parentheses. When a parenthesized qualified name is both a class and a valid property path, the class (type-predicate) 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.

Please narrow the fallback wording to dot-qualified names. TryParseParenthesizedNameAsValueExp explicitly excludes the schema:class and tablespace.schema:class colon forms; an unresolved colon-form name remains a type predicate and fails class resolution instead of becoming a value expression. As written, "a parenthesized name that does not resolve to a class" overstates the behavior. Please say dot-qualified (for example alias.prop) and mirror the constraint in the linked core docs.

* For multi-column operands (e.g. `Point2d`/`Point3d` and navigation properties), the comparison is expanded column-wise: `IS` joins the per-column comparisons with `AND`, `IS NOT` joins them with `OR` (consistent with `=` and `<>`).
* Example: `SELECT * FROM bis.Element WHERE CodeValue IS NOT UserLabel`
* Example: `SELECT * FROM bis.Element WHERE CodeValue IS json_extract(JsonProperties, '$.code')`

## ## `04/24/2026`: Allow optional ON clause with CROSS JOIN
* ECSql version change `2.0.3.1` -> `2.0.3.2`.
Expand Down
152 changes: 145 additions & 7 deletions iModelCore/ECDb/ECDb/ECSql/ECSqlParser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2434,20 +2434,61 @@ BentleyStatus ECSqlParser::ParseSearchCondition(std::unique_ptr<BooleanExp>& exp
}
case OSQLParseNode::boolean_test:
{
std::unique_ptr<BooleanExp> op1 = nullptr;
if (SUCCESS != ParseSearchCondition(op1, parseNode->getChild(0/*boolean_primary*/)))
return ERROR;

bool isNot = false;
if (SUCCESS != ParseNotToken(isNot, parseNode->getChild(2/*sql_not*/)))
return ERROR;

const BooleanSqlOperator op = isNot ? BooleanSqlOperator::IsNot : BooleanSqlOperator::Is;
OSQLParseNode const* lhsNode = parseNode->getChild(0/*boolean_primary*/);
OSQLParseNode const* rhsNode = parseNode->getChild(3/*truth_value | value_exp*/);

// A parenthesized qualified name on the right-hand side - e.g. 'X IS (alias.prop)' - parses as a
// '(ClassName)' type predicate because it is grammatically identical to one. If the name does not
// resolve to a class, reinterpret it as a property reference or enum literal so the comparison behaves
// like the null-safe 'X IS alias.prop' / 'X IS ts.Enum.Enumerator' instead of failing with a
// "class not found" error.
std::unique_ptr<ValueExp> rhsAsValueExp = nullptr;
if (SQL_ISRULE(rhsNode, type_predicate) && SUCCESS != TryParseParenthesizedNameAsValueExp(rhsAsValueExp, rhsNode))
return ERROR;

// The right-hand operand of 'X IS [NOT] <rhs>' is either a "truth value"
// (NULL/TRUE/FALSE/UNKNOWN or the (ClassName) type predicate), which keeps the original
// boolean-predicate semantics, or any other value expression, which is a null-safe comparison.
const bool rhsIsTruthValue = rhsAsValueExp == nullptr
&& (SQL_ISRULE(rhsNode, type_predicate)
|| SQL_ISTOKEN(rhsNode, NULL) || SQL_ISTOKEN(rhsNode, TRUE)
|| SQL_ISTOKEN(rhsNode, FALSE) || SQL_ISTOKEN(rhsNode, UNKNOWN));

if (!rhsIsTruthValue)
{
// X IS [NOT] Y : both sides are value expressions compared with SQLite's null-safe IS operator.
// The left side is parsed as a value expression (not a boolean predicate) so that it keeps its real
// type. This matters for operands whose comparability depends on their type (e.g. points and
// navigation properties) and to detect a NULL literal operand for the column-wise expansion.
std::unique_ptr<ValueExp> lhsValueExp = nullptr;
if (SUCCESS != ParseValueExp(lhsValueExp, lhsNode))
return ERROR;

std::unique_ptr<ValueExp> rhsValueExp = nullptr;
if (rhsAsValueExp != nullptr)
rhsValueExp = std::move(rhsAsValueExp);
else if (SUCCESS != ParseValueExp(rhsValueExp, rhsNode))
return ERROR;

exp = std::make_unique<BinaryBooleanExp>(std::move(lhsValueExp), op, std::move(rhsValueExp));
return SUCCESS;
}

// X IS [NOT] NULL|TRUE|FALSE|UNKNOWN|(ClassName) : the left side is a boolean predicate.
std::unique_ptr<BooleanExp> op1 = nullptr;
if (SUCCESS != ParseSearchCondition(op1, lhsNode))
return ERROR;

std::unique_ptr<ValueExp> truthValueExp = nullptr;
if (SUCCESS != ParseTruthValue(truthValueExp, parseNode->getChild(3/*truth_value*/)))
if (SUCCESS != ParseValueExp(truthValueExp, rhsNode))
Comment thread
aruniverse marked this conversation as resolved.
return ERROR;

// X IS [NOT] NULL|TRUE|FALSE|UNKNOWN
exp = std::make_unique<BinaryBooleanExp>(std::move(op1), isNot ? BooleanSqlOperator::IsNot : BooleanSqlOperator::Is, std::move(truthValueExp));
exp = std::make_unique<BinaryBooleanExp>(std::move(op1), op, std::move(truthValueExp));
return SUCCESS;
}
case OSQLParseNode::boolean_primary:
Expand Down Expand Up @@ -3466,6 +3507,103 @@ BentleyStatus ECSqlParser::ParseTypePredicate(std::unique_ptr<ValueExp>& valueEx
return SUCCESS;
}
//-----------------------------------------------------------------------------------------
// In an 'X IS (...)' expression a parenthesized qualified name such as '(alias.prop)' is
// grammatically indistinguishable from the '(ClassName)' type predicate: both reduce to a
// 'type_predicate' node. When the name does not resolve to an existing class, this reinterprets it as
// a property-reference or enum-literal value expression so the statement behaves like the
// null-safe comparison 'X IS alias.prop' / 'X IS ts.Enum.Enumerator' instead of failing with
// a "class not found" error.
// 'valueExp' is left null - so the caller keeps the type-predicate behavior - when the predicate uses
// an ONLY/ALL prefix, a comma-separated type list, the 'schema:class' (colon) form, or a name that
// does resolve to a class (e.g. 'ECClassId IS (ts.Foo)' or 'ECClassId IS (main.ts.Foo)').
// @bsimethod
//+---------------+---------------+---------------+---------------+---------------+------
BentleyStatus ECSqlParser::TryParseParenthesizedNameAsValueExp(std::unique_ptr<ValueExp>& valueExp, OSQLParseNode const* typePredicateNode) const
{
valueExp = nullptr;
if (!SQL_ISRULE(typePredicateNode, type_predicate))

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 method has no code path which returns in ERROR, but I think here if the typePredicateNode is not a type_predicate, we should error out.

return SUCCESS;

OSQLParseNode const* type_list = typePredicateNode->getChild(1/*type_list*/);
if (type_list->count() != 1)
return SUCCESS; // a comma-separated type list is always a genuine type predicate

OSQLParseNode const* type_list_item = type_list->getChild(0);
OSQLParseNode const* opt_only = type_list_item->getChild(0/*opt_only*/);
if (opt_only->count() != 0)
return SUCCESS; // an ONLY/ALL prefix is always a genuine type predicate

OSQLParseNode const* tableNode = type_list_item->getChild(1/*table_node*/);
if (!SQL_ISRULE(tableNode, table_node))

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.

If the tableNode is not of type table_node, shouldn't we error out?

return SUCCESS;

OSQLParseNode const* nameNode = tableNode->getChild(0);
PropertyPath propertyPath;
if (SQL_ISRULE(nameNode, qualified_class_name))
{
// The 'schema:class' (colon) form is class-only syntax and is never a property path.
if (!nameNode->getChild(1/*'.' or ':'*/)->getTokenValue().Equals("."))
return SUCCESS;

Utf8StringCR schemaNameOrAlias = nameNode->getChild(0)->getTokenValue();
OSQLParseNode const* classNameNode = nameNode->getChild(2/*class_name*/);
Utf8StringCR propertyName = classNameNode->getChild(0)->getTokenValue();
if (schemaNameOrAlias.empty() || propertyName.empty())
return SUCCESS;

// If the name resolves to a class, keep the '(ClassName)' type-predicate meaning.
if (m_context->GetECDb().Schemas().GetClass(schemaNameOrAlias, propertyName, SchemaLookupMode::AutoDetect) != nullptr)
return SUCCESS;

propertyPath.Push(schemaNameOrAlias);
propertyPath.Push(propertyName);
}
else if (SQL_ISRULE(nameNode, tablespace_qualified_class_name))
{
Utf8StringCR tableSpaceName = nameNode->getChild(0)->getTokenValue();
OSQLParseNode const* qualifiedNameNode = nameNode->getChild(2/*qualified_class_name*/);
if (!SQL_ISRULE(qualifiedNameNode, qualified_class_name))

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.

Here also same, if qualifiedNameNode is not a qualified_class_name shouldn't we error out or atleast continue with the execution as is but not branch out on SUCCESS? This method just branches out SUCCESS when we are sure that the node is a className somehow, right?

return SUCCESS;

// The 'tablespace.schema:class' (colon) form is class-only syntax and is never a property path.
if (!qualifiedNameNode->getChild(1/*'.' or ':'*/)->getTokenValue().Equals("."))
return SUCCESS;

Utf8StringCR schemaNameOrAlias = qualifiedNameNode->getChild(0)->getTokenValue();
OSQLParseNode const* classNameNode = qualifiedNameNode->getChild(2/*class_name*/);
Utf8StringCR classNameOrPropertyName = classNameNode->getChild(0)->getTokenValue();
if (tableSpaceName.empty() || schemaNameOrAlias.empty() || classNameOrPropertyName.empty())
return SUCCESS;

// If the name resolves to a tablespace-qualified class, keep the '(ClassName)' type-predicate meaning.
if (m_context->GetECDb().Schemas().GetClass(schemaNameOrAlias, classNameOrPropertyName, SchemaLookupMode::AutoDetect, tableSpaceName.c_str()) != nullptr)
return SUCCESS;

propertyPath.Push(tableSpaceName);
propertyPath.Push(schemaNameOrAlias);
propertyPath.Push(classNameOrPropertyName);
}
else
return SUCCESS;

if (propertyPath.Size() == 3)
{
ECEnumerationCP ecEnum = m_context->GetECDb().Schemas().GetEnumeration(propertyPath[0].GetName(), propertyPath[1].GetName(), SchemaLookupMode::AutoDetect);
if (ecEnum != nullptr)
{
ECEnumeratorCP enumerator = ecEnum->FindEnumeratorByName(propertyPath[2].GetName().c_str());
if (enumerator != nullptr)
{
valueExp = std::make_unique<EnumValueExp>(*enumerator, propertyPath);
return SUCCESS;
}
}
}

valueExp = std::make_unique<PropertyNameExp>(propertyPath);
return SUCCESS;
}
//-----------------------------------------------------------------------------------------
// @bsimethod
//+---------------+---------------+---------------+---------------+---------------+------
BentleyStatus ECSqlParser::ParseValueExp(std::unique_ptr<ValueExp>& valueExp, OSQLParseNode const* parseNode) const
Expand Down
2 changes: 1 addition & 1 deletion iModelCore/ECDb/ECDb/ECSql/ECSqlParser.h
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,6 @@ struct ECSqlParser final
BentleyStatus ParseTableNodeWithOptMemberCall(std::unique_ptr<ClassNameExp>&, connectivity::OSQLParseNode const&, ECSqlType, PolymorphicInfo polymorphic, bool disqualifyPrimaryJoin) const;
BentleyStatus ParseTableRef(std::unique_ptr<ClassRefExp>&, connectivity::OSQLParseNode const*, ECSqlType ecsqlType) const;
BentleyStatus ParseTerm(std::unique_ptr<ValueExp>&, connectivity::OSQLParseNode const*) const;
BentleyStatus ParseTruthValue(std::unique_ptr<ValueExp>& exp, connectivity::OSQLParseNode const* node) const { return ParseValueExp(exp, node); }

BentleyStatus ParseSearchCondition(std::unique_ptr<BooleanExp>&, connectivity::OSQLParseNode const*) const;
BentleyStatus ParseSetFct(std::unique_ptr<ValueExp>&, connectivity::OSQLParseNode const&, Utf8StringCR functionName, bool isStandardSetFunction) const;
Expand All @@ -262,6 +261,7 @@ struct ECSqlParser final
BentleyStatus ParseTableValuedFunction(std::unique_ptr<TableValuedFunctionExp>&, connectivity::OSQLParseNode const&) const;
BentleyStatus ParseIIFExp(std::unique_ptr<ValueExp> &valueExp, connectivity::OSQLParseNode const *parseNode) const;
BentleyStatus ParseTypePredicate(std::unique_ptr<ValueExp> &valueExp, connectivity::OSQLParseNode const *parseNode) const;
BentleyStatus TryParseParenthesizedNameAsValueExp(std::unique_ptr<ValueExp> &valueExp, connectivity::OSQLParseNode const *typePredicateNode) const;
BentleyStatus ParseWindowClause(std::unique_ptr<WindowFunctionClauseExp>&, connectivity::OSQLParseNode const*) const;
BentleyStatus ParseWindowDefinitionListExp(std::unique_ptr<WindowDefinitionListExp>&, connectivity::OSQLParseNode const*) const;
BentleyStatus ParseWindowDefinitionExp(std::unique_ptr<WindowDefinitionExp>&, connectivity::OSQLParseNode const*) const;
Expand Down
Loading