Skip to main content

ploidy_core/ir/views/
struct_.rs

1//! Struct types: object schemas and `allOf` composition.
2//!
3//! In OpenAPI, a `type: object` schema with `properties` describes a record
4//! with named fields. A schema can also inherit fields from other schemas via
5//! `allOf`, which is how OpenAPI models composition and inheritance:
6//!
7//! ```yaml
8//! components:
9//!   schemas:
10//!     Address:
11//!       type: object
12//!       required: [city]
13//!       properties:
14//!         city:
15//!           type: string
16//!         zip:
17//!           type: string
18//!     Office:
19//!       allOf:
20//!         - $ref: '#/components/schemas/Address'
21//!         - type: object
22//!           required: [floor]
23//!           properties:
24//!             floor:
25//!               type: integer
26//! ```
27//!
28//! Ploidy represents both cases as a [`StructView`]. A struct has
29//! its own fields plus fields inherited from its `allOf` parents.
30//! Each field carries properties that guide codegen:
31//!
32//! * **Required vs. optional.** A field listed in `required` is
33//!   non-optional; others are wrapped in [`ContainerView::Optional`].
34//! * **Flattened.** Fields originating from `anyOf` parents are
35//!   flattened into the struct as optional fields.
36//! * **Tag.** A field is a tag if its name matches the discriminator of a
37//!   [tagged union] that references this struct as a variant.
38//! * **Indirection.** A field needs indirection (e.g., [`Box<T>`] in Rust)
39//!   when it and any of its parent structs form a cycle in the type graph.
40//! * **Inherited.** A field that comes from an `allOf` parent rather than
41//!   this struct's own `properties`.
42//!
43//! [`ContainerView::Optional`]: super::container::ContainerView::Optional
44//! [tagged union]: super::tagged::TaggedView
45
46use fixedbitset::FixedBitSet;
47use itertools::Itertools;
48use petgraph::{
49    Direction,
50    graph::NodeIndex,
51    visit::{EdgeFiltered, EdgeRef, IntoNeighbors},
52};
53use rustc_hash::FxHashSet;
54
55use crate::ir::{
56    graph::{CookedGraph, GraphEdge},
57    types::{FieldMeta, GraphInlineType, GraphSchemaType, GraphStruct, GraphType, StructFieldName},
58};
59
60use super::{ViewNode, container::ContainerView, ir::TypeView};
61
62/// A graph-aware view of a [struct type][GraphStruct].
63#[derive(Debug)]
64pub struct StructView<'graph, 'a> {
65    cooked: &'graph CookedGraph<'a>,
66    index: NodeIndex<usize>,
67    ty: GraphStruct<'a>,
68}
69
70impl<'graph, 'a> StructView<'graph, 'a> {
71    #[inline]
72    pub(in crate::ir) fn new(
73        cooked: &'graph CookedGraph<'a>,
74        index: NodeIndex<usize>,
75        ty: GraphStruct<'a>,
76    ) -> Self {
77        Self { cooked, index, ty }
78    }
79
80    /// Returns the description, if present in the schema.
81    #[inline]
82    pub fn description(&self) -> Option<&'a str> {
83        self.ty.description
84    }
85
86    /// Returns an iterator over all fields, including fields inherited
87    /// from `allOf` schemas. Fields are returned in declaration order:
88    /// ancestor fields first, in the order of their parents in `allOf`;
89    /// then this struct's own fields.
90    #[inline]
91    pub fn fields(&self) -> impl Iterator<Item = StructFieldView<'_, 'graph, 'a>> {
92        let all = self
93            .inherited_fields() // Not a `DoubleEndedIterator`; can't reverse directly.
94            .chain(self.own_fields())
95            .collect_vec();
96
97        // Deduplicate fields right-to-left, so that later (closer) fields
98        // win over earlier (distant) ones; then reverse again to
99        // restore declaration order.
100        let mut seen = FxHashSet::default();
101        let deduped = all
102            .into_iter()
103            .rev()
104            .filter(|f| seen.insert(f.meta.name))
105            .collect_vec();
106        deduped.into_iter().rev()
107    }
108
109    /// Returns an iterator over all fields inherited from
110    /// this struct's ancestors.
111    fn inherited_fields(&self) -> impl Iterator<Item = StructFieldView<'_, 'graph, 'a>> {
112        enum Step {
113            Enter(NodeIndex<usize>),
114            Exit(NodeIndex<usize>),
115        }
116
117        // This post-order DFS over inheritance edges yields ancestors in
118        // declaration order. We avoid Petgraph's `DfsPostOrder` because
119        // it would yield them in reverse declaration order.
120        let inherits = EdgeFiltered::from_fn(&self.cooked.graph, |e| {
121            matches!(e.weight(), GraphEdge::Inherits { .. })
122        });
123        let mut stack = vec![Step::Enter(self.index)];
124        let mut visited = FixedBitSet::with_capacity(self.cooked.graph.node_count());
125        let mut ancestors = vec![];
126        while let Some(step) = stack.pop() {
127            match step {
128                Step::Enter(node) => {
129                    if visited.put(node.index()) {
130                        continue;
131                    }
132                    // Add the node's parents in reverse, so that the loop
133                    // visits them in declaration order before yielding
134                    // the node itself.
135                    stack.push(Step::Exit(node));
136                    stack.extend(
137                        inherits
138                            .neighbors(node) // Not a `DoubleEndedIterator`.
139                            .collect_vec()
140                            .into_iter()
141                            .rev()
142                            .map(Step::Enter),
143                    );
144                }
145                Step::Exit(node) if node != self.index => {
146                    ancestors.push(node);
147                }
148                _ => {}
149            }
150        }
151
152        ancestors
153            .into_iter()
154            .flat_map(|index| self.cooked.fields(index))
155            .map(|info| StructFieldView::new(self, info.meta, info.target, true))
156    }
157
158    /// Returns an iterator over fields declared directly on this struct,
159    /// excluding inherited fields.
160    #[inline]
161    pub fn own_fields(&self) -> impl Iterator<Item = StructFieldView<'_, 'graph, 'a>> {
162        self.cooked
163            .fields(self.index)
164            .map(|info| StructFieldView::new(self, info.meta, info.target, false))
165    }
166
167    /// Returns an iterator over immediate parent types from `allOf`,
168    /// including named and inline schemas.
169    #[inline]
170    pub fn parents(&self) -> impl Iterator<Item = TypeView<'graph, 'a>> {
171        self.cooked
172            .inherits(self.index)
173            .map(|info| TypeView::new(self.cooked, info.target))
174    }
175}
176
177impl<'graph, 'a> ViewNode<'graph, 'a> for StructView<'graph, 'a> {
178    #[inline]
179    fn cooked(&self) -> &'graph CookedGraph<'a> {
180        self.cooked
181    }
182
183    #[inline]
184    fn index(&self) -> NodeIndex<usize> {
185        self.index
186    }
187}
188
189/// A graph-aware view of a struct field.
190pub type StructFieldView<'view, 'graph, 'a> = FieldView<'view, 'a, StructView<'graph, 'a>>;
191
192/// A graph-aware view of a struct or union field.
193#[derive(Debug)]
194pub struct FieldView<'view, 'a, P> {
195    parent: &'view P,
196    meta: FieldMeta<'a>,
197    ty: NodeIndex<usize>,
198    inherited: bool,
199}
200
201#[allow(private_bounds, reason = "`ViewNode` is sealed")]
202impl<'view, 'graph, 'a: 'graph, P: ViewNode<'graph, 'a>> FieldView<'view, 'a, P> {
203    #[inline]
204    pub(in crate::ir) fn new(
205        parent: &'view P,
206        meta: FieldMeta<'a>,
207        ty: NodeIndex<usize>,
208        inherited: bool,
209    ) -> Self {
210        Self {
211            parent,
212            meta,
213            ty,
214            inherited,
215        }
216    }
217
218    /// Returns the field name.
219    #[inline]
220    pub fn name(&self) -> StructFieldName<'a> {
221        self.meta.name
222    }
223
224    /// Returns a view of the inner type that this type wraps.
225    #[inline]
226    pub fn ty(&self) -> TypeView<'graph, 'a> {
227        TypeView::new(self.parent.cooked(), self.ty)
228    }
229
230    /// Returns whether this field is required or optional.
231    #[inline]
232    pub fn required(&self) -> Required {
233        if self.meta.required {
234            let nullable = matches!(self.ty().as_container(), Some(ContainerView::Optional(_)));
235            Required::Required { nullable }
236        } else {
237            Required::Optional
238        }
239    }
240
241    /// Returns the description, if present in the schema.
242    #[inline]
243    pub fn description(&self) -> Option<&'a str> {
244        self.meta.description
245    }
246
247    /// Returns `true` if this field is flattened from an
248    /// `anyOf` parent.
249    #[inline]
250    pub fn flattened(&self) -> bool {
251        self.meta.flattened
252    }
253}
254
255/// Whether a field is required or optional.
256///
257/// Required fields are always present, but may be nullable; optional fields
258/// may be absent entirely.
259#[derive(Clone, Copy, Debug, Eq, PartialEq)]
260pub enum Required {
261    /// The field must be present in the payload.
262    Required {
263        /// Whether the field can be `null` if present.
264        nullable: bool,
265    },
266    /// The field may be absent from the payload.
267    Optional,
268}
269
270impl<'view, 'graph, 'a> FieldView<'view, 'a, StructView<'graph, 'a>> {
271    /// Returns `true` if this field was inherited from a parent via `allOf`.
272    #[inline]
273    pub fn inherited(&self) -> bool {
274        self.inherited
275    }
276
277    /// Returns `true` if this field is a tag.
278    ///
279    /// A field is a tag only if this struct inherits from or is a variant of
280    /// a tagged union, and the field name matches that union's tag.
281    #[inline]
282    pub fn tag(&self) -> bool {
283        let StructFieldName::Name(name) = self.meta.name else {
284            return false;
285        };
286        let cooked = self.parent.cooked();
287        cooked
288            .graph
289            .edges_directed(self.parent.index(), Direction::Incoming)
290            .filter(|e| {
291                matches!(
292                    e.weight(),
293                    GraphEdge::Variant(_) | GraphEdge::Inherits { .. }
294                )
295            })
296            .filter_map(|e| match cooked.graph[e.source()] {
297                GraphType::Schema(GraphSchemaType::Tagged(_, tagged))
298                | GraphType::Inline(GraphInlineType::Tagged(_, tagged)) => Some(tagged),
299                _ => None,
300            })
301            .any(|neighbor| neighbor.tag == name)
302    }
303
304    /// Returns `true` if this field needs `Box<T>` to break a cycle.
305    ///
306    /// A field needs boxing if its target type is in the same strongly
307    /// connected component as the type that contains it, excluding
308    /// edges through heap-allocating containers (arrays and maps).
309    #[inline]
310    pub fn needs_box(&self) -> bool {
311        let graph = self.parent.cooked();
312        graph.metadata.box_sccs[self.parent.index().index()]
313            == graph.metadata.box_sccs[self.ty.index()]
314    }
315}