🐿️ Type safe SQL in Gleam
0

Configure Feed

Select the types of activity you want to include in your feed.

squirrel / src / squirrel.gleam
7.4 kB 250 lines
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}