mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
433 lines
12 KiB
Rust
433 lines
12 KiB
Rust
//! 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");
|
||
}
|
||
}
|