Skip to main content

fraiseql_cli/commands/
compile.rs

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