diff --git a/CHANGELOG.md b/CHANGELOG.md index 245fd0d..a5f6945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.0.0 - 2024-11-11 + +- Renamed to `pog`. +- Configuration and query API redesigned. +- Introduced the `Date`, `Time`, and `Timestamp` types. +- The default connection pool size is now 10. + ## v0.15.0 - Unreleased - Ensure `ssl` and `pgo` are running before using `gleam_pgo`. diff --git a/README.md b/README.md index 2848cb8..f0effbb 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@ -# Gleam PGO +# Pog A PostgreSQL database client for Gleam, based on [PGO][erlang-pgo]. [erlang-pgo]: https://github.com/erleans/pgo ```gleam -import gleam/pgo +import pog import gleam/dynamic import gleeunit/should pub fn main() { // Start a database connection pool. // Typically you will want to create one pool for use in your program - let db = pgo.connect(pgo.Config( - ..pgo.default_config(), - host: "localhost", - database: "my_database", - pool_size: 15, - )) + let db = + pog.default_config() + |> pog.host("localhost") + |> pog.database("my_database") + |> pog.pool_size(15) + |> pog.connect // An SQL statement to run. It takes one int as a parameter - let sql = " + let sql_query = " select name, age, colour, friends from @@ -29,7 +29,7 @@ pub fn main() { id = $1" // This is the decoder for the value returned by the query - let return_type = dynamic.tuple4( + let row_decoder = dynamic.tuple4( dynamic.string, dynamic.int, dynamic.string, @@ -38,8 +38,11 @@ pub fn main() { // Run the query against the PostgreSQL database // The int `1` is given as a parameter - let assert Ok(response) = - pgo.execute(sql, db, [pgo.int(1)], return_type) + let assert Ok(response) = + pog.query(sql_query) + |> pog.parameter(pog.int(1)) + |> pog.returning(row_decoder) + |> pog.execute(db) // And then do something with the returned results response.count diff --git a/gleam.toml b/gleam.toml index aae16b8..4a8e1b9 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,10 +1,10 @@ -name = "gleam_pgo" -version = "0.14.0" -gleam = ">= 0.32.0" +name = "pog" +version = "1.0.0" +gleam = ">= 1.4.0" licences = ["Apache-2.0"] -description = "Gleam bindings to the PGO PostgreSQL client" +description = "A PostgreSQL database client for Gleam, based on PGO" -repository = { type = "github", user = "gleam-experiments", repo = "pgo" } +repository = { type = "github", user = "lpil", repo = "pog" } links = [ { title = "Website", href = "https://gleam.run" }, { title = "Sponsor", href = "https://github.com/sponsors/lpil" }, diff --git a/src/gleam/pgo.gleam b/src/pog.gleam similarity index 74% rename from src/gleam/pgo.gleam rename to src/pog.gleam index f05d242..fa93d48 100644 --- a/src/gleam/pgo.gleam +++ b/src/pog.gleam @@ -2,7 +2,11 @@ //// //// Gleam wrapper around pgo library +// TODO: add time and timestamp with zone once PGO supports them + import gleam/dynamic.{type DecodeErrors, type Decoder, type Dynamic} +import gleam/float +import gleam/int import gleam/list import gleam/option.{type Option, None, Some} import gleam/result @@ -53,6 +57,110 @@ pub type Config { ) } +/// Database server hostname. +/// +/// (default: 127.0.0.1) +pub fn host(config: Config, host: String) -> Config { + Config(..config, host:) +} + +/// Port the server is listening on. +/// +/// (default: 5432) +pub fn port(config: Config, port: Int) -> Config { + Config(..config, port:) +} + +/// Name of database to use. +pub fn database(config: Config, database: String) -> Config { + Config(..config, database:) +} + +/// Username to connect to database as. +pub fn user(config: Config, user: String) -> Config { + Config(..config, user:) +} + +/// Password for the user. +pub fn password(config: Config, password: Option(String)) -> Config { + Config(..config, password:) +} + +/// Whether to use SSL or not. +/// +/// (default: False) +pub fn ssl(config: Config, ssl: Bool) -> Config { + Config(..config, ssl:) +} + +/// Any Postgres connection parameter here, such as +/// `"application_name: myappname"` and `"timezone: GMT"` +pub fn connection_parameter( + config: Config, + name name: String, + value value: String, +) -> Config { + Config( + ..config, + connection_parameters: [#(name, value), ..config.connection_parameters], + ) +} + +/// Number of connections to keep open with the database +/// +/// default: 10 +pub fn pool_size(config: Config, pool_size: Int) -> Config { + Config(..config, pool_size:) +} + +/// Checking out connections is handled through a queue. If it +/// takes longer than queue_target to get out of the queue for longer than +/// queue_interval then the queue_target will be doubled and checkouts will +/// start to be dropped if that target is surpassed. +/// +/// default: 50 +pub fn queue_target(config: Config, queue_target: Int) -> Config { + Config(..config, queue_target:) +} + +/// Checking out connections is handled through a queue. If it +/// takes longer than queue_target to get out of the queue for longer than +/// queue_interval then the queue_target will be doubled and checkouts will +/// start to be dropped if that target is surpassed. +/// +/// default: 1000 +pub fn queue_interval(config: Config, queue_interval: Int) -> Config { + Config(..config, queue_interval:) +} + +/// The database is pinged every idle_interval when the connection is idle. +/// +/// default: 1000 +pub fn idle_interval(config: Config, idle_interval: Int) -> Config { + Config(..config, idle_interval:) +} + +/// Trace pgo is instrumented with [OpenTelemetry][1] and +/// when this option is true a span will be created (if sampled). +/// +/// default: False +/// +/// [1]: https://opentelemetry.io +pub fn trace(config: Config, trace: Bool) -> Config { + Config(..config, trace:) +} + +/// Which internet protocol to use for this connection +pub fn ip_version(config: Config, ip_version: IpVersion) -> Config { + Config(..config, ip_version:) +} + +/// By default, PGO will return a n-tuple, in the order of the query. +/// By setting `rows_as_map` to `True`, the result will be `Dict`. +pub fn rows_as_map(config: Config, rows_as_map: Bool) -> Config { + Config(..config, rows_as_map:) +} + /// The internet protocol version to use. pub type IpVersion { /// Internet Protocol version 4 (IPv4) @@ -73,7 +181,7 @@ pub fn default_config() -> Config { password: None, ssl: False, connection_parameters: [], - pool_size: 1, + pool_size: 10, queue_target: 50, queue_interval: 1000, idle_interval: 1000, @@ -136,45 +244,54 @@ pub type Connection /// PostgreSQL instance specified in the config. If the configuration is invalid /// or it cannot connect for another reason it will continue to attempt to /// connect, and any queries made using the connection pool will fail. -@external(erlang, "gleam_pgo_ffi", "connect") +@external(erlang, "pog_ffi", "connect") pub fn connect(a: Config) -> Connection /// Shut down a connection pool. -@external(erlang, "gleam_pgo_ffi", "disconnect") +@external(erlang, "pog_ffi", "disconnect") pub fn disconnect(a: Connection) -> Nil /// A value that can be sent to PostgreSQL as one of the arguments to a /// parameterised SQL query. pub type Value -@external(erlang, "gleam_pgo_ffi", "null") +@external(erlang, "pog_ffi", "null") pub fn null() -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn bool(a: Bool) -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn int(a: Int) -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn float(a: Float) -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn text(a: String) -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn bytea(a: BitArray) -> Value -@external(erlang, "gleam_pgo_ffi", "coerce") +@external(erlang, "pog_ffi", "coerce") pub fn array(a: List(a)) -> Value -/// Coerce a timestamp represented as `#(#(year, month, day), #(hour, minute, second))` into a `Value`. -@external(erlang, "gleam_pgo_ffi", "coerce") -pub fn timestamp(a: #(#(Int, Int, Int), #(Int, Int, Int))) -> Value +pub fn timestamp(timestamp: Timestamp) -> Value { + coerce_value(#(date(timestamp.date), time(timestamp.time))) +} + +pub fn date(date: Date) -> Value { + coerce_value(#(date.year, date.month, date.day)) +} + +pub fn time(time: Time) -> Value { + let seconds = int.to_float(time.seconds) + let seconds = seconds +. int.to_float(time.microseconds) /. 1_000_000.0 + coerce_value(#(time.hours, time.minutes, seconds)) +} -/// Coerce a date represented as `#(year, month, day)` into a `Value`. -@external(erlang, "gleam_pgo_ffi", "coerce") -pub fn date(a: #(Int, Int, Int)) -> Value +@external(erlang, "pog_ffi", "coerce") +fn coerce_value(a: anything) -> Value pub type TransactionError { TransactionQueryError(QueryError) @@ -187,7 +304,7 @@ pub type TransactionError { /// /// If the function returns an `Error` or panics then the transaction is rolled /// back. -@external(erlang, "gleam_pgo_ffi", "transaction") +@external(erlang, "pog_ffi", "transaction") pub fn transaction( pool: Connection, callback: fn(Connection) -> Result(t, String), @@ -205,7 +322,7 @@ pub type Returned(t) { Returned(count: Int, rows: List(t)) } -@external(erlang, "gleam_pgo_ffi", "query") +@external(erlang, "pog_ffi", "query") fn run_query( a: Connection, b: String, @@ -232,6 +349,34 @@ pub type QueryError { ConnectionUnavailable } +pub opaque type Query(row_type) { + Query(sql: String, parameters: List(Value), row_decoder: Decoder(row_type)) +} + +/// Create a new query to use with the `execute`, `returning`, and `parameter` +/// functions. +/// +pub fn query(sql: String) -> Query(Nil) { + Query(sql:, parameters: [], row_decoder: fn(_) { Ok(Nil) }) +} + +/// Set the decoder to use for the type of row returned by executing this +/// query. +/// +/// If the decoder is unable to decode the row value then the query will return +/// an error from the `exec` function, but the query will still have been run +/// against the database. +/// +pub fn returning(query: Query(t1), decoder: Decoder(t2)) -> Query(t2) { + let Query(sql:, parameters:, row_decoder: _) = query + Query(sql:, parameters:, row_decoder: decoder) +} + +/// Push a new query parameter value for the query. +pub fn parameter(query: Query(t1), parameter: Value) -> Query(t1) { + Query(..query, parameters: [parameter, ..query.parameters]) +} + /// Run a query against a PostgreSQL database. /// /// The provided dynamic decoder is used to decode the rows returned by @@ -239,14 +384,13 @@ pub type QueryError { /// use the `dynamic.dynamic` decoder. /// pub fn execute( - query sql: String, + query query: Query(t), on pool: Connection, - with arguments: List(Value), - expecting decoder: Decoder(t), ) -> Result(Returned(t), QueryError) { - use #(count, rows) <- result.then(run_query(pool, sql, arguments)) + let parameters = list.reverse(query.parameters) + use #(count, rows) <- result.then(run_query(pool, query.sql, parameters)) use rows <- result.then( - list.try_map(over: rows, with: decoder) + list.try_map(over: rows, with: query.row_decoder) |> result.map_error(UnexpectedResultType), ) Ok(Returned(count, rows)) @@ -525,18 +669,57 @@ pub fn error_code_name(error_code: String) -> Result(String, Nil) { } } -/// Checks to see if the value is formatted as `#(#(Int, Int, Int), #(Int, Int, Int))` -/// to represent `#(#(year, month, day), #(hour, minute, second))`, and returns the -/// value if it is. -pub fn decode_timestamp(value: dynamic.Dynamic) { - dynamic.tuple2( - dynamic.tuple3(dynamic.int, dynamic.int, dynamic.int), - dynamic.tuple3(dynamic.int, dynamic.int, dynamic.int), +pub fn decode_timestamp( + value: dynamic.Dynamic, +) -> Result(Timestamp, DecodeErrors) { + dynamic.decode2( + Timestamp, + dynamic.element(0, decode_date), + dynamic.element(1, decode_time), )(value) } -/// Checks to see if the value is formatted as `#(Int, Int, Int)` to represent a date -/// as `#(year, month, day)`, and returns the value if it is. -pub fn decode_date(value: dynamic.Dynamic) { - dynamic.tuple3(dynamic.int, dynamic.int, dynamic.int)(value) +pub fn decode_date(value: dynamic.Dynamic) -> Result(Date, DecodeErrors) { + dynamic.decode3( + Date, + dynamic.element(0, dynamic.int), + dynamic.element(1, dynamic.int), + dynamic.element(2, dynamic.int), + )(value) +} + +pub fn decode_time(value: dynamic.Dynamic) -> Result(Time, DecodeErrors) { + case dynamic.tuple3(dynamic.int, dynamic.int, decode_seconds)(value) { + Error(e) -> Error(e) + Ok(#(hours, minutes, #(seconds, microseconds))) -> + Ok(Time(hours:, minutes:, seconds:, microseconds:)) + } +} + +fn decode_seconds(value: dynamic.Dynamic) -> Result(#(Int, Int), DecodeErrors) { + case dynamic.int(value) { + Ok(i) -> Ok(#(i, 0)) + Error(_) -> + case dynamic.float(value) { + Error(e) -> Error(e) + Ok(i) -> { + let floored = float.floor(i) + let seconds = float.round(floored) + let microseconds = float.round({ i -. floored } *. 1_000_000.0) + Ok(#(seconds, microseconds)) + } + } + } +} + +pub type Date { + Date(year: Int, month: Int, day: Int) +} + +pub type Time { + Time(hours: Int, minutes: Int, seconds: Int, microseconds: Int) +} + +pub type Timestamp { + Timestamp(date: Date, time: Time) } diff --git a/src/gleam_pgo_ffi.erl b/src/pog_ffi.erl similarity index 87% rename from src/gleam_pgo_ffi.erl rename to src/pog_ffi.erl index f26d715..a302ebb 100644 --- a/src/gleam_pgo_ffi.erl +++ b/src/pog_ffi.erl @@ -1,10 +1,10 @@ --module(gleam_pgo_ffi). +-module(pog_ffi). -export([query/3, connect/1, disconnect/1, coerce/1, null/0, transaction/2]). --record(pgo_pool, {name, pid}). +-record(pog_pool, {name, pid}). --include_lib("gleam_pgo/include/gleam@pgo_Config.hrl"). +-include_lib("pog/include/pog_Config.hrl"). -include_lib("pg_types/include/pg_types.hrl"). null() -> @@ -39,7 +39,7 @@ default_ssl_options(Host, Ssl) -> connect(Config) -> Id = integer_to_list(erlang:unique_integer([positive])), - PoolName = list_to_atom("gleam_pgo_pool_" ++ Id), + PoolName = list_to_atom("pog_pool_" ++ Id), #config{ host = Host, port = Port, @@ -81,28 +81,28 @@ connect(Config) -> none -> Options1 end, {ok, Pid} = pgo_pool:start_link(PoolName, Options2), - #pgo_pool{name = PoolName, pid = Pid}. + #pog_pool{name = PoolName, pid = Pid}. -disconnect(#pgo_pool{pid = Pid}) -> +disconnect(#pog_pool{pid = Pid}) -> erlang:exit(Pid, normal), nil. -transaction(#pgo_pool{name = Name} = Conn, Callback) -> +transaction(#pog_pool{name = Name} = Conn, Callback) -> F = fun() -> case Callback(Conn) of {ok, T} -> {ok, T}; - {error, Reason} -> error({gleam_pgo_rollback_transaction, Reason}) + {error, Reason} -> error({pog_rollback_transaction, Reason}) end end, try pgo:transaction(Name, F, #{}) catch - error:{gleam_pgo_rollback_transaction, Reason} -> + error:{pog_rollback_transaction, Reason} -> {error, {transaction_rolled_back, Reason}} end. -query(#pgo_pool{name = Name}, Sql, Arguments) -> +query(#pog_pool{name = Name}, Sql, Arguments) -> case pgo:query(Sql, Arguments, #{pool => Name}) of #{rows := Rows, num_rows := NumRows} -> {ok, {NumRows, Rows}}; @@ -122,7 +122,7 @@ convert_error({pgsql_error, #{ }}) -> {constraint_violated, Message, Constraint, Detail}; convert_error({pgsql_error, #{code := Code, message := Message}}) -> - Constant = case gleam@pgo:error_code_name(Code) of + Constant = case pog:error_code_name(Code) of {ok, X} -> X; {error, nil} -> <<"unknown">> end, diff --git a/test/gleam/pgo_test.gleam b/test/gleam/pgo_test.gleam deleted file mode 100644 index e9835d8..0000000 --- a/test/gleam/pgo_test.gleam +++ /dev/null @@ -1,528 +0,0 @@ -import exception -import gleam/dynamic.{type Decoder} -import gleam/option.{None, Some} -import gleam/pgo -import gleam/string -import gleeunit/should - -pub fn url_config_everything_test() { - pgo.url_config("postgres://u:p@db.test:1234/my_db") - |> should.equal(Ok( - pgo.Config( - ..pgo.default_config(), - host: "db.test", - port: 1234, - database: "my_db", - user: "u", - password: Some("p"), - ), - )) -} - -pub fn url_config_alternative_postgres_protocol_test() { - pgo.url_config("postgresql://u:p@db.test:1234/my_db") - |> should.equal(Ok( - pgo.Config( - ..pgo.default_config(), - host: "db.test", - port: 1234, - database: "my_db", - user: "u", - password: Some("p"), - ), - )) -} - -pub fn url_config_not_postgres_protocol_test() { - pgo.url_config("foo://u:p@db.test:1234/my_db") - |> should.equal(Error(Nil)) -} - -pub fn url_config_no_password_test() { - pgo.url_config("postgres://u@db.test:1234/my_db") - |> should.equal(Ok( - pgo.Config( - ..pgo.default_config(), - host: "db.test", - port: 1234, - database: "my_db", - user: "u", - password: None, - ), - )) -} - -pub fn url_config_path_slash_test() { - pgo.url_config("postgres://u:p@db.test:1234/my_db/foo") - |> should.equal(Error(Nil)) -} - -fn start_default() { - pgo.Config( - ..pgo.default_config(), - database: "gleam_pgo_test", - password: Some("postgres"), - pool_size: 1, - ) - |> pgo.connect -} - -fn default_config() { - pgo.Config( - ..pgo.default_config(), - database: "gleam_pgo_test", - password: Some("postgres"), - pool_size: 1, - ) -} - -pub fn inserting_new_rows_test() { - let db = start_default() - let sql = - " - INSERT INTO - cats - VALUES - (DEFAULT, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), - (DEFAULT, 'felix', false, ARRAY ['grey'], now(), '2020-03-05')" - let assert Ok(returned) = pgo.execute(sql, db, [], dynamic.dynamic) - - returned.count - |> should.equal(2) - returned.rows - |> should.equal([]) - - pgo.disconnect(db) -} - -pub fn inserting_new_rows_and_returning_test() { - let db = start_default() - let sql = - " - INSERT INTO - cats - VALUES - (DEFAULT, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), - (DEFAULT, 'felix', false, ARRAY ['grey'], now(), '2020-03-05') - RETURNING - name" - let assert Ok(returned) = - pgo.execute(sql, db, [], dynamic.element(0, dynamic.string)) - - returned.count - |> should.equal(2) - returned.rows - |> should.equal(["bill", "felix"]) - - pgo.disconnect(db) -} - -pub fn selecting_rows_test() { - let db = start_default() - let sql = - " - INSERT INTO - cats - VALUES - (DEFAULT, 'neo', true, ARRAY ['black'], '2022-10-10 11:30:30', '2020-03-04') - RETURNING - id" - - let assert Ok(pgo.Returned(rows: [id], ..)) = - pgo.execute(sql, db, [], dynamic.element(0, dynamic.int)) - - let assert Ok(returned) = - pgo.execute( - "SELECT * FROM cats WHERE id = $1", - db, - [pgo.int(id)], - dynamic.tuple6( - dynamic.int, - dynamic.string, - dynamic.bool, - dynamic.list(dynamic.string), - pgo.decode_timestamp, - pgo.decode_date, - ), - ) - - returned.count - |> should.equal(1) - returned.rows - |> should.equal([ - #(id, "neo", True, ["black"], #(#(2022, 10, 10), #(11, 30, 30)), #( - 2020, - 3, - 4, - )), - ]) - - pgo.disconnect(db) -} - -pub fn invalid_sql_test() { - let db = start_default() - let sql = "select select" - - let assert Error(pgo.PostgresqlError(code, name, message)) = - pgo.execute(sql, db, [], dynamic.dynamic) - - code - |> should.equal("42601") - name - |> should.equal("syntax_error") - message - |> should.equal("syntax error at or near \"select\"") - - pgo.disconnect(db) -} - -pub fn insert_constraint_error_test() { - let db = start_default() - let sql = - " - INSERT INTO - cats - VALUES - (900, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), - (900, 'felix', false, ARRAY ['black'], now(), '2020-03-05')" - - let assert Error(pgo.ConstraintViolated(message, constraint, detail)) = - pgo.execute(sql, db, [], dynamic.dynamic) - - constraint - |> should.equal("cats_pkey") - - detail - |> should.equal("Key (id)=(900) already exists.") - - message - |> should.equal( - "duplicate key value violates unique constraint \"cats_pkey\"", - ) - - pgo.disconnect(db) -} - -pub fn select_from_unknown_table_test() { - let db = start_default() - let sql = "SELECT * FROM unknown" - - let assert Error(pgo.PostgresqlError(code, name, message)) = - pgo.execute(on: db, query: sql, with: [], expecting: dynamic.dynamic) - - code - |> should.equal("42P01") - name - |> should.equal("undefined_table") - message - |> should.equal("relation \"unknown\" does not exist") - - pgo.disconnect(db) -} - -pub fn insert_with_incorrect_type_test() { - let db = start_default() - let sql = - " - INSERT INTO - cats - VALUES - (true, true, true, true)" - let assert Error(pgo.PostgresqlError(code, name, message)) = - pgo.execute(sql, db, [], dynamic.dynamic) - - code - |> should.equal("42804") - name - |> should.equal("datatype_mismatch") - message - |> should.equal( - "column \"id\" is of type integer but expression is of type boolean", - ) - - pgo.disconnect(db) -} - -pub fn execute_with_wrong_number_of_arguments_test() { - let db = start_default() - let sql = "SELECT * FROM cats WHERE id = $1" - - pgo.execute(sql, db, [], dynamic.dynamic) - |> should.equal(Error(pgo.UnexpectedArgumentCount(expected: 1, got: 0))) - - pgo.disconnect(db) -} - -fn assert_roundtrip( - db: pgo.Connection, - value: a, - type_name: String, - encoder: fn(a) -> pgo.Value, - decoder: Decoder(a), -) -> pgo.Connection { - pgo.execute( - string.append("select $1::", type_name), - db, - [encoder(value)], - dynamic.element(0, decoder), - ) - |> should.equal(Ok(pgo.Returned(count: 1, rows: [value]))) - db -} - -pub fn null_test() { - let db = start_default() - pgo.execute( - "select $1", - db, - [pgo.null()], - dynamic.element(0, dynamic.optional(dynamic.int)), - ) - |> should.equal(Ok(pgo.Returned(count: 1, rows: [None]))) - - pgo.disconnect(db) -} - -pub fn bool_test() { - start_default() - |> assert_roundtrip(True, "bool", pgo.bool, dynamic.bool) - |> assert_roundtrip(False, "bool", pgo.bool, dynamic.bool) - |> pgo.disconnect -} - -pub fn int_test() { - start_default() - |> assert_roundtrip(0, "int", pgo.int, dynamic.int) - |> assert_roundtrip(1, "int", pgo.int, dynamic.int) - |> assert_roundtrip(2, "int", pgo.int, dynamic.int) - |> assert_roundtrip(3, "int", pgo.int, dynamic.int) - |> assert_roundtrip(4, "int", pgo.int, dynamic.int) - |> assert_roundtrip(5, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-0, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-1, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-2, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-3, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-4, "int", pgo.int, dynamic.int) - |> assert_roundtrip(-5, "int", pgo.int, dynamic.int) - |> assert_roundtrip(10_000_000, "int", pgo.int, dynamic.int) - |> pgo.disconnect -} - -pub fn float_test() { - start_default() - |> assert_roundtrip(0.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(1.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(2.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(3.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(4.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(5.123, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-0.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-1.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-2.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-3.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-4.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(-5.654, "float", pgo.float, dynamic.float) - |> assert_roundtrip(10_000_000.0, "float", pgo.float, dynamic.float) - |> pgo.disconnect -} - -pub fn text_test() { - start_default() - |> assert_roundtrip("", "text", pgo.text, dynamic.string) - |> assert_roundtrip("✨", "text", pgo.text, dynamic.string) - |> assert_roundtrip("Hello, Joe!", "text", pgo.text, dynamic.string) - |> pgo.disconnect -} - -pub fn bytea_test() { - start_default() - |> assert_roundtrip(<<"":utf8>>, "bytea", pgo.bytea, dynamic.bit_array) - |> assert_roundtrip(<<"✨":utf8>>, "bytea", pgo.bytea, dynamic.bit_array) - |> assert_roundtrip( - <<"Hello, Joe!":utf8>>, - "bytea", - pgo.bytea, - dynamic.bit_array, - ) - |> assert_roundtrip(<<1>>, "bytea", pgo.bytea, dynamic.bit_array) - |> assert_roundtrip(<<1, 2, 3>>, "bytea", pgo.bytea, dynamic.bit_array) - |> pgo.disconnect -} - -pub fn array_test() { - let decoder = dynamic.list(dynamic.string) - start_default() - |> assert_roundtrip(["black"], "text[]", pgo.array, decoder) - |> assert_roundtrip(["gray"], "text[]", pgo.array, decoder) - |> assert_roundtrip(["gray", "black"], "text[]", pgo.array, decoder) - |> pgo.disconnect -} - -pub fn datetime_test() { - start_default() - |> assert_roundtrip( - #(#(2022, 10, 12), #(11, 30, 33)), - "timestamp", - pgo.timestamp, - pgo.decode_timestamp, - ) - |> pgo.disconnect -} - -pub fn date_test() { - start_default() - |> assert_roundtrip(#(2022, 10, 11), "date", pgo.date, pgo.decode_date) - |> pgo.disconnect -} - -pub fn nullable_test() { - start_default() - |> assert_roundtrip( - Some("Hello, Joe"), - "text", - pgo.nullable(pgo.text, _), - dynamic.optional(dynamic.string), - ) - |> assert_roundtrip( - None, - "text", - pgo.nullable(pgo.text, _), - dynamic.optional(dynamic.string), - ) - |> assert_roundtrip( - Some(123), - "int", - pgo.nullable(pgo.int, _), - dynamic.optional(dynamic.int), - ) - |> assert_roundtrip( - None, - "int", - pgo.nullable(pgo.int, _), - dynamic.optional(dynamic.int), - ) - |> pgo.disconnect -} - -pub fn expected_argument_type_test() { - let db = start_default() - pgo.execute("select $1::int", db, [pgo.float(1.2)], dynamic.int) - |> should.equal(Error(pgo.UnexpectedArgumentType("int4", "1.2"))) - - pgo.disconnect(db) -} - -pub fn expected_return_type_test() { - let db = start_default() - pgo.execute("select 1", db, [], dynamic.element(0, dynamic.string)) - |> should.equal( - Error( - pgo.UnexpectedResultType([ - dynamic.DecodeError(expected: "String", found: "Int", path: ["0"]), - ]), - ), - ) - - pgo.disconnect(db) -} - -pub fn expected_maps_test() { - let db = pgo.Config(..default_config(), rows_as_map: True) |> pgo.connect - - let sql = - " - INSERT INTO - cats - VALUES - (DEFAULT, 'neo', true, ARRAY ['black'], '2022-10-10 11:30:30', '2020-03-04') - RETURNING - id" - - let assert Ok(pgo.Returned(rows: [id], ..)) = - pgo.execute(sql, db, [], dynamic.field("id", dynamic.int)) - - let assert Ok(returned) = - pgo.execute( - "SELECT * FROM cats WHERE id = $1", - db, - [pgo.int(id)], - dynamic.decode6( - fn(id, name, is_cute, colors, last_petted_at, birthday) { - #(id, name, is_cute, colors, last_petted_at, birthday) - }, - dynamic.field("id", dynamic.int), - dynamic.field("name", dynamic.string), - dynamic.field("is_cute", dynamic.bool), - dynamic.field("colors", dynamic.list(dynamic.string)), - dynamic.field("last_petted_at", pgo.decode_timestamp), - dynamic.field("birthday", pgo.decode_date), - ), - ) - - returned.count - |> should.equal(1) - returned.rows - |> should.equal([ - #(id, "neo", True, ["black"], #(#(2022, 10, 10), #(11, 30, 30)), #( - 2020, - 3, - 4, - )), - ]) - - pgo.disconnect(db) -} - -pub fn transaction_commit_test() { - let db = start_default() - let id_decoder = dynamic.element(0, dynamic.int) - let assert Ok(_) = pgo.execute("truncate table cats", db, [], Ok) - - let insert = fn(db, name) { - let sql = " - INSERT INTO - cats - VALUES - (DEFAULT, '" <> name <> "', true, ARRAY ['black'], now(), '2020-03-04') - RETURNING id" - let assert Ok(pgo.Returned(rows: [id], ..)) = - pgo.execute(sql, db, [], id_decoder) - id - } - - // A succeeding transaction - let assert Ok(#(id1, id2)) = - pgo.transaction(db, fn(db) { - let id1 = insert(db, "one") - let id2 = insert(db, "two") - Ok(#(id1, id2)) - }) - - // An error returning transaction, it gets rolled back - let assert Error(pgo.TransactionRolledBack("Nah bruv!")) = - pgo.transaction(db, fn(db) { - let _id1 = insert(db, "two") - let _id2 = insert(db, "three") - Error("Nah bruv!") - }) - - // A crashing transaction, it gets rolled back - let _ = - exception.rescue(fn() { - pgo.transaction(db, fn(db) { - let _id1 = insert(db, "four") - let _id2 = insert(db, "five") - panic as "testing rollbacks" - }) - }) - - let assert Ok(returned) = - pgo.execute("select id from cats order by id", db, [], id_decoder) - - let assert [got1, got2] = returned.rows - let assert True = id1 == got1 - let assert True = id2 == got2 - - pgo.disconnect(db) -} diff --git a/test/gleam_pgo_test.gleam b/test/gleam_pgo_test.gleam deleted file mode 100644 index ecd12ad..0000000 --- a/test/gleam_pgo_test.gleam +++ /dev/null @@ -1,5 +0,0 @@ -import gleeunit - -pub fn main() { - gleeunit.main() -} diff --git a/test/pog_test.gleam b/test/pog_test.gleam new file mode 100644 index 0000000..1c3f97f --- /dev/null +++ b/test/pog_test.gleam @@ -0,0 +1,543 @@ +import exception +import gleam/dynamic.{type Decoder} +import gleam/option.{None, Some} +import gleeunit +import gleeunit/should +import pog + +pub fn main() { + gleeunit.main() +} + +pub fn url_config_everything_test() { + let expected = + pog.default_config() + |> pog.host("db.test") + |> pog.port(1234) + |> pog.database("my_db") + |> pog.user("u") + |> pog.password(Some("p")) + + pog.url_config("postgres://u:p@db.test:1234/my_db") + |> should.equal(Ok(expected)) +} + +pub fn url_config_alternative_postgres_protocol_test() { + let expected = + pog.default_config() + |> pog.host("db.test") + |> pog.port(1234) + |> pog.database("my_db") + |> pog.user("u") + |> pog.password(Some("p")) + pog.url_config("postgresql://u:p@db.test:1234/my_db") + |> should.equal(Ok(expected)) +} + +pub fn url_config_not_postgres_protocol_test() { + pog.url_config("foo://u:p@db.test:1234/my_db") + |> should.equal(Error(Nil)) +} + +pub fn url_config_no_password_test() { + let expected = + pog.default_config() + |> pog.host("db.test") + |> pog.port(1234) + |> pog.database("my_db") + |> pog.user("u") + |> pog.password(None) + pog.url_config("postgres://u@db.test:1234/my_db") + |> should.equal(Ok(expected)) +} + +pub fn url_config_path_slash_test() { + pog.url_config("postgres://u:p@db.test:1234/my_db/foo") + |> should.equal(Error(Nil)) +} + +fn start_default() { + pog.Config( + ..pog.default_config(), + database: "gleam_pog_test", + password: Some("postgres"), + pool_size: 1, + ) + |> pog.connect +} + +fn default_config() { + pog.Config( + ..pog.default_config(), + database: "gleam_pog_test", + password: Some("postgres"), + pool_size: 1, + ) +} + +pub fn inserting_new_rows_test() { + let db = start_default() + let sql = + " + INSERT INTO + cats + VALUES + (DEFAULT, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), + (DEFAULT, 'felix', false, ARRAY ['grey'], now(), '2020-03-05')" + let assert Ok(returned) = pog.query(sql) |> pog.execute(db) + + returned.count + |> should.equal(2) + returned.rows + |> should.equal([]) + + pog.disconnect(db) +} + +pub fn inserting_new_rows_and_returning_test() { + let db = start_default() + let sql = + " + INSERT INTO + cats + VALUES + (DEFAULT, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), + (DEFAULT, 'felix', false, ARRAY ['grey'], now(), '2020-03-05') + RETURNING + name" + let assert Ok(returned) = + pog.query(sql) + |> pog.returning(dynamic.element(0, dynamic.string)) + |> pog.execute(db) + + returned.count + |> should.equal(2) + returned.rows + |> should.equal(["bill", "felix"]) + + pog.disconnect(db) +} + +pub fn selecting_rows_test() { + let db = start_default() + let sql = + " + INSERT INTO + cats + VALUES + (DEFAULT, 'neo', true, ARRAY ['black'], '2022-10-10 11:30:30.1', '2020-03-04') + RETURNING + id" + + let assert Ok(pog.Returned(rows: [id], ..)) = + pog.query(sql) + |> pog.returning(dynamic.element(0, dynamic.int)) + |> pog.execute(db) + + let assert Ok(returned) = + pog.query("SELECT * FROM cats WHERE id = $1") + |> pog.parameter(pog.int(id)) + |> pog.returning(dynamic.tuple6( + dynamic.int, + dynamic.string, + dynamic.bool, + dynamic.list(dynamic.string), + pog.decode_timestamp, + pog.decode_date, + )) + |> pog.execute(db) + + returned.count + |> should.equal(1) + returned.rows + |> should.equal([ + #( + id, + "neo", + True, + ["black"], + pog.Timestamp(pog.Date(2022, 10, 10), pog.Time(11, 30, 30, 100_000)), + pog.Date(2020, 3, 4), + ), + ]) + + pog.disconnect(db) +} + +pub fn invalid_sql_test() { + let db = start_default() + let sql = "select select" + + let assert Error(pog.PostgresqlError(code, name, message)) = + pog.query(sql) |> pog.execute(db) + + code + |> should.equal("42601") + name + |> should.equal("syntax_error") + message + |> should.equal("syntax error at or near \"select\"") + + pog.disconnect(db) +} + +pub fn insert_constraint_error_test() { + let db = start_default() + let sql = + " + INSERT INTO + cats + VALUES + (900, 'bill', true, ARRAY ['black'], now(), '2020-03-04'), + (900, 'felix', false, ARRAY ['black'], now(), '2020-03-05')" + + let assert Error(pog.ConstraintViolated(message, constraint, detail)) = + pog.query(sql) |> pog.execute(db) + + constraint + |> should.equal("cats_pkey") + + detail + |> should.equal("Key (id)=(900) already exists.") + + message + |> should.equal( + "duplicate key value violates unique constraint \"cats_pkey\"", + ) + + pog.disconnect(db) +} + +pub fn select_from_unknown_table_test() { + let db = start_default() + let sql = "SELECT * FROM unknown" + + let assert Error(pog.PostgresqlError(code, name, message)) = + pog.query(sql) |> pog.execute(db) + + code + |> should.equal("42P01") + name + |> should.equal("undefined_table") + message + |> should.equal("relation \"unknown\" does not exist") + + pog.disconnect(db) +} + +pub fn insert_with_incorrect_type_test() { + let db = start_default() + let sql = + " + INSERT INTO + cats + VALUES + (true, true, true, true)" + let assert Error(pog.PostgresqlError(code, name, message)) = + pog.query(sql) |> pog.execute(db) + + code + |> should.equal("42804") + name + |> should.equal("datatype_mismatch") + message + |> should.equal( + "column \"id\" is of type integer but expression is of type boolean", + ) + + pog.disconnect(db) +} + +pub fn execute_with_wrong_number_of_arguments_test() { + let db = start_default() + let sql = "SELECT * FROM cats WHERE id = $1" + + pog.query(sql) + |> pog.returning(dynamic.dynamic) + |> pog.execute(db) + |> should.equal(Error(pog.UnexpectedArgumentCount(expected: 1, got: 0))) + + pog.disconnect(db) +} + +fn assert_roundtrip( + db: pog.Connection, + value: a, + type_name: String, + encoder: fn(a) -> pog.Value, + decoder: Decoder(a), +) -> pog.Connection { + pog.query("select $1::" <> type_name) + |> pog.parameter(encoder(value)) + |> pog.returning(dynamic.element(0, decoder)) + |> pog.execute(db) + |> should.equal(Ok(pog.Returned(count: 1, rows: [value]))) + db +} + +pub fn null_test() { + let db = start_default() + pog.query("select $1") + |> pog.parameter(pog.null()) + |> pog.returning(dynamic.element(0, dynamic.optional(dynamic.int))) + |> pog.execute(db) + |> should.equal(Ok(pog.Returned(count: 1, rows: [None]))) + + pog.disconnect(db) +} + +pub fn bool_test() { + start_default() + |> assert_roundtrip(True, "bool", pog.bool, dynamic.bool) + |> assert_roundtrip(False, "bool", pog.bool, dynamic.bool) + |> pog.disconnect +} + +pub fn int_test() { + start_default() + |> assert_roundtrip(0, "int", pog.int, dynamic.int) + |> assert_roundtrip(1, "int", pog.int, dynamic.int) + |> assert_roundtrip(2, "int", pog.int, dynamic.int) + |> assert_roundtrip(3, "int", pog.int, dynamic.int) + |> assert_roundtrip(4, "int", pog.int, dynamic.int) + |> assert_roundtrip(5, "int", pog.int, dynamic.int) + |> assert_roundtrip(-0, "int", pog.int, dynamic.int) + |> assert_roundtrip(-1, "int", pog.int, dynamic.int) + |> assert_roundtrip(-2, "int", pog.int, dynamic.int) + |> assert_roundtrip(-3, "int", pog.int, dynamic.int) + |> assert_roundtrip(-4, "int", pog.int, dynamic.int) + |> assert_roundtrip(-5, "int", pog.int, dynamic.int) + |> assert_roundtrip(10_000_000, "int", pog.int, dynamic.int) + |> pog.disconnect +} + +pub fn float_test() { + start_default() + |> assert_roundtrip(0.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(1.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(2.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(3.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(4.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(5.123, "float", pog.float, dynamic.float) + |> assert_roundtrip(-0.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(-1.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(-2.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(-3.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(-4.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(-5.654, "float", pog.float, dynamic.float) + |> assert_roundtrip(10_000_000.0, "float", pog.float, dynamic.float) + |> pog.disconnect +} + +pub fn text_test() { + start_default() + |> assert_roundtrip("", "text", pog.text, dynamic.string) + |> assert_roundtrip("✨", "text", pog.text, dynamic.string) + |> assert_roundtrip("Hello, Joe!", "text", pog.text, dynamic.string) + |> pog.disconnect +} + +pub fn bytea_test() { + start_default() + |> assert_roundtrip(<<"":utf8>>, "bytea", pog.bytea, dynamic.bit_array) + |> assert_roundtrip(<<"✨":utf8>>, "bytea", pog.bytea, dynamic.bit_array) + |> assert_roundtrip( + <<"Hello, Joe!":utf8>>, + "bytea", + pog.bytea, + dynamic.bit_array, + ) + |> assert_roundtrip(<<1>>, "bytea", pog.bytea, dynamic.bit_array) + |> assert_roundtrip(<<1, 2, 3>>, "bytea", pog.bytea, dynamic.bit_array) + |> pog.disconnect +} + +pub fn array_test() { + let decoder = dynamic.list(dynamic.string) + start_default() + |> assert_roundtrip(["black"], "text[]", pog.array, decoder) + |> assert_roundtrip(["gray"], "text[]", pog.array, decoder) + |> assert_roundtrip(["gray", "black"], "text[]", pog.array, decoder) + |> pog.disconnect +} + +pub fn datetime_test() { + start_default() + |> assert_roundtrip( + pog.Timestamp(pog.Date(2022, 10, 12), pog.Time(11, 30, 33, 101)), + "timestamp", + pog.timestamp, + pog.decode_timestamp, + ) + |> pog.disconnect +} + +pub fn date_test() { + start_default() + |> assert_roundtrip(pog.Date(2022, 10, 11), "date", pog.date, pog.decode_date) + |> pog.disconnect +} + +pub fn nullable_test() { + start_default() + |> assert_roundtrip( + Some("Hello, Joe"), + "text", + pog.nullable(pog.text, _), + dynamic.optional(dynamic.string), + ) + |> assert_roundtrip( + None, + "text", + pog.nullable(pog.text, _), + dynamic.optional(dynamic.string), + ) + |> assert_roundtrip( + Some(123), + "int", + pog.nullable(pog.int, _), + dynamic.optional(dynamic.int), + ) + |> assert_roundtrip( + None, + "int", + pog.nullable(pog.int, _), + dynamic.optional(dynamic.int), + ) + |> pog.disconnect +} + +pub fn expected_argument_type_test() { + let db = start_default() + + pog.query("select $1::int") + |> pog.returning(dynamic.element(0, dynamic.string)) + |> pog.parameter(pog.float(1.2)) + |> pog.execute(db) + |> should.equal(Error(pog.UnexpectedArgumentType("int4", "1.2"))) + + pog.disconnect(db) +} + +pub fn expected_return_type_test() { + let db = start_default() + pog.query("select 1") + |> pog.returning(dynamic.element(0, dynamic.string)) + |> pog.execute(db) + |> should.equal( + Error( + pog.UnexpectedResultType([ + dynamic.DecodeError(expected: "String", found: "Int", path: ["0"]), + ]), + ), + ) + + pog.disconnect(db) +} + +pub fn expected_maps_test() { + let db = pog.Config(..default_config(), rows_as_map: True) |> pog.connect + + let sql = + " + INSERT INTO + cats + VALUES + (DEFAULT, 'neo', true, ARRAY ['black'], '2022-10-10 11:30:30', '2020-03-04') + RETURNING + id" + + let assert Ok(pog.Returned(rows: [id], ..)) = + pog.query(sql) + |> pog.returning(dynamic.field("id", dynamic.int)) + |> pog.execute(db) + + let assert Ok(returned) = + pog.query("SELECT * FROM cats WHERE id = $1") + |> pog.parameter(pog.int(id)) + |> pog.returning(dynamic.decode6( + fn(id, name, is_cute, colors, last_petted_at, birthday) { + #(id, name, is_cute, colors, last_petted_at, birthday) + }, + dynamic.field("id", dynamic.int), + dynamic.field("name", dynamic.string), + dynamic.field("is_cute", dynamic.bool), + dynamic.field("colors", dynamic.list(dynamic.string)), + dynamic.field("last_petted_at", pog.decode_timestamp), + dynamic.field("birthday", pog.decode_date), + )) + |> pog.execute(db) + + returned.count + |> should.equal(1) + returned.rows + |> should.equal([ + #( + id, + "neo", + True, + ["black"], + pog.Timestamp(pog.Date(2022, 10, 10), pog.Time(11, 30, 30, 0)), + pog.Date(2020, 3, 4), + ), + ]) + + pog.disconnect(db) +} + +pub fn transaction_commit_test() { + let db = start_default() + let id_decoder = dynamic.element(0, dynamic.int) + let assert Ok(_) = pog.query("truncate table cats") |> pog.execute(db) + + let insert = fn(db, name) { + let sql = " + INSERT INTO + cats + VALUES + (DEFAULT, '" <> name <> "', true, ARRAY ['black'], now(), '2020-03-04') + RETURNING id" + let assert Ok(pog.Returned(rows: [id], ..)) = + pog.query(sql) + |> pog.returning(id_decoder) + |> pog.execute(db) + id + } + + // A succeeding transaction + let assert Ok(#(id1, id2)) = + pog.transaction(db, fn(db) { + let id1 = insert(db, "one") + let id2 = insert(db, "two") + Ok(#(id1, id2)) + }) + + // An error returning transaction, it gets rolled back + let assert Error(pog.TransactionRolledBack("Nah bruv!")) = + pog.transaction(db, fn(db) { + let _id1 = insert(db, "two") + let _id2 = insert(db, "three") + Error("Nah bruv!") + }) + + // A crashing transaction, it gets rolled back + let _ = + exception.rescue(fn() { + pog.transaction(db, fn(db) { + let _id1 = insert(db, "four") + let _id2 = insert(db, "five") + panic as "testing rollbacks" + }) + }) + + let assert Ok(returned) = + pog.query("select id from cats order by id") + |> pog.returning(id_decoder) + |> pog.execute(db) + + let assert [got1, got2] = returned.rows + let assert True = id1 == got1 + let assert True = id2 == got2 + + pog.disconnect(db) +} diff --git a/test/reset_db.sh b/test/reset_db.sh index 545845f..8899742 100755 --- a/test/reset_db.sh +++ b/test/reset_db.sh @@ -6,13 +6,13 @@ echo echo Resetting database psql <