fraiseql-db 2.2.0

Database abstraction layer for FraiseQL v2
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Cross-dialect property-based tests for `GenericWhereGenerator`.
//!
//! These tests verify invariants that must hold for **all** dialects and **all**
//! valid inputs — not just the representative samples covered by unit tests:
//!
//! 1. **Placeholder isolation** — generated SQL never contains raw user values.
//! 2. **Param count consistency** — every dialect produces the same number of bind parameters for
//!    the same logical clause.
//! 3. **Placeholder syntax** — each dialect uses its own placeholder style (`$N` for PG, `@pN` for
//!    SQL Server, `?` for MySQL/SQLite).
//! 4. **No cross-dialect leakage** — PostgreSQL-specific syntax (`::`) never appears in MySQL or
//!    SQLite output.
//! 5. **Reusability** — calling `generate()` twice on the same generator produces identical SQL
//!    (counter resets between calls).
//!
//! # Running
//!
//! ```bash
//! cargo test -p fraiseql-db --test dialect_properties
//! # With extra dialects:
//! cargo test -p fraiseql-db --features sqlite,mysql,sqlserver --test dialect_properties
//! ```

#![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable

use fraiseql_db::{PostgresDialect, WhereClause, WhereOperator, postgres::PostgresWhereGenerator};
use proptest::prelude::*;
use serde_json::{Value, json};

// ── Arbitrary generators ──────────────────────────────────────────────────────

/// Arbitrary single-segment field paths (alphanumeric, no special chars).
fn arb_field_name() -> impl Strategy<Value = String> {
    "[a-z][a-z0-9_]{0,15}".prop_map(String::from)
}

/// Arbitrary multi-segment paths (1–3 segments).
fn arb_path() -> impl Strategy<Value = Vec<String>> {
    prop::collection::vec(arb_field_name(), 1..=3)
}

/// Scalar string values that should always be parameterized.
fn arb_string_value() -> impl Strategy<Value = Value> {
    "[a-zA-Z0-9@._\\-]{1,30}".prop_map(Value::String)
}

/// Scalar numeric values (integers).
fn arb_number_value() -> impl Strategy<Value = Value> {
    any::<i32>().prop_map(|n| json!(n))
}

/// Scalar boolean values.
fn arb_bool_value() -> impl Strategy<Value = Value> {
    any::<bool>().prop_map(|b| json!(b))
}

/// Mix of scalar types that any dialect can handle for Eq.
fn arb_scalar_value() -> impl Strategy<Value = Value> {
    prop_oneof![arb_string_value(), arb_number_value(), arb_bool_value(),]
}

/// Simple LIKE-family operators that use exactly one param.
fn arb_like_operator() -> impl Strategy<Value = WhereOperator> {
    prop_oneof![
        Just(WhereOperator::Contains),
        Just(WhereOperator::Startswith),
        Just(WhereOperator::Endswith),
        Just(WhereOperator::Like),
    ]
}

/// Comparison operators that take exactly one scalar param.
fn arb_comparison_operator() -> impl Strategy<Value = WhereOperator> {
    prop_oneof![
        Just(WhereOperator::Eq),
        Just(WhereOperator::Neq),
        Just(WhereOperator::Gt),
        Just(WhereOperator::Gte),
        Just(WhereOperator::Lt),
        Just(WhereOperator::Lte),
    ]
}

const fn field(path: Vec<String>, op: WhereOperator, val: Value) -> WhereClause {
    WhereClause::Field {
        path,
        operator: op,
        value: val,
    }
}

// ── PostgreSQL property tests ─────────────────────────────────────────────────

