Skip to main content

qql_core/params/
text.rs

1//! Text-based parameter substitution and literal formatting.
2
3use super::render::{truncate_vector_literals, value_to_literal};
4pub use super::scan::{
5    ident_at, is_ident_continue, is_ident_start, is_placeholder_start, skip_protected,
6};
7
8use crate::ast::Value;
9use crate::error::{QqlError, Span};
10use alloc::format;
11use alloc::string::String;
12
13/// Substitute named parameters (`:name`) into `source`.
14///
15/// `lookup` receives the parameter name without the `:` prefix and returns the bound `Value`.
16pub fn bind_named<F>(source: &str, lookup: F) -> Result<String, QqlError>
17where
18    F: Fn(&str) -> Option<Value>,
19{
20    let bytes = source.as_bytes();
21    let len = bytes.len();
22    let mut out = String::with_capacity(source.len());
23    let mut i = 0;
24    let mut run = 0;
25
26    while i < len {
27        if let Some(next) = skip_protected(bytes, i) {
28            i = next;
29            continue;
30        }
31
32        let ch = bytes[i];
33        if ch == b'?' {
34            return Err(QqlError::validation(
35                "QQL-BIND-MIXED-STYLE",
36                "query contains positional placeholder '?' — use bind_positional / execute_with_positional_params instead of named parameter binding",
37                Some(Span {
38                    start: i,
39                    end: i + 1,
40                }),
41            ));
42        }
43
44        if ch == b':' && i + 1 < len && is_placeholder_start(bytes, i) {
45            let next = bytes[i + 1];
46            if next != b':' && is_ident_start(next) {
47                out.push_str(&source[run..i]);
48                let colon_pos = i;
49                i += 1;
50                let name_start = i;
51                while i < len {
52                    if is_ident_continue(bytes[i]) {
53                        i += 1;
54                    } else if bytes[i] == b'.' && i + 1 < len && is_ident_start(bytes[i + 1]) {
55                        i += 2;
56                    } else {
57                        break;
58                    }
59                }
60                let name = ident_at(source, name_start, i);
61                let span = Some(Span {
62                    start: colon_pos,
63                    end: i,
64                });
65                if let Some(val) = lookup(name) {
66                    if matches!(val, Value::Null) {
67                        return Err(QqlError::validation(
68                            "QQL-BIND-NULL-PARAM",
69                            format!(
70                                "parameter ':{name}' is null; QQL cannot bind null — pass a concrete value or remove the placeholder"
71                            ),
72                            span,
73                        ));
74                    }
75                    out.push_str(&value_to_literal(&val)?);
76                } else {
77                    return Err(QqlError::validation(
78                        "QQL-BIND-MISSING-PARAM",
79                        format!("missing value for named parameter ':{}'", name),
80                        span,
81                    ));
82                }
83                run = i;
84                continue;
85            }
86        }
87
88        i += 1;
89    }
90
91    out.push_str(&source[run..len]);
92    Ok(out)
93}
94
95/// Substitute positional parameters (`?`) sequentially into `source`.
96pub fn bind_positional(source: &str, params: &[Value]) -> Result<String, QqlError> {
97    let bytes = source.as_bytes();
98    let len = bytes.len();
99    let mut out = String::with_capacity(source.len());
100    let mut i = 0;
101    let mut run = 0;
102    let mut param_index = 0;
103
104    while i < len {
105        if let Some(next) = skip_protected(bytes, i) {
106            i = next;
107            continue;
108        }
109
110        let ch = bytes[i];
111        if ch == b':' && i + 1 < len && is_placeholder_start(bytes, i) {
112            let next = bytes[i + 1];
113            if next != b':' && is_ident_start(next) {
114                return Err(QqlError::validation(
115                    "QQL-BIND-MIXED-STYLE",
116                    "query contains named placeholder ':name' — use bind_named / execute_with_params instead of positional parameter binding",
117                    Some(Span {
118                        start: i,
119                        end: i + 2,
120                    }),
121                ));
122            }
123        }
124
125        if ch == b'?' {
126            let q_span = Some(Span {
127                start: i,
128                end: i + 1,
129            });
130            if param_index < params.len() {
131                if matches!(params[param_index], Value::Null) {
132                    return Err(QqlError::validation(
133                        "QQL-BIND-NULL-PARAM",
134                        format!(
135                            "positional parameter ?{} is null; QQL cannot bind null — pass a concrete value or remove the placeholder",
136                            param_index + 1
137                        ),
138                        q_span,
139                    ));
140                }
141                out.push_str(&source[run..i]);
142                out.push_str(&value_to_literal(&params[param_index])?);
143                param_index += 1;
144                i += 1;
145                run = i;
146                continue;
147            }
148            return Err(QqlError::validation(
149                "QQL-BIND-MISSING-PARAM",
150                format!(
151                    "missing value for positional parameter ?{} (only {} parameter(s) provided)",
152                    param_index + 1,
153                    params.len()
154                ),
155                q_span,
156            ));
157        }
158
159        i += 1;
160    }
161
162    out.push_str(&source[run..len]);
163
164    if param_index < params.len() {
165        let msg = if param_index == 0 {
166            format!(
167                "no '?' placeholders found in query, but {} positional parameters were supplied",
168                params.len()
169            )
170        } else {
171            format!(
172                "too many positional parameters provided: bound {}, but {} were supplied",
173                param_index,
174                params.len()
175            )
176        };
177        return Err(QqlError::validation("QQL-BIND-UNUSED-PARAMS", msg, None));
178    }
179
180    Ok(out)
181}
182
183/// Substitute named parameters (`:name`) into `source`, truncating long vector literals for readable preview.
184pub fn bind_named_readable<F>(source: &str, lookup: F, max_dims: usize) -> Result<String, QqlError>
185where
186    F: Fn(&str) -> Option<Value>,
187{
188    let bound = bind_named(source, lookup)?;
189    Ok(truncate_vector_literals(&bound, max_dims))
190}
191
192/// Substitute positional parameters (`?`) sequentially into `source`, truncating long vector literals for readable preview.
193pub fn bind_positional_readable(
194    source: &str,
195    params: &[Value],
196    max_dims: usize,
197) -> Result<String, QqlError> {
198    let bound = bind_positional(source, params)?;
199    Ok(truncate_vector_literals(&bound, max_dims))
200}