1#[allow(clippy::disallowed_types)] use std::collections::HashMap;
9
10use sqlparser::ast::{ColumnDef, ObjectName};
11use sqlparser::keywords::Keyword;
12use sqlparser::parser::Parser;
13use sqlparser::tokenizer::Token;
14
15use super::tokenizer::{expect_custom_keyword, expect_statement_end, parse_with_options};
16use super::ParseError;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct CreateLookupTableStatement {
21 pub name: ObjectName,
23 pub columns: Vec<ColumnDef>,
25 pub primary_key: Vec<String>,
27 pub with_options: HashMap<String, String>,
29 pub or_replace: bool,
31 pub if_not_exists: bool,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub struct LookupTableProperties {
38 pub connector: LookupConnector,
40 pub strategy: LookupStrategy,
42 pub cache_memory: Option<ByteSize>,
44 pub cache_ttl: Option<u64>,
46 pub pushdown_mode: PushdownMode,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum LookupConnector {
53 Static,
55 External(String),
57}
58
59impl LookupConnector {
60 pub fn parse(s: &str) -> Result<Self, ParseError> {
66 let connector = s.trim().to_ascii_lowercase();
67 if connector.is_empty() {
68 return Err(ParseError::ValidationError(
69 "connector name cannot be empty".to_string(),
70 ));
71 }
72 Ok(if connector == "static" {
73 Self::Static
74 } else {
75 Self::External(connector)
76 })
77 }
78
79 #[must_use]
81 pub fn as_str(&self) -> &str {
82 match self {
83 Self::Static => "static",
84 Self::External(name) => name,
85 }
86 }
87}
88
89impl std::fmt::Display for LookupConnector {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.write_str(self.as_str())
92 }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
97pub enum LookupStrategy {
98 #[default]
100 Replicated,
101 OnDemand,
103}
104
105impl LookupStrategy {
106 pub fn parse(s: &str) -> Result<Self, ParseError> {
112 match s.as_bytes() {
113 b"replicated" => Ok(Self::Replicated),
114 b"on-demand" => Ok(Self::OnDemand),
115 other => Err(ParseError::ValidationError(format!(
116 "unknown lookup strategy: '{}' (expected: replicated or on-demand)",
117 String::from_utf8_lossy(other)
118 ))),
119 }
120 }
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
125pub enum PushdownMode {
126 #[default]
128 Auto,
129 Enabled,
131 Disabled,
133}
134
135impl PushdownMode {
136 pub fn parse(s: &str) -> Result<Self, ParseError> {
142 match s.to_lowercase().as_str() {
143 "auto" => Ok(Self::Auto),
144 "enabled" | "true" | "on" => Ok(Self::Enabled),
145 "disabled" | "false" | "off" => Ok(Self::Disabled),
146 other => Err(ParseError::ValidationError(format!(
147 "unknown pushdown mode: '{other}' (expected: auto, enabled, disabled)"
148 ))),
149 }
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct ByteSize(pub u64);
156
157impl ByteSize {
158 pub fn parse(s: &str) -> Result<Self, ParseError> {
166 let s = s.trim().to_lowercase();
167 let (num_str, multiplier) = if let Some(n) = s.strip_suffix("tb") {
168 (n, 1024 * 1024 * 1024 * 1024)
169 } else if let Some(n) = s.strip_suffix("gb") {
170 (n, 1024 * 1024 * 1024)
171 } else if let Some(n) = s.strip_suffix("mb") {
172 (n, 1024 * 1024)
173 } else if let Some(n) = s.strip_suffix("kb") {
174 (n, 1024)
175 } else if let Some(n) = s.strip_suffix('b') {
176 (n, 1)
177 } else {
178 (s.as_str(), 1)
180 };
181
182 let num: u64 = num_str
183 .trim()
184 .parse()
185 .map_err(|_| ParseError::ValidationError(format!("invalid byte size: '{s}'")))?;
186
187 Ok(Self(num * multiplier))
188 }
189
190 #[must_use]
192 pub fn as_bytes(&self) -> u64 {
193 self.0
194 }
195}
196
197pub fn parse_create_lookup_table(
216 parser: &mut Parser,
217) -> Result<CreateLookupTableStatement, ParseError> {
218 parser
220 .expect_keyword(Keyword::CREATE)
221 .map_err(ParseError::SqlParseError)?;
222
223 let or_replace = parser.parse_keywords(&[Keyword::OR, Keyword::REPLACE]);
224
225 expect_custom_keyword(parser, "LOOKUP")?;
226
227 parser
228 .expect_keyword(Keyword::TABLE)
229 .map_err(ParseError::SqlParseError)?;
230
231 let if_not_exists = parser.parse_keywords(&[Keyword::IF, Keyword::NOT, Keyword::EXISTS]);
232
233 let name = parser
234 .parse_object_name(false)
235 .map_err(ParseError::SqlParseError)?;
236
237 parser
239 .expect_token(&Token::LParen)
240 .map_err(ParseError::SqlParseError)?;
241
242 let mut columns = Vec::new();
243 let mut primary_key = Vec::new();
244
245 loop {
246 if parser.parse_keywords(&[Keyword::PRIMARY, Keyword::KEY]) {
248 parser
249 .expect_token(&Token::LParen)
250 .map_err(ParseError::SqlParseError)?;
251
252 loop {
253 let ident = parser
254 .parse_identifier()
255 .map_err(ParseError::SqlParseError)?;
256 primary_key.push(ident.value);
257
258 if !parser.consume_token(&Token::Comma) {
259 break;
260 }
261 }
262
263 parser
264 .expect_token(&Token::RParen)
265 .map_err(ParseError::SqlParseError)?;
266
267 let _ = parser.consume_token(&Token::Comma);
269 } else if parser.consume_token(&Token::RParen) {
270 break;
272 } else {
273 let col = parser
275 .parse_column_def()
276 .map_err(ParseError::SqlParseError)?;
277 columns.push(col);
278
279 if !parser.consume_token(&Token::Comma) {
281 parser
282 .expect_token(&Token::RParen)
283 .map_err(ParseError::SqlParseError)?;
284 break;
285 }
286 }
287 }
288
289 if columns.is_empty() {
290 return Err(ParseError::StreamingError(
291 "LOOKUP TABLE must have at least one column".to_string(),
292 ));
293 }
294
295 let with_options = parse_with_options(parser)?;
297 if with_options.is_empty() {
298 return Err(ParseError::StreamingError(
299 "LOOKUP TABLE requires a WITH clause".to_string(),
300 ));
301 }
302 expect_statement_end(parser)?;
303
304 Ok(CreateLookupTableStatement {
305 name,
306 columns,
307 primary_key,
308 with_options,
309 or_replace,
310 if_not_exists,
311 })
312}
313
314pub fn parse_drop_lookup_table(parser: &mut Parser) -> Result<(ObjectName, bool), ParseError> {
322 parser
323 .expect_keyword(Keyword::DROP)
324 .map_err(ParseError::SqlParseError)?;
325
326 expect_custom_keyword(parser, "LOOKUP")?;
327
328 parser
329 .expect_keyword(Keyword::TABLE)
330 .map_err(ParseError::SqlParseError)?;
331
332 let if_exists = parser.parse_keywords(&[Keyword::IF, Keyword::EXISTS]);
333
334 let name = parser
335 .parse_object_name(false)
336 .map_err(ParseError::SqlParseError)?;
337
338 Ok((name, if_exists))
339}
340
341pub fn validate_properties<S: ::std::hash::BuildHasher>(
347 options: &HashMap<String, String, S>,
348) -> Result<LookupTableProperties, ParseError> {
349 let connector_str = options.get("connector").ok_or_else(|| {
350 ParseError::ValidationError("missing required property: 'connector'".to_string())
351 })?;
352 let connector = LookupConnector::parse(connector_str)?;
353
354 let strategy = match options.get("strategy") {
355 Some(s) => LookupStrategy::parse(s)?,
356 None => LookupStrategy::default(),
357 };
358
359 let cache_memory = options
360 .get("cache.memory")
361 .map(|s| ByteSize::parse(s))
362 .transpose()?;
363
364 if options.contains_key("cache.disk") {
365 return Err(ParseError::ValidationError(
366 "cache.disk is unsupported; on-demand lookup caching is memory-only".into(),
367 ));
368 }
369
370 let cache_ttl = options
371 .get("cache.ttl")
372 .map(|s| {
373 s.parse::<u64>()
374 .map_err(|_| ParseError::ValidationError(format!("invalid cache.ttl: '{s}'")))
375 })
376 .transpose()?;
377
378 let pushdown_mode = match options.get("pushdown") {
379 Some(s) => PushdownMode::parse(s)?,
380 None => PushdownMode::default(),
381 };
382
383 if strategy == LookupStrategy::Replicated && (cache_memory.is_some() || cache_ttl.is_some()) {
384 return Err(ParseError::ValidationError(
385 "cache.memory and cache.ttl require strategy = 'on-demand'".into(),
386 ));
387 }
388
389 Ok(LookupTableProperties {
390 connector,
391 strategy,
392 cache_memory,
393 cache_ttl,
394 pushdown_mode,
395 })
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use crate::parser::StreamingParser;
402 use crate::parser::StreamingStatement;
403
404 fn parse_one(sql: &str) -> StreamingStatement {
406 let stmts = StreamingParser::parse_sql(sql).unwrap();
407 assert_eq!(stmts.len(), 1, "Expected exactly 1 statement");
408 stmts.into_iter().next().unwrap()
409 }
410
411 #[test]
412 fn test_parse_basic_create_lookup_table() {
413 let stmt = parse_one(
414 "CREATE LOOKUP TABLE instruments (
415 symbol VARCHAR NOT NULL,
416 name VARCHAR,
417 PRIMARY KEY (symbol)
418 ) WITH (
419 'connector' = 'catalog-source',
420 'endpoint' = 'https://catalog.example'
421 )",
422 );
423 match stmt {
424 StreamingStatement::CreateLookupTable(lt) => {
425 assert_eq!(lt.name.to_string(), "instruments");
426 assert_eq!(lt.columns.len(), 2);
427 assert_eq!(lt.primary_key, vec!["symbol"]);
428 assert!(!lt.or_replace);
429 assert!(!lt.if_not_exists);
430 assert_eq!(
431 lt.with_options.get("connector"),
432 Some(&"catalog-source".to_string())
433 );
434 }
435 _ => panic!("Expected CreateLookupTable, got {stmt:?}"),
436 }
437 }
438
439 #[test]
440 fn test_parse_or_replace_and_if_not_exists() {
441 let stmt = parse_one(
442 "CREATE OR REPLACE LOOKUP TABLE IF NOT EXISTS dims (
443 id INT,
444 PRIMARY KEY (id)
445 ) WITH (
446 'connector' = 'static'
447 )",
448 );
449 match stmt {
450 StreamingStatement::CreateLookupTable(lt) => {
451 assert!(lt.or_replace);
452 assert!(lt.if_not_exists);
453 }
454 _ => panic!("Expected CreateLookupTable, got {stmt:?}"),
455 }
456 }
457
458 #[test]
459 fn test_parse_with_primary_key() {
460 let stmt = parse_one(
461 "CREATE LOOKUP TABLE t (
462 a INT,
463 b VARCHAR,
464 c FLOAT,
465 PRIMARY KEY (a, b)
466 ) WITH ('connector' = 'static')",
467 );
468 match stmt {
469 StreamingStatement::CreateLookupTable(lt) => {
470 assert_eq!(lt.primary_key, vec!["a", "b"]);
471 assert_eq!(lt.columns.len(), 3);
472 }
473 _ => panic!("Expected CreateLookupTable, got {stmt:?}"),
474 }
475 }
476
477 #[test]
478 fn test_parse_with_clause_properties() {
479 let stmt = parse_one(
480 "CREATE LOOKUP TABLE t (
481 id INT,
482 PRIMARY KEY (id)
483 ) WITH (
484 'connector' = 'mock-direct',
485 'connection' = 'endpoint=localhost',
486 'strategy' = 'on-demand',
487 'cache.memory' = '512mb',
488 'pushdown' = 'auto'
489 )",
490 );
491 match stmt {
492 StreamingStatement::CreateLookupTable(lt) => {
493 let props = validate_properties(<.with_options).unwrap();
494 assert_eq!(
495 props.connector,
496 LookupConnector::External("mock-direct".into())
497 );
498 assert_eq!(
499 lt.with_options.get("connection").map(String::as_str),
500 Some("endpoint=localhost")
501 );
502 assert_eq!(props.strategy, LookupStrategy::OnDemand);
503 assert_eq!(props.cache_memory, Some(ByteSize(512 * 1024 * 1024)));
504 assert_eq!(props.pushdown_mode, PushdownMode::Auto);
505 }
506 _ => panic!("Expected CreateLookupTable, got {stmt:?}"),
507 }
508 }
509
510 #[test]
511 fn test_parse_drop_lookup_table() {
512 let stmt = parse_one("DROP LOOKUP TABLE instruments");
513 match stmt {
514 StreamingStatement::DropLookupTable { name, if_exists } => {
515 assert_eq!(name.to_string(), "instruments");
516 assert!(!if_exists);
517 }
518 _ => panic!("Expected DropLookupTable, got {stmt:?}"),
519 }
520 }
521
522 #[test]
523 fn test_parse_drop_lookup_table_if_exists() {
524 let stmt = parse_one("DROP LOOKUP TABLE IF EXISTS instruments");
525 match stmt {
526 StreamingStatement::DropLookupTable { name, if_exists } => {
527 assert_eq!(name.to_string(), "instruments");
528 assert!(if_exists);
529 }
530 _ => panic!("Expected DropLookupTable, got {stmt:?}"),
531 }
532 }
533
534 #[test]
535 fn test_byte_size_parsing() {
536 assert_eq!(
537 ByteSize::parse("512mb").unwrap(),
538 ByteSize(512 * 1024 * 1024)
539 );
540 assert_eq!(
541 ByteSize::parse("1gb").unwrap(),
542 ByteSize(1024 * 1024 * 1024)
543 );
544 assert_eq!(ByteSize::parse("10kb").unwrap(), ByteSize(10 * 1024));
545 assert_eq!(ByteSize::parse("100b").unwrap(), ByteSize(100));
546 assert_eq!(ByteSize::parse("1024").unwrap(), ByteSize(1024));
547 assert_eq!(
548 ByteSize::parse("2tb").unwrap(),
549 ByteSize(2 * 1024 * 1024 * 1024 * 1024)
550 );
551 }
552
553 #[test]
554 fn lookup_connector_parsing_is_provider_neutral() {
555 assert_eq!(
556 LookupConnector::parse(" STATIC ").unwrap(),
557 LookupConnector::Static
558 );
559 assert_eq!(
560 LookupConnector::parse("CuStOm-SrC").unwrap(),
561 LookupConnector::External("custom-src".to_string())
562 );
563 assert_eq!(
564 LookupConnector::parse("memory").unwrap(),
565 LookupConnector::External("memory".to_string())
566 );
567 assert_eq!(
568 LookupConnector::parse("alpha_lookup").unwrap(),
569 LookupConnector::External("alpha_lookup".to_string())
570 );
571 assert!(LookupConnector::parse(" ").is_err());
572 }
573
574 #[test]
575 fn test_error_missing_columns() {
576 let result =
577 StreamingParser::parse_sql("CREATE LOOKUP TABLE t () WITH ('connector' = 'static')");
578 assert!(result.is_err());
579 }
580
581 #[test]
582 fn test_error_missing_with_clause() {
583 let result = StreamingParser::parse_sql("CREATE LOOKUP TABLE t (id INT, PRIMARY KEY (id))");
584 assert!(result.is_err());
585 }
586
587 #[test]
588 fn test_error_invalid_property() {
589 let mut options = HashMap::new();
590 options.insert("connector".to_string(), "catalog-source".to_string());
591 options.insert("strategy".to_string(), "invalid-strategy".to_string());
592 let result = validate_properties(&options);
593 assert!(result.is_err());
594 }
595
596 #[test]
597 fn lookup_strategy_and_cache_surface_is_strict() {
598 assert_eq!(
599 LookupStrategy::parse("replicated").unwrap(),
600 LookupStrategy::Replicated
601 );
602 assert_eq!(
603 LookupStrategy::parse("on-demand").unwrap(),
604 LookupStrategy::OnDemand
605 );
606 for unsupported in [
607 "partitioned",
608 "sharded",
609 "full",
610 "poll",
611 "snapshot",
612 "cdc",
613 "on_demand",
614 "lazy",
615 "manual",
616 ] {
617 assert!(LookupStrategy::parse(unsupported).is_err(), "{unsupported}");
618 }
619
620 let mut replicated_cache = HashMap::from([
621 ("connector".to_string(), "catalog-source".to_string()),
622 ("strategy".to_string(), "replicated".to_string()),
623 ("cache.memory".to_string(), "1mb".to_string()),
624 ]);
625 assert!(validate_properties(&replicated_cache).is_err());
626 replicated_cache.insert("strategy".into(), "on-demand".into());
627 assert!(validate_properties(&replicated_cache).is_ok());
628 replicated_cache.insert("cache.disk".into(), "1gb".into());
629 assert!(validate_properties(&replicated_cache).is_err());
630 }
631}