fraiseql-core 2.19.1

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! The `value_json` seam: how an inline GraphQL argument is stored and read back.
//!
//! A parsed argument is kept as a JSON string on [`GraphQLArgument::value_json`]
//! and re-read by the matcher, the directive evaluator, the query classifier and
//! the multi-root pipeline. Everything about that round trip lives here, because
//! #719 was two independent failures of it:
//!
//! * **The writer escaped only `"`.** A Windows path, a newline or a control character in a string
//!   literal produced *invalid JSON*, which the reader dropped via `.ok()?`. A dropped `where:`
//!   argument does not narrow a result set — it **widens** it.
//! * **Variables were signalled in-band as `"$name"`.** A literal `"$100"` was indistinguishable
//!   from a reference to a variable named `100`, and resolved to `null`.
//!
//! Both are fixed structurally rather than by patching the escaper: serialization
//! goes through `serde_json`, and a variable is a tagged **object**
//! `{"$var": "name"}`. GraphQL names match `[_A-Za-z][_0-9A-Za-z]*`, so no
//! client-supplied object key can ever be `$var` — the marker is unforgeable
//! rather than merely unlikely.

use std::collections::HashMap;

use apollo_parser::cst::{self, CstNode as _};
use fraiseql_error::{FraiseQLError, Result};
use serde_json::{Map, Value};

/// Object key marking a GraphQL variable reference.
///
/// Not a valid GraphQL name, so a client cannot produce it as a literal key.
pub const VARIABLE_TAG: &str = "$var";

/// Maximum nesting depth accepted when serializing or resolving an argument.
///
/// Bounds stack use against an adversarial payload; the parser and the resolver
/// share the limit so a value that serialized cannot fail to resolve.
pub const MAX_DEPTH: usize = 64;

/// Why a literal written in a document cannot be stored, and the byte offset it was
/// written at.
#[derive(Debug)]
pub(crate) struct LiteralError {
    pub(crate) at:      usize,
    pub(crate) message: String,
}

/// Build the JSON representation of a literal written in a document: an argument, a
/// directive argument or a variable default.
///
/// An object literal keeps the order its keys were written in, so an inline `orderBy`
/// sorts as the same object passed as a variable does (#1567).
///
/// # Errors
///
/// [`LiteralError`] for a literal with no JSON counterpart, or none that keeps what was
/// written: an integer beyond `i64`, a number beyond `f64`, an input object naming a key
/// twice (§ 5.6.3: refused, as one of its two values would otherwise be applied silently),
/// or a value nesting deeper than [`MAX_DEPTH`].
pub(crate) fn encode_literal(value: &cst::Value) -> std::result::Result<String, LiteralError> {
    let json = literal_to_json(value, 0)?;
    Ok(json.to_string())
}

fn at(node: &apollo_parser::SyntaxNode) -> usize {
    usize::from(node.text_range().start())
}

