spacedrive/core/src/infra/sync/fk_mapper.rs
2025-11-26 11:08:59 -08:00

433 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Foreign Key UUID Mapping for Sync
//!
//! Handles automatic conversion between local integer FKs and global UUIDs for sync.
//!
//! ## The Problem
//!
//! Auto-incrementing integer PKs are local to each database:
//! - Device A has device_id=1 for Device A, device_id=2 for Device B
//! - Device B has device_id=1 for Device B, device_id=2 for Device A
//!
//! We cannot sync integer IDs directly - they mean different things on each device.
//!
//! ## The Solution
//!
//! Sync protocol uses UUIDs exclusively. This module provides:
//! 1. `convert_fk_to_uuid()` - Converts local integer FKs → UUIDs before sending
//! 2. `map_sync_json_to_local()` - Converts UUIDs → local integer FKs on receive
//!
//! ## Fully Polymorphic Design
//!
//! This module contains ZERO model-specific code. All lookups go through the registry:
//! - Table names are mapped to model types via `registry::get_model_type_by_table()`
//! - FK lookups use the registered `Syncable` trait implementations
//! - New models are automatically supported when registered via `register_syncable!` macros
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use anyhow::{anyhow, Result};
use sea_orm::DatabaseConnection;
use serde_json::{json, Value};
use uuid::Uuid;
/// Foreign key mapping declaration
#[derive(Debug, Clone)]
pub struct FKMapping {
/// Field name in the model (e.g., "device_id")
pub local_field: &'static str,
/// Target table name (e.g., "devices")
pub target_table: &'static str,
}
impl FKMapping {
pub fn new(local_field: &'static str, target_table: &'static str) -> Self {
Self {
local_field,
target_table,
}
}
/// Get the UUID field name for sync JSON
/// Example: "device_id" → "device_uuid"
pub fn uuid_field_name(&self) -> String {
format!("{}_uuid", self.local_field.trim_end_matches("_id"))
}
}
/// Convert local integer FK to UUID for sync
///
/// Modifies JSON in place:
/// - Looks up the UUID for the integer FK
/// - Adds a new field with UUID (e.g., "device_uuid")
/// - Removes the original integer field (e.g., "device_id")
pub async fn convert_fk_to_uuid(
json: &mut Value,
fk: &FKMapping,
db: &DatabaseConnection,
) -> Result<()> {
// Extract the local integer ID
let local_id = match json.get(fk.local_field).and_then(|v| v.as_i64()) {
Some(id) => id as i32,
None => {
// Field might be null (e.g., parent_id for root entries)
if json
.get(fk.local_field)
.map(|v| v.is_null())
.unwrap_or(false)
{
// Add null UUID field and return
json[fk.uuid_field_name()] = Value::Null;
return Ok(());
}
return Err(anyhow!(
"Missing or invalid FK field '{}' in sync data",
fk.local_field
));
}
};
// Look up UUID from target table
let uuid = lookup_uuid_for_local_id(fk.target_table, local_id, db).await?;
// Add UUID field to JSON
json[fk.uuid_field_name()] = json!(uuid.to_string());
// Remove integer ID field (we only sync UUIDs)
if let Some(obj) = json.as_object_mut() {
obj.remove(fk.local_field);
}
Ok(())
}
/// Look up UUID for a local integer ID via the registry
async fn lookup_uuid_for_local_id(
table: &str,
local_id: i32,
db: &DatabaseConnection,
) -> Result<Uuid> {
// Map table name to model type via registry (fully polymorphic)
let model_type = super::registry::get_model_type_by_table(table).ok_or_else(|| {
anyhow!(
"No model registered for table '{}' - check sync registration",
table
)
})?;
super::registry::lookup_uuid_by_id(model_type, local_id, Arc::new(db.clone()))
.await
.map_err(|e| anyhow!("FK lookup failed for {}: {}", table, e))?
.ok_or_else(|| anyhow!("{} with id={} not found", table, local_id))
}
/// Batch look up UUIDs for multiple local integer IDs via the registry
///
/// Returns a HashMap mapping local_id -> UUID for all found records.
/// Records not found are omitted from the result map (caller must handle missing entries).
pub async fn batch_lookup_uuids_for_local_ids(
table: &str,
local_ids: HashSet<i32>,
db: &DatabaseConnection,
) -> Result<HashMap<i32, Uuid>> {
if local_ids.is_empty() {
return Ok(HashMap::new());
}
let model_type = super::registry::get_model_type_by_table(table).ok_or_else(|| {
anyhow!(
"No model registered for table '{}' - check sync registration",
table
)
})?;
super::registry::batch_lookup_uuids_by_ids(model_type, local_ids, Arc::new(db.clone()))
.await
.map_err(|e| anyhow!("Batch FK lookup failed for {}: {}", table, e))
}
/// Convert UUIDs back to local integer IDs for database insertion
///
/// Modifies JSON in place:
/// - Looks up local ID for each UUID FK
/// - Replaces UUID field with integer ID field
/// - Removes UUID field
///
/// This function is idempotent - if FK is already resolved (local_field exists, uuid_field doesn't),
/// it will skip processing. This allows batch FK resolution followed by per-record application.
pub async fn map_sync_json_to_local(
mut data: Value,
mappings: Vec<FKMapping>,
db: &DatabaseConnection,
) -> Result<Value> {
for fk in mappings {
let uuid_field = fk.uuid_field_name();
// Check if FK is already resolved (idempotent behavior)
// If uuid_field doesn't exist and local_field exists, FK was already resolved
let uuid_value = data.get(&uuid_field);
if uuid_value.is_none() {
// UUID field not present - check if local_field already exists
if data.get(fk.local_field).is_some() {
// FK already resolved, skip
continue;
} else {
// Neither field present - set to NULL
data[fk.local_field] = Value::Null;
continue;
}
}
// UUID field exists - process it
if uuid_value.unwrap().is_null() {
// Null UUID means null FK (e.g., parent_id for root entries)
data[fk.local_field] = Value::Null;
// Remove UUID field
if let Some(obj) = data.as_object_mut() {
obj.remove(&uuid_field);
}
continue;
}
let uuid: Uuid = uuid_value
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("Missing UUID field '{}'", uuid_field))?
.parse()?;
// Map UUID to local ID
// If the referenced record doesn't exist yet (sync dependency), return error for retry
// NEVER set to NULL - that corrupts data. Caller must use dependency tracking.
let local_id = lookup_local_id_for_uuid(fk.target_table, uuid, db)
.await
.map_err(|e| {
anyhow::anyhow!(
"Sync dependency missing: {} -> {} (uuid={}): {}",
fk.local_field,
fk.target_table,
uuid,
e
)
})?;
// Replace UUID with local ID
data[fk.local_field] = json!(local_id);
// Remove UUID field
if let Some(obj) = data.as_object_mut() {
obj.remove(&uuid_field);
}
}
Ok(data)
}
/// Result of batch FK mapping operation
#[derive(Debug)]
pub struct BatchFkMapResult {
/// Records that were successfully mapped (all FKs resolved)
pub succeeded: Vec<Value>,
/// Records that failed due to missing FK references
/// Contains (record, missing_fk_field, missing_uuid) for retry/dependency tracking
pub failed: Vec<(Value, String, Uuid)>,
}
/// Batch convert UUIDs to local IDs for multiple records
///
/// This function processes multiple records at once, using batch FK lookups
/// to reduce database queries from N*M (N records × M FKs) to M (one per FK type).
///
/// Records with missing FK references are returned in `failed` for retry via dependency tracking.
/// NEVER sets FK to NULL on missing reference - that would corrupt data.
pub async fn batch_map_sync_json_to_local(
records: Vec<Value>,
mappings: Vec<FKMapping>,
db: &DatabaseConnection,
) -> Result<BatchFkMapResult> {
if records.is_empty() {
return Ok(BatchFkMapResult {
succeeded: records,
failed: vec![],
});
}
// Track which records have failed (by index) and why
let mut failed_records: HashMap<usize, (String, Uuid)> = HashMap::new();
let mut records = records;
// For each FK mapping, collect all UUIDs from all records, batch lookup, then apply
for fk in &mappings {
let uuid_field = fk.uuid_field_name();
// Collect all UUIDs for this FK type from all records
let mut uuids_to_lookup: HashSet<Uuid> = HashSet::new();
for (idx, data) in records.iter().enumerate() {
// Skip already-failed records
if failed_records.contains_key(&idx) {
continue;
}
if let Some(uuid_value) = data.get(&uuid_field) {
if !uuid_value.is_null() {
if let Some(uuid_str) = uuid_value.as_str() {
if let Ok(uuid) = Uuid::parse_str(uuid_str) {
uuids_to_lookup.insert(uuid);
}
}
}
}
}
// Batch lookup all UUIDs for this FK type (single query)
let uuid_to_id_map = if !uuids_to_lookup.is_empty() {
batch_lookup_local_ids_for_uuids(fk.target_table, uuids_to_lookup, db).await?
} else {
HashMap::new()
};
// Apply mappings to all records using the batch lookup results
for (idx, data) in records.iter_mut().enumerate() {
// Skip already-failed records
if failed_records.contains_key(&idx) {
continue;
}
let uuid_value = data.get(&uuid_field);
if uuid_value.is_none() || uuid_value.unwrap().is_null() {
// Null UUID means null FK (e.g., parent_id for root entries)
data[fk.local_field] = Value::Null;
continue;
}
let uuid: Uuid = match uuid_value
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
{
Some(uuid) => uuid,
None => {
// Invalid UUID format - this is a data error, mark as failed
tracing::warn!(
fk_field = %fk.local_field,
"Invalid UUID format in FK field"
);
// Use a nil UUID to indicate parse failure
failed_records.insert(idx, (fk.local_field.to_string(), Uuid::nil()));
continue;
}
};
// Look up local ID from batch results
// NEVER set to NULL on missing - that corrupts data. Always fail for retry.
let local_id = match uuid_to_id_map.get(&uuid) {
Some(&id) => id,
None => {
// Referenced record not found - mark for retry via dependency tracking
tracing::debug!(
fk_field = %fk.local_field,
target_table = %fk.target_table,
uuid = %uuid,
"FK reference not found, marking for retry"
);
failed_records.insert(idx, (fk.local_field.to_string(), uuid));
continue;
}
};
// Replace UUID with local ID
data[fk.local_field] = json!(local_id);
// Remove UUID field
if let Some(obj) = data.as_object_mut() {
obj.remove(&uuid_field);
}
}
}
// Separate succeeded and failed records
let mut succeeded = Vec::with_capacity(records.len() - failed_records.len());
let mut failed = Vec::with_capacity(failed_records.len());
for (idx, record) in records.into_iter().enumerate() {
if let Some((fk_field, missing_uuid)) = failed_records.remove(&idx) {
failed.push((record, fk_field, missing_uuid));
} else {
succeeded.push(record);
}
}
if !failed.is_empty() {
tracing::info!(
succeeded = succeeded.len(),
failed = failed.len(),
"Batch FK mapping completed with some records pending dependencies"
);
}
Ok(BatchFkMapResult { succeeded, failed })
}
/// Look up local integer ID for a UUID via the registry
async fn lookup_local_id_for_uuid(table: &str, uuid: Uuid, db: &DatabaseConnection) -> Result<i32> {
let model_type = super::registry::get_model_type_by_table(table).ok_or_else(|| {
anyhow!(
"No model registered for table '{}' - check sync registration",
table
)
})?;
super::registry::lookup_id_by_uuid(model_type, uuid, Arc::new(db.clone()))
.await
.map_err(|e| anyhow!("FK lookup failed for {}: {}", table, e))?
.ok_or_else(|| {
anyhow!(
"{} with uuid={} not found (sync dependency missing)",
table,
uuid
)
})
}
/// Batch look up local integer IDs for multiple UUIDs via the registry
///
/// Returns a HashMap mapping UUID -> local_id for all found records.
/// Records not found are omitted from the result map (caller must handle missing entries).
pub async fn batch_lookup_local_ids_for_uuids(
table: &str,
uuids: HashSet<Uuid>,
db: &DatabaseConnection,
) -> Result<HashMap<Uuid, i32>> {
if uuids.is_empty() {
return Ok(HashMap::new());
}
let model_type = super::registry::get_model_type_by_table(table).ok_or_else(|| {
anyhow!(
"No model registered for table '{}' - check sync registration",
table
)
})?;
super::registry::batch_lookup_ids_by_uuids(model_type, uuids, Arc::new(db.clone()))
.await
.map_err(|e| anyhow!("Batch FK lookup failed for {}: {}", table, e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uuid_field_name() {
let fk = FKMapping::new("device_id", "devices");
assert_eq!(fk.uuid_field_name(), "device_uuid");
let fk = FKMapping::new("parent_id", "entries");
assert_eq!(fk.uuid_field_name(), "parent_uuid");
let fk = FKMapping::new("entry_id", "entries");
assert_eq!(fk.uuid_field_name(), "entry_uuid");
}
}