fraiseql-cli 2.15.0

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
use std::collections::HashMap;

use indexmap::IndexMap;
use regex::Regex;

use super::{ExtractedSchema, Result, SchemaExtractor};
use crate::schema::intermediate::{
    IntermediateArgument, IntermediateField, IntermediateQuery, IntermediateType,
};

pub(super) struct TypeScriptExtractor;

impl SchemaExtractor for TypeScriptExtractor {
    fn extract(&self, source: &str) -> Result<ExtractedSchema> {
        let mut types = Vec::new();
        let mut queries = Vec::new();

        // Find type_("Name" and extract balanced braces body
        let type_start_re = Regex::new(r#"type_\(\s*"(\w+)"\s*,"#)?;
        // Find query("name" and extract balanced braces body
        let query_start_re = Regex::new(r#"query\(\s*"(\w+)"\s*,"#)?;

        for cap in type_start_re.captures_iter(source) {
            let name = cap[1].to_string();
            let after_match =
                cap.get(0).expect("regex group 0 is always Some on a successful match").end();
            if let Some(body) = extract_balanced_braces(&source[after_match..]) {
                let fields = extract_ts_fields(&body);
                types.push(IntermediateType {
                    sql_source: None,
                    name,
                    fields,
                    description: None,
                    implements: Vec::new(),
                    requires_role: None,
                    is_error: false,
                    is_input: false,
                    relay: false,
                    embedded: false,
                    subscribable_tables: None,
                    subscribable_pre_image: false,
                    // `extract` scrapes source text; no language has a relationship annotation for
                    // it to find, and it already omits `inject_params` and
                    // `subscribable_tables` on the same grounds. Authoring one
                    // goes through the SDK or `fraiseql.toml` (#1266).
                    relationships: Vec::new(),
                    inject_params: indexmap::IndexMap::new(),
                });
            }
        }

        for cap in query_start_re.captures_iter(source) {
            let name = cap[1].to_string();
            let after_match =
                cap.get(0).expect("regex group 0 is always Some on a successful match").end();
            if let Some(body) = extract_balanced_braces(&source[after_match..]) {
                let params = parse_ts_query_params(&body);
                let return_type = params.get("returnType").cloned().unwrap_or_default();
                let returns_list = params.get("returnArray").is_some_and(|v| v == "true");
                let sql_source = params.get("sqlSource").cloned();
                let arguments = extract_ts_query_args(&body);

                queries.push(IntermediateQuery {
                    count: false,
                    name,
                    return_type,
                    returns_list,
                    nullable: false,
                    arguments,
                    description: None,
                    sql_source,
                    // `extract` scrapes source text and produces no `functions` section, so it
                    // cannot produce the declaration a `function = "<name>"` binding must pair
                    // with (#1329). Authoring a function-backed field goes through the SDK.
                    function: None,
                    auto_params: None,
                    deprecated: None,
                    jsonb_column: None,
                    relay: false,
                    inject: IndexMap::default(),
                    read_routing: fraiseql_core::db::types::ReadRouting::default(),
                    cache_ttl_seconds: None,
                    additional_views: vec![],
                    requires_role: None,
                    requires_actor: Vec::new(),
                    relay_cursor_type: None,
                    // `extract` scans SDK source text; it does not parse REST annotations.
                    rest: None,
                    rest_stream: false,
                    // `extract` scrapes source text; the pagination-order override (#1303) is an
                    // authoring decision, made through the SDK or `fraiseql.toml`. Absent, the
                    // compiler derives the entity identity, which is the right answer here.
                    pagination_order: None,
                });
            }
        }

        Ok(ExtractedSchema { types, queries })
    }
}

/// Extract text inside balanced braces `{ ... }` from the start of `s`.
pub(super) fn extract_balanced_braces(s: &str) -> Option<String> {
    let start = s.find('{')?;
    let mut depth = 0;
    for (i, ch) in s[start..].char_indices() {
        match ch {
            '{' => depth += 1,
            '}' => {
                depth -= 1;
                if depth == 0 {
                    return Some(s[start + 1..start + i].to_string());
                }
            },
            _ => {},
        }
    }
    None
}

pub(super) fn extract_ts_fields(body: &str) -> Vec<IntermediateField> {
    let mut fields = Vec::new();
    // Match: fieldName: { type: "Type", nullable: bool }
    let field_re =
        Regex::new(r#"(\w+)\s*:\s*\{\s*type\s*:\s*"(\w+)"\s*,\s*nullable\s*:\s*(true|false)\s*\}"#)
            .expect("valid regex");

    for cap in field_re.captures_iter(body) {
        fields.push(IntermediateField {
            vector_config:   None,
            vector_distance: None,
            name:            cap[1].to_string(),
            field_type:      cap[2].to_string(),
            nullable:        &cap[3] == "true",
            description:     None,
            directives:      None,
            // #1329: refusal-only on a field; `extract` never authors one.
            function:        None,
            deprecated:      None,
            requires_scope:  None,
            on_deny:         None,
            authorize:       None,
            hierarchy:       None,
            unique:          None,
        });
    }
    fields
}

pub(super) fn parse_ts_query_params(body: &str) -> HashMap<String, String> {
    let mut params = HashMap::new();
    // returnType: "Type"
    let str_re = Regex::new(r#"(\w+)\s*:\s*"(\w+)""#).expect("valid regex");
    for cap in str_re.captures_iter(body) {
        params.insert(cap[1].to_string(), cap[2].to_string());
    }
    // returnArray: true/false
    let bool_re = Regex::new(r"(\w+)\s*:\s*(true|false)").expect("valid regex");
    for cap in bool_re.captures_iter(body) {
        params.insert(cap[1].to_string(), cap[2].to_string());
    }
    params
}

pub(super) fn extract_ts_query_args(body: &str) -> Vec<IntermediateArgument> {
    let mut args = Vec::new();
    // args: [{ name: "id", type: "ID", required: true }]
    let arg_re = Regex::new(
        r#"name\s*:\s*"(\w+)"\s*,\s*type\s*:\s*"(\w+)"\s*,\s*required\s*:\s*(true|false)"#,
    )
    .expect("valid regex");

    for cap in arg_re.captures_iter(body) {
        args.push(IntermediateArgument {
            name:        cap[1].to_string(),
            arg_type:    cap[2].to_string(),
            nullable:    &cap[3] != "true",
            default:     None,
            description: None,
            deprecated:  None,
        });
    }
    args
}