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