Skip to main content

fraiseql_cli/commands/
compile.rs

1//! Schema compilation command
2//!
3//! Compiles schema.json (from Python/TypeScript/etc.) into optimized schema.compiled.json
4
5use std::{fs, path::Path};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType};
9use tracing::{info, warn};
10
11use crate::{
12    config::TomlProjectConfig,
13    schema::{
14        IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer, SchemaValidator,
15        database_validator::validate_schema_against_database,
16    },
17};
18
19/// Input source configuration for schema compilation.
20#[derive(Debug, Default)]
21pub struct CompileOptions<'a> {
22    /// Path to `fraiseql.toml` (TOML workflow) or `schema.json` (legacy).
23    pub input:          &'a str,
24    /// Optional path to `types.json` (TOML workflow, backward compat).
25    pub types:          Option<&'a str>,
26    /// Optional directory for schema file auto-discovery.
27    pub schema_dir:     Option<&'a str>,
28    /// Explicit type file paths (highest priority).
29    pub type_files:     Vec<String>,
30    /// Explicit query file paths.
31    pub query_files:    Vec<String>,
32    /// Explicit mutation file paths.
33    pub mutation_files: Vec<String>,
34    /// Optional database URL for indexed column validation.
35    pub database:       Option<&'a str>,
36}
37
38impl<'a> CompileOptions<'a> {
39    /// Create new compile options with just the input path.
40    #[must_use]
41    pub fn new(input: &'a str) -> Self {
42        Self {
43            input,
44            ..Default::default()
45        }
46    }
47
48    /// Set the types path.
49    #[must_use]
50    pub fn with_types(mut self, types: &'a str) -> Self {
51        self.types = Some(types);
52        self
53    }
54
55    /// Set the schema directory for auto-discovery.
56    #[must_use]
57    pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
58        self.schema_dir = Some(schema_dir);
59        self
60    }
61
62    /// Set the database URL for validation.
63    #[must_use]
64    pub fn with_database(mut self, database: &'a str) -> Self {
65        self.database = Some(database);
66        self
67    }
68}
69
70/// Select and execute the appropriate schema-loading strategy for TOML-based workflows.
71///
72/// Tries strategies in priority order:
73/// 1. Explicit file lists (highest priority)
74/// 2. Directory auto-discovery
75/// 3. Single types file (backward-compatible)
76/// 4. Domain discovery → TOML includes → TOML-only (fallback sequence)
77#[allow(clippy::cognitive_complexity)] // Reason: multi-strategy schema discovery with fallback chain
78fn load_intermediate_schema(
79    toml_path: &str,
80    type_files: &[String],
81    query_files: &[String],
82    mutation_files: &[String],
83    schema_dir: Option<&str>,
84    types_path: Option<&str>,
85) -> Result<IntermediateSchema> {
86    if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
87        info!("Mode: Explicit file lists");
88        return crate::schema::SchemaMerger::merge_explicit_files(
89            toml_path,
90            type_files,
91            query_files,
92            mutation_files,
93        )
94        .context("Failed to load explicit schema files");
95    }
96    if let Some(dir) = schema_dir {
97        info!("Mode: Auto-discovery from directory: {}", dir);
98        return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
99            .context("Failed to load schema from directory");
100    }
101    if let Some(types) = types_path {
102        info!("Mode: Language + TOML (types.json + fraiseql.toml)");
103        return crate::schema::SchemaMerger::merge_files(types, toml_path)
104            .context("Failed to merge types.json with TOML");
105    }
106    info!("Mode: TOML-based (checking for domain discovery...)");
107    if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
108        return Ok(schema);
109    }
110    info!("No domains configured, checking for TOML includes...");
111    if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
112        return Ok(schema);
113    }
114    info!("No includes configured, using TOML-only definitions");
115    crate::schema::SchemaMerger::merge_toml_only(toml_path)
116        .context("Failed to load schema from TOML")
117}
118
119/// Compile a schema to `CompiledSchema` without writing to disk.
120///
121/// This is the core compilation logic, shared between `compile` (which writes to disk)
122/// and `run` (which serves in-memory without any file artifacts).
123///
124/// # Arguments
125///
126/// * `opts` - Compilation options including input paths and configuration
127///
128/// # Errors
129///
130/// Returns error if input is missing, parsing fails, validation fails, or the database
131/// connection fails (when `database` is provided).
132#[allow(clippy::cognitive_complexity)] // Reason: end-to-end compilation pipeline with validation, introspection, and output stages
133pub async fn compile_to_schema(
134    opts: CompileOptions<'_>,
135) -> Result<(CompiledSchema, OptimizationReport)> {
136    info!("Compiling schema: {}", opts.input);
137
138    // 1. Determine workflow based on input file and options
139    let input_path = Path::new(opts.input);
140    if !input_path.exists() {
141        anyhow::bail!("Input file not found: {}", opts.input);
142    }
143
144    // Load schema based on file type and options
145    let is_toml = input_path
146        .extension()
147        .and_then(|ext| ext.to_str())
148        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
149    let mut intermediate: IntermediateSchema = if is_toml {
150        info!("Using TOML-based workflow");
151        load_intermediate_schema(
152            opts.input,
153            &opts.type_files,
154            &opts.query_files,
155            &opts.mutation_files,
156            opts.schema_dir,
157            opts.types,
158        )?
159    } else {
160        // Legacy JSON workflow
161        info!("Using legacy JSON workflow");
162        let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
163
164        // 2. Parse JSON into IntermediateSchema (language-agnostic format)
165        info!("Parsing intermediate schema...");
166        serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
167    };
168
169    // 2a. Load and apply security configuration from fraiseql.toml if it exists.
170    // Skip when the input itself is a TomlSchema file: in that case the security
171    // settings are embedded in the TomlSchema, and the CWD fraiseql.toml uses a
172    // different TOML format (TomlSchema vs TomlProjectConfig) that is not compatible.
173    if !is_toml && Path::new("fraiseql.toml").exists() {
174        info!("Loading security configuration from fraiseql.toml...");
175        match TomlProjectConfig::from_file("fraiseql.toml") {
176            Ok(config) => {
177                info!("Validating security configuration...");
178                config.validate()?;
179
180                info!("Applying security configuration to schema...");
181                // Merge security config into intermediate schema
182                let security_json = config.fraiseql.security.to_json();
183                intermediate.security = Some(security_json);
184
185                info!("Security configuration applied successfully");
186            },
187            Err(e) => {
188                anyhow::bail!(
189                    "Failed to parse fraiseql.toml: {e}\n\
190                     Fix the configuration file or remove it to use defaults."
191                );
192            },
193        }
194    } else {
195        info!("No fraiseql.toml found, using default security configuration");
196    }
197
198    // 3. Validate intermediate schema
199    info!("Validating schema structure...");
200    let validation_report =
201        SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
202
203    if !validation_report.is_valid() {
204        validation_report.print();
205        anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
206    }
207
208    // Print warnings if any
209    if validation_report.warning_count() > 0 {
210        validation_report.print();
211    }
212
213    // 4. Convert to CompiledSchema (validates and normalizes)
214    info!("Converting to compiled format...");
215    let mut schema = SchemaConverter::convert(intermediate)
216        .context("Failed to convert schema to compiled format")?;
217
218    // 5. Optimize schema and generate SQL hints (mutates schema in place, report for display)
219    info!("Analyzing schema for optimization opportunities...");
220    let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
221
222    // 5a. Stamp schema format version for runtime compatibility checks.
223    schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
224
225    // 5b-pre. Infer native_columns for ID/UUID-typed arguments on JSONB-backed queries.
226    // DB introspection (step 5b) overrides these inferred values when `--database` is provided.
227    infer_native_columns_from_arg_types(&mut schema);
228
229    // 5b. Optional: Validate indexed columns and native columns against database.
230    if let Some(db_url) = opts.database {
231        info!("Validating indexed columns against database...");
232        validate_indexed_columns(&schema, db_url).await?;
233
234        info!("Validating native columns for direct query arguments...");
235        let pg_introspector = build_postgres_introspector(db_url)
236            .context("Failed to connect for native column validation")?;
237        let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
238        for w in &db_report.warnings {
239            warn!("{w}");
240        }
241        // Patch QueryDefinitions with DB-discovered native_columns, overriding inferred values.
242        for query in &mut schema.queries {
243            if let Some(cols) = db_report.native_columns.get(&query.name) {
244                query.native_columns = cols.clone();
245            }
246        }
247    } else {
248        // Warn for queries that still have unresolved direct arguments after inference.
249        // Arguments already covered by native_columns inference are not warned about.
250        for query in &schema.queries {
251            if query.sql_source.is_none() {
252                continue;
253            }
254            let unresolved: Vec<_> = query
255                .arguments
256                .iter()
257                .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
258                .filter(|a| !query.native_columns.contains_key(&a.name))
259                .collect();
260            if !unresolved.is_empty() {
261                let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
262                warn!(
263                    "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
264                     columns — no --database URL provided. These filters will use JSONB \
265                     extraction. Provide --database or annotate with native_columns.",
266                    query.name,
267                    names,
268                    query.sql_source.as_deref().unwrap_or("?"),
269                );
270            }
271        }
272    }
273
274    // 5c. Warn when SQLite is the target but the schema uses features SQLite doesn't support.
275    check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
276
277    // 5d. Warn when mutations have wide invalidation fan-out (HOT update pressure).
278    warn_wide_cascade_mutations(&schema);
279
280    Ok((schema, report))
281}
282
283/// Run the compile command
284///
285/// # Arguments
286///
287/// * `input` - Path to fraiseql.toml (TOML) or schema.json (legacy)
288/// * `types` - Optional path to types.json (when using TOML workflow)
289/// * `schema_dir` - Optional directory for auto-discovery of schema files
290/// * `type_files` - Optional vector of explicit type file paths
291/// * `query_files` - Optional vector of explicit query file paths
292/// * `mutation_files` - Optional vector of explicit mutation file paths
293/// * `output` - Path to write schema.compiled.json
294/// * `check` - If true, validate only without writing output
295/// * `database` - Optional database URL for indexed column validation
296///
297/// # Workflows
298///
299/// 1. TOML-only: `fraiseql compile fraiseql.toml`
300/// 2. Language + TOML: `fraiseql compile fraiseql.toml --types types.json`
301/// 3. Multi-file auto-discovery: `fraiseql compile fraiseql.toml --schema-dir schema/`
302/// 4. Multi-file explicit: `fraiseql compile fraiseql.toml --type-file a.json --type-file b.json`
303/// 5. Legacy JSON: `fraiseql compile schema.json`
304///
305/// # Errors
306///
307/// Returns error if:
308/// - Input file doesn't exist or can't be read
309/// - JSON/TOML parsing fails
310/// - Schema validation fails
311/// - Output file can't be written
312/// - Database connection fails (when database URL is provided)
313#[allow(clippy::too_many_arguments)] // Reason: run() is the CLI entry point that receives individual args from clap; keeping them separate for clarity
314pub async fn run(
315    input: &str,
316    types: Option<&str>,
317    schema_dir: Option<&str>,
318    type_files: Vec<String>,
319    query_files: Vec<String>,
320    mutation_files: Vec<String>,
321    output: &str,
322    check: bool,
323    database: Option<&str>,
324) -> Result<()> {
325    let opts = CompileOptions {
326        input,
327        types,
328        schema_dir,
329        type_files,
330        query_files,
331        mutation_files,
332        database,
333    };
334    let (schema, optimization_report) = compile_to_schema(opts).await?;
335
336    // If check-only mode, stop here
337    if check {
338        println!("✓ Schema is valid");
339        println!("  Types: {}", schema.types.len());
340        println!("  Queries: {}", schema.queries.len());
341        println!("  Mutations: {}", schema.mutations.len());
342        optimization_report.print();
343        return Ok(());
344    }
345
346    // Write compiled schema
347    info!("Writing compiled schema to: {output}");
348    let output_json =
349        serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
350    fs::write(output, output_json).context("Failed to write compiled schema")?;
351
352    // Success message
353    println!("✓ Schema compiled successfully");
354    println!("  Input:  {input}");
355    println!("  Output: {output}");
356    println!("  Types: {}", schema.types.len());
357    println!("  Queries: {}", schema.queries.len());
358    println!("  Mutations: {}", schema.mutations.len());
359    optimization_report.print();
360
361    Ok(())
362}
363
364/// Emit warnings when schema uses features that SQLite does not support.
365///
366/// SQLite lacks stored procedures (mutations) and relay/subscription support.
367/// A compile-time warning helps catch this before runtime failures.
368fn check_sqlite_compatibility_warnings(
369    schema: &CompiledSchema,
370    input_path: &str,
371    is_toml: bool,
372    database_url: Option<&str>,
373) {
374    let target_is_sqlite = database_url
375        .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
376        || is_toml && detect_sqlite_target_in_toml(input_path);
377
378    if !target_is_sqlite {
379        return;
380    }
381
382    let mutation_count = schema.mutations.len();
383    let relay_count = schema.queries.iter().filter(|q| q.relay).count();
384    let subscription_count = schema.subscriptions.len();
385
386    if mutation_count > 0 {
387        warn!(
388            "Schema contains {} mutation(s) but target database is SQLite. \
389             Mutations are not supported on SQLite. \
390             See: https://fraiseql.dev/docs/database-compatibility",
391            mutation_count,
392        );
393    }
394    if relay_count > 0 {
395        warn!(
396            "Schema contains {} relay query/queries but target database is SQLite. \
397             Relay (keyset pagination) is not supported on SQLite. \
398             See: https://fraiseql.dev/docs/database-compatibility",
399            relay_count,
400        );
401    }
402    if subscription_count > 0 {
403        warn!(
404            "Schema contains {} subscription(s) but target database is SQLite. \
405             Subscriptions are not supported on SQLite. \
406             See: https://fraiseql.dev/docs/database-compatibility",
407            subscription_count,
408        );
409    }
410}
411
412/// Check if the TOML schema file specifies `database_target = "sqlite"`.
413///
414/// Reads and parses the TOML to extract the schema metadata. Returns `false`
415/// on any parse error (non-fatal — warning detection is best-effort).
416fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
417    let Ok(content) = fs::read_to_string(toml_path) else {
418        return false;
419    };
420    let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
421        return false;
422    };
423    toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
424}
425
426/// Minimum distinct invalidation targets (views + fact tables) that triggers
427/// the HOT-update fan-out warning.
428const WIDE_FANOUT_THRESHOLD: usize = 3;
429
430/// Return mutations whose total invalidation fan-out meets or exceeds `threshold`.
431///
432/// Fan-out is the count of distinct views (`invalidates_views`) plus fact tables
433/// (`invalidates_fact_tables`) that a mutation touches on every successful write.
434/// Used by [`warn_wide_cascade_mutations`] and exposed for unit testing.
435fn wide_cascade_mutations(
436    schema: &CompiledSchema,
437    threshold: usize,
438) -> Vec<&fraiseql_core::schema::MutationDefinition> {
439    schema
440        .mutations
441        .iter()
442        .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
443        .collect()
444}
445
446/// Emit a warning for each mutation whose invalidation fan-out is wide enough
447/// to risk exhausting PostgreSQL HOT-update page slots under high write load.
448///
449/// When a mutation touches many tables on every write, the free space reserved
450/// on each heap page (needed for HOT updates) fills up quickly. Subsequent
451/// mutations must write to a new page instead of updating in place, which
452/// increases I/O and table bloat. Setting `fillfactor=70-80` on the backing
453/// tables leaves 20-30 % of each page free, keeping HOT updates available.
454///
455/// The warning lists ready-to-run `ALTER TABLE … SET (fillfactor = 75)` statements
456/// derived from the view names using FraiseQL naming conventions
457/// (`tv_foo` / `v_foo` → `tb_foo`).
458fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
459    for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
460        let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
461
462        // Build a sorted, deduplicated target list for a stable message.
463        let mut targets: Vec<&str> = mutation
464            .invalidates_views
465            .iter()
466            .chain(mutation.invalidates_fact_tables.iter())
467            .map(String::as_str)
468            .collect();
469        targets.sort_unstable();
470        targets.dedup();
471
472        // Derive a likely backing-table name from FraiseQL view naming conventions.
473        // tv_foo → tb_foo, v_foo → tb_foo, anything else (e.g. fact tables) unchanged.
474        let alter_stmts: Vec<String> = targets
475            .iter()
476            .map(|&name| {
477                let table = name
478                    .strip_prefix("tv_")
479                    .or_else(|| name.strip_prefix("v_"))
480                    .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
481                format!("ALTER TABLE {table} SET (fillfactor = 75);")
482            })
483            .collect();
484
485        warn!(
486            "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
487             Under high write load, HOT-update page slots on these tables may be \
488             exhausted, forcing full-page writes and reducing mutation throughput. \
489             Set fillfactor=70-80 on the backing tables: {}  \
490             Monitor HOT efficiency: SELECT relname, \
491             n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
492             FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
493            mutation.name,
494            total,
495            targets.join(", "),
496            alter_stmts.join("  "),
497        );
498    }
499}
500
501/// Build a PostgreSQL introspector connected to `db_url`.
502///
503/// Shared by `validate_indexed_columns` and the native column validation path.
504///
505/// # Errors
506///
507/// Returns error if the pool cannot be created or the connection URL is invalid.
508fn build_postgres_introspector(
509    db_url: &str,
510) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
511    use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
512    use tokio_postgres::NoTls;
513
514    let mut cfg = Config::new();
515    cfg.url = Some(db_url.to_string());
516    cfg.manager = Some(ManagerConfig {
517        recycling_method: RecyclingMethod::Fast,
518    });
519    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
520
521    let pool = cfg
522        .create_pool(Some(Runtime::Tokio1), NoTls)
523        .context("Failed to create connection pool for database validation")?;
524
525    Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
526}
527
528/// Validate indexed columns against database views.
529///
530/// Connects to the database and introspects view columns to verify that
531/// any indexed column naming conventions are properly set up.
532///
533/// # Arguments
534///
535/// * `schema` - The compiled schema to validate
536/// * `db_url` - Database connection URL
537///
538/// # Errors
539///
540/// Returns error if database connection fails. Warnings are printed for
541/// missing indexed columns but don't cause validation to fail.
542async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
543    use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
544    use fraiseql_core::db::postgres::PostgresIntrospector;
545    use tokio_postgres::NoTls;
546
547    // Create pool for introspection
548    let mut cfg = Config::new();
549    cfg.url = Some(db_url.to_string());
550    cfg.manager = Some(ManagerConfig {
551        recycling_method: RecyclingMethod::Fast,
552    });
553    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
554
555    let pool = cfg
556        .create_pool(Some(Runtime::Tokio1), NoTls)
557        .context("Failed to create connection pool for indexed column validation")?;
558
559    let introspector = PostgresIntrospector::new(pool);
560
561    let mut total_indexed = 0;
562    let mut total_views = 0;
563
564    // Check each query's sql_source (view)
565    for query in &schema.queries {
566        if let Some(view_name) = &query.sql_source {
567            total_views += 1;
568
569            // Get indexed columns for this view
570            match introspector.get_indexed_nested_columns(view_name).await {
571                Ok(indexed_cols) => {
572                    if !indexed_cols.is_empty() {
573                        info!(
574                            "View '{}': found {} indexed column(s): {:?}",
575                            view_name,
576                            indexed_cols.len(),
577                            indexed_cols
578                        );
579                        total_indexed += indexed_cols.len();
580                    }
581                },
582                Err(e) => {
583                    warn!(
584                        "Could not introspect view '{}': {}. Skipping indexed column check.",
585                        view_name, e
586                    );
587                },
588            }
589        }
590    }
591
592    println!("✓ Indexed column validation complete");
593    println!("  Views checked: {total_views}");
594    println!("  Indexed columns found: {total_indexed}");
595
596    Ok(())
597}
598
599/// Auto-param names excluded from `native_columns` inference and JSONB-extraction warnings.
600const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
601    "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
602];
603
604/// Infer `native_columns` for `ID`/`UUID`-typed arguments on JSONB-backed queries.
605///
606/// When a query reads from a JSONB table (`sql_source` + non-empty `jsonb_column`) and an
607/// argument is typed [`FieldType::Id`] or [`FieldType::Uuid`], the argument name almost
608/// certainly maps to a native UUID column alongside the `data` JSONB column
609/// (e.g. `id UUID NOT NULL`). Emitting `WHERE id = $1::uuid` instead of
610/// `WHERE data->>'id' = $1` lets the planner use the B-tree index without
611/// needing a database connection at compile time.
612///
613/// Auto-param names (`where`, `limit`, `offset`, etc.) are skipped.
614/// Arguments already present in `native_columns` are not overridden.
615fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
616    for query in &mut schema.queries {
617        if query.sql_source.is_none() || query.jsonb_column.is_empty() {
618            continue;
619        }
620        for arg in &query.arguments {
621            if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
622                continue;
623            }
624            if query.native_columns.contains_key(&arg.name) {
625                continue; // already explicitly declared — don't override
626            }
627            if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
628                query.native_columns.insert(arg.name.clone(), "uuid".to_string());
629            }
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use std::collections::HashMap;
637
638    use fraiseql_core::{
639        schema::{
640            ArgumentDefinition, AutoParams, CompiledSchema, CursorType, FieldDefinition,
641            FieldDenyPolicy, FieldType, MutationDefinition, QueryDefinition, TypeDefinition,
642        },
643        validation::CustomTypeRegistry,
644    };
645    use indexmap::IndexMap;
646
647    use super::{
648        WIDE_FANOUT_THRESHOLD, infer_native_columns_from_arg_types, wide_cascade_mutations,
649    };
650
651    fn mutation_with_fanout(
652        name: &str,
653        views: &[&str],
654        fact_tables: &[&str],
655    ) -> MutationDefinition {
656        let mut m = MutationDefinition::new(name, "SomeResult");
657        m.invalidates_views = views.iter().map(|s| (*s).to_string()).collect();
658        m.invalidates_fact_tables = fact_tables.iter().map(|s| (*s).to_string()).collect();
659        m
660    }
661
662    #[test]
663    fn test_wide_cascade_below_threshold_not_flagged() {
664        let schema = CompiledSchema {
665            mutations: vec![mutation_with_fanout("update", &["tv_user", "tv_post"], &[])],
666            ..Default::default()
667        };
668        assert!(
669            wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty(),
670            "2 targets is below threshold of 3"
671        );
672    }
673
674    #[test]
675    fn test_wide_cascade_at_threshold_flagged() {
676        let schema = CompiledSchema {
677            mutations: vec![mutation_with_fanout(
678                "updateUserWithPosts",
679                &["tv_user", "tv_post", "tv_comment"],
680                &[],
681            )],
682            ..Default::default()
683        };
684        let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
685        assert_eq!(flagged.len(), 1);
686        assert_eq!(flagged[0].name, "updateUserWithPosts");
687    }
688
689    #[test]
690    fn test_wide_cascade_views_plus_fact_tables_counted_together() {
691        let schema = CompiledSchema {
692            mutations: vec![mutation_with_fanout(
693                "createOrder",
694                &["tv_order", "tv_order_item"],
695                &["tf_sales"],
696            )],
697            ..Default::default()
698        };
699        let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
700        assert_eq!(flagged.len(), 1, "2 views + 1 fact table = 3 total, meets threshold");
701    }
702
703    #[test]
704    fn test_wide_cascade_only_wide_mutations_flagged() {
705        let schema = CompiledSchema {
706            mutations: vec![
707                mutation_with_fanout("narrow", &["tv_user"], &[]),
708                mutation_with_fanout("wide", &["tv_user", "tv_post", "tv_comment"], &[]),
709            ],
710            ..Default::default()
711        };
712        let flagged = wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD);
713        assert_eq!(flagged.len(), 1);
714        assert_eq!(flagged[0].name, "wide");
715    }
716
717    #[test]
718    fn test_wide_cascade_no_mutations_no_warnings() {
719        let schema = CompiledSchema::default();
720        assert!(wide_cascade_mutations(&schema, WIDE_FANOUT_THRESHOLD).is_empty());
721    }
722
723    #[test]
724    fn test_validate_schema_success() {
725        let schema = CompiledSchema {
726            types: vec![TypeDefinition {
727                name:                "User".into(),
728                fields:              vec![
729                    FieldDefinition {
730                        name:           "id".into(),
731                        field_type:     FieldType::Int,
732                        nullable:       false,
733                        default_value:  None,
734                        description:    None,
735                        vector_config:  None,
736                        alias:          None,
737                        deprecation:    None,
738                        requires_scope: None,
739                        on_deny:        FieldDenyPolicy::default(),
740                        encryption:     None,
741                    },
742                    FieldDefinition {
743                        name:           "name".into(),
744                        field_type:     FieldType::String,
745                        nullable:       false,
746                        default_value:  None,
747                        description:    None,
748                        vector_config:  None,
749                        alias:          None,
750                        deprecation:    None,
751                        requires_scope: None,
752                        on_deny:        FieldDenyPolicy::default(),
753                        encryption:     None,
754                    },
755                ],
756                description:         Some("User type".to_string()),
757                sql_source:          String::new().into(),
758                jsonb_column:        String::new(),
759                sql_projection_hint: None,
760                implements:          vec![],
761                requires_role:       None,
762                is_error:            false,
763                relay:               false,
764                relationships:       Vec::new(),
765            }],
766            queries: vec![QueryDefinition {
767                name:                "users".to_string(),
768                return_type:         "User".to_string(),
769                returns_list:        true,
770                nullable:            false,
771                arguments:           vec![],
772                sql_source:          Some("v_user".to_string()),
773                description:         Some("Get users".to_string()),
774                auto_params:         AutoParams::default(),
775                deprecation:         None,
776                jsonb_column:        "data".to_string(),
777                relay:               false,
778                relay_cursor_column: None,
779                relay_cursor_type:   CursorType::default(),
780                inject_params:       IndexMap::default(),
781                cache_ttl_seconds:   None,
782                additional_views:    vec![],
783                requires_role:       None,
784                rest_path:           None,
785                rest_method:         None,
786                native_columns:      HashMap::new(),
787            }],
788            enums: vec![],
789            input_types: vec![],
790            interfaces: vec![],
791            unions: vec![],
792            mutations: vec![],
793            subscriptions: vec![],
794            directives: vec![],
795            observers: Vec::new(),
796            fact_tables: HashMap::default(),
797            federation: None,
798            security: None,
799            observers_config: None,
800            subscriptions_config: None,
801            validation_config: None,
802            debug_config: None,
803            mcp_config: None,
804            schema_sdl: None,
805            // None is intentional here: this struct is used only for in-process
806            // validation assertions and is never serialised to disk. The real
807            // compile path stamps the version at compile_impl() line 220.
808            schema_format_version: None,
809            custom_scalars: CustomTypeRegistry::default(),
810            ..Default::default()
811        };
812
813        // Validation is done inside SchemaConverter::convert, not exposed separately
814        // This test just verifies we can build a valid schema structure
815        assert_eq!(schema.types.len(), 1);
816        assert_eq!(schema.queries.len(), 1);
817    }
818
819    #[test]
820    fn test_validate_schema_unknown_type() {
821        let schema = CompiledSchema {
822            types: vec![],
823            enums: vec![],
824            input_types: vec![],
825            interfaces: vec![],
826            unions: vec![],
827            queries: vec![QueryDefinition {
828                name:                "users".to_string(),
829                return_type:         "UnknownType".to_string(),
830                returns_list:        true,
831                nullable:            false,
832                arguments:           vec![],
833                sql_source:          Some("v_user".to_string()),
834                description:         Some("Get users".to_string()),
835                auto_params:         AutoParams::default(),
836                deprecation:         None,
837                jsonb_column:        "data".to_string(),
838                relay:               false,
839                relay_cursor_column: None,
840                relay_cursor_type:   CursorType::default(),
841                inject_params:       IndexMap::default(),
842                cache_ttl_seconds:   None,
843                additional_views:    vec![],
844                requires_role:       None,
845                rest_path:           None,
846                rest_method:         None,
847                native_columns:      HashMap::new(),
848            }],
849            mutations: vec![],
850            subscriptions: vec![],
851            directives: vec![],
852            observers: Vec::new(),
853            fact_tables: HashMap::default(),
854            federation: None,
855            security: None,
856            observers_config: None,
857            subscriptions_config: None,
858            validation_config: None,
859            debug_config: None,
860            mcp_config: None,
861            schema_sdl: None,
862            // None is intentional here: this struct is used only for in-process
863            // validation assertions and is never serialised to disk. The real
864            // compile path stamps the version at compile_impl() line 220.
865            schema_format_version: None,
866            custom_scalars: CustomTypeRegistry::default(),
867            ..Default::default()
868        };
869
870        // Note: Validation is private to SchemaConverter
871        // This test demonstrates the schema structure with an invalid type
872        assert_eq!(schema.types.len(), 0);
873        assert_eq!(schema.queries[0].return_type, "UnknownType");
874    }
875
876    fn make_query(
877        name: &str,
878        sql_source: Option<&str>,
879        jsonb_column: &str,
880        args: Vec<(&str, FieldType)>,
881        native_columns: std::collections::HashMap<String, String>,
882    ) -> QueryDefinition {
883        QueryDefinition {
884            name: name.to_string(),
885            return_type: "T".to_string(),
886            returns_list: false,
887            nullable: true,
888            arguments: args.into_iter().map(|(n, t)| ArgumentDefinition::new(n, t)).collect(),
889            sql_source: sql_source.map(str::to_string),
890            jsonb_column: jsonb_column.to_string(),
891            native_columns,
892            auto_params: AutoParams::default(),
893            ..Default::default()
894        }
895    }
896
897    #[test]
898    fn test_infer_id_arg_becomes_uuid_native_column() {
899        let mut schema = CompiledSchema {
900            queries: vec![make_query(
901                "user",
902                Some("tv_user"),
903                "data",
904                vec![("id", FieldType::Id)],
905                std::collections::HashMap::new(),
906            )],
907            ..Default::default()
908        };
909        infer_native_columns_from_arg_types(&mut schema);
910        assert_eq!(
911            schema.queries[0].native_columns.get("id").map(String::as_str),
912            Some("uuid"),
913            "ID-typed arg should be inferred as uuid native column"
914        );
915    }
916
917    #[test]
918    fn test_infer_uuid_arg_becomes_uuid_native_column() {
919        let mut schema = CompiledSchema {
920            queries: vec![make_query(
921                "user",
922                Some("tv_user"),
923                "data",
924                vec![("userId", FieldType::Uuid)],
925                std::collections::HashMap::new(),
926            )],
927            ..Default::default()
928        };
929        infer_native_columns_from_arg_types(&mut schema);
930        assert_eq!(
931            schema.queries[0].native_columns.get("userId").map(String::as_str),
932            Some("uuid")
933        );
934    }
935
936    #[test]
937    fn test_infer_does_not_override_explicit_declaration() {
938        let mut explicit = std::collections::HashMap::new();
939        explicit.insert("id".to_string(), "text".to_string()); // explicit, non-uuid
940        let mut schema = CompiledSchema {
941            queries: vec![make_query(
942                "user",
943                Some("tv_user"),
944                "data",
945                vec![("id", FieldType::Id)],
946                explicit,
947            )],
948            ..Default::default()
949        };
950        infer_native_columns_from_arg_types(&mut schema);
951        // explicit "text" must not be overridden by the inferred "uuid"
952        assert_eq!(
953            schema.queries[0].native_columns.get("id").map(String::as_str),
954            Some("text"),
955            "explicit native_columns declaration must win over inference"
956        );
957    }
958
959    #[test]
960    fn test_infer_skips_queries_without_sql_source() {
961        let mut schema = CompiledSchema {
962            queries: vec![make_query(
963                "user",
964                None,
965                "data",
966                vec![("id", FieldType::Id)],
967                std::collections::HashMap::new(),
968            )],
969            ..Default::default()
970        };
971        infer_native_columns_from_arg_types(&mut schema);
972        assert!(
973            schema.queries[0].native_columns.is_empty(),
974            "queries without sql_source must not get inferred native_columns"
975        );
976    }
977
978    #[test]
979    fn test_infer_skips_queries_without_jsonb_column() {
980        let mut schema = CompiledSchema {
981            queries: vec![make_query(
982                "user",
983                Some("v_user"),
984                "", // no jsonb_column — plain column view
985                vec![("id", FieldType::Id)],
986                std::collections::HashMap::new(),
987            )],
988            ..Default::default()
989        };
990        infer_native_columns_from_arg_types(&mut schema);
991        assert!(
992            schema.queries[0].native_columns.is_empty(),
993            "queries without jsonb_column must not get inferred native_columns"
994        );
995    }
996
997    #[test]
998    fn test_infer_skips_non_id_types() {
999        let mut schema = CompiledSchema {
1000            queries: vec![make_query(
1001                "user",
1002                Some("tv_user"),
1003                "data",
1004                vec![("username", FieldType::String), ("age", FieldType::Int)],
1005                std::collections::HashMap::new(),
1006            )],
1007            ..Default::default()
1008        };
1009        infer_native_columns_from_arg_types(&mut schema);
1010        assert!(
1011            schema.queries[0].native_columns.is_empty(),
1012            "String/Int args must not be inferred as native columns"
1013        );
1014    }
1015
1016    #[test]
1017    fn test_infer_skips_auto_param_names() {
1018        let mut schema = CompiledSchema {
1019            queries: vec![make_query(
1020                "users",
1021                Some("tv_user"),
1022                "data",
1023                vec![
1024                    ("where", FieldType::Id),
1025                    ("limit", FieldType::Id),
1026                    ("orderBy", FieldType::Id),
1027                ],
1028                std::collections::HashMap::new(),
1029            )],
1030            ..Default::default()
1031        };
1032        infer_native_columns_from_arg_types(&mut schema);
1033        assert!(
1034            schema.queries[0].native_columns.is_empty(),
1035            "auto-param names must never be inferred as native columns even if typed ID"
1036        );
1037    }
1038}