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, process::Command};
6
7use anyhow::{Context, Result};
8use fraiseql_core::schema::{
9    CURRENT_SCHEMA_FORMAT_VERSION, CompiledSchema, FieldType, NamingConvention, canonicalize_json,
10};
11use tracing::{info, warn};
12
13use crate::{
14    config::TomlProjectConfig,
15    schema::{
16        ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter, SchemaOptimizer,
17        SchemaValidator,
18        database_validator::validate_schema_against_database,
19        mutation_contract::{Severity, validate_mutation_contract},
20        pg_catalog::PgCatalog,
21    },
22};
23
24/// Input source configuration for schema compilation.
25#[derive(Debug, Default)]
26pub struct CompileOptions<'a> {
27    /// Path to `fraiseql.toml` (TOML workflow) or `schema.json` (legacy).
28    pub input:          &'a str,
29    /// Optional path to `types.json` (TOML workflow, backward compat).
30    pub types:          Option<&'a str>,
31    /// Optional directory for schema file auto-discovery.
32    pub schema_dir:     Option<&'a str>,
33    /// Explicit type file paths (highest priority).
34    pub type_files:     Vec<String>,
35    /// Explicit query file paths.
36    pub query_files:    Vec<String>,
37    /// Explicit mutation file paths.
38    pub mutation_files: Vec<String>,
39    /// Optional database URL for indexed column validation.
40    pub database:       Option<&'a str>,
41    /// Skip embedding content hash in compiled schema (for test fixtures).
42    pub skip_hash:      bool,
43}
44
45impl<'a> CompileOptions<'a> {
46    /// Create new compile options with just the input path.
47    #[must_use]
48    pub fn new(input: &'a str) -> Self {
49        Self {
50            input,
51            ..Default::default()
52        }
53    }
54
55    /// Set the types path.
56    #[must_use]
57    pub fn with_types(mut self, types: &'a str) -> Self {
58        self.types = Some(types);
59        self
60    }
61
62    /// Set the schema directory for auto-discovery.
63    #[must_use]
64    pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
65        self.schema_dir = Some(schema_dir);
66        self
67    }
68
69    /// Set the database URL for validation.
70    #[must_use]
71    pub fn with_database(mut self, database: &'a str) -> Self {
72        self.database = Some(database);
73        self
74    }
75}
76
77/// Select and execute the appropriate schema-loading strategy for TOML-based workflows.
78///
79/// Tries strategies in priority order:
80/// 1. Explicit file lists (highest priority)
81/// 2. Directory auto-discovery
82/// 3. Single types file (backward-compatible)
83/// 4. Domain discovery → TOML includes → TOML-only (fallback sequence)
84#[allow(clippy::cognitive_complexity)] // Reason: multi-strategy schema discovery with fallback chain
85fn load_intermediate_schema(
86    toml_path: &str,
87    type_files: &[String],
88    query_files: &[String],
89    mutation_files: &[String],
90    schema_dir: Option<&str>,
91    types_path: Option<&str>,
92) -> Result<IntermediateSchema> {
93    if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
94        info!("Mode: Explicit file lists");
95        return crate::schema::SchemaMerger::merge_explicit_files(
96            toml_path,
97            type_files,
98            query_files,
99            mutation_files,
100        )
101        .context("Failed to load explicit schema files");
102    }
103    if let Some(dir) = schema_dir {
104        info!("Mode: Auto-discovery from directory: {}", dir);
105        return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
106            .context("Failed to load schema from directory");
107    }
108    if let Some(types) = types_path {
109        info!("Mode: Language + TOML (types.json + fraiseql.toml)");
110        return crate::schema::SchemaMerger::merge_files(types, toml_path)
111            .context("Failed to merge types.json with TOML");
112    }
113    info!("Mode: TOML-based (checking for domain discovery...)");
114    if let Ok(schema) = crate::schema::SchemaMerger::merge_from_domains(toml_path) {
115        return Ok(schema);
116    }
117    info!("No domains configured, checking for TOML includes...");
118    if let Ok(schema) = crate::schema::SchemaMerger::merge_with_includes(toml_path) {
119        return Ok(schema);
120    }
121    info!("No includes configured, using TOML-only definitions");
122    crate::schema::SchemaMerger::merge_toml_only(toml_path)
123        .context("Failed to load schema from TOML")
124}
125
126/// Compile a schema to `CompiledSchema` without writing to disk.
127///
128/// This is the core compilation logic, shared between `compile` (which writes to disk)
129/// and `run` (which serves in-memory without any file artifacts).
130///
131/// # Arguments
132///
133/// * `opts` - Compilation options including input paths and configuration
134///
135/// # Errors
136///
137/// Returns error if input is missing, parsing fails, validation fails, or the database
138/// connection fails (when `database` is provided).
139#[allow(clippy::cognitive_complexity)] // Reason: end-to-end compilation pipeline with validation, introspection, and output stages
140pub async fn compile_to_schema(
141    opts: CompileOptions<'_>,
142) -> Result<(CompiledSchema, OptimizationReport)> {
143    info!("Compiling schema: {}", opts.input);
144
145    // 1. Determine workflow based on input file and options
146    let input_path = Path::new(opts.input);
147    if !input_path.exists() {
148        anyhow::bail!("Input file not found: {}", opts.input);
149    }
150
151    // Load schema based on file type and options
152    let is_toml = input_path
153        .extension()
154        .and_then(|ext| ext.to_str())
155        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
156    let mut intermediate: IntermediateSchema = if is_toml {
157        info!("Using TOML-based workflow");
158        load_intermediate_schema(
159            opts.input,
160            &opts.type_files,
161            &opts.query_files,
162            &opts.mutation_files,
163            opts.schema_dir,
164            opts.types,
165        )?
166    } else {
167        // Legacy JSON workflow
168        info!("Using legacy JSON workflow");
169        let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
170
171        // 2. Parse JSON into IntermediateSchema (language-agnostic format)
172        info!("Parsing intermediate schema...");
173        serde_json::from_str(&schema_json).context("Failed to parse schema.json")?
174    };
175
176    // 2a. Load and apply security configuration from fraiseql.toml if it exists.
177    // Skip when the input itself is a TomlSchema file: in that case the security
178    // settings are embedded in the TomlSchema, and the CWD fraiseql.toml uses a
179    // different TOML format (TomlSchema vs TomlProjectConfig) that is not compatible.
180    // Opt-in mutation-error-union synthesis, read from [fraiseql.mutations] below.
181    let mut auto_error_union = false;
182    // Casing acronyms from [fraiseql.naming], added on top of the built-in defaults.
183    let mut naming_acronyms: Vec<String> = Vec::new();
184    // Per-operation @cost weight overrides from [fraiseql.cost_weights] (#379).
185    let mut operation_cost_weights: std::collections::HashMap<String, usize> =
186        std::collections::HashMap::new();
187    // GraphQL surface convention for the legacy JSON (Workflow-B) path. Defaults
188    // to camelCase (snake_case DB, camelCase client surface + input recasing);
189    // [fraiseql.naming] convention = "preserve" restores the as-authored names.
190    // The TomlSchema path carries its own naming_convention via the merger and is
191    // left untouched (see the `if !is_toml` apply below).
192    let mut naming_convention = NamingConvention::CamelCase;
193    if !is_toml && Path::new("fraiseql.toml").exists() {
194        info!("Loading security configuration from fraiseql.toml...");
195        match TomlProjectConfig::from_file("fraiseql.toml") {
196            Ok(config) => {
197                info!("Validating security configuration...");
198                config.validate()?;
199
200                auto_error_union = config.fraiseql.mutations.auto_error_union;
201                naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
202                operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
203                naming_convention = config.fraiseql.naming.convention;
204
205                info!("Applying security configuration to schema...");
206                // Merge security config into intermediate schema
207                let mut security_json = config.fraiseql.security.to_json();
208
209                // Embed tenancy configuration into the security section
210                if !matches!(
211                    config.fraiseql.tenancy.mode,
212                    crate::config::security::TenancyModeConfig::None
213                ) {
214                    security_json["tenancy"] = config.fraiseql.tenancy.to_json();
215                }
216
217                intermediate.security = Some(security_json);
218
219                info!("Security configuration applied successfully");
220            },
221            Err(e) => {
222                anyhow::bail!(
223                    "Failed to parse fraiseql.toml: {e}\n\
224                     Fix the configuration file or remove it to use defaults."
225                );
226            },
227        }
228    } else {
229        info!("No fraiseql.toml found, using default security configuration");
230    }
231
232    // Install the project's casing acronyms so compile-time key inference
233    // (native-column resolution, DDL) agrees with the runtime. Defaults apply
234    // when none are configured.
235    fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
236
237    // Apply the naming convention to the legacy JSON (Workflow-B) path. The
238    // JSON schema never carries one, so without this it would fall to the enum
239    // default (Preserve); Workflow-B instead defaults to CamelCase (overridable
240    // via [fraiseql.naming].convention). The TomlSchema path already carries its
241    // own convention from the merger, so it is left untouched.
242    if !is_toml {
243        intermediate.naming_convention = naming_convention;
244    }
245
246    // 2b. Validate @tenant_id annotations when tenancy mode is "row".
247    // Extract tenancy config from the already-embedded security JSON.
248    let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
249        let tenancy = sec.get("tenancy")?;
250        let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
251        if mode == "row" {
252            Some(
253                tenancy
254                    .get("tenantClaim")
255                    .and_then(|c| c.as_str())
256                    .unwrap_or("tenant_id")
257                    .to_string(),
258            )
259        } else {
260            None
261        }
262    });
263    if let Some(tenant_claim) = &tenancy_row_claim {
264        info!("Validating @tenant_id annotations for row-isolation tenancy...");
265        crate::schema::converter::tenancy::validate_tenant_annotations(
266            &mut intermediate,
267            tenant_claim,
268        )
269        .context("@tenant_id validation failed")?;
270    }
271
272    // 3. Validate intermediate schema
273    info!("Validating schema structure...");
274    let validation_report =
275        SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
276
277    if !validation_report.is_valid() {
278        validation_report.print();
279        anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
280    }
281
282    // Print warnings if any
283    if validation_report.warning_count() > 0 {
284        validation_report.print();
285    }
286
287    // 4. Convert to CompiledSchema (validates and normalizes)
288    info!("Converting to compiled format...");
289    let mut schema =
290        SchemaConverter::convert_with_options(intermediate, &ConvertOptions { auto_error_union })
291            .context("Failed to convert schema to compiled format")?;
292
293    // Carry the project's casing acronyms into the compiled schema so the runtime
294    // installs them at boot (see `fraiseql_db::utils::set_runtime_acronyms`).
295    schema.naming_acronyms = naming_acronyms;
296
297    // Carry the project's @cost weight overrides (#379) into the compiled schema so
298    // the runtime per-tenant cost-budget check can apply them. Non-clobbering: only
299    // overwrite when configured, leaving any value a future merger path may set.
300    if !operation_cost_weights.is_empty() {
301        schema.operation_cost_weights = operation_cost_weights;
302    }
303
304    // 5. Optimize schema and generate SQL hints (mutates schema in place, report for display)
305    info!("Analyzing schema for optimization opportunities...");
306    let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
307
308    // 5a. Stamp schema format version for runtime compatibility checks.
309    schema.schema_format_version = Some(CURRENT_SCHEMA_FORMAT_VERSION);
310
311    // 5b-pre. Infer native_columns for ID/UUID-typed arguments on JSONB-backed queries.
312    // DB introspection (step 5b) overrides these inferred values when `--database` is provided.
313    infer_native_columns_from_arg_types(&mut schema);
314
315    // 5b. Optional: Validate indexed columns and native columns against database.
316    if let Some(db_url) = opts.database {
317        info!("Validating indexed columns against database...");
318        validate_indexed_columns(&schema, db_url).await?;
319
320        info!("Validating native columns for direct query arguments...");
321        let pg_introspector = build_postgres_introspector(db_url)
322            .context("Failed to connect for native column validation")?;
323        let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
324        for w in &db_report.warnings {
325            warn!("{w}");
326        }
327        // Patch QueryDefinitions with DB-discovered native_columns, overriding inferred values.
328        for query in &mut schema.queries {
329            if let Some(cols) = db_report.native_columns.get(&query.name) {
330                query.native_columns = cols.clone();
331            }
332        }
333
334        // Mutation call/response contract (#384 item 3: inject_params resolve to real
335        // function arguments). PostgreSQL-only — the catalog reads `pg_proc`; other
336        // dialects are validated structurally above. Advisory like the rest of
337        // `compile --database`: findings warn, they never fail the compile.
338        if db_url.starts_with("postgres") {
339            info!("Validating mutation contract against the database...");
340            let catalog = PgCatalog::connect(db_url)
341                .context("Failed to connect for mutation-contract validation")?;
342            let contract = validate_mutation_contract(&schema, &catalog).await?;
343            for m in &contract.mutations {
344                for v in &m.violations {
345                    let kind = match v.severity() {
346                        Severity::Error => "contract error",
347                        Severity::Warn => "contract warning",
348                    };
349                    warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
350                }
351            }
352        }
353    } else {
354        // Warn for queries that still have unresolved direct arguments after inference.
355        // Arguments already covered by native_columns inference are not warned about.
356        for query in &schema.queries {
357            if query.sql_source.is_none() {
358                continue;
359            }
360            let unresolved: Vec<_> = query
361                .arguments
362                .iter()
363                .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
364                .filter(|a| !query.native_columns.contains_key(&a.name))
365                .collect();
366            if !unresolved.is_empty() {
367                let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
368                warn!(
369                    "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
370                     columns — no --database URL provided. These filters will use JSONB \
371                     extraction. Provide --database or annotate with native_columns.",
372                    query.name,
373                    names,
374                    query.sql_source.as_deref().unwrap_or("?"),
375                );
376            }
377        }
378    }
379
380    // 5c. Warn when SQLite is the target but the schema uses features SQLite doesn't support.
381    check_sqlite_compatibility_warnings(&schema, opts.input, is_toml, opts.database);
382
383    // 5d. Warn when mutations have wide invalidation fan-out (HOT update pressure).
384    warn_wide_cascade_mutations(&schema);
385
386    Ok((schema, report))
387}
388
389/// Run the compile command
390///
391/// # Arguments
392///
393/// * `input` - Path to fraiseql.toml (TOML) or schema.json (legacy)
394/// * `types` - Optional path to types.json (when using TOML workflow)
395/// * `schema_dir` - Optional directory for auto-discovery of schema files
396/// * `type_files` - Optional vector of explicit type file paths
397/// * `query_files` - Optional vector of explicit query file paths
398/// * `mutation_files` - Optional vector of explicit mutation file paths
399/// * `output` - Path to write schema.compiled.json
400/// * `check` - If true, validate only without writing output
401/// * `database` - Optional database URL for indexed column validation
402/// * `emit_ddl` - Optional directory to write `CREATE TABLE` DDL files (confiture format)
403/// * `check_migrations` - If true, run `confiture migrate validate` after compilation
404/// * `skip_hash` - Skip embedding content hash (for test fixtures)
405///
406/// # Errors
407///
408/// Returns error if:
409/// - Input file doesn't exist or can't be read
410/// - JSON/TOML parsing fails
411/// - Schema validation fails
412/// - Output file can't be written
413/// - Database connection fails (when database URL is provided)
414/// - DDL output directory cannot be created (when `emit_ddl` is provided)
415/// - `confiture` is not installed (when `check_migrations` is true)
416/// - Migration drift detected (when `check_migrations` is true)
417#[allow(clippy::too_many_arguments)]
418// Reason: run() is the CLI entry point that receives individual args from clap; keeping them
419// separate for clarity
420#[doc(hidden)] // Internal-pub: CLI entry point dispatched by runner.rs; not a stable downstream API.
421pub async fn run(
422    input: &str,
423    types: Option<&str>,
424    schema_dir: Option<&str>,
425    type_files: Vec<String>,
426    query_files: Vec<String>,
427    mutation_files: Vec<String>,
428    output: &str,
429    check: bool,
430    database: Option<&str>,
431    emit_ddl: Option<&str>,
432    check_migrations: bool,
433    skip_hash: bool,
434) -> Result<()> {
435    // Defense-in-depth: never write the compiled output over the input schema.
436    // The removed `serve` command did exactly this (H23) by deriving an output
437    // path identical to its input via a faulty extension swap. `--check` writes
438    // nothing, so the guard only applies to a real write.
439    if !check && Path::new(output) == Path::new(input) {
440        anyhow::bail!(
441            "Refusing to write compiled output over the input file '{input}'. \
442             Use a distinct --output path (e.g. schema.compiled.json)."
443        );
444    }
445
446    let opts = CompileOptions {
447        input,
448        types,
449        schema_dir,
450        type_files,
451        query_files,
452        mutation_files,
453        database,
454        skip_hash,
455    };
456    let (schema, optimization_report) = compile_to_schema(opts).await?;
457
458    // If check-only mode, stop here
459    if check {
460        println!("✓ Schema is valid");
461        println!("  Types: {}", schema.types.len());
462        println!("  Queries: {}", schema.queries.len());
463        println!("  Mutations: {}", schema.mutations.len());
464        optimization_report.print();
465        return Ok(());
466    }
467
468    // Write compiled schema
469    info!("Writing compiled schema to: {output}");
470    let output_json = if skip_hash {
471        serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?
472    } else {
473        use sha2::{Digest, Sha256};
474
475        let body =
476            serde_json::to_string_pretty(&schema).context("Failed to serialize compiled schema")?;
477        let value: serde_json::Value = serde_json::from_str(&body)?;
478        // Canonicalize (recursively sort keys) before hashing — matches from_json verifier
479        let canonical = serde_json::to_string_pretty(&canonicalize_json(&value))?;
480        let hash = Sha256::digest(canonical.as_bytes());
481        let hash_hex = hex::encode(&hash[..16]);
482
483        let obj = value.as_object().context("schema must serialise as JSON object")?;
484
485        // Insert _content_hash as the first field (serde_json::Map preserves insertion order)
486        let mut new_obj = serde_json::Map::new();
487        new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
488        for (k, v) in obj {
489            new_obj.insert(k.clone(), v.clone());
490        }
491        serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
492    };
493
494    fs::write(output, output_json).context("Failed to write compiled schema")?;
495
496    // Success message
497    println!("✓ Schema compiled successfully");
498    println!("  Input:  {input}");
499    println!("  Output: {output}");
500    println!("  Types: {}", schema.types.len());
501    println!("  Queries: {}", schema.queries.len());
502    println!("  Mutations: {}", schema.mutations.len());
503    optimization_report.print();
504
505    // Emit DDL to directory if requested
506    if let Some(ddl_dir) = emit_ddl {
507        emit_ddl_to_dir(&schema, ddl_dir)?;
508    }
509
510    // Check for migration drift if requested
511    if check_migrations {
512        run_check_migrations(&schema)?;
513    }
514
515    Ok(())
516}
517
518/// Emit `CREATE TABLE` DDL files for all compiled schema types to `output_dir`.
519///
520/// Each type produces one `<type_snake_case>.sql` file containing a `CREATE TABLE IF NOT EXISTS`
521/// statement. The output directory is created if it does not already exist.
522///
523/// Output is compatible with confiture's `db/schema/` directory format, so that
524/// `fraiseql migrate generate` can auto-detect drift between the compiled schema and the
525/// current migrations.
526///
527/// # Errors
528///
529/// Returns an error if the output directory cannot be created, or if any DDL file
530/// cannot be written.
531pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
532    fs::create_dir_all(output_dir)
533        .context(format!("Failed to create DDL output directory: {output_dir}"))?;
534
535    let mut count = 0;
536    for type_def in &schema.types {
537        let table_name = to_snake_case(type_def.name.as_str());
538        let ddl = build_create_table_ddl(&table_name, type_def);
539
540        let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
541        fs::write(&file_path, ddl)
542            .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
543        count += 1;
544    }
545
546    println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
547    Ok(())
548}
549
550/// Delegate to `confiture migrate validate` for migration drift detection.
551///
552/// Emits DDL to a temporary directory, then invokes confiture. Exits non-zero when
553/// drift is detected, printing a friendly remediation hint.
554///
555/// # Errors
556///
557/// Returns an error if confiture is not installed, if the temp directory cannot be
558/// created, or if confiture reports drift or validation failures.
559fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
560    let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
561    let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
562
563    emit_ddl_to_dir(schema, tmp_path)?;
564
565    info!("Running confiture migrate validate for drift detection...");
566
567    let status = Command::new("confiture").args(["migrate", "validate"]).status();
568
569    match status {
570        Err(_) => {
571            // confiture not installed — warn but don't fail the build
572            eprintln!(
573                "WARN: confiture is not installed; skipping migration drift check.\n\
574                 Install it with: cargo install confiture"
575            );
576            Ok(())
577        },
578        Ok(s) if s.success() => {
579            println!("✓ No migration drift detected.");
580            Ok(())
581        },
582        Ok(_) => {
583            eprintln!(
584                "WARN: compiled schema diverges from database — run fraiseql migrate generate"
585            );
586            anyhow::bail!(
587                "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
588            )
589        },
590    }
591}
592
593/// Convert a `PascalCase` or `camelCase` type name to `snake_case`.
594pub(crate) fn to_snake_case(name: &str) -> String {
595    let mut result = String::with_capacity(name.len() + 4);
596    for (i, ch) in name.chars().enumerate() {
597        if ch.is_uppercase() && i > 0 {
598            result.push('_');
599        }
600        result.extend(ch.to_lowercase());
601    }
602    result
603}
604
605/// Generate a `CREATE TABLE IF NOT EXISTS` DDL statement for a compiled type definition.
606fn build_create_table_ddl(
607    table_name: &str,
608    type_def: &fraiseql_core::schema::TypeDefinition,
609) -> String {
610    let mut lines: Vec<String> = Vec::new();
611    lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
612    lines.push(format!("-- Type: {}", type_def.name));
613    if let Some(desc) = &type_def.description {
614        lines.push(format!("-- {desc}"));
615    }
616    lines.push(String::new());
617    lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
618
619    let col_lines: Vec<String> = type_def
620        .fields
621        .iter()
622        .map(|field| {
623            let col_name = to_snake_case(field.name.as_str());
624            let pg_type = field_type_to_pg(&field.field_type);
625            let nullable = if field.nullable { "" } else { " NOT NULL" };
626            format!("    {col_name} {pg_type}{nullable}")
627        })
628        .collect();
629
630    let last = col_lines.len().saturating_sub(1);
631    for (i, col) in col_lines.iter().enumerate() {
632        if i < last {
633            lines.push(format!("{col},"));
634        } else {
635            lines.push(col.clone());
636        }
637    }
638
639    lines.push(");".to_string());
640    lines.push(String::new());
641    lines.join("\n")
642}
643
644/// Map a `FieldType` to its PostgreSQL column type string.
645pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
646    match ft {
647        FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
648        FieldType::Int => "INTEGER".to_string(),
649        FieldType::Float => "DOUBLE PRECISION".to_string(),
650        FieldType::Boolean => "BOOLEAN".to_string(),
651        FieldType::Id | FieldType::Uuid => "UUID".to_string(),
652        FieldType::DateTime => "TIMESTAMPTZ".to_string(),
653        FieldType::Date => "DATE".to_string(),
654        FieldType::Time => "TIME".to_string(),
655        FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
656        FieldType::Decimal => "NUMERIC".to_string(),
657        FieldType::Vector => "VECTOR".to_string(),
658        // Use the actual Postgres enum type name so DDL matches the schema.
659        FieldType::Enum(name) => name.clone(),
660        FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
661        // FieldType is #[non_exhaustive]; future variants default to TEXT.
662        _ => "TEXT".to_string(),
663    }
664}
665
666/// Emit warnings when schema uses features that SQLite does not support.
667///
668/// SQLite executes direct-SQL Insert/Delete mutations, but lacks Update /
669/// stored-procedure (`fn_*`) mutations and relay/subscription support. A
670/// compile-time warning helps catch this before runtime failures.
671fn check_sqlite_compatibility_warnings(
672    schema: &CompiledSchema,
673    input_path: &str,
674    is_toml: bool,
675    database_url: Option<&str>,
676) {
677    let target_is_sqlite = database_url
678        .is_some_and(|url| url.to_ascii_lowercase().starts_with("sqlite://"))
679        || is_toml && detect_sqlite_target_in_toml(input_path);
680
681    if !target_is_sqlite {
682        return;
683    }
684
685    // SQLite executes direct-SQL Insert/Delete mutations via the `DirectSql` strategy;
686    // only Update and custom / stored-procedure (`fn_*`) mutations are unsupported.
687    let unsupported_mutation_count = schema
688        .mutations
689        .iter()
690        .filter(|m| {
691            matches!(
692                m.operation,
693                fraiseql_core::schema::MutationOperation::Update { .. }
694                    | fraiseql_core::schema::MutationOperation::Custom
695            )
696        })
697        .count();
698    let relay_count = schema.queries.iter().filter(|q| q.relay).count();
699    let subscription_count = schema.subscriptions.len();
700
701    if unsupported_mutation_count > 0 {
702        warn!(
703            "Schema contains {} Update or custom mutation(s) but target database is SQLite. \
704             SQLite supports only direct-SQL Insert/Delete mutations. \
705             See: https://fraiseql.dev/docs/database-compatibility",
706            unsupported_mutation_count,
707        );
708    }
709    if relay_count > 0 {
710        warn!(
711            "Schema contains {} relay query/queries but target database is SQLite. \
712             Relay (keyset pagination) is not supported on SQLite. \
713             See: https://fraiseql.dev/docs/database-compatibility",
714            relay_count,
715        );
716    }
717    if subscription_count > 0 {
718        warn!(
719            "Schema contains {} subscription(s) but target database is SQLite. \
720             Subscriptions are not supported on SQLite. \
721             See: https://fraiseql.dev/docs/database-compatibility",
722            subscription_count,
723        );
724    }
725}
726
727/// Check if the TOML schema file specifies `database_target = "sqlite"`.
728///
729/// Reads and parses the TOML to extract the schema metadata. Returns `false`
730/// on any parse error (non-fatal — warning detection is best-effort).
731fn detect_sqlite_target_in_toml(toml_path: &str) -> bool {
732    let Ok(content) = fs::read_to_string(toml_path) else {
733        return false;
734    };
735    let Ok(toml_schema) = toml::from_str::<crate::config::toml_schema::TomlSchema>(&content) else {
736        return false;
737    };
738    toml_schema.schema.database_target.to_ascii_lowercase().contains("sqlite")
739}
740
741/// Minimum distinct invalidation targets (views + fact tables) that triggers
742/// the HOT-update fan-out warning.
743pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
744
745/// Return mutations whose total invalidation fan-out meets or exceeds `threshold`.
746///
747/// Fan-out is the count of distinct views (`invalidates_views`) plus fact tables
748/// (`invalidates_fact_tables`) that a mutation touches on every successful write.
749/// Used by [`warn_wide_cascade_mutations`] and exposed for unit testing.
750pub(crate) fn wide_cascade_mutations(
751    schema: &CompiledSchema,
752    threshold: usize,
753) -> Vec<&fraiseql_core::schema::MutationDefinition> {
754    schema
755        .mutations
756        .iter()
757        .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
758        .collect()
759}
760
761/// Emit a warning for each mutation whose invalidation fan-out is wide enough
762/// to risk exhausting PostgreSQL HOT-update page slots under high write load.
763///
764/// When a mutation touches many tables on every write, the free space reserved
765/// on each heap page (needed for HOT updates) fills up quickly. Subsequent
766/// mutations must write to a new page instead of updating in place, which
767/// increases I/O and table bloat. Setting `fillfactor=70-80` on the backing
768/// tables leaves 20-30 % of each page free, keeping HOT updates available.
769///
770/// The warning lists ready-to-run `ALTER TABLE … SET (fillfactor = 75)` statements
771/// derived from the view names using FraiseQL naming conventions
772/// (`tv_foo` / `v_foo` → `tb_foo`).
773fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
774    for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
775        let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
776
777        // Build a sorted, deduplicated target list for a stable message.
778        let mut targets: Vec<&str> = mutation
779            .invalidates_views
780            .iter()
781            .chain(mutation.invalidates_fact_tables.iter())
782            .map(String::as_str)
783            .collect();
784        targets.sort_unstable();
785        targets.dedup();
786
787        // Derive a likely backing-table name from FraiseQL view naming conventions.
788        // tv_foo → tb_foo, v_foo → tb_foo, anything else (e.g. fact tables) unchanged.
789        let alter_stmts: Vec<String> = targets
790            .iter()
791            .map(|&name| {
792                let table = name
793                    .strip_prefix("tv_")
794                    .or_else(|| name.strip_prefix("v_"))
795                    .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
796                format!("ALTER TABLE {table} SET (fillfactor = 75);")
797            })
798            .collect();
799
800        warn!(
801            "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
802             Under high write load, HOT-update page slots on these tables may be \
803             exhausted, forcing full-page writes and reducing mutation throughput. \
804             Set fillfactor=70-80 on the backing tables: {}  \
805             Monitor HOT efficiency: SELECT relname, \
806             n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
807             FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
808            mutation.name,
809            total,
810            targets.join(", "),
811            alter_stmts.join("  "),
812        );
813    }
814}
815
816/// Build a PostgreSQL introspector connected to `db_url`.
817///
818/// Shared by `validate_indexed_columns` and the native column validation path.
819///
820/// # Errors
821///
822/// Returns error if the pool cannot be created or the connection URL is invalid.
823fn build_postgres_introspector(
824    db_url: &str,
825) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
826    use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
827    use tokio_postgres::NoTls;
828
829    let mut cfg = Config::new();
830    cfg.url = Some(db_url.to_string());
831    cfg.manager = Some(ManagerConfig {
832        recycling_method: RecyclingMethod::Fast,
833    });
834    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
835
836    let pool = cfg
837        .create_pool(Some(Runtime::Tokio1), NoTls)
838        .context("Failed to create connection pool for database validation")?;
839
840    Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
841}
842
843/// Validate indexed columns against database views.
844///
845/// Connects to the database and introspects view columns to verify that
846/// any indexed column naming conventions are properly set up.
847///
848/// # Arguments
849///
850/// * `schema` - The compiled schema to validate
851/// * `db_url` - Database connection URL
852///
853/// # Errors
854///
855/// Returns error if database connection fails. Warnings are printed for
856/// missing indexed columns but don't cause validation to fail.
857async fn validate_indexed_columns(schema: &CompiledSchema, db_url: &str) -> Result<()> {
858    use deadpool_postgres::{Config, ManagerConfig, RecyclingMethod, Runtime};
859    use fraiseql_core::db::postgres::PostgresIntrospector;
860    use tokio_postgres::NoTls;
861
862    // Create pool for introspection
863    let mut cfg = Config::new();
864    cfg.url = Some(db_url.to_string());
865    cfg.manager = Some(ManagerConfig {
866        recycling_method: RecyclingMethod::Fast,
867    });
868    cfg.pool = Some(deadpool_postgres::PoolConfig::new(2));
869
870    let pool = cfg
871        .create_pool(Some(Runtime::Tokio1), NoTls)
872        .context("Failed to create connection pool for indexed column validation")?;
873
874    let introspector = PostgresIntrospector::new(pool);
875
876    let mut total_indexed = 0;
877    let mut total_views = 0;
878
879    // Check each query's sql_source (view)
880    for query in &schema.queries {
881        if let Some(view_name) = &query.sql_source {
882            total_views += 1;
883
884            // Get indexed columns for this view
885            match introspector.get_indexed_nested_columns(view_name).await {
886                Ok(indexed_cols) => {
887                    if !indexed_cols.is_empty() {
888                        info!(
889                            "View '{}': found {} indexed column(s): {:?}",
890                            view_name,
891                            indexed_cols.len(),
892                            indexed_cols
893                        );
894                        total_indexed += indexed_cols.len();
895                    }
896                },
897                Err(e) => {
898                    warn!(
899                        "Could not introspect view '{}': {}. Skipping indexed column check.",
900                        view_name, e
901                    );
902                },
903            }
904        }
905    }
906
907    println!("✓ Indexed column validation complete");
908    println!("  Views checked: {total_views}");
909    println!("  Indexed columns found: {total_indexed}");
910
911    Ok(())
912}
913
914/// Auto-param names excluded from `native_columns` inference and JSONB-extraction warnings.
915const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
916    "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
917];
918
919/// Infer `native_columns` for `ID`/`UUID`-typed arguments on JSONB-backed queries.
920///
921/// When a query reads from a JSONB table (`sql_source` + non-empty `jsonb_column`) and an
922/// argument is typed [`FieldType::Id`] or [`FieldType::Uuid`], the argument name almost
923/// certainly maps to a native UUID column alongside the `data` JSONB column
924/// (e.g. `id UUID NOT NULL`). Emitting `WHERE id = $1::uuid` instead of
925/// `WHERE data->>'id' = $1` lets the planner use the B-tree index without
926/// needing a database connection at compile time.
927///
928/// Auto-param names (`where`, `limit`, `offset`, etc.) are skipped.
929/// Arguments already present in `native_columns` are not overridden.
930pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
931    for query in &mut schema.queries {
932        if query.sql_source.is_none() || query.jsonb_column.is_empty() {
933            continue;
934        }
935        for arg in &query.arguments {
936            if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
937                continue;
938            }
939            if query.native_columns.contains_key(&arg.name) {
940                continue; // already explicitly declared — don't override
941            }
942            if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
943                query.native_columns.insert(arg.name.clone(), "uuid".to_string());
944            }
945        }
946    }
947}