1use crate::config::resolved::{
4 ColumnInfo, IncludeDirection, IncludeSpec, PkType, ResolvedEntity, ResolvedModel,
5 ResolvedReport,
6};
7use crate::config::types::*;
8use crate::config::{default_schema_id, validate, FullConfig};
9use crate::db::pool::Pool;
10use crate::db::{active_cast_name, parse_canonical};
11use crate::error::ConfigError;
12use crate::store::qualified_sys_table;
13use std::collections::{HashMap, HashSet};
14
15pub fn resolve(config: &FullConfig) -> Result<ResolvedModel, ConfigError> {
17 validate(config)?;
18 let default_sid = default_schema_id(config)?;
19
20 let schemas_by_id: HashMap<_, _> = config.schemas.iter().map(|s| (s.id.as_str(), s)).collect();
21 let tables_by_id: HashMap<_, _> = config.tables.iter().map(|t| (t.id.as_str(), t)).collect();
22 let columns_by_table: HashMap<_, Vec<&ColumnConfig>> =
23 config.columns.iter().fold(HashMap::new(), |mut m, c| {
24 m.entry(c.table_id.as_str()).or_default().push(c);
25 m
26 });
27 let column_id_to_name: HashMap<&str, &str> = config
28 .columns
29 .iter()
30 .map(|c| (c.id.as_str(), c.name.as_str()))
31 .collect();
32 let table_id_to_path: HashMap<&str, &str> = config
33 .api_entities
34 .iter()
35 .map(|api| (api.entity_id.as_str(), api.path_segment.as_str()))
36 .collect();
37
38 let mut entities = Vec::new();
39 let mut entity_by_path = HashMap::new();
40
41 for api in &config.api_entities {
42 let table = tables_by_id.get(api.entity_id.as_str()).ok_or_else(|| {
43 ConfigError::MissingReference {
44 kind: "table",
45 id: api.entity_id.clone(),
46 }
47 })?;
48 let table_sid = table.schema_id.as_deref().unwrap_or(default_sid);
49 let schema = schemas_by_id
50 .get(table_sid)
51 .ok_or_else(|| ConfigError::MissingReference {
52 kind: "schema",
53 id: table_sid.to_string(),
54 })?;
55 let table_columns = columns_by_table
56 .get(table.id.as_str())
57 .map(|v| v.as_slice())
58 .unwrap_or(&[]);
59
60 let pk_names = match &table.primary_key {
61 PrimaryKeyConfig::Single(s) => vec![s.clone()],
62 PrimaryKeyConfig::Composite(v) => v.clone(),
63 };
64 let pk_col = table_columns
65 .iter()
66 .find(|c| c.name == pk_names[0])
67 .ok_or_else(|| ConfigError::InvalidPrimaryKey {
68 table_id: table.id.clone(),
69 column: pk_names[0].clone(),
70 })?;
71 let pk_type = infer_pk_type(pk_col);
72
73 let mut columns: Vec<ColumnInfo> = table_columns
74 .iter()
75 .map(|c| {
76 let is_pk = pk_names.contains(&c.name);
77 let canonical = parse_canonical(&c.type_);
78 let is_asset = matches!(
79 canonical,
80 crate::db::CanonicalType::Asset | crate::db::CanonicalType::AssetArray
81 );
82 let asset_is_array = matches!(canonical, crate::db::CanonicalType::AssetArray);
83 let pg_type = active_cast_name(&canonical);
84 ColumnInfo {
85 name: c.name.clone(),
86 pk_type: if is_pk { Some(pk_type.clone()) } else { None },
87 nullable: c.nullable,
88 has_default: c.default.is_some(),
89 pg_type,
90 is_asset,
91 asset_is_array,
92 asset_config: c.asset.clone(),
93 }
94 })
95 .collect();
96
97 let config_col_names: HashSet<String> = columns.iter().map(|c| c.name.clone()).collect();
98 let ts_cast = active_cast_name(&crate::db::CanonicalType::Timestamp);
101 let ts_cast_str: Option<&str> = ts_cast.as_deref();
102 for (name, nullable, has_default, pg_type) in [
103 ("created_at", false, true, ts_cast_str),
104 ("updated_at", false, true, ts_cast_str),
105 ("archived_at", true, false, ts_cast_str),
106 ("created_by", true, false, None),
107 ("updated_by", true, false, None),
108 ] {
109 if !config_col_names.contains(name) {
110 columns.push(ColumnInfo {
111 name: name.to_string(),
112 pk_type: None,
113 nullable,
114 has_default,
115 pg_type: pg_type.map(str::to_owned),
116 is_asset: false,
117 asset_is_array: false,
118 asset_config: None,
119 });
120 }
121 }
122
123 let extensible_columns: Vec<String> = table_columns
127 .iter()
128 .filter(|c| c.extensible)
129 .filter_map(|c| {
130 let canonical = parse_canonical(&c.type_);
131 if matches!(
132 canonical,
133 crate::db::CanonicalType::Json | crate::db::CanonicalType::Jsonb
134 ) {
135 Some(c.name.clone())
136 } else {
137 tracing::warn!(
138 table = %table.id,
139 column = %c.name,
140 "ignoring `extensible: true` on non-JSON column"
141 );
142 None
143 }
144 })
145 .collect();
146
147 let sensitive_columns: HashSet<String> = api.sensitive_columns.iter().cloned().collect();
148 let includes = build_includes_for_table(
149 &table.id,
150 &config.relationships,
151 &column_id_to_name,
152 &table_id_to_path,
153 );
154 let entity = ResolvedEntity {
155 table_id: table.id.clone(),
156 schema_name: schema.name.clone(),
157 table_name: table.name.clone(),
158 path_segment: api.path_segment.clone(),
159 pk_columns: pk_names.clone(),
160 pk_type: pk_type.clone(),
161 columns,
162 operations: api.operations.clone(),
163 sensitive_columns,
164 includes,
165 validation: api.validation.clone(),
166 events: api.events.clone(),
167 archive_field: api.archive_field.clone().or_else(|| {
168 if api
169 .operations
170 .iter()
171 .any(|o| o == "archive" || o == "unarchive")
172 {
173 Some("archived_at".to_string())
174 } else {
175 None
176 }
177 }),
178 package_id: String::new(),
179 audit_log: table.audit_log,
180 global: table.global,
181 parent_ref_column: api.parent_ref_column.clone(),
182 versioning: table.versioning.clone(),
183 mcp: api.mcp.clone(),
184 extensible_columns,
185 };
186 entity_by_path.insert(api.path_segment.clone(), entity.clone());
187 entities.push(entity);
188 }
189
190 let audit_entities: Vec<ResolvedEntity> = entities
194 .iter()
195 .filter(|e| e.audit_log)
196 .map(|e| {
197 let audit_entity = ResolvedEntity {
198 table_id: format!("{}_audit", e.table_id),
199 schema_name: e.schema_name.clone(),
200 table_name: format!("{}_audit", e.table_name),
201 path_segment: format!("{}_audit", e.path_segment),
202 pk_columns: vec!["audit_id".to_string()],
203 pk_type: PkType::Uuid,
204 columns: build_audit_columns(&e.columns),
205 operations: vec!["list".to_string(), "read".to_string()],
206 sensitive_columns: e.sensitive_columns.clone(),
207 includes: Vec::new(),
208 validation: HashMap::new(),
209 events: Vec::new(),
210 archive_field: None,
211 package_id: e.package_id.clone(),
212 audit_log: false,
213 global: e.global,
214 parent_ref_column: None,
215 versioning: None,
216 mcp: None,
217 extensible_columns: Vec::new(),
218 };
219 audit_entity
220 })
221 .collect();
222 for ae in audit_entities {
223 entity_by_path.insert(ae.path_segment.clone(), ae.clone());
224 entities.push(ae);
225 }
226
227 let mut reports = HashMap::new();
228 for r in &config.reports {
229 let resolved = compile_report(r)?;
230 if reports.insert(resolved.id.clone(), resolved).is_some() {
231 return Err(ConfigError::Validation(format!(
232 "duplicate report id: {}",
233 r.id
234 )));
235 }
236 }
237
238 Ok(ResolvedModel {
239 entities,
240 entity_by_path,
241 reports,
242 })
243}
244
245pub fn compile_report(cfg: &ReportConfig) -> Result<ResolvedReport, ConfigError> {
250 if cfg.id.trim().is_empty() {
251 return Err(ConfigError::Validation(
252 "report id must not be empty".into(),
253 ));
254 }
255 if cfg.sql.trim().is_empty() {
256 return Err(ConfigError::Validation(format!(
257 "report '{}' has empty sql",
258 cfg.id
259 )));
260 }
261
262 let (mut sql, param_order) =
263 translate_named_params(&cfg.sql).map_err(ConfigError::Validation)?;
264
265 let mut rules = HashMap::new();
266 let mut defaults = HashMap::new();
267 let mut casts = HashMap::new();
268 for p in &cfg.params {
269 rules.insert(p.name.clone(), p.rule.clone());
270 if let Some(d) = &p.default {
271 defaults.insert(p.name.clone(), d.clone());
272 }
273 if let Some(t) = &p.db_type {
274 casts.insert(p.name.clone(), t.clone());
275 }
276 }
277
278 let casts_by_pos: HashMap<usize, String> = param_order
281 .iter()
282 .enumerate()
283 .filter_map(|(i, name)| casts.get(name).map(|c| (i + 1, c.clone())))
284 .collect();
285 if !casts_by_pos.is_empty() {
286 sql = apply_param_casts(&sql, &casts_by_pos);
287 }
288
289 Ok(ResolvedReport {
290 id: cfg.id.clone(),
291 name: cfg.name.clone(),
292 description: cfg.description.clone(),
293 package_id: crate::store::DEFAULT_PACKAGE_ID.to_string(),
294 schemas: cfg.schemas.clone(),
295 sql,
296 param_order,
297 rules,
298 defaults,
299 casts,
300 validate_on_register: cfg.validate_on_register.unwrap_or(true),
301 cache_ttl_secs: cfg.cache_ttl_secs,
302 })
303}
304
305fn translate_named_params(sql: &str) -> Result<(String, Vec<String>), String> {
309 let chars: Vec<char> = sql.chars().collect();
310 let mut out = String::with_capacity(sql.len());
311 let mut order: Vec<String> = Vec::new();
312 let mut i = 0;
313 while i < chars.len() {
314 let c = chars[i];
315 match c {
316 '\'' => {
317 out.push(c);
319 i += 1;
320 while i < chars.len() {
321 out.push(chars[i]);
322 if chars[i] == '\'' {
323 if i + 1 < chars.len() && chars[i + 1] == '\'' {
324 out.push(chars[i + 1]);
325 i += 2;
326 continue;
327 }
328 i += 1;
329 break;
330 }
331 i += 1;
332 }
333 }
334 '"' => {
335 out.push(c);
337 i += 1;
338 while i < chars.len() {
339 out.push(chars[i]);
340 if chars[i] == '"' {
341 if i + 1 < chars.len() && chars[i + 1] == '"' {
342 out.push(chars[i + 1]);
343 i += 2;
344 continue;
345 }
346 i += 1;
347 break;
348 }
349 i += 1;
350 }
351 }
352 ':' if i + 1 < chars.len() && chars[i + 1] == ':' => {
353 out.push(':');
355 out.push(':');
356 i += 2;
357 }
358 ':' if i + 1 < chars.len() && is_param_start(chars[i + 1]) => {
359 let mut j = i + 1;
360 let mut name = String::new();
361 while j < chars.len() && is_param_char(chars[j]) {
362 name.push(chars[j]);
363 j += 1;
364 }
365 let pos = match order.iter().position(|n| n == &name) {
366 Some(p) => p + 1,
367 None => {
368 order.push(name.clone());
369 order.len()
370 }
371 };
372 out.push_str(&format!("${}", pos));
373 i = j;
374 }
375 _ => {
376 out.push(c);
377 i += 1;
378 }
379 }
380 }
381 Ok((out, order))
382}
383
384fn is_param_start(c: char) -> bool {
385 c.is_ascii_alphabetic() || c == '_'
386}
387
388fn is_param_char(c: char) -> bool {
389 c.is_ascii_alphanumeric() || c == '_'
390}
391
392fn apply_param_casts(sql: &str, casts_by_pos: &HashMap<usize, String>) -> String {
395 let chars: Vec<char> = sql.chars().collect();
396 let mut out = String::with_capacity(sql.len());
397 let mut i = 0;
398 while i < chars.len() {
399 if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1].is_ascii_digit() {
400 let mut j = i + 1;
401 let mut num = String::new();
402 while j < chars.len() && chars[j].is_ascii_digit() {
403 num.push(chars[j]);
404 j += 1;
405 }
406 out.push('$');
407 out.push_str(&num);
408 if let Some(cast) = num.parse::<usize>().ok().and_then(|n| casts_by_pos.get(&n)) {
409 out.push_str("::");
410 out.push_str(cast);
411 }
412 i = j;
413 } else {
414 out.push(chars[i]);
415 i += 1;
416 }
417 }
418 out
419}
420
421fn build_includes_for_table(
422 our_table_id: &str,
423 relationships: &[RelationshipConfig],
424 column_id_to_name: &HashMap<&str, &str>,
425 table_id_to_path: &HashMap<&str, &str>,
426) -> Vec<IncludeSpec> {
427 let mut includes = Vec::new();
428 for rel in relationships {
429 let from_col = column_id_to_name
430 .get(rel.from_column_id.as_str())
431 .map(|s| s.to_string());
432 let to_col = column_id_to_name
433 .get(rel.to_column_id.as_str())
434 .map(|s| s.to_string());
435 let from_path = table_id_to_path
436 .get(rel.from_table_id.as_str())
437 .map(|s| s.to_string());
438 let to_path = table_id_to_path
439 .get(rel.to_table_id.as_str())
440 .map(|s| s.to_string());
441 if let (Some(our_key), Some(their_key), Some(related_path)) =
442 (from_col.clone(), to_col.clone(), to_path.clone())
443 {
444 if rel.from_table_id == our_table_id {
445 includes.push(IncludeSpec {
446 name: related_path.clone(),
447 direction: IncludeDirection::ToOne,
448 related_path_segment: related_path,
449 our_key_column: our_key,
450 their_key_column: their_key,
451 });
452 }
453 }
454 if let (Some(our_key), Some(their_key), Some(related_path)) = (to_col, from_col, from_path)
455 {
456 if rel.to_table_id == our_table_id {
457 includes.push(IncludeSpec {
458 name: related_path.clone(),
459 direction: IncludeDirection::ToMany,
460 related_path_segment: related_path,
461 our_key_column: our_key,
462 their_key_column: their_key,
463 });
464 }
465 }
466 }
467 includes
468}
469
470#[derive(Clone, Debug, Default)]
482pub struct CrossPackageIndex {
483 entries: HashMap<(String, String), (IncludeSpec, ResolvedEntity)>,
484}
485
486impl CrossPackageIndex {
487 pub fn get(&self, table_id: &str, name: &str) -> Option<&(IncludeSpec, ResolvedEntity)> {
489 self.entries.get(&(table_id.to_string(), name.to_string()))
490 }
491
492 pub fn is_empty(&self) -> bool {
493 self.entries.is_empty()
494 }
495}
496
497pub async fn build_cross_package_index(pool: &Pool) -> CrossPackageIndex {
501 let mut ids = crate::store::list_package_ids(pool)
502 .await
503 .unwrap_or_default();
504 if !ids.iter().any(|i| i == crate::store::DEFAULT_PACKAGE_ID) {
505 ids.push(crate::store::DEFAULT_PACKAGE_ID.to_string());
506 }
507
508 let mut col_name: HashMap<String, String> = HashMap::new();
510 let mut table_to_path: HashMap<String, String> = HashMap::new();
511 let mut table_to_pkg: HashMap<String, String> = HashMap::new();
512 let mut entity_by_table: HashMap<String, ResolvedEntity> = HashMap::new();
513 let mut all_rels: Vec<RelationshipConfig> = Vec::new();
514
515 for id in ids {
516 let cfg = match load_from_pool(pool, &id).await {
517 Ok(c) => c,
518 Err(_) => continue,
519 };
520 for c in &cfg.columns {
521 col_name.insert(c.id.clone(), c.name.clone());
522 }
523 for api in &cfg.api_entities {
524 table_to_path.insert(api.entity_id.clone(), api.path_segment.clone());
525 table_to_pkg.insert(api.entity_id.clone(), id.clone());
526 }
527 if let Ok(model) = resolve(&cfg) {
528 for e in model.with_package_id(&id).entities {
529 entity_by_table.entry(e.table_id.clone()).or_insert(e);
530 }
531 }
532 all_rels.extend(cfg.relationships.iter().cloned());
533 }
534
535 CrossPackageIndex {
536 entries: cross_package_entries(
537 &col_name,
538 &table_to_path,
539 &table_to_pkg,
540 &entity_by_table,
541 &all_rels,
542 ),
543 }
544}
545
546fn cross_package_entries(
550 col_name: &HashMap<String, String>,
551 table_to_path: &HashMap<String, String>,
552 table_to_pkg: &HashMap<String, String>,
553 entity_by_table: &HashMap<String, ResolvedEntity>,
554 relationships: &[RelationshipConfig],
555) -> HashMap<(String, String), (IncludeSpec, ResolvedEntity)> {
556 let mut entries: HashMap<(String, String), (IncludeSpec, ResolvedEntity)> = HashMap::new();
557 for rel in relationships {
558 let (Some(from_pkg), Some(to_pkg)) = (
559 table_to_pkg.get(&rel.from_table_id),
560 table_to_pkg.get(&rel.to_table_id),
561 ) else {
562 continue;
563 };
564 if from_pkg == to_pkg {
565 continue; }
567 let (Some(from_col), Some(to_col), Some(from_path), Some(to_path)) = (
568 col_name.get(&rel.from_column_id),
569 col_name.get(&rel.to_column_id),
570 table_to_path.get(&rel.from_table_id),
571 table_to_path.get(&rel.to_table_id),
572 ) else {
573 continue;
574 };
575
576 if let Some(related) = entity_by_table.get(&rel.to_table_id) {
578 let spec = IncludeSpec {
579 name: to_path.clone(),
580 direction: IncludeDirection::ToOne,
581 related_path_segment: to_path.clone(),
582 our_key_column: from_col.clone(),
583 their_key_column: to_col.clone(),
584 };
585 entries.insert(
586 (rel.from_table_id.clone(), to_path.clone()),
587 (spec, related.clone()),
588 );
589 }
590 if let Some(related) = entity_by_table.get(&rel.from_table_id) {
592 let spec = IncludeSpec {
593 name: from_path.clone(),
594 direction: IncludeDirection::ToMany,
595 related_path_segment: from_path.clone(),
596 our_key_column: to_col.clone(),
597 their_key_column: from_col.clone(),
598 };
599 entries.insert(
600 (rel.to_table_id.clone(), from_path.clone()),
601 (spec, related.clone()),
602 );
603 }
604 }
605 entries
606}
607
608fn build_audit_columns(source_columns: &[ColumnInfo]) -> Vec<ColumnInfo> {
612 let mut cols = Vec::with_capacity(5 + source_columns.len());
613 cols.push(ColumnInfo {
614 name: "audit_id".to_string(),
615 pk_type: Some(PkType::Uuid),
616 nullable: false,
617 has_default: true,
618 pg_type: Some("uuid".to_string()),
619 is_asset: false,
620 asset_is_array: false,
621 asset_config: None,
622 });
623 cols.push(ColumnInfo {
624 name: "audit_action".to_string(),
625 pk_type: None,
626 nullable: false,
627 has_default: false,
628 pg_type: None,
629 is_asset: false,
630 asset_is_array: false,
631 asset_config: None,
632 });
633 cols.push(ColumnInfo {
634 name: "audit_at".to_string(),
635 pk_type: None,
636 nullable: false,
637 has_default: true,
638 pg_type: Some("timestamptz".to_string()),
639 is_asset: false,
640 asset_is_array: false,
641 asset_config: None,
642 });
643 cols.push(ColumnInfo {
644 name: "audit_by".to_string(),
645 pk_type: None,
646 nullable: true,
647 has_default: false,
648 pg_type: None,
649 is_asset: false,
650 asset_is_array: false,
651 asset_config: None,
652 });
653 cols.push(ColumnInfo {
654 name: "changed_fields".to_string(),
655 pk_type: None,
656 nullable: true,
657 has_default: false,
658 pg_type: Some("jsonb".to_string()),
659 is_asset: false,
660 asset_is_array: false,
661 asset_config: None,
662 });
663 for col in source_columns {
664 cols.push(ColumnInfo {
665 name: col.name.clone(),
666 pk_type: None,
667 nullable: col.nullable,
668 has_default: col.has_default,
669 pg_type: col.pg_type.clone(),
670 is_asset: col.is_asset,
671 asset_is_array: col.asset_is_array,
672 asset_config: col.asset_config.clone(),
673 });
674 }
675 cols
676}
677
678fn infer_pk_type(col: &ColumnConfig) -> PkType {
679 use crate::db::CanonicalType;
680 match parse_canonical(&col.type_) {
681 CanonicalType::Uuid => PkType::Uuid,
682 CanonicalType::BigInt | CanonicalType::BigSerial => PkType::BigInt,
683 CanonicalType::Int | CanonicalType::Serial | CanonicalType::SmallInt => PkType::Int,
684 CanonicalType::Custom(s) => {
686 let lower = s.to_lowercase();
687 if lower.contains("uuid") {
688 PkType::Uuid
689 } else if lower.contains("bigserial") || lower.contains("bigint") {
690 PkType::BigInt
691 } else if lower.contains("serial") || lower.contains("int") {
692 PkType::Int
693 } else {
694 PkType::Text
695 }
696 }
697 _ => PkType::Text,
698 }
699}
700
701pub async fn load_from_pool(pool: &Pool, package_id: &str) -> Result<FullConfig, ConfigError> {
703 let mut schemas =
704 load_config_table::<SchemaConfig>(pool, &qualified_sys_table("_sys_schemas"), package_id)
705 .await?;
706 if schemas.is_empty() {
707 schemas = vec![SchemaConfig {
708 id: "default".into(),
709 name: "public".into(),
710 comment: None,
711 }];
712 }
713 let enums =
714 load_config_table::<EnumConfig>(pool, &qualified_sys_table("_sys_enums"), package_id)
715 .await?;
716 let tables =
717 load_config_table::<TableConfig>(pool, &qualified_sys_table("_sys_tables"), package_id)
718 .await?;
719 let columns =
720 load_config_table::<ColumnConfig>(pool, &qualified_sys_table("_sys_columns"), package_id)
721 .await?;
722 let indexes =
723 load_config_table::<IndexConfig>(pool, &qualified_sys_table("_sys_indexes"), package_id)
724 .await?;
725 let relationships = load_config_table::<RelationshipConfig>(
726 pool,
727 &qualified_sys_table("_sys_relationships"),
728 package_id,
729 )
730 .await?;
731 let api_entities = load_config_table::<ApiEntityConfig>(
732 pool,
733 &qualified_sys_table("_sys_api_entities"),
734 package_id,
735 )
736 .await?;
737 let kv_stores = load_config_table::<KvStoreConfig>(
738 pool,
739 &qualified_sys_table("_sys_kv_stores"),
740 package_id,
741 )
742 .await?;
743 let reports =
744 load_config_table::<ReportConfig>(pool, &qualified_sys_table("_sys_reports"), package_id)
745 .await?;
746
747 let config = FullConfig {
748 schemas,
749 enums,
750 tables,
751 columns,
752 indexes,
753 relationships,
754 api_entities,
755 kv_stores,
756 reports,
757 };
758 Ok(config)
759}
760
761async fn load_config_table<T>(
762 pool: &Pool,
763 table: &str,
764 package_id: &str,
765) -> Result<Vec<T>, ConfigError>
766where
767 T: for<'de> serde::Deserialize<'de>,
768{
769 let sql = format!(
770 "SELECT payload FROM {} WHERE package_id = $1 ORDER BY id",
771 table
772 );
773 tracing::debug!(sql = %sql, package_id = %package_id, "query");
774 let rows = sqlx::query_scalar::<_, serde_json::Value>(&sql)
775 .bind(package_id)
776 .fetch_all(pool)
777 .await
778 .map_err(|e| ConfigError::Load(e.to_string()))?;
779
780 let mut out = Vec::with_capacity(rows.len());
781 for row in rows {
782 let value: T = serde_json::from_value(row).map_err(|e| ConfigError::Load(e.to_string()))?;
783 out.push(value);
784 }
785 Ok(out)
786}
787
788#[cfg(test)]
789mod report_tests {
790 use super::*;
791
792 #[test]
793 fn translates_named_params_to_positional() {
794 let (sql, order) =
795 translate_named_params("SELECT * FROM t WHERE a >= :from AND b < :to").unwrap();
796 assert_eq!(sql, "SELECT * FROM t WHERE a >= $1 AND b < $2");
797 assert_eq!(order, vec!["from".to_string(), "to".to_string()]);
798 }
799
800 #[test]
801 fn repeated_named_param_reuses_placeholder() {
802 let (sql, order) =
803 translate_named_params("SELECT * FROM t WHERE a = :x OR b = :x").unwrap();
804 assert_eq!(sql, "SELECT * FROM t WHERE a = $1 OR b = $1");
805 assert_eq!(order, vec!["x".to_string()]);
806 }
807
808 #[test]
809 fn preserves_cast_operator_and_string_literals() {
810 let (sql, order) = translate_named_params(
812 "SELECT created_at::date, ':notaparam' AS lit FROM t WHERE d = :day",
813 )
814 .unwrap();
815 assert_eq!(
816 sql,
817 "SELECT created_at::date, ':notaparam' AS lit FROM t WHERE d = $1"
818 );
819 assert_eq!(order, vec!["day".to_string()]);
820 }
821
822 #[test]
823 fn injects_declared_casts_onto_placeholders() {
824 let cfg = ReportConfig {
825 id: "r1".into(),
826 name: "R1".into(),
827 description: None,
828 schemas: vec![],
829 sql: "SELECT * FROM t WHERE created_at >= :from AND n > :min".into(),
830 params: vec![
831 ReportParam {
832 name: "from".into(),
833 default: None,
834 db_type: Some("timestamptz".into()),
835 rule: ValidationRule::default(),
836 },
837 ReportParam {
838 name: "min".into(),
839 default: None,
840 db_type: Some("numeric".into()),
841 rule: ValidationRule::default(),
842 },
843 ],
844 validate_on_register: None,
845 cache_ttl_secs: None,
846 };
847 let resolved = compile_report(&cfg).unwrap();
848 assert_eq!(
849 resolved.sql,
850 "SELECT * FROM t WHERE created_at >= $1::timestamptz AND n > $2::numeric"
851 );
852 assert_eq!(
853 resolved.param_order,
854 vec!["from".to_string(), "min".to_string()]
855 );
856 assert!(resolved.validate_on_register); }
858
859 #[test]
860 fn cast_injection_distinguishes_1_from_10() {
861 let mut casts = HashMap::new();
862 casts.insert(1usize, "int".to_string());
863 let out = apply_param_casts("$1 $10", &casts);
865 assert_eq!(out, "$1::int $10");
866 }
867
868 #[test]
869 fn empty_sql_is_rejected() {
870 let cfg = ReportConfig {
871 id: "r".into(),
872 name: "R".into(),
873 description: None,
874 schemas: vec![],
875 sql: " ".into(),
876 params: vec![],
877 validate_on_register: None,
878 cache_ttl_secs: None,
879 };
880 assert!(compile_report(&cfg).is_err());
881 }
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887
888 fn ent(table_id: &str, path: &str, schema: &str, pkg: &str) -> ResolvedEntity {
889 ResolvedEntity {
890 table_id: table_id.into(),
891 schema_name: schema.into(),
892 table_name: table_id.into(),
893 path_segment: path.into(),
894 pk_columns: vec!["id".into()],
895 pk_type: PkType::Uuid,
896 columns: vec![],
897 operations: vec!["list".into(), "read".into()],
898 sensitive_columns: HashSet::new(),
899 includes: vec![],
900 validation: HashMap::new(),
901 events: vec![],
902 archive_field: None,
903 package_id: pkg.into(),
904 audit_log: false,
905 global: false,
906 parent_ref_column: None,
907 versioning: None,
908 mcp: None,
909 extensible_columns: vec![],
910 }
911 }
912
913 fn rel(id: &str, from_t: &str, from_c: &str, to_t: &str, to_c: &str) -> RelationshipConfig {
914 RelationshipConfig {
915 id: id.into(),
916 from_schema_id: None,
917 from_table_id: from_t.into(),
918 from_column_id: from_c.into(),
919 to_package_id: None,
920 to_schema_id: None,
921 to_table_id: to_t.into(),
922 to_column_id: to_c.into(),
923 on_update: None,
924 on_delete: None,
925 name: None,
926 }
927 }
928
929 #[test]
932 fn cross_package_relationship_builds_both_directions() {
933 let col_name = HashMap::from([
934 ("c_org_id".to_string(), "org_id".to_string()),
935 ("c_org_pk".to_string(), "id".to_string()),
936 ]);
937 let table_to_path = HashMap::from([
938 ("t_users".to_string(), "users".to_string()),
939 ("t_orgs".to_string(), "orgs".to_string()),
940 ]);
941 let table_to_pkg = HashMap::from([
942 ("t_users".to_string(), "pkg_a".to_string()),
943 ("t_orgs".to_string(), "pkg_b".to_string()),
944 ]);
945 let entity_by_table = HashMap::from([
946 ("t_users".to_string(), ent("t_users", "users", "a", "pkg_a")),
947 ("t_orgs".to_string(), ent("t_orgs", "orgs", "b", "pkg_b")),
948 ]);
949 let rels = vec![rel("r1", "t_users", "c_org_id", "t_orgs", "c_org_pk")];
950
951 let entries = cross_package_entries(
952 &col_name,
953 &table_to_path,
954 &table_to_pkg,
955 &entity_by_table,
956 &rels,
957 );
958
959 let (one_spec, one_rel) = entries
961 .get(&("t_users".to_string(), "orgs".to_string()))
962 .expect("users → orgs to_one entry");
963 assert!(matches!(one_spec.direction, IncludeDirection::ToOne));
964 assert_eq!(one_spec.our_key_column, "org_id");
965 assert_eq!(one_spec.their_key_column, "id");
966 assert_eq!(one_rel.schema_name, "b");
967
968 let (many_spec, many_rel) = entries
970 .get(&("t_orgs".to_string(), "users".to_string()))
971 .expect("orgs → users to_many entry");
972 assert!(matches!(many_spec.direction, IncludeDirection::ToMany));
973 assert_eq!(many_spec.our_key_column, "id");
974 assert_eq!(many_spec.their_key_column, "org_id");
975 assert_eq!(many_rel.schema_name, "a");
976 }
977
978 #[test]
980 fn same_package_relationship_is_skipped() {
981 let col_name = HashMap::from([
982 ("c_fk".to_string(), "user_id".to_string()),
983 ("c_pk".to_string(), "id".to_string()),
984 ]);
985 let table_to_path = HashMap::from([
986 ("t_orders".to_string(), "orders".to_string()),
987 ("t_users".to_string(), "users".to_string()),
988 ]);
989 let table_to_pkg = HashMap::from([
991 ("t_orders".to_string(), "pkg_a".to_string()),
992 ("t_users".to_string(), "pkg_a".to_string()),
993 ]);
994 let entity_by_table = HashMap::from([
995 (
996 "t_orders".to_string(),
997 ent("t_orders", "orders", "a", "pkg_a"),
998 ),
999 ("t_users".to_string(), ent("t_users", "users", "a", "pkg_a")),
1000 ]);
1001 let rels = vec![rel("r1", "t_orders", "c_fk", "t_users", "c_pk")];
1002
1003 let entries = cross_package_entries(
1004 &col_name,
1005 &table_to_path,
1006 &table_to_pkg,
1007 &entity_by_table,
1008 &rels,
1009 );
1010 assert!(entries.is_empty(), "same-package rel should be skipped");
1011 }
1012
1013 #[test]
1015 fn missing_related_path_yields_no_entry() {
1016 let col_name = HashMap::from([
1017 ("c_org_id".to_string(), "org_id".to_string()),
1018 ("c_org_pk".to_string(), "id".to_string()),
1019 ]);
1020 let table_to_path = HashMap::from([("t_users".to_string(), "users".to_string())]);
1022 let table_to_pkg = HashMap::from([
1023 ("t_users".to_string(), "pkg_a".to_string()),
1024 ("t_orgs".to_string(), "pkg_b".to_string()),
1025 ]);
1026 let entity_by_table =
1027 HashMap::from([("t_users".to_string(), ent("t_users", "users", "a", "pkg_a"))]);
1028 let rels = vec![rel("r1", "t_users", "c_org_id", "t_orgs", "c_org_pk")];
1029
1030 let entries = cross_package_entries(
1031 &col_name,
1032 &table_to_path,
1033 &table_to_pkg,
1034 &entity_by_table,
1035 &rels,
1036 );
1037 assert!(entries.is_empty());
1038 }
1039}