Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql

fix: use numbered placeholders in cursor WHERE clause for PostgreSQL

The build_cursor_where_clause function was using literal '?' placeholders,
which works for SQLite but fails on PostgreSQL (which needs $1, $2, etc.).

Now accepts a start_index parameter and uses executor.placeholder() to
generate the correct format for each database dialect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+53 -30
+49 -30
server/src/database/queries/pagination.gleam
··· 212 212 decoded_cursor: DecodedCursor, 213 213 sort_by: Option(List(#(String, String))), 214 214 is_before: Bool, 215 + start_index: Int, 215 216 ) -> #(String, List(String)) { 216 217 let sort_fields = case sort_by { 217 218 None -> [] ··· 228 229 decoded_cursor.field_values, 229 230 decoded_cursor.cid, 230 231 is_before, 232 + start_index, 231 233 ) 232 234 233 235 let sql = "(" <> string.join(clauses.0, " OR ") <> ")" ··· 243 245 field_values: List(String), 244 246 cid: String, 245 247 is_before: Bool, 248 + start_index: Int, 246 249 ) -> #(List(String), List(String)) { 247 - let #(clauses, params) = 248 - list.index_map(sort_fields, fn(field, i) { 249 - let #(equality_parts, equality_params) = case i { 250 - 0 -> #([], []) 251 - _ -> { 252 - list.range(0, i - 1) 253 - |> list.fold(#([], []), fn(eq_acc, j) { 254 - let #(eq_parts, eq_params) = eq_acc 255 - let prior_field = 256 - list_at(sort_fields, j) |> result.unwrap(#("", "")) 257 - let value = list_at(field_values, j) |> result.unwrap("") 250 + // Build clauses with tracked parameter index 251 + let #(clauses, params, next_index) = 252 + list.index_fold(sort_fields, #([], [], start_index), fn(acc, field, i) { 253 + let #(acc_clauses, acc_params, param_index) = acc 258 254 259 - let field_ref = build_cursor_field_reference(exec, prior_field.0) 260 - let new_part = field_ref <> " = ?" 261 - let new_params = list.append(eq_params, [value]) 262 - 263 - #(list.append(eq_parts, [new_part]), new_params) 264 - }) 255 + // Build equality parts for prior fields 256 + let #(equality_parts, equality_params, idx_after_eq) = case i { 257 + 0 -> #([], [], param_index) 258 + _ -> { 259 + list.index_fold( 260 + list.take(sort_fields, i), 261 + #([], [], param_index), 262 + fn(eq_acc, prior_field, j) { 263 + let #(eq_parts, eq_params, eq_idx) = eq_acc 264 + let value = list_at(field_values, j) |> result.unwrap("") 265 + let field_ref = build_cursor_field_reference(exec, prior_field.0) 266 + let placeholder = executor.placeholder(exec, eq_idx) 267 + let new_part = field_ref <> " = " <> placeholder 268 + #( 269 + list.append(eq_parts, [new_part]), 270 + list.append(eq_params, [value]), 271 + eq_idx + 1, 272 + ) 273 + }, 274 + ) 265 275 } 266 276 } 267 277 268 278 let value = list_at(field_values, i) |> result.unwrap("") 269 - 270 279 let comparison_op = get_comparison_operator(field.1, is_before) 271 280 let field_ref = build_cursor_field_reference(exec, field.0) 281 + let placeholder = executor.placeholder(exec, idx_after_eq) 272 282 273 - let comparison_part = field_ref <> " " <> comparison_op <> " ?" 283 + let comparison_part = 284 + field_ref <> " " <> comparison_op <> " " <> placeholder 274 285 let all_parts = list.append(equality_parts, [comparison_part]) 275 286 let all_params = list.append(equality_params, [value]) 276 287 277 288 let clause = "(" <> string.join(all_parts, " AND ") <> ")" 278 289 279 - #(clause, all_params) 290 + #( 291 + list.append(acc_clauses, [clause]), 292 + list.append(acc_params, all_params), 293 + idx_after_eq + 1, 294 + ) 280 295 }) 281 - |> list.unzip 282 - |> fn(unzipped) { 283 - let flattened_params = list.flatten(unzipped.1) 284 - #(unzipped.0, flattened_params) 285 - } 286 296 287 - let #(final_equality_parts, final_equality_params) = 288 - list.index_map(sort_fields, fn(field, j) { 297 + // Build final clause with all fields equal and CID comparison 298 + let #(final_equality_parts, final_equality_params, idx_after_final_eq) = 299 + list.index_fold(sort_fields, #([], [], next_index), fn(acc, field, j) { 300 + let #(parts, params, idx) = acc 289 301 let value = list_at(field_values, j) |> result.unwrap("") 290 302 let field_ref = build_cursor_field_reference(exec, field.0) 291 - #(field_ref <> " = ?", value) 303 + let placeholder = executor.placeholder(exec, idx) 304 + #( 305 + list.append(parts, [field_ref <> " = " <> placeholder]), 306 + list.append(params, [value]), 307 + idx + 1, 308 + ) 292 309 }) 293 - |> list.unzip 294 310 295 311 let last_field = list.last(sort_fields) |> result.unwrap(#("", "desc")) 296 312 let cid_comparison_op = get_comparison_operator(last_field.1, is_before) 313 + let cid_placeholder = executor.placeholder(exec, idx_after_final_eq) 297 314 298 315 let final_parts = 299 - list.append(final_equality_parts, ["cid " <> cid_comparison_op <> " ?"]) 316 + list.append(final_equality_parts, [ 317 + "cid " <> cid_comparison_op <> " " <> cid_placeholder, 318 + ]) 300 319 let final_params = list.append(final_equality_params, [cid]) 301 320 302 321 let final_clause = "(" <> string.join(final_parts, " AND ") <> ")"
+4
server/src/database/repositories/records.gleam
··· 552 552 decoded_cursor, 553 553 sort_by, 554 554 !is_forward, 555 + list.length(bind_values) + 1, 555 556 ) 556 557 557 558 let new_where = list.append(where_parts, [cursor_where]) ··· 705 706 decoded_cursor, 706 707 sort_by, 707 708 !is_forward, 709 + list.length(bind_values) + 1, 708 710 ) 709 711 710 712 let new_where = list.append(where_parts, [cursor_where]) ··· 946 948 decoded_cursor, 947 949 sort_by, 948 950 !is_forward, 951 + list.length(with_where_values) + 1, 949 952 ) 950 953 951 954 let new_where = list.append(with_where_parts, [cursor_where]) ··· 1302 1305 decoded_cursor, 1303 1306 sort_by, 1304 1307 !is_forward, 1308 + list.length(with_where_values) + 1, 1305 1309 ) 1306 1310 1307 1311 let new_where = list.append(with_where_parts, [cursor_where])