Compare commits

...

1 Commits

Author SHA1 Message Date
Petru Conduraru 5d439f2729 Source UNIQUE-clause validation from express_parser (#4499 review)
Per review, do not hard-code the uniqueness rules. express_parser already
captures each entity's UNIQUE clauses (nodes.entity_definition.unique), but
they were dropped at codegen. Emit them, via the same pipeline that already
produces the WHERE rules, into the generated express/rules/<SCHEMA>.py
modules, and have validate read them from there so the data tracks the
EXPRESS schema.

- express/nodes.py: capture the full attribute list per UNIQUE clause
  (label, (attrs...)); previously only the first attribute was kept, which
  silently dropped compound clauses such as IfcApplication.UR2
  (ApplicationFullName, Version).
- express/rule_compiler.py: collect_uniqueness_rules / format_uniqueness_rules
  emit a uniqueness_rules dict into every generated rules module.
- express/rules/{IFC2X3,IFC4,IFC4X1,IFC4X2}.py: carry the generated dict.
- validate.py: get_uniqueness_rules reads it statically (ast, cached, no
  heavy import; graceful {} if absent) and validate_uniqueness_rules applies
  it generically. IfcRoot.UR1 and IfcApplication keep their dedicated paths
  to avoid double-reporting.

Verified: get_uniqueness_rules('IFC4') returns IfcApplication /
IfcPropertyEnumeration / IfcRoot from the rules module; a duplicate
IfcPropertyEnumeration name (IFC4) and IFC2X3 UR clauses are flagged, valid
files clean, no double-reporting. All test/test_validate.py fixtures (40)
pass; black and ruff clean.

Refs #4499. IFC4X3 variants gain the dict automatically on the next full
rules regeneration; validate no-ops gracefully until then.

Generated with the assistance of an AI coding tool.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:39:36 +03:00
9 changed files with 217 additions and 7 deletions
@@ -182,11 +182,25 @@ class EntityDeclaration(Node):
self.where = [(r.simple_id, format_clause(r.expression[0])) for r in clause[1::2]]
# Each entity-level UNIQUE clause is captured as (label, attributes)
# where label may be None and attributes is a tuple of the referenced
# attribute names. A rule may reference more than one attribute, e.g.
# ``UR2 : ApplicationFullName, Version;`` (a compound uniqueness rule).
self.unique = []
clause = [r for r in self.entity_body[0] if r.rule == "unique_clause"]
if clause:
clause = clause[0]
self.unique = [(r[0], r[2].simple_id) for r in map(list, list(clause)[1::2])]
for rule in map(list, list(clause)[1::2]):
if len(rule) >= 2 and rule[1] == ":":
label = rule[0]
attribute_tokens = rule[2:]
else:
label = None
attribute_tokens = rule
# Referenced attributes are Node instances (they carry a
# ``simple_id``); labels and the ``:``/``,`` punctuation are str.
attributes = tuple(t.simple_id for t in attribute_tokens if not isinstance(t, str))
self.unique.append((label, attributes))
def __repr__(self):
strm = io.StringIO()
@@ -222,8 +236,9 @@ class EntityDeclaration(Node):
if self.unique:
print(" UNIQUE", file=strm)
for nm_exp in self.unique:
print(" %s : %s;" % nm_exp, file=strm)
for label, attributes in self.unique:
prefix = "%s : " % label if label else ""
print(" %s%s;" % (prefix, ", ".join(attributes)), file=strm)
print("END_ENTITY;", file=strm)
return strm.getvalue()
@@ -792,6 +792,34 @@ class AttributeGetattrTransformer(ast.NodeTransformer):
child.parent = node
def collect_uniqueness_rules(schema):
"""Enumerate the entity-level EXPRESS ``UNIQUE`` clauses of a parsed schema.
``schema`` is an ``ifcopenshell.express.express_parser`` schema (the pyparsing
AST wrapper), whose entity definitions expose ``.unique`` as a list of
``(label, attributes)`` pairs. Returns an ordered mapping::
{entity_name: [(rule_label, (attribute_name, ...)), ...], ...}
limited to entities that actually declare a ``UNIQUE`` clause. This is the
single source of truth used both to emit ``uniqueness_rules`` into the
generated schema rules module and, at runtime, to drive uniqueness
validation, so the data always tracks the EXPRESS schema rather than a
hand-maintained table.
"""
rules = {}
for name, entity in schema.entities.items():
unique = getattr(entity, "unique", None)
if unique:
rules[name] = [(label, tuple(attributes)) for label, attributes in unique]
return rules
def format_uniqueness_rules(schema):
"""Render :func:`collect_uniqueness_rules` as an assignable Python literal."""
return "uniqueness_rules = " + repr(collect_uniqueness_rules(schema))
if __name__ == "__main__":
import io
import sys
@@ -1013,6 +1041,10 @@ INDETERMINATE = indeterminate_type()
sep="\n",
)
# Entity-level UNIQUE clauses are not retained by the compiled C++ schema, so
# emit them here (straight from express_parser) for ifcopenshell.validate.
print(format_uniqueness_rules(schema), "\n", file=output, sep="\n")
for nm in schema.all_declarations.keys():
print(nm)
@@ -8127,4 +8127,8 @@ def IfcVectorSum(arg1, arg2):
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
else:
result = IfcVector(Orientation=vec1, Magnitude=0.0)
return result
return result
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
uniqueness_rules = {'IfcActionRequest': [('UR2', ('RequestID',))], 'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcCostSchedule': [('UR2', ('ID',))], 'IfcFuelProperties': [('UR11', ('Material',))], 'IfcGeneralMaterialProperties': [('UR11', ('Material',))], 'IfcHygroscopicMaterialProperties': [('UR11', ('Material',))], 'IfcMechanicalMaterialProperties': [('UR11', ('Material',))], 'IfcOpticalMaterialProperties': [('UR11', ('Material',))], 'IfcOrderAction': [('UR2', ('ActionID',))], 'IfcPermit': [('UR2', ('PermitID',))], 'IfcProductsOfCombustionProperties': [('UR11', ('Material',))], 'IfcProjectOrder': [('UR2', ('ID',))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))], 'IfcThermalMaterialProperties': [('UR11', ('Material',))], 'IfcWaterProperties': [('UR11', ('Material',))]}
@@ -12226,4 +12226,8 @@ def IfcVectorSum(arg1, arg2):
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
else:
result = IfcVector(Orientation=vec1, Magnitude=0.0)
return result
return result
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
@@ -12383,4 +12383,8 @@ def IfcVectorSum(arg1, arg2):
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
else:
result = IfcVector(Orientation=vec1, Magnitude=0.0)
return result
return result
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
@@ -12675,4 +12675,8 @@ def IfcVectorSum(arg1, arg2):
result = IfcVector(Orientation=res, Magnitude=sqrt(mag))
else:
result = IfcVector(Orientation=vec1, Magnitude=0.0)
return result
return result
# Entity-level EXPRESS UNIQUE clauses, emitted from express_parser for ifcopenshell.validate.
uniqueness_rules = {'IfcApplication': [('UR1', ('ApplicationIdentifier',)), ('UR2', ('ApplicationFullName', 'Version'))], 'IfcPropertyEnumeration': [('UR1', ('Name',))], 'IfcRoot': [('UR1', ('GlobalId',))]}
@@ -46,6 +46,7 @@ Can be used to run validation on IFC file from the command line:
from __future__ import annotations
import argparse
import ast
import functools
import itertools
import json
@@ -593,6 +594,11 @@ def validate(f: Union[ifcopenshell.file, str], logger: Union[Logger, json_logger
else:
logger.error("For instance:\n %s\n%s", inst, e)
if isinstance(logger, json_logger):
logger.set_state("instance", None)
logger.set_state("attribute", None)
validate_uniqueness_rules(f, logger, schema)
if filename:
# IfcOpenShell uses lazy-loading, so entity instance
# attributes aren't parsed yet, and counts aren't verified yet.
@@ -761,6 +767,125 @@ def validate_ifc_applications(f: ifcopenshell.file, logger: Union[Logger, json_l
)
@functools.lru_cache(maxsize=None)
def get_uniqueness_rules(schema_identifier: str) -> dict[str, list[tuple[str, tuple[str, ...]]]]:
"""Return the entity-level EXPRESS ``UNIQUE`` clauses for a schema.
The data is read from the ``uniqueness_rules`` mapping emitted into the
generated schema rules module (``ifcopenshell/express/rules/<schema>.py``)
by ``ifcopenshell.express.rule_compiler``, i.e. straight from
``express_parser`` rather than a hard-coded table. The assignment is
extracted statically (without importing the large rules module) and
literal-evaluated. Returns an empty mapping when the rules module or the
``uniqueness_rules`` assignment is absent (e.g. a schema whose rules have
not been regenerated yet), so validation degrades gracefully.
"""
rules_dir = os.path.join(os.path.dirname(ifcopenshell.express.rule_executor.__file__), "rules")
rules_path = os.path.join(rules_dir, f"{schema_identifier}.py")
try:
with open(rules_path, "r") as rules_file:
source = rules_file.read()
except OSError:
return {}
if "uniqueness_rules" not in source:
return {}
try:
module = ast.parse(source)
except SyntaxError:
return {}
for node in module.body:
if isinstance(node, ast.Assign) and any(
isinstance(target, ast.Name) and target.id == "uniqueness_rules" for target in node.targets
):
try:
return ast.literal_eval(node.value)
except (ValueError, SyntaxError):
return {}
return {}
def _uniqueness_key_component(value: Any) -> Any:
# An instance reference is unique by identity (its STEP id), not by content.
if isinstance(value, ifcopenshell.entity_instance):
return ("#", value.id())
return value
def validate_uniqueness_rules(
f: ifcopenshell.file,
logger: Union[Logger, json_logger],
schema: schema_definition,
) -> None:
"""Validate entity-level EXPRESS ``UNIQUE`` clauses generically.
Generalises the dedicated ``IfcApplication`` check to every other
entity-level ``UNIQUE`` clause of the active schema. The rules come from
:func:`get_uniqueness_rules` (express_parser driven). ``IfcRoot.UR1`` and
``IfcApplication.UR1/UR2`` keep their dedicated passes and are skipped here
to avoid double reporting.
"""
uniqueness_rules = get_uniqueness_rules(f.schema_identifier)
for entity_name, rules in uniqueness_rules.items():
if entity_name in ("IfcRoot", "IfcApplication"):
continue
try:
insts = f.by_type(entity_name)
except RuntimeError:
# Entity not present in this schema variant.
continue
if not insts:
continue
try:
_, attrs = get_entity_attributes(schema, entity_name)
except Exception:
continue
attr_names = [attr.name() for attr in attrs]
for label, rule_attrs in rules:
try:
indices = tuple(attr_names.index(name) for name in rule_attrs)
except ValueError:
# A referenced attribute is absent in this schema variant.
continue
if len(indices) == 1:
pos: Union[int, tuple[int, ...]] = indices[0]
description = "The attribute %s should be unique" % rule_attrs[0]
else:
pos = indices
description = "The combination of attributes %s should be unique" % " and ".join(rule_attrs)
rule = "Rule %s.%s:\n %s" % (entity_name, label, description)
seen: dict[tuple, ifcopenshell.entity_instance] = {}
for inst in insts:
try:
values = tuple(inst[i] for i in indices)
except Exception:
continue
# Per EXPRESS, a rule is not enforced when a referenced value is
# indeterminate (?), so skip instances with a missing value.
if any(value is None for value in values):
continue
try:
key = tuple(_uniqueness_key_component(value) for value in values)
previous_element = seen.get(key)
except TypeError:
continue
if previous_element is None:
seen[key] = inst
continue
if isinstance(logger, json_logger):
logger.set_state("instance", inst)
logger.error(
"On instance:\n %s\n %s\n%s\nViolated by:\n %s\n %s",
inst,
annotate_inst_attr_pos(inst, pos),
rule,
previous_element,
annotate_inst_attr_pos(previous_element, pos),
)
class LogDetectionHandler(Handler):
message_logged = False
@@ -0,0 +1,11 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('','2022-10-01T16:31:47',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Red'),IFCLABEL('Green')),$);
#2=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Blue')),$);
ENDSEC;
END-ISO-10303-21;
@@ -0,0 +1,11 @@
ISO-10303-21;
HEADER;
FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1');
FILE_NAME('','2022-10-01T16:31:47',(''),(''),'IfcOpenShell 0.7.0','IfcOpenShell 0.7.0','');
FILE_SCHEMA(('IFC4'));
ENDSEC;
DATA;
#1=IFCPROPERTYENUMERATION('Colour',(IFCLABEL('Red'),IFCLABEL('Green')),$);
#2=IFCPROPERTYENUMERATION('Size',(IFCLABEL('Big')),$);
ENDSEC;
END-ISO-10303-21;