Skip to main content

backbeat_cli/
views.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! Generates DuckDB query DDL from a dump's schema registry, and assembles it with the
5//! consumer-registered view sets carried in the dump.
6//!
7//! `convert` writes one sparse-wide Parquet table (see [`crate::convert`]). The DDL here turns that
8//! flat table into an ergonomic query surface so an agent need not know the column layout:
9//!
10//! * a base view **`events`** every other statement builds on — the only thing bound to the Parquet
11//!   path (so the rest is path-independent and can live in the dump/footer verbatim);
12//! * one **per-event-type view** (named by the event's qualified name) selecting just that event's
13//!   rows, so `SELECT * FROM "my::Event"` beats filtering the wide table by hand;
14//! * a **`backbeat_keys`** manifest listing every promoted key/span column and which event declares
15//!   it, so the joins are *discoverable* — an agent reads the manifest instead of guessing keys.
16//!
17//! These **Tier 1** views are generated purely from the registry — no domain knowledge. A consumer
18//! adds **Tier 2** domain joins (e.g. dc's `stream_by_dump`) by `register_views!`-ing a `.sql` file;
19//! those ride in the dump and are appended after Tier 1 by [`assemble`]. The whole DDL references
20//! `events`, so binding that one view to a Parquet (or any source) activates everything.
21
22use backbeat::{schema::FieldRole, wire::OwnedSchema};
23use std::collections::HashMap;
24
25/// Whether a field is promoted to a top-level Parquet column (mirrors [`crate::convert`]'s rule):
26/// keys and span ids become dense, queryable/join-able columns; everything else nests in the
27/// per-event struct column.
28fn is_promoted(role: FieldRole) -> bool {
29    matches!(
30        role,
31        FieldRole::Key | FieldRole::SpanId | FieldRole::ParentSpanId
32    )
33}
34
35/// A short role label for the `backbeat_keys` manifest.
36fn role_label(role: FieldRole) -> &'static str {
37    match role {
38        FieldRole::Key => "key",
39        FieldRole::SpanId => "span_id",
40        FieldRole::ParentSpanId => "parent_span_id",
41        _ => "",
42    }
43}
44
45/// Quotes a SQL identifier (event names contain `::`, so they always need quoting). Embedded double
46/// quotes are doubled per SQL rules.
47fn quote_ident(name: &str) -> String {
48    format!("\"{}\"", name.replace('"', "\"\""))
49}
50
51/// Escapes a single-quoted SQL string literal.
52fn quote_str(s: &str) -> String {
53    format!("'{}'", s.replace('\'', "''"))
54}
55
56/// The per-schema display name used for the Parquet `event` column and the per-event view name: the
57/// `qualified_name`, suffixed with `#<id>` only when two schemas collide on it (distinct
58/// content-addressed ids — genuinely different event types sharing a name). Mirrors
59/// `convert::display_names` so the generated views line up with the columns convert writes.
60fn display_names(schemas: &[OwnedSchema]) -> Vec<String> {
61    let mut counts: HashMap<&str, usize> = HashMap::new();
62    for s in schemas {
63        *counts.entry(s.qualified_name.as_str()).or_default() += 1;
64    }
65    schemas
66        .iter()
67        .map(|s| {
68            if counts[s.qualified_name.as_str()] > 1 {
69                format!("{}#{:016x}", s.qualified_name, s.id.get())
70            } else {
71                s.qualified_name.clone()
72            }
73        })
74        .collect()
75}
76
77/// Generates the Tier-1 DDL from the unioned schema registry. References a base view `events`
78/// (see [`bootstrap`]) but does not define it, so this text is path-independent and embeds verbatim
79/// in the dump and the Parquet footer.
80pub fn generate_tier1(schemas: &[OwnedSchema]) -> String {
81    let names = display_names(schemas);
82    let mut out = String::new();
83
84    out.push_str(
85        "-- backbeat Tier-1 views (generated from the dump's schema registry).\n\
86         -- Everything below builds on a base view named `events`; create it over your Parquet\n\
87         -- before running these (the `.sql` sidecar convert writes does this for you).\n\n",
88    );
89
90    // Per-event-type views: filter the wide table to one event by its stable content-addressed id
91    // (robust across a merged dump that holds two versions of a name). `SELECT *` keeps the row
92    // whole — the event's own struct column is populated; other events' columns are simply null.
93    out.push_str("-- One view per event type.\n");
94    for (s, name) in schemas.iter().zip(&names) {
95        out.push_str(&format!(
96            "CREATE OR REPLACE VIEW {} AS SELECT * FROM events WHERE event_id = {};\n",
97            quote_ident(name),
98            s.id.get()
99        ));
100    }
101
102    // The `backbeat_keys` manifest: which promoted columns exist and which event declares each, so
103    // an agent can discover the join/filter keys rather than guess them.
104    out.push_str("\n-- Discoverable key/span columns: (event, column, role).\n");
105    let mut rows: Vec<String> = Vec::new();
106    for (s, name) in schemas.iter().zip(&names) {
107        for f in s.fields.iter().filter(|f| is_promoted(f.role)) {
108            rows.push(format!(
109                "  ({}, {}, {})",
110                quote_str(name),
111                quote_str(&f.name),
112                quote_str(role_label(f.role))
113            ));
114        }
115    }
116    if rows.is_empty() {
117        // VALUES cannot be empty; emit a typed, zero-row manifest so the view always exists.
118        // (`column` is a DuckDB reserved word, so the manifest names the column `field`.)
119        out.push_str(
120            "CREATE OR REPLACE VIEW backbeat_keys AS\n  \
121             SELECT NULL::VARCHAR AS event, NULL::VARCHAR AS field, NULL::VARCHAR AS role WHERE false;\n",
122        );
123    } else {
124        out.push_str("CREATE OR REPLACE VIEW backbeat_keys AS\n  SELECT * FROM (VALUES\n");
125        out.push_str(&rows.join(",\n"));
126        out.push_str("\n  ) AS t(event, field, role);\n");
127    }
128
129    out
130}
131
132/// A one-line bootstrap that binds the base `events` view to a Parquet file, prepended to the
133/// `.sql` sidecar (where `convert` knows the output path). The footer-embedded copy omits this so it
134/// stays path-independent.
135pub fn bootstrap(parquet_path: &str) -> String {
136    format!(
137        "-- Bind the base table to this conversion's Parquet output.\n\
138         CREATE OR REPLACE VIEW events AS SELECT * FROM read_parquet({});\n\n",
139        quote_str(parquet_path)
140    )
141}
142
143/// Assembles the full DDL body: Tier-1 (generated) followed by each registered Tier-2 view set
144/// (verbatim, in dump order). This is the path-independent text written to the Parquet footer; the
145/// sidecar is this prefixed with [`bootstrap`].
146pub fn assemble(schemas: &[OwnedSchema], tier2: &[String]) -> String {
147    let mut out = generate_tier1(schemas);
148    for (i, sql) in tier2.iter().enumerate() {
149        out.push_str(&format!("\n-- Registered view set #{}.\n", i + 1));
150        out.push_str(sql);
151        if !sql.ends_with('\n') {
152            out.push('\n');
153        }
154    }
155    out
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use backbeat::{
162        id::EventId,
163        schema::FieldType,
164        wire::{OwnedField, OwnedSchema},
165    };
166
167    fn field(name: &str, role: FieldRole) -> OwnedField {
168        OwnedField {
169            name: name.to_string(),
170            description: None,
171            ty: FieldType::U64,
172            offset: 0,
173            width: 8,
174            role,
175            unit: None,
176            sentinel: None,
177            enum_labels: Vec::new(),
178        }
179    }
180
181    fn schema(id: u64, name: &str, fields: Vec<OwnedField>) -> OwnedSchema {
182        OwnedSchema {
183            id: EventId(id),
184            qualified_name: name.to_string(),
185            description: None,
186            record_size: 8,
187            phase: backbeat::schema::Phase::None,
188            fields,
189        }
190    }
191
192    #[test]
193    fn quotes_identifiers_and_strings() {
194        assert_eq!(quote_ident("my::Event"), "\"my::Event\"");
195        assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
196        assert_eq!(quote_str("it's"), "'it''s'");
197    }
198
199    #[test]
200    fn per_event_view_filters_by_id() {
201        let s = vec![schema(7, "ns::Pkt", vec![field("conn_id", FieldRole::Key)])];
202        let ddl = generate_tier1(&s);
203        assert!(ddl.contains(
204            "CREATE OR REPLACE VIEW \"ns::Pkt\" AS SELECT * FROM events WHERE event_id = 7;"
205        ));
206        // The promoted key is surfaced in the discovery manifest.
207        assert!(ddl.contains("CREATE OR REPLACE VIEW backbeat_keys"));
208        assert!(ddl.contains("('ns::Pkt', 'conn_id', 'key')"));
209    }
210
211    #[test]
212    fn empty_key_manifest_is_a_typed_zero_row_view() {
213        // An event with no promoted columns: backbeat_keys must still exist (VALUES can't be empty).
214        let s = vec![schema(1, "ns::Marker", vec![field("x", FieldRole::None)])];
215        let ddl = generate_tier1(&s);
216        assert!(ddl.contains("CREATE OR REPLACE VIEW backbeat_keys"));
217        assert!(ddl.contains("WHERE false"));
218        assert!(!ddl.contains("VALUES"));
219    }
220
221    #[test]
222    fn collision_suffixes_display_name_with_id() {
223        // Two schemas sharing a qualified_name (distinct ids) get `#<id>`-suffixed view names.
224        let s = vec![
225            schema(0xAA, "ns::E", vec![field("k", FieldRole::Key)]),
226            schema(0xBB, "ns::E", vec![field("k", FieldRole::Key)]),
227        ];
228        let ddl = generate_tier1(&s);
229        assert!(ddl.contains("\"ns::E#00000000000000aa\""));
230        assert!(ddl.contains("\"ns::E#00000000000000bb\""));
231    }
232
233    #[test]
234    fn assemble_appends_tier2_with_trailing_newline() {
235        let s = vec![schema(1, "ns::E", vec![field("k", FieldRole::Key)])];
236        let ddl = assemble(&s, &["CREATE MACRO m() AS TABLE SELECT 1;".to_string()]);
237        assert!(ddl.contains("-- Registered view set #1."));
238        assert!(ddl.contains("CREATE MACRO m() AS TABLE SELECT 1;"));
239        assert!(
240            ddl.ends_with('\n'),
241            "tier-2 without trailing newline gets one"
242        );
243    }
244
245    #[test]
246    fn bootstrap_binds_events_to_path() {
247        assert!(bootstrap("out.parquet").contains(
248            "CREATE OR REPLACE VIEW events AS SELECT * FROM read_parquet('out.parquet')"
249        ));
250    }
251}