fn literal_to_json(value: &cst::Value, depth: usize) -> std::result::Result<Value, LiteralError> {
    let refuse = |node: &apollo_parser::SyntaxNode, message: String| LiteralError {
        at: at(node),
        message,
    };
    if depth > MAX_DEPTH {
        return Err(refuse(
            value.syntax(),
            format!("GraphQL argument nests deeper than the {MAX_DEPTH}-level limit"),
        ));
    }
    Ok(match value {
        cst::Value::Variable(variable) => {
            let name = variable.name().map(|n| n.text().to_string()).unwrap_or_default();
            variable_ref(&name)
        },
        cst::Value::StringValue(s) => Value::String(String::from(s)),
        cst::Value::IntValue(i) => {
            let text = i.syntax().text().to_string();
            let n: i64 = text.parse().map_err(|_| {
                refuse(
                    i.syntax(),
                    format!("integer `{text}` is out of range (a 64-bit integer at most)"),
                )
            })?;
            Value::from(n)
        },
        cst::Value::FloatValue(f) => {
            let text = f.syntax().text().to_string();
            text.parse::<f64>()
                .ok()
                .and_then(serde_json::Number::from_f64)
                .map(Value::Number)
                .ok_or_else(|| {
                    refuse(
                        f.syntax(),
                        format!("number `{text}` is out of range (a 64-bit float at most)"),
                    )
                })?
        },
        cst::Value::BooleanValue(b) => Value::Bool(b.true_token().is_some()),
        cst::Value::NullValue(_) => Value::Null,
        // An enum value is a bare GraphQL name; JSON has no enum, so it travels
        // as a string and `value_type` records that it was an enum.
        cst::Value::EnumValue(e) => {
            Value::String(e.name().map(|n| n.text().to_string()).unwrap_or_default())
        },
        cst::Value::ListValue(list) => Value::Array(
            list.values()
                .map(|item| literal_to_json(&item, depth + 1))
                .collect::<std::result::Result<_, _>>()?,
        ),
        cst::Value::ObjectValue(object) => {
            let mut map = Map::new();
            for field in object.object_fields() {
                let key = field.name().map(|n| n.text().to_string()).unwrap_or_default();
                let Some(item) = field.value() else {
                    return Err(refuse(field.syntax(), format!("`{key}` has no value")));
                };
                let item = literal_to_json(&item, depth + 1)?;
                if map.insert(key.clone(), item).is_some() {
                    return Err(refuse(
                        field.syntax(),
                        format!(
                            "the input object names `{key}` twice; an input object's field \
                             names are unique (GraphQL § 5.6.3)"
                        ),
                    ));
                }
            }
            Value::Object(map)
        },
    })
}

/// The JSON representation of a reference to variable `name`.
#[must_use]
pub fn variable_ref(name: &str) -> Value {
    let mut map = Map::with_capacity(1);
    map.insert(VARIABLE_TAG.to_string(), Value::String(name.to_string()));
    Value::Object(map)
}

/// The variable this value references, if it is a reference rather than a literal.
#[must_use]
pub fn variable_name(value: &Value) -> Option<&str> {
    let map = value.as_object()?;
    if map.len() != 1 {
        return None;
    }
    map.get(VARIABLE_TAG)?.as_str()
}

/// Read a stored `value_json` back into JSON.
///
/// # Errors
///
/// Returns `FraiseQLError::Internal` if the stored string is not valid JSON.
/// This can only happen if something wrote `value_json` without going through
/// `encode_literal` — and it must be loud: the previous `.ok()?` dropped the argument,
/// and a dropped filter widens the result set instead of narrowing it.
pub fn decode(value_json: &str) -> Result<Value> {
    serde_json::from_str(value_json).map_err(|e| FraiseQLError::Internal {
        message: format!(
            "stored GraphQL argument is not valid JSON ({e}); refusing to execute the query \
             rather than dropping the argument, which would widen the result set"
        ),
        source:  None,
    })
}

/// Substitute every variable reference inside `value` with its concrete value.
///
/// An undefined variable resolves to JSON `null`, matching GraphQL's treatment
/// of an omitted nullable argument.
#[must_use]
#[allow(clippy::implicit_hasher)]
// Reason: the variables map is built by the runtime with the default hasher; a
// generic parameter here would leak into every caller for no benefit.
pub fn resolve_variables(value: Value, variables: &HashMap<String, Value>) -> Value {
    resolve_at(value, variables, 0)
}

/// Read a stored `value_json` back into JSON **with its variable references
/// substituted** — the form a consumer that wants values almost always means.
///
/// [`decode`] alone yields a value that may still contain `{"$var": "name"}`
/// markers. Every consumer that then compares, filters or authorizes on the
/// result must pair it with [`resolve_variables`]; the field authorizer did not,
/// so a policy matching on an argument compared against the marker and silently
/// took its default branch for every client that passes arguments as variables —
/// which is every generated client (#903).
///
/// Use [`decode`] directly only when the marker itself is the subject, as
/// `resolve_inline_arg` does: it distinguishes a whole-argument variable the
/// request omitted (drop the argument, fall back to the query default) from one
/// bound to null.
///
/// # Errors
///
/// Returns `FraiseQLError::Internal` if the stored string is not valid JSON.
#[allow(clippy::implicit_hasher)] // Reason: as resolve_variables — one hasher, no generic leak.
pub fn decode_resolved(value_json: &str, variables: &HashMap<String, Value>) -> Result<Value> {
    Ok(resolve_variables(decode(value_json)?, variables))
}

