diff --git a/src/ifcopenshell-python/ifcopenshell/express/nodes.py b/src/ifcopenshell-python/ifcopenshell/express/nodes.py index 2b8b872c83..bcba01d8f1 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/nodes.py +++ b/src/ifcopenshell-python/ifcopenshell/express/nodes.py @@ -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() diff --git a/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py b/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py index 38fa867778..7cce29f948 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rule_compiler.py @@ -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) diff --git a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC2X3.py b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC2X3.py index bdcf863e48..a57e33f647 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC2X3.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC2X3.py @@ -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 \ No newline at end of file + 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',))]} diff --git a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4.py b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4.py index 7b931af2e9..ac49248422 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4.py @@ -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 \ No newline at end of file + 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',))]} diff --git a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X1.py b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X1.py index b07e9a2234..cc1c550b45 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X1.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X1.py @@ -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 \ No newline at end of file + 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',))]} diff --git a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X2.py b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X2.py index 07f6a261e7..1ee404d68c 100644 --- a/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X2.py +++ b/src/ifcopenshell-python/ifcopenshell/express/rules/IFC4X2.py @@ -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 \ No newline at end of file + 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',))]} diff --git a/src/ifcopenshell-python/ifcopenshell/validate.py b/src/ifcopenshell-python/ifcopenshell/validate.py index 524dc78d35..6b1c1947cc 100644 --- a/src/ifcopenshell-python/ifcopenshell/validate.py +++ b/src/ifcopenshell-python/ifcopenshell/validate.py @@ -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/.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 diff --git a/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-property-enumeration-name-ifc4.ifc b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-property-enumeration-name-ifc4.ifc new file mode 100644 index 0000000000..9e44c0c8a1 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/fail-duplicated-property-enumeration-name-ifc4.ifc @@ -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; diff --git a/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-property-enumeration-name-ifc4.ifc b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-property-enumeration-name-ifc4.ifc new file mode 100644 index 0000000000..567f065153 --- /dev/null +++ b/src/ifcopenshell-python/test/fixtures/validate/pass-not-duplicated-property-enumeration-name-ifc4.ifc @@ -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;