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}