use nodedb_sql::types::{EngineType, SqlExpr, SqlValue};
use nodedb_types::Surrogate;
use nodedb_types::columnar::{ColumnDef, ColumnType, ColumnarSchema};
use crate::bridge::envelope::PhysicalPlan;
use crate::types::{TenantId, VShardId};
use nodedb_physical::physical_plan::ColumnarInsertIntent;
use nodedb_physical::physical_plan::*;
use super::super::convert::ConvertContext;
use super::super::value::{
assignments_to_update_values, row_to_msgpack, rows_to_msgpack_array, sql_value_to_string,
};
use nodedb_physical::physical_task::{PhysicalTask, PostSetOp};
pub(crate) fn build_columnar_schema(column_schema: &[(String, String)]) -> Option<ColumnarSchema> {
if column_schema.is_empty() {
return None;
}
let mut cols = Vec::with_capacity(column_schema.len());
let mut has_id = false;
for (name, type_str) in column_schema {
let bare_type = type_str
.split_whitespace()
.next()
.unwrap_or(type_str.as_str());
let col_type = bare_type
.parse::<ColumnType>()
.unwrap_or(ColumnType::String);
let is_id = name == "id" || name == "document_id";
if is_id {
has_id = true;
cols.push(ColumnDef::required(name.clone(), col_type).with_primary_key());
} else {
cols.push(ColumnDef::nullable(name.clone(), col_type));
}
}
if !has_id {
cols.insert(
0,
ColumnDef::required("id", ColumnType::String).with_primary_key(),
);
}
ColumnarSchema::new(cols).ok()
}
fn build_schema_bytes(column_schema: &[(String, String)]) -> Vec<u8> {
build_columnar_schema(column_schema)
.map(|schema| zerompk::to_msgpack_vec(&schema).unwrap_or_default())
.unwrap_or_default()
}
fn extract_doc_id(row: &[(String, SqlValue)], primary_key: Option<&str>) -> String {
row.iter()
.find(|(k, _)| match primary_key {
Some(pk) => k == pk,
None => k == "id" || k == "document_id" || k == "key",
})
.map(|(_, v)| sql_value_to_string(v))
.unwrap_or_default()
}
pub(super) fn assign_for_pk(
ctx: &ConvertContext,
collection: &str,
pk_bytes: &[u8],
) -> crate::Result<Surrogate> {
match ctx.surrogate_assigner.as_ref() {
Some(a) => a.assign(ctx.database_id, ctx.tenant_id, collection, pk_bytes),
None => Ok(Surrogate::ZERO),
}
}
pub(super) fn assign_fresh(ctx: &ConvertContext, collection: &str) -> crate::Result<Surrogate> {
match ctx.surrogate_assigner.as_ref() {
Some(a) => a.assign_fresh(ctx.database_id, ctx.tenant_id, collection),
None => Ok(Surrogate::ZERO),
}
}
fn is_auto_rowid_pk(primary_key: Option<&str>) -> bool {
primary_key == Some("_rowid")
}
pub(super) fn columnar_row_surrogates(
ctx: &ConvertContext,
collection: &str,
columnar_rows: &[&Vec<(String, SqlValue)>],
primary_key: Option<&str>,
) -> crate::Result<Vec<Surrogate>> {
let mut out = Vec::with_capacity(columnar_rows.len());
for row in columnar_rows {
if is_auto_rowid_pk(primary_key) {
out.push(assign_fresh(ctx, collection)?);
continue;
}
let pk = extract_doc_id(row, primary_key);
if pk.is_empty() {
out.push(assign_fresh(ctx, collection)?);
} else {
out.push(assign_for_pk(ctx, collection, pk.as_bytes())?);
}
}
Ok(out)
}
pub(in super::super) fn nodedb_value_to_sql(val: nodedb_types::Value) -> SqlValue {
match val {
nodedb_types::Value::Integer(n) => SqlValue::Int(n),
nodedb_types::Value::Float(f) => SqlValue::Float(f),
nodedb_types::Value::String(s) => SqlValue::String(s),
nodedb_types::Value::Bool(b) => SqlValue::Bool(b),
nodedb_types::Value::Null => SqlValue::Null,
_ => SqlValue::String(format!("{val:?}")),
}
}
pub(in super::super) struct ConvertInsertArgs<'a> {
pub collection: &'a str,
pub engine: &'a EngineType,
pub rows: &'a [Vec<(String, SqlValue)>],
pub column_defaults: &'a [(String, String)],
pub column_schema: &'a [(String, String)],
pub if_absent: bool,
pub primary_key: Option<&'a str>,
pub tenant_id: TenantId,
pub ctx: &'a ConvertContext,
}
pub(in super::super) fn convert_insert(
args: ConvertInsertArgs<'_>,
) -> crate::Result<Vec<PhysicalTask>> {
let ConvertInsertArgs {
collection,
engine,
rows,
column_defaults,
column_schema,
if_absent,
primary_key,
tenant_id,
ctx,
} = args;
let coll_qualified = super::super::convert::db_qualified(ctx.database_id, collection);
let collection = coll_qualified.as_str();
let vshard = VShardId::from_collection_in_database(ctx.database_id, collection);
let mut tasks = Vec::new();
let mut columnar_rows: Vec<&Vec<(String, SqlValue)>> = Vec::new();
let is_crdt = super::crdt_gate::document_collection_is_crdt(ctx, collection)?;
if is_crdt && if_absent {
return Err(crate::Error::BadRequest {
detail: format!(
"INSERT ... IF NOT EXISTS on CRDT collection '{collection}' is not supported; \
CRDT documents converge via last-writer-wins full replace"
),
});
}
let mut expanded_rows: Vec<Vec<(String, SqlValue)>> = Vec::with_capacity(rows.len());
for row in rows {
if column_defaults.is_empty() {
expanded_rows.push(row.clone());
continue;
}
let mut expanded = row.clone();
for (col_name, default_expr) in column_defaults {
if !expanded.iter().any(|(k, _)| k == col_name)
&& let Some(val) = super::super::value::evaluate_default_expr(default_expr)
.map_err(|e| crate::Error::PlanError {
detail: format!("default for column '{col_name}': {e}"),
})?
{
expanded.push((col_name.clone(), nodedb_value_to_sql(val)));
}
}
expanded_rows.push(expanded);
}
for (i, row) in expanded_rows.iter().enumerate() {
let doc_id = extract_doc_id(row, primary_key);
match engine {
EngineType::KeyValue => {
return Err(crate::Error::PlanError {
detail: "KV INSERT must use SqlPlan::KvInsert path".into(),
});
}
EngineType::Timeseries => {
return Err(crate::Error::PlanError {
detail: format!(
"INSERT into '{collection}': timeseries collections use TimeseriesIngest, not Insert"
),
});
}
EngineType::Columnar | EngineType::Spatial => {
columnar_rows.push(&rows[i]);
}
EngineType::DocumentSchemaless | EngineType::DocumentStrict => {
let value_bytes = row_to_msgpack(row)?;
let (doc_id, surrogate) = if is_auto_rowid_pk(primary_key) || doc_id.is_empty() {
let s = assign_fresh(ctx, collection)?;
(s.as_u32().to_string(), s)
} else {
let s = assign_for_pk(ctx, collection, doc_id.as_bytes())?;
(doc_id, s)
};
let plan = if is_crdt {
PhysicalPlan::Crdt(CrdtOp::DocUpsert {
collection: collection.into(),
document_id: doc_id,
fields_json: super::crdt_gate::row_to_fields_json(row)?,
surrogate,
partial: false,
returning: None,
})
} else {
PhysicalPlan::Document(DocumentOp::PointInsert {
collection: collection.into(),
document_id: doc_id,
value: value_bytes,
if_absent,
surrogate,
})
};
tasks.push(PhysicalTask {
tenant_id,
vshard_id: vshard,
database_id: ctx.database_id,
plan,
post_set_op: PostSetOp::None,
txn_id: None,
});
}
EngineType::Array => {
return Err(crate::Error::PlanError {
detail: format!(
"INSERT into '{collection}': array engine uses INSERT INTO ARRAY syntax"
),
});
}
}
}
if !columnar_rows.is_empty() {
let payload = rows_to_msgpack_array(&columnar_rows, column_defaults)?;
let intent = if if_absent {
ColumnarInsertIntent::InsertIfAbsent
} else {
ColumnarInsertIntent::Insert
};
let surrogates = columnar_row_surrogates(ctx, collection, &columnar_rows, primary_key)?;
let schema_bytes = build_schema_bytes(column_schema);
tasks.push(PhysicalTask {
tenant_id,
vshard_id: vshard,
database_id: ctx.database_id,
plan: PhysicalPlan::Columnar(ColumnarOp::Insert {
collection: collection.into(),
payload,
format: "msgpack".into(),
intent,
on_conflict_updates: Vec::new(),
surrogates,
schema_bytes,
provenance: None,
wal_lsn: None,
}),
post_set_op: PostSetOp::None,
txn_id: None,
});
}
Ok(tasks)
}
pub(in super::super) struct ConvertUpsertArgs<'a> {
pub collection: &'a str,
pub engine: &'a EngineType,
pub rows: &'a [Vec<(String, SqlValue)>],
pub column_defaults: &'a [(String, String)],
pub column_schema: &'a [(String, String)],
pub on_conflict_updates: &'a [(String, SqlExpr)],
pub primary_key: Option<&'a str>,
pub tenant_id: TenantId,
pub ctx: &'a ConvertContext,
}
pub(in super::super) fn convert_upsert(
args: ConvertUpsertArgs<'_>,
) -> crate::Result<Vec<PhysicalTask>> {
let ConvertUpsertArgs {
collection,
engine,
rows,
column_defaults,
column_schema,
on_conflict_updates,
primary_key,
tenant_id,
ctx,
} = args;
let coll_qualified = super::super::convert::db_qualified(ctx.database_id, collection);
let collection = coll_qualified.as_str();
let vshard = VShardId::from_collection_in_database(ctx.database_id, collection);
let mut tasks = Vec::new();
let is_crdt = super::crdt_gate::document_collection_is_crdt(ctx, collection)?;
if is_crdt && !on_conflict_updates.is_empty() {
return Err(crate::Error::BadRequest {
detail: format!(
"UPSERT with ON CONFLICT DO UPDATE on CRDT collection '{collection}' is not \
supported; CRDT documents converge via last-writer-wins full replace"
),
});
}
let on_conflict_values = if on_conflict_updates.is_empty() {
Vec::new()
} else {
assignments_to_update_values(on_conflict_updates)?
};
let mut columnar_rows: Vec<&Vec<(String, SqlValue)>> = Vec::new();
for row in rows {
let doc_id = extract_doc_id(row, primary_key);
match engine {
EngineType::DocumentSchemaless | EngineType::DocumentStrict => {
let value_bytes = row_to_msgpack(row)?;
let (doc_id, surrogate) = if is_auto_rowid_pk(primary_key) || doc_id.is_empty() {
let s = assign_fresh(ctx, collection)?;
(s.as_u32().to_string(), s)
} else {
let s = assign_for_pk(ctx, collection, doc_id.as_bytes())?;
(doc_id, s)
};
let plan = if is_crdt {
PhysicalPlan::Crdt(CrdtOp::DocUpsert {
collection: collection.into(),
document_id: doc_id,
fields_json: super::crdt_gate::row_to_fields_json(row)?,
surrogate,
partial: false,
returning: None,
})
} else {
PhysicalPlan::Document(DocumentOp::Upsert {
collection: collection.into(),
document_id: doc_id,
value: value_bytes,
on_conflict_updates: on_conflict_values.clone(),
surrogate,
})
};
tasks.push(PhysicalTask {
tenant_id,
vshard_id: vshard,
database_id: ctx.database_id,
plan,
post_set_op: PostSetOp::None,
txn_id: None,
});
}
EngineType::Columnar | EngineType::Spatial => {
columnar_rows.push(row);
}
EngineType::Timeseries | EngineType::KeyValue | EngineType::Array => {
return Err(crate::Error::PlanError {
detail: format!(
"UPSERT into '{collection}': engine type {engine:?} does not support upsert"
),
});
}
}
}
if !columnar_rows.is_empty() {
let payload = rows_to_msgpack_array(&columnar_rows, column_defaults)?;
let surrogates = columnar_row_surrogates(ctx, collection, &columnar_rows, primary_key)?;
let schema_bytes = build_schema_bytes(column_schema);
tasks.push(PhysicalTask {
tenant_id,
vshard_id: vshard,
database_id: ctx.database_id,
plan: PhysicalPlan::Columnar(ColumnarOp::Insert {
collection: collection.into(),
payload,
format: "msgpack".into(),
intent: ColumnarInsertIntent::Put,
on_conflict_updates: on_conflict_values,
surrogates,
schema_bytes,
provenance: None,
wal_lsn: None,
}),
post_set_op: PostSetOp::None,
txn_id: None,
});
}
Ok(tasks)
}