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(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AliasScope {
44 Project,
46 User,
48}
49
50#[derive(Debug, Clone)]
57pub struct AliasRecord {
58 pub name: String,
60 pub sources: SourceSpec,
62 pub aggregator: Option<AliasAggregator>,
64 pub filter: String,
66 pub default_limit: Option<u32>,
68 pub description: Option<String>,
70 pub params_schema: Option<String>,
73 pub scope: Option<AliasScope>,
77}
78
79impl AliasRecord {
80 pub fn new(
83 name: impl Into<String>,
84 sources: SourceSpec,
85 aggregator: Option<AliasAggregator>,
86 filter: impl Into<String>,
87 default_limit: Option<u32>,
88 description: Option<String>,
89 params_schema: Option<String>,
90 ) -> Self {
91 Self {
92 name: name.into(),
93 sources,
94 aggregator,
95 filter: filter.into(),
96 default_limit,
97 description,
98 params_schema,
99 scope: None,
100 }
101 }
102}
103
104const CREATE_GLOBAL_ALIASES_SQL: &str = "
107 CREATE TABLE IF NOT EXISTS _global_aliases (
108 name TEXT PRIMARY KEY,
109 sources_json TEXT NOT NULL,
110 aggregator_json TEXT,
111 filter TEXT NOT NULL,
112 default_limit INTEGER,
113 description TEXT,
114 params_schema TEXT
115 )
116";
117
118pub const LEGACY_PER_TABLE_ALIASES_SQL: &str = "
121 CREATE TABLE IF NOT EXISTS _aliases (
122 name TEXT PRIMARY KEY,
123 filter TEXT NOT NULL,
124 default_limit INTEGER,
125 description TEXT,
126 params_schema TEXT
127 )
128";
129
130type LegacyAliasRow = (String, String, Option<u32>, Option<String>, Option<String>);
135
136pub struct GlobalAliasStorage {
141 project_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
142 user_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
143 project_path: Option<PathBuf>,
144 user_path: Option<PathBuf>,
145}
146
147impl std::fmt::Debug for GlobalAliasStorage {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.debug_struct("GlobalAliasStorage")
153 .field("project_path", &self.project_path)
154 .field("user_path", &self.user_path)
155 .field("project_mounted", &self.project_conn.is_some())
156 .field("user_mounted", &self.user_conn.is_some())
157 .finish()
158 }
159}
160
161impl GlobalAliasStorage {
162 pub fn open(project_dir: Option<&Path>, user_dir: Option<&Path>) -> Result<Self, MiniAppError> {
175 if project_dir.is_none() && user_dir.is_none() {
176 return Err(MiniAppError::Config(
177 "GlobalAliasStorage::open requires at least one of project_dir / user_dir".into(),
178 ));
179 }
180 let project = project_dir.map(open_scope_db).transpose()?;
181 let user = user_dir.map(open_scope_db).transpose()?;
182 Ok(Self {
183 project_conn: project.as_ref().map(|(c, _)| Arc::clone(c)),
184 user_conn: user.as_ref().map(|(c, _)| Arc::clone(c)),
185 project_path: project.map(|(_, p)| p),
186 user_path: user.map(|(_, p)| p),
187 })
188 }
189
190 #[cfg(test)]
192 pub fn open_in_memory() -> Result<Self, MiniAppError> {
193 let conn = rusqlite::Connection::open_in_memory()?;
194 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
195 Ok(Self {
196 project_conn: Some(Arc::new(Mutex::new(conn))),
197 user_conn: None,
198 project_path: None,
199 user_path: None,
200 })
201 }
202
203 pub fn path_for_scope(&self, scope: AliasScope) -> Option<&Path> {
206 match scope {
207 AliasScope::Project => self.project_path.as_deref(),
208 AliasScope::User => self.user_path.as_deref(),
209 }
210 }
211
212 fn conn_for_scope(
213 &self,
214 scope: AliasScope,
215 ) -> Result<Arc<Mutex<rusqlite::Connection>>, MiniAppError> {
216 let opt = match scope {
217 AliasScope::Project => self.project_conn.as_ref(),
218 AliasScope::User => self.user_conn.as_ref(),
219 };
220 opt.map(Arc::clone).ok_or_else(|| {
221 MiniAppError::Config(format!("GlobalAliasStorage scope {scope:?} is not mounted"))
222 })
223 }
224
225 pub async fn alias_create(
234 &self,
235 scope: AliasScope,
236 record: AliasRecord,
237 ) -> Result<(), MiniAppError> {
238 let conn = self.conn_for_scope(scope)?;
239 let sources_json = serde_json::to_string(&record.sources).map_err(|e| {
240 MiniAppError::Schema(format!(
241 "serialise sources for alias '{}': {e}",
242 record.name
243 ))
244 })?;
245 let aggregator_json = match &record.aggregator {
246 Some(agg) => Some(serde_json::to_string(agg).map_err(|e| {
247 MiniAppError::Schema(format!(
248 "serialise aggregator for alias '{}': {e}",
249 record.name
250 ))
251 })?),
252 None => None,
253 };
254 let name = record.name.clone();
255 let filter = record.filter.clone();
256 let default_limit = record.default_limit;
257 let description = record.description.clone();
258 let params_schema = record.params_schema.clone();
259 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
260 let conn = conn
261 .lock()
262 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
263 conn.execute(
264 "INSERT OR IGNORE INTO _global_aliases \
265 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema) \
266 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
267 rusqlite::params![
268 name,
269 sources_json,
270 aggregator_json,
271 filter,
272 default_limit,
273 description,
274 params_schema,
275 ],
276 )?;
277 if conn.changes() == 0 {
278 return Err(MiniAppError::AliasAlreadyExists { name });
279 }
280 Ok(())
281 })
282 .await
283 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
284 }
285
286 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
290 if let Some(rec) = self.alias_get_scope(AliasScope::Project, name).await? {
291 return Ok(rec);
292 }
293 if let Some(rec) = self.alias_get_scope(AliasScope::User, name).await? {
294 return Ok(rec);
295 }
296 Err(MiniAppError::AliasNotFound {
297 name: name.to_string(),
298 })
299 }
300
301 pub async fn alias_get_scope(
306 &self,
307 scope: AliasScope,
308 name: &str,
309 ) -> Result<Option<AliasRecord>, MiniAppError> {
310 let conn = match scope {
311 AliasScope::Project => self.project_conn.as_ref(),
312 AliasScope::User => self.user_conn.as_ref(),
313 };
314 let Some(conn) = conn.map(Arc::clone) else {
315 return Ok(None);
316 };
317 let name_owned = name.to_string();
318 tokio::task::spawn_blocking(move || -> Result<Option<AliasRecord>, MiniAppError> {
319 let conn = conn
320 .lock()
321 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
322 let mut stmt = conn.prepare(
323 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema \
324 FROM _global_aliases WHERE name = ?1",
325 )?;
326 let row = stmt
327 .query_row(rusqlite::params![name_owned], extract_row)
328 .optional()?;
329 match row {
330 Some(mut rec) => {
331 rec.scope = Some(scope);
332 Ok(Some(rec))
333 }
334 None => Ok(None),
335 }
336 })
337 .await
338 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
339 }
340
341 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
345 let project = match self.project_conn.as_ref() {
346 Some(c) => list_scope(Arc::clone(c), AliasScope::Project).await?,
347 None => Vec::new(),
348 };
349 let user = match self.user_conn.as_ref() {
350 Some(c) => list_scope(Arc::clone(c), AliasScope::User).await?,
351 None => Vec::new(),
352 };
353 let mut merged: std::collections::BTreeMap<String, AliasRecord> =
354 std::collections::BTreeMap::new();
355 for rec in user {
358 merged.insert(rec.name.clone(), rec);
359 }
360 for rec in project {
361 merged.insert(rec.name.clone(), rec);
362 }
363 Ok(merged.into_values().collect())
364 }
365
366 pub async fn alias_delete(&self, scope: AliasScope, name: &str) -> Result<(), MiniAppError> {
374 let conn = self.conn_for_scope(scope)?;
375 let name_owned = name.to_string();
376 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
377 let conn = conn
378 .lock()
379 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
380 let affected = conn.execute(
381 "DELETE FROM _global_aliases WHERE name = ?1",
382 rusqlite::params![name_owned],
383 )?;
384 if affected == 0 {
385 return Err(MiniAppError::AliasNotFound { name: name_owned });
386 }
387 Ok(())
388 })
389 .await
390 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
391 }
392
393 pub async fn migrate_from_per_table(
411 &self,
412 target_scope: AliasScope,
413 per_table: Vec<(String, Arc<Mutex<rusqlite::Connection>>)>,
414 ) -> Result<usize, MiniAppError> {
415 let dest = self.conn_for_scope(target_scope).map_err(|_| {
416 MiniAppError::Config(format!(
417 "GlobalAliasStorage::migrate_from_per_table requires {target_scope:?} scope to be mounted"
418 ))
419 })?;
420 tokio::task::spawn_blocking(move || -> Result<usize, MiniAppError> {
421 let mut migrated = 0usize;
422 for (table_name, src_conn) in per_table {
423 let rows: Vec<LegacyAliasRow> = {
424 let src = src_conn
425 .lock()
426 .map_err(|_| MiniAppError::Schema("source mutex poisoned".into()))?;
427 let mut stmt = src.prepare(
428 "SELECT name, filter, default_limit, description, params_schema \
429 FROM _aliases ORDER BY name ASC",
430 )?;
431 stmt.query_map([], |row| {
432 Ok((
433 row.get::<_, String>(0)?,
434 row.get::<_, String>(1)?,
435 row.get::<_, Option<u32>>(2)?,
436 row.get::<_, Option<String>>(3)?,
437 row.get::<_, Option<String>>(4)?,
438 ))
439 })?
440 .collect::<Result<Vec<_>, _>>()?
441 };
442 if rows.is_empty() {
443 continue;
444 }
445 let sources_json = serde_json::to_string(&SourceSpec::Single(table_name.clone()))
446 .map_err(|e| {
447 MiniAppError::Schema(format!(
448 "serialise Single source for table '{table_name}' during migration: {e}"
449 ))
450 })?;
451 let dst = dest
452 .lock()
453 .map_err(|_| MiniAppError::Schema("dest mutex poisoned".into()))?;
454 for (name, filter, default_limit, description, params_schema) in rows {
455 dst.execute(
456 "INSERT OR IGNORE INTO _global_aliases \
457 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema) \
458 VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6)",
459 rusqlite::params![
460 name,
461 sources_json,
462 filter,
463 default_limit,
464 description,
465 params_schema,
466 ],
467 )?;
468 if dst.changes() > 0 {
469 migrated += 1;
470 }
471 }
472 }
473 Ok(migrated)
474 })
475 .await
476 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
477 }
478}
479
480fn open_scope_db(dir: &Path) -> Result<(Arc<Mutex<rusqlite::Connection>>, PathBuf), MiniAppError> {
481 std::fs::create_dir_all(dir)?;
482 let db_path = dir.join("_global.db");
483 let conn = rusqlite::Connection::open(&db_path)?;
484 conn.pragma_update(None, "journal_mode", "WAL")?;
491 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
492 Ok((Arc::new(Mutex::new(conn)), db_path))
493}
494
495fn extract_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AliasRecord> {
496 let name: String = row.get(0)?;
497 let sources_json: String = row.get(1)?;
498 let aggregator_json: Option<String> = row.get(2)?;
499 let filter: String = row.get(3)?;
500 let default_limit: Option<u32> = row.get(4)?;
501 let description: Option<String> = row.get(5)?;
502 let params_schema: Option<String> = row.get(6)?;
503 let sources: SourceSpec = serde_json::from_str(&sources_json).map_err(|e| {
504 rusqlite::Error::FromSqlConversionFailure(
505 1,
506 rusqlite::types::Type::Text,
507 Box::new(std::io::Error::other(format!(
508 "deserialise sources_json: {e}"
509 ))),
510 )
511 })?;
512 let aggregator: Option<AliasAggregator> = match aggregator_json {
513 Some(s) => Some(serde_json::from_str(&s).map_err(|e| {
514 rusqlite::Error::FromSqlConversionFailure(
515 2,
516 rusqlite::types::Type::Text,
517 Box::new(std::io::Error::other(format!(
518 "deserialise aggregator_json: {e}"
519 ))),
520 )
521 })?),
522 None => None,
523 };
524 Ok(AliasRecord {
525 name,
526 sources,
527 aggregator,
528 filter,
529 default_limit,
530 description,
531 params_schema,
532 scope: None,
533 })
534}
535
536async fn list_scope(
537 conn: Arc<Mutex<rusqlite::Connection>>,
538 scope: AliasScope,
539) -> Result<Vec<AliasRecord>, MiniAppError> {
540 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
541 let conn = conn
542 .lock()
543 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
544 let mut stmt = conn.prepare(
545 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema \
546 FROM _global_aliases ORDER BY name ASC",
547 )?;
548 let rows = stmt
549 .query_map([], extract_row)?
550 .collect::<Result<Vec<_>, _>>()?;
551 Ok(rows
552 .into_iter()
553 .map(|mut r| {
554 r.scope = Some(scope);
555 r
556 })
557 .collect())
558 })
559 .await
560 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
561}
562
563#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::aggregator::AliasAggregator;
571 use tempfile::TempDir;
572
573 fn sample_record(name: &str) -> AliasRecord {
574 AliasRecord::new(
575 name,
576 SourceSpec::Single("rows".into()),
577 None,
578 r#"{"type":"eq","field":"status","value":"open"}"#,
579 Some(20),
580 Some("sample".into()),
581 None,
582 )
583 }
584
585 #[tokio::test]
586 async fn create_get_roundtrip_in_memory() {
587 let storage = GlobalAliasStorage::open_in_memory().unwrap();
588 storage
589 .alias_create(AliasScope::Project, sample_record("foo"))
590 .await
591 .unwrap();
592 let got = storage.alias_get("foo").await.unwrap();
593 assert_eq!(got.name, "foo");
594 assert!(matches!(got.sources, SourceSpec::Single(ref t) if t == "rows"));
595 assert!(got.aggregator.is_none());
596 assert_eq!(got.default_limit, Some(20));
597 assert_eq!(got.description.as_deref(), Some("sample"));
598 assert_eq!(got.scope, Some(AliasScope::Project));
599 }
600
601 #[tokio::test]
602 async fn create_persists_sources_multi_and_aggregator_groupby() {
603 let storage = GlobalAliasStorage::open_in_memory().unwrap();
604 let rec = AliasRecord::new(
605 "by_tag",
606 SourceSpec::Multi(vec!["a".into(), "b".into()]),
607 Some(AliasAggregator::GroupBy {
608 by_field: "tag".into(),
609 having: None,
610 inner: Some(Box::new(AliasAggregator::Sum {
611 field: "value".into(),
612 })),
613 }),
614 "{}".to_string(),
615 None,
616 None,
617 None,
618 );
619 storage
620 .alias_create(AliasScope::Project, rec)
621 .await
622 .unwrap();
623 let got = storage.alias_get("by_tag").await.unwrap();
624 match got.sources {
625 SourceSpec::Multi(v) => assert_eq!(v, vec!["a".to_string(), "b".to_string()]),
626 other => panic!("expected Multi, got {other:?}"),
627 }
628 match got.aggregator {
629 Some(AliasAggregator::GroupBy {
630 by_field,
631 inner: Some(inner),
632 ..
633 }) => {
634 assert_eq!(by_field, "tag");
635 assert!(matches!(*inner, AliasAggregator::Sum { ref field } if field == "value"));
636 }
637 other => panic!("expected GroupBy+Sum, got {other:?}"),
638 }
639 }
640
641 #[tokio::test]
642 async fn create_persists_pattern_source() {
643 let storage = GlobalAliasStorage::open_in_memory().unwrap();
644 let rec = AliasRecord::new(
645 "shi_all",
646 SourceSpec::Pattern("shi_*".into()),
647 Some(AliasAggregator::Count),
648 "{}".to_string(),
649 None,
650 None,
651 None,
652 );
653 storage
654 .alias_create(AliasScope::Project, rec)
655 .await
656 .unwrap();
657 let got = storage.alias_get("shi_all").await.unwrap();
658 match got.sources {
659 SourceSpec::Pattern(p) => assert_eq!(p, "shi_*"),
660 other => panic!("expected Pattern, got {other:?}"),
661 }
662 assert!(matches!(got.aggregator, Some(AliasAggregator::Count)));
663 }
664
665 #[tokio::test]
666 async fn create_duplicate_returns_already_exists() {
667 let storage = GlobalAliasStorage::open_in_memory().unwrap();
668 storage
669 .alias_create(AliasScope::Project, sample_record("dup"))
670 .await
671 .unwrap();
672 let err = storage
673 .alias_create(AliasScope::Project, sample_record("dup"))
674 .await
675 .expect_err("expected AliasAlreadyExists");
676 assert_eq!(err.code(), crate::error::codes::ALIAS_ALREADY_EXISTS);
677 }
678
679 #[tokio::test]
680 async fn get_unknown_returns_not_found() {
681 let storage = GlobalAliasStorage::open_in_memory().unwrap();
682 let err = storage
683 .alias_get("nope")
684 .await
685 .expect_err("expected AliasNotFound");
686 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
687 }
688
689 #[tokio::test]
690 async fn delete_round_trip_then_not_found() {
691 let storage = GlobalAliasStorage::open_in_memory().unwrap();
692 storage
693 .alias_create(AliasScope::Project, sample_record("to_delete"))
694 .await
695 .unwrap();
696 storage
697 .alias_delete(AliasScope::Project, "to_delete")
698 .await
699 .unwrap();
700 let err = storage
701 .alias_delete(AliasScope::Project, "to_delete")
702 .await
703 .expect_err("second delete should fail");
704 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
705 }
706
707 #[tokio::test]
708 async fn list_returns_sorted_ascending_by_name() {
709 let storage = GlobalAliasStorage::open_in_memory().unwrap();
710 for n in ["c", "a", "b"] {
711 storage
712 .alias_create(AliasScope::Project, sample_record(n))
713 .await
714 .unwrap();
715 }
716 let names: Vec<String> = storage
717 .alias_list()
718 .await
719 .unwrap()
720 .into_iter()
721 .map(|r| r.name)
722 .collect();
723 assert_eq!(names, vec!["a", "b", "c"]);
724 }
725
726 #[tokio::test]
727 async fn project_overrides_user_on_name_collision() {
728 let project_dir = TempDir::new().unwrap();
729 let user_dir = TempDir::new().unwrap();
730 let storage =
731 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
732 let mut user_rec = sample_record("shared");
733 user_rec.description = Some("user-version".into());
734 storage
735 .alias_create(AliasScope::User, user_rec)
736 .await
737 .unwrap();
738 let mut project_rec = sample_record("shared");
739 project_rec.description = Some("project-version".into());
740 storage
741 .alias_create(AliasScope::Project, project_rec)
742 .await
743 .unwrap();
744
745 let got = storage.alias_get("shared").await.unwrap();
747 assert_eq!(got.description.as_deref(), Some("project-version"));
748 assert_eq!(got.scope, Some(AliasScope::Project));
749
750 let all = storage.alias_list().await.unwrap();
752 assert_eq!(all.len(), 1);
753 assert_eq!(all[0].description.as_deref(), Some("project-version"));
754 assert_eq!(all[0].scope, Some(AliasScope::Project));
755 }
756
757 #[tokio::test]
758 async fn user_only_alias_returned_when_no_project_collision() {
759 let project_dir = TempDir::new().unwrap();
760 let user_dir = TempDir::new().unwrap();
761 let storage =
762 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
763 let user_only = sample_record("user_only");
764 storage
765 .alias_create(AliasScope::User, user_only)
766 .await
767 .unwrap();
768 let got = storage.alias_get("user_only").await.unwrap();
769 assert_eq!(got.scope, Some(AliasScope::User));
770 }
771
772 #[tokio::test]
773 async fn open_persists_across_reopen() {
774 let project_dir = TempDir::new().unwrap();
775 {
776 let storage = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
777 storage
778 .alias_create(AliasScope::Project, sample_record("persisted"))
779 .await
780 .unwrap();
781 }
782 let reopened = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
783 let got = reopened.alias_get("persisted").await.unwrap();
784 assert_eq!(got.name, "persisted");
785 }
786
787 #[tokio::test]
788 async fn open_requires_at_least_one_scope() {
789 let err = GlobalAliasStorage::open(None, None)
790 .expect_err("expected Config error when both dirs are None");
791 assert_eq!(err.code(), crate::error::codes::CONFIG_ERROR);
792 }
793
794 #[tokio::test]
795 async fn migrate_from_per_table_lossless_roundtrip() {
796 let conn_a = rusqlite::Connection::open_in_memory().unwrap();
798 conn_a.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
799 conn_a
800 .execute(
801 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
802 VALUES (?1, ?2, ?3, ?4, ?5)",
803 rusqlite::params!["a_open", "{}", 50i64, "alpha", Option::<String>::None],
804 )
805 .unwrap();
806 conn_a
807 .execute(
808 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
809 VALUES (?1, ?2, ?3, ?4, ?5)",
810 rusqlite::params![
811 "a_closed",
812 "{}",
813 Option::<i64>::None,
814 Option::<String>::None,
815 Some("[\"x\"]".to_string())
816 ],
817 )
818 .unwrap();
819
820 let conn_b = rusqlite::Connection::open_in_memory().unwrap();
821 conn_b.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
822 conn_b
823 .execute(
824 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
825 VALUES (?1, ?2, ?3, ?4, ?5)",
826 rusqlite::params!["b_recent", "{}", 10i64, "bravo", Option::<String>::None],
827 )
828 .unwrap();
829
830 let storage = GlobalAliasStorage::open_in_memory().unwrap();
831 let migrated = storage
832 .migrate_from_per_table(
833 AliasScope::Project,
834 vec![
835 ("table_a".to_string(), Arc::new(Mutex::new(conn_a))),
836 ("table_b".to_string(), Arc::new(Mutex::new(conn_b))),
837 ],
838 )
839 .await
840 .unwrap();
841 assert_eq!(migrated, 3);
842
843 let all = storage.alias_list().await.unwrap();
846 assert_eq!(all.len(), 3);
847 let a_open = all.iter().find(|r| r.name == "a_open").unwrap();
848 assert!(matches!(a_open.sources, SourceSpec::Single(ref t) if t == "table_a"));
849 assert!(a_open.aggregator.is_none());
850 assert_eq!(a_open.default_limit, Some(50));
851 assert_eq!(a_open.description.as_deref(), Some("alpha"));
852 assert_eq!(a_open.params_schema, None);
853
854 let a_closed = all.iter().find(|r| r.name == "a_closed").unwrap();
855 assert!(matches!(a_closed.sources, SourceSpec::Single(ref t) if t == "table_a"));
856 assert_eq!(a_closed.params_schema.as_deref(), Some("[\"x\"]"));
857
858 let b_recent = all.iter().find(|r| r.name == "b_recent").unwrap();
859 assert!(matches!(b_recent.sources, SourceSpec::Single(ref t) if t == "table_b"));
860 assert_eq!(b_recent.default_limit, Some(10));
861 }
862
863 #[tokio::test]
864 async fn migrate_from_per_table_idempotent_on_second_run() {
865 let conn = rusqlite::Connection::open_in_memory().unwrap();
866 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
867 conn.execute(
868 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
869 VALUES (?1, ?2, ?3, ?4, ?5)",
870 rusqlite::params![
871 "x",
872 "{}",
873 Option::<i64>::None,
874 Option::<String>::None,
875 Option::<String>::None
876 ],
877 )
878 .unwrap();
879 let conn_arc = Arc::new(Mutex::new(conn));
880
881 let storage = GlobalAliasStorage::open_in_memory().unwrap();
882 let first = storage
883 .migrate_from_per_table(
884 AliasScope::Project,
885 vec![("t".to_string(), Arc::clone(&conn_arc))],
886 )
887 .await
888 .unwrap();
889 let second = storage
890 .migrate_from_per_table(
891 AliasScope::Project,
892 vec![("t".to_string(), Arc::clone(&conn_arc))],
893 )
894 .await
895 .unwrap();
896 assert_eq!(first, 1);
897 assert_eq!(second, 0);
898 let all = storage.alias_list().await.unwrap();
899 assert_eq!(all.len(), 1);
900 }
901
902 #[tokio::test]
903 async fn migrate_from_per_table_skips_collision_with_existing_global() {
904 let storage = GlobalAliasStorage::open_in_memory().unwrap();
908 let mut existing = sample_record("shared");
909 existing.description = Some("existing-global".into());
910 storage
911 .alias_create(AliasScope::Project, existing)
912 .await
913 .unwrap();
914
915 let conn = rusqlite::Connection::open_in_memory().unwrap();
916 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
917 conn.execute(
918 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
919 VALUES (?1, ?2, ?3, ?4, ?5)",
920 rusqlite::params![
921 "shared",
922 "{}",
923 Option::<i64>::None,
924 Some("legacy-per-table".to_string()),
925 Option::<String>::None
926 ],
927 )
928 .unwrap();
929 let migrated = storage
930 .migrate_from_per_table(
931 AliasScope::Project,
932 vec![("ignored_table".to_string(), Arc::new(Mutex::new(conn)))],
933 )
934 .await
935 .unwrap();
936 assert_eq!(migrated, 0);
937 let got = storage.alias_get("shared").await.unwrap();
938 assert_eq!(got.description.as_deref(), Some("existing-global"));
939 }
940}