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