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