use macros_process_mining::register_binding;
#[cfg(feature = "ocel-sqlite")]
use crate::bindings::{RegistryItem, StateRef};
#[cfg(all(feature = "ocel-sqlite", feature = "extraction-dbcon"))]
use crate::core::event_data::object_centric::extraction::DbconRowProvider;
use crate::core::event_data::object_centric::extraction::{
compile, validate, Blueprint, CompiledOcel, EmissionShape, ExtractionCatalog, ExtractionReport,
SqlDialect, ValidationError,
};
#[cfg(feature = "ocel-sqlite")]
use crate::core::event_data::object_centric::extraction::{
extract, provider::distinct_column_values, provider::preview_rows, Catalog, RowProvider,
SlimOcelSink, SqliteRowProvider, TablePreview,
};
#[cfg(feature = "ocel-sqlite")]
use crate::core::event_data::object_centric::linked_ocel::SlimLinkedOCEL;
#[cfg(feature = "ocel-sqlite")]
use crate::core::tabular_source::TabularReader;
#[cfg(feature = "ocel-sqlite")]
use std::collections::HashMap;
#[register_binding]
fn extraction_validate(blueprint: Blueprint, catalog: ExtractionCatalog) -> Vec<ValidationError> {
validate(&blueprint, &catalog)
}
#[register_binding]
fn extraction_compile(
blueprint: Blueprint,
catalog: ExtractionCatalog,
shape: EmissionShape,
#[bind(default)] dialect: SqlDialect,
) -> CompiledOcel {
compile(&blueprint, &catalog, dialect, shape)
}
#[cfg(feature = "ocel-sqlite")]
fn open_sources<'a>(
state: StateRef<'a>,
sources: &HashMap<String, String>,
) -> Result<Vec<(Vec<String>, OpenedSource<'a>)>, 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<(Vec<String>, OpenedSource<'a>)> = 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 opened = match src.format() {
"sqlite" | "sqlite3" | "db" => src
.reader(SqliteRowProvider::from_slice)
.map(OpenedSource::Sqlite),
#[cfg(feature = "extraction-dbcon")]
format @ ("csv" | "tsv" | "parquet" | "xlsx") => {
let format = format.to_string();
let name = named.clone();
src.reader(move |bytes| {
DbconRowProvider::from_bytes(&name, &format, std::sync::Arc::from(bytes))
})
.map(OpenedSource::Dbcon)
}
other => Err(format!("this build cannot read '{other}' from memory")),
};
out.push((
source_ids,
opened.map_err(|e| format!("source '{named}': {e}"))?,
));
}
Ok(out)
}
#[cfg(feature = "ocel-sqlite")]
fn opened_for<'a, 'b>(
opened: &'b [(Vec<String>, OpenedSource<'a>)],
source_id: &str,
) -> Result<&'b OpenedSource<'a>, String> {
opened
.iter()
.find(|(ids, _)| ids.iter().any(|id| id == source_id))
.map(|(_, o)| o)
.ok_or_else(|| format!("no source '{source_id}'"))
}
pub(super) fn merge_into(into: &mut ExtractionCatalog, from: ExtractionCatalog) {
into.tables.extend(from.tables);
into.domains.extend(from.domains);
into.previews.extend(from.previews);
}
pub(super) fn report_error_message(report: &ExtractionReport) -> Option<String> {
if report.errors.is_empty() {
return None;
}
let listed = report
.errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" | ");
let total = report.errors.len() as u64 + report.errors_suppressed;
let mut msg = format!("extraction reported {total} errors: {listed}");
if report.errors_suppressed > 0 {
msg.push_str(&format!(
" (and {} further errors, not kept)",
report.errors_suppressed
));
}
Some(msg)
}
#[cfg(feature = "ocel-sqlite")]
fn discover_all(opened: &[(Vec<String>, OpenedSource<'_>)]) -> Result<ExtractionCatalog, String> {
let mut catalog = ExtractionCatalog::new();
for (source_ids, reader) in opened {
for source_id in source_ids {
merge_into(&mut catalog, reader.catalog(source_id)?);
}
}
Ok(catalog)
}
#[cfg(feature = "ocel-sqlite")]
enum OpenedSource<'a> {
Sqlite(TabularReader<'a, SqliteRowProvider>),
#[cfg(feature = "extraction-dbcon")]
Dbcon(TabularReader<'a, DbconRowProvider>),
}
#[cfg(feature = "ocel-sqlite")]
impl OpenedSource<'_> {
fn provider(&self) -> &dyn RowProvider {
match self {
Self::Sqlite(r) => r.get(),
#[cfg(feature = "extraction-dbcon")]
Self::Dbcon(r) => r.get(),
}
}
fn catalog(&self, source_id: &str) -> Result<ExtractionCatalog, String> {
match self {
Self::Sqlite(r) => r
.get()
.discover_catalog(source_id)
.map_err(|e| format!("source '{source_id}': {e}")),
#[cfg(feature = "extraction-dbcon")]
Self::Dbcon(r) => Ok(r.get().discover_catalog(source_id)),
}
}
}
#[cfg(feature = "ocel-sqlite")]
fn provider_refs<'a>(
opened: &'a [(Vec<String>, OpenedSource<'a>)],
) -> HashMap<String, &'a dyn RowProvider> {
opened
.iter()
.flat_map(|(source_ids, reader)| {
let provider = reader.provider();
source_ids.iter().map(move |id| (id.clone(), provider))
})
.collect()
}
#[cfg(feature = "ocel-sqlite")]
#[register_binding(stringify_error)]
fn extraction_discover_catalog_items(
#[bind(state)] state: StateRef<'_>,
sources: HashMap<String, String>,
) -> Result<ExtractionCatalog, String> {
discover_all(&open_sources(state, &sources)?)
}
#[cfg(feature = "ocel-sqlite")]
#[register_binding(stringify_error)]
fn extraction_column_domain_items(
#[bind(state)] state: StateRef<'_>,
sources: HashMap<String, String>,
source_id: String,
table: String,
column: String,
) -> Result<Vec<String>, String> {
let opened = open_sources(state, &sources)?;
let provider = opened_for(&opened, &source_id)?.provider();
distinct_column_values(provider, &table, &column)
.map_err(|e| format!("source '{source_id}': {e}"))
}
#[cfg(feature = "ocel-sqlite")]
#[register_binding(stringify_error)]
fn extraction_table_preview_items(
#[bind(state)] state: StateRef<'_>,
sources: HashMap<String, String>,
source_id: String,
table: String,
#[bind(default)] limit: Option<usize>,
) -> Result<TablePreview, String> {
let opened = open_sources(state, &sources)?;
let source = opened_for(&opened, &source_id)?;
let catalog = source.catalog(&source_id)?;
let schema = catalog
.table(&source_id, &table)
.ok_or_else(|| format!("source '{source_id}' has no table '{table}'"))?;
let columns: Vec<&str> = schema.columns.keys().map(String::as_str).collect();
preview_rows(source.provider(), &table, &columns, limit.unwrap_or(20))
.map_err(|e| format!("source '{source_id}': {e}"))
}
#[cfg(feature = "ocel-sqlite")]
#[register_binding(stringify_error)]
fn extraction_run_items(
#[bind(state)] state: StateRef<'_>,
blueprint: Blueprint,
sources: HashMap<String, String>,
#[bind(default)] catalog: Option<ExtractionCatalog>,
) -> Result<SlimLinkedOCEL, String> {
let providers = open_sources(state, &sources)?;
let catalog = match catalog {
Some(c) => c,
None => discover_all(&providers)?,
};
let mut sink = SlimOcelSink::new();
let report = extract(&blueprint, &catalog, &provider_refs(&providers), &mut sink)
.map_err(|e| e.to_string())?;
if let Some(msg) = report_error_message(&report) {
return Err(msg);
}
Ok(sink.into_ocel())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bindings::{call, list_functions, AppState};
use crate::core::event_data::object_centric::extraction::{AttributeMapping, FlatEventTable};
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(),
})
}
fn flat_catalog() -> ExtractionCatalog {
ExtractionCatalog::new().with_table(
"db",
crate::core::event_data::object_centric::extraction::TableSchema::new(
"events",
[
("case_id", "TEXT", false),
("activity", "TEXT", false),
("ts", "TEXT", false),
],
),
)
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn a_registry_source_reports_its_column_domain_and_a_preview() {
use crate::core::tabular_source::TabularSource;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shop.sqlite");
{
let con = rusqlite::Connection::open(&path).expect("open");
con.execute_batch(
"CREATE TABLE orders (kind TEXT, amount TEXT);
INSERT INTO orders VALUES ('web', '10'), ('phone', '20'), ('web', '30');",
)
.expect("seed");
}
let state = AppState::default();
state.add(
"src1",
RegistryItem::TabularSource(TabularSource::new(
std::fs::read(&path).expect("read"),
"sqlite",
)),
);
let domain = list_functions()
.into_iter()
.find(|b| b.name.ends_with("extraction_column_domain_items"))
.expect("domain binding is registered");
let args = serde_json::json!({
"sources": { "shop": "src1" }, "source_id": "shop",
"table": "orders", "column": "kind",
});
let out = call(domain, &args, &state).expect("domain succeeds");
let mut values: Vec<String> = serde_json::from_slice(&out).expect("deserializes");
values.sort();
assert_eq!(values, vec!["phone".to_string(), "web".to_string()]);
let preview = list_functions()
.into_iter()
.find(|b| b.name.ends_with("extraction_table_preview_items"))
.expect("preview binding is registered");
let args = serde_json::json!({
"sources": { "shop": "src1" }, "source_id": "shop", "table": "orders", "limit": 2,
});
let out = call(preview, &args, &state).expect("preview succeeds");
let preview: TablePreview = serde_json::from_slice(&out).expect("deserializes");
assert_eq!(
preview.columns,
vec!["amount".to_string(), "kind".to_string()]
);
assert_eq!(preview.rows.len(), 2, "limit is respected");
assert_eq!(
preview.rows[0],
vec![Some("10".to_string()), Some("web".to_string())]
);
}
#[cfg(all(feature = "ocel-sqlite", feature = "extraction-dbcon"))]
#[test]
fn a_registry_csv_reports_its_column_domain_too() {
use crate::core::tabular_source::TabularSource;
let state = AppState::default();
state.add(
"src1",
RegistryItem::TabularSource(TabularSource::new(
b"kind,amount\nweb,10\nphone,20\nweb,30\n".to_vec(),
"csv",
)),
);
let domain = list_functions()
.into_iter()
.find(|b| b.name.ends_with("extraction_column_domain_items"))
.expect("domain binding is registered");
let args = serde_json::json!({
"sources": { "rows": "src1" }, "source_id": "rows",
"table": "rows", "column": "kind",
});
let out = call(domain, &args, &state).expect("domain succeeds");
let mut values: Vec<String> = serde_json::from_slice(&out).expect("deserializes");
values.sort();
assert_eq!(values, vec!["phone".to_string(), "web".to_string()]);
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn an_extraction_reads_a_source_held_in_the_registry() {
use crate::core::tabular_source::TabularSource;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("shop.sqlite");
{
let con = rusqlite::Connection::open(&path).expect("open");
con.execute_batch(
"CREATE TABLE orders (order_id TEXT, placed_at TEXT);
INSERT INTO orders VALUES ('o1', '2024-01-02T03:04:05Z'),
('o2', '2024-01-03T03:04:05Z');",
)
.expect("seed");
}
let bytes = std::fs::read(&path).expect("read");
let state = AppState::default();
state.add(
"src1",
RegistryItem::TabularSource(TabularSource::new(bytes, "sqlite")),
);
let discover = list_functions()
.into_iter()
.find(|b| b.name.ends_with("extraction_discover_catalog_items"))
.expect("discover binding is registered");
let args = serde_json::json!({ "sources": { "shop": "src1" } });
let out = call(discover, &args, &state).expect("discovery succeeds");
let catalog: serde_json::Value = serde_json::from_slice(&out).expect("deserializes");
assert!(
catalog["tables"]["shop"]["orders"].is_object(),
"discovered the table: {catalog}"
);
let run = list_functions()
.into_iter()
.find(|b| b.name.ends_with("extraction_run_items"))
.expect("run binding is registered");
let blueprint = serde_json::json!({
"version": 1,
"nodes": [{ "id": "n1", "op": { "type": "source", "source_id": "shop", "table": "orders" } }],
"mappings": [{
"type": "single",
"node": "n1",
"target": {
"type": "event",
"event_type": { "type": "constant", "value": "placed" },
"id": { "type": "column", "column": "order_id" },
"timestamp": { "type": "value", "source": { "type": "column", "column": "placed_at" } }
}
}]
});
let args = serde_json::json!({ "blueprint": blueprint, "sources": { "shop": "src1" } });
let out = call(run, &args, &state).expect("run succeeds");
let handle: String = serde_json::from_slice(&out).expect("a handle id");
let items = state.items.read().expect("lock");
let Some(RegistryItem::SlimLinkedOCEL(log)) = items.get(&handle) else {
panic!("the run should store a log under its returned handle");
};
use crate::core::event_data::object_centric::linked_ocel::LinkedOCELAccess;
assert_eq!(log.get_ev_types().count(), 1);
assert_eq!(log.get_all_evs().count(), 2);
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn two_sources_merge_into_one_catalog_without_clobbering_each_other() {
use crate::core::event_data::object_centric::extraction::Catalog;
use crate::core::tabular_source::TabularSource;
let dir = tempfile::tempdir().expect("tempdir");
let mut state = AppState::default();
for (item, table) in [("srcA", "alpha"), ("srcB", "beta")] {
let path = dir.path().join(format!("{item}.sqlite"));
{
let con = rusqlite::Connection::open(&path).expect("open");
con.execute_batch(&format!("CREATE TABLE {table} (id TEXT);"))
.expect("seed");
}
state = {
state.add(
item,
RegistryItem::TabularSource(TabularSource::new(
std::fs::read(&path).expect("read"),
"sqlite",
)),
);
state
};
}
let discover = list_functions()
.into_iter()
.find(|b| b.name == "extraction_discover_catalog_items")
.expect("registered");
let args = serde_json::json!({ "sources": { "a": "srcA", "b": "srcB" } });
let out = call(discover, &args, &state).expect("discovery succeeds");
let catalog: ExtractionCatalog = serde_json::from_slice(&out).expect("deserializes");
assert!(catalog.table("a", "alpha").is_some(), "{catalog:?}");
assert!(catalog.table("b", "beta").is_some(), "{catalog:?}");
}
#[cfg(all(feature = "ocel-sqlite", feature = "extraction-dbcon"))]
#[test]
fn a_workbook_registered_as_bytes_discovers_one_table_per_sheet() {
use crate::core::io::Importable;
use crate::core::tabular_source::TabularSource;
assert!(
<TabularSource as Importable>::known_import_formats()
.iter()
.any(|f| f.extension == "xlsx"),
"xlsx must be an advertised import format or nothing can register one"
);
let state = AppState::default();
state.add(
"book",
RegistryItem::TabularSource(TabularSource::new(minimal_xlsx(), "xlsx")),
);
let discover = list_functions()
.into_iter()
.find(|b| b.name == "extraction_discover_catalog_items")
.expect("registered");
let args = serde_json::json!({ "sources": { "s": "book" } });
let out = call(discover, &args, &state).expect("the workbook opens from memory");
let catalog: serde_json::Value = serde_json::from_slice(&out).expect("catalog is JSON");
let tables = &catalog["tables"]["s"];
assert!(
tables.get("orders").is_some(),
"expected a table per sheet, got {tables}"
);
assert!(
tables["orders"]["columns"].get("id").is_some(),
"expected the header row as columns, got {}",
tables["orders"]["columns"]
);
}
#[cfg(all(feature = "ocel-sqlite", feature = "extraction-dbcon"))]
fn minimal_xlsx() -> Vec<u8> {
const MINIMAL_XLSX_BASE64: &str = concat!(
"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",
"AABZBAAAAAA=",
);
super::super::decode_base64(MINIMAL_XLSX_BASE64).expect("the fixture is valid base64")
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn a_source_that_cannot_be_opened_is_named_in_the_error() {
use crate::core::tabular_source::TabularSource;
let state = AppState::default();
state.add(
"parquet1",
RegistryItem::TabularSource(TabularSource::new(b"PAR1".to_vec(), "parquet")),
);
state.add(
"junk",
RegistryItem::TabularSource(TabularSource::new(vec![0u8; 4096], "sqlite")),
);
state.add(
"notasource",
RegistryItem::SlimLinkedOCEL(SlimLinkedOCEL::new()),
);
let discover = list_functions()
.into_iter()
.find(|b| b.name == "extraction_discover_catalog_items")
.expect("registered");
let err_for = |id: &str| {
let args = serde_json::json!({ "sources": { "s": id } });
call(discover, &args, &state).expect_err("must not open")
};
assert!(
err_for("missing").contains("no item 'missing'"),
"{}",
err_for("missing")
);
assert!(
err_for("notasource").contains("is not a data source"),
"{}",
err_for("notasource")
);
let parquet = err_for("parquet1");
assert!(parquet.contains("source 's'"), "{parquet}");
#[cfg(feature = "extraction-dbcon")]
assert!(parquet.to_lowercase().contains("parquet file"), "{parquet}");
#[cfg(not(feature = "extraction-dbcon"))]
assert!(parquet.contains("cannot read 'parquet'"), "{parquet}");
let junk = err_for("junk");
assert!(
junk.contains("source 's'") && junk.contains("not a SQLite database"),
"{junk}"
);
}
#[cfg(feature = "ocel-sqlite")]
#[test]
fn a_state_argument_is_not_a_required_json_argument() {
for name in ["extraction_discover_catalog_items", "extraction_run_items"] {
let binding = list_functions()
.into_iter()
.find(|b| b.name == name)
.unwrap_or_else(|| panic!("{name} is registered"));
let declared: Vec<String> = (binding.args)().into_iter().map(|(n, _)| n).collect();
let required = (binding.required_args)();
assert!(
!declared.contains(&"state".to_string()),
"{name} must not declare a schema for its state argument: {declared:?}"
);
for req in &required {
assert!(
declared.contains(req),
"{name} requires '{req}' but declares no schema for it: {declared:?}"
);
}
assert!(
required.contains(&"sources".to_string()),
"{name} still requires its real arguments: {required:?}"
);
}
}
#[test]
fn extraction_validate_round_trips_through_the_registry() {
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_validate")
.expect("extraction_validate registered");
let args = serde_json::json!({
"blueprint": flat_blueprint(),
"catalog": flat_catalog(),
});
let state = AppState::default();
let bytes = call(binding, &args, &state).expect("call succeeds");
let errors: Vec<ValidationError> =
serde_json::from_slice(&bytes).expect("result deserializes");
assert!(
errors.is_empty(),
"a valid blueprint validates clean: {errors:?}"
);
}
#[test]
fn extraction_validate_reports_an_unknown_source() {
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_validate")
.expect("extraction_validate registered");
let args = serde_json::json!({
"blueprint": flat_blueprint(),
"catalog": ExtractionCatalog::new(),
});
let state = AppState::default();
let bytes = call(binding, &args, &state).expect("call succeeds");
let errors: Vec<ValidationError> =
serde_json::from_slice(&bytes).expect("result deserializes");
assert!(
!errors.is_empty(),
"an empty catalog cannot satisfy a blueprint reading table 'events'"
);
}
#[test]
fn extraction_compile_round_trips_through_the_registry() {
let binding = list_functions()
.into_iter()
.find(|b| b.name == "extraction_compile")
.expect("extraction_compile registered");
let args = serde_json::json!({
"blueprint": flat_blueprint(),
"catalog": flat_catalog(),
"shape": "PerType",
});
let state = AppState::default();
let bytes = call(binding, &args, &state).expect("call succeeds");
let compiled: serde_json::Value =
serde_json::from_slice(&bytes).expect("result deserializes");
let views = compiled
.get("views")
.and_then(|v| v.as_array())
.expect("a 'views' array");
assert!(!views.is_empty(), "compiling a valid blueprint emits views");
}
#[test]
fn every_extraction_binding_has_non_empty_schemas() {
let expected = ["extraction_validate", "extraction_compile"];
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"
);
}
}
}