proptest! {
    /// Values are never interpolated into SQL — always appear in params.
    #[test]
    fn prop_pg_string_never_inlined(
        path in arb_path(),
        value in "[a-zA-Z0-9]{4,20}",
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, WhereOperator::Eq, Value::String(value.clone()));
        let (sql, params) = gen.generate(&clause).unwrap();
        prop_assert!(!sql.contains(&value), "Value must not appear in SQL: {sql}");
        prop_assert_eq!(params.len(), 1);
        prop_assert_eq!(&params[0], &json!(value));
    }

    /// Placeholder numbering starts at $1 and increases sequentially.
    #[test]
    fn prop_pg_placeholders_sequential(
        paths in prop::collection::vec(arb_path(), 1..=4),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clauses: Vec<WhereClause> = paths
            .into_iter()
            .map(|p| field(p, WhereOperator::Eq, json!("x")))
            .collect();
        let n = clauses.len();
        let clause = WhereClause::And(clauses);
        let (sql, params) = gen.generate(&clause).unwrap();
        for i in 1..=n {
            prop_assert!(sql.contains(&format!("${i}")), "Missing ${i} in: {sql}");
        }
        prop_assert_eq!(params.len(), n);
    }

    /// PostgreSQL never uses `?` as a placeholder.
    #[test]
    fn prop_pg_no_question_mark_placeholder(
        path in arb_path(),
        op in arb_comparison_operator(),
        value in arb_scalar_value(),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, op, value);
        if let Ok((sql, _)) = gen.generate(&clause) {
            // The only `?` in PG SQL would be wrong
            prop_assert!(!sql.contains('?'), "PG must not use ?: {sql}");
        }
    }

    /// `generate()` is idempotent — calling twice yields same SQL.
    #[test]
    fn prop_pg_generate_resets_counter(
        path in arb_path(),
        value in arb_string_value(),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, WhereOperator::Eq, value);
        let (sql1, params1) = gen.generate(&clause).unwrap();
        let (sql2, params2) = gen.generate(&clause).unwrap();
        prop_assert_eq!(&sql1, &sql2, "SQL must be identical on second call");
        prop_assert_eq!(params1, params2);
    }

    /// IsNull produces zero parameters regardless of path.
    #[test]
    fn prop_pg_isnull_zero_params(
        path in arb_path(),
        is_null in any::<bool>(),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, WhereOperator::IsNull, json!(is_null));
        let (_, params) = gen.generate(&clause).unwrap();
        prop_assert!(params.is_empty(), "IsNull must have zero params, got {params:?}");
    }

    /// Numeric equality on PG uses ::numeric cast on the LHS.
    #[test]
    fn prop_pg_numeric_eq_casts_lhs(
        path in arb_path(),
        value in any::<i64>(),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, WhereOperator::Eq, json!(value));
        let (sql, _) = gen.generate(&clause).unwrap();
        prop_assert!(
            sql.contains("::numeric"),
            "PG numeric Eq must cast LHS to ::numeric: {sql}"
        );
    }

    /// LIKE operators parameterize the search term (one param, not zero).
    #[test]
    fn prop_pg_like_has_one_param(
        path in arb_path(),
        op in arb_like_operator(),
        value in "[a-zA-Z0-9]{2,15}",
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let clause = field(path, op, Value::String(value));
        let (_, params) = gen.generate(&clause).unwrap();
        prop_assert_eq!(params.len(), 1, "LIKE-family must have exactly 1 param");
    }

    /// IN with N elements produces exactly N params.
    #[test]
    fn prop_pg_in_param_count(
        path in arb_path(),
        values in prop::collection::vec(arb_string_value(), 1..=8),
    ) {
        let gen = PostgresWhereGenerator::new(PostgresDialect);
        let n = values.len();
        let clause = field(path, WhereOperator::In, Value::Array(values));
        let (_, params) = gen.generate(&clause).unwrap();
        prop_assert_eq!(params.len(), n);
    }
}

// ── SQLite property tests ─────────────────────────────────────────────────────

#[cfg(feature = "sqlite")]
mod sqlite_props {
    use fraiseql_db::{SqliteDialect, sqlite::SqliteWhereGenerator};

