Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 4.9 kB view raw
1// server/src/database/executor.gleam 2 3import gleam/dynamic.{type Dynamic} 4import gleam/dynamic/decode.{type Decoder} 5import gleam/list 6import gleam/option.{type Option, None, Some} 7import gleam/string 8 9/// Unified error type for all database operations 10pub type DbError { 11 ConnectionError(message: String) 12 QueryError(message: String) 13 DecodeError(message: String) 14 ConstraintError(message: String) 15} 16 17/// Parameter values for database queries 18pub type Value { 19 Text(String) 20 Int(Int) 21 Float(Float) 22 Bool(Bool) 23 Null 24 Blob(BitArray) 25 /// ISO 8601 timestamp string - PostgreSQL treats as TIMESTAMPTZ, SQLite as TEXT 26 Timestamptz(String) 27} 28 29/// Database dialect identifier 30pub type Dialect { 31 SQLite 32 PostgreSQL 33} 34 35/// The Executor provides a unified interface for database operations 36/// across different backends (SQLite, PostgreSQL, etc.) 37pub opaque type Executor { 38 Executor( 39 dialect: Dialect, 40 /// Execute a query and return raw dynamic results 41 query_raw: fn(String, List(Value)) -> Result(List(Dynamic), DbError), 42 /// Execute a statement without returning results 43 exec: fn(String, List(Value)) -> Result(Nil, DbError), 44 /// Generate a placeholder for the given parameter index (1-based) 45 /// SQLite: "?" (ignores index), PostgreSQL: "$1", "$2", etc. 46 placeholder: fn(Int) -> String, 47 /// Generate SQL for extracting a field from a JSON column 48 /// SQLite: json_extract(column, '$.field') 49 /// PostgreSQL: column->>'field' 50 json_extract: fn(String, String) -> String, 51 /// Generate SQL for extracting a nested JSON path 52 /// SQLite: json_extract(column, '$.path.to.field') 53 /// PostgreSQL: column->'path'->'to'->>'field' 54 json_extract_path: fn(String, List(String)) -> String, 55 /// Generate SQL for current timestamp 56 /// SQLite: datetime('now'), PostgreSQL: NOW() 57 now: fn() -> String, 58 ) 59} 60 61// ===== Executor Constructor ===== 62 63/// Create an Executor (used by backend implementations) 64pub fn new( 65 dialect: Dialect, 66 query_raw: fn(String, List(Value)) -> Result(List(Dynamic), DbError), 67 exec: fn(String, List(Value)) -> Result(Nil, DbError), 68 placeholder: fn(Int) -> String, 69 json_extract: fn(String, String) -> String, 70 json_extract_path: fn(String, List(String)) -> String, 71 now: fn() -> String, 72) -> Executor { 73 Executor( 74 dialect: dialect, 75 query_raw: query_raw, 76 exec: exec, 77 placeholder: placeholder, 78 json_extract: json_extract, 79 json_extract_path: json_extract_path, 80 now: now, 81 ) 82} 83 84// ===== Executor Accessors ===== 85 86/// Get the dialect of the executor 87pub fn dialect(exec: Executor) -> Dialect { 88 exec.dialect 89} 90 91/// Execute a query with the given SQL, parameters, and decoder 92pub fn query( 93 exec: Executor, 94 sql: String, 95 params: List(Value), 96 decoder: Decoder(a), 97) -> Result(List(a), DbError) { 98 case exec.query_raw(sql, params) { 99 Ok(rows) -> { 100 let results = 101 list.try_map(rows, fn(row) { 102 case decode.run(row, decoder) { 103 Ok(decoded) -> Ok(decoded) 104 Error(errors) -> 105 Error(DecodeError( 106 "Failed to decode row: " <> string.inspect(errors), 107 )) 108 } 109 }) 110 results 111 } 112 Error(err) -> Error(err) 113 } 114} 115 116/// Execute a statement without returning results 117pub fn exec( 118 exec: Executor, 119 sql: String, 120 params: List(Value), 121) -> Result(Nil, DbError) { 122 exec.exec(sql, params) 123} 124 125/// Generate a placeholder for parameter at given index (1-based) 126pub fn placeholder(exec: Executor, index: Int) -> String { 127 exec.placeholder(index) 128} 129 130/// Generate SQL for extracting a JSON field 131pub fn json_extract(exec: Executor, column: String, field: String) -> String { 132 exec.json_extract(column, field) 133} 134 135/// Generate SQL for extracting a nested JSON path 136pub fn json_extract_path( 137 exec: Executor, 138 column: String, 139 path: List(String), 140) -> String { 141 exec.json_extract_path(column, path) 142} 143 144/// Generate SQL for current timestamp 145pub fn now(exec: Executor) -> String { 146 exec.now() 147} 148 149// ===== Helper Functions ===== 150 151/// Build a list of placeholders for N parameters starting at offset 152pub fn placeholders(exec: Executor, count: Int, start_index: Int) -> String { 153 case count { 154 0 -> "" 155 _ -> { 156 list.range(start_index, start_index + count - 1) 157 |> list.map(fn(i) { placeholder(exec, i) }) 158 |> string.join(", ") 159 } 160 } 161} 162 163/// Convert an optional text value to a Value (Text or Null) 164pub fn nullable_text(value: Option(String)) -> Value { 165 case value { 166 Some(s) -> Text(s) 167 None -> Null 168 } 169} 170 171/// Convert an optional int value to a Value (Int or Null) 172pub fn nullable_int(value: Option(Int)) -> Value { 173 case value { 174 Some(i) -> Int(i) 175 None -> Null 176 } 177} 178 179/// Convert a boolean to a Value 180/// PostgreSQL uses native BOOLEAN, SQLite uses INTEGER (0/1) 181/// Each executor handles this appropriately 182pub fn bool_value(value: Bool) -> Value { 183 Bool(value) 184}