use sqlparser::ast::{self, Expr, Value, ValueWithSpan};
use sqlparser::tokenizer::Span;
use super::kv_insert::build_kv_insert_plan;
use super::params::KvInsertParams;
use crate::catalog::SqlCatalog;
use crate::error::{Result, SqlError};
use crate::types::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KvCounterKind {
Integer,
Float,
}
#[derive(Debug, Clone, PartialEq)]
pub enum KvCounterFreshRow {
Raw,
Typed {
column: Option<String>,
cells: Vec<(String, SqlValue)>,
},
}
pub fn plan_kv_counter_fresh_row(
info: &CollectionInfo,
key: &str,
kind: KvCounterKind,
catalog: &dyn SqlCatalog,
) -> Result<KvCounterFreshRow> {
if info.engine != EngineType::KeyValue {
return Err(SqlError::Unsupported {
detail: format!(
"KV counter on '{}', which is not a key-value collection",
info.name
),
});
}
let pk_col = info.primary_key.as_deref().unwrap_or("key");
let value_columns: Vec<&ColumnInfo> = info
.columns
.iter()
.filter(|c| c.name != pk_col && c.name != "key" && c.name != "ttl")
.collect();
if value_columns.is_empty() || (value_columns.len() == 1 && value_columns[0].name == "value") {
return Ok(KvCounterFreshRow::Raw);
}
let column = value_columns
.iter()
.filter(|c| moves_as(kind, &c.data_type))
.map(|c| c.name.clone())
.min();
let Some(column) = column else {
return Ok(KvCounterFreshRow::Typed {
column: None,
cells: Vec::new(),
});
};
let placeholder = match kind {
KvCounterKind::Integer => "0",
KvCounterKind::Float => "0.0",
};
let columns = [pk_col.to_string(), column.clone()];
let row = ast::Parens::with_empty_span(vec![
literal(Value::SingleQuotedString(key.to_string())),
literal(Value::Number(placeholder.to_string(), false)),
]);
let plans = build_kv_insert_plan(KvInsertParams {
collection: info.name.clone(),
columns: &columns,
rows_ast: std::slice::from_ref(&row),
intent: KvInsertIntent::Put,
on_conflict_updates: Vec::new(),
pk_col: info.primary_key.as_deref(),
declared_columns: &info.columns,
catalog,
})?;
let cells = match plans.into_iter().next() {
Some(SqlPlan::KvInsert(KvInsertPlan { mut entries, .. })) if entries.len() == 1 => {
let (_, cells) = entries.remove(0);
cells
.into_iter()
.filter(|(name, _)| *name != column)
.collect()
}
_ => {
return Err(SqlError::Unsupported {
detail: "KV counter fresh row did not plan as one KV insert".into(),
});
}
};
Ok(KvCounterFreshRow::Typed {
column: Some(column),
cells,
})
}
fn moves_as(kind: KvCounterKind, data_type: &SqlDataType) -> bool {
match kind {
KvCounterKind::Integer => matches!(data_type, SqlDataType::Int64),
KvCounterKind::Float => matches!(data_type, SqlDataType::Int64 | SqlDataType::Float64),
}
}
fn literal(value: Value) -> Expr {
Expr::Value(ValueWithSpan {
value,
span: Span::empty(),
})
}
#[cfg(test)]
mod tests {
use super::*;
struct NoCatalog;
impl SqlCatalog for NoCatalog {
fn get_collection(
&self,
_database_id: nodedb_types::DatabaseId,
_name: &str,
) -> std::result::Result<Option<CollectionInfo>, crate::catalog::SqlCatalogError> {
Ok(None)
}
}
fn column(name: &str, data_type: SqlDataType, default: Option<&str>) -> ColumnInfo {
ColumnInfo {
name: name.to_string(),
data_type,
nullable: true,
is_primary_key: false,
default: default.map(str::to_string),
raw_type: None,
int_width: None,
float_width: None,
}
}
fn kv_info(pk: &str, columns: Vec<ColumnInfo>) -> CollectionInfo {
let mut key = column(pk, SqlDataType::String, None);
key.is_primary_key = true;
let mut all = vec![key];
all.extend(columns);
CollectionInfo {
name: "c".into(),
engine: EngineType::KeyValue,
columns: all,
primary_key: Some(pk.into()),
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: false,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::CollectionHomed,
open_schema: CollectionInfo::open_schema_for(EngineType::KeyValue),
}
}
#[test]
fn a_single_value_column_is_raw() {
let info = kv_info("key", vec![column("value", SqlDataType::String, None)]);
let row = plan_kv_counter_fresh_row(&info, "k", KvCounterKind::Integer, &NoCatalog)
.expect("plan");
assert_eq!(row, KvCounterFreshRow::Raw);
}
#[test]
fn a_typed_collection_plans_the_insert_row_with_defaults() {
let info = kv_info(
"key",
vec![
column("n", SqlDataType::Int64, None),
column("status", SqlDataType::String, Some("'new'")),
column("score", SqlDataType::Float64, None),
],
);
let row = plan_kv_counter_fresh_row(&info, "k", KvCounterKind::Integer, &NoCatalog)
.expect("plan");
let KvCounterFreshRow::Typed { column, cells } = row else {
panic!("expected a typed row");
};
assert_eq!(column.as_deref(), Some("n"));
assert!(
cells
.iter()
.any(|(name, value)| name == "status" && *value == SqlValue::String("new".into())),
"{cells:?}"
);
assert!(
cells.iter().all(|(name, _)| name != "n" && name != "key"),
"{cells:?}"
);
}
#[test]
fn a_named_primary_key_is_kept_in_the_row() {
let info = kv_info("id", vec![column("n", SqlDataType::Int64, None)]);
let row = plan_kv_counter_fresh_row(&info, "k1", KvCounterKind::Integer, &NoCatalog)
.expect("plan");
let KvCounterFreshRow::Typed { cells, .. } = row else {
panic!("expected a typed row");
};
assert!(
cells
.iter()
.any(|(name, value)| name == "id" && *value == SqlValue::String("k1".into())),
"{cells:?}"
);
}
#[test]
fn counters_move_the_first_column_of_their_kind_in_name_order() {
let info = kv_info(
"key",
vec![
column("b", SqlDataType::Int64, None),
column("a", SqlDataType::Float64, None),
column("label", SqlDataType::String, None),
],
);
let target = |kind| match plan_kv_counter_fresh_row(&info, "k", kind, &NoCatalog) {
Ok(KvCounterFreshRow::Typed { column, .. }) => column,
other => panic!("expected a typed row, got {other:?}"),
};
assert_eq!(target(KvCounterKind::Integer).as_deref(), Some("b"));
assert_eq!(target(KvCounterKind::Float).as_deref(), Some("a"));
let text_only = kv_info("key", vec![column("label", SqlDataType::String, None)]);
let row = plan_kv_counter_fresh_row(&text_only, "k", KvCounterKind::Integer, &NoCatalog)
.expect("plan");
assert_eq!(
row,
KvCounterFreshRow::Typed {
column: None,
cells: Vec::new(),
}
);
}
}