    use super::*;

    proptest! {
        /// SQLite always uses `?` as placeholder, never `$N` or `@pN`.
        #[test]
        fn prop_sqlite_uses_question_mark(
            path in arb_path(),
            op in arb_comparison_operator(),
            value in arb_scalar_value(),
        ) {
            let gen = SqliteWhereGenerator::new(SqliteDialect);
            let clause = field(path, op, value);
            if let Ok((sql, params)) = gen.generate(&clause) {
                if !params.is_empty() {
                    prop_assert!(sql.contains('?'), "SQLite must use ?: {sql}");
                    prop_assert!(!sql.contains("$1"), "SQLite must not use $1: {sql}");
                    prop_assert!(!sql.contains("@p"), "SQLite must not use @pN: {sql}");
                }
            }
        }

        /// SQLite never leaks PostgreSQL cast syntax into generated SQL.
        #[test]
        fn prop_sqlite_no_pg_cast_syntax(
            path in arb_path(),
            op in arb_comparison_operator(),
            value in arb_scalar_value(),
        ) {
            let gen = SqliteWhereGenerator::new(SqliteDialect);
            let clause = field(path, op, value);
            if let Ok((sql, _)) = gen.generate(&clause) {
                prop_assert!(
                    !sql.contains("::text") && !sql.contains("::numeric"),
                    "SQLite SQL must not contain PostgreSQL :: cast syntax: {sql}"
                );
            }
        }

        /// SQLite and PostgreSQL produce the same param count for simple equality.
        #[test]
        fn prop_sqlite_pg_same_param_count_eq(
            path in arb_path(),
            value in arb_string_value(),
        ) {
            let pg = PostgresWhereGenerator::new(PostgresDialect);
            let sq = SqliteWhereGenerator::new(SqliteDialect);
            let clause = field(path, WhereOperator::Eq, value);
            let (_, pg_params) = pg.generate(&clause).unwrap();
            let (_, sq_params) = sq.generate(&clause).unwrap();
            prop_assert_eq!(pg_params.len(), sq_params.len());
            prop_assert_eq!(pg_params[0].clone(), sq_params[0].clone(), "Same bound value");
        }

        /// SQLite uses json_extract for field access.
        #[test]
        fn prop_sqlite_uses_json_extract(
            path in arb_path(),
            value in arb_string_value(),
        ) {
            let gen = SqliteWhereGenerator::new(SqliteDialect);
            let clause = field(path, WhereOperator::Eq, value);
            let (sql, _) = gen.generate(&clause).unwrap();
            prop_assert!(
                sql.contains("json_extract"),
                "SQLite must use json_extract: {sql}"
            );
        }

        /// SQLite IsNull produces zero params for any path.
        #[test]
        fn prop_sqlite_isnull_zero_params(
            path in arb_path(),
            is_null in any::<bool>(),
        ) {
            let gen = SqliteWhereGenerator::new(SqliteDialect);
            let clause = field(path, WhereOperator::IsNull, json!(is_null));
            let (_, params) = gen.generate(&clause).unwrap();
            prop_assert!(params.is_empty());
        }
    }
}

// ── MySQL property tests ──────────────────────────────────────────────────────

#[cfg(feature = "mysql")]
mod mysql_props {
    use fraiseql_db::{MySqlDialect, mysql::MySqlWhereGenerator};

    use super::*;

