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, content_ref) \
54 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"
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 match &entity.content_ref {
89 Some(c) => SqlValue::Text(c.clone()),
90 None => SqlValue::Null,
91 },
92 ],
93 label: Some("entity-upsert".to_string()),
94 }
95}
96
97pub fn entity_soft_delete_statement(id: Uuid, deleted_at: i64) -> SqlStatement {
99 SqlStatement {
100 sql: "UPDATE entities SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL".to_string(),
101 params: vec![
102 SqlValue::Integer(deleted_at),
103 SqlValue::Text(id.to_string()),
104 ],
105 label: Some("entity-delete-soft".to_string()),
106 }
107}
108
109pub fn entity_hard_delete_statement(id: Uuid) -> SqlStatement {
112 SqlStatement {
113 sql: "DELETE FROM entities WHERE id = ?1".to_string(),
114 params: vec![SqlValue::Text(id.to_string())],
115 label: Some("entity-delete-hard".to_string()),
116 }
117}
118
119pub struct SqlEntityStore {
124 pool: Arc<ConnectionPool>,
125 is_file_backed: bool,
126 writer_task: Option<WriterTaskHandle>,
127}
128
129impl SqlEntityStore {
130 pub fn new(pool: Arc<ConnectionPool>, is_file_backed: bool) -> Self {
147 let writer_task = pool.writer_task_handle().ok().flatten();
153
154 Self {
155 pool,
156 is_file_backed,
157 writer_task,
158 }
159 }
160
161 fn open_standalone_reader(&self) -> Result<rusqlite::Connection, StorageError> {
162 let config = self.pool.config();
163 let path = config.path.as_ref().ok_or_else(|| StorageError::Pool {
164 operation: "entity_reader".into(),
165 message: "in-memory databases do not support standalone connections".into(),
166 })?;
167
168 let conn = rusqlite::Connection::open_with_flags(
169 path,
170 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
171 | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
172 | rusqlite::OpenFlags::SQLITE_OPEN_URI,
173 )
174 .map_err(|e| map_err(e, "open_entity_reader"))?;
175
176 conn.busy_timeout(config.busy_timeout)
177 .map_err(|e| map_err(e, "open_entity_reader"))?;
178 conn.pragma_update(None, "foreign_keys", "ON")
179 .map_err(|e| map_err(e, "open_entity_reader"))?;
180 conn.pragma_update(None, "synchronous", "NORMAL")
181 .map_err(|e| map_err(e, "open_entity_reader"))?;
182
183 Ok(conn)
184 }
185
186 async fn with_writer<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
203 where
204 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
205 R: Send + 'static,
206 {
207 if let Some(writer_task) = &self.writer_task {
208 return writer_task
209 .send(move |conn| f(conn).map_err(|e| map_err(e, op)))
210 .await;
211 }
212
213 let pool = Arc::clone(&self.pool);
214 tokio::task::spawn_blocking(move || {
215 let guard = pool.try_writer().map_err(|e| map_sqlite_err(e, op))?;
216 f(guard.conn()).map_err(|e| map_err(e, op))
217 })
218 .await
219 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
220 }
221
222 async fn with_reader<F, R>(&self, op: &'static str, f: F) -> Result<R, StorageError>
223 where
224 F: FnOnce(&rusqlite::Connection) -> Result<R, rusqlite::Error> + Send + 'static,
225 R: Send + 'static,
226 {
227 if self.is_file_backed {
228 let conn = self.open_standalone_reader()?;
229 tokio::task::spawn_blocking(move || f(&conn).map_err(|e| map_err(e, op)))
230 .await
231 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
232 } else {
233 let pool = Arc::clone(&self.pool);
234 tokio::task::spawn_blocking(move || {
235 let guard = pool.reader().map_err(|e| map_sqlite_err(e, op))?;
236 f(guard.conn()).map_err(|e| map_err(e, op))
237 })
238 .await
239 .map_err(|e| StorageError::driver(StorageCapability::Entities, op, e))?
240 }
241 }
242}
243
244fn read_entity(row: &rusqlite::Row<'_>) -> Result<Entity, rusqlite::Error> {
249 let id_str: String = row.get(0)?;
250 let namespace: String = row.get(1)?;
251 let kind: String = row.get(2)?;
252 let entity_type: Option<String> = row.get(3)?;
253 let name: String = row.get(4)?;
254 let description: Option<String> = row.get(5)?;
255 let properties_str: Option<String> = row.get(6)?;
256 let tags_str: String = row.get(7)?;
257 let created_at: i64 = row.get(8)?;
258 let updated_at: i64 = row.get(9)?;
259 let deleted_at: Option<i64> = row.get(10)?;
260 let merged_into_str: Option<String> = row.get(11)?;
261 let merge_event_id_str: Option<String> = row.get(12)?;
262 let content_ref: Option<String> = row.get(13)?;
263
264 let id = parse_uuid(&id_str)?;
265
266 let properties = properties_str
267 .map(|s| {
268 serde_json::from_str(&s).map_err(|e| {
269 rusqlite::Error::FromSqlConversionFailure(
270 6,
271 rusqlite::types::Type::Text,
272 Box::new(e),
273 )
274 })
275 })
276 .transpose()?;
277
278 let tags: Vec<String> = serde_json::from_str(&tags_str).map_err(|e| {
279 rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(e))
280 })?;
281
282 let merged_into = merged_into_str
283 .as_deref()
284 .map(Uuid::parse_str)
285 .transpose()
286 .map_err(|e| {
287 rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
288 })?;
289
290 let merge_event_id = merge_event_id_str
291 .as_deref()
292 .map(Uuid::parse_str)
293 .transpose()
294 .map_err(|e| {
295 rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
296 })?;
297
298 Ok(Entity {
299 id,
300 namespace,
301 kind,
302 entity_type,
303 name,
304 description,
305 properties,
306 tags,
307 created_at,
308 updated_at,
309 deleted_at,
310 merged_into,
311 merge_event_id,
312 content_ref,
313 })
314}
315
316fn batch_upsert_entities(
326 conn: &rusqlite::Connection,
327 entities: &[Entity],
328 attempted: u64,
329) -> Result<BatchWriteSummary, rusqlite::Error> {
330 let mut affected = 0u64;
331 let mut failed = 0u64;
332 let mut first_error = String::new();
333
334 for entity in entities {
335 let id_str = entity.id.to_string();
336 let properties_str = entity
337 .properties
338 .as_ref()
339 .map(|v| serde_json::to_string(v).unwrap_or_default());
340 let tags_str = serde_json::to_string(&entity.tags).unwrap_or_else(|_| "[]".to_string());
341
342 let merged_into_str = entity.merged_into.map(|u| u.to_string());
343 let merge_event_id_str = entity.merge_event_id.map(|u| u.to_string());
344 match conn.execute(
345 "INSERT OR REPLACE INTO entities \
346 (id, namespace, kind, entity_type, name, description, properties, tags, \
347 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref) \
348 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
349 rusqlite::params![
350 id_str,
351 &entity.namespace,
352 entity.kind,
353 entity.entity_type,
354 entity.name,
355 entity.description,
356 properties_str,
357 tags_str,
358 entity.created_at,
359 entity.updated_at,
360 entity.deleted_at,
361 merged_into_str,
362 merge_event_id_str,
363 entity.content_ref,
364 ],
365 ) {
366 Ok(_) => affected += 1,
367 Err(e) => {
368 if first_error.is_empty() {
369 first_error = e.to_string();
370 }
371 failed += 1;
372 }
373 }
374 }
375
376 Ok(BatchWriteSummary {
377 attempted,
378 affected,
379 failed,
380 first_error,
381 })
382}
383
384fn parse_uuid(s: &str) -> Result<Uuid, rusqlite::Error> {
385 Uuid::parse_str(s).map_err(|e| {
386 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
387 })
388}
389
390fn escape_like(input: &str) -> String {
396 let mut out = String::with_capacity(input.len());
397 for c in input.chars() {
398 if matches!(c, '\\' | '%' | '_') {
399 out.push('\\');
400 }
401 out.push(c);
402 }
403 out
404}
405
406fn build_entity_where(
407 namespace: &str,
408 filter: &EntityFilter,
409) -> (String, Vec<Box<dyn rusqlite::types::ToSql>>) {
410 let (ns_condition, ns_params): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
414 if !filter.namespaces.is_empty() {
415 let placeholders: Vec<String> = (1..=filter.namespaces.len())
416 .map(|i| format!("?{i}"))
417 .collect();
418 let params: Vec<Box<dyn rusqlite::types::ToSql>> = filter
419 .namespaces
420 .iter()
421 .map(|ns| -> Box<dyn rusqlite::types::ToSql> { Box::new(ns.clone()) })
422 .collect();
423 (
424 format!("namespace IN ({})", placeholders.join(", ")),
425 params,
426 )
427 } else {
428 (
429 "namespace = ?1".to_string(),
430 vec![Box::new(namespace.to_string())],
431 )
432 };
433
434 let mut conditions: Vec<String> = vec![ns_condition, "deleted_at IS NULL".to_string()];
435 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = ns_params;
436
437 if !filter.ids.is_empty() {
438 let placeholders: Vec<String> = filter
439 .ids
440 .iter()
441 .map(|id| {
442 params.push(Box::new(id.to_string()));
443 format!("?{}", params.len())
444 })
445 .collect();
446 conditions.push(format!("id IN ({})", placeholders.join(", ")));
447 }
448
449 if !filter.kinds.is_empty() {
450 let placeholders: Vec<String> = filter
451 .kinds
452 .iter()
453 .map(|k| {
454 params.push(Box::new(k.clone()));
455 format!("?{}", params.len())
456 })
457 .collect();
458 conditions.push(format!("kind IN ({})", placeholders.join(", ")));
459 }
460
461 if !filter.entity_types.is_empty() {
462 let placeholders: Vec<String> = filter
463 .entity_types
464 .iter()
465 .map(|t| {
466 params.push(Box::new(t.clone()));
467 format!("?{}", params.len())
468 })
469 .collect();
470 conditions.push(format!("entity_type IN ({})", placeholders.join(", ")));
471 }
472
473 if let Some(ref prefix) = filter.name_prefix {
474 params.push(Box::new(format!("{}%", escape_like(prefix))));
475 conditions.push(format!("name LIKE ?{} ESCAPE '\\'", params.len()));
476 }
477
478 if let Some(ref exact) = filter.name_exact {
479 params.push(Box::new(exact.clone()));
480 conditions.push(format!("name = ?{} COLLATE BINARY", params.len()));
485 }
486
487 if !filter.names_ci.is_empty() {
488 let placeholders: Vec<String> = filter
491 .names_ci
492 .iter()
493 .map(|n| {
494 params.push(Box::new(n.to_ascii_lowercase()));
495 format!("?{}", params.len())
496 })
497 .collect();
498 conditions.push(format!("LOWER(name) IN ({})", placeholders.join(", ")));
499 }
500
501 if !filter.tags_any.is_empty() {
502 let placeholders: Vec<String> = filter
503 .tags_any
504 .iter()
505 .map(|t| {
506 params.push(Box::new(t.to_lowercase()));
509 format!("?{}", params.len())
510 })
511 .collect();
512 conditions.push(format!(
513 "EXISTS (SELECT 1 FROM json_each(tags) WHERE LOWER(json_each.value) IN ({}))",
514 placeholders.join(", ")
515 ));
516 }
517
518 let clause = format!(" WHERE {}", conditions.join(" AND "));
519 (clause, params)
520}
521
522fn build_candidate_entity_query(
523 columns: &str,
524 where_sql: &str,
525 candidate_param_indices: &[usize],
526 order_by: &str,
527 limit_idx: usize,
528 offset_idx: usize,
529) -> String {
530 let candidate_rows = candidate_param_indices
531 .iter()
532 .map(|idx| format!("(?{idx})"))
533 .collect::<Vec<_>>()
534 .join(", ");
535
536 format!(
537 "WITH candidates(folded_name) AS (VALUES {candidate_rows}), \
538 matched_entities(entity_id) AS (\
539 SELECT (\
540 SELECT id FROM entities{where_sql} \
541 AND LOWER(name) = candidates.folded_name LIMIT 1\
542 ) FROM candidates\
543 ) \
544 SELECT {columns} FROM entities \
545 JOIN matched_entities ON entities.id = matched_entities.entity_id \
546 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
547 )
548}
549
550#[async_trait]
555impl EntityStore for SqlEntityStore {
556 async fn upsert_entity(&self, entity: Entity) -> Result<(), StorageError> {
557 let statement = entity_upsert_statement(&entity);
558 self.with_writer("upsert_entity", move |conn| {
559 let mut stmt = conn.prepare(&statement.sql)?;
560 bind_params(&mut stmt, &statement.params)?;
561 stmt.raw_execute()?;
562 Ok(())
563 })
564 .await
565 }
566
567 async fn upsert_entities(
568 &self,
569 entities: Vec<Entity>,
570 ) -> Result<BatchWriteSummary, StorageError> {
571 let attempted = entities.len() as u64;
572
573 if let Some(writer_task) = &self.writer_task {
580 return writer_task
581 .send(move |conn| {
582 batch_upsert_entities(conn, &entities, attempted)
583 .map_err(|e| map_err(e, "upsert_entities"))
584 })
585 .await;
586 }
587
588 let origin = self.pool.origin();
592 self.with_writer("upsert_entities", move |conn| {
593 conn.execute_batch("BEGIN IMMEDIATE")?;
594 let _tx_handle = khive_storage::tx_registry::register_scoped(
595 Some("entity_upsert_batch".to_string()),
596 origin,
597 );
598
599 let summary = batch_upsert_entities(conn, &entities, attempted)?;
600
601 if let Err(e) = conn.execute_batch("COMMIT") {
602 let _ = conn.execute_batch("ROLLBACK");
603 return Err(e);
604 }
605 Ok(summary)
606 })
607 .await
608 }
609
610 async fn get_entity(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
611 let id_str = id.to_string();
612
613 self.with_reader("get_entity", move |conn| {
614 let mut stmt = conn.prepare(
615 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
616 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
617 FROM entities WHERE id = ?1 AND deleted_at IS NULL",
618 )?;
619 let mut rows = stmt.query(rusqlite::params![id_str])?;
620 match rows.next()? {
621 Some(row) => Ok(Some(read_entity(row)?)),
622 None => Ok(None),
623 }
624 })
625 .await
626 }
627
628 async fn delete_entity(&self, id: Uuid, mode: DeleteMode) -> Result<bool, StorageError> {
629 match mode {
630 DeleteMode::Soft => {
631 let now = chrono::Utc::now().timestamp_micros();
632 let statement = entity_soft_delete_statement(id, now);
633 self.with_writer("delete_entity_soft", move |conn| {
634 let mut stmt = conn.prepare(&statement.sql)?;
635 bind_params(&mut stmt, &statement.params)?;
636 Ok(stmt.raw_execute()? > 0)
637 })
638 .await
639 }
640 DeleteMode::Hard => {
641 let statement = entity_hard_delete_statement(id);
642 self.with_writer("delete_entity_hard", move |conn| {
643 let mut stmt = conn.prepare(&statement.sql)?;
644 bind_params(&mut stmt, &statement.params)?;
645 Ok(stmt.raw_execute()? > 0)
646 })
647 .await
648 }
649 }
650 }
651
652 async fn query_entities(
653 &self,
654 namespace: &str,
655 filter: EntityFilter,
656 page: PageRequest,
657 ) -> Result<Page<Entity>, StorageError> {
658 let namespace = namespace.to_string();
659 let limit_i64 = i64::from(page.limit);
660 let offset_i64 = i64::try_from(page.offset).map_err(|_| StorageError::InvalidInput {
661 capability: StorageCapability::Entities,
662 operation: "query_entities".into(),
663 message: format!(
664 "PageRequest: offset must be <= i64::MAX, got {}",
665 page.offset
666 ),
667 })?;
668
669 self.with_reader("query_entities", move |conn| {
670 let total = if filter.names_ci.is_empty() {
671 let (count_sql, count_params) = build_entity_where(&namespace, &filter);
672 let sql = format!("SELECT COUNT(*) FROM entities{count_sql}");
673 let mut stmt = conn.prepare(&sql)?;
674 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
675 count_params.iter().map(|p| p.as_ref()).collect();
676 Some(stmt.query_row(param_refs.as_slice(), |row| row.get::<_, i64>(0))? as u64)
677 } else {
678 None
679 };
680
681 let mut lookup_filter = filter.clone();
682 lookup_filter.names_ci.clear();
683 let effective_filter = if filter.names_ci.is_empty() {
684 &filter
685 } else {
686 &lookup_filter
687 };
688 let (where_sql, mut data_params) = build_entity_where(&namespace, effective_filter);
689
690 let candidate_param_indices = if filter.names_ci.is_empty() {
691 Vec::new()
692 } else {
693 let mut candidates: Vec<String> = filter
694 .names_ci
695 .iter()
696 .map(|name| name.to_ascii_lowercase())
697 .collect();
698 candidates.sort_unstable();
699 candidates.dedup();
700 candidates
701 .into_iter()
702 .map(|candidate| {
703 data_params.push(Box::new(candidate));
704 data_params.len()
705 })
706 .collect()
707 };
708
709 let order_by = if let Some(ref prefix) = filter.name_prefix {
715 data_params.push(Box::new(prefix.to_ascii_lowercase()));
716 format!(
717 "CASE WHEN LOWER(name) = ?{} THEN 0 ELSE 1 END, created_at DESC",
718 data_params.len()
719 )
720 } else {
721 "created_at DESC".to_string()
722 };
723
724 data_params.push(Box::new(limit_i64));
725 data_params.push(Box::new(offset_i64));
726
727 let limit_idx = data_params.len() - 1;
728 let offset_idx = data_params.len();
729
730 let columns = "id, namespace, kind, entity_type, name, description, properties, tags, \
731 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref";
732 let data_sql = if filter.names_ci.is_empty() {
733 format!(
734 "SELECT {columns} FROM entities{where_sql} \
735 ORDER BY {order_by} LIMIT ?{limit_idx} OFFSET ?{offset_idx}"
736 )
737 } else {
738 build_candidate_entity_query(
739 columns,
740 &where_sql,
741 &candidate_param_indices,
742 &order_by,
743 limit_idx,
744 offset_idx,
745 )
746 };
747
748 let mut stmt = conn.prepare(&data_sql)?;
749 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
750 data_params.iter().map(|p| p.as_ref()).collect();
751 let rows = stmt.query_map(param_refs.as_slice(), read_entity)?;
752
753 let mut items = Vec::new();
754 for row in rows {
755 items.push(row?);
756 }
757
758 Ok(Page { items, total })
759 })
760 .await
761 }
762
763 async fn get_entity_including_deleted(&self, id: Uuid) -> Result<Option<Entity>, StorageError> {
764 let id_str = id.to_string();
765
766 self.with_reader("get_entity_including_deleted", move |conn| {
767 let mut stmt = conn.prepare(
768 "SELECT id, namespace, kind, entity_type, name, description, properties, tags, \
769 created_at, updated_at, deleted_at, merged_into, merge_event_id, content_ref \
770 FROM entities WHERE id = ?1",
771 )?;
772 let mut rows = stmt.query(rusqlite::params![id_str])?;
773 match rows.next()? {
774 Some(row) => Ok(Some(read_entity(row)?)),
775 None => Ok(None),
776 }
777 })
778 .await
779 }
780
781 async fn count_entities(
782 &self,
783 namespace: &str,
784 filter: EntityFilter,
785 ) -> Result<u64, StorageError> {
786 let namespace = namespace.to_string();
787
788 self.with_reader("count_entities", move |conn| {
789 let (where_sql, params) = build_entity_where(&namespace, &filter);
790 let sql = format!("SELECT COUNT(*) FROM entities{}", where_sql);
791 let mut stmt = conn.prepare(&sql)?;
792 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
793 params.iter().map(|p| p.as_ref()).collect();
794 let count: i64 = stmt.query_row(param_refs.as_slice(), |row| row.get(0))?;
795 Ok(count as u64)
796 })
797 .await
798 }
799}
800
801const ENTITIES_DDL: &str = include_str!("../../sql/entities-ddl.sql");
806
807pub(crate) fn ensure_entities_schema(conn: &rusqlite::Connection) -> Result<(), rusqlite::Error> {
808 conn.execute_batch(ENTITIES_DDL)
809}
810
811#[cfg(test)]
812#[path = "entity_tests.rs"]
813mod tests;