1use std::{
13 fs::{self, File, OpenOptions},
14 io::BufWriter,
15 path::{Path, PathBuf},
16 sync::Arc,
17};
18
19use arrow::{
20 array::{
21 ArrayRef, BooleanBuilder, ListBuilder, RecordBatch, StringBuilder, UInt32Builder,
22 UInt64Builder,
23 },
24 datatypes::{DataType, Field, Schema},
25};
26use parquet::{arrow::ArrowWriter, file::properties::WriterProperties};
27
28use super::{ExportError, schema::PARSER_VERSION, writer::CompressionOpt};
29use crate::ir::{AccountId, Component, Edge, Module, ModuleSource, Region, Workspace};
30
31#[must_use]
37pub fn dependencies_schema() -> Schema {
38 Schema::new(vec![
39 utf8("from_address"),
40 utf8("to_address"),
41 utf8("edge_kind"),
42 utf8("source_attr"),
43 utf8("file"),
44 Field::new("line", DataType::UInt32, false),
45 Field::new("column", DataType::UInt32, false),
46 ])
47}
48
49#[must_use]
51pub fn dependencies_field_names() -> Vec<&'static str> {
52 vec![
53 "from_address",
54 "to_address",
55 "edge_kind",
56 "source_attr",
57 "file",
58 "line",
59 "column",
60 ]
61}
62
63pub(crate) fn write_dependencies_parquet(
65 ws: &Workspace,
66 final_path: &Path,
67 compression: CompressionOpt,
68) -> Result<(u64, u64), ExportError> {
69 let schema = Arc::new(dependencies_schema());
70
71 let edges: &[Edge] = ws.edges.as_slice();
75 debug_assert!(
76 edges.iter().zip(edges.iter().skip(1)).all(|(a, b)| (
77 a.from.as_str(),
78 a.to.as_str(),
79 a.kind.as_str()
80 ) <= (
81 b.from.as_str(),
82 b.to.as_str(),
83 b.kind.as_str()
84 )),
85 "graph::edges::collect_edges_in_place must produce sorted edges"
86 );
87 let rows = edges.len();
88
89 let mut from = StringBuilder::with_capacity(rows, rows * 48);
90 let mut to = StringBuilder::with_capacity(rows, rows * 48);
91 let mut kind = StringBuilder::with_capacity(rows, rows * 24);
92 let mut attr = StringBuilder::with_capacity(rows, rows * 16);
93 let mut file = StringBuilder::with_capacity(rows, rows * 48);
94 let mut line = UInt32Builder::with_capacity(rows);
95 let mut column = UInt32Builder::with_capacity(rows);
96
97 for e in edges {
98 from.append_value(e.from.as_str());
99 to.append_value(e.to.as_str());
100 kind.append_value(e.kind.as_str());
101 attr.append_value(e.attr.as_deref().unwrap_or(""));
102 file.append_value(render_path(&e.span.file));
103 line.append_value(e.span.line);
104 column.append_value(e.span.column);
105 }
106
107 let columns: Vec<ArrayRef> = vec![
108 Arc::new(from.finish()),
109 Arc::new(to.finish()),
110 Arc::new(kind.finish()),
111 Arc::new(attr.finish()),
112 Arc::new(file.finish()),
113 Arc::new(line.finish()),
114 Arc::new(column.finish()),
115 ];
116 let batch = RecordBatch::try_new(Arc::clone(&schema), columns).map_err(|source| {
117 ExportError::Arrow {
118 path: Arc::from(final_path),
119 source,
120 }
121 })?;
122
123 write_single_batch(final_path, &schema, &batch, compression, rows as u64)
124}
125
126#[must_use]
137pub fn components_schema() -> Schema {
138 Schema::new(vec![
139 utf8("component_path"),
140 utf8("kind"),
141 Field::new("resource_count", DataType::UInt32, false),
142 Field::new("data_count", DataType::UInt32, false),
143 Field::new("module_call_count", DataType::UInt32, false),
144 Field::new("output_count", DataType::UInt32, false),
145 Field::new("variable_count", DataType::UInt32, false),
146 Field::new("local_count", DataType::UInt32, false),
147 Field::new("provider_count", DataType::UInt32, false),
148 Field::new(
149 "environments_seen",
150 DataType::List(Arc::new(Field::new("item", DataType::Utf8, false))),
151 false,
152 ),
153 Field::new("has_terragrunt", DataType::Boolean, false),
154 utf8("state_backend_kind"),
155 utf8("state_account_id"),
156 utf8("state_region"),
157 Field::new("unresolved_count", DataType::UInt64, false),
158 ])
159}
160
161#[must_use]
163pub fn components_field_names() -> Vec<&'static str> {
164 vec![
165 "component_path",
166 "kind",
167 "resource_count",
168 "data_count",
169 "module_call_count",
170 "output_count",
171 "variable_count",
172 "local_count",
173 "provider_count",
174 "environments_seen",
175 "has_terragrunt",
176 "state_backend_kind",
177 "state_account_id",
178 "state_region",
179 "unresolved_count",
180 ]
181}
182
183#[allow(clippy::too_many_lines)] pub(crate) fn write_components_parquet(
185 ws: &Workspace,
186 final_path: &Path,
187 compression: CompressionOpt,
188) -> Result<(u64, u64), ExportError> {
189 let schema = Arc::new(components_schema());
190
191 let mut sorted: Vec<&Component> = ws.components.iter().collect();
195 sorted.sort_by(|a, b| a.path.as_os_str().cmp(b.path.as_os_str()));
196
197 let rows = sorted.len();
198 let mut component_path = StringBuilder::with_capacity(rows, rows * 32);
199 let mut kind = StringBuilder::with_capacity(rows, rows * 12);
200 let mut resource_count = UInt32Builder::with_capacity(rows);
201 let mut data_count = UInt32Builder::with_capacity(rows);
202 let mut module_call_count = UInt32Builder::with_capacity(rows);
203 let mut output_count = UInt32Builder::with_capacity(rows);
204 let mut variable_count = UInt32Builder::with_capacity(rows);
205 let mut local_count = UInt32Builder::with_capacity(rows);
206 let mut provider_count = UInt32Builder::with_capacity(rows);
207 let mut environments_seen: ListBuilder<StringBuilder> = ListBuilder::with_capacity(
208 StringBuilder::new(),
209 rows,
210 )
211 .with_field(Arc::new(Field::new("item", DataType::Utf8, false)));
212 let mut has_terragrunt = BooleanBuilder::with_capacity(rows);
213 let mut state_backend_kind = StringBuilder::with_capacity(rows, rows * 8);
214 let mut state_account_id = StringBuilder::with_capacity(rows, rows * 12);
215 let mut state_region = StringBuilder::with_capacity(rows, rows * 12);
216 let mut unresolved_count = UInt64Builder::with_capacity(rows);
217
218 for c in &sorted {
219 component_path.append_value(render_path(&c.path));
220 kind.append_value(match c.kind {
221 crate::ir::ComponentKind::Component => "component",
222 crate::ir::ComponentKind::Module => "module",
223 });
224
225 let (n_res, n_data) =
226 c.resources
227 .iter()
228 .fold((0u32, 0u32), |(res, data), r| match r.kind {
229 crate::ir::ResourceKind::Managed => (res.saturating_add(1), data),
230 crate::ir::ResourceKind::Data => (res, data.saturating_add(1)),
231 });
232 resource_count.append_value(n_res);
233 data_count.append_value(n_data);
234 module_call_count.append_value(c.modules.len().try_into().unwrap_or(u32::MAX));
235 output_count.append_value(c.outputs.len().try_into().unwrap_or(u32::MAX));
236 variable_count.append_value(c.variables.len().try_into().unwrap_or(u32::MAX));
237 local_count.append_value(c.locals.len().try_into().unwrap_or(u32::MAX));
238 provider_count.append_value(c.providers.len().try_into().unwrap_or(u32::MAX));
239
240 let inner = environments_seen.values();
245 let mut envs: Vec<String> = Vec::new();
246 for env in &ws.environments {
247 envs.push(env.name.to_string());
250 }
251 envs.sort();
252 envs.dedup();
253 for e in envs {
254 inner.append_value(&e);
255 }
256 environments_seen.append(true);
257
258 has_terragrunt.append_value(c.terragrunt.is_some());
259
260 let backend = c
261 .terragrunt
262 .as_ref()
263 .and_then(|tg| tg.state_backend.as_ref())
264 .or(c.state_backend.as_ref());
265 state_backend_kind.append_value(backend.map_or("", |b| b.kind.as_ref()));
266 state_account_id.append_value(
267 backend
268 .and_then(|b| b.state_account_id.as_ref())
269 .map_or("", AccountId::as_str),
270 );
271 state_region.append_value(
272 backend
273 .and_then(|b| b.state_region.as_ref())
274 .map_or("", Region::as_str),
275 );
276
277 let unres = count_unresolved(c);
278 unresolved_count.append_value(unres);
279 }
280
281 let columns: Vec<ArrayRef> = vec![
282 Arc::new(component_path.finish()),
283 Arc::new(kind.finish()),
284 Arc::new(resource_count.finish()),
285 Arc::new(data_count.finish()),
286 Arc::new(module_call_count.finish()),
287 Arc::new(output_count.finish()),
288 Arc::new(variable_count.finish()),
289 Arc::new(local_count.finish()),
290 Arc::new(provider_count.finish()),
291 Arc::new(environments_seen.finish()),
292 Arc::new(has_terragrunt.finish()),
293 Arc::new(state_backend_kind.finish()),
294 Arc::new(state_account_id.finish()),
295 Arc::new(state_region.finish()),
296 Arc::new(unresolved_count.finish()),
297 ];
298
299 let batch = RecordBatch::try_new(Arc::clone(&schema), columns).map_err(|source| {
300 ExportError::Arrow {
301 path: Arc::from(final_path),
302 source,
303 }
304 })?;
305 write_single_batch(final_path, &schema, &batch, compression, rows as u64)
306}
307
308fn count_unresolved(c: &Component) -> u64 {
309 use crate::ir::Expression;
310
311 fn walk(expr: &Expression, n: &mut u64) {
312 match expr {
313 Expression::Literal(_) => {}
314 Expression::Unresolved(_) => *n = n.saturating_add(1),
315 Expression::BinaryOp { lhs, rhs, .. } => {
316 walk(lhs, n);
317 walk(rhs, n);
318 }
319 Expression::UnaryOp { operand, .. } => walk(operand, n),
320 Expression::TemplateConcat(parts) | Expression::Array(parts) => {
321 for p in parts {
322 walk(p, n);
323 }
324 }
325 Expression::Object(entries) => {
326 for (k, v) in entries {
327 walk(k, n);
328 walk(v, n);
329 }
330 }
331 Expression::FuncCall(call) => {
332 for a in &call.args {
333 walk(a, n);
334 }
335 }
336 Expression::Conditional(cnd) => {
337 walk(&cnd.cond, n);
338 walk(&cnd.then_branch, n);
339 walk(&cnd.else_branch, n);
340 }
341 Expression::For(f) => {
342 walk(&f.collection, n);
343 walk(&f.value, n);
344 if let Some(k) = &f.key {
345 walk(k, n);
346 }
347 if let Some(cd) = &f.cond {
348 walk(cd, n);
349 }
350 }
351 }
352 }
353
354 let mut total: u64 = 0;
355 for r in &c.resources {
356 for (_, e) in &r.attributes {
357 walk(e, &mut total);
358 }
359 }
360 for m in &c.modules {
361 for (_, e) in &m.inputs {
362 walk(e, &mut total);
363 }
364 }
365 for o in &c.outputs {
366 walk(&o.value, &mut total);
367 }
368 for l in &c.locals {
369 walk(&l.value, &mut total);
370 }
371 total
372}
373
374#[must_use]
380pub fn modules_schema() -> Schema {
381 Schema::new(vec![
382 utf8("module_id"),
383 utf8("source_raw"),
384 utf8("source_kind"),
385 utf8("canonical_path"),
386 Field::new("call_count", DataType::UInt32, false),
387 Field::new("resolved", DataType::Boolean, false),
388 ])
389}
390
391#[must_use]
393pub fn modules_field_names() -> Vec<&'static str> {
394 vec![
395 "module_id",
396 "source_raw",
397 "source_kind",
398 "canonical_path",
399 "call_count",
400 "resolved",
401 ]
402}
403
404pub(crate) fn write_modules_parquet(
405 ws: &Workspace,
406 final_path: &Path,
407 compression: CompressionOpt,
408) -> Result<(u64, u64), ExportError> {
409 let schema = Arc::new(modules_schema());
410
411 let mut rows: Vec<ModuleRow> = Vec::new();
417 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
418 for c in &ws.components {
419 for m in &c.modules {
420 let entry = counts.entry(source_key(&m.source)).or_insert(0);
421 *entry = entry.saturating_add(1);
422 }
423 }
424 for module in &ws.modules {
425 let key = source_key(&module.source);
426 let call_count = counts.get(&key).copied().unwrap_or(0);
427 rows.push(ModuleRow::from_walked(module, call_count));
428 }
429
430 let walked: std::collections::HashSet<String> =
433 ws.modules.iter().map(|m| source_key(&m.source)).collect();
434 for c in &ws.components {
435 for m in &c.modules {
436 let key = source_key(&m.source);
437 if walked.contains(&key) {
438 continue;
439 }
440 let call_count = counts.get(&key).copied().unwrap_or(0);
441 rows.push(ModuleRow::from_call_site(m, call_count));
442 }
443 }
444
445 rows.sort_by(|a, b| {
449 (a.source_raw.as_str(), a.source_kind.as_str())
450 .cmp(&(b.source_raw.as_str(), b.source_kind.as_str()))
451 });
452 rows.dedup_by(|a, b| a.source_raw == b.source_raw && a.source_kind == b.source_kind);
453
454 let row_count = rows.len();
455 let mut id = StringBuilder::with_capacity(row_count, row_count * 8);
456 let mut source_raw = StringBuilder::with_capacity(row_count, row_count * 64);
457 let mut source_kind = StringBuilder::with_capacity(row_count, row_count * 12);
458 let mut canonical_path = StringBuilder::with_capacity(row_count, row_count * 64);
459 let mut call_count = UInt32Builder::with_capacity(row_count);
460 let mut resolved = BooleanBuilder::with_capacity(row_count);
461
462 for r in &rows {
463 id.append_value(&r.module_id);
464 source_raw.append_value(&r.source_raw);
465 source_kind.append_value(&r.source_kind);
466 canonical_path.append_value(&r.canonical_path);
467 call_count.append_value(r.call_count);
468 resolved.append_value(r.resolved);
469 }
470
471 let columns: Vec<ArrayRef> = vec![
472 Arc::new(id.finish()),
473 Arc::new(source_raw.finish()),
474 Arc::new(source_kind.finish()),
475 Arc::new(canonical_path.finish()),
476 Arc::new(call_count.finish()),
477 Arc::new(resolved.finish()),
478 ];
479 let batch = RecordBatch::try_new(Arc::clone(&schema), columns).map_err(|source| {
480 ExportError::Arrow {
481 path: Arc::from(final_path),
482 source,
483 }
484 })?;
485 write_single_batch(final_path, &schema, &batch, compression, row_count as u64)
486}
487
488#[derive(Clone, Debug)]
489struct ModuleRow {
490 module_id: String,
491 source_raw: String,
492 source_kind: String,
493 canonical_path: String,
494 call_count: u32,
495 resolved: bool,
496}
497
498impl ModuleRow {
499 fn from_walked(m: &Module, call_count: u32) -> Self {
500 Self {
501 module_id: format!("{}", m.id.get()),
502 source_raw: source_raw_str(&m.source),
503 source_kind: source_kind_str(&m.source).to_string(),
504 canonical_path: m
505 .canonical_path
506 .as_deref()
507 .map(render_path)
508 .unwrap_or_default(),
509 call_count,
510 resolved: m.canonical_path.is_some(),
511 }
512 }
513
514 fn from_call_site(m: &crate::ir::ModuleCall, call_count: u32) -> Self {
515 Self {
516 module_id: String::new(),
517 source_raw: m.source_raw.to_string(),
518 source_kind: source_kind_str(&m.source).to_string(),
519 canonical_path: String::new(),
520 call_count,
521 resolved: false,
522 }
523 }
524}
525
526fn source_raw_str(s: &ModuleSource) -> String {
527 match s {
528 ModuleSource::Local(v)
529 | ModuleSource::Registry(v)
530 | ModuleSource::Git(v)
531 | ModuleSource::External(v) => v.to_string(),
532 }
533}
534
535fn source_kind_str(s: &ModuleSource) -> &'static str {
536 match s {
537 ModuleSource::Local(_) => "local",
538 ModuleSource::Registry(_) => "registry",
539 ModuleSource::Git(_) => "git",
540 ModuleSource::External(_) => "external",
541 }
542}
543
544fn source_key(s: &ModuleSource) -> String {
545 format!("{}|{}", source_kind_str(s), source_raw_str(s))
546}
547
548fn utf8(name: &'static str) -> Field {
553 Field::new(name, DataType::Utf8, false)
554}
555
556fn render_path(p: &Path) -> String {
557 let mut out = String::with_capacity(p.as_os_str().len());
558 for (idx, c) in p.components().enumerate() {
559 if idx > 0 {
560 out.push('/');
561 }
562 match c {
563 std::path::Component::Normal(s) => out.push_str(&s.to_string_lossy()),
564 std::path::Component::ParentDir => out.push_str(".."),
565 std::path::Component::CurDir => out.push('.'),
566 std::path::Component::Prefix(_) | std::path::Component::RootDir => {
567 out.push_str(&c.as_os_str().to_string_lossy());
568 }
569 }
570 }
571 out
572}
573
574fn write_single_batch(
575 final_path: &Path,
576 schema: &Arc<Schema>,
577 batch: &RecordBatch,
578 compression: CompressionOpt,
579 rows: u64,
580) -> Result<(u64, u64), ExportError> {
581 let partial = partial_path(final_path);
582 if partial.exists() {
583 fs::remove_file(&partial).map_err(|source| ExportError::Io {
584 path: Arc::from(partial.as_path()),
585 source,
586 })?;
587 }
588 let file = OpenOptions::new()
589 .write(true)
590 .create_new(true)
591 .open(&partial)
592 .map_err(|source| ExportError::Io {
593 path: Arc::from(partial.as_path()),
594 source,
595 })?;
596 let buf = BufWriter::with_capacity(64 * 1024, file);
597
598 let props = WriterProperties::builder()
599 .set_compression(compression.to_parquet_compression())
600 .set_key_value_metadata(Some(vec![
601 parquet::file::metadata::KeyValue::new(
602 "tfparser.schema.major".to_string(),
603 Some(super::SCHEMA_MAJOR.to_string()),
604 ),
605 parquet::file::metadata::KeyValue::new(
606 "tfparser.schema.minor".to_string(),
607 Some(super::SCHEMA_MINOR.to_string()),
608 ),
609 parquet::file::metadata::KeyValue::new(
610 "tfparser.parser.version".to_string(),
611 Some(PARSER_VERSION.to_string()),
612 ),
613 ]))
614 .build();
615 let mut writer =
616 ArrowWriter::try_new(buf, Arc::clone(schema), Some(props)).map_err(|source| {
617 ExportError::Parquet {
618 path: Arc::from(partial.as_path()),
619 source,
620 }
621 })?;
622 if rows > 0 {
623 writer.write(batch).map_err(|source| ExportError::Parquet {
624 path: Arc::from(partial.as_path()),
625 source,
626 })?;
627 }
628 let inner: BufWriter<File> = writer.into_inner().map_err(|source| ExportError::Parquet {
629 path: Arc::from(partial.as_path()),
630 source,
631 })?;
632 let file = inner.into_inner().map_err(|err| ExportError::Io {
633 path: Arc::from(partial.as_path()),
634 source: err.into_error(),
635 })?;
636 file.sync_all().map_err(|source| ExportError::Io {
637 path: Arc::from(partial.as_path()),
638 source,
639 })?;
640 drop(file);
641
642 fs::rename(&partial, final_path).map_err(|source| ExportError::Io {
643 path: Arc::from(partial.as_path()),
644 source,
645 })?;
646 let bytes = fs::metadata(final_path)
647 .map(|m| m.len())
648 .map_err(|source| ExportError::Io {
649 path: Arc::from(final_path),
650 source,
651 })?;
652 Ok((rows, bytes))
653}
654
655fn partial_path(final_path: &Path) -> PathBuf {
656 let mut s: std::ffi::OsString = final_path.as_os_str().to_os_string();
657 s.push(".partial");
658 PathBuf::from(s)
659}
660
661trait CompressionOptExt {
665 fn to_parquet_compression(self) -> parquet::basic::Compression;
666}
667
668impl CompressionOptExt for CompressionOpt {
669 fn to_parquet_compression(self) -> parquet::basic::Compression {
670 use parquet::basic::{Compression, ZstdLevel};
671 match self {
672 Self::Uncompressed => Compression::UNCOMPRESSED,
673 Self::Zstd(level) => Compression::ZSTD(ZstdLevel::try_new(level).unwrap_or_default()),
674 Self::Snappy => Compression::SNAPPY,
675 }
676 }
677}
678
679#[cfg(test)]
680#[allow(
681 clippy::unwrap_used,
682 clippy::expect_used,
683 clippy::panic,
684 clippy::indexing_slicing
685)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn test_dependencies_schema_columns_match_documented() {
691 let s = dependencies_schema();
692 let got: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
693 assert_eq!(got, dependencies_field_names());
694 assert_eq!(s.fields().len(), 7);
695 }
696
697 #[test]
698 fn test_components_schema_columns_match_documented() {
699 let s = components_schema();
700 let got: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
701 assert_eq!(got, components_field_names());
702 }
703
704 #[test]
705 fn test_modules_schema_columns_match_documented() {
706 let s = modules_schema();
707 let got: Vec<&str> = s.fields().iter().map(|f| f.name().as_str()).collect();
708 assert_eq!(got, modules_field_names());
709 }
710
711 #[test]
712 fn test_no_nullable_columns_in_secondary_schemas() {
713 for s in [dependencies_schema(), components_schema(), modules_schema()] {
714 for f in s.fields() {
715 assert!(!f.is_nullable(), "column `{}` nullable", f.name());
716 }
717 }
718 }
719}