micromegas/servers/
view_ddl.rs1use anyhow::{Context, Result};
12use datafusion::sql::sqlparser::{
13 ast::{Expr as SqlExpr, SqlOption, Value},
14 dialect::GenericDialect,
15 keywords::Keyword,
16 parser::Parser,
17 tokenizer::Token,
18};
19use micromegas_analytics::lakehouse::read_scope::CallerContext;
20use micromegas_analytics::lakehouse::view_definition::{
21 ViewDefinition, ViewOptions, check_view_set_name_charset, parse_time_delta,
22};
23use tonic::Status;
24
25#[derive(Debug, Clone)]
27pub enum ViewDdl {
28 Create {
29 name: String,
30 or_replace: bool,
31 definition: Box<ViewDefinition>,
34 sql: String,
36 },
37 Drop {
38 name: String,
39 if_exists: bool,
40 },
41}
42
43pub fn authorize_view_ddl(caller: &CallerContext) -> Result<(), Status> {
50 if !caller.is_admin {
51 return Err(Status::permission_denied(
52 "CREATE/DROP MATERIALIZED VIEW requires admin privileges",
53 ));
54 }
55 Ok(())
56}
57
58pub fn parse_view_ddl(sql: &str) -> Result<Option<ViewDdl>> {
69 let first_word = sql
70 .trim_start()
71 .split(|c: char| c.is_whitespace() || c == '(')
72 .next()
73 .unwrap_or("")
74 .to_lowercase();
75 if first_word != "create"
79 && first_word != "drop"
80 && !first_word.starts_with("--")
81 && !first_word.starts_with("/*")
82 {
83 return Ok(None);
84 }
85
86 let mut parser = Parser::new(&GenericDialect {})
87 .try_with_sql(sql)
88 .with_context(|| "tokenizing statement")?;
89
90 if parser.parse_keyword(Keyword::CREATE) {
91 let or_replace = parser.parse_keywords(&[Keyword::OR, Keyword::REPLACE]);
92 if !parser.parse_keyword(Keyword::MATERIALIZED) {
93 return Ok(None);
96 }
97 parser
98 .expect_keyword_is(Keyword::VIEW)
99 .with_context(|| "expected VIEW after CREATE [OR REPLACE] MATERIALIZED")?;
100 let name = parser
101 .parse_object_name(false)
102 .with_context(|| "expected a view set name")?
103 .to_string();
104 let options = parser
105 .parse_options(Keyword::WITH)
106 .with_context(|| "expected WITH (...) options")?;
107 require_eof(
108 &mut parser,
109 "CREATE [OR REPLACE] MATERIALIZED VIEW <name> WITH (...)",
110 )?;
111 let definition = view_definition_from_options(name.clone(), options)
112 .with_context(|| format!("CREATE MATERIALIZED VIEW {name}"))?;
113 return Ok(Some(ViewDdl::Create {
114 name,
115 or_replace,
116 definition: Box::new(definition),
117 sql: sql.to_string(),
118 }));
119 }
120
121 if parser.parse_keyword(Keyword::DROP) {
122 if !parser.parse_keyword(Keyword::MATERIALIZED) {
123 return Ok(None);
124 }
125 parser
126 .expect_keyword_is(Keyword::VIEW)
127 .with_context(|| "expected VIEW after DROP MATERIALIZED")?;
128 let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]);
129 let name = parser
130 .parse_object_name(false)
131 .with_context(|| "expected a view set name")?
132 .to_string();
133 require_eof(&mut parser, "DROP MATERIALIZED VIEW [IF EXISTS] <name>")?;
134 return Ok(Some(ViewDdl::Drop { name, if_exists }));
135 }
136
137 Ok(None)
138}
139
140fn require_eof(parser: &mut Parser, context: &str) -> Result<()> {
145 let _ = parser.consume_token(&Token::SemiColon);
146 let trailing = parser.peek_token_ref().token.clone();
147 if trailing != Token::EOF {
148 anyhow::bail!("unexpected trailing input after {context}: {trailing}");
149 }
150 Ok(())
151}
152
153fn value_to_string(expr: &SqlExpr) -> Result<String> {
154 match expr {
155 SqlExpr::Value(v) => match &v.value {
156 Value::SingleQuotedString(s) => Ok(s.clone()),
157 Value::DollarQuotedString(d) => Ok(d.value.clone()),
158 other => anyhow::bail!("expected a string literal, got {other}"),
159 },
160 other => anyhow::bail!("expected a string literal, got {other}"),
161 }
162}
163
164fn value_to_i32(expr: &SqlExpr) -> Result<i32> {
165 match expr {
166 SqlExpr::Value(v) => match &v.value {
167 Value::Number(s, _) => s
168 .parse::<i32>()
169 .with_context(|| format!("invalid integer {s:?}")),
170 Value::SingleQuotedString(s) => s
171 .parse::<i32>()
172 .with_context(|| format!("invalid integer {s:?}")),
173 other => anyhow::bail!("expected an integer literal, got {other}"),
174 },
175 other => anyhow::bail!("expected an integer literal, got {other}"),
176 }
177}
178
179fn view_definition_from_options(name: String, options: Vec<SqlOption>) -> Result<ViewDefinition> {
186 let mut extract_query = None;
187 let mut count_src_query = None;
188 let mut merge_partitions_query = None;
189 let mut update_group = None;
190 let mut time_column = None;
191 let mut min_time_column = None;
192 let mut max_time_column = None;
193 let mut source_partition_delta: Option<String> = None;
194 let mut merge_partition_delta: Option<String> = None;
195 let mut merge_sort_order: Option<Vec<String>> = None;
196
197 for opt in options {
198 let SqlOption::KeyValue { key, value } = opt else {
199 anyhow::bail!("unsupported view option (expected `key = value`)");
200 };
201 let key_name = key.value.to_lowercase();
202 match key_name.as_str() {
203 "extract_query" => extract_query = Some(value_to_string(&value)?),
204 "count_src_query" => count_src_query = Some(value_to_string(&value)?),
205 "merge_partitions_query" => merge_partitions_query = Some(value_to_string(&value)?),
206 "update_group" => update_group = Some(value_to_i32(&value)?),
207 "time_column" => time_column = Some(value_to_string(&value)?),
208 "min_time_column" => min_time_column = Some(value_to_string(&value)?),
209 "max_time_column" => max_time_column = Some(value_to_string(&value)?),
210 "source_partition_delta" => {
211 let s = value_to_string(&value)?;
212 parse_time_delta(&s).with_context(|| "source_partition_delta")?;
213 source_partition_delta = Some(s);
214 }
215 "merge_partition_delta" => {
216 let s = value_to_string(&value)?;
217 parse_time_delta(&s).with_context(|| "merge_partition_delta")?;
218 merge_partition_delta = Some(s);
219 }
220 "merge_sort_order" => {
221 let s = value_to_string(&value)?;
222 let cols: Vec<String> = s
223 .split(',')
224 .map(|c| c.trim().to_string())
225 .filter(|c| !c.is_empty())
226 .collect();
227 if cols.is_empty() {
228 anyhow::bail!(
229 "merge_sort_order must be a non-empty, comma-separated column list"
230 );
231 }
232 merge_sort_order = Some(cols);
233 }
234 other => anyhow::bail!("unknown view option '{other}'"),
235 }
236 }
237
238 let extract_query = extract_query.context("missing required option 'extract_query'")?;
239 let count_src_query = count_src_query.context("missing required option 'count_src_query'")?;
240 let merge_partitions_query =
241 merge_partitions_query.context("missing required option 'merge_partitions_query'")?;
242 let update_group = update_group.context("missing required option 'update_group'")?;
243
244 let (resolved_min, resolved_max) = match (time_column, min_time_column, max_time_column) {
245 (Some(t), min, max) => (min.unwrap_or_else(|| t.clone()), max.unwrap_or(t)),
246 (None, Some(min), Some(max)) => (min, max),
247 _ => anyhow::bail!(
248 "missing required option 'time_column' (or both 'min_time_column' and \
249 'max_time_column')"
250 ),
251 };
252
253 let source_partition_delta = source_partition_delta.unwrap_or_else(|| "1 day".to_string());
254 let merge_partition_delta =
255 merge_partition_delta.unwrap_or_else(|| source_partition_delta.clone());
256
257 check_view_set_name_charset(&name)?;
258
259 Ok(ViewDefinition {
260 view_set_name: name,
261 extract_query,
262 count_src_query,
263 merge_partitions_query,
264 update_group,
265 options: ViewOptions {
266 min_time_column: resolved_min,
267 max_time_column: resolved_max,
268 source_partition_delta,
269 merge_partition_delta,
270 merge_sort_order,
271 },
272 })
273}