use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::filter::LogicalFilter;
use super::projection::{LogicalPagination, LogicalProjection, LogicalSort};
use super::value::LogicalValue;
pub type LogicalRecord = BTreeMap<String, LogicalValue>;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct LogicalRead {
pub message_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<LogicalFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projection: Option<LogicalProjection>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sort: Vec<LogicalSort>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<LogicalInclude>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pagination: Option<LogicalPagination>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogicalInclude {
pub relation: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalWrite {
pub message_type: String,
pub records: Vec<LogicalRecord>,
#[serde(default)]
pub conflict: ConflictStrategy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LogicalAssignment {
Set { value: LogicalValue },
ServerNow,
Increment { by: LogicalValue },
Coalesce { value: LogicalValue },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalUpdate {
pub message_type: String,
pub filter: LogicalFilter,
pub assignments: BTreeMap<String, LogicalAssignment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_fields: Vec<String>,
#[serde(default)]
pub require_affected: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ConflictStrategy {
#[default]
Error,
Ignore,
Replace,
Update {
fields: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
conflict_on: Option<Vec<String>>,
},
}
impl ConflictStrategy {
pub fn update(fields: Vec<String>) -> Self {
Self::Update {
fields,
conflict_on: None,
}
}
pub fn update_on(fields: Vec<String>, conflict_on: Vec<String>) -> Self {
Self::Update {
fields,
conflict_on: Some(conflict_on),
}
}
pub fn conflict_target(&self) -> Option<&[String]> {
match self {
Self::Update {
conflict_on: Some(cols),
..
} if !cols.is_empty() => Some(cols),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalDelete {
pub message_type: String,
pub filter: LogicalFilter,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub return_fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalSearch {
pub message_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vector: Option<Vec<f32>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<LogicalFilter>,
pub top_k: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score_threshold: Option<f32>,
#[serde(default)]
pub require_hybrid: bool,
#[serde(default)]
pub with_vector: bool,
#[serde(default = "default_true")]
pub with_payload: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalAggregate {
pub message_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<LogicalFilter>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub group_by: Vec<String>,
pub aggregates: Vec<AggregateExpr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub having: Option<LogicalFilter>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sort: Vec<LogicalSort>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pagination: Option<LogicalPagination>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AggregateExpr {
pub func: AggregateFunc,
pub field: String,
pub alias: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AggregateFunc {
Count,
CountDistinct,
Sum,
Avg,
Min,
Max,
}
impl AggregateFunc {
pub fn sql_token(self) -> &'static str {
match self {
Self::Count | Self::CountDistinct => "COUNT",
Self::Sum => "SUM",
Self::Avg => "AVG",
Self::Min => "MIN",
Self::Max => "MAX",
}
}
}
impl LogicalAggregate {
pub fn count_all(message_type: impl Into<String>, alias: impl Into<String>) -> Self {
Self {
message_type: message_type.into(),
filter: None,
group_by: Vec::new(),
aggregates: vec![AggregateExpr {
func: AggregateFunc::Count,
field: "*".into(),
alias: alias.into(),
}],
having: None,
sort: Vec::new(),
pagination: None,
}
}
pub fn with_filter(mut self, f: LogicalFilter) -> Self {
self.filter = Some(f);
self
}
pub fn with_group_by(mut self, fields: Vec<String>) -> Self {
self.group_by = fields;
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LogicalResourceOp {
pub op: ResourceOpKind,
pub resource_kind: ResourceKind,
pub resource_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spec: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceOpKind {
Ensure,
Drop,
List,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResourceKind {
Table,
Collection,
Bucket,
Index,
Constraint,
Topic,
}
impl LogicalRead {
pub fn message(message_type: impl Into<String>) -> Self {
Self {
message_type: message_type.into(),
..Self::default()
}
}
pub fn with_filter(mut self, f: LogicalFilter) -> Self {
self.filter = Some(f);
self
}
pub fn with_sort(mut self, s: Vec<LogicalSort>) -> Self {
self.sort = s;
self
}
pub fn with_include(mut self, relation: impl Into<String>) -> Self {
self.include.push(LogicalInclude {
relation: relation.into(),
});
self
}
pub fn with_pagination(mut self, p: LogicalPagination) -> Self {
self.pagination = Some(p);
self
}
pub fn with_projection(mut self, p: LogicalProjection) -> Self {
self.projection = Some(p);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ir::filter::ComparisonOp;
#[test]
fn read_builder_chains() {
let r = LogicalRead::message("Customer")
.with_filter(LogicalFilter::Comparison {
field: "active".into(),
op: ComparisonOp::Eq,
value: LogicalValue::Bool(true),
})
.with_pagination(LogicalPagination::limit(50));
assert_eq!(r.message_type, "Customer");
assert!(r.filter.is_some());
assert_eq!(r.pagination.unwrap().limit, Some(50));
}
#[test]
fn conflict_default_is_error() {
let w = LogicalWrite {
message_type: "X".into(),
records: vec![],
conflict: ConflictStrategy::default(),
return_fields: vec![],
};
assert_eq!(w.conflict, ConflictStrategy::Error);
}
#[test]
fn conflict_on_is_back_compat_and_targeted() {
let legacy: ConflictStrategy =
serde_json::from_str(r#"{"kind":"update","fields":["name"]}"#).expect("parse legacy");
assert_eq!(legacy, ConflictStrategy::update(vec!["name".into()]));
assert_eq!(
legacy.conflict_target(),
None,
"no conflict_on ⇒ use the PK"
);
let alt = ConflictStrategy::update_on(vec!["name".into()], vec!["email".into()]);
assert_eq!(alt.conflict_target(), Some(&["email".to_string()][..]));
let json = serde_json::to_string(&alt).expect("serialize");
assert_eq!(
serde_json::from_str::<ConflictStrategy>(&json).expect("round-trip"),
alt
);
let empty = ConflictStrategy::Update {
fields: vec!["name".into()],
conflict_on: Some(vec![]),
};
assert_eq!(empty.conflict_target(), None);
}
#[test]
fn search_defaults_to_payload_on() {
let json = r#"{"message_type":"Doc","top_k":5}"#;
let s: LogicalSearch = serde_json::from_str(json).expect("parse");
assert!(s.with_payload, "with_payload must default to true");
assert!(!s.with_vector, "with_vector must default to false");
assert!(!s.require_hybrid);
}
}