Skip to main content

ferric_fred/
release_table.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Deserializer, Serialize};
4
5use crate::{ReleaseElementId, ReleaseId, SeriesId};
6
7/// A release's table tree, from the `fred/release/tables` endpoint — the layout
8/// a release uses to present its series (sections and tables, with series rows
9/// nested beneath them).
10///
11/// FRED returns the top-level `elements` as a JSON object keyed by element id
12/// whose values are the *roots* of the tree, each carrying its subtree inline
13/// via [`children`](ReleaseTableElement::children). We collect those into an
14/// ordered [`roots`](ReleaseTable::roots) vector (each element already carries
15/// its own id). `name` and `element_id` are present only when a subtree was
16/// requested (see [`ReleaseTablesRequest::element`](crate::ReleaseTablesRequest::element));
17/// for a whole-release request they are absent.
18// `Eq` is intentionally omitted: `ReleaseTableElement` carries an `f64`
19// observation value (which is only `PartialEq`), so the tree is `PartialEq` only,
20// mirroring [`Observation`](crate::Observation).
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
23pub struct ReleaseTable {
24    /// The name of the requested element, when a subtree was requested.
25    #[serde(default)]
26    pub name: Option<String>,
27
28    /// The id of the requested element, when a subtree was requested.
29    #[serde(default)]
30    pub element_id: Option<ReleaseElementId>,
31
32    /// The root elements of the tree, ordered by element id. (FRED's redundant
33    /// top-level `release_id` — a string, unlike the numeric one on each
34    /// element — is dropped; the caller already knows it.)
35    ///
36    /// On the wire FRED names this `elements` (an object keyed by id); we read
37    /// that but re-serialize as a `roots` array, matching this field and the
38    /// flattened shape.
39    #[serde(
40        rename(serialize = "roots", deserialize = "elements"),
41        deserialize_with = "roots_from_map"
42    )]
43    pub roots: Vec<ReleaseTableElement>,
44}
45
46/// A node in a release's table tree: a section, a table, or a series row. Nodes
47/// nest via [`children`](ReleaseTableElement::children) to arbitrary depth.
48// `Eq` is intentionally omitted — `observation_value: Option<f64>` is `PartialEq`
49// only; see the note on [`ReleaseTable`].
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
52pub struct ReleaseTableElement {
53    /// This element's id.
54    pub element_id: ReleaseElementId,
55
56    /// The release this element belongs to.
57    pub release_id: ReleaseId,
58
59    /// The parent element's id, absent for a root.
60    #[serde(default)]
61    pub parent_id: Option<ReleaseElementId>,
62
63    /// The series this element points to, for a `series`-type row. Absent for
64    /// structural elements (sections/tables), where FRED sends `null` or `""`.
65    #[serde(default, deserialize_with = "optional_series_id")]
66    pub series_id: Option<SeriesId>,
67
68    /// The element kind, e.g. `"section"`, `"table"`, or `"series"`. Kept as a
69    /// string (its vocabulary is open-ended and thinly documented; ADR-0017).
70    #[serde(rename = "type")]
71    pub element_type: String,
72
73    /// Human-readable label, e.g. `"CPI for U.S. City Average"`.
74    pub name: String,
75
76    /// The element's line number within its table, when FRED provides one.
77    #[serde(default)]
78    pub line: Option<String>,
79
80    /// The element's depth as FRED reports it (`"0"` at the top). Mirrors the
81    /// nesting of [`children`](ReleaseTableElement::children).
82    pub level: String,
83
84    /// The element's observation value at the request's `observation_date` (or
85    /// FRED's latest), present only when the request set
86    /// [`include_observation_values`](crate::ReleaseTablesRequest::include_observation_values).
87    /// `None` for a structural (non-`series`) element, when values weren't
88    /// requested, or when FRED reports the value as missing (`"."`) — mirroring
89    /// [`Observation`](crate::Observation)'s value handling.
90    #[serde(default, deserialize_with = "deserialize_optional_value")]
91    pub observation_value: Option<f64>,
92
93    /// FRED's formatted label for the [`observation_value`](ReleaseTableElement::observation_value)
94    /// date, e.g. `"Jun 2023"` or `"2023"` — a display string keyed to the
95    /// series' frequency, **not** an ISO date (unlike the request's
96    /// `observation_date`). `None` when values weren't requested or the element
97    /// carries no series.
98    #[serde(default)]
99    pub observation_date: Option<String>,
100
101    /// The child elements nested beneath this one (empty for a leaf).
102    #[serde(default)]
103    pub children: Vec<ReleaseTableElement>,
104}
105
106/// Deserialize FRED's `elements` object (keyed by element id) into an ordered
107/// vector of its values. Ordering is by element id, so the result is
108/// deterministic regardless of the object's key order.
109fn roots_from_map<'de, D>(deserializer: D) -> Result<Vec<ReleaseTableElement>, D::Error>
110where
111    D: Deserializer<'de>,
112{
113    // Keys are stringified ids; each value already carries its own element_id,
114    // so we keep only the values and sort by that.
115    let map: BTreeMap<String, ReleaseTableElement> = BTreeMap::deserialize(deserializer)?;
116    let mut roots: Vec<ReleaseTableElement> = map.into_values().collect();
117    roots.sort_by_key(|element| element.element_id);
118    Ok(roots)
119}
120
121/// Deserialize a `series_id` that FRED sends as `null`, an empty string, or a
122/// real id, mapping the first two to `None`.
123fn optional_series_id<'de, D>(deserializer: D) -> Result<Option<SeriesId>, D::Error>
124where
125    D: Deserializer<'de>,
126{
127    let raw: Option<String> = Option::deserialize(deserializer)?;
128    Ok(raw.filter(|id| !id.is_empty()).map(SeriesId::new))
129}
130
131/// Deserialize a release-table element's `observation_value`: `"."` → `None`,
132/// otherwise parse the string as `f64`. Mirrors [`Observation`](crate::Observation)'s
133/// value handling; paired with `#[serde(default)]`, so an absent field — values
134/// not requested, or a structural element — also yields `None`. A present,
135/// non-`"."` value that fails to parse is an error, not a silent `None`.
136fn deserialize_optional_value<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
137where
138    D: Deserializer<'de>,
139{
140    let raw = String::deserialize(deserializer)?;
141    if raw == "." {
142        return Ok(None);
143    }
144    raw.parse::<f64>()
145        .map(Some)
146        .map_err(serde::de::Error::custom)
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    /// A two-level tree: a section root containing one series row, which in turn
154    /// has a series child. Mirrors the real `fred/release/tables` shape (nulls
155    /// for structural elements, a real series_id on a leaf).
156    const TABLE_BODY: &str = r#"{
157        "name": null,
158        "element_id": null,
159        "release_id": "10",
160        "elements": {
161            "34483": {
162                "element_id": 34483, "release_id": 10, "parent_id": null,
163                "series_id": null, "type": "section", "name": "Monthly, SA",
164                "line": null, "level": "0",
165                "children": [
166                    {
167                        "element_id": 34484, "release_id": 10, "parent_id": 34483,
168                        "series_id": "", "type": "series", "name": "All items",
169                        "line": "1", "level": "1",
170                        "children": [
171                            {
172                                "element_id": 34485, "release_id": 10, "parent_id": 34484,
173                                "series_id": "CPIFABSL", "type": "series",
174                                "name": "Food and beverages", "line": "2", "level": "2",
175                                "children": []
176                            }
177                        ]
178                    }
179                ]
180            }
181        }
182    }"#;
183
184    #[test]
185    fn deserializes_a_nested_table() {
186        let table: ReleaseTable = serde_json::from_str(TABLE_BODY).unwrap();
187
188        // Whole-release request: no requested-element name/id.
189        assert!(table.name.is_none());
190        assert!(table.element_id.is_none());
191
192        assert_eq!(table.roots.len(), 1);
193        let section = &table.roots[0];
194        assert_eq!(section.element_id, ReleaseElementId::new(34483));
195        assert_eq!(section.element_type, "section");
196        assert!(section.parent_id.is_none());
197        assert!(section.series_id.is_none()); // null → None
198        assert_eq!(section.children.len(), 1);
199
200        let all_items = &section.children[0];
201        assert_eq!(all_items.parent_id, Some(ReleaseElementId::new(34483)));
202        assert!(all_items.series_id.is_none()); // "" → None
203        assert_eq!(all_items.line.as_deref(), Some("1"));
204
205        let leaf = &all_items.children[0];
206        assert_eq!(leaf.series_id, Some(SeriesId::new("CPIFABSL")));
207        assert_eq!(leaf.element_type, "series");
208        assert!(leaf.children.is_empty());
209    }
210
211    #[test]
212    fn observation_values_deserialize_when_present() {
213        // Mirrors the live `include_observation_values=true` shape: series rows
214        // carry `observation_value` (a stringly-typed number, or "." for
215        // missing) and a frequency-formatted `observation_date`; structural
216        // elements carry neither.
217        let body = r#"{
218            "release_id": "10",
219            "elements": {
220                "36714": {
221                    "element_id": 36714, "release_id": 10, "type": "table",
222                    "name": "Monthly, Seasonally Adjusted", "level": "0",
223                    "children": [
224                        {
225                            "element_id": 36715, "release_id": 10, "parent_id": 36714,
226                            "series_id": "CUSR0000SA0L5", "type": "series",
227                            "name": "All items less medical care", "level": "1",
228                            "observation_value": "292.260", "observation_date": "Jun 2023",
229                            "children": []
230                        },
231                        {
232                            "element_id": 36716, "release_id": 10, "parent_id": 36714,
233                            "series_id": "CPILEGSL", "type": "series", "name": "Missing",
234                            "level": "1",
235                            "observation_value": ".", "observation_date": "Jun 2023",
236                            "children": []
237                        }
238                    ]
239                }
240            }
241        }"#;
242        let table: ReleaseTable = serde_json::from_str(body).unwrap();
243        let table_elem = &table.roots[0];
244        // Structural element: no value, no date.
245        assert_eq!(table_elem.observation_value, None);
246        assert_eq!(table_elem.observation_date, None);
247
248        let with_value = &table_elem.children[0];
249        assert_eq!(with_value.observation_value, Some(292.260));
250        assert_eq!(with_value.observation_date.as_deref(), Some("Jun 2023"));
251
252        // FRED's "." sentinel maps to a missing value, not a parse error.
253        let missing = &table_elem.children[1];
254        assert_eq!(missing.observation_value, None);
255        assert_eq!(missing.observation_date.as_deref(), Some("Jun 2023"));
256    }
257
258    #[test]
259    fn observation_values_absent_when_not_requested() {
260        // The base (structure-only) shape has no value/date fields at all.
261        let table: ReleaseTable = serde_json::from_str(TABLE_BODY).unwrap();
262        let leaf = &table.roots[0].children[0].children[0];
263        assert_eq!(leaf.observation_value, None);
264        assert_eq!(leaf.observation_date, None);
265    }
266
267    #[test]
268    fn roots_are_ordered_by_element_id() {
269        // Object key order is largest-first; roots must come back id-ascending.
270        let body = r#"{
271            "elements": {
272                "200": {"element_id":200,"release_id":1,"type":"table","name":"B","level":"0"},
273                "100": {"element_id":100,"release_id":1,"type":"table","name":"A","level":"0"}
274            }
275        }"#;
276        let table: ReleaseTable = serde_json::from_str(body).unwrap();
277        let ids: Vec<u32> = table.roots.iter().map(|e| e.element_id.get()).collect();
278        assert_eq!(ids, [100, 200]);
279    }
280}