Something went wrong. Try again.
🐿️ Type safe SQL in Gleam
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690import argvimport envoyimport filepathimport glam/doc.{type Document}import gleam/boolimport gleam/dict.{type Dict}import gleam/intimport gleam/ioimport gleam/listimport gleam/option.{type Option, None, Some}import gleam/resultimport gleam/stringimport gleam/uri.{Uri}import gleam_community/ansiimport glexerimport glexer/tokenimport simplifileimport squirrel/internal/database/postgresimport squirrel/internal/error.{ type Error, CannotOverwriteExistingFile, CannotReadFile, CannotWriteToFile, InvalidConnectionString, OutdatedFile,}import squirrel/internal/projectimport squirrel/internal/query.{type TypedQuery}import term_size
const squirrel_version = "v4.7.0"
/// 🐿️ Performs code generation for your Gleam project.////// `squirrel` is not configurable and will discover the queries to generate/// code for by relying on a conventional project's structure:/// - `squirrel` first looks for all directories called `sql` under the `src`/// directory of your Gleam project, and reads all the `*.sql` files in there/// (in glob terms `src/**/sql/*.sql`)./// - Each `*.sql` file _must contain a single query_ as it is turned into a/// Gleam function with the same name./// - All functions coming from the same `sql` directory will be grouped under/// a Gleam file called `sql.gleam` at the same level: given a `src/$PATH/sql`/// directory, you'll end up with a generated `src/$PATH/sql.gleam` file.////// > ⚠️ In order to generate type safe code, `squirrel` has to connect/// > to your Postgres database. To know what host, user, etc. values to use/// > when connecting, it will read the `DATABASE_URL` env variable that has to/// > be a valid connection string with the following format:/// >/// > ```txt/// > postgres://user:password@host:port/database?connect_timeout=seconds/// > ```/// >/// > If a `DATABASE_URL` variable is not set, Squirrel will instead read your/// > [Postgres env variables](https://www.postgresql.org/docs/current/libpq-envars.html)/// > and use the following defaults if one is not set:/// > - `PGHOST`: `"localhost"`/// > - `PGPORT`: `5432`/// > - `PGUSER`: `"postgres"`/// > - `PGDATABASE`: the name of your Gleam project/// > - `PGPASSWORD`: `""`/// > - `PGCONNECT_TIMEOUT`: `5` seconds////// > ⚠️ The generated code relies on the/// > [`pog`](https://hexdocs.pm/pog/) package to work, so make sure to add/// > that dependency to your project.///pub fn main() { case parse_cli_args() { Error(Nil) -> { help_text() |> doc.to_string(term_width()) |> io.println
exit(1) }
Ok(mode) -> case result.try(connection_options(), postgres.connect_and_authenticate) { Error(error) -> { error.to_doc(error) |> doc.to_string(term_width()) |> io.println
exit(1) }
Ok(connection) -> { let generated_queries = walk(project.src()) |> dict.merge(walk(project.test_())) |> dict.merge(walk(project.dev())) |> generate_queries(connection)
let #(report, status_code) = case mode { GenerateCode -> case ensure_no_errors(generated_queries) { Error(errors) -> report_errors(errors) Ok(generated_queries) -> generated_queries |> write_queries |> report_written_queries }
CheckGeneratedCode -> generated_queries |> check_queries |> report_checked_queries }
io.println(report) exit(status_code) } } }}
fn ensure_no_errors( generated_queries: Dict(String, #(List(TypedQuery), List(Error))),) -> Result(Dict(String, List(TypedQuery)), List(Error)) { let all_errors = dict.fold(generated_queries, [], fn(errors, _directory, queries_and_errors) { let #(_queries, new_errors) = queries_and_errors list.append(new_errors, errors) })
case all_errors { [_, ..] -> Error(all_errors) [] -> Ok(dict.map_values(generated_queries, fn(_, queries) { queries.0 })) }}
// --- CLI ARGS PARSING --------------------------------------------------------
type Mode { GenerateCode CheckGeneratedCode}
fn parse_cli_args() -> Result(Mode, Nil) { case argv.load().arguments { [] -> Ok(GenerateCode) ["check"] -> Ok(CheckGeneratedCode) _ -> Error(Nil) }}
fn help_text() -> Document { let nesting = 4 let title_line = doc.from_string("🐿️ Squirrel " <> squirrel_version)
let usage_line = doc.concat([ doc.zero_width_string(ansi.yellow("Usage: ")), doc.zero_width_string(ansi.green("gleam run -m squirrel")), doc.from_string(" [COMMAND]"), ])
let check_command = [ doc.from_string("check "), "checks the generated code is up to date with the sql files" |> flexible_string |> doc.nest(by: 7), ] |> doc.concat |> doc.group
let commands = [ doc.zero_width_string(ansi.yellow("Commands:")), doc.nest(check_command, by: nesting), ] |> doc.join(with: doc.nest(doc.line, by: nesting))
[title_line, usage_line, commands] |> doc.join(with: doc.lines(2))}
// --- CONNECTION OPTIONS PARSING ----------------------------------------------
/// Returns the connection options to use to connect to the database./// It first tries to read and parse a `DATABASE_URL` env variable (failing if/// it has an invalid format).////// If the `DATABASE_URL` variable is not set, it uses the Postgres env vars and/// some defaults if any of those are not set.///fn connection_options() -> Result(postgres.ConnectionOptions, Error) { case envoy.get("DATABASE_URL") { Ok(url) -> parse_connection_url(url) |> result.replace_error(InvalidConnectionString(url)) Error(_) -> Ok(connection_options_from_variables()) }}
const default_host = "localhost"
const default_user = "postgres"
const default_database = "database"
const default_password = ""
const default_port = 5432
const default_timeout = 5
/// Creates a `ConnectionOptions` reading values from env variables and falling/// back to some defaults if any required one is not set.///fn connection_options_from_variables() -> postgres.ConnectionOptions { let host = envoy.get("PGHOST") |> result.unwrap(default_host) let user = envoy.get("PGUSER") |> result.unwrap(default_user) let password = envoy.get("PGPASSWORD") |> result.unwrap(default_password) let database = envoy.get("PGDATABASE") |> result.or(project.name()) |> result.unwrap(default_database) let port = envoy.get("PGPORT") |> result.try(int.parse) |> result.unwrap(default_port) let timeout_seconds = envoy.get("PGCONNECT_TIMEOUT") |> result.try(int.parse) |> result.unwrap(default_timeout)
postgres.ConnectionOptions( host:, port:, user:, password:, database:, timeout_seconds:, )}
/// Parses a connection string into a `ConnectionOptions` failing if it has an/// invalid format instead of silently producing a default one.///fn parse_connection_url( raw: String,) -> Result(postgres.ConnectionOptions, Nil) { use uri <- result.try(uri.parse(raw)) let Uri(scheme:, userinfo:, host:, port:, path:, query:, ..) = uri
use parameters <- result.try(case query { None -> Ok([]) Some(parameters) -> uri.parse_query(parameters) }) use _ <- result.try(check_scheme(scheme)) let #(user, password) = parse_user_and_password_from_userinfo(userinfo) let database = parse_database_from_path(path) use timeout <- result.try(case list.key_find(parameters, "connect_timeout") { Error(_) -> Ok(default_timeout) Ok(timeout) -> int.parse(timeout) })
Ok(postgres.ConnectionOptions( host: host |> option.unwrap(default_host), port: port |> option.unwrap(default_port), user: user |> option.unwrap(default_user), password: password |> option.unwrap(default_password), database: database |> option.unwrap(default_database), timeout_seconds: timeout, ))}
fn check_scheme(scheme: Option(String)) -> Result(Nil, Nil) { case scheme { Some("postgres") | Some("postgresql") | None -> Ok(Nil) Some(_) -> Error(Nil) }}
fn parse_user_and_password_from_userinfo( userinfo: Option(String),) -> #(Option(String), Option(String)) { case userinfo { None -> #(None, None) Some(userinfo) -> case string.split(userinfo, on: ":") { [user] -> #(Some(user), None) [user, password, ..] -> #(Some(user), Some(password)) _ -> #(None, None) } }}
fn parse_database_from_path(path: String) -> Option(String) { case string.split(path, "/") { ["", database, ..] -> Some(database) _ -> None }}
/// Finds all `from/**/sql` directories and lists the full paths of the `*.sql`/// files inside each one.///fn walk(from: String) -> Dict(String, List(String)) { case filepath.base_name(from) { "sql" -> { let assert Ok(files) = simplifile.read_directory(from) let files = { use file <- list.filter_map(files) use extension <- result.try(filepath.extension(file)) use <- bool.guard(when: extension != "sql", return: Error(Nil)) let file_name = filepath.join(from, file) case simplifile.is_file(file_name) { Ok(True) -> Ok(file_name) Ok(False) | Error(_) -> Error(Nil) } } dict.from_list([#(from, files)]) }
_ -> { let files = case simplifile.read_directory(from) { Ok(files) -> files Error(simplifile.Enoent) -> [] Error(_) -> panic as { "couldn't read directory: " <> from } } let directories = { use file <- list.filter_map(files) let file_name = filepath.join(from, file) case simplifile.is_directory(file_name) { Ok(True) -> Ok(file_name) Ok(False) | Error(_) -> Error(Nil) } }
list.map(directories, walk) |> list.fold(from: dict.new(), with: dict.merge) } }}
// --- MODE: GENERATE QUERIES --------------------------------------------------
/// Given a dict of directories and their `*.sql` files, performs code/// generation for each one, bundling all `*.sql` files under the same directory/// into a single Gleam module.///fn generate_queries( directories: Dict(String, List(String)), connection: postgres.Context,) -> Dict(String, #(List(TypedQuery), List(Error))) { use _directory, files <- dict.map_values(directories)
let #(queries, errors) = list.map(files, query.from_file) |> result.partition
case postgres.main(queries, connection) { Error(error) -> #([], [error, ..errors]) Ok(#(queries, type_errors)) -> #(queries, list.append(errors, type_errors)) }}
/// Given the queries generated by `generate_queries`, tries to write those to/// their own file and returns a dictionary that - for each file - holds the/// number of queries that could be generated or the error that occurred trying/// to write the query.///fn write_queries( queries: Dict(String, List(TypedQuery)),) -> Dict(String, Result(Int, Error)) { use directory, queries <- dict.map_values(queries) let output_file = directory_to_output_file(directory) write_queries_to_file(queries, from: directory, to: output_file)}
fn write_queries_to_file( queries: List(TypedQuery), from queries_directory: String, to file: String,) -> Result(Int, Error) { use <- bool.guard(when: queries == [], return: Ok(0)) let directory = filepath.directory_name(file) let _ = simplifile.create_directory_all(directory)
let code = query.generate_code(squirrel_version, for: queries, from: queries_directory)
safely_overwrite(to: file, content: code) |> result.replace(list.length(queries))}
fn safely_overwrite( to file: String, content code: String,) -> Result(Nil, Error) { case simplifile.read(file) |> result.map(classify_file_content) { // If the file is generated, empty, or doesn't exist at all then it's safe // to overwrite it! Ok(LikelyGenerated) | Ok(Empty) | Error(simplifile.Enoent) -> simplifile.write(code, to: file) |> result.map_error(CannotWriteToFile(file, _))
// Otherwise, we can't safely replace its content. Ok(NotGenerated) -> Error(CannotOverwriteExistingFile(file)) Error(reason) -> Error(CannotWriteToFile(file, reason)) }}
@internalpub fn classify_file_content(content: String) -> FileOrigin { let likely_generated = // In newer versions of squirrel this is always at the beginning of the // file and it would be enough to check for this comment to establish if // a file is generated or not... string.contains( content, "> 🐿️ This module was generated automatically using", ) // ...but in older versions that module comment is not present! So we // need to check if there's any function generated by squirrel. || string.contains( content, "> 🐿️ This function was generated automatically using", )
case likely_generated { True -> LikelyGenerated False -> case string.trim(content) { "" -> Empty _ -> NotGenerated } }}
@internalpub type FileOrigin { /// The file was most likely generated by Squirrel. It can safely be /// overwritten as we do not risk deleting code written by a human. /// LikelyGenerated
/// The file wasn't likely generated but it's only made of whitespace, so it's /// safe to override. Empty
/// The file was most likely not generated by Squirrel. It is not safe to /// overwrite it, or we'd risk deleting code written by a human! NotGenerated}
fn directory_to_output_file(directory: String) -> String { filepath.directory_name(directory) |> filepath.join("sql.gleam")}
// --- MODE: CHECK GENERATED CODE ----------------------------------------------
fn check_queries( queries: Dict(String, #(List(query.TypedQuery), List(Error))),) -> Dict(String, Result(Nil, List(Error))) { use directory, #(queries, errors) <- dict.map_values(queries) case errors { // If there's any error in the query generation (for example there's a piece // of sql code with a syntax error, or a column doesn't exist, ...) // we don't even try to check that the files match, but just report the // errors that took place. [_, ..] -> Error(errors) [] -> { let output_file = directory_to_output_file(directory) case simplifile.read(output_file) { Error(reason) -> Error([CannotReadFile(file: output_file, reason:)]) Ok(actual_code) -> case check_queries_code(queries, actual_code) { Different -> Error([OutdatedFile(file: output_file)]) Same -> Ok(Nil) } } } }}
@internalpub type CheckResult { Different Same}
/// Checks that the given `file`'s code is the same that would be generated from/// the given `queries`./// Returns `Same` if the code is ok, `Different` otherwise.////// > ⚠️ Note how this comparison doesn't take comments and whitespace into/// > account! If the only thing that changed are comments and/or formatting/// > two files will still be considered the same.///fn check_queries_code( queries: List(TypedQuery), actual_code: String,) -> CheckResult { // The code is compared ignoring any comments, so we have no need to know // what actual directory the queries come from: that information is only used // to generate better comments! let expected_code = query.generate_code(squirrel_version, for: queries, from: "check-queries") compare_code_snippets(actual_code, expected_code)}
@internalpub fn compare_code_snippets( actual_code: String, expected_code: String,) -> CheckResult { let actual_tokens = glexer.new(actual_code) |> glexer.discard_comments |> glexer.discard_whitespace |> glexer.lex
let expected_tokens = glexer.new(expected_code) |> glexer.discard_comments |> glexer.discard_whitespace |> glexer.lex
compare_token_lists(expected_tokens, actual_tokens)}
fn compare_token_lists( expected_tokens: List(#(token.Token, glexer.Position)), actual_tokens: List(#(token.Token, glexer.Position)),) { case expected_tokens, actual_tokens { [], [] -> Same [], _ | _, [] -> Different [#(expected_token, _position), ..expected_rest], [#(actual_token, _position), ..actual_rest] -> case expected_token == actual_token { True -> compare_token_lists(expected_rest, actual_rest) False -> Different } }}
// --- PRETTY REPORT PRINTING --------------------------------------------------
fn term_width() -> Int { term_size.columns() |> result.unwrap(80) |> int.min(80)}
fn report_written_queries( directories: Dict(String, Result(Int, Error)),) -> #(String, Int) { let #(ok, errors) = { use acc, _, outcome <- dict.fold(directories, #(0, [])) let #(generated_queries, errors) = acc case outcome { Error(error) -> #(generated_queries, [error, ..errors]) Ok(new_count) -> #(generated_queries + new_count, errors) } }
let status_code = case errors { [_, ..] -> 1 [] -> 0 }
let report = case ok, errors { 0, [_, ..] -> errors_to_doc(errors) |> doc.to_string(term_width())
0, [] -> [ text_with_header( "🐿️ ", "I couldn't find any `*.sql` file to generate queries from.", ), doc.lines(2), flexible_string( "Hint: I look for all `*.sql` files in any directory called `sql`under your project's `src`, `test`, and `dev` directories.", ), ] |> doc.concat |> doc.to_string(term_width()) |> ansi.yellow
n, [] -> text_with_header( "🐿️ ", "Generated " <> int.to_string(n) <> " " <> pluralise(n, "query", "queries") <> "!", ) |> doc.to_string(term_width()) |> ansi.green
n, [_, ..] -> [ errors_to_doc(errors), doc.lines(2), text_with_header( "🥜 ", "I could still generate " <> int.to_string(n) <> " " <> pluralise(n, "query", "queries") <> ".", ), ] |> doc.concat |> doc.to_string(term_width()) }
#(report, status_code)}
fn errors_to_doc(errors: List(Error)) -> Document { list.map(errors, error.to_doc) |> doc.join(with: doc.lines(2))}
fn report_errors(errors: List(Error)) -> #(String, Int) { let report = errors_to_doc(errors) |> doc.to_string(term_width()) #(report, 1)}
fn report_checked_queries( dirs: Dict(String, Result(Nil, List(Error))),) -> #(String, Int) { let errors = { use all_errors, _, result <- dict.fold(dirs, []) case result { Error(errors) -> errors |> list.append(all_errors) Ok(_) -> all_errors } }
let status_code = case errors { [_, ..] -> 1 [] -> 0 }
let report = case errors { [_, ..] -> list.map(errors, error.to_doc) |> doc.join(with: doc.lines(2)) |> doc.to_string(term_width())
[] -> text_with_header("🐿️ ", "All good!") |> doc.to_string(term_width()) |> ansi.green }
#(report, status_code)}
fn text_with_header(header: String, text: String) { [ doc.from_string(header), flexible_string(text) |> doc.nest(by: string.length(header)), ] |> doc.concat |> doc.group}
fn pluralise(count: Int, singular: String, plural: String) -> String { case count { 1 -> singular _ -> plural }}
fn flexible_string(string: String) -> Document { string.split(string, on: "\n") |> list.flat_map(string.split(_, on: " ")) |> list.map(doc.from_string) |> doc.join(with: doc.flex_space) |> doc.group}
// --- FFI ---------------------------------------------------------------------
@external(erlang, "squirrel_ffi", "exit")fn exit(n: Int) -> Nil