Skip to main content

panproto_inst/
attributes.rs

1//! ACSet attribute discipline.
2//!
3//! An attributed C-set separates *combinatorial* data (nodes and arcs over the
4//! schema shape) from *attribute* data (scalar values hanging off objects). An
5//! [`AttributeSchema`] records, per vertex, the attributes an instance node
6//! over that vertex may carry: an ordered list of `(name, value kind)` pairs
7//! derived from the schema's scalar-valued outgoing edges.
8//!
9//! [`AttributeSchema::validate_wtype`] and
10//! [`AttributeSchema::validate_finstance`] check an instance's attribute
11//! payload against the declaration, reporting undeclared keys and kind
12//! mismatches. The check is opt-in: it is not part of the structural
13//! [`validate_wtype`](crate::validate::validate_wtype) pass, since instances
14//! routinely carry round-trip fields that no schema edge declares.
15
16use std::collections::{HashMap, HashSet};
17
18use panproto_gat::Name;
19use panproto_schema::Schema;
20
21use crate::functor::FInstance;
22use crate::value::Value;
23use crate::wtype::WInstance;
24
25/// A single attribute declaration: an attribute name and the schema kind of
26/// the vertex that supplies its values.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct AttributeDecl {
29    /// The attribute name (an edge label, or the target vertex id when the
30    /// edge is unlabeled).
31    pub name: String,
32    /// The schema kind of the attribute's value vertex (e.g. `"string"`).
33    pub kind: Name,
34}
35
36/// The attribute discipline of a schema: per vertex, the ordered attributes a
37/// conforming instance node may carry.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct AttributeSchema {
40    /// Per-vertex attribute declarations, each ordered by attribute name.
41    pub attrs: HashMap<Name, Vec<AttributeDecl>>,
42}
43
44/// A violation found when validating an instance's attributes.
45#[derive(Clone, Debug, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum AttributeViolation {
48    /// An attribute key is present on an instance node but not declared for
49    /// that node's vertex.
50    Undeclared {
51        /// The offending node id (W-type) or row index (F-instance).
52        node_id: u32,
53        /// The vertex the node sits over.
54        anchor: Name,
55        /// The undeclared attribute key.
56        key: String,
57    },
58    /// An attribute value's kind does not match the declared kind.
59    Mistyped {
60        /// The offending node id (W-type) or row index (F-instance).
61        node_id: u32,
62        /// The vertex the node sits over.
63        anchor: Name,
64        /// The attribute key.
65        key: String,
66        /// The declared value kind.
67        expected: Name,
68        /// The value's actual type name.
69        found: String,
70    },
71}
72
73impl AttributeSchema {
74    /// Derive the attribute discipline of a schema.
75    ///
76    /// For each vertex, its attributes are its outgoing edges whose target is a
77    /// *leaf* vertex (one with no outgoing edges of its own). The attribute
78    /// name is the edge label, or the target vertex id when the edge is
79    /// unlabeled; the value kind is the target vertex's kind. Attributes are
80    /// ordered by name; duplicate names keep the first declaration.
81    #[must_use]
82    pub fn from_schema(schema: &Schema) -> Self {
83        let mut attrs: HashMap<Name, Vec<AttributeDecl>> = HashMap::new();
84        for vertex_id in schema.vertices.keys() {
85            let mut decls: Vec<AttributeDecl> = Vec::new();
86            let mut seen: HashSet<String> = HashSet::new();
87            for edge in schema.outgoing_edges(vertex_id.as_ref()) {
88                // Only scalar-valued edges (to leaf vertices) are attributes.
89                if !schema.outgoing_edges(edge.tgt.as_ref()).is_empty() {
90                    continue;
91                }
92                let Some(tgt_vertex) = schema.vertex(edge.tgt.as_ref()) else {
93                    continue;
94                };
95                let name = edge
96                    .name
97                    .as_ref()
98                    .map_or_else(|| edge.tgt.to_string(), Name::to_string);
99                if !seen.insert(name.clone()) {
100                    continue;
101                }
102                decls.push(AttributeDecl {
103                    name,
104                    kind: tgt_vertex.kind.clone(),
105                });
106            }
107            if !decls.is_empty() {
108                decls.sort_by(|a, b| a.name.cmp(&b.name));
109                attrs.insert(vertex_id.clone(), decls);
110            }
111        }
112        Self { attrs }
113    }
114
115    /// Look up the declared attributes of a vertex.
116    #[must_use]
117    pub fn for_vertex(&self, vertex: &str) -> &[AttributeDecl] {
118        self.attrs.get(vertex).map_or(&[], Vec::as_slice)
119    }
120
121    /// Validate the attribute payload (`extra_fields`) of every node in a
122    /// W-type instance against this schema.
123    ///
124    /// Reports undeclared keys and kind mismatches. Nodes whose anchor has no
125    /// declared attributes are unconstrained (any extra fields are round-trip
126    /// data, not schema attributes) and skipped.
127    #[must_use]
128    pub fn validate_wtype(&self, instance: &WInstance) -> Vec<AttributeViolation> {
129        let mut violations = Vec::new();
130        for node in instance.nodes.values() {
131            let Some(decls) = self.attrs.get(&node.anchor) else {
132                continue;
133            };
134            for (key, value) in &node.extra_fields {
135                Self::check_one(node.id, &node.anchor, key, value, decls, &mut violations);
136            }
137        }
138        violations
139    }
140
141    /// Validate the columns of every row of a functor (relational) instance
142    /// against this schema. The reported `node_id` is the row index.
143    #[must_use]
144    pub fn validate_finstance(&self, instance: &FInstance) -> Vec<AttributeViolation> {
145        let mut violations = Vec::new();
146        for (table, rows) in &instance.tables {
147            let anchor = Name::from(table.as_str());
148            let Some(decls) = self.attrs.get(&anchor) else {
149                continue;
150            };
151            for (row_idx, row) in rows.iter().enumerate() {
152                let row_id = u32::try_from(row_idx).unwrap_or(u32::MAX);
153                for (key, value) in row {
154                    Self::check_one(row_id, &anchor, key, value, decls, &mut violations);
155                }
156            }
157        }
158        violations
159    }
160
161    fn check_one(
162        node_id: u32,
163        anchor: &Name,
164        key: &str,
165        value: &Value,
166        decls: &[AttributeDecl],
167        out: &mut Vec<AttributeViolation>,
168    ) {
169        match decls.iter().find(|d| d.name == key) {
170            None => out.push(AttributeViolation::Undeclared {
171                node_id,
172                anchor: anchor.clone(),
173                key: key.to_owned(),
174            }),
175            Some(decl) if !kind_accepts(&decl.kind, value) => {
176                out.push(AttributeViolation::Mistyped {
177                    node_id,
178                    anchor: anchor.clone(),
179                    key: key.to_owned(),
180                    expected: decl.kind.clone(),
181                    found: value.type_name().to_owned(),
182                });
183            }
184            Some(_) => {}
185        }
186    }
187}
188
189/// Returns `true` if a value is admissible for a declared scalar kind.
190///
191/// Recognized primitive kinds are matched structurally; kinds the classifier
192/// does not recognize accept any value, and an explicit null is always
193/// admissible (it stands for an absent optional attribute).
194fn kind_accepts(kind: &Name, value: &Value) -> bool {
195    if matches!(value, Value::Null) {
196        return true;
197    }
198    match kind.as_ref() {
199        "string" | "str" | "text" => {
200            matches!(value, Value::Str(_) | Value::Token(_) | Value::CidLink(_))
201        }
202        "int" | "integer" => matches!(value, Value::Int(_)),
203        "float" | "number" | "double" | "decimal" => {
204            matches!(value, Value::Float(_) | Value::Int(_))
205        }
206        "bool" | "boolean" => matches!(value, Value::Bool(_)),
207        "bytes" | "blob" => matches!(value, Value::Bytes(_) | Value::Blob { .. }),
208        // Unrecognized kinds cannot be classified, so anything is admissible.
209        _ => true,
210    }
211}
212
213#[cfg(test)]
214#[allow(clippy::expect_used, clippy::unwrap_used)]
215mod tests {
216    use std::collections::HashMap;
217
218    use panproto_schema::{Edge, Schema, Vertex};
219
220    use super::*;
221    use crate::metadata::Node;
222
223    fn attr_schema() -> Schema {
224        use smallvec::{SmallVec, smallvec};
225
226        let vertices = [
227            ("post", "object"),
228            ("post.title", "string"),
229            ("post.count", "int"),
230        ]
231        .into_iter()
232        .map(|(id, kind)| {
233            (
234                Name::from(id),
235                Vertex {
236                    id: Name::from(id),
237                    kind: Name::from(kind),
238                    nsid: None,
239                },
240            )
241        })
242        .collect();
243
244        let title = Edge {
245            src: "post".into(),
246            tgt: "post.title".into(),
247            kind: "prop".into(),
248            name: Some("title".into()),
249        };
250        let count = Edge {
251            src: "post".into(),
252            tgt: "post.count".into(),
253            kind: "prop".into(),
254            name: Some("count".into()),
255        };
256        let mut outgoing: HashMap<Name, SmallVec<Edge, 4>> = HashMap::new();
257        outgoing.insert("post".into(), smallvec![title.clone(), count.clone()]);
258        let edges = HashMap::from([(title, Name::from("prop")), (count, Name::from("prop"))]);
259
260        Schema {
261            protocol: "test".into(),
262            vertices,
263            edges,
264            hyper_edges: HashMap::new(),
265            constraints: HashMap::new(),
266            required: HashMap::new(),
267            nsids: HashMap::new(),
268            entries: Vec::new(),
269            variants: HashMap::new(),
270            orderings: HashMap::new(),
271            recursion_points: HashMap::new(),
272            spans: HashMap::new(),
273            usage_modes: HashMap::new(),
274            nominal: HashMap::new(),
275            coercions: HashMap::new(),
276            mergers: HashMap::new(),
277            defaults: HashMap::new(),
278            policies: HashMap::new(),
279            outgoing,
280            incoming: HashMap::new(),
281            between: HashMap::new(),
282        }
283    }
284
285    #[test]
286    fn derives_ordered_attributes() {
287        let attrs = AttributeSchema::from_schema(&attr_schema());
288        let post = attrs.for_vertex("post");
289        assert_eq!(post.len(), 2);
290        // Ordered by name: count before title.
291        assert_eq!(post[0].name, "count");
292        assert_eq!(post[0].kind.as_ref(), "int");
293        assert_eq!(post[1].name, "title");
294        assert_eq!(post[1].kind.as_ref(), "string");
295    }
296
297    #[test]
298    fn accepts_declared_and_well_typed() {
299        let attrs = AttributeSchema::from_schema(&attr_schema());
300        let mut nodes = HashMap::new();
301        nodes.insert(
302            0,
303            Node::new(0, "post")
304                .with_extra_field("title", Value::Str("hello".into()))
305                .with_extra_field("count", Value::Int(3)),
306        );
307        let inst = WInstance::new(nodes, vec![], vec![], 0, "post".into());
308        assert!(attrs.validate_wtype(&inst).is_empty());
309    }
310
311    #[test]
312    fn flags_undeclared_key() {
313        let attrs = AttributeSchema::from_schema(&attr_schema());
314        let mut nodes = HashMap::new();
315        nodes.insert(
316            0,
317            Node::new(0, "post").with_extra_field("bogus", Value::Int(1)),
318        );
319        let inst = WInstance::new(nodes, vec![], vec![], 0, "post".into());
320        let v = attrs.validate_wtype(&inst);
321        assert!(matches!(
322            v.as_slice(),
323            [AttributeViolation::Undeclared { key, node_id: 0, .. }] if key == "bogus"
324        ));
325    }
326
327    #[test]
328    fn flags_mistyped_value() {
329        let attrs = AttributeSchema::from_schema(&attr_schema());
330        let mut nodes = HashMap::new();
331        nodes.insert(
332            0,
333            Node::new(0, "post").with_extra_field("count", Value::Str("three".into())),
334        );
335        let inst = WInstance::new(nodes, vec![], vec![], 0, "post".into());
336        let v = attrs.validate_wtype(&inst);
337        assert!(matches!(
338            v.as_slice(),
339            [AttributeViolation::Mistyped { key, expected, .. }]
340                if key == "count" && expected.as_ref() == "int"
341        ));
342    }
343
344    #[test]
345    fn validates_finstance_rows() {
346        let attrs = AttributeSchema::from_schema(&attr_schema());
347        let good = HashMap::from([
348            ("title".to_owned(), Value::Str("x".into())),
349            ("count".to_owned(), Value::Int(1)),
350        ]);
351        let bad = HashMap::from([("count".to_owned(), Value::Str("nope".into()))]);
352        let inst = FInstance::new().with_table("post", vec![good, bad]);
353        let v = attrs.validate_finstance(&inst);
354        assert_eq!(v.len(), 1);
355        assert!(matches!(
356            &v[0],
357            AttributeViolation::Mistyped { node_id: 1, key, .. } if key == "count"
358        ));
359    }
360}