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    CompiledSchema, FieldType, InputStyle, MutationOperation, NamingConvention, content_hash_of,
10};
11use fraiseql_db::postgres::PostgresTlsConfig;
12use tracing::{info, warn};
13
14use crate::{
15    config::{ConfigSource, TomlProjectConfig},
16    schema::{
17        CompiledArtifact, ConvertOptions, IntermediateSchema, OptimizationReport, SchemaConverter,
18        SchemaOptimizer, 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    /// Downgrade error-severity schema↔database drift findings to advisories
45    /// (#384). Without this, error-severity drift fails the compile.
46    pub allow_drift:    bool,
47    /// The project config to compile with. `None` resolves it from `input` with
48    /// [`ConfigSource::resolve`], exactly as `compile` and `run` do — never from the
49    /// working directory (#1387).
50    pub config:         Option<ConfigSource>,
51}
52
53impl<'a> CompileOptions<'a> {
54    /// Create new compile options with just the input path.
55    #[must_use]
56    pub fn new(input: &'a str) -> Self {
57        Self {
58            input,
59            ..Default::default()
60        }
61    }
62
63    /// Set the types path.
64    #[must_use]
65    pub fn with_types(mut self, types: &'a str) -> Self {
66        self.types = Some(types);
67        self
68    }
69
70    /// Set the schema directory for auto-discovery.
71    #[must_use]
72    pub fn with_schema_dir(mut self, schema_dir: &'a str) -> Self {
73        self.schema_dir = Some(schema_dir);
74        self
75    }
76
77    /// Set the database URL for validation.
78    #[must_use]
79    pub fn with_database(mut self, database: &'a str) -> Self {
80        self.database = Some(database);
81        self
82    }
83}
84
85/// Select and execute the appropriate schema-loading strategy for TOML-based workflows.
86///
87/// Tries strategies in priority order:
88/// 1. Explicit file lists (highest priority)
89/// 2. Directory auto-discovery
90/// 3. Single types file (backward-compatible)
91/// 4. Domain discovery → TOML includes → TOML-only (fallback sequence)
92///
93/// # Errors
94///
95/// Returns an error if a **configured** schema source fails to load. Distinguishing
96/// "not configured" (fall through to the next strategy) from "configured but failed"
97/// (propagate) is the substance of #723 — see the fallback sequence below.
98#[allow(clippy::cognitive_complexity)] // Reason: multi-strategy schema discovery with fallback chain
99pub fn load_intermediate_schema(
100    toml_path: &str,
101    type_files: &[String],
102    query_files: &[String],
103    mutation_files: &[String],
104    schema_dir: Option<&str>,
105    types_path: Option<&str>,
106) -> Result<IntermediateSchema> {
107    if !type_files.is_empty() || !query_files.is_empty() || !mutation_files.is_empty() {
108        info!("Mode: Explicit file lists");
109        return crate::schema::SchemaMerger::merge_explicit_files(
110            toml_path,
111            type_files,
112            query_files,
113            mutation_files,
114        )
115        .context("Failed to load explicit schema files");
116    }
117    if let Some(dir) = schema_dir {
118        info!("Mode: Auto-discovery from directory: {}", dir);
119        return crate::schema::SchemaMerger::merge_from_directory(toml_path, dir)
120            .context("Failed to load schema from directory");
121    }
122    if let Some(types) = types_path {
123        info!("Mode: Language + TOML (types.json + fraiseql.toml)");
124        return crate::schema::SchemaMerger::merge_files(types, toml_path)
125            .context("Failed to merge types.json with TOML");
126    }
127    // Fallback sequence: domain discovery → TOML includes → TOML-only.
128    //
129    // Each step asks "is this configured?" **before** attempting it, so a failure inside a
130    // configured source propagates instead of being swallowed by the next fallback (#723).
131    //
132    // This used to be `if let Ok(schema) = merge_from_domains(toml_path)`, twice. A bad
133    // `root_dir`, an unreadable domain file or a JSON parse error was discarded and
134    // compilation fell through to TOML-only definitions — producing either a schema silently
135    // missing the user's domain types, or a later death with the misleading "Failed to load
136    // schema from TOML" naming the wrong thing entirely. That directly contradicts the #612
137    // doctrine this codebase otherwise holds to: configured input that fails must fail loud.
138    let toml_schema = crate::config::TomlSchema::from_file(toml_path)
139        .context(format!("Failed to load TOML from {toml_path}"))?;
140
141    if toml_schema.domain_discovery.enabled {
142        info!("Mode: TOML-based with domain discovery");
143        return crate::schema::SchemaMerger::merge_from_domains(toml_path).context(
144            "Failed to load schema from the configured [domain_discovery] section. Fix the \
145             error above, or remove the section to compile from TOML definitions only.",
146        );
147    }
148
149    if !toml_schema.includes.is_empty() {
150        info!("Mode: TOML-based with schema includes");
151        return crate::schema::SchemaMerger::merge_with_includes(toml_path).context(
152            "Failed to load schema from the configured [includes] section. Fix the error \
153             above, or remove the section to compile from TOML definitions only.",
154        );
155    }
156
157    info!("No domains or includes configured, using TOML-only definitions");
158    crate::schema::SchemaMerger::merge_toml_only(toml_path)
159        .context("Failed to load schema from TOML")
160}
161
162/// Compile a schema to the whole `schema.compiled.json` artifact, without writing it.
163///
164/// This is the core compilation logic, shared between `compile` (which writes to disk)
165/// and `run` (which serves in-memory without any file artifacts).
166///
167/// # Arguments
168///
169/// * `opts` - Compilation options including input paths and configuration
170///
171/// # Errors
172///
173/// Returns error if input is missing, parsing fails, validation fails, or the database
174/// connection fails (when `database` is provided).
175#[allow(clippy::cognitive_complexity)] // Reason: end-to-end compilation pipeline with validation, introspection, and output stages
176pub async fn compile_to_schema(
177    opts: CompileOptions<'_>,
178) -> Result<(CompiledArtifact, OptimizationReport)> {
179    info!("Compiling schema: {}", opts.input);
180
181    // A removed engine's URL is refused before any work, naming the PostgreSQL-only rule
182    // (#1341), rather than failing inside the connection pool at step 5b.
183    if let Some(db_url) = opts.database {
184        crate::connection::require_postgres(db_url)?;
185    }
186
187    // 1. Determine workflow based on input file and options
188    let input_path = Path::new(opts.input);
189    if !input_path.exists() {
190        anyhow::bail!("Input file not found: {}", opts.input);
191    }
192
193    // Load schema based on file type and options
194    let is_toml = input_path
195        .extension()
196        .and_then(|ext| ext.to_str())
197        .is_some_and(|ext| ext.eq_ignore_ascii_case("toml"));
198    let mut intermediate: IntermediateSchema = if is_toml {
199        info!("Using TOML-based workflow");
200        load_intermediate_schema(
201            opts.input,
202            &opts.type_files,
203            &opts.query_files,
204            &opts.mutation_files,
205            opts.schema_dir,
206            opts.types,
207        )?
208    } else {
209        // Legacy JSON workflow
210        info!("Using legacy JSON workflow");
211        let schema_json = fs::read_to_string(input_path).context("Failed to read schema.json")?;
212
213        // 2. Parse JSON into IntermediateSchema (language-agnostic format). Parse via Value first
214        //    so we can detect a federation block that the SDK emitted but that failed to bind into
215        //    the schema (the silent-drop class this issue fixed): the block must carry through or
216        //    fail loudly, never vanish into a non-federated subgraph.
217        info!("Parsing intermediate schema...");
218        let raw: serde_json::Value =
219            serde_json::from_str(&schema_json).context("Failed to parse schema.json")?;
220
221        // Refuse a security control declared under a key that does not bind (#806/#807).
222        // Must run on the raw JSON: after deserialization the evidence is gone, which is
223        // precisely the defect — an unread key becomes an empty default and nothing
224        // downstream can distinguish "no scope declared" from "scope declared and lost".
225        crate::schema::intermediate::reject_drifted_keys(&raw)
226            .map_err(|e| anyhow::anyhow!("{e}"))?;
227
228        let input_has_federation = ["federation", "federation_config"].iter().any(|key| {
229            raw.get(*key)
230                .and_then(serde_json::Value::as_object)
231                .is_some_and(|o| !o.is_empty())
232        });
233        let intermediate: IntermediateSchema =
234            serde_json::from_value(raw).context("Failed to parse schema.json")?;
235        if input_has_federation && intermediate.federation_config.is_none() {
236            anyhow::bail!(
237                "schema.json carries a `federation` block that did not bind into the compiled \
238                 schema — refusing to compile a silently non-federated subgraph. This is a \
239                 compiler bug; please report it."
240            );
241        }
242        intermediate
243    };
244
245    // 2a. Load and apply the project config that belongs to this input (#1387).
246    // A TOML input carries its settings itself (`ConfigSource::Input` names no
247    // separate file): it is a TomlSchema, a different format from TomlProjectConfig.
248    let config_source = match opts.config {
249        Some(source) => source,
250        None => ConfigSource::resolve(input_path, None)?,
251    };
252    info!("Project config: {config_source}");
253    // Opt-in mutation-error-union synthesis, read from [fraiseql.mutations] below.
254    let mut auto_error_union = false;
255    // Casing acronyms from [fraiseql.naming], added on top of the built-in defaults.
256    let mut naming_acronyms: Vec<String> = Vec::new();
257    // Per-operation @cost weight overrides from [fraiseql.cost_weights] (#379).
258    let mut operation_cost_weights: std::collections::HashMap<String, usize> =
259        std::collections::HashMap::new();
260    // GraphQL surface convention for the legacy JSON (Workflow-B) path. Defaults
261    // to camelCase (snake_case DB, camelCase client surface + input recasing);
262    // [fraiseql.naming] convention = "preserve" restores the as-authored names.
263    // The TomlSchema path carries its own naming_convention via the merger and is
264    // left untouched (see the `if !is_toml` apply below).
265    let mut naming_convention = NamingConvention::CamelCase;
266    // Transport security for the `--database` checks (#1429): `[database] ssl_mode` from
267    // the config this compile loaded. Unset leaves the URL's own `?sslmode=` in charge.
268    let mut database_tls = if is_toml && opts.database.is_some() {
269        crate::config::TomlSchema::from_file(opts.input)
270            .with_context(|| format!("Failed to load TOML from {}", opts.input))?
271            .database
272            .postgres_tls()?
273    } else {
274        PostgresTlsConfig::default()
275    };
276    if let Some(config_path) = config_source.project_config() {
277        info!("Loading security configuration from {}...", config_path.display());
278        let config_file = config_path.to_str().ok_or_else(|| {
279            anyhow::anyhow!("Config path is not valid UTF-8: {}", config_path.display())
280        })?;
281        match TomlProjectConfig::from_file(config_file) {
282            Ok(config) => {
283                info!("Validating security configuration...");
284                config.validate()?;
285
286                auto_error_union = config.fraiseql.mutations.auto_error_union;
287                naming_acronyms.clone_from(&config.fraiseql.naming.acronyms);
288                operation_cost_weights.clone_from(&config.fraiseql.cost_weights);
289                naming_convention = config.fraiseql.naming.convention;
290                database_tls = config.database.postgres_tls()?;
291
292                info!("Applying security configuration to schema...");
293                // Merge security config into intermediate schema
294                let mut security_json = config.fraiseql.security.to_json();
295
296                // Embed tenancy configuration into the security section
297                if !matches!(
298                    config.fraiseql.tenancy.mode,
299                    crate::config::security::TenancyModeConfig::None
300                ) {
301                    security_json["tenancy"] = config.fraiseql.tenancy.to_json();
302                }
303
304                intermediate.security = Some(security_json);
305
306                // Session variables are the mechanism RLS policies read; carry them
307                // through rather than leaving `schema.json` as the only way to
308                // declare them (#628).
309                if config.fraiseql.session_variables
310                    != fraiseql_core::schema::SessionVariablesConfig::default()
311                {
312                    intermediate.session_variables = Some(config.fraiseql.session_variables);
313                }
314
315                // `[inject_defaults]` lives in the config the SDK loaders read (#1384); a
316                // copy the SDK emitted into the schema must agree with it.
317                intermediate.inject_defaults =
318                    crate::config::inject_defaults::InjectDefaultsToml::reconcile(
319                        config.inject_defaults.as_ref(),
320                        intermediate.inject_defaults.take(),
321                    )?;
322
323                info!("Security configuration applied successfully");
324            },
325            Err(e) => {
326                anyhow::bail!(
327                    "Failed to parse {}: {e}\n\
328                     Fix the configuration file or remove it to use defaults.",
329                    config_path.display()
330                );
331            },
332        }
333    }
334
335    // Install the project's casing acronyms so compile-time key inference
336    // (native-column resolution, DDL) agrees with the runtime. Defaults apply
337    // when none are configured.
338    fraiseql_core::utils::casing::set_runtime_acronyms(&naming_acronyms);
339
340    // Apply the naming convention to the legacy JSON (Workflow-B) path. The
341    // JSON schema never carries one, so without this it would fall to the enum
342    // default (Preserve); Workflow-B instead defaults to CamelCase (overridable
343    // via [fraiseql.naming].convention). The TomlSchema path already carries its
344    // own convention from the merger, so it is left untouched.
345    if !is_toml {
346        intermediate.naming_convention = naming_convention;
347    }
348
349    // 2b. Validate @tenant_id annotations when tenancy mode is "row".
350    // Extract tenancy config from the already-embedded security JSON.
351    let tenancy_row_claim: Option<String> = intermediate.security.as_ref().and_then(|sec| {
352        let tenancy = sec.get("tenancy")?;
353        let mode = tenancy.get("mode").and_then(|m| m.as_str()).unwrap_or("none");
354        if mode == "row" {
355            // `tenant_claim`, matching `fraiseql_core::schema::TenancyConfig`. This
356            // read `tenantClaim` while the runtime read `tenant_claim`, so the
357            // compiler validated `@tenant_id` against one claim and the server
358            // injected another (#757).
359            Some(
360                tenancy
361                    .get("tenant_claim")
362                    .and_then(|c| c.as_str())
363                    .unwrap_or("tenant_id")
364                    .to_string(),
365            )
366        } else {
367            None
368        }
369    });
370    if let Some(tenant_claim) = &tenancy_row_claim {
371        info!("Validating @tenant_id annotations for row-isolation tenancy...");
372        crate::schema::converter::tenancy::validate_tenant_annotations(
373            &mut intermediate,
374            tenant_claim,
375        )
376        .context("@tenant_id validation failed")?;
377    }
378
379    // 3. Validate intermediate schema
380    info!("Validating schema structure...");
381    let validation_report =
382        SchemaValidator::validate(&intermediate).context("Failed to validate schema")?;
383
384    if !validation_report.is_valid() {
385        validation_report.print();
386        anyhow::bail!("Schema validation failed with {} error(s)", validation_report.error_count());
387    }
388
389    // Print warnings if any
390    if validation_report.warning_count() > 0 {
391        validation_report.print();
392    }
393
394    // 4. Convert to the compiled artifact (validates and normalizes). `functions` is
395    // a sibling section of the compiled schema, not a field of it, so the converter
396    // hands both halves back and the rest of this pipeline sharpens the schema half.
397    // Which parameters each mutation receives from `[inject_defaults]` rather than declares
398    // (#1385), so a contract error a default caused can name the default instead of
399    // repeating once per mutation. Computed by the converter's own rule; a refusal that
400    // rule raises is the converter's to report.
401    let mutation_defaults =
402        intermediate.inject_defaults.clone().unwrap_or_default().for_mutations();
403    let default_args: std::collections::HashMap<String, Vec<String>> = intermediate
404        .mutations
405        .iter()
406        .filter_map(|m| {
407            let mut inject = m.inject.clone();
408            crate::schema::intermediate::IntermediateInjectDefaults::apply_to(
409                &mutation_defaults,
410                &mut inject,
411                &m.exclude_inject_defaults,
412                &m.name,
413            )
414            .ok()
415            .filter(|added| !added.is_empty())
416            .map(|added| (m.name.clone(), added))
417        })
418        .collect();
419
420    info!("Converting to compiled format...");
421    let CompiledArtifact {
422        mut schema,
423        functions,
424    } = SchemaConverter::convert_artifact(intermediate, &ConvertOptions { auto_error_union })
425        .context("Failed to convert schema to compiled format")?;
426
427    // Carry the project's casing acronyms into the compiled schema so the runtime
428    // installs them at boot (see `fraiseql_db::utils::set_runtime_acronyms`).
429    schema.naming_acronyms = naming_acronyms;
430
431    // Carry the project's @cost weight overrides (#379) into the compiled schema so
432    // the runtime per-tenant cost-budget check can apply them. Non-clobbering: only
433    // overwrite when configured, leaving any value a future merger path may set.
434    if !operation_cost_weights.is_empty() {
435        schema.operation_cost_weights = operation_cost_weights;
436    }
437
438    // 5. Optimize schema and generate SQL hints (mutates schema in place, report for display)
439    info!("Analyzing schema for optimization opportunities...");
440    let report = SchemaOptimizer::optimize(&mut schema).context("Failed to optimize schema")?;
441
442    // 5b-pre. Infer native_columns for ID/UUID-typed arguments on JSONB-backed queries.
443    // DB introspection (step 5b) overrides these inferred values when `--database` is provided.
444    infer_native_columns_from_arg_types(&mut schema);
445
446    // 5b. Optional: Validate native columns against database.
447    if let Some(db_url) = opts.database {
448        info!("Validating native columns for direct query arguments...");
449        // One pool, one connection decision, for every check below (#1429).
450        let pool = crate::connection::postgres_pool(db_url, "database validation", &database_tls)
451            .await
452            .context("Failed to connect for database validation")?;
453        let pg_introspector = fraiseql_core::db::postgres::PostgresIntrospector::new(pool.clone());
454        let db_report = validate_schema_against_database(&schema, &pg_introspector).await?;
455
456        // Error-severity drift fails the compile (#384) — collected here, raised
457        // after every check has reported, so one run surfaces the full list.
458        let mut drift_errors: Vec<String> = Vec::new();
459        for w in &db_report.warnings {
460            match w.severity() {
461                Severity::Error => {
462                    warn!("{w} [drift error]");
463                    drift_errors.push(w.to_string());
464                },
465                Severity::Warn => warn!("{w}"),
466            }
467        }
468        apply_database_report(&mut schema, &db_report);
469
470        // Mutation call/response contract (#384 item 3: inject_params resolve to real
471        // function arguments). Every URL that reached the drift check is PostgreSQL —
472        // a removed engine's was refused up front — so this runs for every form of it,
473        // libpq `key=value` included (#1403).
474        info!("Validating mutation contract against the database...");
475        let catalog = PgCatalog::from_pool(pool);
476        let contract = validate_mutation_contract(&schema, &catalog).await?;
477        let mut caused_by_default: std::collections::BTreeMap<String, Vec<String>> =
478            std::collections::BTreeMap::new();
479        for m in &contract.mutations {
480            let added = default_args.get(&m.mutation).map_or(&[][..], Vec::as_slice);
481            for v in &m.violations {
482                let kind = match v.severity() {
483                    Severity::Error => "contract error",
484                    Severity::Warn => "contract warning",
485                };
486                warn!("mutation `{}` (sql_source: {}): {v} [{kind}]", m.mutation, m.sql_source);
487                if v.severity() != Severity::Error {
488                    continue;
489                }
490                if let Some(key) = default_caused(v, added) {
491                    caused_by_default.entry(key).or_default().push(m.mutation.clone());
492                } else {
493                    drift_errors.push(format!(
494                        "mutation `{}` (sql_source: {}): {v}",
495                        m.mutation, m.sql_source
496                    ));
497                }
498            }
499        }
500        for (key, mutations) in caused_by_default {
501            drift_errors.push(default_collision(&key, &mutations));
502        }
503
504        // The linter must be able to FAIL (#384): a schema that names database
505        // objects which do not exist — or cannot serve the declared shape —
506        // does not compile. `--allow-drift` restores the advisory behaviour;
507        // the findings above have already been reported either way.
508        if !drift_errors.is_empty() {
509            if opts.allow_drift {
510                warn!(
511                    "{} schema↔database drift error(s) suppressed by --allow-drift",
512                    drift_errors.len()
513                );
514            } else {
515                let list =
516                    drift_errors.iter().map(|e| format!("  - {e}")).collect::<Vec<_>>().join("\n");
517                anyhow::bail!(
518                    "schema↔database drift: {} error(s):\n{list}\n\
519                     Fix the declarations or the database objects, or pass --allow-drift \
520                     to compile anyway (advisory mode).",
521                    drift_errors.len()
522                );
523            }
524        }
525    } else {
526        // Warn for queries that still have unresolved direct arguments after inference.
527        // Arguments already covered by native_columns inference are not warned about.
528        for query in &schema.queries {
529            if query.sql_source.is_none() {
530                continue;
531            }
532            let unresolved: Vec<_> = query
533                .arguments
534                .iter()
535                .filter(|a| !NATIVE_COLUMN_SKIP_ARGS.contains(&a.name.as_str()))
536                .filter(|a| !query.native_columns.contains_key(&a.name))
537                .collect();
538            if !unresolved.is_empty() {
539                let names: Vec<_> = unresolved.iter().map(|a| a.name.as_str()).collect();
540                warn!(
541                    "query `{}`: argument(s) {:?} on `{}` could not be resolved to native \
542                     columns — no --database URL provided. These filters will use JSONB \
543                     extraction. Provide --database or annotate with native_columns.",
544                    query.name,
545                    names,
546                    query.sql_source.as_deref().unwrap_or("?"),
547                );
548            }
549
550            // #1303: the same trade, for the page ordering. The offline answer is
551            // correct — `data->>'id'` orders every type under ADR-0017 — and it is
552            // the most expensive of the three, which is exactly the shape of every
553            // other `--database` sharpening in this pipeline.
554            if query.pagination_order == Some(fraiseql_core::schema::PaginationOrder::JsonIdentity)
555            {
556                warn!(
557                    "query `{}`: offset pages will be ordered by `data->>'id'` — no --database \
558                     URL provided, so a cheaper unique column on `{}` could not be found. \
559                     Provide --database to use `pk_{}` or a native `id` column instead, or \
560                     declare pagination_order.",
561                    query.name,
562                    query.sql_source.as_deref().unwrap_or("?"),
563                    fraiseql_core::utils::to_snake_case(&query.return_type),
564                );
565            }
566        }
567    }
568
569    // 5d. Warn when mutations have wide invalidation fan-out (HOT update pressure).
570    warn_wide_cascade_mutations(&schema);
571
572    // 5e. Warn when a `preserve` schema would silently forward camelCase input keys
573    // to snake_case SQL functions on the single-JSONB mutation path (#456).
574    warn_jsonb_preserve_mismatch(&schema);
575
576    // 5f. Refuse a schema in which a mutation could invalidate nothing (#910).
577    refuse_unattributable_mutations(&schema)?;
578
579    // 5g. Refuse authorization declarations no enforcer reads (#983).
580    refuse_unenforced_authz_declarations(&schema)?;
581
582    // 5h. Refuse a schema a server would refuse to load (ruling AF 4).
583    refuse_what_a_server_would_not_load(&schema)?;
584
585    Ok((CompiledArtifact { schema, functions }, report))
586}
587
588/// Refuse a compiled schema that a server would refuse to load (ruling AF 4).
589///
590/// The load-time checks (`CompiledSchema::finish_load`: duplicate names, type roles and
591/// injects, subscription policies and filters, `requires_scope` without a `security`
592/// section, fact-table links, relationships) hold for every producer, so they live at load
593/// — and until now they ran only when a server started, on an artifact the compiler had
594/// already written. The schema is serialized as the writer serializes it, stamped with its
595/// content hash, and loaded exactly as a server loads it; a refusal fails the compile with
596/// the loader's own message.
597///
598/// # Errors
599///
600/// Returns the loader's refusal.
601/// The `[inject_defaults]` key a contract error is due to, if it is due to one (#1385).
602///
603/// Attributed only when the arithmetic says so: the function takes exactly the arguments
604/// the mutation would send without its default-supplied parameters, or the parameter the
605/// call binds at a mismatching position is one a default supplied. Anything else is the
606/// function's own error and is reported as such.
607pub(crate) fn default_caused(
608    violation: &crate::schema::mutation_contract::ContractViolation,
609    added: &[String],
610) -> Option<String> {
611    use crate::schema::mutation_contract::ContractViolation;
612    match violation {
613        ContractViolation::ArityMismatch { expected, found }
614            if !added.is_empty() && found.contains(&expected.saturating_sub(added.len())) =>
615        {
616            Some(added.join(", "))
617        },
618        ContractViolation::InjectNameMismatch { expected, .. } if added.contains(expected) => {
619            Some(expected.clone())
620        },
621        _ => None,
622    }
623}
624
625/// One error for every mutation a default does not fit, instead of one per mutation.
626pub(crate) fn default_collision(key: &str, mutations: &[String]) -> String {
627    const SHOWN: usize = 5;
628    let shown = mutations.iter().take(SHOWN).map(|m| format!("`{m}`")).collect::<Vec<_>>();
629    let more = mutations.len().saturating_sub(SHOWN);
630    let list = if more == 0 {
631        shown.join(", ")
632    } else {
633        format!("{}, and {more} more", shown.join(", "))
634    };
635    format!(
636        "[inject_defaults] adds `{key}` to {} mutation(s) whose functions do not take it: \
637         {list}. Exclude it on those mutations (`exclude_inject_defaults = [\"{key}\"]`), or \
638         move it from the base [inject_defaults] table to [inject_defaults.queries].",
639        mutations.len()
640    )
641}
642
643fn refuse_what_a_server_would_not_load(schema: &CompiledSchema) -> Result<()> {
644    let body = serde_json::to_string(schema).context("Failed to serialize compiled schema")?;
645    let mut value: serde_json::Value = serde_json::from_str(&body)?;
646    let hash = content_hash_of(&value);
647    value
648        .as_object_mut()
649        .context("schema must serialise as JSON object")?
650        .insert("_content_hash".to_string(), serde_json::Value::String(hash));
651    CompiledSchema::from_json(&serde_json::to_string(&value)?, true).map_err(|e| {
652        anyhow::anyhow!("the compiled schema would be refused when a server loads it: {e}")
653    })?;
654    Ok(())
655}
656
657/// Refuse `security.rules`, `security.field_auth` and `security.default_policy` (#983).
658///
659/// All three are carried by the compiled seam and read by **nothing**: field-level
660/// RBAC runs on `role_definitions` + scopes, and `#677` lowered type gates onto
661/// operations. An operator who writes `[[security.rules]]` in a hand-authored
662/// `schema.json` gets a successful compile and zero enforcement — an authorization
663/// rule that is silently not applied, which is the worst shape a security
664/// declaration can take.
665///
666/// Refused rather than wired: an enforcer for them is `#626`'s design, and wiring
667/// three keys ad hoc to make the compile stop lying would prejudge it. This is the
668/// same disposition `#612` gave `security.policies`, which is why that one is
669/// already unproducible.
670///
671/// # Errors
672///
673/// Returns an error naming each declaration present.
674fn refuse_unenforced_authz_declarations(schema: &CompiledSchema) -> Result<()> {
675    let Some(security) = schema.security.as_ref() else {
676        return Ok(());
677    };
678
679    let mut declared: Vec<String> = Vec::new();
680    if !security.rules.is_empty() {
681        declared.push(format!("`security.rules` ({} rule(s))", security.rules.len()));
682    }
683    if !security.field_auth.is_empty() {
684        declared.push(format!("`security.field_auth` ({} rule(s))", security.field_auth.len()));
685    }
686    if let Some(policy) = security.default_policy.as_deref() {
687        declared.push(format!("`security.default_policy` (\"{policy}\")"));
688    }
689    if declared.is_empty() {
690        return Ok(());
691    }
692
693    anyhow::bail!(
694        "authorization declaration(s) with no enforcer: {}.\n\n\
695         FraiseQL enforces field-level authorization through `security.role_definitions` \
696         (roles → scopes) and each field's `requires_scope`; nothing reads these keys, so a \
697         rule written here is not applied to any request. Remove them, or express the same \
698         intent with role definitions and `requires_scope`.\n\n\
699         (A general authorization-rule engine is tracked separately; until it exists, a \
700         declaration that silently enforces nothing is refused rather than accepted.)",
701        declared.join(", ")
702    )
703}
704
705/// Refuse a schema whose caching depends on a mutation the engine cannot attribute (#910).
706///
707/// A mutation's invalidation is resolved from `invalidates_views`, the return type's
708/// view, the entity a payload type wraps, the `entity_type` its function stamps on
709/// `mutation_response`, and its cascade envelope. The first three are knowable here;
710/// the last two are not. When the knowable ones resolve to nothing, the mutation
711/// *may* invalidate nothing at runtime — and for a view annotated
712/// `cache_ttl_seconds = 0`, documented as "mutation-invalidated only", nothing means
713/// **forever**: the write lands, every cached entry stays warm for the process
714/// lifetime, and no log line says so.
715///
716/// The compiler knows every mutation and every `cache_ttl_seconds`, so it can make
717/// the shape impossible instead of loud. A `tracing::warn!` beside a successful
718/// compile is the same defect with more text; a boot-time refusal is later and
719/// per-deployment. Declaring `invalidates_views` costs one line and is checkable.
720///
721/// Schemas that annotate no view as cacheable are unaffected — there is no entry
722/// to strand.
723///
724/// # Errors
725///
726/// Returns an error naming every unattributable mutation when the schema also
727/// declares at least one cacheable view.
728fn refuse_unattributable_mutations(schema: &CompiledSchema) -> Result<()> {
729    if !fraiseql_core::cache::declares_cacheable_views(schema) {
730        return Ok(());
731    }
732    let unattributable = fraiseql_core::cache::unattributable_mutations(schema);
733    if unattributable.is_empty() {
734        return Ok(());
735    }
736
737    let cacheable: Vec<&str> = schema
738        .queries
739        .iter()
740        .filter(|q| q.cache_ttl_seconds.is_some())
741        .filter_map(|q| q.sql_source.as_deref())
742        .collect();
743    let list = unattributable.iter().map(|m| format!("  - {m}")).collect::<Vec<_>>().join("\n");
744    anyhow::bail!(
745        "mutation(s) whose cache invalidation cannot be resolved from the schema:\n{list}\n\n\
746         This schema annotates {} cacheable view(s) ({}), and a mutation that resolves to no \
747         view invalidates nothing when it succeeds — permanently, for any view annotated \
748         `cache_ttl_seconds = 0`.\n\n\
749         Fix by declaring what each mutation writes:\n\
750         \x20   invalidates_views = [\"v_price\"]\n\n\
751         A mutation whose return type is backed by a view, or whose payload wraps an entity \
752         that is, already resolves and needs no annotation.",
753        cacheable.len(),
754        cacheable.join(", "),
755    )
756}
757
758/// Run the compile command
759///
760/// # Arguments
761///
762/// * `input` - Path to fraiseql.toml (TOML) or schema.json (legacy)
763/// * `types` - Optional path to types.json (when using TOML workflow)
764/// * `schema_dir` - Optional directory for auto-discovery of schema files
765/// * `type_files` - Optional vector of explicit type file paths
766/// * `query_files` - Optional vector of explicit query file paths
767/// * `mutation_files` - Optional vector of explicit mutation file paths
768/// * `output` - Path to write schema.compiled.json
769/// * `check` - If true, validate only without writing output
770/// * `database` - Optional database URL for indexed column validation
771/// * `emit_ddl` - Optional directory to write `CREATE TABLE` DDL files (confiture format)
772/// * `check_migrations` - If true, run `confiture migrate validate` after compilation
773/// * `skip_hash` - Skip embedding content hash (for test fixtures)
774/// * `allow_drift` - Downgrade error-severity schema↔database drift to advisories (#384)
775///
776/// # Errors
777///
778/// Returns error if:
779/// - Input file doesn't exist or can't be read
780/// - JSON/TOML parsing fails
781/// - Schema validation fails
782/// - Output file can't be written
783/// - Database connection fails (when database URL is provided)
784/// - DDL output directory cannot be created (when `emit_ddl` is provided)
785/// - `confiture` is not installed (when `check_migrations` is true)
786/// - Migration drift detected (when `check_migrations` is true)
787#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
788// Reason: run() is the CLI entry point that receives individual args from clap; keeping them
789// separate for clarity — each bool is a distinct clap flag
790#[doc(hidden)] // Internal-pub: CLI entry point dispatched by runner.rs; not a stable downstream API.
791pub async fn run(
792    input: &str,
793    types: Option<&str>,
794    schema_dir: Option<&str>,
795    type_files: Vec<String>,
796    query_files: Vec<String>,
797    mutation_files: Vec<String>,
798    output: &str,
799    check: bool,
800    database: Option<&str>,
801    emit_ddl: Option<&str>,
802    check_migrations: bool,
803    skip_hash: bool,
804    allow_drift: bool,
805    config: Option<&str>,
806) -> Result<()> {
807    // Defense-in-depth: never write the compiled output over the input schema.
808    // The removed `serve` command did exactly this (H23) by deriving an output
809    // path identical to its input via a faulty extension swap. `--check` writes
810    // nothing, so the guard only applies to a real write.
811    if !check && Path::new(output) == Path::new(input) {
812        anyhow::bail!(
813            "Refusing to write compiled output over the input file '{input}'. \
814             Use a distinct --output path (e.g. schema.compiled.json)."
815        );
816    }
817
818    // Which config an artifact was compiled with is not answerable from the artifact,
819    // so every compile says it, before the work that could fail (#1387).
820    let config = ConfigSource::resolve(Path::new(input), config.map(Path::new))?;
821    println!("Config: {config}");
822
823    let opts = CompileOptions {
824        input,
825        types,
826        schema_dir,
827        type_files,
828        query_files,
829        mutation_files,
830        database,
831        skip_hash,
832        allow_drift,
833        config: Some(config),
834    };
835    let (artifact, optimization_report) = compile_to_schema(opts).await?;
836    let schema = &artifact.schema;
837
838    // If check-only mode, stop here
839    if check {
840        println!("✓ Schema is valid");
841        println!("  Types: {}", schema.types.len());
842        println!("  Queries: {}", schema.queries.len());
843        println!("  Mutations: {}", schema.mutations.len());
844        optimization_report.print();
845        return Ok(());
846    }
847
848    // Write compiled schema
849    info!("Writing compiled schema to: {output}");
850    // One routine assembles the file — schema plus every sibling section — so the
851    // `_content_hash` below covers all of it (#899/#1325).
852    let value = artifact.to_json_value()?;
853
854    let output_json = if skip_hash {
855        serde_json::to_string_pretty(&value).context("Failed to serialize compiled schema")?
856    } else {
857        // One routine, shared with the `from_json` verifier, so writer and reader
858        // cannot drift (#899).
859        let hash_hex = content_hash_of(&value);
860
861        let obj = value.as_object().context("schema must serialise as JSON object")?;
862
863        // Insert _content_hash as the first field (serde_json::Map preserves insertion order)
864        let mut new_obj = serde_json::Map::new();
865        new_obj.insert("_content_hash".to_string(), serde_json::Value::String(hash_hex));
866        for (k, v) in obj {
867            new_obj.insert(k.clone(), v.clone());
868        }
869        serde_json::to_string_pretty(&serde_json::Value::Object(new_obj))?
870    };
871
872    fs::write(output, output_json).context("Failed to write compiled schema")?;
873
874    // Success message
875    println!("✓ Schema compiled successfully");
876    println!("  Input:  {input}");
877    println!("  Output: {output}");
878    println!("  Types: {}", schema.types.len());
879    println!("  Queries: {}", schema.queries.len());
880    println!("  Mutations: {}", schema.mutations.len());
881    optimization_report.print();
882
883    // Emit DDL to directory if requested
884    if let Some(ddl_dir) = emit_ddl {
885        emit_ddl_to_dir(schema, ddl_dir)?;
886    }
887
888    // Check for migration drift if requested
889    if check_migrations {
890        run_check_migrations(schema)?;
891    }
892
893    Ok(())
894}
895
896/// Emit `CREATE TABLE` DDL files for all compiled schema types to `output_dir`.
897///
898/// Each type produces one `<type_snake_case>.sql` file containing a `CREATE TABLE IF NOT EXISTS`
899/// statement. The output directory is created if it does not already exist.
900///
901/// Output is compatible with confiture's `db/schema/` directory format, so that
902/// `fraiseql migrate generate` can auto-detect drift between the compiled schema and the
903/// current migrations.
904///
905/// # Errors
906///
907/// Returns an error if the output directory cannot be created, or if any DDL file
908/// cannot be written.
909pub fn emit_ddl_to_dir(schema: &CompiledSchema, output_dir: &str) -> Result<()> {
910    fs::create_dir_all(output_dir)
911        .context(format!("Failed to create DDL output directory: {output_dir}"))?;
912
913    let mut count = 0;
914    for type_def in &schema.types {
915        let table_name = to_snake_case(type_def.name.as_str());
916        let ddl = build_create_table_ddl(&table_name, type_def)?;
917
918        let file_path = Path::new(output_dir).join(format!("{table_name}.sql"));
919        fs::write(&file_path, ddl)
920            .context(format!("Failed to write DDL for type '{}'", type_def.name))?;
921        count += 1;
922    }
923
924    println!("✓ DDL emitted to {output_dir}/ ({count} table(s))");
925    Ok(())
926}
927
928/// Delegate to `confiture migrate validate` for migration drift detection.
929///
930/// Emits DDL to a temporary directory, then invokes confiture. Exits non-zero when
931/// drift is detected, printing a friendly remediation hint.
932///
933/// # Errors
934///
935/// Returns an error if confiture is not installed, if the temp directory cannot be
936/// created, or if confiture reports drift or validation failures.
937fn run_check_migrations(schema: &CompiledSchema) -> Result<()> {
938    let tmp_dir = tempfile::tempdir().context("Failed to create temporary DDL directory")?;
939    let tmp_path = tmp_dir.path().to_str().context("Temp directory path is not valid UTF-8")?;
940
941    emit_ddl_to_dir(schema, tmp_path)?;
942
943    info!("Running confiture migrate validate for drift detection...");
944
945    let status = Command::new("confiture").args(["migrate", "validate"]).status();
946
947    match status {
948        Err(_) => {
949            // confiture not installed — warn but don't fail the build
950            eprintln!(
951                "WARN: confiture is not installed; skipping migration drift check.\n\
952                 Install it with: cargo install confiture"
953            );
954            Ok(())
955        },
956        Ok(s) if s.success() => {
957            println!("✓ No migration drift detected.");
958            Ok(())
959        },
960        Ok(_) => {
961            eprintln!(
962                "WARN: compiled schema diverges from database — run fraiseql migrate generate"
963            );
964            anyhow::bail!(
965                "Migration drift detected. Run `fraiseql migrate generate` to create a migration."
966            )
967        },
968    }
969}
970
971/// Convert a `PascalCase` or `camelCase` type name to `snake_case`.
972///
973/// Delegates to the pipeline's one namer rather than reimplementing it. This used to
974/// insert a separator before every uppercase character, so `HTTPServer` became
975/// `h_t_t_p_server` in emitted DDL while JSONB key derivation — which already used
976/// `fraiseql_core::utils::casing` — produced `http_server`. Two casing systems that
977/// disagree about the same name produce DDL for a table the runtime never looks in.
978pub(crate) fn to_snake_case(name: &str) -> String {
979    fraiseql_core::utils::to_snake_case(name)
980}
981
982/// Generate a `CREATE TABLE IF NOT EXISTS` DDL statement for a compiled type definition.
983///
984/// # Errors
985///
986/// Returns an error when a vector field declares an index the metric has no
987/// pgvector operator class for — DDL `CREATE INDEX` would refuse (#959).
988fn build_create_table_ddl(
989    table_name: &str,
990    type_def: &fraiseql_core::schema::TypeDefinition,
991) -> Result<String> {
992    let mut lines: Vec<String> = Vec::new();
993    lines.push("-- Generated by fraiseql compile --emit-ddl".to_string());
994    lines.push(format!("-- Type: {}", type_def.name));
995    if let Some(desc) = &type_def.description {
996        lines.push(format!("-- {desc}"));
997    }
998    lines.push(String::new());
999    lines.push(format!("CREATE TABLE IF NOT EXISTS tb_{table_name} ("));
1000
1001    let col_lines: Vec<String> = type_def
1002        .fields
1003        .iter()
1004        .map(|field| {
1005            let col_name = to_snake_case(field.name.as_str());
1006            // #386: a dimensioned `vector(N)` column — a bare `vector` column
1007            // cannot carry an HNSW/IVFFlat index, so a dimension-less emission
1008            // silently produced unindexable DDL. #959: the same for `bit(N)`,
1009            // where a missing width is worse still — bare `bit` is `bit(1)`.
1010            let pg_type =
1011                if field.field_type.is_searchable_vector() && field.vector_config.is_some() {
1012                    field.field_type.to_sql_type(field.vector_config.as_ref())
1013                } else {
1014                    field_type_to_pg(&field.field_type)
1015                };
1016            let nullable = if field.nullable { "" } else { " NOT NULL" };
1017            format!("    {col_name} {pg_type}{nullable}")
1018        })
1019        .collect();
1020
1021    let last = col_lines.len().saturating_sub(1);
1022    for (i, col) in col_lines.iter().enumerate() {
1023        if i < last {
1024            lines.push(format!("{col},"));
1025        } else {
1026            lines.push(col.clone());
1027        }
1028    }
1029
1030    lines.push(");".to_string());
1031
1032    // #386: vector columns need the extension and their declared ANN index.
1033    // Binary vectors need it too — `bit(N)` is a PostgreSQL type, but `<~>`,
1034    // `<%>` and the `bit_*_ops` operator classes come from pgvector (#959).
1035    let vector_fields: Vec<_> = type_def
1036        .fields
1037        .iter()
1038        .filter_map(|f| f.vector_config.as_ref().map(|c| (f, c)))
1039        .collect();
1040    if !vector_fields.is_empty() {
1041        lines.push(String::new());
1042        lines.push("CREATE EXTENSION IF NOT EXISTS vector;".to_string());
1043        for (field, config) in vector_fields {
1044            let col_name = to_snake_case(field.name.as_str());
1045            let index_ddl = config
1046                .index_type
1047                .index_sql(
1048                    &format!("tb_{table_name}"),
1049                    &col_name,
1050                    &field.field_type,
1051                    config.distance_metric,
1052                )
1053                .with_context(|| format!("field '{}.{}'", type_def.name, field.name.as_str()))?;
1054            if let Some(index_ddl) = index_ddl {
1055                lines.push(format!("{index_ddl};"));
1056            }
1057        }
1058    }
1059
1060    lines.push(String::new());
1061    Ok(lines.join("\n"))
1062}
1063
1064/// Map a `FieldType` to its PostgreSQL column type string.
1065pub(crate) fn field_type_to_pg(ft: &FieldType) -> String {
1066    match ft {
1067        FieldType::String | FieldType::Scalar(_) => "TEXT".to_string(),
1068        FieldType::Int => "INTEGER".to_string(),
1069        FieldType::Float => "DOUBLE PRECISION".to_string(),
1070        FieldType::Boolean => "BOOLEAN".to_string(),
1071        FieldType::Id | FieldType::Uuid => "UUID".to_string(),
1072        FieldType::DateTime => "TIMESTAMPTZ".to_string(),
1073        FieldType::Date => "DATE".to_string(),
1074        FieldType::Time => "TIME".to_string(),
1075        FieldType::Json | FieldType::List(_) | FieldType::Object(_) => "JSONB".to_string(),
1076        FieldType::Decimal => "NUMERIC".to_string(),
1077        FieldType::Vector => "VECTOR".to_string(),
1078        // Width-less on purpose: a `BitVector` field always carries a
1079        // `vector_config`, so the dimensioned `bit(N)` form is what the DDL
1080        // emitter uses; this branch is the shape a config-less one would take,
1081        // and `varbit` is the only bit type that does not silently truncate.
1082        FieldType::BitVector => "VARBIT".to_string(),
1083        // Width-less for the same reason as `BitVector`: a vector field always
1084        // carries a `vector_config`, so the dimensioned form is what the DDL
1085        // emitter uses.
1086        FieldType::HalfVector => "HALFVEC".to_string(),
1087        FieldType::SparseVector => "SPARSEVEC".to_string(),
1088        // Use the actual Postgres enum type name so DDL matches the schema.
1089        FieldType::Enum(name) => name.clone(),
1090        FieldType::Input(_) | FieldType::Interface(_) | FieldType::Union(_) => "JSONB".to_string(),
1091        // FieldType is #[non_exhaustive]; future variants default to TEXT.
1092        _ => "TEXT".to_string(),
1093    }
1094}
1095
1096/// Minimum distinct invalidation targets (views + fact tables) that triggers
1097/// the HOT-update fan-out warning.
1098pub(crate) const WIDE_FANOUT_THRESHOLD: usize = 3;
1099
1100/// Return mutations whose total invalidation fan-out meets or exceeds `threshold`.
1101///
1102/// Fan-out is the count of distinct views (`invalidates_views`) plus fact tables
1103/// (`invalidates_fact_tables`) that a mutation touches on every successful write.
1104/// Used by [`warn_wide_cascade_mutations`] and exposed for unit testing.
1105pub(crate) fn wide_cascade_mutations(
1106    schema: &CompiledSchema,
1107    threshold: usize,
1108) -> Vec<&fraiseql_core::schema::MutationDefinition> {
1109    schema
1110        .mutations
1111        .iter()
1112        .filter(|m| m.invalidates_views.len() + m.invalidates_fact_tables.len() >= threshold)
1113        .collect()
1114}
1115
1116/// Emit a warning for each mutation whose invalidation fan-out is wide enough
1117/// to risk exhausting PostgreSQL HOT-update page slots under high write load.
1118///
1119/// When a mutation touches many tables on every write, the free space reserved
1120/// on each heap page (needed for HOT updates) fills up quickly. Subsequent
1121/// mutations must write to a new page instead of updating in place, which
1122/// increases I/O and table bloat. Setting `fillfactor=70-80` on the backing
1123/// tables leaves 20-30 % of each page free, keeping HOT updates available.
1124///
1125/// The warning lists ready-to-run `ALTER TABLE … SET (fillfactor = 75)` statements
1126/// derived from the view names using FraiseQL naming conventions
1127/// (`tv_foo` / `v_foo` → `tb_foo`).
1128fn warn_wide_cascade_mutations(schema: &CompiledSchema) {
1129    for mutation in wide_cascade_mutations(schema, WIDE_FANOUT_THRESHOLD) {
1130        let total = mutation.invalidates_views.len() + mutation.invalidates_fact_tables.len();
1131
1132        // Build a sorted, deduplicated target list for a stable message.
1133        let mut targets: Vec<&str> = mutation
1134            .invalidates_views
1135            .iter()
1136            .chain(mutation.invalidates_fact_tables.iter())
1137            .map(String::as_str)
1138            .collect();
1139        targets.sort_unstable();
1140        targets.dedup();
1141
1142        // Derive a likely backing-table name from FraiseQL view naming conventions.
1143        // tv_foo → tb_foo, v_foo → tb_foo, anything else (e.g. fact tables) unchanged.
1144        let alter_stmts: Vec<String> = targets
1145            .iter()
1146            .map(|&name| {
1147                let table = name
1148                    .strip_prefix("tv_")
1149                    .or_else(|| name.strip_prefix("v_"))
1150                    .map_or_else(|| name.to_string(), |rest| format!("tb_{rest}"));
1151                format!("ALTER TABLE {table} SET (fillfactor = 75);")
1152            })
1153            .collect();
1154
1155        warn!(
1156            "mutation '{}' has a wide invalidation fan-out ({} targets: [{}]). \
1157             Under high write load, HOT-update page slots on these tables may be \
1158             exhausted, forcing full-page writes and reducing mutation throughput. \
1159             Set fillfactor=70-80 on the backing tables: {}  \
1160             Monitor HOT efficiency: SELECT relname, \
1161             n_tup_hot_upd * 100 / NULLIF(n_tup_upd, 0) AS hot_pct \
1162             FROM pg_stat_user_tables WHERE n_tup_upd > 0 ORDER BY hot_pct;",
1163            mutation.name,
1164            total,
1165            targets.join(", "),
1166            alter_stmts.join("  "),
1167        );
1168    }
1169}
1170
1171/// Detect single-JSONB mutations that would silently forward `camelCase` input
1172/// keys to a `snake_case` SQL function under `naming_convention = "preserve"`.
1173///
1174/// Returns `(mutation_name, camelCase_field_names)` for every mutation that, on a
1175/// `Preserve` schema, takes one declared `input` Input type through the
1176/// single-JSONB path (`input_style = "jsonb"` or an `Update`) and whose Input
1177/// type has `camelCase`-looking field name(s). Under `Preserve` the runtime
1178/// forwards the payload verbatim — no `snake_case` recasing — so a function
1179/// reading `payload->>'snake_field'` sees NULL for every multi-word field (#456).
1180///
1181/// Pure (no I/O) so it can be unit-tested; [`warn_jsonb_preserve_mismatch`] is the
1182/// thin logging wrapper.
1183pub(crate) fn jsonb_preserve_mismatches(schema: &CompiledSchema) -> Vec<(String, Vec<String>)> {
1184    if schema.naming_convention != NamingConvention::Preserve {
1185        return Vec::new();
1186    }
1187    let mut out = Vec::new();
1188    for mutation in &schema.mutations {
1189        // The single-JSONB path: an explicit `input_style = jsonb` or an Update,
1190        // both of which forward the whole `input` object as one JSONB arg.
1191        let single_jsonb = matches!(mutation.input_style, InputStyle::Jsonb)
1192            || matches!(mutation.operation, MutationOperation::Update { .. });
1193        if !single_jsonb {
1194            continue;
1195        }
1196        // The single-`input`-object pattern: exactly one arg named "input" whose
1197        // type is a declared input type (mirrors the runtime's detection). The
1198        // compiler emits input-type references as `FieldType::Object`, never
1199        // `FieldType::Input`, so recognise an `Object` naming a registered input
1200        // type too — otherwise this warning is blind to every real compiled schema
1201        // (#456).
1202        let input_type_name = match mutation.arguments.as_slice() {
1203            [arg] if arg.name == "input" => match &arg.arg_type {
1204                FieldType::Input(name) => name.as_str(),
1205                FieldType::Object(name) if schema.find_input_type(name).is_some() => name.as_str(),
1206                _ => continue,
1207            },
1208            _ => continue,
1209        };
1210        let Some(input_type) = schema.find_input_type(input_type_name) else {
1211            continue;
1212        };
1213        // A field name with any uppercase letter is camelCase-looking — under
1214        // Preserve it reaches the function verbatim and won't match a snake key.
1215        let camel_fields: Vec<String> = input_type
1216            .fields
1217            .iter()
1218            .filter(|f| f.name.chars().any(|c| c.is_ascii_uppercase()))
1219            .map(|f| f.name.clone())
1220            .collect();
1221        if !camel_fields.is_empty() {
1222            out.push((mutation.name.clone(), camel_fields));
1223        }
1224    }
1225    out
1226}
1227
1228/// Warn for every [`jsonb_preserve_mismatches`] hit — the silent #456
1229/// misconfiguration where a `preserve` schema forwards camelCase input keys to a
1230/// snake_case SQL function on the single-JSONB path.
1231fn warn_jsonb_preserve_mismatch(schema: &CompiledSchema) {
1232    for (mutation, fields) in jsonb_preserve_mismatches(schema) {
1233        warn!(
1234            "mutation '{mutation}' forwards its input as a single JSONB payload but the schema \
1235             uses naming_convention = \"preserve\", and its input type has camelCase field(s) \
1236             [{}]. Under 'preserve' the runtime forwards input keys verbatim (no snake_case \
1237             recasing), so a SQL function reading payload->>'snake_field' will receive these \
1238             camelCase keys and see NULL. Set naming_convention = \"camelCase\" (the default for \
1239             new schemas) if the function expects snake_case keys, or rename the fields. (#456)",
1240            fields.join(", "),
1241        );
1242    }
1243}
1244
1245/// Build a PostgreSQL introspector connected to `db_url`.
1246///
1247/// # Errors
1248///
1249/// Returns error if the connection URL is invalid, the server cannot be reached, or
1250/// it is older than PostgreSQL 18.
1251pub(crate) async fn build_postgres_introspector(
1252    db_url: &str,
1253    tls: &PostgresTlsConfig,
1254) -> Result<fraiseql_core::db::postgres::PostgresIntrospector> {
1255    let pool = crate::connection::postgres_pool(db_url, "database validation", tls).await?;
1256    Ok(fraiseql_core::db::postgres::PostgresIntrospector::new(pool))
1257}
1258
1259/// Auto-param names excluded from `native_columns` inference and JSONB-extraction warnings.
1260const NATIVE_COLUMN_SKIP_ARGS: &[&str] = &[
1261    "where", "limit", "offset", "orderBy", "first", "last", "after", "before",
1262];
1263
1264/// Fold what introspection discovered back into the compiled schema.
1265///
1266/// Both halves are *sharpenings*: the schema is already correct without a
1267/// database, and a `--database` compile makes it cheaper. Split out of the
1268/// pipeline so the fold is testable without one — the patch loop it replaced was
1269/// reachable only from a live connection, which is the shape a discovery that
1270/// silently stops being applied hides in.
1271///
1272/// An authored `pagination_order` is protected upstream, in the validator, which
1273/// only reports a sharpening for a query whose ordering the compiler derived. It
1274/// is stated here too because this is where the overwrite happens.
1275pub(crate) fn apply_database_report(
1276    schema: &mut fraiseql_core::schema::CompiledSchema,
1277    report: &crate::schema::database_validator::DatabaseValidationReport,
1278) {
1279    for query in &mut schema.queries {
1280        if let Some(cols) = report.native_columns.get(&query.name) {
1281            query.native_columns = cols.clone();
1282        }
1283        if let Some(order) = report.pagination_orders.get(&query.name) {
1284            query.pagination_order = Some(order.clone());
1285        }
1286    }
1287}
1288
1289/// Infer `native_columns` for `ID`/`UUID`-typed arguments on JSONB-backed queries.
1290///
1291/// When a query reads from a JSONB table (`sql_source` + non-empty `jsonb_column`) and an
1292/// argument is typed [`FieldType::Id`] or [`FieldType::Uuid`], the argument name almost
1293/// certainly maps to a native UUID column alongside the `data` JSONB column
1294/// (e.g. `id UUID NOT NULL`). Emitting `WHERE id = $1::uuid` instead of
1295/// `WHERE data->>'id' = $1` lets the planner use the B-tree index without
1296/// needing a database connection at compile time.
1297///
1298/// Auto-param names (`where`, `limit`, `offset`, etc.) are skipped.
1299/// Arguments already present in `native_columns` are not overridden.
1300pub(crate) fn infer_native_columns_from_arg_types(schema: &mut CompiledSchema) {
1301    for query in &mut schema.queries {
1302        if query.sql_source.is_none() || query.jsonb_column.is_empty() {
1303            continue;
1304        }
1305        for arg in &query.arguments {
1306            if NATIVE_COLUMN_SKIP_ARGS.contains(&arg.name.as_str()) {
1307                continue;
1308            }
1309            if query.native_columns.contains_key(&arg.name) {
1310                continue; // already explicitly declared — don't override
1311            }
1312            if matches!(arg.arg_type, FieldType::Id | FieldType::Uuid) {
1313                query.native_columns.insert(arg.name.clone(), "uuid".to_string());
1314            }
1315        }
1316    }
1317}