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::{BatchWriteSummary, DeleteMode, Page, PageRequest};
11use khive_storage::EntityStore;
12use khive_storage::StorageCapability;
13
14use crate::error::SqliteError;
15use crate::pool::ConnectionPool;
16
17fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
18 StorageError::driver(StorageCapability::Entities, op, e)
19}
20
21fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
22 StorageError::driver(StorageCapability::Entities, op, e)
23}
24
25pub struct SqlEntityStore {
30 pool: Arc<ConnectionPool>,
31 is_file_backed: bool,
32}
33
34impl SqlEntityStore {
35 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
37 Self {
38 pool,
39 is_file_backed,
40 }
41 }
42
43 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
44 let config = self.pool.config();
45 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
46 operation: "entity_reader".into(),
47 message: "in-memory databases do not support standalone connections".into(),
48 })?;
49
50 let conn = rusqlite::Connection::open_with_flags(
51 path,
52 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
53 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
54 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
55 )
56 .map_err(|e| map_err(e, "open_entity_reader"))?;
57
58 conn.busy_timeout(config.busy_timeout)
59 .map_err(|e| map_err(e, "open_entity_reader"))?;
60 conn.pragma_update(None, "foreign_keys", "ON")
61 .map_err(|e| map_err(e, "open_entity_reader"))?;
62 conn.pragma_update(None, "synchronous", "NORMAL")
63 .map_err(|e| map_err(e, "open_entity_reader"))?;
64
65 Ok(conn)
66 }
67
68 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
69 where
70 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
71 R: Send + 'static,
72 {
73 let pool = Arc::clone(&self.pool);
74 tokio::task::spawn_blocking(move || {
75 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
76 f(guard.conn()).map_err(|e| map_err(e, op))
77 })
78 .await
79 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
80 }
81
82 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
83 where
84 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
85 R: Send + 'static,
86 {
87 if self.is_file_backed {
88 let conn = self.open_standalone_reader()?;
89 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
90 .await
91 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
92 } else {
93 let pool = Arc::clone(&self.pool);
94 tokio::task::spawn_blocking(move || {
95 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
96 f(guard.conn()).map_err(|e| map_err(e, op))
97 })
98 .await
99 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
100 }
101 }
102}
103
104fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
109 let id_str: String = row.get(0)?;
110 let namespace: String = row.get(1)?;
111 let kind: String = row.get(2)?;
112 let entity_type: Option<String> = row.get(3)?;
113 let name: String = row.get(4)?;
114 let description: Option<String> = row.get(5)?;
115 let properties_str: Option<String> = row.get(6)?;
116 let tags_str: String = row.get(7)?;
117 let created_at: i64 = row.get(8)?;
118 let updated_at: i64 = row.get(9)?;
119 let deleted_at: Option<i64> = row.get(10)?;
120 let merged_into_str: Option<String> = row.get(11)?;
121 let merge_event_id_str: Option<String> = row.get(12)?;
122
123 let id = parse_uuid(&id_str)?;
124
125 let properties = properties_str
126 .map(|s| {
127 serde_json::from_str(&s).map_err(|e| {
128 rusqlite::Error::FromSqlConversionFailure(
129 6,
130 rusqlite::types::Type::Text,
131 Box::new(e),
132 )
133 })
134 })
135 .transpose()?;
136
137 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
138 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
139 })?;
140
141 let merged_into = merged_into_str
142 .as_deref()
143 .map(Uuid::parse_str)
144 .transpose()
145 .map_err(|e| {
146 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
147 })?;
148
149 let merge_event_id = merge_event_id_str
150 .as_deref()
151 .map(Uuid::parse_str)
152 .transpose()
153 .map_err(|e| {
154 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
155 })?;
156
157 Ok(Entity {
158 id,
159 namespace,
160 kind,
161 entity_type,
162 name,
163 description,
164 properties,
165 tags,
166 created_at,
167 updated_at,
168 deleted_at,
169 merged_into,
170 merge_event_id,
171 })
172}
173
174fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
175 Uuid::parse_str(s).map_err(|e| {
176 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
177 })
178}
179
180fn build_entity_where(
181 namespace: &str,
182 filter: &EntityFilter,
183) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
184 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
188 if !filter.namespaces.is_empty() {
189 let placeholders: Vec<String> = (1..=filter.namespaces.len())
190 .map(|i| format!("?{i}"))
191 .collect();
192 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
193 .namespaces
194 .iter()
195 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
196 .collect();
197 (
198 format!("namespace IN ({})", placeholders.join(", ")),
199 params,
200 )
201 } else {
202 (
203 "namespace = ?1".to_string(),
204 vec![Box::new(namespace.to_string())],
205 )
206 };
207
208 let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
209 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
210
211 if !filter.ids.is_empty() {
212 let placeholders: Vec<String> = filter
213 .ids
214 .iter()
215 .map(|id| {
216 params.push(Box::new(id.to_string()));
217 format!("?{}", params.len())
218 })
219 .collect();
220 conditions.push(format!("id IN ({})", placeholders.join(", ")));
221 }
222
223 if !filter.kinds.is_empty() {
224 let placeholders: Vec<String> = filter
225 .kinds
226 .iter()
227 .map(|k| {
228 params.push(Box::new(k.clone()));
229 format!("?{}", params.len())
230 })
231 .collect();
232 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
233 }
234
235 if !filter.entity_types.is_empty() {
236 let placeholders: Vec<String> = filter
237 .entity_types
238 .iter()
239 .map(|t| {
240 params.push(Box::new(t.clone()));
241 format!("?{}", params.len())
242 })
243 .collect();
244 conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
245 }
246
247 if let Some(ref prefix) = filter.name_prefix {
248 params.push(Box::new(format!("{}%", prefix)));
249 conditions.push(format!("name LIKE ?{}", params.len()));
250 }
251
252 if !filter.tags_any.is_empty() {
253 let placeholders: Vec<String> = filter
254 .tags_any
255 .iter()
256 .map(|t| {
257 params.push(Box::new(t.to_lowercase()));
260 format!("?{}", params.len())
261 })
262 .collect();
263 conditions.push(format!(
264 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
265 placeholders.join(", ")
266 ));
267 }
268
269 let clause = format!(" WHERE {}", conditions.join(" AND "));
270 (clause, params)
271}
272
273#[async_trait]
278impl EntityStore for SqlEntityStore {
279 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
280 let namespace = entity.namespace.clone();
281 let id_str = entity.id.to_string();
282 let properties_str = entity
283 .properties
284 .as_ref()
285 .map(|v| serde_json::to_string(v).unwrap_or_default());
286 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
287
288 let merged_into_str = entity.merged_into.map(|u| u.to_string());
289 let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
290
291 self.with_writer("upsert_entity", move |conn| {
292 conn.execute(
293 "INSERT OR REPLACE INTO entities \
294 (id, namespace, kind, entity_type, name, description, properties, tags, \
295 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
296 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
297 rusqlite::params![
298 id_str,
299 namespace,
300 entity.kind,
301 entity.entity_type,
302 entity.name,
303 entity.description,
304 properties_str,
305 tags_str,
306 entity.created_at,
307 entity.updated_at,
308 entity.deleted_at,
309 merged_into_str,
310 merge_event_id_str,
311 ],
312 )?;
313 Ok(())
314 })
315 .await
316 }
317
318 async fn upsert_entities(
319 &self,
320 entities: Vec<Entity>,
321 ) -> Result<BatchWriteSummary, StorageError> {
322 let attempted = entities.len() as u64;
323
324 self.with_writer("upsert_entities", move |conn| {
325 conn.execute_batch("BEGIN IMMEDIATE")?;
326 let mut affected = 0u64;
327 let mut failed = 0u64;
328 let mut first_error = String::new();
329
330 for entity in &entities {
331 let id_str = entity.id.to_string();
332 let properties_str = entity
333 .properties
334 .as_ref()
335 .map(|v| serde_json::to_string(v).unwrap_or_default());
336 let tags_str =
337 serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
338
339 let merged_into_str = entity.merged_into.map(|u| u.to_string());
340 let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
341 match conn.execute(
342 "INSERT OR REPLACE INTO entities \
343 (id, namespace, kind, entity_type, name, description, properties, tags, \
344 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
345 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
346 rusqlite::params![
347 id_str,
348 &entity.namespace,
349 entity.kind,
350 entity.entity_type,
351 entity.name,
352 entity.description,
353 properties_str,
354 tags_str,
355 entity.created_at,
356 entity.updated_at,
357 entity.deleted_at,
358 merged_into_str,
359 merge_event_id_str,
360 ],
361 ) {
362 Ok(_) => affected += 1,
363 Err(e) => {
364 if first_error.is_empty() {
365 first_error = e.to_string();
366 }
367 failed += 1;
368 }
369 }
370 }
371
372 if let Err(e) = conn.execute_batch("COMMIT") {
373 let _ = conn.execute_batch("ROLLBACK");
374 return Err(e);
375 }
376 Ok(BatchWriteSummary {
377 attempted,
378 affected,
379 failed,
380 first_error,
381 })
382 })
383 .await
384 }
385
386 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
387 let id_str = id.to_string();
388
389 self.with_reader("get_entity", move |conn| {
390 let mut stmt = conn.prepare(
391 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
392 created_at, updated_at, deleted_at, merged_into, merge_event_id \
393 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
394 )?;
395 let mut rows = stmt.query(rusqlite::params![id_str])?;
396 match rows.next()? {
397 Some(row) => Ok(Some(read_entity(row)?)),
398 None => Ok(None),
399 }
400 })
401 .await
402 }
403
404 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
405 let id_str = id.to_string();
406
407 match mode {
408 DeleteMode::Soft => {
409 self.with_writer("delete_entity_soft", move |conn| {
410 let now = chrono::Utc::now().timestamp_micros();
411 let deleted = conn.execute(
412 "UPDATE entities SET deleted_at = ?1 \
413 WHERE id = ?2 AND deleted_at IS NULL",
414 rusqlite::params![now, id_str],
415 )?;
416 Ok(deleted > 0)
417 })
418 .await
419 }
420 DeleteMode::Hard => {
421 self.with_writer("delete_entity_hard", move |conn| {
422 let deleted = conn.execute(
423 "DELETE FROM entities WHERE id = ?1",
424 rusqlite::params![id_str],
425 )?;
426 Ok(deleted > 0)
427 })
428 .await
429 }
430 }
431 }
432
433 async fn query_entities(
434 &self,
435 namespace: &str,
436 filter: EntityFilter,
437 page: PageRequest,
438 ) -> Result<Page<Entity>, StorageError> {
439 let namespace = namespace.to_string();
440
441 self.with_reader("query_entities", move |conn| {
442 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
443 let total: i64 = {
444 let sql = format!("SELECT COUNT(*) FROM entities{}", count_sql);
445 let mut stmt = conn.prepare(&sql)?;
446 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
447 count_params.iter().map(|p| p.as_ref()).collect();
448 stmt.query_row(param_refs.as_slice(), |row| row.get(0))?
449 };
450
451 let (where_sql, mut data_params) = build_entity_where(&namespace, &filter);
452 data_params.push(Box::new(page.limit as i64));
453 data_params.push(Box::new(page.offset as i64));
454
455 let limit_idx = data_params.len() - 1;
456 let offset_idx = data_params.len();
457
458 let data_sql = format!(
459 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
460 created_at, updated_at, deleted_at, merged_into, merge_event_id \
461 FROM entities{} ORDER BY created_at DESC LIMIT ?{} OFFSET ?{}",
462 where_sql, limit_idx, offset_idx,
463 );
464
465 let mut stmt = conn.prepare(&data_sql)?;
466 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
467 data_params.iter().map(|p| p.as_ref()).collect();
468 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
469
470 let mut items = Vec::new();
471 for row in rows {
472 items.push(row?);
473 }
474
475 Ok(Page {
476 items,
477 total: Some(total as u64),
478 })
479 })
480 .await
481 }
482
483 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
484 let id_str = id.to_string();
485
486 self.with_reader("get_entity_including_deleted", move |conn| {
487 let mut stmt = conn.prepare(
488 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
489 created_at, updated_at, deleted_at, merged_into, merge_event_id \
490 FROM entities WHERE id = ?1",
491 )?;
492 let mut rows = stmt.query(rusqlite::params![id_str])?;
493 match rows.next()? {
494 Some(row) => Ok(Some(read_entity(row)?)),
495 None => Ok(None),
496 }
497 })
498 .await
499 }
500
501 async fn count_entities(
502 &self,
503 namespace: &str,
504 filter: EntityFilter,
505 ) -> Result<u64, StorageError> {
506 let namespace = namespace.to_string();
507
508 self.with_reader("count_entities", move |conn| {
509 let (where_sql, params) = build_entity_where(&namespace, &filter);
510 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
511 let mut stmt = conn.prepare(&sql)?;
512 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
513 params.iter().map(|p| p.as_ref()).collect();
514 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
515 Ok(count as u64)
516 })
517 .await
518 }
519}
520
521const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
526
527pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
528 conn.execute_batch(ENTITIES_DDL)
529}
530
531#[cfg(test)]
532#[path = "entity_tests.rs"]
533mod tests;