Skip to main content

architect_sdk/config/
loader.rs

1//! Load config from in-memory structs or from architect._sys_* tables in DB.
2
3use 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
15/// Build resolved model from full config (call after validate).
16pub 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        // Use active_cast_name so the cast is correct per dialect
99        // (timestamptz for Postgres, None for SQLite/MySQL which need no cast).
100        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        // Collect JSON/JSONB columns flagged `extensible: true` — the extensible-fields bags.
124        // A non-JSON column flagged extensible is ignored (logged) since JSON-path access
125        // only makes sense on a JSON document.
126        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    // Synthesize read-only audit entities for every entity with audit_log enabled.
191    // The companion `{table}_audit` table is created by apply_migrations; here we expose it
192    // as `{path_segment}_audit` with only list + read operations.
193    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
245/// Translate a report's named-parameter SQL (`:from`, `:to`) into positional placeholders
246/// (`$1`, `$2`) and build the runtime lookup tables. Repeated named params reuse a single
247/// placeholder. String literals, quoted identifiers, and `::type` casts are skipped so they are
248/// never mistaken for a parameter.
249pub 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    // Inject declared casts onto the positional placeholders (`$1` → `$1::timestamptz`). All params
279    // bind as TEXT, so numeric/temporal comparisons need a cast; authors may also cast inline.
280    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
305/// Replace `:name` tokens with `$N` positional placeholders, returning the rewritten SQL and the
306/// ordered list of distinct param names. Skips single-quoted strings, double-quoted identifiers,
307/// and the `::` cast operator.
308fn 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                // Single-quoted string literal: copy verbatim, honoring '' escapes.
318                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                // Double-quoted identifier: copy verbatim, honoring "" escapes.
336                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                // `::` cast operator — not a named param.
354                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
392/// Rewrite each `$<n>` placeholder to `$<n>::<cast>` when `casts_by_pos` has an entry for `n`.
393/// Matches the full number so `$1` is never confused with `$10`.
394fn 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/// Precomputed cross-package include map. Built from ALL installed packages' configs so that
471/// `?include=<name>` can join a related entity that lives in a *different* package, in either
472/// direction (to_one when the FK is in the requesting table, to_many when it points back to it).
473///
474/// Keyed by `(owning_table_id, include_name)`. The `include_name` is the related entity's
475/// `path_segment`, matching the same-package include convention. Same-package relationships are
476/// intentionally skipped — they are already resolved by [`resolve`] into `ResolvedEntity::includes`.
477///
478/// Limitation: the join executes against the request's tenant pool. For Database-strategy tenants
479/// the related package must be physically migrated into that tenant's DB; if it is not, the join
480/// fails at execution time rather than here.
481#[derive(Clone, Debug, Default)]
482pub struct CrossPackageIndex {
483    entries: HashMap<(String, String), (IncludeSpec, ResolvedEntity)>,
484}
485
486impl CrossPackageIndex {
487    /// Look up a cross-package include for `table_id` by include name (the related path_segment).
488    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
497/// Build the cross-package include index by loading every installed package's config from the
498/// central config DB (`_sys_*` tables) and resolving cross-package relationships. Best-effort:
499/// packages that fail to load or resolve are skipped rather than aborting the whole index.
500pub 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    // Global maps spanning all packages.
509    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
546/// Pure relationship → cross-package include mapping. Separated from DB loading so it can be
547/// unit-tested. Only relationships whose two sides live in *different* packages produce entries;
548/// same-package relationships are handled by [`resolve`].
549fn 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; // same-package relationships are handled by resolve()
566        }
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        // to_one: the requesting (from) table holds the FK and includes the to-side entity.
577        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        // to_many: the to-side table includes the rows that point back to it via the FK.
591        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
608/// Build the column list for a synthetic audit entity.
609/// Prepends the five audit metadata columns then appends all source columns with pk_type cleared
610/// (audit_id is the new PK, so source PKs become regular queryable columns).
611fn 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        // Custom pass-through: fall back to string matching for raw SQL types.
685        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
701/// Load full config from architect._sys_* tables for one package. Tables must already exist (ensure_sys_tables).
702pub 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        // `::date` is a cast, not a param; ':x' inside a string literal is literal text.
811        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); // defaults to true
857    }
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        // $10 must not be rewritten when only $1 has a cast.
864        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    /// A cross-package FK (users in pkg A → orgs in pkg B) yields BOTH directions:
930    /// users can include `orgs` (to_one) and orgs can include `users` (to_many).
931    #[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        // to_one: users?include=orgs
960        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        // to_many: orgs?include=users
969        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    /// Same-package relationships must NOT appear in the cross-package index (resolve() owns them).
979    #[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        // Both tables in the same package.
990        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    /// When the related table has no API entity (not exposed), no include is built for it.
1014    #[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        // t_orgs has a package mapping but no path_segment (no api_entity).
1021        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}