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