1use super::error::ParseError;
4use super::Parser;
5use crate::ast::{
6 CreateSloQuery, CreateTableQuery, CreateTimeSeriesQuery, DropTimeSeriesQuery, HypertableDdl,
7 QueryExpr,
8};
9use crate::lexer::Token;
10use reddb_types::catalog::CollectionModel;
11
12impl<'a> Parser<'a> {
13 pub fn parse_create_timeseries_body(&mut self) -> Result<QueryExpr, ParseError> {
15 let if_not_exists = self.match_if_not_exists()?;
16 let name = self.expect_ident()?;
17
18 let mut retention_ms = None;
19 let mut chunk_size = None;
20 let mut downsample_policies = Vec::new();
21 let mut session_key: Option<String> = None;
22 let mut session_gap_ms: Option<u64> = None;
23 let mut columnar = true;
24
25 loop {
27 if self.consume(&Token::Retention)? {
28 let value = self.parse_float()?;
29 let unit = self.parse_duration_unit()?;
30 retention_ms = Some((value * unit) as u64);
31 } else if self.consume_ident_ci("CHUNK_SIZE")? || self.consume_ident_ci("CHUNKSIZE")? {
32 chunk_size = Some(self.parse_integer()? as usize);
33 } else if self.consume_ident_ci("NO")? {
34 if !self.consume_ident_ci("COLUMNAR")? {
35 return Err(ParseError::expected(
36 vec!["COLUMNAR"],
37 self.peek(),
38 self.position(),
39 ));
40 }
41 columnar = false;
42 } else if self.consume_ident_ci("COLUMNAR")? {
43 return Err(retired_columnar_keyword_error(self.position()));
44 } else if self.consume_ident_ci("DOWNSAMPLE")? {
45 downsample_policies.push(self.parse_downsample_policy_spec()?);
46 while self.consume(&Token::Comma)? {
47 downsample_policies.push(self.parse_downsample_policy_spec()?);
48 }
49 } else if self.consume(&Token::With)? {
50 self.parse_with_session_clause(&mut session_key, &mut session_gap_ms)?;
56 } else {
57 break;
58 }
59 }
60
61 Ok(QueryExpr::CreateTimeSeries(CreateTimeSeriesQuery {
62 name,
63 retention_ms,
64 chunk_size,
65 downsample_policies,
66 if_not_exists,
67 hypertable: None,
68 session_key,
69 session_gap_ms,
70 columnar,
71 }))
72 }
73
74 fn parse_with_session_clause(
79 &mut self,
80 session_key: &mut Option<String>,
81 session_gap_ms: &mut Option<u64>,
82 ) -> Result<(), ParseError> {
83 if !self.consume_ident_ci("SESSION_KEY")? {
84 return Err(ParseError::new(
85 "expected SESSION_KEY after WITH on CREATE TIMESERIES".to_string(),
86 self.position(),
87 ));
88 }
89 let key = self.expect_ident()?;
90 if !self.consume_ident_ci("SESSION_GAP")? {
91 return Err(ParseError::new(
92 "WITH SESSION_KEY requires a paired SESSION_GAP <duration>".to_string(),
93 self.position(),
94 ));
95 }
96 let value = self.parse_float()?;
97 let unit = self.parse_duration_unit()?;
98 *session_key = Some(key);
99 *session_gap_ms = Some((value * unit) as u64);
100 Ok(())
101 }
102
103 pub fn parse_create_metrics_body(&mut self) -> Result<QueryExpr, ParseError> {
108 let if_not_exists = self.match_if_not_exists()?;
109 let name = self.expect_ident()?;
110
111 let mut raw_retention_ms = None;
112 let mut tenant_by = None;
113 let mut downsample_policies = Vec::new();
114
115 loop {
116 if self.consume(&Token::Retention)? {
117 let value = self.parse_float()?;
118 let unit = self.parse_duration_unit()?;
119 raw_retention_ms = Some((value * unit) as u64);
120 } else if self.consume_ident_ci("DOWNSAMPLE")? {
121 downsample_policies.push(self.parse_downsample_policy_spec()?);
122 while self.consume(&Token::Comma)? {
123 downsample_policies.push(self.parse_downsample_policy_spec()?);
124 }
125 } else if tenant_by.is_none() && self.consume_ident_ci("TENANT")? {
126 self.expect(Token::By)?;
127 self.expect(Token::LParen)?;
128 let mut path = self.expect_ident_or_keyword()?;
129 while self.consume(&Token::Dot)? {
130 let next = self.expect_ident_or_keyword()?;
131 path = format!("{path}.{next}");
132 }
133 self.expect(Token::RParen)?;
134 tenant_by = Some(path);
135 } else {
136 break;
137 }
138 }
139
140 Ok(QueryExpr::CreateTable(CreateTableQuery {
141 collection_model: CollectionModel::Metrics,
142 name,
143 columns: Vec::new(),
144 if_not_exists,
145 default_ttl_ms: raw_retention_ms,
146 metrics_rollup_policies: downsample_policies,
147 context_index_fields: Vec::new(),
148 context_index_enabled: false,
149 timestamps: false,
150 partition_by: None,
151 tenant_by,
152 append_only: true,
153 subscriptions: Vec::new(),
154 analytics_config: Vec::new(),
155 vault_own_master_key: false,
156 ai_policy: None,
157 }))
158 }
159
160 pub fn parse_create_metric_body(&mut self) -> Result<QueryExpr, ParseError> {
162 let mut path = self.expect_ident_or_keyword()?.to_ascii_lowercase();
163 while self.consume(&Token::Dot)? {
164 let next = self.expect_ident_or_keyword()?.to_ascii_lowercase();
165 path = format!("{path}.{next}");
166 }
167
168 let mut kind = None;
169 let mut role = None;
170 let mut source: Option<String> = None;
171 let mut query: Option<String> = None;
172 let mut window_ms: Option<u64> = None;
173 let mut time_field: Option<String> = None;
174 loop {
175 if self.consume_ident_ci("TYPE")? || self.consume_ident_ci("KIND")? {
176 kind = Some(self.expect_ident_or_keyword()?.to_ascii_lowercase());
177 } else if self.consume_ident_ci("ROLE")? {
178 role = Some(self.expect_ident_or_keyword()?.to_ascii_lowercase());
179 } else if self.consume_ident_ci("SOURCE")? {
180 source = Some(self.expect_ident_or_keyword()?);
181 } else if self.consume_ident_ci("QUERY")? {
182 let value = self.parse_literal_value()?;
183 match value {
184 reddb_types::types::Value::Text(s) => query = Some(s.to_string()),
185 other => {
186 return Err(ParseError::new(
187 format!("derived metric QUERY expects a string literal, got {other:?}"),
188 self.position(),
189 ));
190 }
191 }
192 } else if self.consume_ident_ci("WINDOW")? {
193 let value = self.parse_float()?;
194 let unit = self.parse_duration_unit()?;
195 window_ms = Some((value * unit) as u64);
196 } else if self.consume_ident_ci("TIME_FIELD")? {
197 time_field = Some(self.expect_ident_or_keyword()?);
198 } else {
199 break;
200 }
201 }
202
203 Ok(QueryExpr::CreateMetric(crate::ast::CreateMetricQuery {
204 path,
205 kind: kind.ok_or_else(|| {
206 ParseError::new(
207 "metric descriptor requires TYPE or KIND".to_string(),
208 self.position(),
209 )
210 })?,
211 role: role.ok_or_else(|| {
212 ParseError::new(
213 "metric descriptor requires ROLE".to_string(),
214 self.position(),
215 )
216 })?,
217 source,
218 query,
219 window_ms,
220 time_field,
221 }))
222 }
223
224 pub fn parse_alter_metric_body(&mut self) -> Result<QueryExpr, ParseError> {
235 let mut path = self.expect_ident_or_keyword()?.to_ascii_lowercase();
236 while self.consume(&Token::Dot)? {
237 let next = self.expect_ident_or_keyword()?.to_ascii_lowercase();
238 path = format!("{path}.{next}");
239 }
240
241 if !self.consume(&Token::Set)? && !self.consume_ident_ci("SET")? {
242 return Err(ParseError::expected(
243 vec!["SET"],
244 self.peek(),
245 self.position(),
246 ));
247 }
248
249 let mut set_role = None;
250 let mut attempted_kind = None;
251 let mut attempted_path = None;
252
253 if self.consume_ident_ci("ROLE")? {
254 set_role = Some(self.expect_ident_or_keyword()?.to_ascii_lowercase());
255 } else if self.consume_ident_ci("KIND")? || self.consume_ident_ci("TYPE")? {
256 attempted_kind = Some(self.expect_ident_or_keyword()?.to_ascii_lowercase());
257 } else if self.consume(&Token::Path)? || self.consume_ident_ci("PATH")? {
258 let mut new_path = self.expect_ident_or_keyword()?.to_ascii_lowercase();
259 while self.consume(&Token::Dot)? {
260 let next = self.expect_ident_or_keyword()?.to_ascii_lowercase();
261 new_path = format!("{new_path}.{next}");
262 }
263 attempted_path = Some(new_path);
264 } else {
265 return Err(ParseError::expected(
266 vec!["ROLE", "KIND", "TYPE", "PATH"],
267 self.peek(),
268 self.position(),
269 ));
270 }
271
272 Ok(QueryExpr::AlterMetric(crate::ast::AlterMetricQuery {
273 path,
274 set_role,
275 attempted_kind,
276 attempted_path,
277 }))
278 }
279
280 pub fn parse_create_slo_body(&mut self) -> Result<QueryExpr, ParseError> {
293 let mut path = self.expect_ident_or_keyword()?.to_ascii_lowercase();
294 while self.consume(&Token::Dot)? {
295 let next = self.expect_ident_or_keyword()?.to_ascii_lowercase();
296 path = format!("{path}.{next}");
297 }
298
299 if !self.consume(&Token::On)? {
300 return Err(ParseError::expected(
301 vec!["ON"],
302 self.peek(),
303 self.position(),
304 ));
305 }
306
307 let mut metric_path = self.expect_ident_or_keyword()?.to_ascii_lowercase();
308 while self.consume(&Token::Dot)? {
309 let next = self.expect_ident_or_keyword()?.to_ascii_lowercase();
310 metric_path = format!("{metric_path}.{next}");
311 }
312
313 let mut target: Option<f64> = None;
314 let mut window_ms: Option<u64> = None;
315
316 loop {
317 if self.consume_ident_ci("TARGET")? {
318 target = Some(self.parse_float()?);
319 } else if self.consume_ident_ci("WINDOW")? {
320 let value = self.parse_float()?;
321 let unit = self.parse_duration_unit()?;
322 window_ms = Some((value * unit) as u64);
323 } else {
324 break;
325 }
326 }
327
328 Ok(QueryExpr::CreateSlo(CreateSloQuery {
329 path,
330 metric_path,
331 target: target.ok_or_else(|| {
332 ParseError::new(
333 "SLO descriptor requires TARGET <number>".to_string(),
334 self.position(),
335 )
336 })?,
337 window_ms: window_ms.ok_or_else(|| {
338 ParseError::new(
339 "SLO descriptor requires WINDOW <duration>".to_string(),
340 self.position(),
341 )
342 })?,
343 }))
344 }
345
346 pub fn parse_create_hypertable_body(&mut self) -> Result<QueryExpr, ParseError> {
359 let if_not_exists = self.match_if_not_exists()?;
360 let name = self.expect_ident()?;
361
362 let mut time_column: Option<String> = None;
363 let mut chunk_interval_ns: Option<u64> = None;
364 let mut ttl_ns: Option<u64> = None;
365 let mut retention_ms = None;
366 let mut columnar = true;
367
368 loop {
369 if self.consume_ident_ci("TIME_COLUMN")? {
370 time_column = Some(self.expect_ident()?);
371 } else if self.consume_ident_ci("CHUNK_INTERVAL")? {
372 chunk_interval_ns = Some(self.parse_duration_ns_literal("CHUNK_INTERVAL")?);
373 } else if self.consume_ident_ci("NO")? {
374 if !self.consume_ident_ci("COLUMNAR")? {
375 return Err(ParseError::expected(
376 vec!["COLUMNAR"],
377 self.peek(),
378 self.position(),
379 ));
380 }
381 columnar = false;
382 } else if self.consume_ident_ci("COLUMNAR")? {
383 return Err(retired_columnar_keyword_error(self.position()));
384 } else if self.consume_ident_ci("TTL")? {
385 ttl_ns = Some(self.parse_duration_ns_literal("TTL")?);
386 } else if self.consume(&Token::Retention)? {
387 let value = self.parse_float()?;
388 let unit = self.parse_duration_unit()?;
389 retention_ms = Some((value * unit) as u64);
390 } else {
391 break;
392 }
393 }
394
395 let time_column = time_column.ok_or_else(|| {
396 ParseError::new(
397 "CREATE HYPERTABLE requires TIME_COLUMN <ident>".to_string(),
398 self.position(),
399 )
400 })?;
401 let chunk_interval_ns = chunk_interval_ns.ok_or_else(|| {
402 ParseError::new(
403 "CREATE HYPERTABLE requires CHUNK_INTERVAL '<duration>' (e.g. '1d')".to_string(),
404 self.position(),
405 )
406 })?;
407
408 Ok(QueryExpr::CreateTimeSeries(CreateTimeSeriesQuery {
409 name,
410 retention_ms,
411 chunk_size: None,
412 downsample_policies: Vec::new(),
413 if_not_exists,
414 hypertable: Some(HypertableDdl {
415 time_column,
416 chunk_interval_ns,
417 default_ttl_ns: ttl_ns,
418 }),
419 session_key: None,
420 session_gap_ms: None,
421 columnar,
422 }))
423 }
424
425 fn parse_duration_ns_literal(&mut self, clause: &str) -> Result<u64, ParseError> {
428 let pos = self.position();
429 let value = self.parse_literal_value()?;
430 match value {
431 reddb_types::types::Value::Text(s) => {
432 reddb_types::duration::parse_duration_ns(&s).ok_or_else(|| {
433 ParseError::new(
434 format!("{clause} duration {s:?} is not a valid duration literal"),
439 pos,
440 )
441 })
442 }
443 other => Err(ParseError::new(
444 format!("{clause} expects a string duration literal, got {other:?}"),
445 pos,
446 )),
447 }
448 }
449
450 pub fn parse_drop_timeseries_body(&mut self) -> Result<QueryExpr, ParseError> {
452 let if_exists = self.match_if_exists()?;
453 let name = self.parse_drop_collection_name()?;
454 Ok(QueryExpr::DropTimeSeries(DropTimeSeriesQuery {
455 name,
456 if_exists,
457 }))
458 }
459
460 pub fn parse_duration_unit(&mut self) -> Result<f64, ParseError> {
462 match self.peek().clone() {
469 Token::Ident(ref unit) => {
470 let mult = match unit.to_ascii_lowercase().as_str() {
471 "ms" | "msec" | "millisecond" | "milliseconds" => 1.0,
472 "s" | "sec" | "secs" | "second" | "seconds" => 1_000.0,
473 "m" | "min" | "mins" | "minute" | "minutes" => 60_000.0,
474 "h" | "hr" | "hrs" | "hour" | "hours" => 3_600_000.0,
475 "d" | "day" | "days" => 86_400_000.0,
476 other => {
477 return Err(ParseError::new(
478 format!("unknown duration unit {other:?}, expected s/m/h/d"),
483 self.position(),
484 ));
485 }
486 };
487 self.advance()?;
488 Ok(mult)
489 }
490 Token::Min => {
491 self.advance()?;
493 Ok(60_000.0)
494 }
495 Token::Max | Token::Avg => {
496 let kw = self.peek().clone();
500 Err(ParseError::new(
501 format!("unknown duration unit '{}', expected s/m/h/d", kw),
502 self.position(),
503 ))
504 }
505 _ => Ok(1_000.0), }
507 }
508
509 fn parse_downsample_policy_spec(&mut self) -> Result<String, ParseError> {
510 let target = self.parse_resolution_spec()?;
511 self.expect(Token::Colon)?;
512 let source = self.parse_resolution_spec()?;
513 let aggregation = if self.consume(&Token::Colon)? {
514 self.expect_ident_or_keyword()?.to_ascii_lowercase()
515 } else {
516 "avg".to_string()
517 };
518 Ok(format!("{target}:{source}:{aggregation}"))
519 }
520
521 fn parse_resolution_spec(&mut self) -> Result<String, ParseError> {
522 match self.peek().clone() {
523 Token::Ident(value) if value.eq_ignore_ascii_case("raw") => {
524 self.advance()?;
525 Ok(value.to_ascii_lowercase())
526 }
527 Token::Integer(value) => {
528 self.advance()?;
529 let unit = self.expect_ident_or_keyword()?.to_ascii_lowercase();
530 Ok(format!("{value}{unit}"))
531 }
532 Token::Float(value) => {
533 self.advance()?;
534 let unit = self.expect_ident_or_keyword()?.to_ascii_lowercase();
535 let number = if value.fract().abs() < f64::EPSILON {
536 format!("{}", value as i64)
537 } else {
538 value.to_string()
539 };
540 Ok(format!("{number}{unit}"))
541 }
542 other => Err(ParseError::new(
543 format!(
544 "expected duration literal for downsample policy, got {}",
545 other
546 ),
547 self.position(),
548 )),
549 }
550 }
551}
552
553fn retired_columnar_keyword_error(position: crate::lexer::Position) -> ParseError {
554 ParseError::new(
555 "COLUMNAR is no longer accepted; columnar projection is automatic for in-scope \
556 collections, use NO COLUMNAR to opt out"
557 .to_string(),
558 position,
559 )
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565 use reddb_types::catalog::CollectionModel;
566
567 fn parse_query(input: &str) -> Result<QueryExpr, ParseError> {
568 crate::parser::parse(input).map(|query| query.query)
569 }
570
571 #[test]
572 fn create_timeseries_accepts_clause_order_and_defaults_to_columnar() {
573 let query = parse_query(
574 "CREATE TIMESERIES IF NOT EXISTS readings DOWNSAMPLE 1h:raw \
575 RETENTION 2 h CHUNKSIZE 64",
576 )
577 .unwrap();
578
579 let QueryExpr::CreateTimeSeries(timeseries) = query else {
580 panic!("expected create timeseries");
581 };
582 assert_eq!(timeseries.name, "readings");
583 assert!(timeseries.if_not_exists);
584 assert!(timeseries.columnar);
585 assert_eq!(timeseries.retention_ms, Some(2 * 3_600_000));
586 assert_eq!(timeseries.chunk_size, Some(64));
587 assert_eq!(timeseries.downsample_policies, vec!["1h:raw:avg"]);
588 assert_eq!(timeseries.session_key, None);
589 assert_eq!(timeseries.session_gap_ms, None);
590 assert!(timeseries.hypertable.is_none());
591 }
592
593 #[test]
594 fn create_timeseries_accepts_no_columnar_opt_out() {
595 let query = parse_query("CREATE TIMESERIES readings NO COLUMNAR").unwrap();
596 let QueryExpr::CreateTimeSeries(timeseries) = query else {
597 panic!("expected create timeseries");
598 };
599 assert!(!timeseries.columnar);
600 }
601
602 #[test]
603 fn create_timeseries_rejects_retired_columnar_keyword() {
604 for sql in [
605 "CREATE TIMESERIES readings COLUMNAR",
606 "CREATE HYPERTABLE readings TIME_COLUMN ts CHUNK_INTERVAL '1h' COLUMNAR",
607 ] {
608 let err = parse_query(sql)
609 .expect_err("COLUMNAR is retired")
610 .to_string();
611 assert!(
612 err.contains("COLUMNAR is no longer accepted"),
613 "{sql}: {err}"
614 );
615 assert!(err.contains("automatic"), "{sql}: {err}");
616 }
617 }
618
619 #[test]
620 fn create_metrics_sets_collection_defaults_and_optional_clauses() {
621 let query = parse_query(
622 "CREATE METRICS IF NOT EXISTS telemetry RETENTION 30 m \
623 DOWNSAMPLE 5m:raw:max TENANT BY (ctx.tenant)",
624 )
625 .unwrap();
626
627 let QueryExpr::CreateTable(metrics) = query else {
628 panic!("expected metrics collection");
629 };
630 assert_eq!(metrics.collection_model, CollectionModel::Metrics);
631 assert_eq!(metrics.name, "telemetry");
632 assert!(metrics.if_not_exists);
633 assert_eq!(metrics.default_ttl_ms, Some(30 * 60_000));
634 assert_eq!(metrics.metrics_rollup_policies, vec!["5m:raw:max"]);
635 assert_eq!(metrics.tenant_by.as_deref(), Some("ctx.tenant"));
636 assert!(metrics.append_only);
637 assert!(metrics.columns.is_empty());
638 }
639
640 #[test]
641 fn create_metric_alter_metric_and_slo_parse_descriptor_forms() {
642 let query = parse_query(
643 "CREATE METRIC Svc.Latency.P99 TYPE gauge ROLE sli SOURCE rollups \
644 QUERY 'SELECT p99 FROM rollups' WINDOW 5 min TIME_FIELD observed_at",
645 )
646 .unwrap();
647 let QueryExpr::CreateMetric(metric) = query else {
648 panic!("expected create metric");
649 };
650 assert_eq!(metric.path, "svc.latency.p99");
651 assert_eq!(metric.kind, "gauge");
652 assert_eq!(metric.role, "sli");
653 assert_eq!(metric.source.as_deref(), Some("rollups"));
654 assert_eq!(metric.query.as_deref(), Some("SELECT p99 FROM rollups"));
655 assert_eq!(metric.window_ms, Some(5 * 60_000));
656 assert_eq!(metric.time_field.as_deref(), Some("observed_at"));
657
658 let query = parse_query("ALTER METRIC Svc.Latency.P99 SET PATH svc.latency.p95").unwrap();
659 let QueryExpr::AlterMetric(alter) = query else {
660 panic!("expected alter metric");
661 };
662 assert_eq!(alter.path, "svc.latency.p99");
663 assert_eq!(alter.set_role, None);
664 assert_eq!(alter.attempted_kind, None);
665 assert_eq!(alter.attempted_path.as_deref(), Some("svc.latency.p95"));
666
667 let query =
668 parse_query("CREATE SLO Api.Availability ON Svc.Latency.P99 TARGET 0.999 WINDOW 28 d")
669 .unwrap();
670 let QueryExpr::CreateSlo(slo) = query else {
671 panic!("expected create slo");
672 };
673 assert_eq!(slo.path, "api.availability");
674 assert_eq!(slo.metric_path, "svc.latency.p99");
675 assert!((slo.target - 0.999).abs() < f64::EPSILON);
676 assert_eq!(slo.window_ms, 28 * 86_400_000);
677 }
678
679 #[test]
680 fn create_hypertable_and_drop_timeseries_parse_variants() {
681 let query = parse_query(
682 "CREATE HYPERTABLE IF NOT EXISTS events TIME_COLUMN ts \
683 CHUNK_INTERVAL '30m' TTL '10s' RETENTION 1 h",
684 )
685 .unwrap();
686 let QueryExpr::CreateTimeSeries(timeseries) = query else {
687 panic!("expected hypertable as timeseries");
688 };
689 let hypertable = timeseries.hypertable.expect("hypertable ddl");
690 assert_eq!(timeseries.name, "events");
691 assert!(timeseries.if_not_exists);
692 assert!(timeseries.columnar);
693 assert_eq!(timeseries.retention_ms, Some(3_600_000));
694 assert_eq!(hypertable.time_column, "ts");
695 assert_eq!(hypertable.chunk_interval_ns, 30 * 60 * 1_000_000_000);
696 assert_eq!(hypertable.default_ttl_ns, Some(10 * 1_000_000_000));
697
698 let query = parse_query(
699 "CREATE HYPERTABLE cold_events TIME_COLUMN ts CHUNK_INTERVAL '1h' NO COLUMNAR",
700 )
701 .unwrap();
702 let QueryExpr::CreateTimeSeries(timeseries) = query else {
703 panic!("expected hypertable as timeseries");
704 };
705 assert!(!timeseries.columnar);
706
707 let query = parse_query("DROP TIMESERIES IF EXISTS tenant.metrics.*").unwrap();
708 assert!(matches!(
709 query,
710 QueryExpr::DropTimeSeries(drop) if drop.name == "tenant.metrics.*" && drop.if_exists
711 ));
712 }
713
714 #[test]
715 fn timeseries_metric_and_slo_errors_are_reported() {
716 for sql in [
717 "CREATE TIMESERIES events WITH RETENTION 1 d",
718 "CREATE METRICS telemetry TENANT (ctx.tenant)",
719 "CREATE METRIC svc.latency TYPE gauge",
720 "CREATE METRIC svc.latency TYPE gauge ROLE sli QUERY 42",
721 "ALTER METRIC svc.latency ROLE sli",
722 "CREATE SLO api.availability ON svc.latency WINDOW 1 h",
723 "CREATE HYPERTABLE events TIME_COLUMN ts CHUNK_INTERVAL 'not-duration'",
724 ] {
725 assert!(parse_query(sql).is_err(), "{sql} should not parse");
726 }
727 }
728}