1use std::collections::HashSet;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use rusqlite::OptionalExtension;
8use uuid::Uuid;
9
10use khive_storage::attachment::{Attachment, AttachmentSubstrate};
11use khive_storage::entity::{Entity, EntityFilter, EntityTombstones, EntityTypeCounts};
12use khive_storage::error::StorageError;
13use khive_storage::types::{
14 BatchWriteSummary, DeleteMode, Page, PageRequest, SeekCursor, SeekPage, SqlStatement, SqlValue,
15};
16use khive_storage::EntityStore;
17use khive_storage::StorageCapability;
18
19use crate::error::SqliteError;
20use crate::pool::ConnectionPool;
21use crate::sql_bridge::bind_params;
22use crate::stores::attachment::{attachment_upsert_statement, delete_record_attachments_statement};
23use crate::writer_task::{execute_wrapped_transaction, WriterTaskHandle};
24
25fn map_err(e: rusqlite::Error, op: &'static str) -> StorageError {
26 StorageError::driver(StorageCapability::Entities, op, e)
27}
28
29fn map_sqlite_err(e: SqliteError, op: &'static str) -> StorageError {
30 e.into_storage_error(StorageCapability::Entities, op)
31}
32
33const NAMESPACE_COUNT_CHUNK_SIZE: usize = 500;
34
35const ENTITIES_COUNT_BY_TYPE_SQL: &str = include_str!("../../sql/entities-count-by-type.sql");
36
37const ENTITY_SELECT_COLUMNS: &str =
38 "entities.id, entities.namespace, entities.kind, entities.entity_type, entities.name, \
39 entities.description, entities.properties, entities.tags, entities.created_at, \
40 entities.updated_at, entities.deleted_at, entities.merged_into, entities.merge_event_id, \
41 (SELECT attachment.content_ref FROM attachments AS attachment \
42 WHERE attachment.record_uuid = entities.id \
43 AND attachment.substrate = 'entity' AND attachment.role = 'content') AS content_ref, entities.version";
44
45pub fn entity_upsert_statement(entity: &Entity) -> SqlStatement {
61 let mut statement = entity_write_statement(entity, "INSERT", "entity-upsert");
62 statement.sql.push_str(
63 " ON CONFLICT(id) DO UPDATE SET namespace=excluded.namespace, kind=excluded.kind, \
64 entity_type=excluded.entity_type, name=excluded.name, description=excluded.description, \
65 properties=excluded.properties, tags=excluded.tags, created_at=excluded.created_at, \
66 updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, \
67 merged_into=excluded.merged_into, merge_event_id=excluded.merge_event_id, \
68 version=entities.version+1",
69 );
70 statement
71}
72
73fn entity_write_statement(entity: &Entity, insert: &str, label: &str) -> SqlStatement {
74 let properties_str = entity
75 .properties
76 .as_ref()
77 .map(|v| serde_json::to_string(v).unwrap_or_default());
78 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
79 SqlStatement {
80 sql: format!(
81 "{insert} INTO entities \
82 (id, namespace, kind, entity_type, name, description, properties, tags, \
83 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
84 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)"
85 ),
86 params: vec![
87 SqlValue::Text(entity.id.to_string()),
88 SqlValue::Text(entity.namespace.clone()),
89 SqlValue::Text(entity.kind.clone()),
90 match &entity.entity_type {
91 Some(t) => SqlValue::Text(t.clone()),
92 None => SqlValue::Null,
93 },
94 SqlValue::Text(entity.name.clone()),
95 match &entity.description {
96 Some(d) => SqlValue::Text(d.clone()),
97 None => SqlValue::Null,
98 },
99 match properties_str {
100 Some(p) => SqlValue::Text(p),
101 None => SqlValue::Null,
102 },
103 SqlValue::Text(tags_str),
104 SqlValue::Integer(entity.created_at),
105 SqlValue::Integer(entity.updated_at),
106 match entity.deleted_at {
107 Some(d) => SqlValue::Integer(d),
108 None => SqlValue::Null,
109 },
110 match entity.merged_into {
111 Some(u) => SqlValue::Text(u.to_string()),
112 None => SqlValue::Null,
113 },
114 match entity.merge_event_id {
115 Some(u) => SqlValue::Text(u.to_string()),
116 None => SqlValue::Null,
117 },
118 ],
119 label: Some(label.to_string()),
120 }
121}
122
123pub fn entity_insert_if_absent_statement(entity: &Entity) -> SqlStatement {
127 let mut statement = entity_upsert_statement(entity);
128 statement.sql = "INSERT INTO entities \
129 (id, namespace, kind, entity_type, name, description, properties, tags, \
130 created_at, updated_at, deleted_at, merged_into, merge_event_id) \
131 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) \
132 ON CONFLICT DO NOTHING"
133 .to_string();
134 statement.label = Some("entity-insert-if-absent".to_string());
135 statement
136}
137
138pub fn entity_replace_if_unchanged_statement(
149 entity: &Entity,
150 expected_updated_at: i64,
151 expected_deleted_at: Option<i64>,
152) -> SqlStatement {
153 let properties_str = entity
154 .properties
155 .as_ref()
156 .map(|v| serde_json::to_string(v).unwrap_or_default());
157 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
158 SqlStatement {
159 sql: "UPDATE entities SET \
160 namespace = ?1, kind = ?2, entity_type = ?3, name = ?4, description = ?5, \
161 properties = ?6, tags = ?7, updated_at = ?8, deleted_at = ?9, \
162 merged_into = ?10, merge_event_id = ?11, version = version + 1 \
163 WHERE id = ?12 AND updated_at = ?13 AND deleted_at IS ?14 \
164 AND ?8 > updated_at AND version = ?15"
165 .to_string(),
166 params: vec![
167 SqlValue::Text(entity.namespace.clone()),
168 SqlValue::Text(entity.kind.clone()),
169 match &entity.entity_type {
170 Some(t) => SqlValue::Text(t.clone()),
171 None => SqlValue::Null,
172 },
173 SqlValue::Text(entity.name.clone()),
174 match &entity.description {
175 Some(d) => SqlValue::Text(d.clone()),
176 None => SqlValue::Null,
177 },
178 match properties_str {
179 Some(p) => SqlValue::Text(p),
180 None => SqlValue::Null,
181 },
182 SqlValue::Text(tags_str),
183 SqlValue::Integer(entity.updated_at),
184 match entity.deleted_at {
185 Some(d) => SqlValue::Integer(d),
186 None => SqlValue::Null,
187 },
188 match entity.merged_into {
189 Some(u) => SqlValue::Text(u.to_string()),
190 None => SqlValue::Null,
191 },
192 match entity.merge_event_id {
193 Some(u) => SqlValue::Text(u.to_string()),
194 None => SqlValue::Null,
195 },
196 SqlValue::Text(entity.id.to_string()),
197 SqlValue::Integer(expected_updated_at),
198 match expected_deleted_at {
199 Some(value) => SqlValue::Integer(value),
200 None => SqlValue::Null,
201 },
202 SqlValue::Integer(entity.version),
203 ],
204 label: Some("entity-replace-if-unchanged".to_string()),
205 }
206}
207
208pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
210 SqlStatement {
211 sql: "UPDATE entities SET deleted_at = ?1, version = version + 1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
212 params: vec![
213 SqlValue::Integer(deleted_at),
214 SqlValue::Text(id.to_string()),
215 ],
216 label: Some("entity-delete-soft".to_string()),
217 }
218}
219
220pub fn entity_merge_tombstone_statement(
222 namespace: &str,
223 from_id: Uuid,
224 into_id: Uuid,
225 merge_event_id: Uuid,
226 now: i64,
227) -> SqlStatement {
228 SqlStatement::new(
229 "UPDATE entities \
230 SET deleted_at = ?1, merged_into = ?2, merge_event_id = ?3, updated_at = ?1, version = version + 1 \
231 WHERE namespace = ?4 AND id = ?5 AND deleted_at IS NULL",
232 vec![
233 SqlValue::Integer(now),
234 SqlValue::Text(into_id.to_string()),
235 SqlValue::Text(merge_event_id.to_string()),
236 SqlValue::Text(namespace.to_string()),
237 SqlValue::Text(from_id.to_string()),
238 ],
239 )
240 .labelled("entity-merge-tombstone")
241}
242
243pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
246 SqlStatement {
247 sql: "DELETE FROM entities WHERE id = ?1".to_string(),
248 params: vec![SqlValue::Text(id.to_string())],
249 label: Some("entity-delete-hard".to_string()),
250 }
251}
252
253pub struct SqlEntityStore {
259 pool: Arc<ConnectionPool>,
260 writer_task: Option<WriterTaskHandle>,
261}
262
263#[derive(Clone, Copy, PartialEq, Eq)]
264enum EntityPageMode {
265 ExactTotal,
266 CountFree,
267}
268
269impl EntityPageMode {
270 fn operation(self) -> &'static str {
271 match self {
272 Self::ExactTotal => "query_entities",
273 Self::CountFree => "query_entities_count_free",
274 }
275 }
276}
277
278impl SqlEntityStore {
279 pub fn new(pool: Arc<ConnectionPool>, _is_file_backed: bool) -> Self {
297 let writer_task = pool.writer_task_handle().ok().flatten();
305
306 Self { pool, writer_task }
307 }
308
309 fn current_writer_task(
310 &self,
311 operation: &'static str,
312 ) -> Result<Option<WriterTaskHandle>, StorageError> {
313 self.pool
314 .writer_task_for_write(self.writer_task.as_ref(), operation)
315 }
316
317 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
334 where
335 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
336 R: Send + 'static,
337 {
338 if let Some(writer_task) = self.current_writer_task(op)? {
339 return writer_task
340 .send_bounded_at(move |conn| f(conn).map_err(|e| map_err(e, op)), op)
341 .await;
342 }
343
344 self.pool
345 .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
346 let pool = Arc::clone(&self.pool);
347 tokio::task::spawn_blocking(move || {
348 let guard = pool
349 .autocommit_write_unit()
350 .map_err(|e| map_sqlite_err(e, op))?;
351 f(guard.conn())
352 .map_err(|e| map_err(e, op))
353 .map_err(crate::error::statement_failure)
354 .inspect_err(|error| pool.record_direct_writer_error(error))
355 })
356 .await
357 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
358 }
359
360 async fn with_writer_tx<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
364 where
365 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
366 R: Send + 'static,
367 {
368 if let Some(writer_task) = self.current_writer_task(op)? {
369 return writer_task
370 .send_bounded_at(move |conn| f(conn).map_err(|error| map_err(error, op)), op)
371 .await;
372 }
373
374 self.pool
375 .record_direct_route(crate::timeout_sink::Site::DirectRouteEntity);
376 let pool = Arc::clone(&self.pool);
377 tokio::task::spawn_blocking(move || {
378 let guard = pool
379 .transaction_write_unit()
380 .map_err(|error| map_sqlite_err(error, op))
381 .inspect_err(|error| pool.record_direct_writer_error(error))?;
382 let conn = guard.conn();
383 let (result, terminal_state) = execute_wrapped_transaction(conn, op, move |conn| {
384 f(conn).map_err(|error| map_err(error, op))
385 });
386 if terminal_state.is_some() {
387 pool.retire_pooled_writer(conn);
388 }
389 result.inspect_err(|error| pool.record_direct_writer_error(error))
390 })
391 .await
392 .map_err(|error| StorageError::driver(StorageCapability::Entities, op, error))?
393 }
394
395 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
396 where
397 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
398 R: Send + 'static,
399 {
400 super::run_pooled_store_read(
401 Arc::clone(&self.pool),
402 StorageCapability::Entities,
403 op,
404 move |conn| f(conn).map_err(|error| map_err(error, op)),
405 )
406 .await
407 }
408
409 async fn with_list_reader<F, R>(
410 &self,
411 op: &'static str,
412 index: Option<&'static str>,
413 mut read: F,
414 ) -> Result<R, StorageError>
415 where
416 F: FnMut(&rusqlite::Connection, bool) -> Result<R, rusqlite::Error> + Send + 'static,
417 R: Send + 'static,
418 {
419 let dispatch = tracing::dispatcher::get_default(Clone::clone);
420 self.with_reader(op, move |conn| match read(conn, true) {
421 Ok(value) => Ok(value),
422 Err(error) => {
423 let Some(index) = index.filter(|index| missing_list_index(&error, index)) else {
424 return Err(error);
425 };
426 tracing::dispatcher::with_default(&dispatch, || {
427 tracing::warn!(
428 index,
429 operation = op,
430 "entity list index missing; retrying without forced index"
431 );
432 });
433 read(conn, false)
434 }
435 })
436 .await
437 }
438
439 async fn query_entities_page(
440 &self,
441 namespace: &str,
442 filter: EntityFilter,
443 page: PageRequest,
444 mode: EntityPageMode,
445 ) -> Result<Page<Entity>, StorageError> {
446 let operation = mode.operation();
447 super::validate_json_equality_paths(
448 &filter.property_equalities,
449 StorageCapability::Entities,
450 operation,
451 )?;
452 let namespace = namespace.to_string();
453 let skip_total = mode == EntityPageMode::CountFree || is_complete_id_lookup(&filter, &page);
454 let limit_i64 = i64::from(page.limit);
455 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
456 capability: StorageCapability::Entities,
457 operation: operation.into(),
458 message: format!(
459 "PageRequest: offset must be <= i64::MAX, got {}",
460 page.offset
461 ),
462 })?;
463
464 let streaming = mode == EntityPageMode::CountFree && is_entity_streaming_page(&filter);
465 let index = if streaming {
466 entity_count_free_index(&filter)
467 } else {
468 entity_list_index(&filter)
469 };
470 let read = move |conn: &rusqlite::Connection, force_index| {
471 let total = if filter.names_ci.is_empty() && !skip_total {
472 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
473 let sql = entity_list_query(
474 &filter,
475 build_entity_count_query(&filter, &count_sql),
476 force_index,
477 );
478 let mut stmt = conn.prepare(&sql)?;
479 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
480 count_params.iter().map(|p| p.as_ref()).collect();
481 Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
482 } else {
483 None
484 };
485
486 let mut lookup_filter = filter.clone();
487 lookup_filter.names_ci.clear();
488 let effective_filter = if filter.names_ci.is_empty() {
489 &filter
490 } else {
491 &lookup_filter
492 };
493 let (where_sql, mut data_params) = if streaming {
494 build_entity_streaming_where(&namespace, effective_filter)
495 } else {
496 build_entity_where(&namespace, effective_filter)
497 };
498
499 let candidate_param_indices = if filter.names_ci.is_empty() {
500 Vec::new()
501 } else {
502 let mut candidates: Vec<String> = filter
503 .names_ci
504 .iter()
505 .map(|name| name.to_ascii_lowercase())
506 .collect();
507 candidates.sort_unstable();
508 candidates.dedup();
509 candidates
510 .into_iter()
511 .map(|candidate| {
512 data_params.push(Box::new(candidate));
513 data_params.len()
514 })
515 .collect()
516 };
517
518 let order_by = if let Some(ref prefix) = filter.name_prefix {
524 data_params.push(Box::new(prefix.to_ascii_lowercase()));
525 format!(
526 "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC, id DESC",
527 data_params.len()
528 )
529 } else {
530 "created_at DESC, id DESC".to_string()
538 };
539
540 data_params.push(Box::new(limit_i64));
541 data_params.push(Box::new(offset_i64));
542
543 let limit_idx = data_params.len() - 1;
544 let offset_idx = data_params.len();
545
546 let columns = ENTITY_SELECT_COLUMNS;
547 let data_sql = if streaming {
548 build_entity_count_free_page_query(
549 columns,
550 effective_filter,
551 &where_sql,
552 &order_by,
553 limit_idx,
554 offset_idx,
555 )
556 } else if filter.names_ci.is_empty() {
557 build_entity_page_query(
558 columns,
559 effective_filter,
560 &where_sql,
561 &order_by,
562 limit_idx,
563 offset_idx,
564 )
565 } else {
566 build_candidate_entity_query(
567 columns,
568 effective_filter,
569 &where_sql,
570 &candidate_param_indices,
571 &order_by,
572 limit_idx,
573 offset_idx,
574 )
575 };
576
577 let data_sql = if streaming {
578 entity_count_free_query(effective_filter, data_sql, force_index)
579 } else {
580 entity_list_query(effective_filter, data_sql, force_index)
581 };
582 let mut stmt = conn.prepare(&data_sql)?;
583 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
584 data_params.iter().map(|p| p.as_ref()).collect();
585 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
586
587 let mut items = Vec::new();
588 for row in rows {
589 items.push(row?);
590 }
591
592 Ok(Page { items, total })
593 };
594 self.with_list_reader(operation, index, read).await
595 }
596}
597
598fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
603 let id_str: String = row.get(0)?;
604 let namespace: String = row.get(1)?;
605 let kind: String = row.get(2)?;
606 let entity_type: Option<String> = row.get(3)?;
607 let name: String = row.get(4)?;
608 let description: Option<String> = row.get(5)?;
609 let properties_str: Option<String> = row.get(6)?;
610 let tags_str: String = row.get(7)?;
611 let created_at: i64 = row.get(8)?;
612 let updated_at: i64 = row.get(9)?;
613 let deleted_at: Option<i64> = row.get(10)?;
614 let merged_into_str: Option<String> = row.get(11)?;
615 let merge_event_id_str: Option<String> = row.get(12)?;
616 let content_ref: Option<String> = row.get(13)?;
617 let version: i64 = row.get(14)?;
618
619 let id = parse_uuid(&id_str)?;
620
621 let properties = properties_str
622 .map(|s| {
623 serde_json::from_str(&s).map_err(|e| {
624 rusqlite::Error::FromSqlConversionFailure(
625 6,
626 rusqlite::types::Type::Text,
627 Box::new(e),
628 )
629 })
630 })
631 .transpose()?;
632
633 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
634 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
635 })?;
636
637 let merged_into = merged_into_str
638 .as_deref()
639 .map(Uuid::parse_str)
640 .transpose()
641 .map_err(|e| {
642 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
643 })?;
644
645 let merge_event_id = merge_event_id_str
646 .as_deref()
647 .map(Uuid::parse_str)
648 .transpose()
649 .map_err(|e| {
650 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
651 })?;
652
653 Ok(Entity {
654 id,
655 namespace,
656 kind,
657 entity_type,
658 name,
659 description,
660 properties,
661 tags,
662 created_at,
663 updated_at,
664 version,
665 deleted_at,
666 merged_into,
667 merge_event_id,
668 content_ref,
669 })
670}
671
672fn batch_upsert_entities(
682 conn: &rusqlite::Connection,
683 entities: &[Entity],
684 attempted: u64,
685) -> Result<BatchWriteSummary, rusqlite::Error> {
686 let mut summary = BatchWriteSummary {
687 attempted,
688 ..BatchWriteSummary::default()
689 };
690
691 for (index, entity) in entities.iter().enumerate() {
692 let id_str = entity.id.to_string();
693 let statement = entity_upsert_statement(entity);
694 let result = (|| {
695 let mut prepared = conn.prepare(&statement.sql)?;
696 bind_params(&mut prepared, &statement.params)?;
697 prepared.raw_execute()
698 })();
699 match result {
700 Ok(_) => summary.affected = summary.affected.saturating_add(1),
701 Err(e) => {
702 let (class, retryability) = super::classify_batch_sqlite_error(&e);
703 summary.record_failure(index, Some(id_str), class, retryability, e.to_string());
704 }
705 }
706 }
707
708 Ok(summary)
709}
710
711fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
712 Uuid::parse_str(s).map_err(|e| {
713 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
714 })
715}
716
717fn build_entity_where(
718 namespace: &str,
719 filter: &EntityFilter,
720) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
721 build_entity_where_with_mode(namespace, filter, false)
722}
723
724fn build_entity_streaming_where(
728 namespace: &str,
729 filter: &EntityFilter,
730) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
731 let mut filter = filter.clone();
732 for values in [
733 &mut filter.namespaces,
734 &mut filter.kinds,
735 &mut filter.entity_types,
736 ] {
737 values.sort_unstable();
738 values.dedup();
739 }
740 for values in filter.entity_types_by_kind.values_mut() {
741 values.sort_unstable();
742 values.dedup();
743 }
744 build_entity_where_with_mode(namespace, &filter, true)
745}
746
747fn build_entity_where_with_mode(
748 namespace: &str,
749 filter: &EntityFilter,
750 row_local: bool,
751) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
752 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
756 if !filter.namespaces.is_empty() {
757 let placeholders: Vec<String> = (1..=filter.namespaces.len())
758 .map(|i| format!("?{i}"))
759 .collect();
760 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
761 .namespaces
762 .iter()
763 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
764 .collect();
765 (
766 format!("namespace IN ({})", placeholders.join(", ")),
767 params,
768 )
769 } else {
770 (
771 "namespace = ?1".to_string(),
772 vec![Box::new(namespace.to_string())],
773 )
774 };
775
776 let mut conditions: Vec<String> = vec![ns_condition];
777 match filter.tombstones {
778 EntityTombstones::Live => conditions.push("deleted_at IS NULL".into()),
779 EntityTombstones::All => {}
780 EntityTombstones::Only => conditions.push("deleted_at IS NOT NULL".into()),
781 }
782 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
783
784 if !filter.ids.is_empty() {
785 let placeholders: Vec<String> = filter
786 .ids
787 .iter()
788 .map(|id| {
789 params.push(Box::new(id.to_string()));
790 format!("?{}", params.len())
791 })
792 .collect();
793 conditions.push(format!("id IN ({})", placeholders.join(", ")));
794 }
795
796 if !filter.kinds.is_empty() {
797 let placeholders: Vec<String> = filter
798 .kinds
799 .iter()
800 .map(|k| {
801 params.push(Box::new(k.clone()));
802 format!("?{}", params.len())
803 })
804 .collect();
805 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
806 }
807
808 let type_scope = conditions.join(" AND ");
809 let type_predicate = |scope: &str, placeholders: &str| {
810 if filter.legacy_entity_type_fallback && row_local {
811 format!(
814 "CASE WHEN entity_type IS NOT NULL THEN entity_type IN ({placeholders}) \
815 WHEN json_valid(properties) THEN \
816 CASE WHEN json_type(properties, '$.type') = 'text' \
817 THEN json_extract(properties, '$.type') IN ({placeholders}) \
818 ELSE 0 END \
819 ELSE 0 END"
820 )
821 } else if filter.legacy_entity_type_fallback {
822 format!(
827 "id IN (SELECT id FROM entities WHERE {scope} \
828 AND entity_type IN ({placeholders}) \
829 UNION ALL SELECT id FROM entities WHERE {scope} \
830 AND entity_type IS NULL AND json_valid(properties) \
831 AND json_type(properties, '$.type') = 'text' \
832 AND json_extract(properties, '$.type') IN ({placeholders}))"
833 )
834 } else {
835 format!("entity_type IN ({placeholders})")
836 }
837 };
838 if !filter.entity_types.is_empty() {
839 let placeholders: Vec<String> = filter
840 .entity_types
841 .iter()
842 .map(|t| {
843 params.push(Box::new(t.clone()));
844 format!("?{}", params.len())
845 })
846 .collect();
847 conditions.push(type_predicate(&type_scope, &placeholders.join(", ")));
848 }
849
850 if !filter.entity_types_by_kind.is_empty() {
851 let mut groups = Vec::new();
852 for (kind, types) in &filter.entity_types_by_kind {
853 if types.is_empty() {
854 continue;
855 }
856 params.push(Box::new(kind.clone()));
857 let kind_param = params.len();
858 let placeholders = types
859 .iter()
860 .map(|value| {
861 params.push(Box::new(value.clone()));
862 format!("?{}", params.len())
863 })
864 .collect::<Vec<_>>()
865 .join(", ");
866 let scope = format!("{type_scope} AND kind = ?{kind_param}");
867 let predicate = type_predicate(&scope, &placeholders);
868 groups.push(format!("(kind = ?{kind_param} AND {predicate})"));
869 }
870 conditions.push(if groups.is_empty() {
871 "0".to_string()
872 } else {
873 format!("({})", groups.join(" OR "))
874 });
875 }
876
877 if let Some(ref prefix) = filter.name_prefix {
878 params.push(Box::new(format!(
879 "{}%",
880 khive_types::escape_like_literal(prefix)
881 )));
882 conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
883 }
884
885 if let Some(ref exact) = filter.name_exact {
886 params.push(Box::new(exact.clone()));
887 conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
892 }
893
894 if !filter.names_ci.is_empty() {
895 let placeholders: Vec<String> = filter
898 .names_ci
899 .iter()
900 .map(|n| {
901 params.push(Box::new(n.to_ascii_lowercase()));
902 format!("?{}", params.len())
903 })
904 .collect();
905 conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
906 }
907
908 if !filter.tags_any.is_empty() {
909 let placeholders: Vec<String> = filter
910 .tags_any
911 .iter()
912 .map(|t| {
913 params.push(Box::new(t.to_lowercase()));
916 format!("?{}", params.len())
917 })
918 .collect();
919 conditions.push(format!(
920 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
921 placeholders.join(", ")
922 ));
923 }
924
925 super::append_json_equalities(
926 &mut conditions,
927 &mut params,
928 "properties",
929 &filter.property_equalities,
930 );
931 let clause = format!(" WHERE {}", conditions.join(" AND "));
932 (clause, params)
933}
934
935fn entity_read_source(filter: &EntityFilter) -> &'static str {
940 if filter.ids.is_empty() {
941 "entities"
942 } else {
943 "entities INDEXED BY sqlite_autoindex_entities_1"
944 }
945}
946
947fn build_entity_count_query(filter: &EntityFilter, where_sql: &str) -> String {
948 let source = entity_list_source(filter);
949 format!("SELECT COUNT(*) FROM {source}{where_sql}")
950}
951
952fn build_entity_page_query(
953 columns: &str,
954 filter: &EntityFilter,
955 where_sql: &str,
956 order_by: &str,
957 limit_idx: usize,
958 offset_idx: usize,
959) -> String {
960 let source = entity_list_source(filter);
961 build_entity_page_query_from_source(columns, source, where_sql, order_by, limit_idx, offset_idx)
962}
963
964fn build_entity_count_free_page_query(
965 columns: &str,
966 filter: &EntityFilter,
967 where_sql: &str,
968 order_by: &str,
969 limit_idx: usize,
970 offset_idx: usize,
971) -> String {
972 let source = entity_count_free_source(filter);
973 build_entity_page_query_from_source(columns, source, where_sql, order_by, limit_idx, offset_idx)
974}
975
976fn build_entity_page_query_from_source(
977 columns: &str,
978 source: &str,
979 where_sql: &str,
980 order_by: &str,
981 limit_idx: usize,
982 offset_idx: usize,
983) -> String {
984 format!(
985 "SELECT {columns} FROM {source}{where_sql} \
986 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
987 )
988}
989
990fn entity_list_source(filter: &EntityFilter) -> &'static str {
994 if filter.tombstones != EntityTombstones::Live
995 || !filter.property_equalities.is_empty()
996 || !filter.ids.is_empty()
997 || !filter.kinds.is_empty()
998 || !filter.entity_types_by_kind.is_empty()
999 || filter.legacy_entity_type_fallback
1000 || filter.name_prefix.is_some()
1001 || filter.name_exact.is_some()
1002 || !filter.names_ci.is_empty()
1003 || !filter.tags_any.is_empty()
1004 {
1005 return entity_read_source(filter);
1006 }
1007 if !filter.entity_types.is_empty() {
1008 "entities INDEXED BY idx_entities_live_namespace_type_order"
1009 } else if filter.namespaces.len() <= 1 {
1010 "entities INDEXED BY idx_entities_live_namespace_order"
1011 } else {
1012 "entities"
1013 }
1014}
1015
1016fn entity_list_index(filter: &EntityFilter) -> Option<&'static str> {
1017 if !filter.ids.is_empty() {
1018 return None;
1019 }
1020 entity_list_source(filter).strip_prefix("entities INDEXED BY ")
1021}
1022
1023fn is_entity_streaming_page(filter: &EntityFilter) -> bool {
1024 filter.ids.is_empty()
1025 && filter.name_prefix.is_none()
1026 && filter.name_exact.is_none()
1027 && filter.names_ci.is_empty()
1028}
1029
1030fn has_one_distinct_value(values: &[String]) -> bool {
1031 values
1032 .first()
1033 .is_some_and(|first| values.iter().all(|value| value == first))
1034}
1035
1036fn entity_count_free_source(filter: &EntityFilter) -> &'static str {
1040 if filter.tombstones != EntityTombstones::Live {
1041 return entity_read_source(filter);
1042 }
1043 if !is_entity_streaming_page(filter) {
1044 return entity_list_source(filter);
1045 }
1046 if !filter.namespaces.is_empty() && !has_one_distinct_value(&filter.namespaces) {
1047 return "entities";
1048 }
1049 if !filter.entity_types_by_kind.is_empty()
1054 && filter.entity_types_by_kind.values().all(Vec::is_empty)
1055 {
1056 return "entities";
1057 }
1058
1059 let mut kind_groups = filter
1060 .entity_types_by_kind
1061 .iter()
1062 .filter(|(_, types)| !types.is_empty());
1063 let one_kind_group = kind_groups.next().is_some() && kind_groups.next().is_none();
1064 if has_one_distinct_value(&filter.kinds) || one_kind_group {
1065 "entities INDEXED BY idx_entities_live_namespace_kind_order"
1066 } else if !filter.legacy_entity_type_fallback && has_one_distinct_value(&filter.entity_types) {
1067 "entities INDEXED BY idx_entities_live_namespace_type_order"
1070 } else {
1071 "entities INDEXED BY idx_entities_live_namespace_order"
1072 }
1073}
1074
1075fn entity_count_free_index(filter: &EntityFilter) -> Option<&'static str> {
1076 if !filter.ids.is_empty() {
1077 return None;
1078 }
1079 entity_count_free_source(filter).strip_prefix("entities INDEXED BY ")
1080}
1081
1082fn missing_list_index(error: &rusqlite::Error, index: &str) -> bool {
1083 if error
1084 .sqlite_error()
1085 .is_none_or(|detail| detail.extended_code != rusqlite::ffi::SQLITE_ERROR)
1086 {
1087 return false;
1088 }
1089 let message = match error {
1090 rusqlite::Error::SqliteFailure(_, Some(message)) => message.as_str(),
1091 rusqlite::Error::SqlInputError { msg, .. } => msg.as_str(),
1092 _ => return false,
1093 };
1094 message.strip_prefix("no such index: ") == Some(index)
1095}
1096
1097fn entity_list_query(filter: &EntityFilter, sql: String, force_index: bool) -> String {
1098 entity_query_with_index(
1099 sql,
1100 entity_list_source(filter),
1101 entity_list_index(filter),
1102 force_index,
1103 )
1104}
1105
1106fn entity_count_free_query(filter: &EntityFilter, sql: String, force_index: bool) -> String {
1107 entity_query_with_index(
1108 sql,
1109 entity_count_free_source(filter),
1110 entity_count_free_index(filter),
1111 force_index,
1112 )
1113}
1114
1115fn entity_query_with_index(
1116 sql: String,
1117 source: &str,
1118 index: Option<&str>,
1119 force_index: bool,
1120) -> String {
1121 if force_index || index.is_none() {
1122 sql
1123 } else {
1124 sql.replacen(source, "entities", 1)
1125 }
1126}
1127
1128fn build_candidate_entity_query(
1129 columns: &str,
1130 filter: &EntityFilter,
1131 where_sql: &str,
1132 candidate_param_indices: &[usize],
1133 order_by: &str,
1134 limit_idx: usize,
1135 offset_idx: usize,
1136) -> String {
1137 let source = entity_read_source(filter);
1138 let candidate_rows = candidate_param_indices
1139 .iter()
1140 .map(|idx| format!("(?{idx})"))
1141 .collect::<Vec<_>>()
1142 .join(", ");
1143
1144 format!(
1145 "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
1146 matched_entities(entity_id) AS (\
1147 SELECT (\
1148 SELECT id FROM {source}{where_sql} \
1149 AND LOWER(name) = candidates.folded_name LIMIT 1\
1150 ) FROM candidates\
1151 ) \
1152 SELECT {columns} FROM entities \
1153 JOIN matched_entities ON entities.id = matched_entities.entity_id \
1154 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
1155 )
1156}
1157
1158fn build_entity_cursor_query(
1159 columns: &str,
1160 filter: &EntityFilter,
1161 where_sql: &str,
1162 limit_idx: usize,
1163) -> String {
1164 let source = entity_read_source(filter);
1169 let from_clause = if !filter.ids.is_empty() {
1170 format!("{source} CROSS JOIN entities_seq ON entities.id = entities_seq.entity_id")
1171 } else {
1172 "entities_seq CROSS JOIN entities INDEXED BY sqlite_autoindex_entities_1 \
1173 ON entities.id = entities_seq.entity_id"
1174 .to_string()
1175 };
1176 format!(
1177 "SELECT {columns}, entities_seq.seq FROM {from_clause}{where_sql} \
1178 ORDER BY entities_seq.seq ASC LIMIT ?{limit_idx}"
1179 )
1180}
1181
1182fn is_complete_id_lookup(filter: &EntityFilter, page: &PageRequest) -> bool {
1183 !filter.ids.is_empty()
1184 && filter.property_equalities.is_empty()
1185 && filter.kinds.is_empty()
1186 && filter.entity_types.is_empty()
1187 && filter.entity_types_by_kind.is_empty()
1188 && filter.name_prefix.is_none()
1189 && filter.name_exact.is_none()
1190 && filter.tags_any.is_empty()
1191 && filter.names_ci.is_empty()
1192 && page.offset == 0
1193 && usize::try_from(page.limit).ok() == Some(filter.ids.len())
1194}
1195
1196#[async_trait]
1201impl EntityStore for SqlEntityStore {
1202 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
1203 let statement = entity_upsert_statement(&entity);
1204 self.with_writer("upsert_entity", move |conn| {
1205 let mut stmt = conn.prepare(&statement.sql)?;
1206 bind_params(&mut stmt, &statement.params)?;
1207 stmt.raw_execute()?;
1208 Ok(())
1209 })
1210 .await
1211 }
1212
1213 async fn insert_entity_if_absent(&self, entity: Entity) -> Result<bool, StorageError> {
1214 let statement = entity_insert_if_absent_statement(&entity);
1215 self.with_writer("insert_entity_if_absent", move |conn| {
1216 let mut stmt = conn.prepare(&statement.sql)?;
1217 bind_params(&mut stmt, &statement.params)?;
1218 Ok(stmt.raw_execute()? > 0)
1219 })
1220 .await
1221 }
1222
1223 async fn upsert_entity_with_attachments(
1224 &self,
1225 entity: Entity,
1226 attachments: Vec<Attachment>,
1227 ) -> Result<(), StorageError> {
1228 let entity_id = entity.id;
1229 let entity_statement = entity_upsert_statement(&entity);
1230 let mut attachment_statements = Vec::with_capacity(attachments.len());
1231 for attachment in attachments {
1232 attachment.validate()?;
1233 if attachment.record_uuid != entity_id
1234 || attachment.substrate != AttachmentSubstrate::Entity
1235 {
1236 return Err(StorageError::InvalidInput {
1237 capability: StorageCapability::Attachments,
1238 operation: "upsert_entity_with_attachments".into(),
1239 message: format!(
1240 "attachment {} must target entity {entity_id}",
1241 attachment.role
1242 ),
1243 });
1244 }
1245 attachment_statements.push(attachment_upsert_statement(&attachment)?);
1246 }
1247
1248 self.with_writer_tx("upsert_entity_with_attachments", move |conn| {
1249 let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
1250 bind_params(&mut entity_stmt, &entity_statement.params)?;
1251 entity_stmt.raw_execute()?;
1252 drop(entity_stmt);
1253
1254 for statement in attachment_statements {
1255 let mut stmt = conn.prepare(&statement.sql)?;
1256 bind_params(&mut stmt, &statement.params)?;
1257 stmt.raw_execute()?;
1258 }
1259 Ok(())
1260 })
1261 .await
1262 }
1263
1264 async fn upsert_entities(
1265 &self,
1266 entities: Vec<Entity>,
1267 ) -> Result<BatchWriteSummary, StorageError> {
1268 let attempted = entities.len() as u64;
1269
1270 if let Some(writer_task) = self.current_writer_task("upsert_entities")? {
1277 return writer_task
1278 .send_bounded_at(
1279 move |conn| {
1280 batch_upsert_entities(conn, &entities, attempted)
1281 .map_err(|e| map_err(e, "upsert_entities"))
1282 },
1283 "upsert_entities",
1284 )
1285 .await;
1286 }
1287
1288 let origin = self.pool.origin();
1292 self.with_writer("upsert_entities", move |conn| {
1293 conn.execute_batch("BEGIN IMMEDIATE")?;
1294 let _tx_handle = khive_storage::tx_registry::register_scoped(
1295 Some("entity_upsert_batch".to_string()),
1296 origin,
1297 );
1298
1299 let summary = batch_upsert_entities(conn, &entities, attempted)?;
1300
1301 if let Err(e) = conn.execute_batch("COMMIT") {
1302 let _ = conn.execute_batch("ROLLBACK");
1303 return Err(e);
1304 }
1305 Ok(summary)
1306 })
1307 .await
1308 }
1309
1310 async fn replace_entity_if_unchanged(
1311 &self,
1312 entity: Entity,
1313 expected_updated_at: i64,
1314 expected_deleted_at: Option<i64>,
1315 ) -> Result<bool, StorageError> {
1316 let statement = entity_replace_if_unchanged_statement(
1317 &entity,
1318 expected_updated_at,
1319 expected_deleted_at,
1320 );
1321 self.with_writer("replace_entity_if_unchanged", move |conn| {
1322 let mut stmt = conn.prepare(&statement.sql)?;
1323 bind_params(&mut stmt, &statement.params)?;
1324 Ok(stmt.raw_execute()? > 0)
1325 })
1326 .await
1327 }
1328
1329 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1330 let id_str = id.to_string();
1331
1332 self.with_reader("get_entity", move |conn| {
1333 let sql = format!(
1334 "SELECT {ENTITY_SELECT_COLUMNS} FROM entities \
1335 WHERE entities.id = ?1 AND entities.deleted_at IS NULL"
1336 );
1337 let mut stmt = conn.prepare(&sql)?;
1338 let mut rows = stmt.query(rusqlite::params![id_str])?;
1339 match rows.next()? {
1340 Some(row) => Ok(Some(read_entity(row)?)),
1341 None => Ok(None),
1342 }
1343 })
1344 .await
1345 }
1346
1347 async fn entity_sequence(&self, id: Uuid) -> Result<Option<i64>, StorageError> {
1348 let id = id.to_string();
1349 self.with_reader("entity_sequence", move |conn| {
1350 conn.query_row(
1351 "SELECT seq FROM entities_seq WHERE entity_id = ?1",
1352 rusqlite::params![id],
1353 |row| row.get(0),
1354 )
1355 .optional()
1356 })
1357 .await
1358 }
1359
1360 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
1361 match mode {
1362 DeleteMode::Soft => {
1363 let now = chrono::Utc::now().timestamp_micros();
1364 let statement = entity_soft_delete_statement(id, now);
1365 self.with_writer("delete_entity_soft", move |conn| {
1366 let mut stmt = conn.prepare(&statement.sql)?;
1367 bind_params(&mut stmt, &statement.params)?;
1368 Ok(stmt.raw_execute()? > 0)
1369 })
1370 .await
1371 }
1372 DeleteMode::Hard => {
1373 let entity_statement = entity_hard_delete_statement(id);
1374 let attachment_statement =
1375 delete_record_attachments_statement(id, AttachmentSubstrate::Entity);
1376 self.with_writer_tx("delete_entity_hard", move |conn| {
1377 let mut entity_stmt = conn.prepare(&entity_statement.sql)?;
1378 bind_params(&mut entity_stmt, &entity_statement.params)?;
1379 let deleted = entity_stmt.raw_execute()? > 0;
1380 drop(entity_stmt);
1381 if deleted {
1382 let mut attachment_stmt = conn.prepare(&attachment_statement.sql)?;
1383 bind_params(&mut attachment_stmt, &attachment_statement.params)?;
1384 attachment_stmt.raw_execute()?;
1385 }
1386 Ok(deleted)
1387 })
1388 .await
1389 }
1390 }
1391 }
1392
1393 async fn query_entities(
1394 &self,
1395 namespace: &str,
1396 filter: EntityFilter,
1397 page: PageRequest,
1398 ) -> Result<Page<Entity>, StorageError> {
1399 self.query_entities_page(namespace, filter, page, EntityPageMode::ExactTotal)
1400 .await
1401 }
1402
1403 async fn query_entities_count_free(
1404 &self,
1405 namespace: &str,
1406 filter: EntityFilter,
1407 page: PageRequest,
1408 ) -> Result<Page<Entity>, StorageError> {
1409 self.query_entities_page(namespace, filter, page, EntityPageMode::CountFree)
1410 .await
1411 }
1412
1413 async fn query_entities_after(
1414 &self,
1415 namespace: &str,
1416 filter: EntityFilter,
1417 after: Option<SeekCursor>,
1418 limit: u32,
1419 ) -> Result<SeekPage<Entity>, StorageError> {
1420 super::validate_json_equality_paths(
1421 &filter.property_equalities,
1422 StorageCapability::Entities,
1423 "query_entities_after",
1424 )?;
1425 if limit == 0 {
1426 return Ok(SeekPage::default());
1427 }
1428 if !filter.names_ci.is_empty() {
1429 return Err(StorageError::InvalidInput {
1430 capability: StorageCapability::Entities,
1431 operation: "query_entities_after".into(),
1432 message: "names_ci candidate folding is not compatible with seek pagination".into(),
1433 });
1434 }
1435
1436 let namespace = namespace.to_string();
1437 let limit_usize = limit as usize;
1438 let probe_limit_i64 = i64::from(limit) + 1;
1439 self.with_reader("query_entities_after", move |conn| {
1440 let (mut where_sql, mut params) = if filter.ids.is_empty() {
1441 build_entity_streaming_where(&namespace, &filter)
1442 } else {
1443 build_entity_where(&namespace, &filter)
1444 };
1445 if let Some(cursor) = after {
1446 params.push(Box::new(cursor.sequence));
1447 where_sql.push_str(&format!(" AND entities_seq.seq > ?{}", params.len()));
1448 }
1449 params.push(Box::new(probe_limit_i64));
1450 let limit_idx = params.len();
1451
1452 let columns = ENTITY_SELECT_COLUMNS;
1453 let sql = build_entity_cursor_query(columns, &filter, &where_sql, limit_idx);
1454 let mut stmt = conn.prepare(&sql)?;
1455 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1456 params.iter().map(|param| param.as_ref()).collect();
1457 let rows = stmt.query_map(param_refs.as_slice(), |row| {
1458 Ok((read_entity(row)?, row.get::<_, i64>(15)?))
1459 })?;
1460 let mut entries = rows.collect::<Result<Vec<_>, _>>()?;
1461 let has_more = entries.len() > limit_usize;
1462 if has_more {
1463 entries.truncate(limit_usize);
1464 }
1465 let next_after = if has_more {
1466 entries.last().map(|(entity, sequence)| SeekCursor {
1467 sequence: *sequence,
1468 id: entity.id,
1469 })
1470 } else {
1471 None
1472 };
1473 let items = entries.into_iter().map(|(entity, _)| entity).collect();
1474 Ok(SeekPage { items, next_after })
1475 })
1476 .await
1477 }
1478
1479 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
1480 let id_str = id.to_string();
1481
1482 self.with_reader("get_entity_including_deleted", move |conn| {
1483 let sql =
1484 format!("SELECT {ENTITY_SELECT_COLUMNS} FROM entities WHERE entities.id = ?1");
1485 let mut stmt = conn.prepare(&sql)?;
1486 let mut rows = stmt.query(rusqlite::params![id_str])?;
1487 match rows.next()? {
1488 Some(row) => Ok(Some(read_entity(row)?)),
1489 None => Ok(None),
1490 }
1491 })
1492 .await
1493 }
1494
1495 async fn count_entities_by_type(
1496 &self,
1497 namespaces: &[String],
1498 ) -> Result<Option<EntityTypeCounts>, StorageError> {
1499 let namespaces =
1500 serde_json::to_string(namespaces).map_err(|error| StorageError::Serialization {
1501 capability: StorageCapability::Entities,
1502 message: error.to_string(),
1503 })?;
1504 self.with_reader("count_entities_by_type", move |conn| {
1505 let mut statement = conn.prepare(ENTITIES_COUNT_BY_TYPE_SQL)?;
1506 let rows = statement.query_map(rusqlite::params![namespaces], |row| {
1507 let count: i64 = row.get(1)?;
1508 let count = u64::try_from(count).map_err(|error| {
1509 rusqlite::Error::FromSqlConversionFailure(
1510 1,
1511 rusqlite::types::Type::Integer,
1512 Box::new(error),
1513 )
1514 })?;
1515 Ok((row.get::<_, Option<String>>(0)?, count))
1516 })?;
1517 rows.collect::<Result<Vec<_>, _>>().map(Some)
1518 })
1519 .await
1520 }
1521
1522 async fn count_entities(
1523 &self,
1524 namespace: &str,
1525 filter: EntityFilter,
1526 ) -> Result<u64, StorageError> {
1527 super::validate_json_equality_paths(
1528 &filter.property_equalities,
1529 StorageCapability::Entities,
1530 "count_entities",
1531 )?;
1532 let namespace = namespace.to_string();
1533
1534 let index = entity_list_index(&filter);
1535 self.with_list_reader("count_entities", index, move |conn, force_index| {
1536 if filter.namespaces.is_empty() {
1537 let (where_sql, params) = build_entity_where(&namespace, &filter);
1538 let sql = entity_list_query(
1539 &filter,
1540 build_entity_count_query(&filter, &where_sql),
1541 force_index,
1542 );
1543 let mut stmt = conn.prepare(&sql)?;
1544 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1545 params.iter().map(|p| p.as_ref()).collect();
1546 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1547 return Ok(count as u64);
1548 }
1549
1550 let deduped_namespaces: Vec<String> = filter
1551 .namespaces
1552 .iter()
1553 .cloned()
1554 .collect::<HashSet<_>>()
1555 .into_iter()
1556 .collect();
1557
1558 let mut total = 0;
1559 for chunk in deduped_namespaces.chunks(NAMESPACE_COUNT_CHUNK_SIZE) {
1560 let chunk_filter = EntityFilter {
1561 namespaces: chunk.to_vec(),
1562 ..filter.clone()
1563 };
1564 let (where_sql, params) = build_entity_where(&namespace, &chunk_filter);
1565 let sql = entity_list_query(
1566 &chunk_filter,
1567 build_entity_count_query(&chunk_filter, &where_sql),
1568 force_index && index.is_some(),
1569 );
1570 let mut stmt = conn.prepare(&sql)?;
1571 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
1572 params.iter().map(|p| p.as_ref()).collect();
1573 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
1574 total += count as u64;
1575 }
1576 Ok(total)
1577 })
1578 .await
1579 }
1580}
1581
1582const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
1587
1588pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
1589 conn.execute_batch(ENTITIES_DDL)
1590}
1591
1592#[cfg(test)]
1593#[path = "entity_tests.rs"]
1594mod tests;
1595
1596#[cfg(test)]
1597#[path = "entity_type_counts_tests.rs"]
1598mod entity_type_counts_tests;
1599
1600#[cfg(test)]
1601#[path = "entity_busy_tests.rs"]
1602mod direct_busy_tests;
1603
1604#[cfg(test)]
1605#[path = "entity_list_plan_tests.rs"]
1606mod entity_list_plan_tests;
1607
1608#[cfg(test)]
1609#[path = "entity_merge_tombstone_tests.rs"]
1610mod entity_merge_tombstone_tests;