Skip to main content

ferric_fred/
release_table.rs

1use std::collections::{BTreeMap, HashSet};
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/// each value carrying its subtree inline via
13/// [`children`](ReleaseTableElement::children). The object is *flattened* — for a
14/// subtree request it also keys every descendant, not just the roots — so we keep
15/// only the true roots (those whose parent isn't itself in the object) as the
16/// ordered [`roots`](ReleaseTable::roots) vector, leaving deeper nodes reachable
17/// solely through `children` (see `roots_from_map`). `name` and `element_id`
18/// are present only when a subtree was requested (see
19/// [`ReleaseTablesRequest::element`](crate::ReleaseTablesRequest::element)); for a
20/// whole-release request they are absent.
21// `Eq` is intentionally omitted: `ReleaseTableElement` carries an `f64`
22// observation value (which is only `PartialEq`), so the tree is `PartialEq` only,
23// mirroring [`Observation`](crate::Observation).
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
26pub struct ReleaseTable {
27    /// The name of the requested element, when a subtree was requested.
28    #[serde(default)]
29    pub name: Option<String>,
30
31    /// The id of the requested element, when a subtree was requested.
32    #[serde(default)]
33    pub element_id: Option<ReleaseElementId>,
34
35    /// The root elements of the tree, ordered by element id. (FRED's redundant
36    /// top-level `release_id` — a string, unlike the numeric one on each
37    /// element — is dropped; the caller already knows it.)
38    ///
39    /// On the wire FRED names this `elements` (a flattened object keyed by id);
40    /// we read that, keep only the tree's true roots, and re-serialize as a
41    /// `roots` array (see `roots_from_map`).
42    #[serde(
43        rename(serialize = "roots", deserialize = "elements"),
44        deserialize_with = "roots_from_map"
45    )]
46    pub roots: Vec<ReleaseTableElement>,
47}
48
49/// A node in a release's table tree: a section, a table, or a series row. Nodes
50/// nest via [`children`](ReleaseTableElement::children) to arbitrary depth.
51// `Eq` is intentionally omitted — `observation_value: Option<f64>` is `PartialEq`
52// only; see the note on [`ReleaseTable`].
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
55pub struct ReleaseTableElement {
56    /// This element's id.
57    pub element_id: ReleaseElementId,
58
59    /// The release this element belongs to.
60    pub release_id: ReleaseId,
61
62    /// The parent element's id, absent for a root.
63    #[serde(default)]
64    pub parent_id: Option<ReleaseElementId>,
65
66    /// The series this element points to, for a `series`-type row. Absent for
67    /// structural elements (sections/tables), where FRED sends `null` or `""`.
68    #[serde(default, deserialize_with = "optional_series_id")]
69    pub series_id: Option<SeriesId>,
70
71    /// The element kind, e.g. `"section"`, `"table"`, or `"series"`. Kept as a
72    /// string (its vocabulary is open-ended and thinly documented; ADR-0017).
73    #[serde(rename = "type")]
74    pub element_type: String,
75
76    /// Human-readable label, e.g. `"CPI for U.S. City Average"`.
77    pub name: String,
78
79    /// The element's line number within its table, when FRED provides one.
80    #[serde(default)]
81    pub line: Option<String>,
82
83    /// The element's depth as FRED reports it (`"0"` at the top). Mirrors the
84    /// nesting of [`children`](ReleaseTableElement::children).
85    pub level: String,
86
87    /// The element's observation value at the request's `observation_date` (or
88    /// FRED's latest), present only when the request set
89    /// [`include_observation_values`](crate::ReleaseTablesRequest::include_observation_values).
90    /// `None` for a structural (non-`series`) element, when values weren't
91    /// requested, or when FRED reports the value as missing (`"."`) — mirroring
92    /// [`Observation`](crate::Observation)'s value handling.
93    #[serde(default, deserialize_with = "deserialize_optional_value")]
94    pub observation_value: Option<f64>,
95
96    /// FRED's formatted label for the [`observation_value`](ReleaseTableElement::observation_value)
97    /// date, e.g. `"Jun 2023"` or `"2023"` — a human-readable **display string**
98    /// keyed to the series' frequency, **not** an ISO `YYYY-MM-DD` date (unlike
99    /// every date *input*, including the request's `observation_date`). It is
100    /// therefore **not round-trippable**: it cannot be parsed deterministically
101    /// or fed back into any date parameter. `None` when values weren't requested
102    /// or the element carries no series.
103    #[serde(default)]
104    pub observation_date: Option<String>,
105
106    /// The child elements nested beneath this one (empty for a leaf).
107    #[serde(default)]
108    pub children: Vec<ReleaseTableElement>,
109}
110
111/// Deserialize FRED's `elements` object (keyed by element id) into the ordered
112/// vector of *root* elements of the returned tree.
113///
114/// FRED flattens the object: for a subtree request (`element_id`) it keys
115/// **every descendant** of the requested element, and each value *also* carries
116/// its own subtree inline via [`children`](ReleaseTableElement::children). Taking
117/// every value as a root would therefore surface each non-root node twice — once
118/// here and once under its parent's `children` — so a consumer walking the tree
119/// double-counts. A value is a true root of the returned tree only when its
120/// `parent_id` is absent from the object's keys (the requested element itself is
121/// not in the object, so its direct children qualify; a whole-release request's
122/// top-level sections carry a null `parent_id` and qualify too). Every other node
123/// stays reachable solely through its parent's `children`. Ordering is by element
124/// id, so the result is deterministic regardless of the object's key order.
125fn roots_from_map<'de, D>(deserializer: D) -> Result<Vec<ReleaseTableElement>, D::Error>
126where
127    D: Deserializer<'de>,
128{
129    // Keys are stringified ids; each value already carries its own element_id.
130    let map: BTreeMap<String, ReleaseTableElement> = BTreeMap::deserialize(deserializer)?;
131    let ids: HashSet<ReleaseElementId> = map.values().map(|element| element.element_id).collect();
132    let mut roots: Vec<ReleaseTableElement> = map
133        .into_values()
134        .filter(|element| {
135            element
136                .parent_id
137                .is_none_or(|parent| !ids.contains(&parent))
138        })
139        .collect();
140    roots.sort_by_key(|element| element.element_id);
141    Ok(roots)
142}
143
144/// Deserialize a `series_id` that FRED sends as `null`, an empty string, or a
145/// real id, mapping the first two to `None`.
146fn optional_series_id<'de, D>(deserializer: D) -> Result<Option<SeriesId>, D::Error>
147where
148    D: Deserializer<'de>,
149{
150    let raw: Option<String> = Option::deserialize(deserializer)?;
151    Ok(raw.filter(|id| !id.is_empty()).map(SeriesId::new))
152}
153
154/// Deserialize a release-table element's `observation_value`: `"."` and the
155/// empty string → `None`, otherwise parse the string as `f64`. Unlike the
156/// `observations` endpoint's raw values, `release/tables` returns
157/// **display-formatted** strings in US format — comma thousands-separators with
158/// a `.` decimal point (a GDP aggregate arrives as `"27,000.0"`, not `"27000.0"`) —
159/// so the commas are stripped before parsing; the `.` decimal point is left
160/// intact. Mirrors [`Observation`](crate::Observation)'s value handling; paired
161/// with `#[serde(default)]`, so an absent field — values not requested, or a
162/// structural element — also yields `None`. A present, non-`"."`, non-empty
163/// value that still fails to parse is an error, not a silent `None`.
164fn deserialize_optional_value<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
165where
166    D: Deserializer<'de>,
167{
168    let raw = String::deserialize(deserializer)?;
169    if raw == "." || raw.is_empty() {
170        return Ok(None);
171    }
172    raw.replace(',', "")
173        .parse::<f64>()
174        .map(Some)
175        .map_err(serde::de::Error::custom)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    /// A two-level tree: a section root containing one series row, which in turn
183    /// has a series child. Mirrors the real `fred/release/tables` shape (nulls
184    /// for structural elements, a real series_id on a leaf).
185    const TABLE_BODY: &str = r#"{
186        "name": null,
187        "element_id": null,
188        "release_id": "10",
189        "elements": {
190            "34483": {
191                "element_id": 34483, "release_id": 10, "parent_id": null,
192                "series_id": null, "type": "section", "name": "Monthly, SA",
193                "line": null, "level": "0",
194                "children": [
195                    {
196                        "element_id": 34484, "release_id": 10, "parent_id": 34483,
197                        "series_id": "", "type": "series", "name": "All items",
198                        "line": "1", "level": "1",
199                        "children": [
200                            {
201                                "element_id": 34485, "release_id": 10, "parent_id": 34484,
202                                "series_id": "CPIFABSL", "type": "series",
203                                "name": "Food and beverages", "line": "2", "level": "2",
204                                "children": []
205                            }
206                        ]
207                    }
208                ]
209            }
210        }
211    }"#;
212
213    #[test]
214    fn deserializes_a_nested_table() {
215        let table: ReleaseTable = serde_json::from_str(TABLE_BODY).unwrap();
216
217        // Whole-release request: no requested-element name/id.
218        assert!(table.name.is_none());
219        assert!(table.element_id.is_none());
220
221        assert_eq!(table.roots.len(), 1);
222        let section = &table.roots[0];
223        assert_eq!(section.element_id, ReleaseElementId::new(34483));
224        assert_eq!(section.element_type, "section");
225        assert!(section.parent_id.is_none());
226        assert!(section.series_id.is_none()); // null → None
227        assert_eq!(section.children.len(), 1);
228
229        let all_items = &section.children[0];
230        assert_eq!(all_items.parent_id, Some(ReleaseElementId::new(34483)));
231        assert!(all_items.series_id.is_none()); // "" → None
232        assert_eq!(all_items.line.as_deref(), Some("1"));
233
234        let leaf = &all_items.children[0];
235        assert_eq!(leaf.series_id, Some(SeriesId::new("CPIFABSL")));
236        assert_eq!(leaf.element_type, "series");
237        assert!(leaf.children.is_empty());
238    }
239
240    /// A subtree request: FRED *flattens* `elements`, keying **every descendant**
241    /// of the requested element (here 12886) while *also* nesting each node's
242    /// subtree under its parent's `children`. Only the requested element's direct
243    /// children (12887, 12890) are true roots; 12888/12889 must appear solely
244    /// under 12887, never promoted into `roots`. Regression test for the
245    /// descendant-duplication bug (#55).
246    #[test]
247    fn subtree_request_keeps_only_true_roots_without_duplication() {
248        let body = r#"{
249            "name": "Personal consumption expenditures",
250            "element_id": 12886,
251            "release_id": "53",
252            "elements": {
253                "12887": {
254                    "element_id": 12887, "release_id": 53, "parent_id": 12886,
255                    "series_id": null, "type": "section", "name": "Goods",
256                    "line": null, "level": "1",
257                    "children": [
258                        {
259                            "element_id": 12888, "release_id": 53, "parent_id": 12887,
260                            "series_id": "DDURRL1A225NBEA", "type": "series",
261                            "name": "Durable goods", "line": null, "level": "2",
262                            "children": []
263                        },
264                        {
265                            "element_id": 12889, "release_id": 53, "parent_id": 12887,
266                            "series_id": "DNDGRL1A225NBEA", "type": "series",
267                            "name": "Nondurable goods", "line": null, "level": "2",
268                            "children": []
269                        }
270                    ]
271                },
272                "12888": {
273                    "element_id": 12888, "release_id": 53, "parent_id": 12887,
274                    "series_id": "DDURRL1A225NBEA", "type": "series",
275                    "name": "Durable goods", "line": null, "level": "2", "children": []
276                },
277                "12889": {
278                    "element_id": 12889, "release_id": 53, "parent_id": 12887,
279                    "series_id": "DNDGRL1A225NBEA", "type": "series",
280                    "name": "Nondurable goods", "line": null, "level": "2", "children": []
281                },
282                "12890": {
283                    "element_id": 12890, "release_id": 53, "parent_id": 12886,
284                    "series_id": null, "type": "section", "name": "Services",
285                    "line": null, "level": "1", "children": []
286                }
287            }
288        }"#;
289        let table: ReleaseTable = serde_json::from_str(body).unwrap();
290        assert_eq!(table.element_id, Some(ReleaseElementId::new(12886)));
291
292        // Only the requested element's direct children are roots.
293        let root_ids: Vec<u32> = table.roots.iter().map(|e| e.element_id.get()).collect();
294        assert_eq!(root_ids, vec![12887, 12890]);
295
296        // 12888 / 12889 are reachable only under 12887, not promoted to roots.
297        let goods = &table.roots[0];
298        assert_eq!(goods.element_id, ReleaseElementId::new(12887));
299        let child_ids: Vec<u32> = goods.children.iter().map(|e| e.element_id.get()).collect();
300        assert_eq!(child_ids, vec![12888, 12889]);
301
302        // No element id appears more than once across the whole tree.
303        fn collect(node: &ReleaseTableElement, out: &mut Vec<u32>) {
304            out.push(node.element_id.get());
305            for child in &node.children {
306                collect(child, out);
307            }
308        }
309        let mut all = Vec::new();
310        for root in &table.roots {
311            collect(root, &mut all);
312        }
313        let mut deduped = all.clone();
314        deduped.sort_unstable();
315        deduped.dedup();
316        assert_eq!(all.len(), deduped.len(), "no element should appear twice");
317    }
318
319    #[test]
320    fn observation_values_deserialize_when_present() {
321        // Mirrors the live `include_observation_values=true` shape: series rows
322        // carry `observation_value` (a stringly-typed number, or "." for
323        // missing) and a frequency-formatted `observation_date`; structural
324        // elements carry neither.
325        let body = r#"{
326            "release_id": "10",
327            "elements": {
328                "36714": {
329                    "element_id": 36714, "release_id": 10, "type": "table",
330                    "name": "Monthly, Seasonally Adjusted", "level": "0",
331                    "children": [
332                        {
333                            "element_id": 36715, "release_id": 10, "parent_id": 36714,
334                            "series_id": "CUSR0000SA0L5", "type": "series",
335                            "name": "All items less medical care", "level": "1",
336                            "observation_value": "292.260", "observation_date": "Jun 2023",
337                            "children": []
338                        },
339                        {
340                            "element_id": 36716, "release_id": 10, "parent_id": 36714,
341                            "series_id": "CPILEGSL", "type": "series", "name": "Missing",
342                            "level": "1",
343                            "observation_value": ".", "observation_date": "Jun 2023",
344                            "children": []
345                        }
346                    ]
347                }
348            }
349        }"#;
350        let table: ReleaseTable = serde_json::from_str(body).unwrap();
351        let table_elem = &table.roots[0];
352        // Structural element: no value, no date.
353        assert_eq!(table_elem.observation_value, None);
354        assert_eq!(table_elem.observation_date, None);
355
356        let with_value = &table_elem.children[0];
357        assert_eq!(with_value.observation_value, Some(292.260));
358        assert_eq!(with_value.observation_date.as_deref(), Some("Jun 2023"));
359
360        // FRED's "." sentinel maps to a missing value, not a parse error.
361        let missing = &table_elem.children[1];
362        assert_eq!(missing.observation_value, None);
363        assert_eq!(missing.observation_date.as_deref(), Some("Jun 2023"));
364    }
365
366    #[test]
367    fn comma_formatted_observation_values_deserialize() {
368        // `release/tables` returns display-formatted values: US formatting with
369        // comma thousands-separators (GDP dollar aggregates like "27,000.0").
370        // Regression test for #77 — these must parse, not blow up the whole tree
371        // with `invalid float literal`. Also pins empty-string → None alongside
372        // the "." sentinel, and that a genuinely non-numeric value still errors.
373        let body = |value: &str| {
374            format!(
375                r#"{{
376                    "release_id": "53",
377                    "elements": {{
378                        "12998": {{
379                            "element_id": 12998, "release_id": 53, "type": "series",
380                            "series_id": "GDP", "name": "Gross domestic product",
381                            "level": "0",
382                            "observation_value": "{value}", "observation_date": "2023",
383                            "children": []
384                        }}
385                    }}
386                }}"#
387            )
388        };
389
390        let parse = |value: &str| -> Result<Option<f64>, _> {
391            serde_json::from_str::<ReleaseTable>(&body(value))
392                .map(|table| table.roots[0].observation_value)
393        };
394
395        // Comma thousands-separator, decimal point preserved.
396        assert_eq!(parse("27,000.0").unwrap(), Some(27000.0));
397        // Multiple commas (millions) round-trip too.
398        assert_eq!(parse("1,234,567.89").unwrap(), Some(1234567.89));
399        // No separator (small value) still parses.
400        assert_eq!(parse("332.568").unwrap(), Some(332.568));
401        // Missing-value sentinels.
402        assert_eq!(parse(".").unwrap(), None);
403        assert_eq!(parse("").unwrap(), None);
404        // A present, non-numeric value is still a hard error, not a silent None.
405        assert!(parse("N/A").is_err());
406    }
407
408    #[test]
409    fn observation_values_absent_when_not_requested() {
410        // The base (structure-only) shape has no value/date fields at all.
411        let table: ReleaseTable = serde_json::from_str(TABLE_BODY).unwrap();
412        let leaf = &table.roots[0].children[0].children[0];
413        assert_eq!(leaf.observation_value, None);
414        assert_eq!(leaf.observation_date, None);
415    }
416
417    #[test]
418    fn roots_are_ordered_by_element_id() {
419        // Object key order is largest-first; roots must come back id-ascending.
420        let body = r#"{
421            "elements": {
422                "200": {"element_id":200,"release_id":1,"type":"table","name":"B","level":"0"},
423                "100": {"element_id":100,"release_id":1,"type":"table","name":"A","level":"0"}
424            }
425        }"#;
426        let table: ReleaseTable = serde_json::from_str(body).unwrap();
427        let ids: Vec<u32> = table.roots.iter().map(|e| e.element_id.get()).collect();
428        assert_eq!(ids, [100, 200]);
429    }
430}