mini_app_core/alias_run.rs
1//! Top-level orchestration for the `alias_run` MCP tool.
2//!
3//! This module exposes [`execute_alias_run`] as the single entry point for
4//! running a named alias. Both the MCP tool handler and direct SDK consumers
5//! call this function; the MCP handler is a thin wrapper that:
6//! 1. Resolves the [`AliasRecord`] from global or per-table storage.
7//! 2. Calls [`execute_alias_run`].
8//! 3. Serialises the [`AliasRunValue`] result to JSON (backward-compat shape).
9//!
10//! # Crux compliance
11//!
12//! - **Crux #1 / #2**: `crates/core/Cargo.toml` must not declare `rmcp` as a
13//! dependency. All types here are pure Core-native types. MCP boundary
14//! conversions (e.g. `MiniAppError → rmcp::ErrorData`) are the sole
15//! responsibility of private adapter functions in `crates/mcp`.
16//!
17//! # MiniJinja render pipeline (Crux §1/#2)
18//!
19//! When `record.params_schema` is `Some`, the `filter` field is a MiniJinja
20//! template. The template is rendered with `params` as the context, then the
21//! rendered string is parsed as a [`ListFilter`] JSON document. When
22//! `params_schema` is `None`, the render step is skipped entirely for backward
23//! compatibility with plain-JSON aliases.
24
25use std::sync::Arc;
26
27use serde::Serialize;
28
29use crate::aggregator::{AliasAggregator, AliasRunResult, SourceSpec, execute_aggregate};
30use crate::alias_storage::AliasRecord;
31use crate::error::MiniAppError;
32use crate::filter::ListFilter;
33use crate::materialize::{FieldSelector, apply_projection};
34use crate::order_by::OrderByItem;
35use crate::registry::TableRegistry;
36
37// =============================================================================
38// Result type
39// =============================================================================
40
41/// The result of [`execute_alias_run`].
42///
43/// Wraps both the plain Rows path (per-table `store.list` + field projection)
44/// and the Aggregator path (`execute_aggregate`). MCP callers serialise each
45/// variant with its natural JSON shape to preserve backward compatibility:
46///
47/// - `Rows(records)` → `serde_json::to_string(&records)`
48/// - `Aggregate(result)` → `serde_json::to_string(&result)`
49#[derive(Debug, Serialize)]
50pub enum AliasRunValue {
51 /// Plain rows path — result of `store.list` + field projection.
52 Rows(Vec<crate::store::RowRecord>),
53 /// Aggregator path — wraps the existing [`AliasRunResult`].
54 Aggregate(AliasRunResult),
55}
56
57// =============================================================================
58// Public entry point
59// =============================================================================
60
61/// Execute an alias and return the typed result.
62///
63/// This is the canonical alias_run implementation. Both the MCP `alias_run`
64/// tool handler and direct SDK consumers call this function.
65///
66/// # Arguments
67///
68/// - `registry` — Live [`TableRegistry`]; used to resolve store handles and
69/// schema configs from table names, and to collect table names for
70/// [`SourceSpec::Pattern`] resolution.
71/// - `record` — The [`AliasRecord`] that describes the alias (sources,
72/// aggregator, filter template, parameter schema, default limit).
73/// - `params` — Optional JSON value used as the MiniJinja render context.
74/// Required when `record.params_schema` is `Some`; ignored (and silently
75/// accepted) when `None`.
76/// - `table_fallback` — Legacy single-table mode: the `table` argument
77/// supplied to `alias_run`. Used when `record.sources` is
78/// `SourceSpec::Single` with an empty placeholder produced by the legacy
79/// per-table path. Ignored when `record.sources` is already a fully
80/// populated `Single`/`Multi`/`Pattern`.
81/// - `limit_override` — Caller-supplied row limit. Falls back to
82/// `record.default_limit` when `None`.
83/// - `offset` — Number of rows to skip (plain Rows path only).
84/// - `fields` — Field projection selector (plain Rows path only).
85///
86/// # Errors
87///
88/// - [`MiniAppError::AliasParamsRequired`] — `params_schema` is `Some` but
89/// `params` is `None`.
90/// - [`MiniAppError::AliasTemplateError`] — MiniJinja render failure.
91/// - [`MiniAppError::Filter`] — filter parse or validate failure.
92/// - [`MiniAppError::Aggregator`] — aggregator execute failure.
93/// - [`MiniAppError::TableNotFound`] — a referenced table is not in the
94/// registry.
95/// - [`MiniAppError::Storage`] — underlying SQLite error.
96#[allow(clippy::too_many_arguments)]
97pub async fn execute_alias_run(
98 registry: &TableRegistry,
99 record: AliasRecord,
100 params: Option<serde_json::Value>,
101 table_fallback: Option<&str>,
102 limit_override: Option<u32>,
103 offset: Option<u32>,
104 fields: Option<FieldSelector>,
105 order_by_override: Option<Vec<OrderByItem>>,
106) -> Result<AliasRunValue, MiniAppError> {
107 // -----------------------------------------------------------------
108 // Step 1: Render the filter template (Crux #1/#2).
109 // -----------------------------------------------------------------
110 let filter_text = record.filter;
111 let filter: ListFilter = if record.params_schema.is_some() {
112 let mut params_value = params.ok_or_else(|| MiniAppError::AliasParamsRequired {
113 name: record.name.clone(),
114 })?;
115 // Defensive parse: some MCP transports (notably the Claude Code
116 // stdio client) stringify `serde_json::Value` argument fields, so
117 // a `params: {"k": "v"}` payload arrives here as
118 // `Value::String("{\"k\":\"v\"}")` rather than `Value::Object(..)`.
119 // When minijinja receives a String it cannot resolve `{{ k }}`
120 // (the String has no keys), the placeholder evaluates to undefined
121 // and lenient render emits an empty value. Re-parse JSON-encoded
122 // strings into their Object form so render works regardless of
123 // the transport.
124 if let serde_json::Value::String(ref s) = params_value {
125 params_value = serde_json::from_str(s).map_err(|e| {
126 MiniAppError::AliasTemplateError(format!(
127 "params arrived as a string but failed JSON parse: {e}"
128 ))
129 })?;
130 }
131 let env = minijinja::Environment::new();
132 let rendered = env
133 .render_str(&filter_text, ¶ms_value)
134 .map_err(|e| MiniAppError::AliasTemplateError(e.to_string()))?;
135 serde_json::from_str(&rendered)
136 .map_err(|e| MiniAppError::Schema(format!("rendered filter parse error: {e}")))?
137 } else {
138 serde_json::from_str(&filter_text)
139 .map_err(|e| MiniAppError::Schema(format!("filter parse error: {e}")))?
140 };
141
142 let limit = limit_override.or(record.default_limit);
143
144 // -----------------------------------------------------------------
145 // Crux #2: Field-projection fallback.
146 //
147 // When the caller supplies `fields` at run-time, use it as-is.
148 // When the caller omits `fields` (None), fall back to the stored
149 // default from `record.fields`.
150 // When `record.fields` is also None, no projection is applied — all
151 // fields are returned (Crux #3: NULL must never become an empty list).
152 // -----------------------------------------------------------------
153 let fields = match fields {
154 Some(f) => Some(f),
155 None => match record.fields.as_deref() {
156 Some(json) => Some(serde_json::from_str(json).map_err(|e| {
157 MiniAppError::Schema(format!(
158 "alias '{}' stored fields parse error: {e}",
159 record.name
160 ))
161 })?),
162 None => None,
163 },
164 };
165
166 // -----------------------------------------------------------------
167 // Order-by fallback (mirrors the fields fallback above).
168 //
169 // When the caller supplies `order_by_override` at run-time, use it.
170 // Otherwise fall back to the stored default from `record.order_by`.
171 // When both are None, no ORDER BY is applied — the store default
172 // (`ORDER BY created_at DESC`) is used instead.
173 // -----------------------------------------------------------------
174 let order_by = match order_by_override {
175 Some(v) => Some(v),
176 None => match record.order_by.as_deref() {
177 Some(json) => Some(serde_json::from_str(json).map_err(|e| {
178 MiniAppError::Schema(format!(
179 "alias '{}' stored order_by parse error: {e}",
180 record.name
181 ))
182 })?),
183 None => None,
184 },
185 };
186
187 // -----------------------------------------------------------------
188 // Step 2: Aggregator path dispatch.
189 // -----------------------------------------------------------------
190 if let Some(agg) = record.aggregator {
191 return execute_aggregator_path(registry, record.sources, filter, agg, limit, order_by)
192 .await;
193 }
194
195 // -----------------------------------------------------------------
196 // Step 3: Plain (Rows) path.
197 // -----------------------------------------------------------------
198 execute_rows_path(
199 registry,
200 record.sources,
201 table_fallback,
202 filter,
203 limit,
204 offset,
205 fields,
206 order_by,
207 )
208 .await
209}
210
211// =============================================================================
212// Internal helpers
213// =============================================================================
214
215/// Aggregator execution path: resolves Pattern sources, validates, and calls
216/// [`execute_aggregate`].
217///
218/// `order_by` is silently ignored on this path — aggregator results have their
219/// own structure and do not map onto a per-row SQL ORDER BY. A [`tracing::warn!`]
220/// is emitted so callers can diagnose unexpected sort arguments.
221async fn execute_aggregator_path(
222 registry: &TableRegistry,
223 sources: SourceSpec,
224 filter: ListFilter,
225 agg: AliasAggregator,
226 _limit: Option<u32>,
227 order_by: Option<Vec<OrderByItem>>,
228) -> Result<AliasRunValue, MiniAppError> {
229 if order_by.is_some() {
230 tracing::warn!(
231 "alias_run: order_by is set but the alias uses an aggregator — \
232 order_by is silently ignored on the aggregator path"
233 );
234 }
235 let resolved = if sources.requires_resolve() {
236 let table_names: Vec<String> = registry.table_names().map(str::to_owned).collect();
237 sources.resolve_pattern(&table_names)?
238 } else {
239 sources
240 };
241
242 let schema_table =
243 resolved.tables().first().cloned().ok_or_else(|| {
244 MiniAppError::Aggregator("alias sources resolved to zero tables".into())
245 })?;
246
247 let schema = Arc::clone(®istry.resolve(Some(schema_table.as_str()))?.schema);
248
249 filter.validate(&schema)?;
250
251 let result = execute_aggregate(registry, resolved, Some(filter), agg, &schema).await?;
252 Ok(AliasRunValue::Aggregate(result))
253}
254
255/// Plain rows execution path: resolves a single-table store and returns rows
256/// after applying optional field projection and order-by.
257#[allow(clippy::too_many_arguments)]
258async fn execute_rows_path(
259 registry: &TableRegistry,
260 sources: SourceSpec,
261 table_fallback: Option<&str>,
262 filter: ListFilter,
263 limit: Option<u32>,
264 offset: Option<u32>,
265 fields: Option<FieldSelector>,
266 order_by: Option<Vec<OrderByItem>>,
267) -> Result<AliasRunValue, MiniAppError> {
268 let table_name: Option<&str> = match &sources {
269 // Non-empty Single → use it directly.
270 // Empty-string Single is the legacy sentinel produced by the MCP wrapper
271 // when reading from per-table `_aliases`; fall through to `table_fallback`.
272 SourceSpec::Single(t) if !t.is_empty() => Some(t.as_str()),
273 SourceSpec::Single(_) => None,
274 SourceSpec::Multi(_) | SourceSpec::Pattern(_) => {
275 return Err(MiniAppError::Aggregator(
276 "Multi/Pattern source aliases require an aggregator (Phase 2 limitation)".into(),
277 ));
278 }
279 };
280
281 // Use `table_name` from sources when available; otherwise fall back to
282 // the legacy `table_fallback` arg (per-table alias_run path).
283 let effective_table = table_name.or(table_fallback);
284
285 let entry = registry.resolve(effective_table)?;
286 let store = Arc::clone(&entry.store);
287 let schema = Arc::clone(&entry.schema);
288
289 filter.validate(&schema)?;
290 let records = store.list(limit, offset, Some(filter), order_by).await?;
291 let records = apply_projection(records, &fields, &schema)?;
292 Ok(AliasRunValue::Rows(records))
293}
294
295// =============================================================================
296// Tests
297// =============================================================================
298
299#[cfg(test)]
300mod tests {
301 use std::collections::HashMap;
302
303 use tempfile::tempdir;
304
305 use super::*;
306 use crate::aggregator::{AliasAggregator, SourceSpec};
307 use crate::alias_storage::AliasRecord;
308 use crate::registry::{TableEntry, TableRegistry};
309 use crate::schema::{FieldDef, FieldType, SchemaConfig};
310 use crate::store::Store;
311
312 // -----------------------------------------------------------------------
313 // Test helpers
314 // -----------------------------------------------------------------------
315
316 /// Minimal schema with a `status` string field (direct struct construction).
317 fn status_schema() -> SchemaConfig {
318 SchemaConfig {
319 table: "items".into(),
320 title: None,
321 description: None,
322 fields: vec![FieldDef {
323 name: "status".into(),
324 ty: FieldType::String,
325 required: false,
326 description: None,
327 }],
328 dump: None,
329 }
330 }
331
332 /// Build a tempfile-backed Store with seed rows.
333 async fn make_store_with_rows(schema: &SchemaConfig, rows: Vec<serde_json::Value>) -> Store {
334 let dir = tempdir().expect("tempdir");
335 let db_path = dir.path().join("test.db");
336 let store = Store::open(&db_path, schema.clone())
337 .await
338 .expect("store open");
339 // Leak the tempdir so the db file lives for the test duration.
340 std::mem::forget(dir);
341 for row in rows {
342 store.create(row).await.expect("insert row");
343 }
344 store
345 }
346
347 /// Build a single-table [`TableRegistry`] from a store + schema.
348 fn registry_from_store(table: &str, store: Store, schema: SchemaConfig) -> TableRegistry {
349 let mut entries = HashMap::new();
350 entries.insert(
351 table.to_string(),
352 TableEntry {
353 store: Arc::new(store),
354 schema: Arc::new(schema),
355 schema_path: Arc::new(std::path::PathBuf::new()),
356 },
357 );
358 TableRegistry::from_entries(entries, Some(table.to_string()))
359 }
360
361 /// Build a minimal [`AliasRecord`] (no aggregator, no params, no stored fields).
362 fn plain_alias(sources: SourceSpec, filter_json: &str) -> AliasRecord {
363 AliasRecord {
364 name: "test_alias".into(),
365 sources,
366 aggregator: None,
367 filter: filter_json.into(),
368 default_limit: None,
369 description: None,
370 params_schema: None,
371 fields: None,
372 order_by: None,
373 scope: None,
374 }
375 }
376
377 // -----------------------------------------------------------------------
378 // Test: Rows path — Single source + plain filter
379 // -----------------------------------------------------------------------
380 #[tokio::test]
381 async fn rows_path_single_source() {
382 let schema = status_schema();
383 let store = make_store_with_rows(
384 &schema,
385 vec![
386 serde_json::json!({"status": "open"}),
387 serde_json::json!({"status": "closed"}),
388 ],
389 )
390 .await;
391 let registry = registry_from_store("items", store, schema);
392
393 // Filter: only "open" rows. ListFilter uses {"type":"eq",...} shape.
394 let filter_json = r#"{"type":"eq","field":"status","value":"open"}"#;
395 let record = plain_alias(SourceSpec::Single("items".into()), filter_json);
396
397 let result = execute_alias_run(®istry, record, None, None, None, None, None, None)
398 .await
399 .expect("execute_alias_run");
400
401 match result {
402 AliasRunValue::Rows(rows) => {
403 assert_eq!(rows.len(), 1);
404 assert_eq!(rows[0].data["status"], "open");
405 }
406 other => panic!("expected Rows, got {other:?}"),
407 }
408 }
409
410 // -----------------------------------------------------------------------
411 // Test: Aggregator path — Count
412 // -----------------------------------------------------------------------
413 #[tokio::test]
414 async fn aggregator_path_count() {
415 let schema = status_schema();
416 let store = make_store_with_rows(
417 &schema,
418 vec![
419 serde_json::json!({"status": "open"}),
420 serde_json::json!({"status": "open"}),
421 serde_json::json!({"status": "closed"}),
422 ],
423 )
424 .await;
425 let registry = registry_from_store("items", store, schema);
426
427 // Use an "open" status filter to match 2 of 3 rows.
428 let record = AliasRecord {
429 name: "count_alias".into(),
430 sources: SourceSpec::Single("items".into()),
431 aggregator: Some(AliasAggregator::Count),
432 filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
433 default_limit: None,
434 description: None,
435 params_schema: None,
436 fields: None,
437 order_by: None,
438 scope: None,
439 };
440
441 let result = execute_alias_run(®istry, record, None, None, None, None, None, None)
442 .await
443 .expect("execute_alias_run");
444
445 match result {
446 AliasRunValue::Aggregate(AliasRunResult::Count(n)) => assert_eq!(n, 2),
447 other => panic!("expected Aggregate(Count(2)), got {other:?}"),
448 }
449 }
450
451 // -----------------------------------------------------------------------
452 // Test: MiniJinja render — params_schema=Some, template substitution
453 // -----------------------------------------------------------------------
454 #[tokio::test]
455 async fn jinja_render_substitution() {
456 let schema = status_schema();
457 let store = make_store_with_rows(
458 &schema,
459 vec![
460 serde_json::json!({"status": "open"}),
461 serde_json::json!({"status": "closed"}),
462 ],
463 )
464 .await;
465 let registry = registry_from_store("items", store, schema);
466
467 // Template: substitute {{ status }} from params. Uses ListFilter JSON shape.
468 let record = AliasRecord {
469 name: "templated_alias".into(),
470 sources: SourceSpec::Single("items".into()),
471 aggregator: None,
472 filter: r#"{"type":"eq","field":"status","value":"{{ status }}"}"#.into(),
473 default_limit: None,
474 description: None,
475 params_schema: Some(r#"["status"]"#.into()),
476 fields: None,
477 order_by: None,
478 scope: None,
479 };
480
481 let params = serde_json::json!({"status": "closed"});
482
483 let result = execute_alias_run(
484 ®istry,
485 record,
486 Some(params),
487 None,
488 None,
489 None,
490 None,
491 None,
492 )
493 .await
494 .expect("execute_alias_run");
495
496 match result {
497 AliasRunValue::Rows(rows) => {
498 assert_eq!(rows.len(), 1);
499 assert_eq!(rows[0].data["status"], "closed");
500 }
501 other => panic!("expected Rows, got {other:?}"),
502 }
503 }
504
505 // -----------------------------------------------------------------------
506 // Test: MCP transport stringified params — Value::String("{...}") path
507 //
508 // Some MCP transports (notably the Claude Code stdio client) deliver
509 // `Option<serde_json::Value>` argument fields as JSON-encoded strings
510 // rather than parsed objects. Verify the defensive re-parse path so
511 // `{{ key }}` still resolves when params arrives as Value::String.
512 // -----------------------------------------------------------------------
513 #[tokio::test]
514 async fn jinja_render_with_stringified_params() {
515 let schema = status_schema();
516 let store = make_store_with_rows(
517 &schema,
518 vec![
519 serde_json::json!({"status": "open"}),
520 serde_json::json!({"status": "closed"}),
521 ],
522 )
523 .await;
524 let registry = registry_from_store("items", store, schema);
525
526 let record = AliasRecord {
527 name: "templated_alias".into(),
528 sources: SourceSpec::Single("items".into()),
529 aggregator: None,
530 filter: r#"{"type":"eq","field":"status","value":"{{ status }}"}"#.into(),
531 default_limit: None,
532 description: None,
533 params_schema: Some(r#"["status"]"#.into()),
534 fields: None,
535 order_by: None,
536 scope: None,
537 };
538
539 // params arrives as Value::String containing a JSON-encoded object
540 // (the failure mode observed via the Claude Code MCP transport).
541 let stringified_params = serde_json::Value::String(r#"{"status": "closed"}"#.to_string());
542
543 let result = execute_alias_run(
544 ®istry,
545 record,
546 Some(stringified_params),
547 None,
548 None,
549 None,
550 None,
551 None,
552 )
553 .await
554 .expect("execute_alias_run must re-parse stringified params");
555
556 match result {
557 AliasRunValue::Rows(rows) => {
558 assert_eq!(rows.len(), 1);
559 assert_eq!(rows[0].data["status"], "closed");
560 }
561 other => panic!("expected Rows, got {other:?}"),
562 }
563 }
564
565 // -----------------------------------------------------------------------
566 // Test: Legacy mode fallback — sources Single("") + table_fallback
567 // -----------------------------------------------------------------------
568 #[tokio::test]
569 async fn legacy_mode_table_fallback() {
570 let schema = status_schema();
571 let store =
572 make_store_with_rows(&schema, vec![serde_json::json!({"status": "open"})]).await;
573 let registry = registry_from_store("items", store, schema);
574
575 // Simulate a legacy record where `sources` has an empty Single table
576 // name and `table_fallback` provides the real name.
577 let record = AliasRecord {
578 name: "legacy_alias".into(),
579 sources: SourceSpec::Single(String::new()),
580 aggregator: None,
581 filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
582 default_limit: None,
583 description: None,
584 params_schema: None,
585 fields: None,
586 order_by: None,
587 scope: None,
588 };
589
590 let result = execute_alias_run(
591 ®istry,
592 record,
593 None,
594 Some("items"),
595 None,
596 None,
597 None,
598 None,
599 )
600 .await
601 .expect("execute_alias_run");
602
603 match result {
604 AliasRunValue::Rows(rows) => assert!(!rows.is_empty()),
605 other => panic!("expected Rows, got {other:?}"),
606 }
607 }
608
609 // -----------------------------------------------------------------------
610 // Test: Multi/Pattern without aggregator → error
611 // -----------------------------------------------------------------------
612 #[tokio::test]
613 async fn multi_without_aggregator_is_error() {
614 let schema = status_schema();
615 let store = make_store_with_rows(&schema, vec![]).await;
616 let registry = registry_from_store("items", store, schema);
617
618 let record = AliasRecord {
619 name: "multi_alias".into(),
620 sources: SourceSpec::Multi(vec!["items".into(), "other".into()]),
621 aggregator: None,
622 filter: r#"{"type":"eq","field":"status","value":"open"}"#.into(),
623 default_limit: None,
624 description: None,
625 params_schema: None,
626 fields: None,
627 order_by: None,
628 scope: None,
629 };
630
631 let err = execute_alias_run(®istry, record, None, None, None, None, None, None)
632 .await
633 .expect_err("should fail");
634
635 let msg = err.to_string();
636 assert!(
637 msg.contains("Multi/Pattern source aliases require an aggregator"),
638 "unexpected error: {msg}"
639 );
640 }
641}