    proptest! {
        /// MySQL always uses `?` as placeholder, never `$N` or `@pN`.
        #[test]
        fn prop_mysql_uses_question_mark(
            path in arb_path(),
            op in arb_comparison_operator(),
            value in arb_scalar_value(),
        ) {
            let gen = MySqlWhereGenerator::new(MySqlDialect);
            let clause = field(path, op, value);
            if let Ok((sql, params)) = gen.generate(&clause) {
                if !params.is_empty() {
                    prop_assert!(sql.contains('?'), "MySQL must use ?: {sql}");
                    prop_assert!(!sql.contains("$1"), "MySQL must not use $1: {sql}");
                }
            }
        }

        /// MySQL never leaks PostgreSQL cast syntax.
        #[test]
        fn prop_mysql_no_pg_cast_syntax(
            path in arb_path(),
            op in arb_comparison_operator(),
            value in arb_scalar_value(),
        ) {
            let gen = MySqlWhereGenerator::new(MySqlDialect);
            let clause = field(path, op, value);
            if let Ok((sql, _)) = gen.generate(&clause) {
                prop_assert!(
                    !sql.contains("::text") && !sql.contains("::numeric"),
                    "MySQL SQL must not contain PostgreSQL :: cast syntax: {sql}"
                );
            }
        }

        /// MySQL and PostgreSQL produce the same param count for Eq.
        #[test]
        fn prop_mysql_pg_same_param_count_eq(
            path in arb_path(),
            value in arb_string_value(),
        ) {
            let pg = PostgresWhereGenerator::new(PostgresDialect);
            let my = MySqlWhereGenerator::new(MySqlDialect);
            let clause = field(path, WhereOperator::Eq, value);
            let (_, pg_params) = pg.generate(&clause).unwrap();
            let (_, my_params) = my.generate(&clause).unwrap();
            prop_assert_eq!(pg_params.len(), my_params.len());
            prop_assert_eq!(pg_params[0].clone(), my_params[0].clone(), "Same bound value");
        }

        /// MySQL uses JSON_UNQUOTE(JSON_EXTRACT(...)) for field access.
        #[test]
        fn prop_mysql_uses_json_unquote(
            path in arb_path(),
            value in arb_string_value(),
        ) {
            let gen = MySqlWhereGenerator::new(MySqlDialect);
            let clause = field(path, WhereOperator::Eq, value);
            let (sql, _) = gen.generate(&clause).unwrap();
            prop_assert!(
                sql.contains("JSON_UNQUOTE"),
                "MySQL must use JSON_UNQUOTE: {sql}"
            );
        }
    }
}

// ── SQL Server property tests ─────────────────────────────────────────────────

#[cfg(feature = "sqlserver")]
mod sqlserver_props {
    use fraiseql_db::{SqlServerDialect, sqlserver::SqlServerWhereGenerator};

    use super::*;

    proptest! {
        /// SQL Server uses `@pN` placeholders, never `?` or `$N`.
        #[test]
        fn prop_sqlserver_uses_named_params(
            path in arb_path(),
            op in arb_comparison_operator(),
            value in arb_scalar_value(),
        ) {
            let gen = SqlServerWhereGenerator::new(SqlServerDialect);
            let clause = field(path, op, value);
            if let Ok((sql, params)) = gen.generate(&clause) {
                if !params.is_empty() {
                    prop_assert!(sql.contains("@p1"), "SQL Server must use @p1: {sql}");
                    prop_assert!(!sql.contains('?'), "SQL Server must not use ?: {sql}");
                    prop_assert!(!sql.contains("$1"), "SQL Server must not use $1: {sql}");
                }
            }
        }

        /// SQL Server and PostgreSQL produce the same param count for Eq.
        #[test]
        fn prop_sqlserver_pg_same_param_count_eq(
            path in arb_path(),
            value in arb_string_value(),
        ) {
            let pg = PostgresWhereGenerator::new(PostgresDialect);
            let ss = SqlServerWhereGenerator::new(SqlServerDialect);
            let clause = field(path, WhereOperator::Eq, value);
            let (_, pg_params) = pg.generate(&clause).unwrap();
            let (_, ss_params) = ss.generate(&clause).unwrap();
            prop_assert_eq!(pg_params.len(), ss_params.len());
            prop_assert_eq!(pg_params[0].clone(), ss_params[0].clone(), "Same bound value");
        }
    }
}