1use std::sync::Arc;
4
5use async_trait::async_trait;
6use uuid::Uuid;
7
8use khive_storage::entity::{Entity, EntityFilter};
9use khive_storage::error::StorageError;
10use khive_storage::types::{
11 BatchWriteSummary, DeleteMode, Page, PageRequest, SqlStatement, SqlValue,
12};
13use khive_storage::EntityStore;
14use khive_storage::StorageCapability;
15
16use crate::error::SqliteError;
17use crate::pool::ConnectionPool;
18use crate::sql_bridge::bind_params;
19use crate::writer_task::WriterTaskHandle;
20
21fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
22 StorageError::driver(StorageCapability::Entities, op, e)
23}
24
25fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
26 StorageError::driver(StorageCapability::Entities, op, e)
27}
28
29pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
45 let properties_str = entity
46 .properties
47 .as_ref()
48 .map(|v| serde_json::to_string(v).unwrap_or_default());
49 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
50 SqlStatement {
51 sql: "INSERT OR REPLACE INTO entities \
52 (id, namespace, kind, entity_type, name, description, properties, tags, \
53 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
54 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
55 .to_string(),
56 params: vec![
57 SqlValue::Text(entity.id.to_string()),
58 SqlValue::Text(entity.namespace.clone()),
59 SqlValue::Text(entity.kind.clone()),
60 match &entity.entity_type {
61 Some(t) => SqlValue::Text(t.clone()),
62 None => SqlValue::Null,
63 },
64 SqlValue::Text(entity.name.clone()),
65 match &entity.description {
66 Some(d) => SqlValue::Text(d.clone()),
67 None => SqlValue::Null,
68 },
69 match properties_str {
70 Some(p) => SqlValue::Text(p),
71 None => SqlValue::Null,
72 },
73 SqlValue::Text(tags_str),
74 SqlValue::Integer(entity.created_at),
75 SqlValue::Integer(entity.updated_at),
76 match entity.deleted_at {
77 Some(d) => SqlValue::Integer(d),
78 None => SqlValue::Null,
79 },
80 match entity.merged_into {
81 Some(u) => SqlValue::Text(u.to_string()),
82 None => SqlValue::Null,
83 },
84 match entity.merge_event_id {
85 Some(u) => SqlValue::Text(u.to_string()),
86 None => SqlValue::Null,
87 },
88 ],
89 label: Some("entity-upsert".to_string()),
90 }
91}
92
93pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
95 SqlStatement {
96 sql: "UPDATE entities SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
97 params: vec![
98 SqlValue::Integer(deleted_at),
99 SqlValue::Text(id.to_string()),
100 ],
101 label: Some("entity-delete-soft".to_string()),
102 }
103}
104
105pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
108 SqlStatement {
109 sql: "DELETE FROM entities WHERE id = ?1".to_string(),
110 params: vec![SqlValue::Text(id.to_string())],
111 label: Some("entity-delete-hard".to_string()),
112 }
113}
114
115pub struct SqlEntityStore {
120 pool: Arc<ConnectionPool>,
121 is_file_backed: bool,
122 writer_task: Option<WriterTaskHandle>,
123}
124
125impl SqlEntityStore {
126 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
143 let writer_task = pool.writer_task_handle().ok().flatten();
149
150 Self {
151 pool,
152 is_file_backed,
153 writer_task,
154 }
155 }
156
157 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
158 let config = self.pool.config();
159 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
160 operation: "entity_reader".into(),
161 message: "in-memory databases do not support standalone connections".into(),
162 })?;
163
164 let conn = rusqlite::Connection::open_with_flags(
165 path,
166 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
167 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
168 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
169 )
170 .map_err(|e| map_err(e, "open_entity_reader"))?;
171
172 conn.busy_timeout(config.busy_timeout)
173 .map_err(|e| map_err(e, "open_entity_reader"))?;
174 conn.pragma_update(None, "foreign_keys", "ON")
175 .map_err(|e| map_err(e, "open_entity_reader"))?;
176 conn.pragma_update(None, "synchronous", "NORMAL")
177 .map_err(|e| map_err(e, "open_entity_reader"))?;
178
179 Ok(conn)
180 }
181
182 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
199 where
200 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
201 R: Send + 'static,
202 {
203 if let Some(writer_task) = &self.writer_task {
204 return writer_task
205 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
206 .await;
207 }
208
209 let pool = Arc::clone(&self.pool);
210 tokio::task::spawn_blocking(move || {
211 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
212 f(guard.conn()).map_err(|e| map_err(e, op))
213 })
214 .await
215 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
216 }
217
218 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
219 where
220 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
221 R: Send + 'static,
222 {
223 if self.is_file_backed {
224 let conn = self.open_standalone_reader()?;
225 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
226 .await
227 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
228 } else {
229 let pool = Arc::clone(&self.pool);
230 tokio::task::spawn_blocking(move || {
231 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
232 f(guard.conn()).map_err(|e| map_err(e, op))
233 })
234 .await
235 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
236 }
237 }
238}
239
240fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
245 let id_str: String = row.get(0)?;
246 let namespace: String = row.get(1)?;
247 let kind: String = row.get(2)?;
248 let entity_type: Option<String> = row.get(3)?;
249 let name: String = row.get(4)?;
250 let description: Option<String> = row.get(5)?;
251 let properties_str: Option<String> = row.get(6)?;
252 let tags_str: String = row.get(7)?;
253 let created_at: i64 = row.get(8)?;
254 let updated_at: i64 = row.get(9)?;
255 let deleted_at: Option<i64> = row.get(10)?;
256 let merged_into_str: Option<String> = row.get(11)?;
257 let merge_event_id_str: Option<String> = row.get(12)?;
258
259 let id = parse_uuid(&id_str)?;
260
261 let properties = properties_str
262 .map(|s| {
263 serde_json::from_str(&s).map_err(|e| {
264 rusqlite::Error::FromSqlConversionFailure(
265 6,
266 rusqlite::types::Type::Text,
267 Box::new(e),
268 )
269 })
270 })
271 .transpose()?;
272
273 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
274 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
275 })?;
276
277 let merged_into = merged_into_str
278 .as_deref()
279 .map(Uuid::parse_str)
280 .transpose()
281 .map_err(|e| {
282 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
283 })?;
284
285 let merge_event_id = merge_event_id_str
286 .as_deref()
287 .map(Uuid::parse_str)
288 .transpose()
289 .map_err(|e| {
290 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
291 })?;
292
293 Ok(Entity {
294 id,
295 namespace,
296 kind,
297 entity_type,
298 name,
299 description,
300 properties,
301 tags,
302 created_at,
303 updated_at,
304 deleted_at,
305 merged_into,
306 merge_event_id,
307 })
308}
309
310fn batch_upsert_entities(
320 conn: &rusqlite::Connection,
321 entities: &[Entity],
322 attempted: u64,
323) -> Result<BatchWriteSummary, rusqlite::Error> {
324 let mut affected = 0u64;
325 let mut failed = 0u64;
326 let mut first_error = String::new();
327
328 for entity in entities {
329 let id_str = entity.id.to_string();
330 let properties_str = entity
331 .properties
332 .as_ref()
333 .map(|v| serde_json::to_string(v).unwrap_or_default());
334 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
335
336 let merged_into_str = entity.merged_into.map(|u| u.to_string());
337 let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
338 match conn.execute(
339 "INSERT OR REPLACE INTO entities \
340 (id, namespace, kind, entity_type, name, description, properties, tags, \
341 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
342 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
343 rusqlite::params![
344 id_str,
345 &entity.namespace,
346 entity.kind,
347 entity.entity_type,
348 entity.name,
349 entity.description,
350 properties_str,
351 tags_str,
352 entity.created_at,
353 entity.updated_at,
354 entity.deleted_at,
355 merged_into_str,
356 merge_event_id_str,
357 ],
358 ) {
359 Ok(_) => affected += 1,
360 Err(e) => {
361 if first_error.is_empty() {
362 first_error = e.to_string();
363 }
364 failed += 1;
365 }
366 }
367 }
368
369 Ok(BatchWriteSummary {
370 attempted,
371 affected,
372 failed,
373 first_error,
374 })
375}
376
377fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
378 Uuid::parse_str(s).map_err(|e| {
379 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
380 })
381}
382
383fn escape_like(input: &str) -> String {
389 let mut out = String::with_capacity(input.len());
390 for c in input.chars() {
391 if matches!(c, '\\' | '%' | '_') {
392 out.push('\\');
393 }
394 out.push(c);
395 }
396 out
397}
398
399fn build_entity_where(
400 namespace: &str,
401 filter: &EntityFilter,
402) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
403 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
407 if !filter.namespaces.is_empty() {
408 let placeholders: Vec<String> = (1..=filter.namespaces.len())
409 .map(|i| format!("?{i}"))
410 .collect();
411 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
412 .namespaces
413 .iter()
414 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
415 .collect();
416 (
417 format!("namespace IN ({})", placeholders.join(", ")),
418 params,
419 )
420 } else {
421 (
422 "namespace = ?1".to_string(),
423 vec![Box::new(namespace.to_string())],
424 )
425 };
426
427 let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
428 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
429
430 if !filter.ids.is_empty() {
431 let placeholders: Vec<String> = filter
432 .ids
433 .iter()
434 .map(|id| {
435 params.push(Box::new(id.to_string()));
436 format!("?{}", params.len())
437 })
438 .collect();
439 conditions.push(format!("id IN ({})", placeholders.join(", ")));
440 }
441
442 if !filter.kinds.is_empty() {
443 let placeholders: Vec<String> = filter
444 .kinds
445 .iter()
446 .map(|k| {
447 params.push(Box::new(k.clone()));
448 format!("?{}", params.len())
449 })
450 .collect();
451 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
452 }
453
454 if !filter.entity_types.is_empty() {
455 let placeholders: Vec<String> = filter
456 .entity_types
457 .iter()
458 .map(|t| {
459 params.push(Box::new(t.clone()));
460 format!("?{}", params.len())
461 })
462 .collect();
463 conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
464 }
465
466 if let Some(ref prefix) = filter.name_prefix {
467 params.push(Box::new(format!("{}%", escape_like(prefix))));
468 conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
469 }
470
471 if let Some(ref exact) = filter.name_exact {
472 params.push(Box::new(exact.clone()));
473 conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
478 }
479
480 if !filter.tags_any.is_empty() {
481 let placeholders: Vec<String> = filter
482 .tags_any
483 .iter()
484 .map(|t| {
485 params.push(Box::new(t.to_lowercase()));
488 format!("?{}", params.len())
489 })
490 .collect();
491 conditions.push(format!(
492 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
493 placeholders.join(", ")
494 ));
495 }
496
497 let clause = format!(" WHERE {}", conditions.join(" AND "));
498 (clause, params)
499}
500
501#[async_trait]
506impl EntityStore for SqlEntityStore {
507 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
508 let statement = entity_upsert_statement(&entity);
509 self.with_writer("upsert_entity", move |conn| {
510 let mut stmt = conn.prepare(&statement.sql)?;
511 bind_params(&mut stmt, &statement.params)?;
512 stmt.raw_execute()?;
513 Ok(())
514 })
515 .await
516 }
517
518 async fn upsert_entities(
519 &self,
520 entities: Vec<Entity>,
521 ) -> Result<BatchWriteSummary, StorageError> {
522 let attempted = entities.len() as u64;
523
524 if let Some(writer_task) = &self.writer_task {
531 return writer_task
532 .send(move |conn| {
533 batch_upsert_entities(conn, &entities, attempted)
534 .map_err(|e| map_err(e, "upsert_entities"))
535 })
536 .await;
537 }
538
539 self.with_writer("upsert_entities", move |conn| {
543 conn.execute_batch("BEGIN IMMEDIATE")?;
544 let _tx_handle =
545 khive_storage::tx_registry::register(Some("entity_upsert_batch".to_string()));
546
547 let summary = batch_upsert_entities(conn, &entities, attempted)?;
548
549 if let Err(e) = conn.execute_batch("COMMIT") {
550 let _ = conn.execute_batch("ROLLBACK");
551 return Err(e);
552 }
553 Ok(summary)
554 })
555 .await
556 }
557
558 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
559 let id_str = id.to_string();
560
561 self.with_reader("get_entity", move |conn| {
562 let mut stmt = conn.prepare(
563 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
564 created_at, updated_at, deleted_at, merged_into, merge_event_id \
565 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
566 )?;
567 let mut rows = stmt.query(rusqlite::params![id_str])?;
568 match rows.next()? {
569 Some(row) => Ok(Some(read_entity(row)?)),
570 None => Ok(None),
571 }
572 })
573 .await
574 }
575
576 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
577 match mode {
578 DeleteMode::Soft => {
579 let now = chrono::Utc::now().timestamp_micros();
580 let statement = entity_soft_delete_statement(id, now);
581 self.with_writer("delete_entity_soft", move |conn| {
582 let mut stmt = conn.prepare(&statement.sql)?;
583 bind_params(&mut stmt, &statement.params)?;
584 Ok(stmt.raw_execute()? > 0)
585 })
586 .await
587 }
588 DeleteMode::Hard => {
589 let statement = entity_hard_delete_statement(id);
590 self.with_writer("delete_entity_hard", move |conn| {
591 let mut stmt = conn.prepare(&statement.sql)?;
592 bind_params(&mut stmt, &statement.params)?;
593 Ok(stmt.raw_execute()? > 0)
594 })
595 .await
596 }
597 }
598 }
599
600 async fn query_entities(
601 &self,
602 namespace: &str,
603 filter: EntityFilter,
604 page: PageRequest,
605 ) -> Result<Page<Entity>, StorageError> {
606 let namespace = namespace.to_string();
607 let limit_i64 = i64::from(page.limit);
608 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
609 capability: StorageCapability::Entities,
610 operation: "query_entities".into(),
611 message: format!(
612 "PageRequest: offset must be <= i64::MAX, got {}",
613 page.offset
614 ),
615 })?;
616
617 self.with_reader("query_entities", move |conn| {
618 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
619 let total: i64 = {
620 let sql = format!("SELECT COUNT(*) FROM entities{}", count_sql);
621 let mut stmt = conn.prepare(&sql)?;
622 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
623 count_params.iter().map(|p| p.as_ref()).collect();
624 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
625 };
626
627 let (where_sql, mut data_params) = build_entity_where(&namespace, &filter);
628
629 let order_by = if let Some(ref prefix) = filter.name_prefix {
635 data_params.push(Box::new(prefix.to_ascii_lowercase()));
636 format!(
637 "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC",
638 data_params.len()
639 )
640 } else {
641 "created_at DESC".to_string()
642 };
643
644 data_params.push(Box::new(limit_i64));
645 data_params.push(Box::new(offset_i64));
646
647 let limit_idx = data_params.len() - 1;
648 let offset_idx = data_params.len();
649
650 let data_sql = format!(
651 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
652 created_at, updated_at, deleted_at, merged_into, merge_event_id \
653 FROM entities{} ORDER BY {} LIMIT ?{} OFFSET ?{}",
654 where_sql, order_by, limit_idx, offset_idx,
655 );
656
657 let mut stmt = conn.prepare(&data_sql)?;
658 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
659 data_params.iter().map(|p| p.as_ref()).collect();
660 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
661
662 let mut items = Vec::new();
663 for row in rows {
664 items.push(row?);
665 }
666
667 Ok(Page {
668 items,
669 total: Some(total as u64),
670 })
671 })
672 .await
673 }
674
675 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
676 let id_str = id.to_string();
677
678 self.with_reader("get_entity_including_deleted", move |conn| {
679 let mut stmt = conn.prepare(
680 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
681 created_at, updated_at, deleted_at, merged_into, merge_event_id \
682 FROM entities WHERE id = ?1",
683 )?;
684 let mut rows = stmt.query(rusqlite::params![id_str])?;
685 match rows.next()? {
686 Some(row) => Ok(Some(read_entity(row)?)),
687 None => Ok(None),
688 }
689 })
690 .await
691 }
692
693 async fn count_entities(
694 &self,
695 namespace: &str,
696 filter: EntityFilter,
697 ) -> Result<u64, StorageError> {
698 let namespace = namespace.to_string();
699
700 self.with_reader("count_entities", move |conn| {
701 let (where_sql, params) = build_entity_where(&namespace, &filter);
702 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
703 let mut stmt = conn.prepare(&sql)?;
704 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
705 params.iter().map(|p| p.as_ref()).collect();
706 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
707 Ok(count as u64)
708 })
709 .await
710 }
711}
712
713const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
718
719pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
720 conn.execute_batch(ENTITIES_DDL)
721}
722
723#[cfg(test)]
724#[path = "entity_tests.rs"]
725mod tests;