From 41390dad92af2aa210f76ecaf988e61e57d1a6c5 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Wed, 9 Sep 2026 20:30:37 +0100 Subject: [PATCH] Accept an unquoted decimal in a selector value Requiring quotes around a decimal was a surprise with no way to predict it: Foobar.Baz>1 works, Foobar.Baz>1.5 is a syntax error, and > >= < <= are almost always used on numbers. Writing Width>"0.2" also reads like a text comparison even though it is not. The "." is excluded from unquoted_string because it separates a pset from a property, but that separator has already been consumed by the time a value is read, so a number is unambiguous in value position. Add a decimal_string alternative to the value rule only. Signed and leading dot forms are accepted, 1. is not, and pset, prop and query keys are unchanged - they still need quoting for a ".". The transformer returns the same string a quoted value would, so =1.5 and ="1.5" are the same query and compare() does the existing type coercion. Nothing that parses today changes meaning; only inputs that used to be a syntax error now work. Update the quoting docs accordingly, and assert the Bay property that the filter test set up but never checked. Generated with the assistance of an AI coding tool. --- .../ifcopenshell-python/selector_syntax.rst | 38 ++++++++++--------- .../ifcopenshell/util/selector.py | 6 ++- .../test/util/test_selector.py | 32 ++++++++++++++++ 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst index cb4628d416..9242908fbb 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst @@ -56,7 +56,7 @@ Filtering is typically used to select any IFC element or type. "``IfcWall, Pset_WallCommon.FireRating=2HR``", "Any 2 hour fire rated wall" - "``IfcWall, Pset_WallCommon.ThermalTransmittance=""1.5""``", "Any wall with a U-value of 1.5. Note the quotes: ``1.5`` contains a ``.``, so unquoted it is a syntax error. See `Quoting values in filters`_." + "``IfcWall, Pset_WallCommon.ThermalTransmittance=1.5``", "Any wall with a U-value of 1.5. A decimal number is the one kind of value that may contain a ``.`` unquoted. See `Quoting values in filters`_." "``IfcWall, IfcColumn, IfcBeam, IfcFooting, /Pset_.*Common/.LoadBearing=TRUE``", "Any load bearing structure" @@ -164,27 +164,31 @@ contain any of the following characters: , . = > < * ! and whitespace -If yours contains one of them, quote it. Every one of them except ``,`` is a -syntax error when left unquoted. The ``,`` is the more dangerous case, because -it does not error: it is read as the separator between two filters. So -``Name=Foo,IfcWall`` does not look for the literal name ``Foo,IfcWall``, it -quietly means "named ``Foo`` **and** an ``IfcWall``". Write -``Name="Foo,IfcWall"`` to match the literal value. +There is one exception, and it is only for a ``{{value}}``: a value that is a +plain decimal number may contain the ``.`` unquoted, so +``ThermalTransmittance=1.5`` is fine. A ``{{pset}}``, ``{{prop}}``, or +``{{keys}}`` containing a ``.`` always needs quoting. -The ``.`` is the one most likely to catch you out. It separates a property set -from a property, so it cannot also appear in an unquoted value, and that makes -every decimal number a syntax error unless it is quoted: +Otherwise, if yours contains one of these characters, quote it. Every one of +them except ``,`` is a syntax error when left unquoted. The ``,`` is the more +dangerous case, because it does not error: it is read as the separator between +two filters. So ``Name=Foo,IfcWall`` does not look for the literal name +``Foo,IfcWall``, it quietly means "named ``Foo`` **and** an ``IfcWall``". +Write ``Name="Foo,IfcWall"`` to match the literal value. + +The ``.`` still separates a property set from a property, so outside of a +number it cannot appear in an unquoted value: .. code-block:: - Pset_WallCommon.ThermalTransmittance=1.5 # syntax error - Pset_WallCommon.ThermalTransmittance="1.5" # correct + Pset_WallCommon.ThermalTransmittance=1.5 # a number, no quotes needed + Pset_WallCommon.ThermalTransmittance>-.5 # signed and leading dot too + Name=v1.2 # syntax error, not a number + Name="v1.2" # correct -Whole numbers are unaffected, which is why ``FireRating=2HR`` and -``ThermalTransmittance>1`` are fine unquoted while ``ThermalTransmittance>1.5`` -is not. Quoting a number does not turn the check into a text comparison - -``>``, ``>=``, ``<``, and ``<=`` still compare numerically, so -``ThermalTransmittance>"0.9"`` does match a value of ``1.5``. +Quoting a number is still allowed and means exactly the same thing. Either way +the check is not a text comparison - ``>``, ``>=``, ``<``, and ``<=`` compare +numerically, so ``ThermalTransmittance>0.9`` does match a value of ``1.5``. .. note:: diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index aa52e76f2b..d3bfc98c48 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -66,8 +66,10 @@ filter_elements_grammar = lark.Lark("""start: filter_group attribute_name: /[A-Z]\\w+/ ifc_class: /Ifc\\w+/ - value: special | quoted_string | regex_string | unquoted_string + value: special | quoted_string | regex_string | decimal_string | unquoted_string unquoted_string: /[^,.=><*!\\s]+/ + decimal_string: SIGNED_DECIMAL + SIGNED_DECIMAL: ["+"|"-"] (INT "." INT | "." INT) regex_string: "/" /[^\\/]+/ "/" quoted_string: ESCAPED_STRING @@ -1233,6 +1235,8 @@ class FacetTransformer(lark.Transformer): def value(self, args): if args[0].data == "unquoted_string": return args[0].children[0].value + elif args[0].data == "decimal_string": + return args[0].children[0].value elif args[0].data == "quoted_string": return args[0].children[0].value[1:-1].replace('\\"', '"') elif args[0].data == "regex_string": diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index c4c7a6ce17..be914033f1 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -16,6 +16,7 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import lark import numpy as np import pytest @@ -307,6 +308,8 @@ class TestFilterElements(test.bootstrap.IFC4): ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Baz": 123}) assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz=123") == {element} ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Bay": 123.3}) + assert subject.filter_elements(self.file, "IfcWall, Foobar.Bay=123.3") == {element} + assert subject.filter_elements(self.file, 'IfcWall, Foobar.Bay="123.3"') == {element} pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Pset_WallCommon") ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["New"]}) assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status=New") == {element} @@ -331,6 +334,35 @@ class TestFilterElements(test.bootstrap.IFC4): assert subject.filter_elements(self.file, "IfcWall, Foobar.Foo!*=ar") == {element2} assert subject.filter_elements(self.file, "IfcWall, Foobar.Foo*=Foo") == set() + def test_selecting_by_property_with_an_unquoted_decimal(self): + element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + element2 = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.pset.add_pset(self.file, product=element, name="Foobar") + ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Baz": 1.5}) + # A decimal needs no quotes. The pset/prop separator has already been + # consumed by the comparison, so a "." is unambiguous in a value. + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz=1.5") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>1") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>1.4") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz<1.4") == set() + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>=1.5") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz<=1.5") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz!=1.5") == {element2} + # Signed and leading dot forms. + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>-1.5") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>+1.4") == {element} + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>.5") == {element} + # Quoting a decimal stays legal and means exactly the same thing. + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz=1.5") == subject.filter_elements( + self.file, 'IfcWall, Foobar.Baz="1.5"' + ) + assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz>1.4") == subject.filter_elements( + self.file, 'IfcWall, Foobar.Baz>"1.4"' + ) + # A dot in a value is still only a number. Anything else needs quotes. + with pytest.raises(lark.exceptions.UnexpectedInput): + subject.filter_elements(self.file, "IfcWall, Foobar.Baz=v1.2") + def test_selecting_by_classification(self): project = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")