Skip to main content

tfparser_core/exporter/
schema.rs

1//! Arrow schema for `resources.parquet`.
2//!
3//! The schema is **authoritative** — `tests/golden/resources-schema.json`
4//! pins it and CI fails on drift. New columns may be appended at the end
5//! for additive evolution (per [10-data-model.md § 6]); existing columns
6//! cannot be renamed, retyped, or reordered.
7//!
8//! [10-data-model.md § 6]: ../../../specs/10-data-model.md
9
10use std::sync::Arc;
11
12use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
13
14/// Parser version embedded in every emitted row.
15///
16/// Bound to `tfparser-core`'s crate version so consumers can branch on
17/// upgrades.
18pub const PARSER_VERSION: &str = env!("CARGO_PKG_VERSION");
19
20/// Parquet schema major version. Bumps require a deprecation cycle.
21pub const SCHEMA_MAJOR: u32 = 0;
22/// Parquet schema minor version. Bumps are additive.
23pub const SCHEMA_MINOR: u32 = 1;
24
25/// Build the canonical `resources.parquet` schema.
26#[must_use]
27pub fn resources_schema() -> Schema {
28    Schema::new(vec![
29        utf8_field("workspace_root"),
30        utf8_field("component_path"),
31        utf8_field("module_path"),
32        utf8_field("address"),
33        utf8_field("kind"),
34        utf8_field("resource_type"),
35        utf8_field("resource_name"),
36        utf8_field("provider_local"),
37        utf8_field("provider_source"),
38        utf8_field("account_id"),
39        utf8_field("account_name"),
40        utf8_field("region"),
41        utf8_field("environment"),
42        utf8_field("count_expr"),
43        utf8_field("for_each_expr"),
44        Field::new(
45            "depends_on",
46            DataType::List(Arc::new(Field::new("item", DataType::Utf8, false))),
47            false,
48        ),
49        utf8_field("attributes_json"),
50        utf8_field("state_account_id"),
51        utf8_field("state_region"),
52        utf8_field("file"),
53        Field::new("line", DataType::UInt32, false),
54        Field::new("column", DataType::UInt32, false),
55        utf8_field("parser_version"),
56        Field::new(
57            "parsed_at",
58            DataType::Timestamp(TimeUnit::Millisecond, Some(Arc::from("UTC"))),
59            false,
60        ),
61    ])
62}
63
64/// Ordered list of column names for the resources schema.
65///
66/// Used by the golden-test harness and by `tfparser schema` to dump the
67/// schema for downstream tools without round-tripping through Arrow.
68#[must_use]
69pub fn schema_field_names() -> Vec<&'static str> {
70    vec![
71        "workspace_root",
72        "component_path",
73        "module_path",
74        "address",
75        "kind",
76        "resource_type",
77        "resource_name",
78        "provider_local",
79        "provider_source",
80        "account_id",
81        "account_name",
82        "region",
83        "environment",
84        "count_expr",
85        "for_each_expr",
86        "depends_on",
87        "attributes_json",
88        "state_account_id",
89        "state_region",
90        "file",
91        "line",
92        "column",
93        "parser_version",
94        "parsed_at",
95    ]
96}
97
98fn utf8_field(name: &'static str) -> Field {
99    Field::new(name, DataType::Utf8, false)
100}
101
102#[cfg(test)]
103#[allow(
104    clippy::unwrap_used,
105    clippy::expect_used,
106    clippy::panic,
107    clippy::indexing_slicing
108)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_schema_has_24_columns() {
114        let s = resources_schema();
115        assert_eq!(s.fields().len(), 24);
116    }
117
118    #[test]
119    fn test_schema_columns_match_documented_names() {
120        let s = resources_schema();
121        let got: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
122        assert_eq!(got, schema_field_names());
123    }
124
125    #[test]
126    fn test_depends_on_is_non_null_list_of_utf8() {
127        let s = resources_schema();
128        let depends_on = s.field_with_name("depends_on").unwrap();
129        assert!(!depends_on.is_nullable());
130        match depends_on.data_type() {
131            DataType::List(item) => {
132                assert_eq!(item.data_type(), &DataType::Utf8);
133                assert!(!item.is_nullable());
134            }
135            other => panic!("expected List<Utf8>, got {other:?}"),
136        }
137    }
138
139    #[test]
140    fn test_parsed_at_is_timestamp_ms_utc() {
141        let s = resources_schema();
142        let f = s.field_with_name("parsed_at").unwrap();
143        match f.data_type() {
144            DataType::Timestamp(TimeUnit::Millisecond, tz) => {
145                assert_eq!(tz.as_deref(), Some("UTC"));
146            }
147            other => panic!("expected Timestamp(ms, UTC), got {other:?}"),
148        }
149    }
150
151    #[test]
152    fn test_no_nullable_columns() {
153        let s = resources_schema();
154        for f in s.fields() {
155            assert!(!f.is_nullable(), "column `{}` is nullable", f.name());
156        }
157    }
158
159    #[test]
160    fn test_field_names_match_schema_size() {
161        let s = resources_schema();
162        assert_eq!(s.fields().len(), schema_field_names().len());
163    }
164}