a database layer insipred by caqti and ecto

fix(postgresql): use Postgresql.null for NULL parameter values

The PostgreSQL driver was converting Value.Null to empty string which
caused NULL parameters to be treated as empty strings instead of SQL
NULL values.

Also adds comprehensive integration tests for Pdate (date type) in both
SQLite and PostgreSQL drivers, plus unit tests for date conversions.

Changed files
+425 -1
driver-postgresql
driver-sqlite
test
+1 -1
driver-postgresql/repodb_postgresql.ml
··· 26 26 27 27 let value_to_string (v : Repodb.Driver.Value.t) : string = 28 28 match v with 29 - | Repodb.Driver.Value.Null -> "" 29 + | Repodb.Driver.Value.Null -> Postgresql.null 30 30 | Repodb.Driver.Value.Int n -> string_of_int n 31 31 | Repodb.Driver.Value.Int64 n -> Int64.to_string n 32 32 | Repodb.Driver.Value.Float f -> string_of_float f
+182
driver-postgresql/test_integration.ml
··· 3432 3432 ("concurrent_domains", `Slow, test_pool_concurrent_domains); 3433 3433 ] 3434 3434 3435 + let test_date_insert_and_query = 3436 + with_db (fun conn -> 3437 + let _ = 3438 + Repodb_postgresql.exec conn "DROP TABLE IF EXISTS events" ~params:[||] 3439 + in 3440 + let _ = 3441 + Repodb_postgresql.exec conn 3442 + "CREATE TABLE events (id SERIAL PRIMARY KEY, name TEXT, event_date \ 3443 + DATE)" 3444 + ~params:[||] 3445 + in 3446 + let date : Ptime.date = (2024, 6, 15) in 3447 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3448 + let insert = 3449 + Repodb_postgresql.exec conn 3450 + "INSERT INTO events (name, event_date) VALUES ($1, $2)" 3451 + ~params:[| Repodb.Driver.Value.text "Conference"; date_value |] 3452 + in 3453 + (match insert with 3454 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3455 + | Ok () -> ()); 3456 + match 3457 + Repodb_postgresql.query conn 3458 + "SELECT event_date FROM events WHERE name = $1" 3459 + ~params:[| Repodb.Driver.Value.text "Conference" |] 3460 + with 3461 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3462 + | Ok [] -> Alcotest.fail "expected row" 3463 + | Ok (row :: _) -> ( 3464 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3465 + match Repodb.Types.of_value Repodb.Types.pdate raw_value with 3466 + | Error e -> Alcotest.fail ("failed to decode date: " ^ e) 3467 + | Ok (y, m, d) -> 3468 + Alcotest.(check int) "year" 2024 y; 3469 + Alcotest.(check int) "month" 6 m; 3470 + Alcotest.(check int) "day" 15 d)) 3471 + 3472 + let test_date_comparison = 3473 + with_db (fun conn -> 3474 + let _ = 3475 + Repodb_postgresql.exec conn "DROP TABLE IF EXISTS events" ~params:[||] 3476 + in 3477 + let _ = 3478 + Repodb_postgresql.exec conn 3479 + "CREATE TABLE events (id SERIAL PRIMARY KEY, name TEXT, event_date \ 3480 + DATE)" 3481 + ~params:[||] 3482 + in 3483 + let dates = 3484 + [ 3485 + ((2024, 1, 10), "Event A"); 3486 + ((2024, 6, 15), "Event B"); 3487 + ((2024, 12, 25), "Event C"); 3488 + ] 3489 + in 3490 + List.iter 3491 + (fun (date, name) -> 3492 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3493 + let _ = 3494 + Repodb_postgresql.exec conn 3495 + "INSERT INTO events (name, event_date) VALUES ($1, $2)" 3496 + ~params:[| Repodb.Driver.Value.text name; date_value |] 3497 + in 3498 + ()) 3499 + dates; 3500 + match 3501 + Repodb_postgresql.query conn 3502 + "SELECT name FROM events WHERE event_date > $1 ORDER BY event_date" 3503 + ~params:[| Repodb.Driver.Value.text "2024-06-01" |] 3504 + with 3505 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3506 + | Ok rows -> 3507 + Alcotest.(check int) "two events after June 1" 2 (List.length rows); 3508 + Alcotest.(check string) 3509 + "first is Event B" "Event B" 3510 + (Repodb.Driver.row_text (List.nth rows 0) 0); 3511 + Alcotest.(check string) 3512 + "second is Event C" "Event C" 3513 + (Repodb.Driver.row_text (List.nth rows 1) 0)) 3514 + 3515 + let test_date_null_handling = 3516 + with_db (fun conn -> 3517 + let _ = 3518 + Repodb_postgresql.exec conn "DROP TABLE IF EXISTS events" ~params:[||] 3519 + in 3520 + let _ = 3521 + Repodb_postgresql.exec conn 3522 + "CREATE TABLE events (id SERIAL PRIMARY KEY, name TEXT, event_date \ 3523 + DATE)" 3524 + ~params:[||] 3525 + in 3526 + let _ = 3527 + Repodb_postgresql.exec conn 3528 + "INSERT INTO events (name, event_date) VALUES ($1, $2)" 3529 + ~params: 3530 + [| Repodb.Driver.Value.text "TBD Event"; Repodb.Driver.Value.null |] 3531 + in 3532 + match 3533 + Repodb_postgresql.query_one conn 3534 + "SELECT event_date FROM events WHERE name = $1" 3535 + ~params:[| Repodb.Driver.Value.text "TBD Event" |] 3536 + with 3537 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3538 + | Ok None -> Alcotest.fail "expected row" 3539 + | Ok (Some row) -> ( 3540 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3541 + match 3542 + Repodb.Types.of_value 3543 + (Repodb.Types.option Repodb.Types.pdate) 3544 + raw_value 3545 + with 3546 + | Error e -> Alcotest.fail ("failed to decode: " ^ e) 3547 + | Ok None -> () 3548 + | Ok (Some _) -> Alcotest.fail "expected None for NULL date")) 3549 + 3550 + let test_date_roundtrip = 3551 + with_db (fun conn -> 3552 + let _ = 3553 + Repodb_postgresql.exec conn "DROP TABLE IF EXISTS events" ~params:[||] 3554 + in 3555 + let _ = 3556 + Repodb_postgresql.exec conn 3557 + "CREATE TABLE events (id SERIAL PRIMARY KEY, event_date DATE)" 3558 + ~params:[||] 3559 + in 3560 + let test_dates = 3561 + [ (2024, 1, 1); (2024, 12, 31); (1999, 6, 15); (2030, 2, 28) ] 3562 + in 3563 + List.iter 3564 + (fun date -> 3565 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3566 + let _ = 3567 + Repodb_postgresql.exec conn 3568 + "INSERT INTO events (event_date) VALUES ($1)" 3569 + ~params:[| date_value |] 3570 + in 3571 + ()) 3572 + test_dates; 3573 + match 3574 + Repodb_postgresql.query conn "SELECT event_date FROM events ORDER BY id" 3575 + ~params:[||] 3576 + with 3577 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3578 + | Ok rows -> 3579 + List.iter2 3580 + (fun expected_date row -> 3581 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3582 + match Repodb.Types.of_value Repodb.Types.pdate raw_value with 3583 + | Error e -> Alcotest.fail ("decode failed: " ^ e) 3584 + | Ok actual_date -> 3585 + Alcotest.(check (triple int int int)) 3586 + "date roundtrip" expected_date actual_date) 3587 + test_dates rows) 3588 + 3589 + let test_date_current_date = 3590 + with_db (fun conn -> 3591 + match 3592 + Repodb_postgresql.query_one conn "SELECT CURRENT_DATE" ~params:[||] 3593 + with 3594 + | Error e -> Alcotest.fail (Repodb_postgresql.error_message e) 3595 + | Ok None -> Alcotest.fail "expected row" 3596 + | Ok (Some row) -> ( 3597 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3598 + match Repodb.Types.of_value Repodb.Types.pdate raw_value with 3599 + | Error e -> Alcotest.fail ("failed to decode CURRENT_DATE: " ^ e) 3600 + | Ok (y, m, d) -> 3601 + Alcotest.(check bool) 3602 + "year reasonable" true 3603 + (y >= 2024 && y <= 2100); 3604 + Alcotest.(check bool) "month valid" true (m >= 1 && m <= 12); 3605 + Alcotest.(check bool) "day valid" true (d >= 1 && d <= 31))) 3606 + 3607 + let date_tests = 3608 + [ 3609 + ("insert_and_query", `Quick, test_date_insert_and_query); 3610 + ("comparison", `Quick, test_date_comparison); 3611 + ("null_handling", `Quick, test_date_null_handling); 3612 + ("roundtrip", `Quick, test_date_roundtrip); 3613 + ("current_date", `Quick, test_date_current_date); 3614 + ] 3615 + 3435 3616 let () = 3436 3617 Alcotest.run "repodb-postgresql" 3437 3618 [ ··· 3447 3628 ("Multi", multi_tests); 3448 3629 ("Query-Repo", query_repo_tests); 3449 3630 ("Pool", pool_tests); 3631 + ("Date", date_tests); 3450 3632 ]
+149
driver-sqlite/test_integration.ml
··· 3127 3127 ("concurrent_domains", `Quick, test_pool_concurrent_domains); 3128 3128 ] 3129 3129 3130 + let test_date_insert_and_query = 3131 + with_db (fun conn -> 3132 + let _ = 3133 + Repodb_sqlite.exec conn 3134 + "CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, event_date \ 3135 + TEXT)" 3136 + ~params:[||] 3137 + in 3138 + let date : Ptime.date = (2024, 6, 15) in 3139 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3140 + let insert = 3141 + Repodb_sqlite.exec conn 3142 + "INSERT INTO events (name, event_date) VALUES (?, ?)" 3143 + ~params:[| Repodb.Driver.Value.text "Conference"; date_value |] 3144 + in 3145 + (match insert with 3146 + | Error e -> Alcotest.fail (Repodb_sqlite.error_message e) 3147 + | Ok () -> ()); 3148 + match 3149 + Repodb_sqlite.query conn "SELECT event_date FROM events WHERE name = ?" 3150 + ~params:[| Repodb.Driver.Value.text "Conference" |] 3151 + with 3152 + | Error e -> Alcotest.fail (Repodb_sqlite.error_message e) 3153 + | Ok [] -> Alcotest.fail "expected row" 3154 + | Ok (row :: _) -> ( 3155 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3156 + match Repodb.Types.of_value Repodb.Types.pdate raw_value with 3157 + | Error e -> Alcotest.fail ("failed to decode date: " ^ e) 3158 + | Ok (y, m, d) -> 3159 + Alcotest.(check int) "year" 2024 y; 3160 + Alcotest.(check int) "month" 6 m; 3161 + Alcotest.(check int) "day" 15 d)) 3162 + 3163 + let test_date_comparison = 3164 + with_db (fun conn -> 3165 + let _ = 3166 + Repodb_sqlite.exec conn 3167 + "CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, event_date \ 3168 + TEXT)" 3169 + ~params:[||] 3170 + in 3171 + let dates = 3172 + [ 3173 + ((2024, 1, 10), "Event A"); 3174 + ((2024, 6, 15), "Event B"); 3175 + ((2024, 12, 25), "Event C"); 3176 + ] 3177 + in 3178 + List.iter 3179 + (fun (date, name) -> 3180 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3181 + let _ = 3182 + Repodb_sqlite.exec conn 3183 + "INSERT INTO events (name, event_date) VALUES (?, ?)" 3184 + ~params:[| Repodb.Driver.Value.text name; date_value |] 3185 + in 3186 + ()) 3187 + dates; 3188 + match 3189 + Repodb_sqlite.query conn 3190 + "SELECT name FROM events WHERE event_date > ? ORDER BY event_date" 3191 + ~params:[| Repodb.Driver.Value.text "2024-06-01" |] 3192 + with 3193 + | Error e -> Alcotest.fail (Repodb_sqlite.error_message e) 3194 + | Ok rows -> 3195 + Alcotest.(check int) "two events after June 1" 2 (List.length rows); 3196 + Alcotest.(check string) 3197 + "first is Event B" "Event B" 3198 + (Repodb.Driver.row_text (List.nth rows 0) 0); 3199 + Alcotest.(check string) 3200 + "second is Event C" "Event C" 3201 + (Repodb.Driver.row_text (List.nth rows 1) 0)) 3202 + 3203 + let test_date_null_handling = 3204 + with_db (fun conn -> 3205 + let _ = 3206 + Repodb_sqlite.exec conn 3207 + "CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, event_date \ 3208 + TEXT)" 3209 + ~params:[||] 3210 + in 3211 + let _ = 3212 + Repodb_sqlite.exec conn 3213 + "INSERT INTO events (name, event_date) VALUES (?, ?)" 3214 + ~params: 3215 + [| Repodb.Driver.Value.text "TBD Event"; Repodb.Driver.Value.null |] 3216 + in 3217 + match 3218 + Repodb_sqlite.query_one conn 3219 + "SELECT event_date FROM events WHERE name = ?" 3220 + ~params:[| Repodb.Driver.Value.text "TBD Event" |] 3221 + with 3222 + | Error e -> Alcotest.fail (Repodb_sqlite.error_message e) 3223 + | Ok None -> Alcotest.fail "expected row" 3224 + | Ok (Some row) -> ( 3225 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3226 + match 3227 + Repodb.Types.of_value 3228 + (Repodb.Types.option Repodb.Types.pdate) 3229 + raw_value 3230 + with 3231 + | Error e -> Alcotest.fail ("failed to decode: " ^ e) 3232 + | Ok None -> () 3233 + | Ok (Some _) -> Alcotest.fail "expected None for NULL date")) 3234 + 3235 + let test_date_roundtrip = 3236 + with_db (fun conn -> 3237 + let _ = 3238 + Repodb_sqlite.exec conn 3239 + "CREATE TABLE events (id INTEGER PRIMARY KEY, event_date TEXT)" 3240 + ~params:[||] 3241 + in 3242 + let test_dates = 3243 + [ (2024, 1, 1); (2024, 12, 31); (1999, 6, 15); (2030, 2, 28) ] 3244 + in 3245 + List.iter 3246 + (fun date -> 3247 + let date_value = Repodb.Types.to_value Repodb.Types.pdate date in 3248 + let _ = 3249 + Repodb_sqlite.exec conn "INSERT INTO events (event_date) VALUES (?)" 3250 + ~params:[| date_value |] 3251 + in 3252 + ()) 3253 + test_dates; 3254 + match 3255 + Repodb_sqlite.query conn "SELECT event_date FROM events ORDER BY id" 3256 + ~params:[||] 3257 + with 3258 + | Error e -> Alcotest.fail (Repodb_sqlite.error_message e) 3259 + | Ok rows -> 3260 + List.iter2 3261 + (fun expected_date row -> 3262 + let raw_value = Repodb.Driver.row_get_idx row 0 in 3263 + match Repodb.Types.of_value Repodb.Types.pdate raw_value with 3264 + | Error e -> Alcotest.fail ("decode failed: " ^ e) 3265 + | Ok actual_date -> 3266 + Alcotest.(check (triple int int int)) 3267 + "date roundtrip" expected_date actual_date) 3268 + test_dates rows) 3269 + 3270 + let date_tests = 3271 + [ 3272 + ("insert_and_query", `Quick, test_date_insert_and_query); 3273 + ("comparison", `Quick, test_date_comparison); 3274 + ("null_handling", `Quick, test_date_null_handling); 3275 + ("roundtrip", `Quick, test_date_roundtrip); 3276 + ] 3277 + 3130 3278 let () = 3131 3279 Alcotest.run "repodb-sqlite" 3132 3280 [ ··· 3141 3289 ("Multi", multi_tests); 3142 3290 ("Query-Repo", query_repo_tests); 3143 3291 ("Pool", pool_tests); 3292 + ("Date", date_tests); 3144 3293 ]
+93
test/test_types.ml
··· 30 30 "ptime maps to TIMESTAMPTZ" "TIMESTAMPTZ" 31 31 (Types.sql_type_name Types.ptime) 32 32 33 + let test_sql_type_name_pdate () = 34 + Alcotest.(check string) 35 + "pdate maps to DATE" "DATE" 36 + (Types.sql_type_name Types.pdate) 37 + 38 + let test_sql_type_name_pdate_sqlite () = 39 + Alcotest.(check string) 40 + "pdate maps to TEXT for SQLite" "TEXT" 41 + (Types.sql_type_name_for_sqlite Types.pdate) 42 + 33 43 let test_sql_type_name_uuid () = 34 44 Alcotest.(check string) 35 45 "uuid maps to UUID" "UUID" ··· 71 81 "custom type name" "CUSTOM_TYPE" 72 82 (Types.sql_type_name custom) 73 83 84 + let test_pdate_to_value () = 85 + let date : Ptime.date = (2024, 1, 15) in 86 + let value = Types.to_value Types.pdate date in 87 + match value with 88 + | Driver.Value.Text s -> 89 + Alcotest.(check string) "date serializes to YYYY-MM-DD" "2024-01-15" s 90 + | _ -> Alcotest.fail "expected Text value" 91 + 92 + let test_pdate_to_value_single_digit_month_day () = 93 + let date : Ptime.date = (2024, 3, 5) in 94 + let value = Types.to_value Types.pdate date in 95 + match value with 96 + | Driver.Value.Text s -> 97 + Alcotest.(check string) "single digit month/day padded" "2024-03-05" s 98 + | _ -> Alcotest.fail "expected Text value" 99 + 100 + let test_pdate_of_value () = 101 + let value = Driver.Value.Text "2024-01-15" in 102 + match Types.of_value Types.pdate value with 103 + | Ok (y, m, d) -> 104 + Alcotest.(check int) "year" 2024 y; 105 + Alcotest.(check int) "month" 1 m; 106 + Alcotest.(check int) "day" 15 d 107 + | Error e -> Alcotest.fail ("failed to parse date: " ^ e) 108 + 109 + let test_pdate_of_value_unpadded () = 110 + let value = Driver.Value.Text "2024-3-5" in 111 + match Types.of_value Types.pdate value with 112 + | Ok (y, m, d) -> 113 + Alcotest.(check int) "year" 2024 y; 114 + Alcotest.(check int) "month" 3 m; 115 + Alcotest.(check int) "day" 5 d 116 + | Error e -> Alcotest.fail ("failed to parse date: " ^ e) 117 + 118 + let test_pdate_roundtrip () = 119 + let original : Ptime.date = (2023, 12, 31) in 120 + let value = Types.to_value Types.pdate original in 121 + match Types.of_value Types.pdate value with 122 + | Ok result -> 123 + Alcotest.(check (triple int int int)) 124 + "roundtrip preserves date" original result 125 + | Error e -> Alcotest.fail ("roundtrip failed: " ^ e) 126 + 127 + let test_pdate_of_value_invalid () = 128 + let value = Driver.Value.Text "not-a-date" in 129 + match Types.of_value Types.pdate value with 130 + | Ok _ -> Alcotest.fail "should have failed on invalid date" 131 + | Error _ -> () 132 + 133 + let test_pdate_of_value_null () = 134 + let value = Driver.Value.Null in 135 + match Types.of_value Types.pdate value with 136 + | Ok _ -> Alcotest.fail "should have failed on NULL" 137 + | Error _ -> () 138 + 139 + let test_pdate_option_null () = 140 + let value = Driver.Value.Null in 141 + match Types.of_value (Types.option Types.pdate) value with 142 + | Ok None -> () 143 + | Ok (Some _) -> Alcotest.fail "expected None for NULL" 144 + | Error e -> Alcotest.fail ("unexpected error: " ^ e) 145 + 146 + let test_pdate_option_some () = 147 + let value = Driver.Value.Text "2024-06-15" in 148 + match Types.of_value (Types.option Types.pdate) value with 149 + | Ok (Some (y, m, d)) -> 150 + Alcotest.(check int) "year" 2024 y; 151 + Alcotest.(check int) "month" 6 m; 152 + Alcotest.(check int) "day" 15 d 153 + | Ok None -> Alcotest.fail "expected Some date" 154 + | Error e -> Alcotest.fail ("unexpected error: " ^ e) 155 + 74 156 let tests = 75 157 [ 76 158 ("sql_type_name int", `Quick, test_sql_type_name_int); ··· 79 161 ("sql_type_name bool", `Quick, test_sql_type_name_bool); 80 162 ("sql_type_name float", `Quick, test_sql_type_name_float); 81 163 ("sql_type_name ptime", `Quick, test_sql_type_name_ptime); 164 + ("sql_type_name pdate", `Quick, test_sql_type_name_pdate); 165 + ("sql_type_name pdate sqlite", `Quick, test_sql_type_name_pdate_sqlite); 82 166 ("sql_type_name uuid", `Quick, test_sql_type_name_uuid); 83 167 ("sql_type_name json", `Quick, test_sql_type_name_json); 84 168 ("sql_type_name option", `Quick, test_sql_type_name_option); ··· 86 170 ("is_nullable option", `Quick, test_is_nullable_option); 87 171 ("is_nullable non-option", `Quick, test_is_nullable_non_option); 88 172 ("custom type", `Quick, test_custom_type); 173 + ("pdate to_value", `Quick, test_pdate_to_value); 174 + ("pdate to_value padded", `Quick, test_pdate_to_value_single_digit_month_day); 175 + ("pdate of_value", `Quick, test_pdate_of_value); 176 + ("pdate of_value unpadded", `Quick, test_pdate_of_value_unpadded); 177 + ("pdate roundtrip", `Quick, test_pdate_roundtrip); 178 + ("pdate of_value invalid", `Quick, test_pdate_of_value_invalid); 179 + ("pdate of_value null", `Quick, test_pdate_of_value_null); 180 + ("pdate option null", `Quick, test_pdate_option_null); 181 + ("pdate option some", `Quick, test_pdate_option_some); 89 182 ]