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.
This commit is contained in:
Bruno Postle
2026-09-09 20:30:37 +01:00
committed by Dion Moult
parent 1c6362ec31
commit 41390dad92
3 changed files with 58 additions and 18 deletions
@@ -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.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" "``IfcWall, IfcColumn, IfcBeam, IfcFooting, /Pset_.*Common/.LoadBearing=TRUE``", "Any load bearing structure"
@@ -164,27 +164,31 @@ contain any of the following characters:
, . = > < * ! and whitespace , . = > < * ! and whitespace
If yours contains one of them, quote it. Every one of them except ``,`` is a There is one exception, and it is only for a ``{{value}}``: a value that is a
syntax error when left unquoted. The ``,`` is the more dangerous case, because plain decimal number may contain the ``.`` unquoted, so
it does not error: it is read as the separator between two filters. So ``ThermalTransmittance=1.5`` is fine. A ``{{pset}}``, ``{{prop}}``, or
``Name=Foo,IfcWall`` does not look for the literal name ``Foo,IfcWall``, it ``{{keys}}`` containing a ``.`` always needs quoting.
quietly means "named ``Foo`` **and** an ``IfcWall``". Write
``Name="Foo,IfcWall"`` to match the literal value.
The ``.`` is the one most likely to catch you out. It separates a property set Otherwise, if yours contains one of these characters, quote it. Every one of
from a property, so it cannot also appear in an unquoted value, and that makes them except ``,`` is a syntax error when left unquoted. The ``,`` is the more
every decimal number a syntax error unless it is quoted: 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:: .. code-block::
Pset_WallCommon.ThermalTransmittance=1.5 # syntax error Pset_WallCommon.ThermalTransmittance=1.5 # a number, no quotes needed
Pset_WallCommon.ThermalTransmittance="1.5" # correct 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 Quoting a number is still allowed and means exactly the same thing. Either way
``ThermalTransmittance>1`` are fine unquoted while ``ThermalTransmittance>1.5`` the check is not a text comparison - ``>``, ``>=``, ``<``, and ``<=`` compare
is not. Quoting a number does not turn the check into a text comparison - numerically, so ``ThermalTransmittance>0.9`` does match a value of ``1.5``.
``>``, ``>=``, ``<``, and ``<=`` still compare numerically, so
``ThermalTransmittance>"0.9"`` does match a value of ``1.5``.
.. note:: .. note::
@@ -66,8 +66,10 @@ filter_elements_grammar = lark.Lark("""start: filter_group
attribute_name: /[A-Z]\\w+/ attribute_name: /[A-Z]\\w+/
ifc_class: /Ifc\\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]+/ unquoted_string: /[^,.=><*!\\s]+/
decimal_string: SIGNED_DECIMAL
SIGNED_DECIMAL: ["+"|"-"] (INT "." INT | "." INT)
regex_string: "/" /[^\\/]+/ "/" regex_string: "/" /[^\\/]+/ "/"
quoted_string: ESCAPED_STRING quoted_string: ESCAPED_STRING
@@ -1233,6 +1235,8 @@ class FacetTransformer(lark.Transformer):
def value(self, args): def value(self, args):
if args[0].data == "unquoted_string": if args[0].data == "unquoted_string":
return args[0].children[0].value return args[0].children[0].value
elif args[0].data == "decimal_string":
return args[0].children[0].value
elif args[0].data == "quoted_string": elif args[0].data == "quoted_string":
return args[0].children[0].value[1:-1].replace('\\"', '"') return args[0].children[0].value[1:-1].replace('\\"', '"')
elif args[0].data == "regex_string": elif args[0].data == "regex_string":
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>. # along with IfcOpenShell. If not, see <http://www.gnu.org/licenses/>.
import lark
import numpy as np import numpy as np
import pytest import pytest
@@ -307,6 +308,8 @@ class TestFilterElements(test.bootstrap.IFC4):
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Baz": 123}) ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Baz": 123})
assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz=123") == {element} assert subject.filter_elements(self.file, "IfcWall, Foobar.Baz=123") == {element}
ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Bay": 123.3}) 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") 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"]}) ifcopenshell.api.pset.edit_pset(self.file, pset=pset, properties={"Status": ["New"]})
assert subject.filter_elements(self.file, "IfcWall, Pset_WallCommon.Status=New") == {element} 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!*=ar") == {element2}
assert subject.filter_elements(self.file, "IfcWall, Foobar.Foo*=Foo") == set() 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): def test_selecting_by_classification(self):
project = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") project = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject")
element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall")