Skip to main content

teaql_sql/
types.rs

1use teaql_core::{DataType, Value};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum DatabaseKind {
5    PostgreSql,
6    Sqlite,
7    MySql,
8}
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct CompiledQuery {
12    pub sql: String,
13    pub params: Vec<Value>,
14    pub comment: Option<String>,
15}
16
17impl CompiledQuery {
18    pub fn sql_with_comment(&self) -> String {
19        match &self.comment {
20            Some(comment) if !comment.is_empty() => {
21                let escaped = comment.replace("*/", "* /");
22                format!("/* {escaped} */ {}", self.sql)
23            }
24            _ => self.sql.clone(),
25        }
26    }
27
28    pub fn debug_sql(&self, kind: DatabaseKind) -> String {
29        let sql = self.sql_with_comment();
30        match kind {
31            DatabaseKind::PostgreSql => replace_postgres_placeholders(&sql, &self.params),
32            DatabaseKind::Sqlite => {
33                replace_positional_placeholders(&sql, &self.params, DatabaseKind::Sqlite)
34            }
35            DatabaseKind::MySql => {
36                replace_positional_placeholders(&sql, &self.params, DatabaseKind::MySql)
37            }
38        }
39    }
40}
41
42fn replace_postgres_placeholders(sql: &str, params: &[Value]) -> String {
43    let mut output = String::with_capacity(sql.len());
44    let mut chars = sql.chars().peekable();
45    let mut state = SqlScanState::Sql;
46    while let Some(ch) = chars.next() {
47        match state {
48            SqlScanState::Sql => match (ch, chars.peek().copied()) {
49                ('\'', _) => {
50                    output.push(ch);
51                    state = SqlScanState::SingleQuote;
52                }
53                ('"', _) => {
54                    output.push(ch);
55                    state = SqlScanState::DoubleQuote;
56                }
57                ('-', Some('-')) => {
58                    output.push_str("--");
59                    chars.next();
60                    state = SqlScanState::LineComment;
61                }
62                ('/', Some('*')) => {
63                    output.push_str("/*");
64                    chars.next();
65                    state = SqlScanState::BlockComment;
66                }
67                ('$', Some(next)) if next.is_ascii_digit() => {
68                    let mut index = String::new();
69                    while let Some(next) = chars.peek().copied().filter(char::is_ascii_digit) {
70                        index.push(next);
71                        chars.next();
72                    }
73                    if let Ok(index) = index.parse::<usize>()
74                        && let Some(value) = index.checked_sub(1).and_then(|idx| params.get(idx))
75                    {
76                        output.push_str(&sql_literal(value, DatabaseKind::PostgreSql));
77                    } else {
78                        output.push('$');
79                        output.push_str(&index);
80                    }
81                }
82                _ => output.push(ch),
83            },
84            SqlScanState::SingleQuote => {
85                output.push(ch);
86                if ch == '\'' {
87                    if matches!(chars.peek(), Some('\'')) {
88                        output.push(chars.next().expect("peeked escaped quote"));
89                    } else {
90                        state = SqlScanState::Sql;
91                    }
92                }
93            }
94            SqlScanState::DoubleQuote => {
95                output.push(ch);
96                if ch == '"' {
97                    if matches!(chars.peek(), Some('"')) {
98                        output.push(chars.next().expect("peeked escaped identifier"));
99                    } else {
100                        state = SqlScanState::Sql;
101                    }
102                }
103            }
104            SqlScanState::LineComment => {
105                output.push(ch);
106                if matches!(ch, '\r' | '\n') {
107                    state = SqlScanState::Sql;
108                }
109            }
110            SqlScanState::BlockComment => {
111                output.push(ch);
112                if ch == '*' && matches!(chars.peek(), Some('/')) {
113                    output.push(chars.next().expect("peeked comment end"));
114                    state = SqlScanState::Sql;
115                }
116            }
117        }
118    }
119    output
120}
121
122fn replace_positional_placeholders(sql: &str, params: &[Value], kind: DatabaseKind) -> String {
123    let mut output = String::with_capacity(sql.len());
124    let mut params = params.iter();
125    let mut state = SqlScanState::Sql;
126    let mut chars = sql.chars().peekable();
127    while let Some(ch) = chars.next() {
128        match state {
129            SqlScanState::Sql => match (ch, chars.peek().copied()) {
130                ('\'', _) => {
131                    output.push(ch);
132                    state = SqlScanState::SingleQuote;
133                }
134                ('"', _) => {
135                    output.push(ch);
136                    state = SqlScanState::DoubleQuote;
137                }
138                ('-', Some('-')) => {
139                    output.push(ch);
140                    output.push(chars.next().expect("peeked line comment"));
141                    state = SqlScanState::LineComment;
142                }
143                ('/', Some('*')) => {
144                    output.push(ch);
145                    output.push(chars.next().expect("peeked block comment"));
146                    state = SqlScanState::BlockComment;
147                }
148                ('?', _) => match params.next() {
149                    Some(value) => output.push_str(&sql_literal(value, kind)),
150                    None => output.push(ch),
151                },
152                _ => output.push(ch),
153            },
154            SqlScanState::SingleQuote => {
155                output.push(ch);
156                if ch == '\'' {
157                    if matches!(chars.peek(), Some('\'')) {
158                        output.push(chars.next().expect("peeked escaped quote"));
159                    } else {
160                        state = SqlScanState::Sql;
161                    }
162                }
163            }
164            SqlScanState::DoubleQuote => {
165                output.push(ch);
166                if ch == '"' {
167                    if matches!(chars.peek(), Some('"')) {
168                        output.push(chars.next().expect("peeked escaped identifier quote"));
169                    } else {
170                        state = SqlScanState::Sql;
171                    }
172                }
173            }
174            SqlScanState::LineComment => {
175                output.push(ch);
176                if matches!(ch, '\r' | '\n') {
177                    state = SqlScanState::Sql;
178                }
179            }
180            SqlScanState::BlockComment => {
181                output.push(ch);
182                if ch == '*' && matches!(chars.peek(), Some('/')) {
183                    output.push(chars.next().expect("peeked block comment end"));
184                    state = SqlScanState::Sql;
185                }
186            }
187        }
188    }
189    output
190}
191
192#[derive(Clone, Copy)]
193enum SqlScanState {
194    Sql,
195    SingleQuote,
196    DoubleQuote,
197    LineComment,
198    BlockComment,
199}
200
201fn sql_bool_literal(value: bool) -> &'static str {
202    match value {
203        true => "TRUE",
204        false => "FALSE",
205    }
206}
207
208fn sql_literal(value: &Value, kind: DatabaseKind) -> String {
209    match value {
210        Value::Null => "NULL".to_owned(),
211        Value::Bool(value) => sql_bool_literal(*value).to_owned(),
212        Value::I64(value) => value.to_string(),
213        Value::U64(value) => value.to_string(),
214        Value::F64(value) => value.to_string(),
215        Value::Decimal(value) => value.to_string(),
216        Value::Text(value) => quoted_sql_string(value),
217        Value::Json(value) => quoted_sql_string(&value.to_string()),
218        Value::Date(value) => match kind {
219            DatabaseKind::PostgreSql => format!("DATE '{}'", value),
220            DatabaseKind::MySql => format!("CAST('{}' AS DATE)", value),
221            DatabaseKind::Sqlite => quoted_sql_string(&value.to_string()),
222        },
223        Value::Timestamp(value) => match kind {
224            DatabaseKind::Sqlite => value.0.to_string(),
225            DatabaseKind::PostgreSql => format!(
226                "TIMESTAMPTZ '{}'",
227                value.to_datetime().format("%Y-%m-%d %H:%M:%S%.3fZ")
228            ),
229            DatabaseKind::MySql => format!(
230                "CAST('{}' AS DATETIME(3))",
231                value
232                    .to_datetime()
233                    .naive_utc()
234                    .format("%Y-%m-%d %H:%M:%S%.3f")
235            ),
236        },
237        Value::Object(value) => {
238            quoted_sql_string(&Value::Object(value.clone()).to_json_value().to_string())
239        }
240        Value::List(values) => {
241            let values = values
242                .iter()
243                .map(|v| sql_literal(v, kind))
244                .collect::<Vec<_>>()
245                .join(", ");
246            match kind {
247                DatabaseKind::PostgreSql => format!("ARRAY[{values}]"),
248                _ => format!("({values})"),
249            }
250        }
251        Value::TypedNull(_) => "NULL".to_owned(),
252    }
253}
254
255fn quoted_sql_string(value: &str) -> String {
256    format!("'{}'", value.replace('\'', "''"))
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum SqlCompileError {
261    UnknownEntity(String),
262    UnknownField(String),
263    EmptyInList,
264    MissingIdProperty(String),
265    MissingVersionProperty(String),
266    EmptyMutation(String),
267    InvalidRecoverVersion(i64),
268    UnsupportedSchemaType(DataType),
269    InvalidFunctionArguments(String),
270    InvalidSubQueryOperator(String),
271}
272
273impl std::fmt::Display for SqlCompileError {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        match self {
276            Self::UnknownEntity(entity) => write!(f, "unknown entity: {entity}"),
277            Self::UnknownField(field) => write!(f, "unknown field: {field}"),
278            Self::EmptyInList => write!(f, "IN requires at least one value"),
279            Self::MissingIdProperty(entity) => write!(f, "entity {entity} has no id property"),
280            Self::MissingVersionProperty(entity) => {
281                write!(f, "entity {entity} has no version property")
282            }
283            Self::EmptyMutation(kind) => write!(f, "{kind} requires at least one writable field"),
284            Self::InvalidRecoverVersion(version) => {
285                write!(f, "recover requires a negative version, got {version}")
286            }
287            Self::UnsupportedSchemaType(data_type) => {
288                write!(f, "unsupported schema type: {data_type:?}")
289            }
290            Self::InvalidFunctionArguments(message) => write!(f, "{message}"),
291            Self::InvalidSubQueryOperator(operator) => {
292                write!(f, "subquery does not support operator: {operator}")
293            }
294        }
295    }
296}
297
298impl std::error::Error for SqlCompileError {}