mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-10 01:41:57 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Vendored
+11
@@ -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;
|
||||
+11
@@ -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;
|
||||
Reference in New Issue
Block a user