1use backbeat::{schema::FieldRole, wire::OwnedSchema};
23use std::collections::HashMap;
24
25fn is_promoted(role: FieldRole) -> bool {
29 matches!(
30 role,
31 FieldRole::Key | FieldRole::SpanId | FieldRole::ParentSpanId
32 )
33}
34
35fn 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
45fn quote_ident(name: &str) -> String {
48 format!("\"{}\"", name.replace('"', "\"\""))
49}
50
51fn quote_str(s: &str) -> String {
53 format!("'{}'", s.replace('\'', "''"))
54}
55
56fn 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
77pub 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 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 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 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
132pub 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
143pub 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 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 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 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}