fn resolve_at(value: Value, variables: &HashMap<String, Value>, depth: usize) -> Value {
    if depth >= MAX_DEPTH {
        return value;
    }
    match value {
        Value::Array(items) => {
            Value::Array(items.into_iter().map(|v| resolve_at(v, variables, depth + 1)).collect())
        },
        Value::Object(map) => {
            if let Some(name) = map.get(VARIABLE_TAG).and_then(Value::as_str) {
                if map.len() == 1 {
                    return variables.get(name).cloned().unwrap_or(Value::Null);
                }
            }
            Value::Object(
                map.into_iter().map(|(k, v)| (k, resolve_at(v, variables, depth + 1))).collect(),
            )
        },
        other => other,
    }
}

/// Render a stored argument value back as GraphQL source syntax.
///
/// Used by the multi-root pipeline, which re-serializes each root field into a
/// standalone query. Strings are escaped by `serde_json` — GraphQL and JSON
/// agree on string-literal syntax — and variable references become `$name`
/// rather than a quoted string.
#[must_use]
pub fn to_graphql(value: &Value) -> String {
    if let Some(name) = variable_name(value) {
        return format!("${name}");
    }
    match value {
        Value::Object(map) => {
            // GraphQL object keys are bare names, not quoted strings.
            let pairs: Vec<String> =
                map.iter().map(|(k, v)| format!("{k}: {}", to_graphql(v))).collect();
            format!("{{{}}}", pairs.join(", "))
        },
        Value::Array(items) => {
            let rendered: Vec<String> = items.iter().map(to_graphql).collect();
            format!("[{}]", rendered.join(", "))
        },
        // `serde_json` emits a correctly escaped string literal; GraphQL's
        // string syntax is the same. Hand-rolling this is what #719 reports.
        Value::String(_) | Value::Number(_) | Value::Bool(_) | Value::Null => value.to_string(),
    }
}

/// The declared defaults of an operation's variables, decoded: `(name, value)` for
/// each variable definition that declares one (GraphQL § 6.4.1).
///
/// # Errors
///
/// Returns `FraiseQLError::Internal` if a stored default is not valid JSON (see
/// [`decode`]).
pub fn variable_defaults(
    definitions: &[crate::graphql::VariableDefinition],
) -> Result<Vec<(String, Value)>> {
    definitions
        .iter()
        .filter_map(|def| def.default_value.as_deref().map(|json| (def.name.as_str(), json)))
        .map(|(name, json)| Ok((name.to_string(), decode(json)?)))
        .collect()
}

/// The request's variables, with every omitted variable that declares a default
/// set to it (GraphQL § 6.4.1 `CoerceVariableValues`).
///
/// A variable the request supplies, `null` included, keeps the request's value.
///
/// `None` when nothing was added, so the caller keeps the request's own value; a
/// `variables` that is not an object is left for validation to refuse.
#[must_use]
pub fn with_variable_defaults(
    defaults: &[(String, Value)],
    variables: Option<&Value>,
) -> Option<Value> {
    let supplied = match variables {
        None | Some(Value::Null) => Map::new(),
        Some(Value::Object(map)) => map.clone(),
        Some(_) => return None,
    };
    let missing: Vec<&(String, Value)> =
        defaults.iter().filter(|(name, _)| !supplied.contains_key(name)).collect();
    if missing.is_empty() {
        return None;
    }
    let mut merged = supplied;
    for (name, value) in missing {
        merged.insert(name.clone(), value.clone());
    }
    Some(Value::Object(merged))
}

#[cfg(test)]
#[path = "value_json_tests.rs"]
mod value_json_tests;