🐿️ Type safe SQL in Gleam
1import envoy
2import filepath
3import glam/doc.{type Document}
4import gleam/bool
5import gleam/dict.{type Dict}
6import gleam/int
7import gleam/io
8import gleam/list
9import gleam/result
10import gleam/string
11import gleam_community/ansi
12import simplifile
13import squirrel/internal/database/postgres
14import squirrel/internal/error.{type Error, CannotWriteToFile}
15import squirrel/internal/query.{type TypedQuery}
16import term_size
17
18const squirrel_version = "v1.0.0"
19
20/// 🐿️ Performs code generation for your Gleam project.
21///
22/// `squirrel` is not configurable and will discover the queries to generate
23/// code for by relying on a conventional project's structure:
24/// - `squirrel` first looks for all directories called `sql` under the `src`
25/// directory of your Gleam project, and reads all the `*.sql` files in there
26/// (in glob terms `src/**/sql/*.sql`).
27/// - Each `*.sql` file _must contain a single query_ as it is turned into a
28/// Gleam function with the same name.
29/// - All functions coming from the same `sql` directory will be grouped under
30/// a Gleam file called `sql.gleam` at the same level: given a `src/$PATH/sql`
31/// directory, you'll end up with a generated `src/$PATH/sql.gleam` file.
32///
33/// > ⚠️ In order to generate type safe code, `squirrel` has to connect
34/// > to your Postgres database. To know what host, user, etc. values to use
35/// > when connecting, it will read your
36/// > [Postgres env variables.](https://www.postgresql.org/docs/current/libpq-envars.html)
37/// >
38/// > If a variable is not set it will go with the following defaults:
39/// > - `PGHOST`: `"localhost"`
40/// > - `PGPORT`: `5432`
41/// > - `PGUSER`: `"root"`
42/// > - `PGDATABASE`: `"database"`
43/// > - `PGPASSWORD`: `""`
44///
45/// > ⚠️ The generated code relies on the
46/// > [`gleam_pgo`](https://hexdocs.pm/gleam_pgo/) and
47/// > [`decode`](https://hexdocs.pm/decode/) packages to work, so make sure to
48/// > add those as dependencies to your project.
49///
50pub fn main() {
51 walk("src")
52 |> run(read_connection_options())
53 |> pretty_report
54 |> io.println
55}
56
57fn read_connection_options() -> postgres.ConnectionOptions {
58 let host = envoy.get("PGHOST") |> result.unwrap("localhost")
59 let user = envoy.get("PGUSER") |> result.unwrap("root")
60 let database = envoy.get("PGDATABASE") |> result.unwrap("database")
61 let password = envoy.get("PGPASSWORD") |> result.unwrap("")
62 let port =
63 envoy.get("PGPORT")
64 |> result.then(int.parse)
65 |> result.unwrap(5432)
66
67 postgres.ConnectionOptions(
68 host: host,
69 port: port,
70 user: user,
71 password: password,
72 database: database,
73 timeout: 1000,
74 )
75}
76
77/// Finds all `from/**/sql` directories and lists the full paths of the `*.sql`
78/// files inside each one.
79///
80fn walk(from: String) -> Dict(String, List(String)) {
81 case filepath.base_name(from) {
82 "sql" -> {
83 let assert Ok(files) = simplifile.read_directory(from)
84 let files = {
85 use file <- list.filter_map(files)
86 use extension <- result.try(filepath.extension(file))
87 use <- bool.guard(when: extension != "sql", return: Error(Nil))
88 let file_name = filepath.join(from, file)
89 case simplifile.is_file(file_name) {
90 Ok(True) -> Ok(file_name)
91 Ok(False) | Error(_) -> Error(Nil)
92 }
93 }
94 dict.from_list([#(from, files)])
95 }
96
97 _ -> {
98 let assert Ok(files) = simplifile.read_directory(from)
99 let directories = {
100 use file <- list.filter_map(files)
101 let file_name = filepath.join(from, file)
102 case simplifile.is_directory(file_name) {
103 Ok(True) -> Ok(file_name)
104 Ok(False) | Error(_) -> Error(Nil)
105 }
106 }
107
108 list.map(directories, walk)
109 |> list.fold(from: dict.new(), with: dict.merge)
110 }
111 }
112}
113
114/// Given a dict of directories and their `*.sql` files, performs code
115/// generation for each one, bundling all `*.sql` files under the same directory
116/// into a single Gleam module.
117///
118fn run(
119 directories: Dict(String, List(String)),
120 connection: postgres.ConnectionOptions,
121) -> Dict(String, #(Int, List(Error))) {
122 use directory, files <- dict.map_values(directories)
123
124 let #(queries, errors) =
125 list.map(files, query.from_file)
126 |> result.partition
127
128 let #(queries, errors) = case postgres.main(queries, connection) {
129 Error(error) -> #([], [error, ..errors])
130 Ok(#(queries, type_errors)) -> #(queries, list.append(errors, type_errors))
131 }
132
133 let output_file =
134 filepath.directory_name(directory)
135 |> filepath.join("sql.gleam")
136
137 case write_queries(queries, to: output_file) {
138 Ok(n) -> #(n, errors)
139 Error(error) -> #(list.length(queries), [error, ..errors])
140 }
141}
142
143fn write_queries(
144 queries: List(TypedQuery),
145 to file: String,
146) -> Result(Int, Error) {
147 use <- bool.guard(when: queries == [], return: Ok(0))
148
149 let directory = filepath.directory_name(file)
150 let _ = simplifile.create_directory_all(directory)
151
152 // We need the top level imports.
153 let imports = "import gleam/pgo\nimport decode\n"
154 let #(count, code) = {
155 use #(count, code), query <- list.fold(queries, #(0, imports))
156 #(count + 1, code <> "\n" <> query.generate_code(squirrel_version, query))
157 }
158
159 let try_write =
160 simplifile.write(code, to: file)
161 |> result.map_error(CannotWriteToFile(file, _))
162
163 use _ <- result.try(try_write)
164 Ok(count)
165}
166
167// --- PRETTY REPORT PRINTING --------------------------------------------------
168
169fn pretty_report(dirs: Dict(String, #(Int, List(Error)))) -> String {
170 let width = term_size.columns() |> result.unwrap(80)
171 let #(ok, errors) = {
172 use #(all_ok, all_errors), _, result <- dict.fold(dirs, #(0, []))
173 let #(ok, errors) = result
174 #(all_ok + ok, errors |> list.append(all_errors))
175 }
176 let errors_doc =
177 list.map(errors, error.to_doc)
178 |> doc.join(with: doc.lines(2))
179
180 case ok, errors {
181 0, [_, ..] -> doc.to_string(errors_doc, width)
182 0, [] ->
183 text_with_header(
184 "🐿️ ",
185 "I couldn't find any `*.sql` file to generate queries from",
186 )
187 |> doc.to_string(width)
188 |> ansi.yellow
189
190 n, [] ->
191 text_with_header(
192 "🐿️ ",
193 "Generated "
194 <> int.to_string(n)
195 <> " "
196 <> pluralise(n, "query", "queries"),
197 )
198 |> doc.to_string(width)
199 |> ansi.green
200 |> string.append("\n")
201 |> string.append(
202 text_with_header(
203 "🥜 ",
204 "Don't forget to run `gleam add decode gleam_pgo` if you haven't yet!",
205 )
206 |> doc.to_string(width)
207 |> ansi.cyan,
208 )
209
210 n, [_, ..] ->
211 [
212 errors_doc,
213 doc.lines(2),
214 text_with_header(
215 "🥜 ",
216 "I could still generate "
217 <> int.to_string(n)
218 <> " "
219 <> pluralise(n, "query", "queries"),
220 ),
221 ]
222 |> doc.concat
223 |> doc.to_string(width)
224 }
225}
226
227fn text_with_header(header: String, text: String) {
228 [
229 doc.from_string(header),
230 flexible_string(text)
231 |> doc.nest(by: string.length(header)),
232 ]
233 |> doc.concat
234 |> doc.group
235}
236
237fn pluralise(count: Int, singular: String, plural: String) -> String {
238 case count {
239 1 -> singular
240 _ -> plural
241 }
242}
243
244fn flexible_string(string: String) -> Document {
245 string.split(string, on: "\n")
246 |> list.flat_map(string.split(_, on: " "))
247 |> list.map(doc.from_string)
248 |> doc.join(with: doc.flex_space)
249 |> doc.group
250}