#![allow(clippy::result_large_err)]
use std::collections::HashMap;
use macros_process_mining::register_binding;
use crate::bindings::extraction_bindings::merge_into;
#[cfg(not(feature = "ocel-sqlite"))]
use crate::bindings::extraction_bindings::report_error_message;
#[cfg(not(feature = "ocel-sqlite"))]
use crate::bindings::{RegistryItem, StateRef};
use crate::core::event_data::object_centric::extraction::{
discover_catalog, extract, Blueprint, Catalog, DbconProviderError, DbconRowProvider,
ExtractionCatalog, ExtractionError, ExtractionReport, ExtractionSink, ExtractionTiming,
ProviderError, RowProvider, SlimOcelSink, TablePreview,
};
use crate::core::event_data::object_centric::linked_ocel::SlimLinkedOCEL;
#[cfg(not(feature = "ocel-sqlite"))]
use crate::core::tabular_source::TabularReader;
#[derive(Debug)]
enum ExtractionRunError {
UnknownSource(String),
UnknownTable { source_id: String, table: String },
Provider(ProviderError),
Connect(DbconProviderError),
Extract(ExtractionError),
#[cfg(feature = "ocel-duckdb")]
Sink(crate::core::event_data::object_centric::extraction::SinkError),
}
impl std::fmt::Display for ExtractionRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownSource(id) => write!(f, "no connection given for source '{id}'"),
Self::UnknownTable { source_id, table } => {
write!(f, "source '{source_id}' has no table '{table}'")
}
Self::Provider(e) => write!(f, "{e}"),
Self::Connect(e) => write!(f, "{e}"),
Self::Extract(e) => write!(f, "{e}"),
#[cfg(feature = "ocel-duckdb")]
Self::Sink(e) => write!(f, "{e}"),
}
}
}
impl From<DbconProviderError> for ExtractionRunError {
fn from(e: DbconProviderError) -> Self {
Self::Connect(e)
}
}
impl From<ProviderError> for ExtractionRunError {
fn from(e: ProviderError) -> Self {
Self::Provider(e)
}
}
impl From<ExtractionError> for ExtractionRunError {
fn from(e: ExtractionError) -> Self {
Self::Extract(e)
}
}
fn discover_catalog_from_connections(
connections: &HashMap<String, String>,
) -> Result<ExtractionCatalog, ExtractionRunError> {
let mut catalog = ExtractionCatalog::new();
for (source_id, connection_string) in connections {
merge_into(
&mut catalog,
discover_catalog(source_id, connection_string)?,
);
}
Ok(catalog)
}
fn open_providers(
connections: &HashMap<String, String>,
) -> Result<HashMap<String, DbconRowProvider>, ExtractionRunError> {
let mut providers = HashMap::with_capacity(connections.len());
for (source_id, connection_string) in connections {
providers.insert(
source_id.clone(),
DbconRowProvider::connect(source_id, connection_string)?,
);
}
Ok(providers)
}
fn run_extraction(
blueprint: &Blueprint,
connections: &HashMap<String, String>,
catalog: Option<ExtractionCatalog>,
sink: &mut dyn ExtractionSink,
) -> Result<ExtractionReport, ExtractionRunError> {
let started = std::time::Instant::now();
let catalog = match catalog {
Some(c) => c,
None => discover_catalog_from_connections(connections)?,
};
let providers = open_providers(connections)?;
let discovery_ms = started.elapsed().as_millis() as u64;
let extraction_started = std::time::Instant::now();
let provider_refs: HashMap<String, &dyn RowProvider> = providers
.iter()
.map(|(source_id, provider)| (source_id.clone(), provider as &dyn RowProvider))
.collect();
let mut report = extract(blueprint, &catalog, &provider_refs, sink)?;
report.timing = Some(ExtractionTiming {
discovery_ms,
extraction_ms: extraction_started.elapsed().as_millis() as u64,
});
Ok(report)
}
#[register_binding]
fn extraction_connection_kinds() -> Vec<String> {
dbcon::enabled_backends()
.iter()
.map(|s| (*s).to_string())
.collect()
}
#[register_binding(stringify_error)]
fn extraction_discover_catalog(
connections: HashMap<String, String>,
) -> Result<ExtractionCatalog, ExtractionRunError> {
discover_catalog_from_connections(&connections)
}
#[register_binding(stringify_error)]
fn extraction_column_domain(
connections: HashMap<String, String>,
source_id: String,
table: String,
column: String,
) -> Result<Vec<String>, ExtractionRunError> {
let connection_string = connections
.get(&source_id)
.ok_or_else(|| ExtractionRunError::UnknownSource(source_id.clone()))?;
let provider = DbconRowProvider::connect(&source_id, connection_string)?;
Ok(provider.distinct_values(&table, &column)?)
}
#[register_binding(stringify_error)]
fn extraction_table_preview(
connections: HashMap<String, String>,
source_id: String,
table: String,
#[bind(default)] limit: Option<usize>,
) -> Result<TablePreview, ExtractionRunError> {
let connection_string = connections
.get(&source_id)
.ok_or_else(|| ExtractionRunError::UnknownSource(source_id.clone()))?;
let provider = DbconRowProvider::connect(&source_id, connection_string)?;
let catalog = discover_catalog(&source_id, connection_string)?;
let schema =
catalog
.table(&source_id, &table)
.ok_or_else(|| ExtractionRunError::UnknownTable {
source_id: source_id.clone(),
table: table.clone(),
})?;
let columns: Vec<&str> = schema.columns.keys().map(String::as_str).collect();
Ok(provider.table_preview(&table, &columns, limit.unwrap_or(DEFAULT_PREVIEW_ROWS))?)
}
const DEFAULT_PREVIEW_ROWS: usize = 5;
#[register_binding(stringify_error)]
fn extraction_run(
ocel: &mut SlimLinkedOCEL,
blueprint: Blueprint,
connections: HashMap<String, String>,
#[bind(default)] catalog: Option<ExtractionCatalog>,
) -> Result<ExtractionReport, ExtractionRunError> {
let mut sink = SlimOcelSink::new();
let report = run_extraction(&blueprint, &connections, catalog, &mut sink)?;
*ocel = sink.into_ocel();
Ok(report)
}
#[cfg(feature = "ocel-duckdb")]
#[register_binding(stringify_error)]
fn extraction_run_to_duckdb(
blueprint: Blueprint,
connections: HashMap<String, String>,
target_path: impl AsRef<std::path::Path>,
#[bind(default)] catalog: Option<ExtractionCatalog>,
) -> Result<ExtractionReport, ExtractionRunError> {
let mut sink =
crate::core::event_data::object_centric::extraction::DuckDbSink::new(target_path)
.map_err(ExtractionRunError::Sink)?;
run_extraction(&blueprint, &connections, catalog, &mut sink)
}
#[cfg(not(feature = "ocel-sqlite"))]
#[register_binding(stringify_error)]
fn extraction_discover_catalog_items_dbcon(
#[bind(state)] state: StateRef<'_>,
sources: HashMap<String, String>,
) -> Result<ExtractionCatalog, String> {
let mut catalog = ExtractionCatalog::new();
for (source_ids, reader) in open_items(state, &sources)? {
for source_id in source_ids {
merge_into(&mut catalog, reader.get().discover_catalog(&source_id));
}
}
Ok(catalog)
}
#[cfg(not(feature = "ocel-sqlite"))]
#[register_binding(stringify_error)]
fn extraction_run_items_dbcon(
#[bind(state)] state: StateRef<'_>,
blueprint: Blueprint,
sources: HashMap<String, String>,
#[bind(default)] catalog: Option<ExtractionCatalog>,
) -> Result<SlimLinkedOCEL, String> {
let opened = open_items(state, &sources)?;
let catalog = match catalog {
Some(c) => c,
None => {
let mut discovered = ExtractionCatalog::new();
for (source_ids, reader) in &opened {
for source_id in source_ids {
merge_into(&mut discovered, reader.get().discover_catalog(source_id));
}
}
discovered
}
};
let provider_refs: HashMap<String, &dyn RowProvider> = opened
.iter()
.flat_map(|(source_ids, reader)| {
let provider = reader.get() as &dyn RowProvider;
source_ids.iter().map(move |id| (id.clone(), provider))
})
.collect();
let mut sink = SlimOcelSink::new();
let report =
extract(&blueprint, &catalog, &provider_refs, &mut sink).map_err(|e| e.to_string())?;
if let Some(msg) = report_error_message(&report) {
return Err(msg);
}
Ok(sink.into_ocel())
}
#[cfg(not(feature = "ocel-sqlite"))]
fn open_items<'a>(
state: StateRef<'a>,
sources: &HashMap<String, String>,
) -> Result<Vec<(Vec<String>, TabularReader<'a, DbconRowProvider>)>, String> {
let mut by_item: Vec<(&str, Vec<String>)> = Vec::new();
for (source_id, item_id) in sources {
match by_item.iter_mut().find(|(id, _)| *id == item_id.as_str()) {
Some((_, ids)) => ids.push(source_id.clone()),
None => by_item.push((item_id.as_str(), vec![source_id.clone()])),
}
}
by_item.sort_unstable();
let mut out = Vec::with_capacity(by_item.len());
for (item_id, source_ids) in by_item {
let named = source_ids.join(", ");
let Some(item) = state.get(item_id) else {
return Err(format!("no item '{item_id}' for source '{named}'"));
};
let RegistryItem::TabularSource(src) = item else {
return Err(format!("item '{item_id}' is not a data source"));
};
let format = src.format().to_string();
let reader = src
.reader(|bytes| {
DbconRowProvider::from_bytes(&named, &format, std::sync::Arc::from(bytes))
})
.map_err(|e| format!("source '{named}': {e}"))?;
out.push((source_ids, reader));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bindings::{call, list_functions, AppState, RegistryItem};
#[cfg(feature = "ocel-sqlite")]
use crate::core::event_data::object_centric::extraction::{AttributeMapping, FlatEventTable};
use crate::core::event_data::object_centric::extraction::{Catalog, TableSchema};
#[cfg(feature = "ocel-sqlite")]
fn flat_blueprint() -> Blueprint {
Blueprint::from_flat_event_table(FlatEventTable {
source_id: "db".to_string(),
table: "events".to_string(),
case_id: "case_id".to_string(),
activity: "activity".to_string(),
timestamp: "ts".to_string(),
case_object_type: "Case".to_string(),
case_attributes: Vec::<AttributeMapping>::new(),
event_attributes: Vec::<AttributeMapping>::new(),
})
}
#[cfg(feature = "ocel-sqlite")]
fn write_fixture_sqlite() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("fixture.sqlite");
let con = rusqlite::Connection::open(&path).expect("open sqlite file");
con.execute_batch("CREATE TABLE events (case_id TEXT, activity TEXT, ts TEXT);")
.expect("create table");
let rows = [
("A", "create", "2020-01-01T00:00:00Z"),
("A", "close", "2020-01-02T00:00:00Z"),
("B", "create", "2020-01-01T00:00:00Z"),
];
for (case_id, activity, ts) in rows {
con.execute(
"INSERT INTO events (case_id, activity, ts) VALUES (?1, ?2, ?3)",
rusqlite::params![case_id, activity, ts],
)
.expect("insert row");
}
drop(con);
(dir, path)
}
#[test]
fn every_connected_binding_has_non_empty_schemas() {
let expected = [
"extraction_discover_catalog",
"extraction_column_domain",
"extraction_run",
#[cfg(feature = "ocel-duckdb")]
"extraction_run_to_duckdb",
];
let registered = list_functions();
for name in expected {
let binding = registered
.iter()
.find(|b| b.name == name)
.unwrap_or_else(|| panic!("{name} is registered"));
assert!(
!(binding.args)().is_empty(),
"{name} should declare at least one argument"
);
for (arg_name, schema) in (binding.args)() {
assert!(
schema.is_object(),
"{name}'s argument '{arg_name}' should have a non-empty JSON schema"
);
}
let return_schema = (binding.return_type)();
assert!(
return_schema.is_object(),
"{name} should have a non-empty return schema"
);
}
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn discover_validate_and_extract_a_sqlite_fixture_through_the_registry() {
let (_dir, path) = write_fixture_sqlite();
let connection_string = format!("sqlite:{}", path.display());
let bp_json = serde_json::to_value(flat_blueprint()).expect("serialize blueprint");
assert!(
!serde_json::to_string(&bp_json)
.expect("stringify")
.to_ascii_lowercase()
.contains("sqlite:"),
"a blueprint must carry no connection string, even after a JSON round trip"
);
let state = AppState::default();
let connections = serde_json::json!({ "db": connection_string });
let discover = list_functions()
.into_iter()
.find(|b| b.name == "extraction_discover_catalog")
.expect("extraction_discover_catalog registered");
let catalog_bytes = call(
discover,
&serde_json::json!({ "connections": connections }),
&state,
)
.expect("discover_catalog succeeds");
let catalog: ExtractionCatalog =
serde_json::from_slice(&catalog_bytes).expect("catalog deserializes");
assert!(catalog.table("db", "events").is_some());
let validate_fn = list_functions()
.into_iter()
.find(|b| b.name == "extraction_validate")
.expect("extraction_validate registered by process_mining");
let validate_bytes = call(
validate_fn,
&serde_json::json!({ "blueprint": bp_json, "catalog": catalog }),
&state,
)
.expect("validate succeeds");
let errors: serde_json::Value =
serde_json::from_slice(&validate_bytes).expect("errors deserialize");
assert_eq!(
errors.as_array().map(Vec::len),
Some(0),
"the fixture blueprint should validate: {errors:?}"
);
let locel_new = list_functions()
.into_iter()
.find(|b| b.name == "locel_new")
.expect("locel_new registered");
let handle_bytes = call(locel_new, &serde_json::json!({}), &state).expect("locel_new");
let handle: String = serde_json::from_slice(&handle_bytes).expect("handle deserializes");
let run_binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_run")
.expect("extraction_run registered");
let run_args = serde_json::json!({
"ocel": handle,
"blueprint": bp_json,
"connections": connections,
});
let report_bytes = call(run_binding, &run_args, &state).expect("extraction_run succeeds");
let report: serde_json::Value =
serde_json::from_slice(&report_bytes).expect("report deserializes as JSON");
let errors = report
.get("errors")
.and_then(|e| e.as_array())
.expect("an 'errors' array");
assert!(errors.is_empty(), "no errors expected: {errors:?}");
let rows_read: u64 = report
.get("per_mapping")
.and_then(|v| v.as_array())
.expect("a 'per_mapping' array")
.iter()
.map(|m| {
m.get("rows_read")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
})
.sum();
assert_eq!(rows_read, 3, "the fixture table has 3 rows");
let items = state.items.read().unwrap();
let stored = items.get(&handle).expect("handle resolves in the registry");
let RegistryItem::SlimLinkedOCEL(locel) = stored else {
panic!("handle resolved to {stored:?}, not a SlimLinkedOCEL");
};
assert_eq!(locel.get_evs_of_type("create").count(), 2);
assert_eq!(locel.get_evs_of_type("close").count(), 1);
assert_eq!(locel.get_obs_of_type("Case").count(), 2);
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn extraction_column_domain_reports_distinct_values() {
let (_dir, path) = write_fixture_sqlite();
let connection_string = format!("sqlite:{}", path.display());
let state = AppState::default();
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_column_domain")
.expect("extraction_column_domain registered");
let args = serde_json::json!({
"connections": { "db": connection_string },
"source_id": "db",
"table": "events",
"column": "activity",
});
let bytes = call(binding, &args, &state).expect("call succeeds");
let mut values: Vec<String> = serde_json::from_slice(&bytes).expect("deserializes");
values.sort();
assert_eq!(values, vec!["close".to_string(), "create".to_string()]);
}
#[cfg(all(feature = "ocel-duckdb", feature = "ocel-sqlite"))]
#[test]
fn extraction_run_to_duckdb_writes_a_readable_file() {
use crate::core::event_data::object_centric::ocel_sql::read_consolidated_ocel_from_duckdb_path;
let (_dir, path) = write_fixture_sqlite();
let connection_string = format!("sqlite:{}", path.display());
let out_dir = tempfile::tempdir().expect("tempdir");
let out_path = out_dir.path().join("out.duckdb");
let state = AppState::default();
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_run_to_duckdb")
.expect("extraction_run_to_duckdb registered");
let args = serde_json::json!({
"blueprint": flat_blueprint(),
"connections": { "db": connection_string },
"target_path": out_path.to_str().unwrap(),
});
let bytes = call(binding, &args, &state).expect("call succeeds");
let report: serde_json::Value =
serde_json::from_slice(&bytes).expect("report deserializes as JSON");
let errors = report
.get("errors")
.and_then(|e| e.as_array())
.expect("an 'errors' array");
assert!(errors.is_empty(), "no errors expected: {errors:?}");
assert!(
out_path.exists(),
"the DuckDB file should have been written"
);
let ocel = read_consolidated_ocel_from_duckdb_path(&out_path).expect("read duckdb back");
assert_eq!(ocel.events.len(), 3);
assert_eq!(ocel.objects.len(), 2);
}
#[test]
fn extraction_run_declares_ocel_as_a_registry_reference() {
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_run")
.expect("extraction_run registered");
let (_, schema) = (binding.args)()
.into_iter()
.find(|(name, _)| name == "ocel")
.expect("an 'ocel' argument");
assert_eq!(
schema.get("x-registry-ref").and_then(|v| v.as_str()),
Some("SlimLinkedOCEL")
);
}
#[test]
fn a_catalog_can_be_built_by_hand() {
let catalog = ExtractionCatalog::new().with_table(
"db",
TableSchema::new("events", [("case_id", "TEXT", false)]),
);
assert!(catalog.table("db", "events").is_some());
}
}