ifc_validate/structure/cardinality.rs
1//! Aggregates where the schema expects a scalar, and the reverse.
2//!
3//! # What this can and cannot check
4//!
5//! The EXPRESS parser records *whether* an attribute is an aggregate
6//! (`LIST`/`SET`/`ARRAY`/`BAG`), not its bounds. So `LIST [1:?] OF X` and
7//! `LIST [3:3] OF X` are indistinguishable here: a three-element list where
8//! the schema demands exactly two is **not** caught, and this module does not
9//! pretend otherwise.
10//!
11//! What is caught is the shape mismatch that actually occurs in the wild: a
12//! scalar written where an aggregate belongs, or an aggregate written where a
13//! scalar belongs. Both are usually a writer that guessed the slot layout.
14//!
15//! Bounds checking needs the parser to retain them. Until it does, a bounds
16//! violation is unchecked rather than passed, and
17//! [`crate::where_rule`] counts it.
18
19use ifc_model::{Model, Value};
20use ifc_schema::Schema;
21
22use crate::report::{Finding, Path, Report};
23use crate::type_check::aliased_aggregate;
24
25/// Reports scalar/aggregate shape mismatches against the schema.
26pub fn aggregate_shape(model: &Model, schema: &Schema, report: &mut Report) {
27 let mut ids: Vec<_> = model.iter().map(|(id, _)| id).collect();
28 ids.sort_unstable();
29 for id in ids {
30 let Some(entity) = model.get(id) else {
31 continue;
32 };
33 let declared = schema.attributes(&entity.type_name);
34 for (index, value) in entity.attributes.iter().enumerate() {
35 let Some(attribute) = declared.get(index) else {
36 continue;
37 };
38 // `$` and `*` carry no shape; optionality and derivation are
39 // checked in `required`.
40 if matches!(value, Value::Null | Value::Derived) {
41 continue;
42 }
43 let is_list = matches!(value.unwrap_typed(), Value::List(_));
44 // A defined type can itself be an aggregate: IFC4 declares
45 // `IfcComplexNumber = ARRAY [1:2] OF REAL` and four others. Such
46 // an attribute is declared scalar yet legitimately holds a list,
47 // so the declared type has to be resolved before judging shape.
48 // A typed wrapper names its own type, which overrides the
49 // declared slot type: `NominalValue : IfcValue` is a SELECT, and
50 // only `IFCCOMPLEXNUMBER((0.,0.))` says which member was chosen.
51 let effective_type = match value {
52 Value::Typed { type_name, .. } => type_name.as_ref(),
53 _ => attribute.type_name.as_str(),
54 };
55 let declared_is_aggregate =
56 attribute.aggregate || aliased_aggregate(schema, effective_type).is_some();
57 let path = || Path::Attribute {
58 entity: id,
59 index,
60 name: Some(attribute.name.clone()),
61 };
62 if declared_is_aggregate && !is_list {
63 report.push(Finding::error(
64 "structure.cardinality.expected_aggregate",
65 path(),
66 format!("{} is an aggregate, a scalar was written", attribute.name),
67 ));
68 } else if !declared_is_aggregate && is_list {
69 report.push(Finding::error(
70 "structure.cardinality.unexpected_aggregate",
71 path(),
72 format!("{} is a scalar, an aggregate was written", attribute.name),
73 ));
74 }
75 }
76 }
77}