1use crate::aggregator::{AliasAggregator, SourceSpec};
34use crate::error::MiniAppError;
35use rusqlite::OptionalExtension;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38
39#[derive(
48 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
49)]
50#[serde(rename_all = "lowercase")]
51pub enum AliasScope {
52 Project,
55 User,
59}
60
61#[derive(Debug, Clone)]
68pub struct AliasRecord {
69 pub name: String,
71 pub sources: SourceSpec,
73 pub aggregator: Option<AliasAggregator>,
75 pub filter: String,
77 pub default_limit: Option<u32>,
79 pub description: Option<String>,
81 pub params_schema: Option<String>,
84 pub fields: Option<String>,
89 pub scope: Option<AliasScope>,
93}
94
95impl AliasRecord {
96 #[allow(clippy::too_many_arguments)]
110 pub fn new(
111 name: impl Into<String>,
112 sources: SourceSpec,
113 aggregator: Option<AliasAggregator>,
114 filter: impl Into<String>,
115 default_limit: Option<u32>,
116 description: Option<String>,
117 params_schema: Option<String>,
118 fields: Option<String>,
119 ) -> Self {
120 Self {
121 name: name.into(),
122 sources,
123 aggregator,
124 filter: filter.into(),
125 default_limit,
126 description,
127 params_schema,
128 fields,
129 scope: None,
130 }
131 }
132}
133
134const CREATE_GLOBAL_ALIASES_SQL: &str = "
137 CREATE TABLE IF NOT EXISTS _global_aliases (
138 name TEXT PRIMARY KEY,
139 sources_json TEXT NOT NULL,
140 aggregator_json TEXT,
141 filter TEXT NOT NULL,
142 default_limit INTEGER,
143 description TEXT,
144 params_schema TEXT,
145 fields TEXT
146 )
147";
148
149pub const LEGACY_PER_TABLE_ALIASES_SQL: &str = "
152 CREATE TABLE IF NOT EXISTS _aliases (
153 name TEXT PRIMARY KEY,
154 filter TEXT NOT NULL,
155 default_limit INTEGER,
156 description TEXT,
157 params_schema TEXT
158 )
159";
160
161type LegacyAliasRow = (String, String, Option<u32>, Option<String>, Option<String>);
166
167pub struct GlobalAliasStorage {
172 project_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
173 user_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
174 project_path: Option<PathBuf>,
175 user_path: Option<PathBuf>,
176}
177
178impl std::fmt::Debug for GlobalAliasStorage {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 f.debug_struct("GlobalAliasStorage")
184 .field("project_path", &self.project_path)
185 .field("user_path", &self.user_path)
186 .field("project_mounted", &self.project_conn.is_some())
187 .field("user_mounted", &self.user_conn.is_some())
188 .finish()
189 }
190}
191
192impl GlobalAliasStorage {
193 pub fn open(project_dir: Option<&Path>, user_dir: Option<&Path>) -> Result<Self, MiniAppError> {
206 if project_dir.is_none() && user_dir.is_none() {
207 return Err(MiniAppError::Config(
208 "GlobalAliasStorage::open requires at least one of project_dir / user_dir".into(),
209 ));
210 }
211 let project = project_dir.map(open_scope_db).transpose()?;
212 let user = user_dir.map(open_scope_db).transpose()?;
213 Ok(Self {
214 project_conn: project.as_ref().map(|(c, _)| Arc::clone(c)),
215 user_conn: user.as_ref().map(|(c, _)| Arc::clone(c)),
216 project_path: project.map(|(_, p)| p),
217 user_path: user.map(|(_, p)| p),
218 })
219 }
220
221 #[cfg(test)]
223 pub fn open_in_memory() -> Result<Self, MiniAppError> {
224 let conn = rusqlite::Connection::open_in_memory()?;
225 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
226 Ok(Self {
227 project_conn: Some(Arc::new(Mutex::new(conn))),
228 user_conn: None,
229 project_path: None,
230 user_path: None,
231 })
232 }
233
234 pub fn path_for_scope(&self, scope: AliasScope) -> Option<&Path> {
237 match scope {
238 AliasScope::Project => self.project_path.as_deref(),
239 AliasScope::User => self.user_path.as_deref(),
240 }
241 }
242
243 fn conn_for_scope(
244 &self,
245 scope: AliasScope,
246 ) -> Result<Arc<Mutex<rusqlite::Connection>>, MiniAppError> {
247 let opt = match scope {
248 AliasScope::Project => self.project_conn.as_ref(),
249 AliasScope::User => self.user_conn.as_ref(),
250 };
251 opt.map(Arc::clone).ok_or_else(|| {
252 MiniAppError::Config(format!("GlobalAliasStorage scope {scope:?} is not mounted"))
253 })
254 }
255
256 pub async fn alias_create(
265 &self,
266 scope: AliasScope,
267 record: AliasRecord,
268 ) -> Result<(), MiniAppError> {
269 let conn = self.conn_for_scope(scope)?;
270 let sources_json = serde_json::to_string(&record.sources).map_err(|e| {
271 MiniAppError::Schema(format!(
272 "serialise sources for alias '{}': {e}",
273 record.name
274 ))
275 })?;
276 let aggregator_json = match &record.aggregator {
277 Some(agg) => Some(serde_json::to_string(agg).map_err(|e| {
278 MiniAppError::Schema(format!(
279 "serialise aggregator for alias '{}': {e}",
280 record.name
281 ))
282 })?),
283 None => None,
284 };
285 let name = record.name.clone();
286 let filter = record.filter.clone();
287 let default_limit = record.default_limit;
288 let description = record.description.clone();
289 let params_schema = record.params_schema.clone();
290 let fields = record.fields.clone();
291 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
292 let conn = conn
293 .lock()
294 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
295 conn.execute(
296 "INSERT OR IGNORE INTO _global_aliases \
297 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields) \
298 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
299 rusqlite::params![
300 name,
301 sources_json,
302 aggregator_json,
303 filter,
304 default_limit,
305 description,
306 params_schema,
307 fields,
308 ],
309 )?;
310 if conn.changes() == 0 {
311 return Err(MiniAppError::AliasAlreadyExists { name });
312 }
313 Ok(())
314 })
315 .await
316 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
317 }
318
319 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
323 if let Some(rec) = self.alias_get_scope(AliasScope::Project, name).await? {
324 return Ok(rec);
325 }
326 if let Some(rec) = self.alias_get_scope(AliasScope::User, name).await? {
327 return Ok(rec);
328 }
329 Err(MiniAppError::AliasNotFound {
330 name: name.to_string(),
331 })
332 }
333
334 pub async fn alias_get_scope(
339 &self,
340 scope: AliasScope,
341 name: &str,
342 ) -> Result<Option<AliasRecord>, MiniAppError> {
343 let conn = match scope {
344 AliasScope::Project => self.project_conn.as_ref(),
345 AliasScope::User => self.user_conn.as_ref(),
346 };
347 let Some(conn) = conn.map(Arc::clone) else {
348 return Ok(None);
349 };
350 let name_owned = name.to_string();
351 tokio::task::spawn_blocking(move || -> Result<Option<AliasRecord>, MiniAppError> {
352 let conn = conn
353 .lock()
354 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
355 let mut stmt = conn.prepare(
356 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields \
357 FROM _global_aliases WHERE name = ?1",
358 )?;
359 let row = stmt
360 .query_row(rusqlite::params![name_owned], extract_row)
361 .optional()?;
362 match row {
363 Some(mut rec) => {
364 rec.scope = Some(scope);
365 Ok(Some(rec))
366 }
367 None => Ok(None),
368 }
369 })
370 .await
371 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
372 }
373
374 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
378 let project = match self.project_conn.as_ref() {
379 Some(c) => list_scope(Arc::clone(c), AliasScope::Project).await?,
380 None => Vec::new(),
381 };
382 let user = match self.user_conn.as_ref() {
383 Some(c) => list_scope(Arc::clone(c), AliasScope::User).await?,
384 None => Vec::new(),
385 };
386 let mut merged: std::collections::BTreeMap<String, AliasRecord> =
387 std::collections::BTreeMap::new();
388 for rec in user {
391 merged.insert(rec.name.clone(), rec);
392 }
393 for rec in project {
394 merged.insert(rec.name.clone(), rec);
395 }
396 Ok(merged.into_values().collect())
397 }
398
399 pub async fn alias_delete(&self, scope: AliasScope, name: &str) -> Result<(), MiniAppError> {
407 let conn = self.conn_for_scope(scope)?;
408 let name_owned = name.to_string();
409 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
410 let conn = conn
411 .lock()
412 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
413 let affected = conn.execute(
414 "DELETE FROM _global_aliases WHERE name = ?1",
415 rusqlite::params![name_owned],
416 )?;
417 if affected == 0 {
418 return Err(MiniAppError::AliasNotFound { name: name_owned });
419 }
420 Ok(())
421 })
422 .await
423 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
424 }
425
426 pub async fn migrate_from_per_table(
444 &self,
445 target_scope: AliasScope,
446 per_table: Vec<(String, Arc<Mutex<rusqlite::Connection>>)>,
447 ) -> Result<usize, MiniAppError> {
448 let dest = self.conn_for_scope(target_scope).map_err(|_| {
449 MiniAppError::Config(format!(
450 "GlobalAliasStorage::migrate_from_per_table requires {target_scope:?} scope to be mounted"
451 ))
452 })?;
453 tokio::task::spawn_blocking(move || -> Result<usize, MiniAppError> {
454 let mut migrated = 0usize;
455 for (table_name, src_conn) in per_table {
456 let rows: Vec<LegacyAliasRow> = {
457 let src = src_conn
458 .lock()
459 .map_err(|_| MiniAppError::Schema("source mutex poisoned".into()))?;
460 let mut stmt = src.prepare(
461 "SELECT name, filter, default_limit, description, params_schema \
462 FROM _aliases ORDER BY name ASC",
463 )?;
464 stmt.query_map([], |row| {
465 Ok((
466 row.get::<_, String>(0)?,
467 row.get::<_, String>(1)?,
468 row.get::<_, Option<u32>>(2)?,
469 row.get::<_, Option<String>>(3)?,
470 row.get::<_, Option<String>>(4)?,
471 ))
472 })?
473 .collect::<Result<Vec<_>, _>>()?
474 };
475 if rows.is_empty() {
476 continue;
477 }
478 let sources_json = serde_json::to_string(&SourceSpec::Single(table_name.clone()))
479 .map_err(|e| {
480 MiniAppError::Schema(format!(
481 "serialise Single source for table '{table_name}' during migration: {e}"
482 ))
483 })?;
484 let dst = dest
485 .lock()
486 .map_err(|_| MiniAppError::Schema("dest mutex poisoned".into()))?;
487 for (name, filter, default_limit, description, params_schema) in rows {
488 dst.execute(
489 "INSERT OR IGNORE INTO _global_aliases \
490 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields) \
491 VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL)",
492 rusqlite::params![
493 name,
494 sources_json,
495 filter,
496 default_limit,
497 description,
498 params_schema,
499 ],
500 )?;
501 if dst.changes() > 0 {
502 migrated += 1;
503 }
504 }
505 }
506 Ok(migrated)
507 })
508 .await
509 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
510 }
511}
512
513fn open_scope_db(dir: &Path) -> Result<(Arc<Mutex<rusqlite::Connection>>, PathBuf), MiniAppError> {
514 std::fs::create_dir_all(dir)?;
515 let db_path = dir.join("_global.db");
516 let conn = rusqlite::Connection::open(&db_path)?;
517 conn.pragma_update(None, "journal_mode", "WAL")?;
524 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
525 let has_fields: bool = {
532 let mut stmt = conn.prepare(
533 "SELECT COUNT(*) FROM pragma_table_info('_global_aliases') WHERE name = 'fields'",
534 )?;
535 stmt.query_row([], |row| row.get::<_, i64>(0))
536 .map(|n| n > 0)?
537 };
538 if !has_fields {
539 conn.execute_batch("ALTER TABLE _global_aliases ADD COLUMN fields TEXT")?;
540 }
541 Ok((Arc::new(Mutex::new(conn)), db_path))
542}
543
544fn extract_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AliasRecord> {
545 let name: String = row.get(0)?;
546 let sources_json: String = row.get(1)?;
547 let aggregator_json: Option<String> = row.get(2)?;
548 let filter: String = row.get(3)?;
549 let default_limit: Option<u32> = row.get(4)?;
550 let description: Option<String> = row.get(5)?;
551 let params_schema: Option<String> = row.get(6)?;
552 let fields: Option<String> = row.get(7)?;
553 let sources: SourceSpec = serde_json::from_str(&sources_json).map_err(|e| {
554 rusqlite::Error::FromSqlConversionFailure(
555 1,
556 rusqlite::types::Type::Text,
557 Box::new(std::io::Error::other(format!(
558 "deserialise sources_json: {e}"
559 ))),
560 )
561 })?;
562 let aggregator: Option<AliasAggregator> = match aggregator_json {
563 Some(s) => Some(serde_json::from_str(&s).map_err(|e| {
564 rusqlite::Error::FromSqlConversionFailure(
565 2,
566 rusqlite::types::Type::Text,
567 Box::new(std::io::Error::other(format!(
568 "deserialise aggregator_json: {e}"
569 ))),
570 )
571 })?),
572 None => None,
573 };
574 Ok(AliasRecord {
575 name,
576 sources,
577 aggregator,
578 filter,
579 default_limit,
580 description,
581 params_schema,
582 fields,
583 scope: None,
584 })
585}
586
587async fn list_scope(
588 conn: Arc<Mutex<rusqlite::Connection>>,
589 scope: AliasScope,
590) -> Result<Vec<AliasRecord>, MiniAppError> {
591 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
592 let conn = conn
593 .lock()
594 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
595 let mut stmt = conn.prepare(
596 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields \
597 FROM _global_aliases ORDER BY name ASC",
598 )?;
599 let rows = stmt
600 .query_map([], extract_row)?
601 .collect::<Result<Vec<_>, _>>()?;
602 Ok(rows
603 .into_iter()
604 .map(|mut r| {
605 r.scope = Some(scope);
606 r
607 })
608 .collect())
609 })
610 .await
611 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
612}
613
614#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::aggregator::AliasAggregator;
622 use tempfile::TempDir;
623
624 fn sample_record(name: &str) -> AliasRecord {
625 AliasRecord::new(
626 name,
627 SourceSpec::Single("rows".into()),
628 None,
629 r#"{"type":"eq","field":"status","value":"open"}"#,
630 Some(20),
631 Some("sample".into()),
632 None,
633 None,
634 )
635 }
636
637 #[tokio::test]
638 async fn create_get_roundtrip_in_memory() {
639 let storage = GlobalAliasStorage::open_in_memory().unwrap();
640 storage
641 .alias_create(AliasScope::Project, sample_record("foo"))
642 .await
643 .unwrap();
644 let got = storage.alias_get("foo").await.unwrap();
645 assert_eq!(got.name, "foo");
646 assert!(matches!(got.sources, SourceSpec::Single(ref t) if t == "rows"));
647 assert!(got.aggregator.is_none());
648 assert_eq!(got.default_limit, Some(20));
649 assert_eq!(got.description.as_deref(), Some("sample"));
650 assert_eq!(got.scope, Some(AliasScope::Project));
651 }
652
653 #[tokio::test]
654 async fn create_persists_sources_multi_and_aggregator_groupby() {
655 let storage = GlobalAliasStorage::open_in_memory().unwrap();
656 let rec = AliasRecord::new(
657 "by_tag",
658 SourceSpec::Multi(vec!["a".into(), "b".into()]),
659 Some(AliasAggregator::GroupBy {
660 by_field: "tag".into(),
661 having: None,
662 inner: Some(Box::new(AliasAggregator::Sum {
663 field: "value".into(),
664 })),
665 }),
666 "{}".to_string(),
667 None,
668 None,
669 None,
670 None,
671 );
672 storage
673 .alias_create(AliasScope::Project, rec)
674 .await
675 .unwrap();
676 let got = storage.alias_get("by_tag").await.unwrap();
677 match got.sources {
678 SourceSpec::Multi(v) => assert_eq!(v, vec!["a".to_string(), "b".to_string()]),
679 other => panic!("expected Multi, got {other:?}"),
680 }
681 match got.aggregator {
682 Some(AliasAggregator::GroupBy {
683 by_field,
684 inner: Some(inner),
685 ..
686 }) => {
687 assert_eq!(by_field, "tag");
688 assert!(matches!(*inner, AliasAggregator::Sum { ref field } if field == "value"));
689 }
690 other => panic!("expected GroupBy+Sum, got {other:?}"),
691 }
692 }
693
694 #[tokio::test]
695 async fn create_persists_pattern_source() {
696 let storage = GlobalAliasStorage::open_in_memory().unwrap();
697 let rec = AliasRecord::new(
698 "shi_all",
699 SourceSpec::Pattern("shi_*".into()),
700 Some(AliasAggregator::Count),
701 "{}".to_string(),
702 None,
703 None,
704 None,
705 None,
706 );
707 storage
708 .alias_create(AliasScope::Project, rec)
709 .await
710 .unwrap();
711 let got = storage.alias_get("shi_all").await.unwrap();
712 match got.sources {
713 SourceSpec::Pattern(p) => assert_eq!(p, "shi_*"),
714 other => panic!("expected Pattern, got {other:?}"),
715 }
716 assert!(matches!(got.aggregator, Some(AliasAggregator::Count)));
717 }
718
719 #[tokio::test]
720 async fn create_duplicate_returns_already_exists() {
721 let storage = GlobalAliasStorage::open_in_memory().unwrap();
722 storage
723 .alias_create(AliasScope::Project, sample_record("dup"))
724 .await
725 .unwrap();
726 let err = storage
727 .alias_create(AliasScope::Project, sample_record("dup"))
728 .await
729 .expect_err("expected AliasAlreadyExists");
730 assert_eq!(err.code(), crate::error::codes::ALIAS_ALREADY_EXISTS);
731 }
732
733 #[tokio::test]
734 async fn get_unknown_returns_not_found() {
735 let storage = GlobalAliasStorage::open_in_memory().unwrap();
736 let err = storage
737 .alias_get("nope")
738 .await
739 .expect_err("expected AliasNotFound");
740 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
741 }
742
743 #[tokio::test]
744 async fn delete_round_trip_then_not_found() {
745 let storage = GlobalAliasStorage::open_in_memory().unwrap();
746 storage
747 .alias_create(AliasScope::Project, sample_record("to_delete"))
748 .await
749 .unwrap();
750 storage
751 .alias_delete(AliasScope::Project, "to_delete")
752 .await
753 .unwrap();
754 let err = storage
755 .alias_delete(AliasScope::Project, "to_delete")
756 .await
757 .expect_err("second delete should fail");
758 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
759 }
760
761 #[tokio::test]
762 async fn list_returns_sorted_ascending_by_name() {
763 let storage = GlobalAliasStorage::open_in_memory().unwrap();
764 for n in ["c", "a", "b"] {
765 storage
766 .alias_create(AliasScope::Project, sample_record(n))
767 .await
768 .unwrap();
769 }
770 let names: Vec<String> = storage
771 .alias_list()
772 .await
773 .unwrap()
774 .into_iter()
775 .map(|r| r.name)
776 .collect();
777 assert_eq!(names, vec!["a", "b", "c"]);
778 }
779
780 #[tokio::test]
781 async fn project_overrides_user_on_name_collision() {
782 let project_dir = TempDir::new().unwrap();
783 let user_dir = TempDir::new().unwrap();
784 let storage =
785 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
786 let mut user_rec = sample_record("shared");
787 user_rec.description = Some("user-version".into());
788 storage
789 .alias_create(AliasScope::User, user_rec)
790 .await
791 .unwrap();
792 let mut project_rec = sample_record("shared");
793 project_rec.description = Some("project-version".into());
794 storage
795 .alias_create(AliasScope::Project, project_rec)
796 .await
797 .unwrap();
798
799 let got = storage.alias_get("shared").await.unwrap();
801 assert_eq!(got.description.as_deref(), Some("project-version"));
802 assert_eq!(got.scope, Some(AliasScope::Project));
803
804 let all = storage.alias_list().await.unwrap();
806 assert_eq!(all.len(), 1);
807 assert_eq!(all[0].description.as_deref(), Some("project-version"));
808 assert_eq!(all[0].scope, Some(AliasScope::Project));
809 }
810
811 #[tokio::test]
812 async fn user_only_alias_returned_when_no_project_collision() {
813 let project_dir = TempDir::new().unwrap();
814 let user_dir = TempDir::new().unwrap();
815 let storage =
816 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
817 let user_only = sample_record("user_only");
818 storage
819 .alias_create(AliasScope::User, user_only)
820 .await
821 .unwrap();
822 let got = storage.alias_get("user_only").await.unwrap();
823 assert_eq!(got.scope, Some(AliasScope::User));
824 }
825
826 #[tokio::test]
827 async fn open_persists_across_reopen() {
828 let project_dir = TempDir::new().unwrap();
829 {
830 let storage = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
831 storage
832 .alias_create(AliasScope::Project, sample_record("persisted"))
833 .await
834 .unwrap();
835 }
836 let reopened = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
837 let got = reopened.alias_get("persisted").await.unwrap();
838 assert_eq!(got.name, "persisted");
839 }
840
841 #[tokio::test]
842 async fn open_requires_at_least_one_scope() {
843 let err = GlobalAliasStorage::open(None, None)
844 .expect_err("expected Config error when both dirs are None");
845 assert_eq!(err.code(), crate::error::codes::CONFIG_ERROR);
846 }
847
848 #[tokio::test]
849 async fn migrate_from_per_table_lossless_roundtrip() {
850 let conn_a = rusqlite::Connection::open_in_memory().unwrap();
852 conn_a.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
853 conn_a
854 .execute(
855 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
856 VALUES (?1, ?2, ?3, ?4, ?5)",
857 rusqlite::params!["a_open", "{}", 50i64, "alpha", Option::<String>::None],
858 )
859 .unwrap();
860 conn_a
861 .execute(
862 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
863 VALUES (?1, ?2, ?3, ?4, ?5)",
864 rusqlite::params![
865 "a_closed",
866 "{}",
867 Option::<i64>::None,
868 Option::<String>::None,
869 Some("[\"x\"]".to_string())
870 ],
871 )
872 .unwrap();
873
874 let conn_b = rusqlite::Connection::open_in_memory().unwrap();
875 conn_b.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
876 conn_b
877 .execute(
878 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
879 VALUES (?1, ?2, ?3, ?4, ?5)",
880 rusqlite::params!["b_recent", "{}", 10i64, "bravo", Option::<String>::None],
881 )
882 .unwrap();
883
884 let storage = GlobalAliasStorage::open_in_memory().unwrap();
885 let migrated = storage
886 .migrate_from_per_table(
887 AliasScope::Project,
888 vec![
889 ("table_a".to_string(), Arc::new(Mutex::new(conn_a))),
890 ("table_b".to_string(), Arc::new(Mutex::new(conn_b))),
891 ],
892 )
893 .await
894 .unwrap();
895 assert_eq!(migrated, 3);
896
897 let all = storage.alias_list().await.unwrap();
900 assert_eq!(all.len(), 3);
901 let a_open = all.iter().find(|r| r.name == "a_open").unwrap();
902 assert!(matches!(a_open.sources, SourceSpec::Single(ref t) if t == "table_a"));
903 assert!(a_open.aggregator.is_none());
904 assert_eq!(a_open.default_limit, Some(50));
905 assert_eq!(a_open.description.as_deref(), Some("alpha"));
906 assert_eq!(a_open.params_schema, None);
907
908 let a_closed = all.iter().find(|r| r.name == "a_closed").unwrap();
909 assert!(matches!(a_closed.sources, SourceSpec::Single(ref t) if t == "table_a"));
910 assert_eq!(a_closed.params_schema.as_deref(), Some("[\"x\"]"));
911
912 let b_recent = all.iter().find(|r| r.name == "b_recent").unwrap();
913 assert!(matches!(b_recent.sources, SourceSpec::Single(ref t) if t == "table_b"));
914 assert_eq!(b_recent.default_limit, Some(10));
915 }
916
917 #[tokio::test]
918 async fn migrate_from_per_table_idempotent_on_second_run() {
919 let conn = rusqlite::Connection::open_in_memory().unwrap();
920 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
921 conn.execute(
922 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
923 VALUES (?1, ?2, ?3, ?4, ?5)",
924 rusqlite::params![
925 "x",
926 "{}",
927 Option::<i64>::None,
928 Option::<String>::None,
929 Option::<String>::None
930 ],
931 )
932 .unwrap();
933 let conn_arc = Arc::new(Mutex::new(conn));
934
935 let storage = GlobalAliasStorage::open_in_memory().unwrap();
936 let first = storage
937 .migrate_from_per_table(
938 AliasScope::Project,
939 vec![("t".to_string(), Arc::clone(&conn_arc))],
940 )
941 .await
942 .unwrap();
943 let second = storage
944 .migrate_from_per_table(
945 AliasScope::Project,
946 vec![("t".to_string(), Arc::clone(&conn_arc))],
947 )
948 .await
949 .unwrap();
950 assert_eq!(first, 1);
951 assert_eq!(second, 0);
952 let all = storage.alias_list().await.unwrap();
953 assert_eq!(all.len(), 1);
954 }
955
956 #[tokio::test]
957 async fn migrate_from_per_table_skips_collision_with_existing_global() {
958 let storage = GlobalAliasStorage::open_in_memory().unwrap();
962 let mut existing = sample_record("shared");
963 existing.description = Some("existing-global".into());
964 storage
965 .alias_create(AliasScope::Project, existing)
966 .await
967 .unwrap();
968
969 let conn = rusqlite::Connection::open_in_memory().unwrap();
970 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
971 conn.execute(
972 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
973 VALUES (?1, ?2, ?3, ?4, ?5)",
974 rusqlite::params![
975 "shared",
976 "{}",
977 Option::<i64>::None,
978 Some("legacy-per-table".to_string()),
979 Option::<String>::None
980 ],
981 )
982 .unwrap();
983 let migrated = storage
984 .migrate_from_per_table(
985 AliasScope::Project,
986 vec![("ignored_table".to_string(), Arc::new(Mutex::new(conn)))],
987 )
988 .await
989 .unwrap();
990 assert_eq!(migrated, 0);
991 let got = storage.alias_get("shared").await.unwrap();
992 assert_eq!(got.description.as_deref(), Some("existing-global"));
993 }
994}