use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use nodedb_sql::types::*;
use nodedb_types::collection::CollectionType;
use nodedb_types::columnar::{ColumnarProfile, DocumentMode};
use nodedb_types::sync::wire::CollectionDescriptor;
use crate::engine::columnar::ColumnarEngine;
use crate::engine::crdt::CrdtEngine;
use crate::engine::strict::StrictEngine;
use crate::index::IndexCatalog;
use crate::nodedb::collection::CollectionMeta;
use crate::storage::engine::StorageEngine;
use super::catalog_columns::{
columns_from_columnar_schema, columns_from_fields, columns_from_strict_schema, parse_kv_config,
parse_strict_config,
};
pub struct LiteCatalog<S: StorageEngine> {
crdt: Arc<Mutex<CrdtEngine>>,
strict: Arc<StrictEngine<S>>,
columnar: Arc<ColumnarEngine<S>>,
metas: HashMap<String, CollectionMeta>,
array_names: Vec<String>,
indexes: Option<Arc<IndexCatalog>>,
}
impl<S: StorageEngine> LiteCatalog<S> {
pub fn new(
crdt: Arc<Mutex<CrdtEngine>>,
strict: Arc<StrictEngine<S>>,
columnar: Arc<ColumnarEngine<S>>,
metas: HashMap<String, CollectionMeta>,
) -> Self {
Self {
crdt,
strict,
columnar,
metas,
array_names: Vec::new(),
indexes: None,
}
}
pub fn with_indexes(mut self, indexes: Arc<IndexCatalog>) -> Self {
self.indexes = Some(indexes);
self
}
pub fn with_arrays(mut self, array_names: Vec<String>) -> Self {
self.array_names = array_names;
self
}
fn info_from_meta(&self, name: &str, meta: &CollectionMeta) -> CollectionInfo {
if let Some(dj) = &meta.descriptor_json
&& let Ok(desc) = sonic_rs::from_str::<CollectionDescriptor>(dj)
{
return self.info_from_descriptor(name, &desc);
}
self.info_from_meta_strings(name, meta)
}
fn columnar_columns_or_fields(
&self,
name: &str,
fields: &[(String, String)],
) -> Vec<ColumnInfo> {
match self.columnar.schema(name) {
Some(schema) => columns_from_columnar_schema(&schema).0,
None => columns_from_fields(fields),
}
}
fn info_from_descriptor(&self, name: &str, desc: &CollectionDescriptor) -> CollectionInfo {
let (engine, columns, primary_key) = match &desc.collection_type {
CollectionType::Document(DocumentMode::Schemaless) => (
EngineType::DocumentSchemaless,
columns_from_fields(&desc.fields),
Some("id".into()),
),
CollectionType::Document(DocumentMode::Strict(schema)) => {
let schema = self.strict.schema(name).unwrap_or_else(|| schema.clone());
let (cols, pk) = columns_from_strict_schema(&schema);
(EngineType::DocumentStrict, cols, pk)
}
CollectionType::Columnar(
ColumnarProfile::Plain
| ColumnarProfile::Timeseries { .. }
| ColumnarProfile::Spatial { .. },
) => {
let cols = self.columnar_columns_or_fields(name, &desc.fields);
(EngineType::Columnar, cols, None)
}
CollectionType::KeyValue(cfg) => {
let (cols, pk) = columns_from_strict_schema(&cfg.schema);
(EngineType::KeyValue, cols, pk)
}
};
CollectionInfo {
name: name.into(),
engine,
columns,
primary_key,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: desc.bitemporal,
primary: desc.primary,
vector_primary: desc.vector_primary.clone(),
partition_strategy: desc.partition_strategy.clone(),
open_schema: CollectionInfo::open_schema_for(engine),
}
}
fn info_from_meta_strings(&self, name: &str, meta: &CollectionMeta) -> CollectionInfo {
let (engine, columns, primary_key) = match meta.collection_type.as_str() {
"document_strict" => {
let schema = self
.strict
.schema(name)
.or_else(|| parse_strict_config(meta.config_json.as_deref()));
match schema {
Some(s) => {
let (cols, pk) = columns_from_strict_schema(&s);
(EngineType::DocumentStrict, cols, pk)
}
None => (
EngineType::DocumentStrict,
columns_from_fields(&meta.fields),
None,
),
}
}
"columnar" | "timeseries" | "spatial" => (
EngineType::Columnar,
self.columnar_columns_or_fields(name, &meta.fields),
None,
),
"kv" => match parse_kv_config(meta.config_json.as_deref()) {
Some(cfg) => {
let (cols, pk) = columns_from_strict_schema(&cfg.schema);
(EngineType::KeyValue, cols, pk)
}
None => (
EngineType::KeyValue,
columns_from_fields(&meta.fields),
None,
),
},
_ => (
EngineType::DocumentSchemaless,
columns_from_fields(&meta.fields),
Some("id".into()),
),
};
CollectionInfo {
name: name.into(),
engine,
columns,
primary_key,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: meta.bitemporal,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::default(),
open_schema: CollectionInfo::open_schema_for(engine),
}
}
}
impl<S: StorageEngine> SqlCatalog for LiteCatalog<S> {
fn array_exists(&self, name: &str) -> bool {
self.array_names.iter().any(|n| n == name)
}
fn get_collection(
&self,
_database_id: nodedb_types::id::DatabaseId,
name: &str,
) -> Result<Option<CollectionInfo>, nodedb_sql::catalog::SqlCatalogError> {
let mut info = self.resolve_collection(name);
if let Some(info) = &mut info
&& matches!(
info.engine,
EngineType::DocumentSchemaless | EngineType::DocumentStrict
)
&& let Some(indexes) = &self.indexes
{
info.indexes = indexes.planner_specs(name);
}
Ok(info)
}
}
impl<S: StorageEngine> LiteCatalog<S> {
fn resolve_collection(&self, name: &str) -> Option<CollectionInfo> {
if let Some(meta) = self.metas.get(name) {
return Some(self.info_from_meta(name, meta));
}
if let Some(schema) = self.strict.schema(name) {
let (columns, pk) = columns_from_strict_schema(&schema);
return Some(CollectionInfo {
name: name.into(),
engine: EngineType::DocumentStrict,
columns,
primary_key: pk,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: schema.bitemporal,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::default(),
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentStrict),
});
}
if let Some(schema) = self.columnar.schema(name) {
let (columns, primary_key) = columns_from_columnar_schema(&schema);
return Some(CollectionInfo {
name: name.into(),
engine: EngineType::Columnar,
columns,
primary_key,
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: self.columnar.is_bitemporal(name),
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::default(),
open_schema: CollectionInfo::open_schema_for(EngineType::Columnar),
});
}
if let Ok(crdt) = self.crdt.lock()
&& crdt.collection_names().iter().any(|n| n == name)
{
return Some(CollectionInfo {
name: name.into(),
engine: EngineType::DocumentSchemaless,
columns: vec![ColumnInfo {
name: "id".into(),
data_type: SqlDataType::String,
nullable: false,
is_primary_key: true,
default: None,
raw_type: None,
int_width: None,
float_width: None,
}],
primary_key: Some("id".into()),
has_auto_tier: false,
indexes: Vec::new(),
bitemporal: false,
primary: nodedb_types::PrimaryEngine::Document,
vector_primary: None,
partition_strategy: nodedb_types::PartitionStrategy::default(),
open_schema: CollectionInfo::open_schema_for(EngineType::DocumentSchemaless),
});
}
None
}
}
#[cfg(test)]
mod tests {
use nodedb_types::collection::CollectionType;
use nodedb_types::collection_config::{PartitionStrategy, PrimaryEngine};
use nodedb_types::columnar::{ColumnDef, ColumnType, StrictSchema};
use nodedb_types::id::DatabaseId;
use nodedb_types::sync::wire::CollectionDescriptor;
use super::*;
use crate::{NodeDbLite, PagedbStorageMem};
async fn make_db() -> Arc<NodeDbLite<PagedbStorageMem>> {
let storage = PagedbStorageMem::open_in_memory().await.unwrap();
NodeDbLite::open(storage).await.unwrap()
}
fn descriptor(name: &str, ct: CollectionType, bitemporal: bool) -> CollectionDescriptor {
CollectionDescriptor {
tenant_id: 1,
database_id: DatabaseId::new(1),
name: name.into(),
collection_type: ct,
bitemporal,
crdt: false,
fields: vec![("v".into(), "BIGINT".into())],
primary: PrimaryEngine::Document,
vector_primary: None,
partition_strategy: PartitionStrategy::default(),
declared_primary_key: None,
descriptor_version: 1,
}
}
async fn catalog_for(db: &NodeDbLite<PagedbStorageMem>) -> LiteCatalog<PagedbStorageMem> {
let metas =
crate::nodedb::collection::ddl::load_persisted_collection_metas(db.storage.as_ref())
.await
.unwrap();
LiteCatalog::new(
Arc::clone(&db.crdt),
Arc::clone(&db.strict),
Arc::clone(&db.columnar),
metas,
)
}
#[tokio::test]
async fn synced_strict_surfaces_real_engine_and_bitemporal() {
let db = make_db().await;
let schema = StrictSchema::new(vec![
ColumnDef::required("id", ColumnType::Int64).with_primary_key(),
ColumnDef::nullable("name", ColumnType::String),
])
.unwrap();
let desc = descriptor("s", CollectionType::strict(schema), true);
db.register_collection_from_descriptor(&desc).await.unwrap();
let catalog = catalog_for(&db).await;
let info = catalog
.get_collection(DatabaseId::new(1), "s")
.unwrap()
.expect("collection surfaced");
assert_eq!(info.engine, EngineType::DocumentStrict);
assert!(
info.bitemporal,
"lite#3: bitemporal must be REAL, not hardcoded false"
);
assert_eq!(info.primary_key.as_deref(), Some("id"));
assert_eq!(info.columns.len(), 2);
assert_eq!(info.columns[0].name, "id");
}
#[tokio::test]
async fn synced_timeseries_surfaces_engine_from_meta() {
let db = make_db().await;
let desc = descriptor("t", CollectionType::timeseries("ts", "1h"), true);
db.register_collection_from_descriptor(&desc).await.unwrap();
let catalog = catalog_for(&db).await;
let info = catalog
.get_collection(DatabaseId::new(1), "t")
.unwrap()
.expect("collection surfaced");
assert_eq!(info.engine, EngineType::Columnar);
assert!(info.bitemporal);
}
#[tokio::test]
async fn fallback_timeseries_surfaces_real_columns() {
let db = make_db().await;
let schema = nodedb_types::columnar::ColumnarSchema {
columns: vec![
ColumnDef::required("time", ColumnType::Timestamp),
ColumnDef::nullable("value", ColumnType::Float64),
],
version: 1,
};
db.columnar
.create_collection(
"metrics",
schema,
nodedb_types::columnar::ColumnarProfile::Timeseries {
time_key: "time".into(),
interval: "1h".into(),
},
false,
)
.await
.unwrap();
let metas =
crate::nodedb::collection::ddl::load_persisted_collection_metas(db.storage.as_ref())
.await
.unwrap();
assert!(!metas.contains_key("metrics"));
let catalog = catalog_for(&db).await;
let info = catalog
.get_collection(DatabaseId::new(1), "metrics")
.unwrap()
.expect("collection surfaced");
assert_eq!(info.engine, EngineType::Columnar);
assert_eq!(
info.columns.len(),
2,
"timeseries fallback must surface real engine columns, not empty"
);
assert_eq!(info.columns[0].name, "time");
assert_eq!(info.columns[1].name, "value");
}
#[tokio::test]
async fn synced_kv_surfaces_engine_and_columns() {
let db = make_db().await;
let schema = StrictSchema::new(vec![
ColumnDef::required("k", ColumnType::String).with_primary_key(),
ColumnDef::nullable("val", ColumnType::Bytes),
])
.unwrap();
let desc = descriptor("kvc", CollectionType::kv(schema), false);
db.register_collection_from_descriptor(&desc).await.unwrap();
let catalog = catalog_for(&db).await;
let info = catalog
.get_collection(DatabaseId::new(1), "kvc")
.unwrap()
.expect("collection surfaced");
assert_eq!(info.engine, EngineType::KeyValue);
assert_eq!(info.primary_key.as_deref(), Some("k"));
assert_eq!(info.columns.len(), 2);
}
}