Skip to main content

tfparser_core/exporter/
secondary.rs

1//! Secondary Parquet tables — `dependencies.parquet`, `components.parquet`,
2//! `modules.parquet` — produced alongside `resources.parquet` per
3//! [10-data-model.md § 5] and [15-resource-graph.md § 5].
4//!
5//! Each writer follows the same pattern as the primary `resources` writer
6//! (atomic `<file>.partial → rename`, zstd-3 default, deterministic row
7//! order, no nullable columns).
8//!
9//! [10-data-model.md § 5]: ../../../specs/10-data-model.md
10//! [15-resource-graph.md § 5]: ../../../specs/15-resource-graph.md
11
12use 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// ----------------------------------------------------------------------------
32// dependencies.parquet
33// ----------------------------------------------------------------------------
34
35/// Build the canonical `dependencies.parquet` schema (spec 10 § 5.1).
36#[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/// Column names in declaration order.
50#[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
63/// Write `dependencies.parquet` to `final_path`. Returns (rows, bytes).
64pub(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    // `graph::edges::collect_edges_in_place` already sorts by
72    // `(from, to, kind)`. Borrowing directly avoids the redundant
73    // re-sort the original `sorted_edges` helper did (P-094 closed).
74    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// P-094 closed (2026-05-14): the workspace edges are already sorted by
127// `graph::edges::collect_edges_in_place`. The helper that re-sorted them
128// here is gone; the writer borrows `ws.edges` directly with a
129// `debug_assert!` invariant check (see `write_dependencies_parquet`).
130
131// ----------------------------------------------------------------------------
132// components.parquet
133// ----------------------------------------------------------------------------
134
135/// Build the canonical `components.parquet` schema (spec 15 § 5).
136#[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/// Column names in declaration order.
162#[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)] // Mirrors the column-by-column writer pattern from `writer.rs`; splitting is churn.
184pub(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    // Deterministic order — sort by path. Note `Workspace.components` is
192    // already sorted by path (I-GRAPH-5), but tests pass partial
193    // workspaces through the writer too.
194    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        // `environments_seen` — Phase 6+ populates this from the
241        // Terragrunt include chain (`*.terragrunt.hcl` filename roots).
242        // Phase 8 ships an empty list; an `if let Some(tg)` block here
243        // would expand it once the cascade exposes the data deterministically.
244        let inner = environments_seen.values();
245        let mut envs: Vec<String> = Vec::new();
246        for env in &ws.environments {
247            // Discoverable environments are workspace-wide; surface them
248            // for every component until the cascade-narrowing pass lands.
249            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// ----------------------------------------------------------------------------
375// modules.parquet
376// ----------------------------------------------------------------------------
377
378/// Build the canonical `modules.parquet` schema (spec 10 § 5.3).
379#[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/// Column names in declaration order.
392#[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    // Index every distinct `ModuleSource` observed across all components'
412    // `module "x" { source = ... }` call sites, then emit one row per
413    // source. Walked modules (kind=Module in workspace.modules) supply
414    // the canonical path; unwalked external sources still appear with
415    // an empty `canonical_path`.
416    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    // Surface sources that appeared in a `module "x"` block but did not
431    // get a walked-body entry (registry / git / external).
432    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    // Dedup by `source_raw` × `source_kind` (the rows already de-emerge
446    // from walked / unwalked split, but a `Local` module might also be
447    // declared twice with the same source).
448    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
548// ----------------------------------------------------------------------------
549// Shared writer plumbing
550// ----------------------------------------------------------------------------
551
552fn 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
661// `EdgeKind::as_str` is in the IR; convenience local trait shim for
662// CompressionOpt → parquet::basic::Compression mapping (the writer
663// module's helper is private).
664trait 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}