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