use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarProfile, ColumnarSchema};
use nodedb_types::result::QueryResult;
use nodedb_types::value::Value;
use crate::error::LiteError;
use crate::query::engine::LiteQueryEngine;
use crate::storage::engine::StorageEngine;
impl<S: StorageEngine> LiteQueryEngine<S> {
pub(in crate::query) async fn handle_create_timeseries(
&self,
sql: &str,
) -> Result<QueryResult, LiteError> {
let (name, schema, interval) = parse_timeseries_create(sql)?;
let time_key = schema
.columns
.iter()
.find(|c| matches!(c.column_type, ColumnType::Timestamp))
.map(|c| c.name.clone())
.ok_or_else(|| {
LiteError::Query(
"timeseries collection requires at least one TIMESTAMP column".into(),
)
})?;
let collection_type = nodedb_types::collection::CollectionType::timeseries(
time_key.as_str(),
interval.as_str(),
);
let fields: Vec<(String, String)> = schema
.columns
.iter()
.map(|c| (c.name.clone(), c.column_type.to_string()))
.collect();
let profile = ColumnarProfile::Timeseries {
time_key,
interval: interval.clone(),
};
self.columnar
.create_collection(&name, schema, profile, false)
.await?;
self.register_columnar_collection(&name);
self.persist_engine_collection_meta(&name, collection_type, fields, false)
.await?;
Ok(QueryResult {
columns: vec!["result".into()],
rows: vec![vec![Value::String(format!(
"timeseries collection '{name}' created (interval: {interval})"
))]],
rows_affected: 0,
command: None,
})
}
}
fn parse_timeseries_create(sql: &str) -> Result<(String, ColumnarSchema, String), LiteError> {
let upper = sql.to_uppercase();
let ts_pos = upper
.find("TIMESERIES")
.ok_or(LiteError::Query("expected TIMESERIES keyword".into()))?;
let after_ts = sql[ts_pos + 10..].trim();
let after_keyword = if after_ts.to_uppercase().starts_with("COLLECTION") {
after_ts[10..].trim()
} else {
after_ts
};
let name_end = after_keyword
.find(|c: char| c == '(' || c.is_whitespace())
.unwrap_or(after_keyword.len());
let name = after_keyword[..name_end].trim().to_lowercase();
if name.is_empty() {
return Err(LiteError::Query("missing collection name".into()));
}
let columns = if let Some(paren_start) = sql.find('(') {
let before_paren = sql[..paren_start].to_uppercase();
if before_paren.contains("TIME") && before_paren.ends_with("TIME") {
default_timeseries_columns()
} else {
parse_ts_columns(sql)?
}
} else {
default_timeseries_columns()
};
let interval = parse_partition_interval(&upper, sql);
let schema = ColumnarSchema::new(columns)?;
Ok((name, schema, interval))
}
fn default_timeseries_columns() -> Vec<ColumnDef> {
vec![
ColumnDef::required("time", ColumnType::Timestamp),
ColumnDef::nullable("value", ColumnType::Float64),
]
}
fn parse_ts_columns(sql: &str) -> Result<Vec<ColumnDef>, LiteError> {
use super::parser::{parse_column_def, split_top_level_commas};
let paren_start = sql
.find('(')
.ok_or(LiteError::Query("expected column definitions".into()))?;
let mut depth = 0;
let mut paren_end = None;
for (i, b) in sql.bytes().enumerate().skip(paren_start) {
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
paren_end = Some(i);
break;
}
}
_ => {}
}
}
let paren_end = paren_end.ok_or(LiteError::Query("unmatched parenthesis".into()))?;
let col_defs_str = &sql[paren_start + 1..paren_end];
let parts = split_top_level_commas(col_defs_str);
let mut columns = Vec::new();
for part in &parts {
let trimmed = part.trim();
if trimmed.is_empty() {
continue;
}
columns.push(parse_column_def(trimmed)?);
}
if columns.is_empty() {
return Ok(default_timeseries_columns());
}
if !columns
.iter()
.any(|c| matches!(c.column_type, ColumnType::Timestamp))
{
return Err(LiteError::Query(
"timeseries collection requires at least one TIMESTAMP column".into(),
));
}
Ok(columns)
}
fn parse_partition_interval(upper: &str, _sql: &str) -> String {
if let Some(pos) = upper.find("PARTITION BY TIME(") {
let start = pos + 18;
if let Some(end) = upper[start..].find(')') {
let interval = upper[start..start + end].trim();
if !interval.is_empty() {
return interval.to_lowercase();
}
}
}
"1h".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_timeseries() {
let sql = "CREATE TIMESERIES COLLECTION metrics (
time TIMESTAMP NOT NULL,
host TEXT,
cpu FLOAT64,
mem FLOAT64
) PARTITION BY TIME(1h)";
let (name, schema, interval) = parse_timeseries_create(sql).expect("parse");
assert_eq!(name, "metrics");
assert_eq!(schema.columns.len(), 4);
assert_eq!(schema.columns[0].name, "time");
assert!(matches!(
schema.columns[0].column_type,
ColumnType::Timestamp
));
assert_eq!(interval, "1h");
}
#[test]
fn parse_timeseries_no_columns() {
let sql = "CREATE TIMESERIES COLLECTION metrics";
let (name, schema, interval) = parse_timeseries_create(sql).expect("parse");
assert_eq!(name, "metrics");
assert_eq!(schema.columns.len(), 2); assert!(matches!(
schema.columns[0].column_type,
ColumnType::Timestamp
));
assert_eq!(interval, "1h"); }
#[test]
fn parse_timeseries_custom_interval() {
let sql = "CREATE TIMESERIES COLLECTION logs PARTITION BY TIME(1d)";
let (_, _, interval) = parse_timeseries_create(sql).expect("parse");
assert_eq!(interval, "1d");
}
#[test]
fn parse_timeseries_without_collection_keyword() {
let sql = "CREATE TIMESERIES sensors (
ts TIMESTAMP NOT NULL,
temperature FLOAT64
)";
let (name, schema, _) = parse_timeseries_create(sql).expect("parse");
assert_eq!(name, "sensors");
assert_eq!(schema.columns.len(), 2);
}
#[test]
fn parse_timeseries_no_timestamp_rejected() {
let sql = "CREATE TIMESERIES COLLECTION bad (
name TEXT,
value FLOAT64
)";
assert!(parse_timeseries_create(sql).is_err());
}
}