From 2e6c430366c8d25fe4a86f348db93fe533fb0f77 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 17:37:28 +0100 Subject: [PATCH 01/21] Inline timeout --- gleam.toml | 3 ++- manifest.toml | 10 ++++++---- src/pog.gleam | 5 +++++ src/pog_ffi.erl | 13 +++++++------ test/pog_test.gleam | 9 --------- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/gleam.toml b/gleam.toml index 2ab8acd..58d5c59 100644 --- a/gleam.toml +++ b/gleam.toml @@ -18,11 +18,12 @@ pages = [ [dependencies] gleam_stdlib = ">= 0.51.0 and < 2.0.0" pgo = ">= 0.12.0 and < 2.0.0" +gleam_erlang = ">= 1.1.0 and < 2.0.0" +gleam_otp = ">= 1.0.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" exception = ">= 2.0.0 and < 3.0.0" -gleam_erlang = ">= 0.30.0 and < 1.0.0" [erlang] # Starting an SSL connection relies on ssl application to be started. diff --git a/manifest.toml b/manifest.toml index e04d732..24c31a9 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,9 +4,10 @@ packages = [ { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, - { name = "gleam_erlang", version = "0.33.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "A1D26B80F01901B59AABEE3475DD4C18D27D58FA5C897D922FCB9B099749C064" }, - { name = "gleam_stdlib", version = "0.51.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "14AFA8D3DDD7045203D422715DBB822D1725992A31DF35A08D97389014B74B68" }, - { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, + { name = "gleam_erlang", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "D7A2E71CE7F6B513E62F9A9EF6DFDE640D9607598C477FCCADEF751C45FD82E7" }, + { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, + { name = "gleam_stdlib", version = "0.61.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "3DC407D6EDA98FCE089150C11F3AD892B6F4C3CA77C87A97BAE8D5AB5E41F331" }, + { name = "gleeunit", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, { name = "opentelemetry_api", version = "1.4.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58" }, { name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" }, { name = "pgo", version = "0.14.0", build_tools = ["rebar3"], requirements = ["backoff", "opentelemetry_api", "pg_types"], otp_app = "pgo", source = "hex", outer_checksum = "71016C22599936E042DC0012EE4589D24C71427D266292F775EBF201D97DF9C9" }, @@ -14,7 +15,8 @@ packages = [ [requirements] exception = { version = ">= 2.0.0 and < 3.0.0" } -gleam_erlang = { version = ">= 0.30.0 and < 1.0.0" } +gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } pgo = { version = ">= 0.12.0 and < 2.0.0" } diff --git a/src/pog.gleam b/src/pog.gleam index abe011d..1c5c8b8 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -279,10 +279,15 @@ fn extract_user_password( } /// Expects `sslmode` to be `require`, `verify-ca`, `verify-full` or `disable`. +/// /// If `sslmode` is set, but not one of those value, fails. +/// /// If `sslmode` is `verify-ca` or `verify-full`, returns `SslVerified`. +/// /// If `sslmode` is `require`, returns `SslUnverified`. +/// /// If `sslmode` is unset, returns `SslDisabled`. +/// fn extract_ssl_mode(query: option.Option(String)) -> Result(Ssl, Nil) { case query { option.None -> Ok(SslDisabled) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index f012650..3033b49 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -73,6 +73,7 @@ connect(Config) -> idle_interval => IdleInterval, trace => Trace, decode_opts => [{return_rows_as_maps, RowsAsMap}], + pool_options => [{timeout, DefaultTimeout}], socket_options => case IpVersion of ipv4 -> []; ipv6 -> [inet6] @@ -83,7 +84,7 @@ connect(Config) -> none -> Options1 end, {ok, Pid} = pgo_pool:start_link(PoolName, Options2), - #pog_pool{name = PoolName, pid = Pid, default_timeout = DefaultTimeout}. + #pog_pool{name = PoolName, pid = Pid}. disconnect(#pog_pool{pid = Pid}) -> erlang:exit(Pid, normal), @@ -104,12 +105,12 @@ transaction(#pog_pool{name = Name} = Conn, Callback) -> end. -query(#pog_pool{name = Name, default_timeout = DefaultTimeout}, Sql, Arguments, Timeout) -> - Timeout1 = case Timeout of - none -> DefaultTimeout; - {some, QueryTimeout} -> QueryTimeout +query(#pog_pool{name = Name}, Sql, Arguments, Timeout) -> + PoolOptions = case Timeout of + none -> []; + {some, QueryTimeout} -> [{timeout, QueryTimeout}] end, - Options = #{pool => Name, pool_options => [{timeout, Timeout1}]}, + Options = #{pool => Name, pool_options => PoolOptions}, Res = pgo:query(Sql, Arguments, Options), case Res of #{rows := Rows, num_rows := NumRows} -> diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 4370848..4ab1549 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -1,6 +1,5 @@ import exception import gleam/dynamic/decode.{type Decoder} -import gleam/erlang/atom import gleam/option.{None, Some} import gleeunit import gleeunit/should @@ -10,11 +9,6 @@ pub fn main() { gleeunit.main() } -pub fn run_with_timeout(time: Int, next: fn() -> a) { - let assert Ok(timeout) = atom.from_string("timeout") - #(timeout, time, next) -} - pub fn url_config_everything_test() { let expected = pog.default_config() @@ -465,7 +459,6 @@ pub fn expected_return_type_test() { } pub fn expected_five_millis_timeout_test() { - use <- run_with_timeout(20) let db = start_default() pog.query("select sub.ret from (select pg_sleep(0.05), 'OK' as ret) as sub") @@ -478,7 +471,6 @@ pub fn expected_five_millis_timeout_test() { } pub fn expected_ten_millis_no_timeout_test() { - use <- run_with_timeout(20) let db = start_default() pog.query("select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub") @@ -491,7 +483,6 @@ pub fn expected_ten_millis_no_timeout_test() { } pub fn expected_ten_millis_no_default_timeout_test() { - use <- run_with_timeout(20) let db = default_config() |> pog.default_timeout(30) From 275aa078bf7af1a45f67ee841bb829134c4cd7f8 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 15:09:22 +0100 Subject: [PATCH 02/21] Use gleam_time --- CHANGELOG.md | 6 +++++ gleam.toml | 5 ++-- manifest.toml | 2 ++ src/pog.gleam | 58 ++++++++++++++++++++++++--------------------- test/pog_test.gleam | 40 ++++++++++++++----------------- 5 files changed, 60 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d36d10..2bf89ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v4.0.0 - Unreleased + +- `connect` and `disconnect` have been removed in favour of `start` and `supervised`. +- TODO: more flexible transactions. +- TODO: date functions changed. + ## v3.3.0 - 2025-07-03 - Updated `result.then` to `result.try` to resolve deprecation warnings. diff --git a/gleam.toml b/gleam.toml index 58d5c59..59a3c60 100644 --- a/gleam.toml +++ b/gleam.toml @@ -16,10 +16,11 @@ pages = [ ] [dependencies] -gleam_stdlib = ">= 0.51.0 and < 2.0.0" -pgo = ">= 0.12.0 and < 2.0.0" gleam_erlang = ">= 1.1.0 and < 2.0.0" gleam_otp = ">= 1.0.0 and < 2.0.0" +gleam_stdlib = ">= 0.51.0 and < 2.0.0" +gleam_time = ">= 1.0.0 and < 2.0.0" +pgo = ">= 0.12.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 24c31a9..e22849f 100644 --- a/manifest.toml +++ b/manifest.toml @@ -7,6 +7,7 @@ packages = [ { name = "gleam_erlang", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "D7A2E71CE7F6B513E62F9A9EF6DFDE640D9607598C477FCCADEF751C45FD82E7" }, { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, { name = "gleam_stdlib", version = "0.61.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "3DC407D6EDA98FCE089150C11F3AD892B6F4C3CA77C87A97BAE8D5AB5E41F331" }, + { name = "gleam_time", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D71F1AFF7FEB534FF55E5DC58E534E9201BA75A444619788A2E4DEA4EBD87D16" }, { name = "gleeunit", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "63022D81C12C17B7F1A60E029964E830A4CBD846BBC6740004FC1F1031AE0326" }, { name = "opentelemetry_api", version = "1.4.0", build_tools = ["rebar3", "mix"], requirements = [], otp_app = "opentelemetry_api", source = "hex", outer_checksum = "3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58" }, { name = "pg_types", version = "0.4.0", build_tools = ["rebar3"], requirements = [], otp_app = "pg_types", source = "hex", outer_checksum = "B02EFA785CAECECF9702C681C80A9CA12A39F9161A846CE17B01FB20AEEED7EB" }, @@ -18,5 +19,6 @@ exception = { version = ">= 2.0.0 and < 3.0.0" } gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } +gleam_time = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } pgo = { version = ">= 0.12.0 and < 2.0.0" } diff --git a/src/pog.gleam b/src/pog.gleam index 1c5c8b8..1a3a121 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -12,6 +12,7 @@ import gleam/list import gleam/option.{type Option, None, Some} import gleam/result import gleam/string +import gleam/time/calendar.{type Date, type TimeOfDay} import gleam/uri.{Uri} /// The port that will be used when none is specified. @@ -351,17 +352,17 @@ pub fn array(converter: fn(a) -> Value, values: List(a)) -> Value { |> coerce_value } -pub fn timestamp(timestamp: Timestamp) -> Value { - coerce_value(#(date(timestamp.date), time(timestamp.time))) +pub fn calendar_datetime(date: Date, time: TimeOfDay) -> Value { + coerce_value(#(calendar_date(date), calendar_time_of_day(time))) } -pub fn date(date: Date) -> Value { +pub fn calendar_date(date: Date) -> Value { coerce_value(#(date.year, date.month, date.day)) } -pub fn time(time: Time) -> Value { +pub fn calendar_time_of_day(time: TimeOfDay) -> Value { let seconds = int.to_float(time.seconds) - let seconds = seconds +. int.to_float(time.microseconds) /. 1_000_000.0 + let seconds = seconds +. int.to_float(time.nanoseconds) /. 1_000_000_000.0 coerce_value(#(time.hours, time.minutes, seconds)) } @@ -765,24 +766,39 @@ pub fn error_code_name(error_code: String) -> Result(String, Nil) { } } -pub fn timestamp_decoder() -> decode.Decoder(Timestamp) { - use date <- decode.field(0, date_decoder()) - use time <- decode.field(1, time_decoder()) - decode.success(Timestamp(date, time)) +pub fn calendar_datetime_decoder() -> decode.Decoder(#(Date, TimeOfDay)) { + use date <- decode.field(0, calendar_date_decoder()) + use time <- decode.field(1, calendar_time_of_day_decoder()) + decode.success(#(date, time)) } -pub fn date_decoder() -> decode.Decoder(Date) { +pub fn calendar_date_decoder() -> decode.Decoder(Date) { use year <- decode.field(0, decode.int) use month <- decode.field(1, decode.int) use day <- decode.field(2, decode.int) - decode.success(Date(year:, month:, day:)) + let date = fn(month) { decode.success(calendar.Date(year:, month:, day:)) } + case month { + 1 -> date(calendar.January) + 2 -> date(calendar.February) + 3 -> date(calendar.March) + 4 -> date(calendar.April) + 5 -> date(calendar.May) + 6 -> date(calendar.June) + 7 -> date(calendar.July) + 8 -> date(calendar.August) + 9 -> date(calendar.September) + 10 -> date(calendar.October) + 11 -> date(calendar.November) + 12 -> date(calendar.December) + _ -> decode.failure(calendar.Date(0, calendar.January, 1), "Calendar date") + } } -pub fn time_decoder() -> decode.Decoder(Time) { +pub fn calendar_time_of_day_decoder() -> decode.Decoder(TimeOfDay) { use hours <- decode.field(0, decode.int) use minutes <- decode.field(1, decode.int) - use #(seconds, microseconds) <- decode.field(2, seconds_decoder()) - decode.success(Time(hours:, minutes:, seconds:, microseconds:)) + use #(seconds, nanoseconds) <- decode.field(2, seconds_decoder()) + decode.success(calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds:)) } fn seconds_decoder() -> decode.Decoder(#(Int, Int)) { @@ -795,21 +811,9 @@ fn seconds_decoder() -> decode.Decoder(#(Int, Int)) { |> decode.map(fn(f) { let floored = float.floor(f) let seconds = float.round(floored) - let microseconds = float.round({ f -. floored } *. 1_000_000.0) + let microseconds = float.round({ f -. floored } *. 1_000_000_000.0) #(seconds, microseconds) }) } decode.one_of(int, [float]) } - -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/test/pog_test.gleam b/test/pog_test.gleam index 4ab1549..1442ff6 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -1,6 +1,7 @@ import exception import gleam/dynamic/decode.{type Decoder} import gleam/option.{None, Some} +import gleam/time/calendar import gleeunit import gleeunit/should import pog @@ -154,8 +155,8 @@ pub fn selecting_rows_test() { use x1 <- decode.field(1, decode.string) use x2 <- decode.field(2, decode.bool) use x3 <- decode.field(3, decode.list(decode.string)) - use x4 <- decode.field(4, pog.timestamp_decoder()) - use x5 <- decode.field(5, pog.date_decoder()) + use x4 <- decode.field(4, pog.calendar_datetime_decoder()) + use x5 <- decode.field(5, pog.calendar_date_decoder()) decode.success(#(x0, x1, x2, x3, x4, x5)) }) |> pog.execute(db) @@ -169,8 +170,11 @@ pub fn selecting_rows_test() { "neo", True, ["black"], - pog.Timestamp(pog.Date(2022, 10, 10), pog.Time(11, 30, 30, 100_000)), - pog.Date(2020, 3, 4), + #( + calendar.Date(2022, calendar.April, 10), + calendar.TimeOfDay(11, 30, 30, 100_000), + ), + calendar.Date(2020, calendar.March, 4), ), ]) @@ -379,24 +383,13 @@ pub fn array_test() { |> 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.timestamp_decoder(), - ) - |> pog.disconnect -} - pub fn date_test() { start_default() |> assert_roundtrip( - pog.Date(2022, 10, 11), + calendar.Date(2022, calendar.October, 11), "date", - pog.date, - pog.date_decoder(), + pog.calendar_date, + pog.calendar_date_decoder(), ) |> pog.disconnect } @@ -523,9 +516,9 @@ pub fn expected_maps_test() { use colors <- decode.field("colors", decode.list(decode.string)) use last_petted_at <- decode.field( "last_petted_at", - pog.timestamp_decoder(), + pog.calendar_datetime_decoder(), ) - use birthday <- decode.field("birthday", pog.date_decoder()) + use birthday <- decode.field("birthday", pog.calendar_date_decoder()) decode.success(#(id, name, is_cute, colors, last_petted_at, birthday)) }) |> pog.execute(db) @@ -539,8 +532,11 @@ pub fn expected_maps_test() { "neo", True, ["black"], - pog.Timestamp(pog.Date(2022, 10, 10), pog.Time(11, 30, 30, 0)), - pog.Date(2020, 3, 4), + #( + calendar.Date(2022, calendar.October, 10), + calendar.TimeOfDay(11, 30, 30, 0), + ), + calendar.Date(2020, calendar.October, 4), ), ]) From 1b51a68816e10615b9145906b91a9570b2733dee Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:09:15 +0100 Subject: [PATCH 03/21] Fix encoder --- src/pog.gleam | 16 +++++++++++++++- test/pog_test.gleam | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/pog.gleam b/src/pog.gleam index 1a3a121..b13f2ad 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -357,7 +357,21 @@ pub fn calendar_datetime(date: Date, time: TimeOfDay) -> Value { } pub fn calendar_date(date: Date) -> Value { - coerce_value(#(date.year, date.month, date.day)) + let month = case date.month { + calendar.January -> 1 + calendar.February -> 2 + calendar.March -> 3 + calendar.April -> 4 + calendar.May -> 5 + calendar.June -> 6 + calendar.July -> 7 + calendar.August -> 8 + calendar.September -> 9 + calendar.October -> 10 + calendar.November -> 11 + calendar.December -> 12 + } + coerce_value(#(date.year, month, date.day)) } pub fn calendar_time_of_day(time: TimeOfDay) -> Value { diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 1442ff6..6268d4d 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -171,7 +171,7 @@ pub fn selecting_rows_test() { True, ["black"], #( - calendar.Date(2022, calendar.April, 10), + calendar.Date(2022, calendar.October, 10), calendar.TimeOfDay(11, 30, 30, 100_000), ), calendar.Date(2020, calendar.March, 4), @@ -470,7 +470,7 @@ pub fn expected_ten_millis_no_timeout_test() { |> pog.timeout(30) |> pog.returning(decode.at([0], decode.string)) |> pog.execute(db) - |> should.equal(Ok(pog.Returned(1, ["Ok"]))) + |> should.equal(Ok(pog.Returned(1, ["OK"]))) pog.disconnect(db) } @@ -484,7 +484,7 @@ pub fn expected_ten_millis_no_default_timeout_test() { pog.query("select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub") |> pog.returning(decode.at([0], decode.string)) |> pog.execute(db) - |> should.equal(Ok(pog.Returned(1, ["Ok"]))) + |> should.equal(Ok(pog.Returned(1, ["OK"]))) pog.disconnect(db) } From cef4ea5a13e66bc181b2506fcc9f8c12ffde85ef Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:13:12 +0100 Subject: [PATCH 04/21] Fix tests --- test/pog_test.gleam | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 6268d4d..7a244c1 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -172,7 +172,7 @@ pub fn selecting_rows_test() { ["black"], #( calendar.Date(2022, calendar.October, 10), - calendar.TimeOfDay(11, 30, 30, 100_000), + calendar.TimeOfDay(11, 30, 30, 100_000_000), ), calendar.Date(2020, calendar.March, 4), ), @@ -533,7 +533,7 @@ pub fn expected_maps_test() { True, ["black"], #( - calendar.Date(2022, calendar.October, 10), + calendar.Date(2022, calendar.March, 10), calendar.TimeOfDay(11, 30, 30, 0), ), calendar.Date(2020, calendar.October, 4), From fa1dff5f6a9d94774234750e6ab302886ec696e4 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:13:40 +0100 Subject: [PATCH 05/21] Update assertions --- test/pog_test.gleam | 208 ++++++++++++++++++++------------------------ 1 file changed, 92 insertions(+), 116 deletions(-) diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 7a244c1..d11cd2a 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -3,7 +3,6 @@ import gleam/dynamic/decode.{type Decoder} import gleam/option.{None, Some} import gleam/time/calendar import gleeunit -import gleeunit/should import pog pub fn main() { @@ -19,8 +18,7 @@ pub fn url_config_everything_test() { |> pog.user("u") |> pog.password(Some("p")) - pog.url_config("postgres://u:p@db.test:1234/my_db") - |> should.equal(Ok(expected)) + assert pog.url_config("postgres://u:p@db.test:1234/my_db") == Ok(expected) } pub fn url_config_alternative_postgres_protocol_test() { @@ -31,13 +29,11 @@ pub fn url_config_alternative_postgres_protocol_test() { |> 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)) + assert pog.url_config("postgresql://u:p@db.test:1234/my_db") == 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)) + assert pog.url_config("foo://u:p@db.test:1234/my_db") == Error(Nil) } pub fn url_config_no_password_test() { @@ -48,8 +44,7 @@ pub fn url_config_no_password_test() { |> pog.database("my_db") |> pog.user("u") |> pog.password(None) - pog.url_config("postgres://u@db.test:1234/my_db") - |> should.equal(Ok(expected)) + assert pog.url_config("postgres://u@db.test:1234/my_db") == Ok(expected) } pub fn url_config_no_port_test() { @@ -60,13 +55,11 @@ pub fn url_config_no_port_test() { |> pog.database("my_db") |> pog.user("u") |> pog.password(None) - pog.url_config("postgres://u@db.test/my_db") - |> should.equal(Ok(expected)) + assert pog.url_config("postgres://u@db.test/my_db") == 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)) + assert pog.url_config("postgres://u:p@db.test:1234/my_db/foo") == Error(Nil) } fn start_default() { @@ -99,10 +92,8 @@ pub fn inserting_new_rows_test() { (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([]) + assert returned.count == 2 + assert returned.rows == [] pog.disconnect(db) } @@ -123,10 +114,8 @@ pub fn inserting_new_rows_and_returning_test() { |> pog.returning(decode.at([0], decode.string)) |> pog.execute(db) - returned.count - |> should.equal(2) - returned.rows - |> should.equal(["bill", "felix"]) + assert returned.count == 2 + assert returned.rows == ["bill", "felix"] pog.disconnect(db) } @@ -161,22 +150,21 @@ pub fn selecting_rows_test() { }) |> pog.execute(db) - returned.count - |> should.equal(1) - returned.rows - |> should.equal([ - #( - id, - "neo", - True, - ["black"], + assert returned.count == 1 + assert returned.rows + == [ #( - calendar.Date(2022, calendar.October, 10), - calendar.TimeOfDay(11, 30, 30, 100_000_000), + id, + "neo", + True, + ["black"], + #( + calendar.Date(2022, calendar.October, 10), + calendar.TimeOfDay(11, 30, 30, 100_000_000), + ), + calendar.Date(2020, calendar.March, 4), ), - calendar.Date(2020, calendar.March, 4), - ), - ]) + ] pog.disconnect(db) } @@ -188,12 +176,9 @@ pub fn invalid_sql_test() { 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\"") + assert code == "42601" + assert name == "syntax_error" + assert message == "syntax error at or near \"select\"" pog.disconnect(db) } @@ -211,16 +196,12 @@ pub fn insert_constraint_error_test() { let assert Error(pog.ConstraintViolated(message, constraint, detail)) = pog.query(sql) |> pog.execute(db) - constraint - |> should.equal("cats_pkey") + assert constraint == "cats_pkey" - detail - |> should.equal("Key (id)=(900) already exists.") + assert detail == "Key (id)=(900) already exists." - message - |> should.equal( - "duplicate key value violates unique constraint \"cats_pkey\"", - ) + assert message + == "duplicate key value violates unique constraint \"cats_pkey\"" pog.disconnect(db) } @@ -232,12 +213,9 @@ pub fn select_from_unknown_table_test() { 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") + assert code == "42P01" + assert name == "undefined_table" + assert message == "relation \"unknown\" does not exist" pog.disconnect(db) } @@ -253,14 +231,10 @@ pub fn insert_with_incorrect_type_test() { 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", - ) + assert code == "42804" + assert name == "datatype_mismatch" + assert message + == "column \"id\" is of type integer but expression is of type boolean" pog.disconnect(db) } @@ -269,9 +243,8 @@ 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.execute(db) - |> should.equal(Error(pog.UnexpectedArgumentCount(expected: 1, got: 0))) + assert pog.execute(pog.query(sql), db) + == Error(pog.UnexpectedArgumentCount(expected: 1, got: 0)) pog.disconnect(db) } @@ -283,21 +256,21 @@ fn assert_roundtrip( encoder: fn(a) -> pog.Value, decoder: Decoder(a), ) -> pog.Connection { - pog.query("select $1::" <> type_name) - |> pog.parameter(encoder(value)) - |> pog.returning(decode.at([0], decoder)) - |> pog.execute(db) - |> should.equal(Ok(pog.Returned(count: 1, rows: [value]))) + assert pog.query("select $1::" <> type_name) + |> pog.parameter(encoder(value)) + |> pog.returning(decode.at([0], decoder)) + |> pog.execute(db) + == 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(decode.at([0], decode.optional(decode.int))) - |> pog.execute(db) - |> should.equal(Ok(pog.Returned(count: 1, rows: [None]))) + assert pog.query("select $1") + |> pog.parameter(pog.null()) + |> pog.returning(decode.at([0], decode.optional(decode.int))) + |> pog.execute(db) + == Ok(pog.Returned(count: 1, rows: [None])) pog.disconnect(db) } @@ -426,27 +399,25 @@ pub fn nullable_test() { pub fn expected_argument_type_test() { let db = start_default() - pog.query("select $1::int") - |> pog.returning(decode.at([0], decode.string)) - |> pog.parameter(pog.float(1.2)) - |> pog.execute(db) - |> should.equal(Error(pog.UnexpectedArgumentType("int4", "1.2"))) + assert pog.query("select $1::int") + |> pog.returning(decode.at([0], decode.string)) + |> pog.parameter(pog.float(1.2)) + |> pog.execute(db) + == 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(decode.at([0], decode.string)) - |> pog.execute(db) - |> should.equal( - Error( + assert pog.query("select 1") + |> pog.returning(decode.at([0], decode.string)) + |> pog.execute(db) + == Error( pog.UnexpectedResultType([ decode.DecodeError(expected: "String", found: "Int", path: ["0"]), ]), - ), - ) + ) pog.disconnect(db) } @@ -454,11 +425,13 @@ pub fn expected_return_type_test() { pub fn expected_five_millis_timeout_test() { let db = start_default() - pog.query("select sub.ret from (select pg_sleep(0.05), 'OK' as ret) as sub") - |> pog.timeout(5) - |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) - |> should.equal(Error(pog.QueryTimeout)) + assert pog.query( + "select sub.ret from (select pg_sleep(0.05), 'OK' as ret) as sub", + ) + |> pog.timeout(5) + |> pog.returning(decode.at([0], decode.string)) + |> pog.execute(db) + == Error(pog.QueryTimeout) pog.disconnect(db) } @@ -466,11 +439,13 @@ pub fn expected_five_millis_timeout_test() { pub fn expected_ten_millis_no_timeout_test() { let db = start_default() - pog.query("select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub") - |> pog.timeout(30) - |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) - |> should.equal(Ok(pog.Returned(1, ["OK"]))) + assert pog.query( + "select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub", + ) + |> pog.timeout(30) + |> pog.returning(decode.at([0], decode.string)) + |> pog.execute(db) + == Ok(pog.Returned(1, ["OK"])) pog.disconnect(db) } @@ -481,10 +456,12 @@ pub fn expected_ten_millis_no_default_timeout_test() { |> pog.default_timeout(30) |> pog.connect - pog.query("select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub") - |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) - |> should.equal(Ok(pog.Returned(1, ["OK"]))) + assert pog.query( + "select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub", + ) + |> pog.returning(decode.at([0], decode.string)) + |> pog.execute(db) + == Ok(pog.Returned(1, ["OK"])) pog.disconnect(db) } @@ -523,22 +500,21 @@ pub fn expected_maps_test() { }) |> pog.execute(db) - returned.count - |> should.equal(1) - returned.rows - |> should.equal([ - #( - id, - "neo", - True, - ["black"], + assert returned.count == 1 + assert returned.rows + == [ #( - calendar.Date(2022, calendar.March, 10), - calendar.TimeOfDay(11, 30, 30, 0), + id, + "neo", + True, + ["black"], + #( + calendar.Date(2022, calendar.March, 10), + calendar.TimeOfDay(11, 30, 30, 0), + ), + calendar.Date(2020, calendar.October, 4), ), - calendar.Date(2020, calendar.October, 4), - ), - ]) + ] pog.disconnect(db) } From 63c0cb3ebb084d6e1ee52b5a8243c22f07ad549b Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:15:45 +0100 Subject: [PATCH 06/21] Fix test --- gleam.toml | 2 +- test/pog_test.gleam | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gleam.toml b/gleam.toml index 59a3c60..f5252e6 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,6 +1,6 @@ name = "pog" version = "3.3.0" -gleam = ">= 1.4.0" +gleam = ">= 1.11.0" licences = ["Apache-2.0"] description = "A PostgreSQL database client for Gleam, based on PGO" diff --git a/test/pog_test.gleam b/test/pog_test.gleam index d11cd2a..e41f9e7 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -509,10 +509,10 @@ pub fn expected_maps_test() { True, ["black"], #( - calendar.Date(2022, calendar.March, 10), + calendar.Date(2022, calendar.October, 10), calendar.TimeOfDay(11, 30, 30, 0), ), - calendar.Date(2020, calendar.October, 4), + calendar.Date(2020, calendar.March, 4), ), ] From 362e4b9600b8f3273ff50454c3489be77e69ca7b Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:19:31 +0100 Subject: [PATCH 07/21] Perhaps this? --- src/pog_ffi.erl | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 3033b49..d3c611a 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -106,11 +106,12 @@ transaction(#pog_pool{name = Name} = Conn, Callback) -> query(#pog_pool{name = Name}, Sql, Arguments, Timeout) -> - PoolOptions = case Timeout of - none -> []; - {some, QueryTimeout} -> [{timeout, QueryTimeout}] + Options = case Timeout of + none -> + #{pool => Name}; + {some, QueryTimeout} -> + #{pool => Name, pool_options => [{timeout, QueryTimeout}]}, end, - Options = #{pool => Name, pool_options => PoolOptions}, Res = pgo:query(Sql, Arguments, Options), case Res of #{rows := Rows, num_rows := NumRows} -> From 1a6e28b018021b333a3a637722176d82e6d02cde Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Tue, 1 Jul 2025 18:21:52 +0100 Subject: [PATCH 08/21] Oops --- src/pog_ffi.erl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index d3c611a..4037212 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -110,7 +110,7 @@ query(#pog_pool{name = Name}, Sql, Arguments, Timeout) -> none -> #{pool => Name}; {some, QueryTimeout} -> - #{pool => Name, pool_options => [{timeout, QueryTimeout}]}, + #{pool => Name, pool_options => [{timeout, QueryTimeout}]} end, Res = pgo:query(Sql, Arguments, Options), case Res of From 1e1b6640d25d0b8dc5297e4cfdc131f54b2150b4 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Wed, 2 Jul 2025 11:48:11 +0100 Subject: [PATCH 09/21] Fix timeouts --- CHANGELOG.md | 1 + src/pog.gleam | 31 +++++++++---------------------- src/pog_ffi.erl | 16 ++++++---------- test/pog_test.gleam | 41 ++++++++++++++++++++--------------------- 4 files changed, 36 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf89ab..7d5e166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## v4.0.0 - Unreleased - `connect` and `disconnect` have been removed in favour of `start` and `supervised`. +- The `default_timeout` function has been removed. - TODO: more flexible transactions. - TODO: date functions changed. diff --git a/src/pog.gleam b/src/pog.gleam index b13f2ad..11674cd 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -59,9 +59,6 @@ pub type Config { /// (default: False) 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`. rows_as_map: Bool, - /// (default: 5000): Default time in milliseconds to wait before the query - /// is considered timeout. Timeout can be edited per query. - default_timeout: Int, ) } @@ -187,13 +184,6 @@ pub fn rows_as_map(config: Config, rows_as_map: Bool) -> Config { Config(..config, rows_as_map:) } -/// By default, pog have a default value of 5000ms as timeout. -/// By setting `default_timeout`, every queries will now use that timeout. -/// The timeout is given in milliseconds. -pub fn default_timeout(config: Config, default_timeout: Int) -> Config { - Config(..config, default_timeout:) -} - /// The internet protocol version to use. pub type IpVersion { /// Internet Protocol version 4 (IPv4) @@ -221,7 +211,6 @@ pub fn default_config() -> Config { trace: False, ip_version: Ipv4, rows_as_map: False, - default_timeout: 5000, ) } @@ -417,7 +406,7 @@ fn run_query( a: Connection, b: String, c: List(Value), - timeout: Option(Int), + timeout: Int, ) -> Result(#(Int, List(Dynamic)), QueryError) pub type QueryError { @@ -447,7 +436,7 @@ pub opaque type Query(row_type) { sql: String, parameters: List(Value), row_decoder: Decoder(row_type), - timeout: option.Option(Int), + timeout: Int, ) } @@ -455,12 +444,7 @@ pub opaque type Query(row_type) { /// functions. /// pub fn query(sql: String) -> Query(Nil) { - Query( - sql:, - parameters: [], - row_decoder: decode.success(Nil), - timeout: option.None, - ) + Query(sql:, parameters: [], row_decoder: decode.success(Nil), timeout: 5000) } /// Set the decoder to use for the type of row returned by executing this @@ -480,11 +464,14 @@ pub fn parameter(query: Query(t1), parameter: Value) -> Query(t1) { Query(..query, parameters: [parameter, ..query.parameters]) } -/// Use a custom timeout for the query. This timeout will take precedence over +/// Use a custom timeout for the query, in milliseconds. /// the default connection timeout. -/// The timeout is given in milliseconds. +/// +/// If this function is not used to give a timeout then default of 5000 ms is +/// used. +/// pub fn timeout(query: Query(t1), timeout: Int) -> Query(t1) { - Query(..query, timeout: Some(timeout)) + Query(..query, timeout:) } /// Run a query against a PostgreSQL database. diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 4037212..670602e 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -2,7 +2,7 @@ -export([query/4, connect/1, disconnect/1, coerce/1, null/0, transaction/2]). --record(pog_pool, {name, pid, default_timeout}). +-record(pog_pool, {name, pid}). -include_lib("pog/include/pog_Config.hrl"). -include_lib("pg_types/include/pg_types.hrl"). @@ -55,8 +55,7 @@ connect(Config) -> idle_interval = IdleInterval, trace = Trace, ip_version = IpVersion, - rows_as_map = RowsAsMap, - default_timeout = DefaultTimeout + rows_as_map = RowsAsMap } = Config, {SslActivated, SslOptions} = default_ssl_options(Host, Ssl), Options1 = #{ @@ -73,7 +72,6 @@ connect(Config) -> idle_interval => IdleInterval, trace => Trace, decode_opts => [{return_rows_as_maps, RowsAsMap}], - pool_options => [{timeout, DefaultTimeout}], socket_options => case IpVersion of ipv4 -> []; ipv6 -> [inet6] @@ -106,12 +104,10 @@ transaction(#pog_pool{name = Name} = Conn, Callback) -> query(#pog_pool{name = Name}, Sql, Arguments, Timeout) -> - Options = case Timeout of - none -> - #{pool => Name}; - {some, QueryTimeout} -> - #{pool => Name, pool_options => [{timeout, QueryTimeout}]} - end, + Options = #{ + pool => Name, + pool_options => [{timeout, Timeout}] + }, Res = pgo:query(Sql, Arguments, Options), case Res of #{rows := Rows, num_rows := NumRows} -> diff --git a/test/pog_test.gleam b/test/pog_test.gleam index e41f9e7..42a8421 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -9,6 +9,25 @@ pub fn main() { gleeunit.main() } +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 url_config_everything_test() { let expected = pog.default_config() @@ -62,25 +81,6 @@ pub fn url_config_path_slash_test() { assert pog.url_config("postgres://u:p@db.test:1234/my_db/foo") == 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 = @@ -442,7 +442,7 @@ pub fn expected_ten_millis_no_timeout_test() { assert pog.query( "select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub", ) - |> pog.timeout(30) + |> pog.timeout(50) |> pog.returning(decode.at([0], decode.string)) |> pog.execute(db) == Ok(pog.Returned(1, ["OK"])) @@ -453,7 +453,6 @@ pub fn expected_ten_millis_no_timeout_test() { pub fn expected_ten_millis_no_default_timeout_test() { let db = default_config() - |> pog.default_timeout(30) |> pog.connect assert pog.query( From 2d7b56e7cb6b0834c465f88ded27efa4b833152d Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Wed, 2 Jul 2025 13:01:31 +0100 Subject: [PATCH 10/21] Use names --- CHANGELOG.md | 4 ++ README.md | 81 +++++++++++----------- gleam.toml | 2 +- manifest.toml | 4 +- src/pog.gleam | 65 ++++++++++++------ src/pog_ffi.erl | 26 +++---- test/pog_test.gleam | 163 ++++++++++++++++++++++++-------------------- 7 files changed, 190 insertions(+), 155 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d5e166..cacb09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,11 @@ ## v4.0.0 - Unreleased +- Starting a pool no longer generates an atom, instead a name is taken as an + argument. +- The `Connection` type has been removed. A subject is now used instead. - `connect` and `disconnect` have been removed in favour of `start` and `supervised`. +- `url_config` and `default_config` now take a name parameter. - The `default_timeout` function has been removed. - TODO: more flexible transactions. - TODO: date functions changed. diff --git a/README.md b/README.md index d3e6411..afc4ec8 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,45 @@ A PostgreSQL database client for Gleam, based on [PGO][erlang-pgo]. [erlang-pgo]: https://github.com/erleans/pgo ```gleam +gleam add pog@4 +``` + +Add a pool to your OTP supervision tree, before any siblings that will need to +use the database. + +Pools are named with a `Name` from `gleam/erlang/process`, so create one +outside of your supervision tree and pass it down to the creation of the pool. + +```gleam +import gleam/otp/static_supervisor import pog -import gleam/dynamic/decode -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 = - pog.default_config() +pub fn start_application_supervisor(pool_name: process.Name(pog.Message)) { + let pool_child = + pog.defaut_config(pool_name) |> pog.host("localhost") |> pog.database("my_database") |> pog.pool_size(15) - |> pog.connect + |> pog.supervised + + supervisor.new(supervisor.RestForOne) + |> supervisor.add(pool_child) + // |> supervisor.add(other) + // |> supervisor.add(application) + // |> supervisor.add(children) + |> supervisor.start +} +``` + +Then in your application you can use a subject created from that name to make +queries: + +```gleam +import pog +import gleam/dynamic/decode +import gleam/erlang/process.{type Subject} +pub fn run(db: Subject(pog.Message)) { // An SQL statement to run. It takes one int as a parameter let sql_query = " select @@ -39,28 +64,18 @@ pub fn main() { // Run the query against the PostgreSQL database // The int `1` is given as a parameter - let assert Ok(response) = + let assert Ok(data) = 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 - |> should.equal(2) - response.rows - |> should.equal([ - #("Nubi", 3, "black", ["Al", "Cutlass"]), - ]) + assert data.count == 2 + assert data.rows == [#("Nubi", 3, "black", ["Al", "Cutlass"])]) } ``` -## Installation - -```sh -gleam add pog -``` - ## Support of connection URI Configuring a Postgres connection is done by using `Config` type in `pog`. @@ -81,10 +96,9 @@ import pog /// Read the DATABASE_URL environment variable. /// Generate the pog.Config from that database URL. /// Finally, connect to database. -pub fn read_connection_uri() -> Result(pog.Connection, Nil) { +pub fn read_connection_uri(name) -> Result(pog.Config, Nil) { use database_url <- result.try(envoy.get("DATABASE_URL")) - use config <- result.try(pog.url_config(database_url)) - Ok(pog.connect(config)) + pog.url_config(name, database_url) } ``` @@ -112,15 +126,6 @@ By default, `pgo` will return every selected value from your query as a tuple. In case you want a different output, you can activate `rows_as_maps` in `Config`. Once activated, every returned rows will take the form of a `Dict`. -## Atom generation - -Creating a connection pool with the `pog.connect` function dynamically generates -an Erlang atom. Atoms are not garbage collected and only a certain number of -them can exist in an Erlang VM instance, and hitting this limit will result in -the VM crashing. Due to this limitation you should not dynamically open new -connection pools, instead create the pools you need when your application starts -and reuse them throughout the lifetime of your program. - ## SSL As for the rest of the web, you should try to use SSL connections with any @@ -172,16 +177,6 @@ in `pog.Config`. The different options are `SslDisabled`, `SslUnverified` & to your database should be highly secured to protect you against man-in-the-middle attacks, you should always try to use the most secured setting. -```gleam -import pog - -pub fn connect() { - pog.default_config() - |> pog.ssl(pog.SslVerified) - |> pog.connect -} -``` - ### Need some help? You tried to setup a secured connection, but it does not work? Your container diff --git a/gleam.toml b/gleam.toml index f5252e6..98c0f5e 100644 --- a/gleam.toml +++ b/gleam.toml @@ -16,7 +16,7 @@ pages = [ ] [dependencies] -gleam_erlang = ">= 1.1.0 and < 2.0.0" +gleam_erlang = ">= 1.2.0 and < 2.0.0" gleam_otp = ">= 1.0.0 and < 2.0.0" gleam_stdlib = ">= 0.51.0 and < 2.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index e22849f..fd5fd70 100644 --- a/manifest.toml +++ b/manifest.toml @@ -4,7 +4,7 @@ packages = [ { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, - { name = "gleam_erlang", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "D7A2E71CE7F6B513E62F9A9EF6DFDE640D9607598C477FCCADEF751C45FD82E7" }, + { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, { name = "gleam_stdlib", version = "0.61.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "3DC407D6EDA98FCE089150C11F3AD892B6F4C3CA77C87A97BAE8D5AB5E41F331" }, { name = "gleam_time", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D71F1AFF7FEB534FF55E5DC58E534E9201BA75A444619788A2E4DEA4EBD87D16" }, @@ -16,7 +16,7 @@ packages = [ [requirements] exception = { version = ">= 2.0.0 and < 3.0.0" } -gleam_erlang = { version = ">= 1.1.0 and < 2.0.0" } +gleam_erlang = { version = ">= 1.2.0 and < 2.0.0" } gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } gleam_time = { version = ">= 1.0.0 and < 2.0.0" } diff --git a/src/pog.gleam b/src/pog.gleam index 11674cd..af88189 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -6,10 +6,13 @@ import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode.{type Decoder} +import gleam/erlang/process.{type Name, type Pid, type Subject} import gleam/float import gleam/int import gleam/list import gleam/option.{type Option, None, Some} +import gleam/otp/actor +import gleam/otp/supervision import gleam/result import gleam/string import gleam/time/calendar.{type Date, type TimeOfDay} @@ -18,9 +21,13 @@ import gleam/uri.{Uri} /// The port that will be used when none is specified. const default_port: Int = 5432 +pub type Message + /// The configuration for a pool of connections. pub type Config { Config( + /// The Erlang name to register the pool with. + pool_name: Name(Message), /// (default: 127.0.0.1): Database server hostname. host: String, /// (default: 5432): Port the server is listening on. @@ -195,8 +202,9 @@ pub type IpVersion { /// The default configuration for a connection pool, with a single connection. /// You will likely want to increase the size of the pool for your application. /// -pub fn default_config() -> Config { +pub fn default_config(pool_name pool_name: Name(Message)) -> Config { Config( + pool_name:, host: "127.0.0.1", port: default_port, database: "postgres", @@ -215,7 +223,10 @@ pub fn default_config() -> Config { } /// Parse a database url into configuration that can be used to start a pool. -pub fn url_config(database_url: String) -> Result(Config, Nil) { +pub fn url_config( + name: Name(Message), + database_url: String, +) -> Result(Config, Nil) { use uri <- result.try(uri.parse(database_url)) let uri = case uri.port { Some(_) -> uri @@ -244,7 +255,7 @@ pub fn url_config(database_url: String) -> Result(Config, Nil) { ["", database] -> Ok( Config( - ..default_config(), + ..default_config(name), host: host, port: db_port, database: database, @@ -294,25 +305,37 @@ fn extract_ssl_mode(query: option.Option(String)) -> Result(Ssl, Nil) { } } -/// A pool of one or more database connections against which queries can be -/// made. +/// Start a database connection pool. Most the time you want to use +/// `supervised` and add the pool to your supervision tree instead of using this +/// function directly. /// -/// Created using the `connect` function and shut-down with the `disconnect` -/// function. -pub type Connection +/// The pool is started in a new process and will asynchronously connect to the +/// 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. +/// +pub fn start(config: Config) -> actor.StartResult(Subject(Message)) { + case start_tree(config) { + Ok(pid) -> Ok(actor.Started(pid, process.named_subject(config.pool_name))) + Error(reason) -> Error(actor.InitExited(process.Abnormal(reason))) + } +} + +@external(erlang, "pog_ffi", "start") +fn start_tree(config: Config) -> Result(Pid, dynamic.Dynamic) -/// Start a database connection pool. +/// Start a database connection pool by adding it to your supervision tree. /// /// The pool is started in a new process and will asynchronously connect to the /// 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, "pog_ffi", "connect") -pub fn connect(a: Config) -> Connection - -/// Shut down a connection pool. -@external(erlang, "pog_ffi", "disconnect") -pub fn disconnect(a: Connection) -> Nil +/// +pub fn supervised( + config: Config, +) -> supervision.ChildSpecification(Subject(Message)) { + supervision.supervisor(fn() { start(config) }) +} /// A value that can be sent to PostgreSQL as one of the arguments to a /// parameterised SQL query. @@ -385,8 +408,8 @@ pub type TransactionError { /// back. @external(erlang, "pog_ffi", "transaction") pub fn transaction( - pool: Connection, - callback: fn(Connection) -> Result(t, String), + pool: Subject(Message), + callback: fn(Subject(Message)) -> Result(t, String), ) -> Result(t, TransactionError) pub fn nullable(inner_type: fn(a) -> Value, value: Option(a)) -> Value { @@ -403,7 +426,7 @@ pub type Returned(t) { @external(erlang, "pog_ffi", "query") fn run_query( - a: Connection, + a: Name(Message), b: String, c: List(Value), timeout: Int, @@ -478,9 +501,13 @@ pub fn timeout(query: Query(t1), timeout: Int) -> Query(t1) { /// pub fn execute( query query: Query(t), - on pool: Connection, + on pool: Subject(Message), ) -> Result(Returned(t), QueryError) { let parameters = list.reverse(query.parameters) + use pool <- result.try( + process.subject_name(pool) + |> result.replace_error(ConnectionUnavailable), + ) use #(count, rows) <- result.try(run_query( pool, query.sql, diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 670602e..042d031 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -1,8 +1,6 @@ -module(pog_ffi). --export([query/4, connect/1, disconnect/1, coerce/1, null/0, transaction/2]). - --record(pog_pool, {name, pid}). +-export([query/4, start/1, coerce/1, null/0, transaction/2]). -include_lib("pog/include/pog_Config.hrl"). -include_lib("pg_types/include/pg_types.hrl"). @@ -38,10 +36,9 @@ default_ssl_options(Host, Ssl) -> ]} end. -connect(Config) -> - Id = integer_to_list(erlang:unique_integer([positive])), - PoolName = list_to_atom("pog_pool_" ++ Id), +start(Config) -> #config{ + pool_name = PoolName, host = Host, port = Port, database = Database, @@ -81,31 +78,26 @@ connect(Config) -> {some, Pw} -> maps:put(password, Pw, Options1); none -> Options1 end, - {ok, Pid} = pgo_pool:start_link(PoolName, Options2), - #pog_pool{name = PoolName, pid = Pid}. - -disconnect(#pog_pool{pid = Pid}) -> - erlang:exit(Pid, normal), - nil. + pgo_pool:start_link(PoolName, Options2). -transaction(#pog_pool{name = Name} = Conn, Callback) -> +transaction(Pool, Callback) when is_atom(Pool) -> F = fun() -> - case Callback(Conn) of + case Callback(Pool) of {ok, T} -> {ok, T}; {error, Reason} -> error({pog_rollback_transaction, Reason}) end end, try - pgo:transaction(Name, F, #{}) + pgo:transaction(Pool, F, #{}) catch error:{pog_rollback_transaction, Reason} -> {error, {transaction_rolled_back, Reason}} end. -query(#pog_pool{name = Name}, Sql, Arguments, Timeout) -> +query(Pool, Sql, Arguments, Timeout) when is_atom(Pool) -> Options = #{ - pool => Name, + pool => Pool, pool_options => [{timeout, Timeout}] }, Res = pgo:query(Sql, Arguments, Options), diff --git a/test/pog_test.gleam b/test/pog_test.gleam index 42a8421..c8ec965 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -1,6 +1,8 @@ import exception import gleam/dynamic/decode.{type Decoder} +import gleam/erlang/process import gleam/option.{None, Some} +import gleam/otp/actor import gleam/time/calendar import gleeunit import pog @@ -9,19 +11,21 @@ pub fn main() { gleeunit.main() } -fn start_default() { - pog.Config( - ..pog.default_config(), - database: "gleam_pog_test", - password: Some("postgres"), - pool_size: 1, - ) - |> pog.connect +fn disconnect(db: actor.Started(a)) -> Nil { + process.send_exit(db.pid) +} + +fn start_default() -> actor.Started(process.Subject(pog.Message)) { + let assert Ok(started) = + process.new_name("pog_test") + |> default_config + |> pog.start + started } -fn default_config() { +fn default_config(name) { pog.Config( - ..pog.default_config(), + ..pog.default_config(name), database: "gleam_pog_test", password: Some("postgres"), pool_size: 1, @@ -29,56 +33,65 @@ fn default_config() { } pub fn url_config_everything_test() { + let name = process.new_name("pog_test") let expected = - pog.default_config() + pog.default_config(name) |> pog.host("db.test") |> pog.port(1234) |> pog.database("my_db") |> pog.user("u") |> pog.password(Some("p")) - assert pog.url_config("postgres://u:p@db.test:1234/my_db") == Ok(expected) + assert pog.url_config(name, "postgres://u:p@db.test:1234/my_db") + == Ok(expected) } pub fn url_config_alternative_postgres_protocol_test() { + let name = process.new_name("pog_test") let expected = - pog.default_config() + pog.default_config(name) |> pog.host("db.test") |> pog.port(1234) |> pog.database("my_db") |> pog.user("u") |> pog.password(Some("p")) - assert pog.url_config("postgresql://u:p@db.test:1234/my_db") == Ok(expected) + assert pog.url_config(name, "postgresql://u:p@db.test:1234/my_db") + == Ok(expected) } pub fn url_config_not_postgres_protocol_test() { - assert pog.url_config("foo://u:p@db.test:1234/my_db") == Error(Nil) + let name = process.new_name("pog_test") + assert pog.url_config(name, "foo://u:p@db.test:1234/my_db") == Error(Nil) } pub fn url_config_no_password_test() { + let name = process.new_name("pog_test") let expected = - pog.default_config() + pog.default_config(name) |> pog.host("db.test") |> pog.port(1234) |> pog.database("my_db") |> pog.user("u") |> pog.password(None) - assert pog.url_config("postgres://u@db.test:1234/my_db") == Ok(expected) + assert pog.url_config(name, "postgres://u@db.test:1234/my_db") == Ok(expected) } pub fn url_config_no_port_test() { + let name = process.new_name("pog_test") let expected = - pog.default_config() + pog.default_config(name) |> pog.host("db.test") |> pog.port(5432) |> pog.database("my_db") |> pog.user("u") |> pog.password(None) - assert pog.url_config("postgres://u@db.test/my_db") == Ok(expected) + assert pog.url_config(name, "postgres://u@db.test/my_db") == Ok(expected) } pub fn url_config_path_slash_test() { - assert pog.url_config("postgres://u:p@db.test:1234/my_db/foo") == Error(Nil) + let name = process.new_name("pog_test") + assert pog.url_config(name, "postgres://u:p@db.test:1234/my_db/foo") + == Error(Nil) } pub fn inserting_new_rows_test() { @@ -90,12 +103,12 @@ pub fn inserting_new_rows_test() { 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) + let assert Ok(returned) = pog.query(sql) |> pog.execute(db.data) assert returned.count == 2 assert returned.rows == [] - pog.disconnect(db) + disconnect(db) } pub fn inserting_new_rows_and_returning_test() { @@ -112,12 +125,12 @@ pub fn inserting_new_rows_and_returning_test() { let assert Ok(returned) = pog.query(sql) |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) + |> pog.execute(db.data) assert returned.count == 2 assert returned.rows == ["bill", "felix"] - pog.disconnect(db) + disconnect(db) } pub fn selecting_rows_test() { @@ -134,7 +147,7 @@ pub fn selecting_rows_test() { let assert Ok(pog.Returned(rows: [id], ..)) = pog.query(sql) |> pog.returning(decode.at([0], decode.int)) - |> pog.execute(db) + |> pog.execute(db.data) let assert Ok(returned) = pog.query("SELECT * FROM cats WHERE id = $1") @@ -148,7 +161,7 @@ pub fn selecting_rows_test() { use x5 <- decode.field(5, pog.calendar_date_decoder()) decode.success(#(x0, x1, x2, x3, x4, x5)) }) - |> pog.execute(db) + |> pog.execute(db.data) assert returned.count == 1 assert returned.rows @@ -166,7 +179,7 @@ pub fn selecting_rows_test() { ), ] - pog.disconnect(db) + disconnect(db) } pub fn invalid_sql_test() { @@ -174,13 +187,13 @@ pub fn invalid_sql_test() { let sql = "select select" let assert Error(pog.PostgresqlError(code, name, message)) = - pog.query(sql) |> pog.execute(db) + pog.query(sql) |> pog.execute(db.data) assert code == "42601" assert name == "syntax_error" assert message == "syntax error at or near \"select\"" - pog.disconnect(db) + disconnect(db) } pub fn insert_constraint_error_test() { @@ -194,7 +207,7 @@ pub fn insert_constraint_error_test() { (900, 'felix', false, ARRAY ['black'], now(), '2020-03-05')" let assert Error(pog.ConstraintViolated(message, constraint, detail)) = - pog.query(sql) |> pog.execute(db) + pog.query(sql) |> pog.execute(db.data) assert constraint == "cats_pkey" @@ -203,7 +216,7 @@ pub fn insert_constraint_error_test() { assert message == "duplicate key value violates unique constraint \"cats_pkey\"" - pog.disconnect(db) + disconnect(db) } pub fn select_from_unknown_table_test() { @@ -211,13 +224,13 @@ pub fn select_from_unknown_table_test() { let sql = "SELECT * FROM unknown" let assert Error(pog.PostgresqlError(code, name, message)) = - pog.query(sql) |> pog.execute(db) + pog.query(sql) |> pog.execute(db.data) assert code == "42P01" assert name == "undefined_table" assert message == "relation \"unknown\" does not exist" - pog.disconnect(db) + disconnect(db) } pub fn insert_with_incorrect_type_test() { @@ -229,37 +242,37 @@ pub fn insert_with_incorrect_type_test() { VALUES (true, true, true, true)" let assert Error(pog.PostgresqlError(code, name, message)) = - pog.query(sql) |> pog.execute(db) + pog.query(sql) |> pog.execute(db.data) assert code == "42804" assert name == "datatype_mismatch" assert message == "column \"id\" is of type integer but expression is of type boolean" - pog.disconnect(db) + disconnect(db) } pub fn execute_with_wrong_number_of_arguments_test() { let db = start_default() let sql = "SELECT * FROM cats WHERE id = $1" - assert pog.execute(pog.query(sql), db) + assert pog.execute(pog.query(sql), db.data) == Error(pog.UnexpectedArgumentCount(expected: 1, got: 0)) - pog.disconnect(db) + disconnect(db) } fn assert_roundtrip( - db: pog.Connection, + db: actor.Started(_), value: a, type_name: String, encoder: fn(a) -> pog.Value, decoder: Decoder(a), -) -> pog.Connection { +) -> actor.Started(_) { assert pog.query("select $1::" <> type_name) |> pog.parameter(encoder(value)) |> pog.returning(decode.at([0], decoder)) - |> pog.execute(db) + |> pog.execute(db.data) == Ok(pog.Returned(count: 1, rows: [value])) db } @@ -269,17 +282,17 @@ pub fn null_test() { assert pog.query("select $1") |> pog.parameter(pog.null()) |> pog.returning(decode.at([0], decode.optional(decode.int))) - |> pog.execute(db) + |> pog.execute(db.data) == Ok(pog.Returned(count: 1, rows: [None])) - pog.disconnect(db) + disconnect(db) } pub fn bool_test() { start_default() |> assert_roundtrip(True, "bool", pog.bool, decode.bool) |> assert_roundtrip(False, "bool", pog.bool, decode.bool) - |> pog.disconnect + |> disconnect } pub fn int_test() { @@ -297,7 +310,7 @@ pub fn int_test() { |> assert_roundtrip(-4, "int", pog.int, decode.int) |> assert_roundtrip(-5, "int", pog.int, decode.int) |> assert_roundtrip(10_000_000, "int", pog.int, decode.int) - |> pog.disconnect + |> disconnect } pub fn float_test() { @@ -315,7 +328,7 @@ pub fn float_test() { |> assert_roundtrip(-4.654, "float", pog.float, decode.float) |> assert_roundtrip(-5.654, "float", pog.float, decode.float) |> assert_roundtrip(10_000_000.0, "float", pog.float, decode.float) - |> pog.disconnect + |> disconnect } pub fn text_test() { @@ -323,7 +336,7 @@ pub fn text_test() { |> assert_roundtrip("", "text", pog.text, decode.string) |> assert_roundtrip("✨", "text", pog.text, decode.string) |> assert_roundtrip("Hello, Joe!", "text", pog.text, decode.string) - |> pog.disconnect + |> disconnect } pub fn bytea_test() { @@ -338,7 +351,7 @@ pub fn bytea_test() { ) |> assert_roundtrip(<<1>>, "bytea", pog.bytea, decode.bit_array) |> assert_roundtrip(<<1, 2, 3>>, "bytea", pog.bytea, decode.bit_array) - |> pog.disconnect + |> disconnect } pub fn array_test() { @@ -353,7 +366,7 @@ pub fn array_test() { pog.array(pog.int, _), decode.list(decode.int), ) - |> pog.disconnect + |> disconnect } pub fn date_test() { @@ -364,7 +377,7 @@ pub fn date_test() { pog.calendar_date, pog.calendar_date_decoder(), ) - |> pog.disconnect + |> disconnect } pub fn nullable_test() { @@ -393,7 +406,7 @@ pub fn nullable_test() { pog.nullable(pog.int, _), decode.optional(decode.int), ) - |> pog.disconnect + |> disconnect } pub fn expected_argument_type_test() { @@ -402,24 +415,24 @@ pub fn expected_argument_type_test() { assert pog.query("select $1::int") |> pog.returning(decode.at([0], decode.string)) |> pog.parameter(pog.float(1.2)) - |> pog.execute(db) + |> pog.execute(db.data) == Error(pog.UnexpectedArgumentType("int4", "1.2")) - pog.disconnect(db) + disconnect(db) } pub fn expected_return_type_test() { let db = start_default() assert pog.query("select 1") |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) + |> pog.execute(db.data) == Error( pog.UnexpectedResultType([ decode.DecodeError(expected: "String", found: "Int", path: ["0"]), ]), ) - pog.disconnect(db) + disconnect(db) } pub fn expected_five_millis_timeout_test() { @@ -430,10 +443,10 @@ pub fn expected_five_millis_timeout_test() { ) |> pog.timeout(5) |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) + |> pog.execute(db.data) == Error(pog.QueryTimeout) - pog.disconnect(db) + disconnect(db) } pub fn expected_ten_millis_no_timeout_test() { @@ -444,29 +457,33 @@ pub fn expected_ten_millis_no_timeout_test() { ) |> pog.timeout(50) |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) + |> pog.execute(db.data) == Ok(pog.Returned(1, ["OK"])) - pog.disconnect(db) + disconnect(db) } pub fn expected_ten_millis_no_default_timeout_test() { - let db = - default_config() - |> pog.connect + let name = process.new_name("pog_test") + let assert Ok(db) = + default_config(name) + |> pog.start assert pog.query( "select sub.ret from (select pg_sleep(0.01), 'OK' as ret) as sub", ) |> pog.returning(decode.at([0], decode.string)) - |> pog.execute(db) + |> pog.execute(db.data) == Ok(pog.Returned(1, ["OK"])) - pog.disconnect(db) + disconnect(db) } pub fn expected_maps_test() { - let db = pog.Config(..default_config(), rows_as_map: True) |> pog.connect + let name = process.new_name("pog_test") + let assert Ok(db) = + pog.Config(..default_config(name), rows_as_map: True) + |> pog.start let sql = " @@ -480,7 +497,7 @@ pub fn expected_maps_test() { let assert Ok(pog.Returned(rows: [id], ..)) = pog.query(sql) |> pog.returning(decode.at(["id"], decode.int)) - |> pog.execute(db) + |> pog.execute(db.data) let assert Ok(returned) = pog.query("SELECT * FROM cats WHERE id = $1") @@ -497,7 +514,7 @@ pub fn expected_maps_test() { use birthday <- decode.field("birthday", pog.calendar_date_decoder()) decode.success(#(id, name, is_cute, colors, last_petted_at, birthday)) }) - |> pog.execute(db) + |> pog.execute(db.data) assert returned.count == 1 assert returned.rows @@ -515,13 +532,13 @@ pub fn expected_maps_test() { ), ] - pog.disconnect(db) + disconnect(db) } pub fn transaction_commit_test() { let db = start_default() let id_decoder = decode.at([0], decode.int) - let assert Ok(_) = pog.query("truncate table cats") |> pog.execute(db) + let assert Ok(_) = pog.query("truncate table cats") |> pog.execute(db.data) let insert = fn(db, name) { let sql = " @@ -539,7 +556,7 @@ pub fn transaction_commit_test() { // A succeeding transaction let assert Ok(#(id1, id2)) = - pog.transaction(db, fn(db) { + pog.transaction(db.data, fn(db) { let id1 = insert(db, "one") let id2 = insert(db, "two") Ok(#(id1, id2)) @@ -547,7 +564,7 @@ pub fn transaction_commit_test() { // An error returning transaction, it gets rolled back let assert Error(pog.TransactionRolledBack("Nah bruv!")) = - pog.transaction(db, fn(db) { + pog.transaction(db.data, fn(db) { let _id1 = insert(db, "two") let _id2 = insert(db, "three") Error("Nah bruv!") @@ -556,7 +573,7 @@ pub fn transaction_commit_test() { // A crashing transaction, it gets rolled back let _ = exception.rescue(fn() { - pog.transaction(db, fn(db) { + pog.transaction(db.data, fn(db) { let _id1 = insert(db, "four") let _id2 = insert(db, "five") panic as "testing rollbacks" @@ -566,11 +583,11 @@ pub fn transaction_commit_test() { let assert Ok(returned) = pog.query("select id from cats order by id") |> pog.returning(id_decoder) - |> pog.execute(db) + |> pog.execute(db.data) let assert [got1, got2] = returned.rows let assert True = id1 == got1 let assert True = id2 == got2 - pog.disconnect(db) + disconnect(db) } From e0630866e8ca04fab6f6a9fc349b55ab31a9739c Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Wed, 2 Jul 2025 13:41:26 +0100 Subject: [PATCH 11/21] Start with new transaction approach --- gleam.toml | 2 +- manifest.toml | 2 +- src/pog.gleam | 10 +++++++++- src/pog_ffi.erl | 31 ++++++++++++++++++++++++++++++- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/gleam.toml b/gleam.toml index 98c0f5e..fd6be53 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,7 +20,7 @@ gleam_erlang = ">= 1.2.0 and < 2.0.0" gleam_otp = ">= 1.0.0 and < 2.0.0" gleam_stdlib = ">= 0.51.0 and < 2.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" -pgo = ">= 0.12.0 and < 2.0.0" +pgo = ">= 0.14.0 and < 1.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index fd5fd70..033e9d9 100644 --- a/manifest.toml +++ b/manifest.toml @@ -21,4 +21,4 @@ gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } gleam_time = { version = ">= 1.0.0 and < 2.0.0" } gleeunit = { version = ">= 1.0.0 and < 2.0.0" } -pgo = { version = ">= 0.12.0 and < 2.0.0" } +pgo = { version = ">= 0.14.0 and < 1.0.0" } diff --git a/src/pog.gleam b/src/pog.gleam index af88189..efb5de6 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -2,6 +2,9 @@ //// //// Gleam wrapper around pgo library +// TODO: Connection is a union of the name and the checked out connection +// TODO: Move handling of this into Gleam to make it easier to write + // TODO: add time and timestamp with zone once pgo supports them import gleam/dynamic.{type Dynamic} @@ -406,12 +409,17 @@ pub type TransactionError { /// /// If the function returns an `Error` or panics then the transaction is rolled /// back. -@external(erlang, "pog_ffi", "transaction") pub fn transaction( pool: Subject(Message), callback: fn(Subject(Message)) -> Result(t, String), ) -> Result(t, TransactionError) +@external(erlang, "pog_ffi", "transaction") +fn run_transaction( + pool: Name(Message), + callback: fn(Subject(Message)) -> Result(t, String), +) -> Result(t, TransactionError) + pub fn nullable(inner_type: fn(a) -> Value, value: Option(a)) -> Value { case value { Some(term) -> inner_type(term) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 042d031..2f60048 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -80,7 +80,7 @@ start(Config) -> end, pgo_pool:start_link(PoolName, Options2). -transaction(Pool, Callback) when is_atom(Pool) -> +old_transaction(Pool, Callback) when is_atom(Pool) -> F = fun() -> case Callback(Pool) of {ok, T} -> {ok, T}; @@ -95,6 +95,35 @@ transaction(Pool, Callback) when is_atom(Pool) -> end. +transaction(Pool, Fun) when is_atom(Pool) andalso is_function(Fun, 1) -> + Exec = fun(Conn, Sql) -> + pgo_handler:extended_query(Conn, Sql, [], #{queue_time => undefined}) + end, + case pgo:checkout(Pool) of + {ok, Ref, Conn} -> + try + #{command := 'begin'} = Exec(Conn, "BEGIN"), + Result = Fun(), + case Exec(Conn, "COMMIT") of + #{command := commit} -> Result; + #{command := rollback} -> Result + end + catch + Type:Reason:Stacktrace -> + Exec(Conn, "ROLLBACK"), + erlang:raise(Type, Reason, Stacktrace) + after + pgo:checkin(Ref, Conn) + end; + {error, _} = Error -> + Error + end; +% TODO: remove +transaction(A, B) -> + erlang:display(A), + erlang:display(B), + erlang:raise(badarg). + query(Pool, Sql, Arguments, Timeout) when is_atom(Pool) -> Options = #{ pool => Pool, From 28a671f7e0c2e066ac897d4810051ff10ebc3943 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 14:56:43 +0100 Subject: [PATCH 12/21] Version --- gleam.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gleam.toml b/gleam.toml index fd6be53..a5ef5cc 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "pog" -version = "3.3.0" +version = "4.0.0" gleam = ">= 1.11.0" licences = ["Apache-2.0"] description = "A PostgreSQL database client for Gleam, based on PGO" From a53d2e149241414b80467f06f1acdd03ee0b3f44 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 16:46:51 +0100 Subject: [PATCH 13/21] Improved transactions --- gleam.toml | 2 +- manifest.toml | 4 +- src/pog.gleam | 104 ++++++++++++++++++++++++++++++++++---------- src/pog_ffi.erl | 76 ++++++++++++-------------------- test/pog_test.gleam | 2 +- 5 files changed, 112 insertions(+), 76 deletions(-) diff --git a/gleam.toml b/gleam.toml index a5ef5cc..563c3d4 100644 --- a/gleam.toml +++ b/gleam.toml @@ -20,11 +20,11 @@ gleam_erlang = ">= 1.2.0 and < 2.0.0" gleam_otp = ">= 1.0.0 and < 2.0.0" gleam_stdlib = ">= 0.51.0 and < 2.0.0" gleam_time = ">= 1.0.0 and < 2.0.0" +exception = ">= 2.1.0 and < 3.0.0" pgo = ">= 0.14.0 and < 1.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" -exception = ">= 2.0.0 and < 3.0.0" [erlang] # Starting an SSL connection relies on ssl application to be started. diff --git a/manifest.toml b/manifest.toml index 033e9d9..751b6dc 100644 --- a/manifest.toml +++ b/manifest.toml @@ -3,7 +3,7 @@ packages = [ { name = "backoff", version = "1.1.6", build_tools = ["rebar3"], requirements = [], otp_app = "backoff", source = "hex", outer_checksum = "CF0CFFF8995FB20562F822E5CC47D8CCF664C5ECDC26A684CBE85C225F9D7C39" }, - { name = "exception", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "F5580D584F16A20B7FCDCABF9E9BE9A2C1F6AC4F9176FA6DD0B63E3B20D450AA" }, + { name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" }, { name = "gleam_erlang", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "F91CE62A2D011FA13341F3723DB7DB118541AAA5FE7311BD2716D018F01EF9E3" }, { name = "gleam_otp", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "7020E652D18F9ABAC9C877270B14160519FA0856EE80126231C505D719AD68DA" }, { name = "gleam_stdlib", version = "0.61.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "3DC407D6EDA98FCE089150C11F3AD892B6F4C3CA77C87A97BAE8D5AB5E41F331" }, @@ -15,7 +15,7 @@ packages = [ ] [requirements] -exception = { version = ">= 2.0.0 and < 3.0.0" } +exception = { version = ">= 2.1.0 and < 3.0.0" } gleam_erlang = { version = ">= 1.2.0 and < 2.0.0" } gleam_otp = { version = ">= 1.0.0 and < 2.0.0" } gleam_stdlib = { version = ">= 0.51.0 and < 2.0.0" } diff --git a/src/pog.gleam b/src/pog.gleam index efb5de6..8b7732b 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -2,14 +2,13 @@ //// //// Gleam wrapper around pgo library -// TODO: Connection is a union of the name and the checked out connection -// TODO: Move handling of this into Gleam to make it easier to write - -// TODO: add time and timestamp with zone once pgo supports them +// TODO: add time things with zone once pgo supports them +import exception import gleam/dynamic.{type Dynamic} import gleam/dynamic/decode.{type Decoder} -import gleam/erlang/process.{type Name, type Pid, type Subject} +import gleam/erlang/process.{type Name, type Pid} +import gleam/erlang/reference.{type Reference} import gleam/float import gleam/int import gleam/list @@ -24,6 +23,13 @@ import gleam/uri.{Uri} /// The port that will be used when none is specified. const default_port: Int = 5432 +pub opaque type Connection { + Pool(Name(Message)) + SingleConnection(SingleConnection) +} + +type SingleConnection + pub type Message /// The configuration for a pool of connections. @@ -317,9 +323,9 @@ fn extract_ssl_mode(query: option.Option(String)) -> Result(Ssl, Nil) { /// or it cannot connect for another reason it will continue to attempt to /// connect, and any queries made using the connection pool will fail. /// -pub fn start(config: Config) -> actor.StartResult(Subject(Message)) { +pub fn start(config: Config) -> actor.StartResult(Connection) { case start_tree(config) { - Ok(pid) -> Ok(actor.Started(pid, process.named_subject(config.pool_name))) + Ok(pid) -> Ok(actor.Started(pid, Pool(config.pool_name))) Error(reason) -> Error(actor.InitExited(process.Abnormal(reason))) } } @@ -334,9 +340,7 @@ fn start_tree(config: Config) -> Result(Pid, dynamic.Dynamic) /// or it cannot connect for another reason it will continue to attempt to /// connect, and any queries made using the connection pool will fail. /// -pub fn supervised( - config: Config, -) -> supervision.ChildSpecification(Subject(Message)) { +pub fn supervised(config: Config) -> supervision.ChildSpecification(Connection) { supervision.supervisor(fn() { start(config) }) } @@ -410,15 +414,67 @@ pub type TransactionError { /// If the function returns an `Error` or panics then the transaction is rolled /// back. pub fn transaction( - pool: Subject(Message), - callback: fn(Subject(Message)) -> Result(t, String), -) -> Result(t, TransactionError) + pool: Connection, + callback: fn(Connection) -> Result(t, String), +) -> Result(t, TransactionError) { + case pool { + SingleConnection(conn) -> { + transaction_layer(conn, callback) + } + Pool(name) -> { + // Check out a single connection from the pool + use #(ref, conn) <- result.try( + checkout(name) |> result.map_error(TransactionQueryError), + ) + + // Make a best attempt to check back in the connection, even if this + // process crashes + use <- exception.defer(fn() { checkin(ref, conn) }) + + transaction_layer(conn, callback) + } + } +} + +fn transaction_layer( + conn: SingleConnection, + callback: fn(Connection) -> Result(t, String), +) -> Result(t, TransactionError) { + let do = fn(conn, sql) { + run_query_extended(conn, sql) + |> result.map_error(TransactionQueryError) + } -@external(erlang, "pog_ffi", "transaction") -fn run_transaction( + // Start a transaction with the single connection + use _ <- result.try(do(conn, "begin")) + + // When the callback crashes we want to roll back the transaction + use <- exception.on_crash(fn() { + let assert Ok(_) = do(conn, "rollback") as "rollback exec failed" + }) + + case callback(SingleConnection(conn)) { + // The callback was OK, commit the transaction + Ok(t) -> { + use _ <- result.try(do(conn, "commit")) + Ok(t) + } + + Error(error) -> { + // The callback failed, roll-back the transaction + use _ <- result.try(do(conn, "rollback")) + Error(TransactionRolledBack(error)) + } + } +} + +@external(erlang, "pog_ffi", "checkout") +fn checkout( pool: Name(Message), - callback: fn(Subject(Message)) -> Result(t, String), -) -> Result(t, TransactionError) +) -> Result(#(Reference, SingleConnection), QueryError) + +@external(erlang, "pgo", "checkin") +fn checkin(ref: Reference, conn: SingleConnection) -> Dynamic pub fn nullable(inner_type: fn(a) -> Value, value: Option(a)) -> Value { case value { @@ -434,12 +490,18 @@ pub type Returned(t) { @external(erlang, "pog_ffi", "query") fn run_query( - a: Name(Message), + a: Connection, b: String, c: List(Value), timeout: Int, ) -> Result(#(Int, List(Dynamic)), QueryError) +@external(erlang, "pog_ffi", "query_extended") +fn run_query_extended( + a: SingleConnection, + b: String, +) -> Result(#(Int, List(Dynamic)), QueryError) + pub type QueryError { /// The query failed as a database constraint would have been violated by the /// change. @@ -509,13 +571,9 @@ pub fn timeout(query: Query(t1), timeout: Int) -> Query(t1) { /// pub fn execute( query query: Query(t), - on pool: Subject(Message), + on pool: Connection, ) -> Result(Returned(t), QueryError) { let parameters = list.reverse(query.parameters) - use pool <- result.try( - process.subject_name(pool) - |> result.replace_error(ConnectionUnavailable), - ) use #(count, rows) <- result.try(run_query( pool, query.sql, diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 2f60048..1516e99 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -1,6 +1,6 @@ -module(pog_ffi). --export([query/4, start/1, coerce/1, null/0, transaction/2]). +-export([query/4, query_extended/2, start/1, coerce/1, null/0, checkout/1]). -include_lib("pog/include/pog_Config.hrl"). -include_lib("pg_types/include/pg_types.hrl"). @@ -80,57 +80,29 @@ start(Config) -> end, pgo_pool:start_link(PoolName, Options2). -old_transaction(Pool, Callback) when is_atom(Pool) -> - F = fun() -> - case Callback(Pool) of - {ok, T} -> {ok, T}; - {error, Reason} -> error({pog_rollback_transaction, Reason}) - end +query(Pool, Sql, Arguments, Timeout) -> + Res = case Pool of + {single_connection, Conn} -> + pgo_handler:extended_query( + Conn, Sql, Arguments, #{queue_time => undefined} + ); + {pool, Name} -> + Options = #{ + pool => Name, + pool_options => [{timeout, Timeout}] + }, + pgo:query(Sql, Arguments, Options) end, - try - pgo:transaction(Pool, F, #{}) - catch - error:{pog_rollback_transaction, Reason} -> - {error, {transaction_rolled_back, Reason}} - end. - + case Res of + #{rows := Rows, num_rows := NumRows} -> + {ok, {NumRows, Rows}}; -transaction(Pool, Fun) when is_atom(Pool) andalso is_function(Fun, 1) -> - Exec = fun(Conn, Sql) -> - pgo_handler:extended_query(Conn, Sql, [], #{queue_time => undefined}) - end, - case pgo:checkout(Pool) of - {ok, Ref, Conn} -> - try - #{command := 'begin'} = Exec(Conn, "BEGIN"), - Result = Fun(), - case Exec(Conn, "COMMIT") of - #{command := commit} -> Result; - #{command := rollback} -> Result - end - catch - Type:Reason:Stacktrace -> - Exec(Conn, "ROLLBACK"), - erlang:raise(Type, Reason, Stacktrace) - after - pgo:checkin(Ref, Conn) - end; - {error, _} = Error -> - Error - end; -% TODO: remove -transaction(A, B) -> - erlang:display(A), - erlang:display(B), - erlang:raise(badarg). + {error, Error} -> + {error, convert_error(Error)} + end. -query(Pool, Sql, Arguments, Timeout) when is_atom(Pool) -> - Options = #{ - pool => Pool, - pool_options => [{timeout, Timeout}] - }, - Res = pgo:query(Sql, Arguments, Options), - case Res of +query_extended(Conn, Sql) -> + case pgo_handler:extended_query(Conn, Sql, [], #{queue_time => undefined}) of #{rows := Rows, num_rows := NumRows} -> {ok, {NumRows, Rows}}; @@ -138,6 +110,12 @@ query(Pool, Sql, Arguments, Timeout) when is_atom(Pool) -> {error, convert_error(Error)} end. +checkout(Name) when is_atom(Name) -> + case pgo:checkout(Name) of + {ok, Ref, Conn} -> {ok, {Ref, Conn}}; + {error, Error} -> {error, convert_error(Error)} + end. + convert_error(none_available) -> connection_unavailable; convert_error({pgo_protocol, {parameters, Expected, Got}}) -> diff --git a/test/pog_test.gleam b/test/pog_test.gleam index c8ec965..e13c7c8 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -15,7 +15,7 @@ fn disconnect(db: actor.Started(a)) -> Nil { process.send_exit(db.pid) } -fn start_default() -> actor.Started(process.Subject(pog.Message)) { +fn start_default() -> actor.Started(pog.Connection) { let assert Ok(started) = process.new_name("pog_test") |> default_config From ed8cd912ae817314dc026d505ef0c5fec033a764 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 17:02:51 +0100 Subject: [PATCH 14/21] Timestamps Author: Giacomo Cavalieri --- CHANGELOG.md | 7 +++++-- src/pog.gleam | 20 ++++++++++++-------- src/pog_ffi.erl | 2 ++ test/pog_test.gleam | 11 +++++++---- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cacb09c..6b9caa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,11 @@ - `connect` and `disconnect` have been removed in favour of `start` and `supervised`. - `url_config` and `default_config` now take a name parameter. - The `default_timeout` function has been removed. -- TODO: more flexible transactions. -- TODO: date functions changed. +- The callback given to the `transaction` function now receives the connection + as an argument. Queries made using this connection will be in the + transaction, while the pool can still be used to run queries outside the + transaction. +- The `gleam_time` package is now used for time types and functions. ## v3.3.0 - 2025-07-03 diff --git a/src/pog.gleam b/src/pog.gleam index 8b7732b..d12ef9a 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -18,6 +18,7 @@ import gleam/otp/supervision import gleam/result import gleam/string import gleam/time/calendar.{type Date, type TimeOfDay} +import gleam/time/timestamp.{type Timestamp} import gleam/uri.{Uri} /// The port that will be used when none is specified. @@ -371,8 +372,17 @@ pub fn array(converter: fn(a) -> Value, values: List(a)) -> Value { |> coerce_value } -pub fn calendar_datetime(date: Date, time: TimeOfDay) -> Value { - coerce_value(#(calendar_date(date), calendar_time_of_day(time))) +pub fn timestamp(timestamp: Timestamp) -> Value { + let #(seconds, nanoseconds) = + timestamp.to_unix_seconds_and_nanoseconds(timestamp) + coerce_value(seconds * 1_000_000 + nanoseconds / 1000) +} + +pub fn timestamp_decoder() -> decode.Decoder(Timestamp) { + use microseconds <- decode.map(decode.int) + let seconds = microseconds / 1_000_000 + let nanoseconds = { microseconds % 1_000_000 } * 1000 + timestamp.from_unix_seconds_and_nanoseconds(seconds, nanoseconds) } pub fn calendar_date(date: Date) -> Value { @@ -860,12 +870,6 @@ pub fn error_code_name(error_code: String) -> Result(String, Nil) { } } -pub fn calendar_datetime_decoder() -> decode.Decoder(#(Date, TimeOfDay)) { - use date <- decode.field(0, calendar_date_decoder()) - use time <- decode.field(1, calendar_time_of_day_decoder()) - decode.success(#(date, time)) -} - pub fn calendar_date_decoder() -> decode.Decoder(Date) { use year <- decode.field(0, decode.int) use month <- decode.field(1, decode.int) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 1516e99..11cc5c8 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -37,6 +37,8 @@ default_ssl_options(Host, Ssl) -> end. start(Config) -> + % Unfortunately this has to be supplied via global mutable state currently. + application:set_env(pg_types, timestamp_config, integer_system_time_microseconds), #config{ pool_name = PoolName, host = Host, diff --git a/test/pog_test.gleam b/test/pog_test.gleam index e13c7c8..728ec0e 100644 --- a/test/pog_test.gleam +++ b/test/pog_test.gleam @@ -4,6 +4,7 @@ import gleam/erlang/process import gleam/option.{None, Some} import gleam/otp/actor import gleam/time/calendar +import gleam/time/timestamp import gleeunit import pog @@ -157,7 +158,7 @@ pub fn selecting_rows_test() { use x1 <- decode.field(1, decode.string) use x2 <- decode.field(2, decode.bool) use x3 <- decode.field(3, decode.list(decode.string)) - use x4 <- decode.field(4, pog.calendar_datetime_decoder()) + use x4 <- decode.field(4, pog.timestamp_decoder()) use x5 <- decode.field(5, pog.calendar_date_decoder()) decode.success(#(x0, x1, x2, x3, x4, x5)) }) @@ -171,9 +172,10 @@ pub fn selecting_rows_test() { "neo", True, ["black"], - #( + timestamp.from_calendar( calendar.Date(2022, calendar.October, 10), calendar.TimeOfDay(11, 30, 30, 100_000_000), + calendar.utc_offset, ), calendar.Date(2020, calendar.March, 4), ), @@ -509,7 +511,7 @@ pub fn expected_maps_test() { use colors <- decode.field("colors", decode.list(decode.string)) use last_petted_at <- decode.field( "last_petted_at", - pog.calendar_datetime_decoder(), + pog.timestamp_decoder(), ) use birthday <- decode.field("birthday", pog.calendar_date_decoder()) decode.success(#(id, name, is_cute, colors, last_petted_at, birthday)) @@ -524,9 +526,10 @@ pub fn expected_maps_test() { "neo", True, ["black"], - #( + timestamp.from_calendar( calendar.Date(2022, calendar.October, 10), calendar.TimeOfDay(11, 30, 30, 0), + calendar.utc_offset, ), calendar.Date(2020, calendar.March, 4), ), From 54165e2f0fc2c8dfa58a6048230c8dc02dd180db Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 19:50:00 +0100 Subject: [PATCH 15/21] Error parameter --- CHANGELOG.md | 2 ++ src/pog.gleam | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b9caa3..2f0fe8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ transaction, while the pool can still be used to run queries outside the transaction. - The `gleam_time` package is now used for time types and functions. +- The `TransactionError` type is now parameterised with an error type rather + being specific to strings. ## v3.3.0 - 2025-07-03 diff --git a/src/pog.gleam b/src/pog.gleam index d12ef9a..2e42243 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -412,9 +412,9 @@ pub fn calendar_time_of_day(time: TimeOfDay) -> Value { @external(erlang, "pog_ffi", "coerce") fn coerce_value(a: anything) -> Value -pub type TransactionError { +pub type TransactionError(error) { TransactionQueryError(QueryError) - TransactionRolledBack(String) + TransactionRolledBack(error) } /// Runs a function within a PostgreSQL transaction. @@ -425,8 +425,8 @@ pub type TransactionError { /// back. pub fn transaction( pool: Connection, - callback: fn(Connection) -> Result(t, String), -) -> Result(t, TransactionError) { + callback: fn(Connection) -> Result(t, error), +) -> Result(t, TransactionError(error)) { case pool { SingleConnection(conn) -> { transaction_layer(conn, callback) @@ -448,8 +448,8 @@ pub fn transaction( fn transaction_layer( conn: SingleConnection, - callback: fn(Connection) -> Result(t, String), -) -> Result(t, TransactionError) { + callback: fn(Connection) -> Result(t, error), +) -> Result(t, TransactionError(error)) { let do = fn(conn, sql) { run_query_extended(conn, sql) |> result.map_error(TransactionQueryError) From f6ed20657beddb780004d723007270ae01760548 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 19:53:37 +0100 Subject: [PATCH 16/21] named_connection --- src/pog.gleam | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/pog.gleam b/src/pog.gleam index 2e42243..519dfa4 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -33,6 +33,15 @@ type SingleConnection pub type Message +/// Create a reference to a pool using the pool's name. +/// +/// If no pool has been started using this name then queries using this +/// connection will fail. +/// +pub fn named_connection(name: Name(Message)) -> Connection { + Pool(name) +} + /// The configuration for a pool of connections. pub type Config { Config( @@ -336,6 +345,10 @@ fn start_tree(config: Config) -> Result(Pid, dynamic.Dynamic) /// Start a database connection pool by adding it to your supervision tree. /// +/// Use the `named_connection` function to create a connection to query this +/// pool with if your supervisor does not pass back the return value of +/// creating the pool. +/// /// The pool is started in a new process and will asynchronously connect to the /// PostgreSQL instance specified in the config. If the configuration is invalid /// or it cannot connect for another reason it will continue to attempt to From 7a97617ff193ae09e0d44d735fad2f249dc4cd4a Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 19:56:56 +0100 Subject: [PATCH 17/21] Default queue time --- src/pog_ffi.erl | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pog_ffi.erl b/src/pog_ffi.erl index 11cc5c8..9c486a2 100644 --- a/src/pog_ffi.erl +++ b/src/pog_ffi.erl @@ -85,9 +85,7 @@ start(Config) -> query(Pool, Sql, Arguments, Timeout) -> Res = case Pool of {single_connection, Conn} -> - pgo_handler:extended_query( - Conn, Sql, Arguments, #{queue_time => undefined} - ); + pgo_handler:extended_query(Conn, Sql, Arguments, #{}); {pool, Name} -> Options = #{ pool => Name, From 6d1791f6ca4e22e2842fc51b1f18ee445385afe5 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 20:02:10 +0100 Subject: [PATCH 18/21] Name! --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index afc4ec8..fcf6b53 100644 --- a/README.md +++ b/README.md @@ -91,12 +91,13 @@ connection URI from the environment. ```gleam import envoy +import gleam/erlang/process.{type Name} import pog /// Read the DATABASE_URL environment variable. /// Generate the pog.Config from that database URL. /// Finally, connect to database. -pub fn read_connection_uri(name) -> Result(pog.Config, Nil) { +pub fn read_connection_uri(name: Name(pog.Message)) -> Result(pog.Config, Nil) { use database_url <- result.try(envoy.get("DATABASE_URL")) pog.url_config(name, database_url) } From c682ddf54193337875d67aa4cacb8782c252e66f Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 20:16:26 +0100 Subject: [PATCH 19/21] Apply suggestions from code review Co-authored-by: Giacomo Cavalieri --- src/pog.gleam | 37 ++++++------------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/src/pog.gleam b/src/pog.gleam index 519dfa4..84d5984 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -399,20 +399,7 @@ pub fn timestamp_decoder() -> decode.Decoder(Timestamp) { } pub fn calendar_date(date: Date) -> Value { - let month = case date.month { - calendar.January -> 1 - calendar.February -> 2 - calendar.March -> 3 - calendar.April -> 4 - calendar.May -> 5 - calendar.June -> 6 - calendar.July -> 7 - calendar.August -> 8 - calendar.September -> 9 - calendar.October -> 10 - calendar.November -> 11 - calendar.December -> 12 - } + let month = calendar.month_to_int(date.month) coerce_value(#(date.year, month, date.day)) } @@ -521,8 +508,8 @@ fn run_query( @external(erlang, "pog_ffi", "query_extended") fn run_query_extended( - a: SingleConnection, - b: String, + connection: SingleConnection, + query: String, ) -> Result(#(Int, List(Dynamic)), QueryError) pub type QueryError { @@ -887,21 +874,9 @@ pub fn calendar_date_decoder() -> decode.Decoder(Date) { use year <- decode.field(0, decode.int) use month <- decode.field(1, decode.int) use day <- decode.field(2, decode.int) - let date = fn(month) { decode.success(calendar.Date(year:, month:, day:)) } - case month { - 1 -> date(calendar.January) - 2 -> date(calendar.February) - 3 -> date(calendar.March) - 4 -> date(calendar.April) - 5 -> date(calendar.May) - 6 -> date(calendar.June) - 7 -> date(calendar.July) - 8 -> date(calendar.August) - 9 -> date(calendar.September) - 10 -> date(calendar.October) - 11 -> date(calendar.November) - 12 -> date(calendar.December) - _ -> decode.failure(calendar.Date(0, calendar.January, 1), "Calendar date") + case calendar.month_from_int(month) { + Ok(month) -> decode.success(calendar.Date(year:, month:, day:)) + Error(_) -> decode.failure(calendar.Date(0, calendar.January, 1), "Calendar date") } } From 881cf30011490f64caf82b8487f7f52e2dba9646 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 20:23:39 +0100 Subject: [PATCH 20/21] Format --- src/pog.gleam | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pog.gleam b/src/pog.gleam index 84d5984..11f35fe 100644 --- a/src/pog.gleam +++ b/src/pog.gleam @@ -876,7 +876,8 @@ pub fn calendar_date_decoder() -> decode.Decoder(Date) { use day <- decode.field(2, decode.int) case calendar.month_from_int(month) { Ok(month) -> decode.success(calendar.Date(year:, month:, day:)) - Error(_) -> decode.failure(calendar.Date(0, calendar.January, 1), "Calendar date") + Error(_) -> + decode.failure(calendar.Date(0, calendar.January, 1), "Calendar date") } } From 0b299f8184a0f4c5603cf78dc3f35ec410a4ef58 Mon Sep 17 00:00:00 2001 From: Louis Pilfold Date: Sun, 6 Jul 2025 23:20:21 +0100 Subject: [PATCH 21/21] Correct docs --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fcf6b53..75d1714 100644 --- a/README.md +++ b/README.md @@ -35,15 +35,14 @@ pub fn start_application_supervisor(pool_name: process.Name(pog.Message)) { } ``` -Then in your application you can use a subject created from that name to make -queries: +Then in your application you can use a connection created from that name with +the `pog.named_connection` to make queries: ```gleam import pog import gleam/dynamic/decode -import gleam/erlang/process.{type Subject} -pub fn run(db: Subject(pog.Message)) { +pub fn run(db: pog.Connection) { // An SQL statement to run. It takes one int as a parameter let sql_query = " select @@ -94,9 +93,8 @@ import envoy import gleam/erlang/process.{type Name} import pog -/// Read the DATABASE_URL environment variable. -/// Generate the pog.Config from that database URL. -/// Finally, connect to database. +/// Read the DATABASE_URL environment variable and then +/// build the pog.Config from that database URL. pub fn read_connection_uri(name: Name(pog.Message)) -> Result(pog.Config, Nil) { use database_url <- result.try(envoy.get("DATABASE_URL")) pog.url_config(name, database_url)