Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
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}