From f0f2d15665f228dc855bf79d2603e3f7b41399c5 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Thu, 17 Sep 2026 16:29:00 -0600 Subject: [PATCH] Support the libpq options connection parameter Pass a caller-supplied `options` value through to the server in the startup packet, as libpq does, so session defaults such as the schema search path can be set from a DSN or URI (`options='-c search_path=x'`). The value also defaults from PGOPTIONS, applies before the first query, and is re-sent on automatic reconnect because it lives in the startup packet rather than in a SET that would need replaying. An empty value is not sent. NUL bytes are rejected like the other startup strings. The driver's own client_encoding startup parameter is applied by the server after `options`, so a conflicting switch cannot break decoding; the reported encoding is still verified. Co-Authored-By: Claude Fable 5.1 --- Project.toml | 2 +- README.md | 3 ++- docs/src/index.md | 3 ++- docs/src/manual.md | 11 +++++++++ docs/src/support.md | 5 ++++ src/Postgres.jl | 25 +++++++++++++------- src/api/API.jl | 13 +++++++--- src/connection_string.jl | 14 ++++++----- test/runtests.jl | 51 +++++++++++++++++++++++++++++++++++++++- 9 files changed, 105 insertions(+), 22 deletions(-) diff --git a/Project.toml b/Project.toml index 12fa361..936aacd 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "Postgres" uuid = "8f23287e-300e-4f50-bc2b-9f1dfe95da84" -version = "2.0.0" +version = "2.1.0" [deps] ConcurrentUtilities = "f0e56b4a-5159-44fe-b623-3e5288b988bb" diff --git a/README.md b/README.md index bce31e9..95ea57a 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,12 @@ Connection options support: - libpq-style keyword strings such as `host=127.0.0.1 port=5432 user=postgres dbname=postgres`. - PostgreSQL URIs such as `postgresql://postgres:postgres@127.0.0.1:5432/postgres`. -- Environment defaults: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, and TLS-related `PGSSL*` variables. +- Environment defaults: `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, `PGOPTIONS`, and TLS-related `PGSSL*` variables. - `sslmode` values: `disable`, `prefer` (the default), `require`, `verify-full`. Only `verify-full` verifies the server's certificate; `require` encrypts without authenticating the server, and the default `prefer` falls back to an unencrypted connection if the server declines TLS. Use `verify-full` with `sslrootcert` when the connection needs to be authenticated. - TLS files: `sslrootcert`, `sslcert`, `sslkey`, and `sslcapath` (`sslcapath` is a fallback CA bundle or directory, used only when `sslrootcert` is unset and ignored otherwise). `sslservername` overrides the TLS server name when connecting to a pre-resolved address; under `verify-full` it is also the name the certificate is verified against, so it must name the server you intend to authenticate. - `connect_timeout` (seconds) and `statement_timeout` (milliseconds). - `application_name` and `statement_cache_maxsize`. +- `options`: server command-line options applied when the session starts, as in libpq (`PGOPTIONS`). For example `options='-c search_path=myschema'` sets the default schema. The value is sent in the startup packet, so it also applies after an automatic reconnect. See the [support policy](https://JuliaDatabases.github.io/Postgres.jl/dev/support/) for tested Julia and PostgreSQL versions, TLS limits, and transaction-pooler diff --git a/docs/src/index.md b/docs/src/index.md index a18438a..1aac8b5 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -17,11 +17,12 @@ Pkg.add("Postgres") Postgres.jl accepts DSN strings or PostgreSQL URIs and supports: - libpq-style keyword strings such as `host=127.0.0.1 port=5432 user=postgres dbname=postgres`. -- Environment defaults from `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, and TLS-related `PGSSL*` variables. +- Environment defaults from `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`, `PGAPPNAME`, `PGCONNECT_TIMEOUT`, `PGOPTIONS`, and TLS-related `PGSSL*` variables. - `sslmode` values: `disable`, `prefer` (the default), `require`, `verify-full`. Only `verify-full` verifies the server's certificate; `require` encrypts without authenticating the server, and the default `prefer` falls back to an unencrypted connection if the server declines TLS. Use `verify-full` with `sslrootcert` when the connection needs to be authenticated. - TLS files: `sslrootcert`, `sslcert`, `sslkey`, and `sslcapath` (`sslcapath` is a fallback CA bundle or directory, used only when `sslrootcert` is unset and ignored otherwise). `sslservername` overrides the TLS server name when connecting to a pre-resolved address; under `verify-full` it is also the name the certificate is verified against, so it must name the server you intend to authenticate. - `connect_timeout` (seconds), `statement_timeout` (milliseconds). - `application_name` and `statement_cache_maxsize`. +- `options`: server command-line options applied when the session starts, as in libpq (`PGOPTIONS`). For example `options='-c search_path=myschema'` sets the default schema. The value is sent in the startup packet, so it also applies after an automatic reconnect. Options that request unsupported security or server-selection behavior are rejected. They are not silently ignored. diff --git a/docs/src/manual.md b/docs/src/manual.md index c97c495..6f8abbd 100644 --- a/docs/src/manual.md +++ b/docs/src/manual.md @@ -26,6 +26,17 @@ conn = DBInterface.connect( ) ``` +Session defaults such as the schema search path are set with libpq's `options` +keyword (or the `PGOPTIONS` environment variable). The value is sent to the +server in the startup packet, so it also applies after an automatic reconnect. + +```julia +conn = DBInterface.connect( + Postgres.Connection, + "host=127.0.0.1 user=postgres password=postgres dbname=postgres options='-c search_path=myschema'", +) +``` + ## Querying `DBInterface.execute` returns a Tables.jl-compatible result. For small result sets, `Tables.rowtable` is a convenient way to materialize rows. diff --git a/docs/src/support.md b/docs/src/support.md index 661c020..5907fb3 100644 --- a/docs/src/support.md +++ b/docs/src/support.md @@ -40,6 +40,11 @@ transaction mode. Configure PostgreSQL or the pooler defaults with formats for correct decoding. Use direct connections or session pooling when the application needs session state. +PgBouncer 1.20 and later accept the `options` startup keyword but reject +parameters inside it that they do not track. `search_path` is tracked by +default only when the server reports it, which PostgreSQL 18 does; for older +servers add it to PgBouncer's `track_extra_parameters`. + `set_statement_timeout!` is rejected while a transaction is open. This keeps the durable reconnect setting consistent with PostgreSQL's transactional `SET` semantics. diff --git a/src/Postgres.jl b/src/Postgres.jl index 1dff28e..8b0cda2 100644 --- a/src/Postgres.jl +++ b/src/Postgres.jl @@ -57,6 +57,10 @@ Supported keyword arguments. All are also available as DSN/URI options except `style`, which is Julia-only: - `dbname`, `port`, `application_name` +- `options`: server command-line options applied when the session starts, as + libpq's `options` (for example `"-c search_path=myschema"` to set the default + schema). Sent in the startup packet, so it also applies after an automatic + reconnect. Defaults to `PGOPTIONS` when built from a DSN. - `connect_timeout` (seconds), `statement_timeout` (milliseconds) - `sslmode` (`"disable"`, `"prefer"` (default), `"require"`, `"verify-full"`), `sslrootcert`, `sslcert`, `sslkey`, `sslcapath`, and `sslservername`. @@ -98,6 +102,7 @@ mutable struct Connection{T, S <: API.AbstractPostgresStyle} <: DBInterface.Conn const dbname::String const port::Int const application_name::Union{String, Nothing} + const options::Union{String, Nothing} const connect_timeout::Union{Int, Nothing} const sslmode::Union{String, Nothing} const sslrootcert::Union{String, Nothing} @@ -134,7 +139,7 @@ mutable struct Connection{T, S <: API.AbstractPostgresStyle} <: DBInterface.Conn # commit/rollback must not COMMIT/ROLLBACK the caller's work owns_base_transaction::Bool - function Connection(; host::AbstractString="", user::AbstractString="", password::Union{AbstractString, Nothing}=nothing, dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) + function Connection(; host::AbstractString="", user::AbstractString="", password::Union{AbstractString, Nothing}=nothing, dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, options::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) numeric_overflow in (:warn, :error) || throw(ArgumentError("numeric_overflow must be :warn or :error")) host = String(host) user = String(user) @@ -142,6 +147,7 @@ mutable struct Connection{T, S <: API.AbstractPostgresStyle} <: DBInterface.Conn port = Int(port) password = password === nothing ? nothing : String(password) app_name = application_name === nothing ? nothing : String(application_name) + options_val = options === nothing ? nothing : String(options) timeout = connect_timeout === nothing ? nothing : Int(connect_timeout) sslmode_val = sslmode === nothing ? nothing : String(sslmode) sslrootcert_val = sslrootcert === nothing ? nothing : String(sslrootcert) @@ -155,14 +161,15 @@ mutable struct Connection{T, S <: API.AbstractPostgresStyle} <: DBInterface.Conn occursin('\0', dbname) && _reject_nul("dbname") password !== nothing && occursin('\0', password) && _reject_nul("password") app_name !== nothing && occursin('\0', app_name) && _reject_nul("application_name") + options_val !== nothing && occursin('\0', options_val) && _reject_nul("options") sslservername_val !== nothing && occursin('\0', sslservername_val) && _reject_nul("sslservername") xor(sslcert_val === nothing, sslkey_val === nothing) && throw(PostgresInterfaceError("sslcert and sslkey must be provided together")) maxsize = max(0, Int(statement_cache_maxsize)) - socket, pid, skey, server_params = API.connect(host, port, dbname, user, password, debug, app_name, timeout, sslmode_val, sslrootcert_val, sslcert_val, sslkey_val, sslcapath_val, sslservername_val, statement_timeout_val) + socket, pid, skey, server_params = API.connect(host, port, dbname, user, password, debug, app_name, options_val, timeout, sslmode_val, sslrootcert_val, sslcert_val, sslkey_val, sslcapath_val, sslservername_val, statement_timeout_val) registry = Dict(API.DEFAULT_TYPE_REGISTRY) registry[1700] = API.TypeInfo(API.NumericValue, (val, registry) -> API.parse_numeric(val, numeric_overflow)) - return new{Statement{typeof(style)}, typeof(style)}(ReentrantLock(), socket, host, user, password, dbname, port, app_name, timeout, sslmode_val, sslrootcert_val, sslcert_val, sslkey_val, sslcapath_val, sslservername_val, statement_timeout_val, pid, skey, Dict{String, Statement{typeof(style)}}(), maxsize, 0, server_params, registry, false, reconnect, debug, style, false, 0, String[], 1, false, true) + return new{Statement{typeof(style)}, typeof(style)}(ReentrantLock(), socket, host, user, password, dbname, port, app_name, options_val, timeout, sslmode_val, sslrootcert_val, sslcert_val, sslkey_val, sslcapath_val, sslservername_val, statement_timeout_val, pid, skey, Dict{String, Statement{typeof(style)}}(), maxsize, 0, server_params, registry, false, reconnect, debug, style, false, 0, String[], 1, false, true) end end @@ -772,7 +779,7 @@ function checkconn(conn::Connection) # connection is closed, but not explicitly, reconnect conn.in_transaction && throw(PostgresInterfaceError("postgres connection has been closed or disconnected; reconnect disabled during transaction")) conn.reconnect || throw(PostgresInterfaceError("postgres connection has been closed or disconnected; reconnect disabled")) - conn.socket, conn.pid, conn.skey, server_params = API.connect(conn.host, conn.port, conn.dbname, conn.user, conn.password, conn.debug, conn.application_name, conn.connect_timeout, conn.sslmode, conn.sslrootcert, conn.sslcert, conn.sslkey, conn.sslcapath, conn.sslservername, conn.statement_timeout) + conn.socket, conn.pid, conn.skey, server_params = API.connect(conn.host, conn.port, conn.dbname, conn.user, conn.password, conn.debug, conn.application_name, conn.options, conn.connect_timeout, conn.sslmode, conn.sslrootcert, conn.sslcert, conn.sslkey, conn.sslcapath, conn.sslservername, conn.statement_timeout) empty!(conn.statements) conn.in_transaction = false conn.transaction_depth = 0 @@ -788,8 +795,8 @@ function checkconn(conn::Connection) return end -function DBInterface.connect(::Type{Connection}, host::AbstractString, user::AbstractString, passwd::Union{AbstractString, Nothing}; dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) - Connection(host=host, user=user, password=passwd, dbname=dbname, port=port, debug=debug, reconnect=reconnect, application_name=application_name, connect_timeout=connect_timeout, sslmode=sslmode, sslrootcert=sslrootcert, sslcert=sslcert, sslkey=sslkey, sslcapath=sslcapath, sslservername=sslservername, statement_timeout=statement_timeout, statement_cache_maxsize=statement_cache_maxsize, numeric_overflow=numeric_overflow, style=style) +function DBInterface.connect(::Type{Connection}, host::AbstractString, user::AbstractString, passwd::Union{AbstractString, Nothing}; dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, options::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) + Connection(host=host, user=user, password=passwd, dbname=dbname, port=port, debug=debug, reconnect=reconnect, application_name=application_name, options=options, connect_timeout=connect_timeout, sslmode=sslmode, sslrootcert=sslrootcert, sslcert=sslcert, sslkey=sslkey, sslcapath=sslcapath, sslservername=sslservername, statement_timeout=statement_timeout, statement_cache_maxsize=statement_cache_maxsize, numeric_overflow=numeric_overflow, style=style) end function DBInterface.connect(::Type{Connection}, dsn::String; debug::Union{Bool, Nothing}=nothing, reconnect::Union{Bool, Nothing}=nothing, statement_cache_maxsize::Union{Integer, Nothing}=nothing, numeric_overflow::Union{Symbol, Nothing}=nothing, style::API.AbstractPostgresStyle=PostgresStyle()) @@ -798,7 +805,7 @@ end function DBInterface.connect(::Type{Connection}, params::ConnectionParams; debug::Union{Bool, Nothing}=nothing, reconnect::Union{Bool, Nothing}=nothing, statement_cache_maxsize::Union{Integer, Nothing}=nothing, numeric_overflow::Union{Symbol, Nothing}=nothing, style::API.AbstractPostgresStyle=PostgresStyle()) actual_maxsize = isnothing(statement_cache_maxsize) ? params.statement_cache_maxsize : statement_cache_maxsize - Connection(host=params.host, user=params.user, password=params.password, dbname=params.dbname, port=params.port, debug=something(debug, params.debug), reconnect=something(reconnect, params.reconnect), application_name=params.application_name, connect_timeout=params.connect_timeout, sslmode=params.sslmode, sslrootcert=params.sslrootcert, sslcert=params.sslcert, sslkey=params.sslkey, sslcapath=params.sslcapath, sslservername=params.sslservername, statement_timeout=params.statement_timeout, statement_cache_maxsize=actual_maxsize, numeric_overflow=something(numeric_overflow, params.numeric_overflow), style=style) + Connection(host=params.host, user=params.user, password=params.password, dbname=params.dbname, port=params.port, debug=something(debug, params.debug), reconnect=something(reconnect, params.reconnect), application_name=params.application_name, options=params.options, connect_timeout=params.connect_timeout, sslmode=params.sslmode, sslrootcert=params.sslrootcert, sslcert=params.sslcert, sslkey=params.sslkey, sslcapath=params.sslcapath, sslservername=params.sslservername, statement_timeout=params.statement_timeout, statement_cache_maxsize=actual_maxsize, numeric_overflow=something(numeric_overflow, params.numeric_overflow), style=style) end function DBInterface.connect(f::Function, ::Type{Connection}, args...; kwargs...) @@ -852,8 +859,8 @@ end Base.isopen(pool::ConnectionPool) = !pool.closed[] -function ConnectionPool(::Type{Connection}, host::AbstractString, user::AbstractString, passwd::Union{AbstractString, Nothing}; dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, limit::Integer=10, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) - connector = () -> DBInterface.connect(Connection, host, user, passwd; dbname=dbname, port=port, debug=debug, reconnect=reconnect, application_name=application_name, connect_timeout=connect_timeout, sslmode=sslmode, sslrootcert=sslrootcert, sslcert=sslcert, sslkey=sslkey, sslcapath=sslcapath, sslservername=sslservername, statement_timeout=statement_timeout, statement_cache_maxsize=statement_cache_maxsize, numeric_overflow=numeric_overflow, style=style) +function ConnectionPool(::Type{Connection}, host::AbstractString, user::AbstractString, passwd::Union{AbstractString, Nothing}; dbname::AbstractString="", port::Integer=5432, debug::Bool=false, reconnect::Bool=false, application_name::Union{AbstractString, Nothing}=nothing, options::Union{AbstractString, Nothing}=nothing, connect_timeout::Union{Integer, Nothing}=nothing, sslmode::Union{AbstractString, Nothing}=nothing, sslrootcert::Union{AbstractString, Nothing}=nothing, sslcert::Union{AbstractString, Nothing}=nothing, sslkey::Union{AbstractString, Nothing}=nothing, sslcapath::Union{AbstractString, Nothing}=nothing, sslservername::Union{AbstractString, Nothing}=nothing, statement_timeout::Union{Integer, Nothing}=nothing, statement_cache_maxsize::Integer=100, limit::Integer=10, numeric_overflow::Symbol=:warn, style::API.AbstractPostgresStyle=PostgresStyle()) + connector = () -> DBInterface.connect(Connection, host, user, passwd; dbname=dbname, port=port, debug=debug, reconnect=reconnect, application_name=application_name, options=options, connect_timeout=connect_timeout, sslmode=sslmode, sslrootcert=sslrootcert, sslcert=sslcert, sslkey=sslkey, sslcapath=sslcapath, sslservername=sslservername, statement_timeout=statement_timeout, statement_cache_maxsize=statement_cache_maxsize, numeric_overflow=numeric_overflow, style=style) return ConnectionPool(connector; limit=limit) end diff --git a/src/api/API.jl b/src/api/API.jl index c9b00ca..89e5718 100644 --- a/src/api/API.jl +++ b/src/api/API.jl @@ -299,14 +299,19 @@ function writestartupmessage( user::String, dbname::String, application_name::Union{Nothing, String}, + options::Union{Nothing, String}, statement_timeout::Union{Nothing, Int}, )::Nothing # statement_timeout is applied with a SET after connect rather than through # the startup `options` parameter: poolers (pgbouncer) reject unknown # startup options outright, so sending it here fails the whole connection. + # A caller-supplied `options` value is different: it is passed through as + # given, like libpq does, and an empty value is not sent at all. + send_options = options !== nothing && !isempty(options) len = 8 + msgsizeof(("user", user)) + msgsizeof(("database", dbname)) + msgsizeof(("client_encoding", "UTF8")) + 1 application_name !== nothing && (len += msgsizeof(("application_name", application_name))) + send_options && (len += msgsizeof(("options", options))) debug && @info "sending startup message" buf = IOBuffer(Vector{UInt8}(undef, len); write=true) write(buf, hton(Int32(len))) @@ -315,6 +320,7 @@ function writestartupmessage( _write_startup_param(buf, "database", dbname) _write_startup_param(buf, "client_encoding", "UTF8") application_name !== nothing && _write_startup_param(buf, "application_name", application_name) + send_options && _write_startup_param(buf, "options", options) write(buf, UInt8(0)) write(socket, take!(buf)) flush(socket) @@ -708,13 +714,14 @@ end # sslservername: TLS SNI override for when `host` is a pre-resolved address — # SNI-routed servers (e.g. Neon) need the hostname on the TLS handshake even # when the TCP dial goes to an IP. -function connect(host::String, port::Integer, dbname::String, user::String, @nospecialize(password::Union{String, Nothing}), debug::Bool, @nospecialize(application_name::Union{String, Nothing}), @nospecialize(connect_timeout::Union{Int, Nothing}), @nospecialize(sslmode::Union{String, Nothing}), @nospecialize(sslrootcert::Union{String, Nothing}), @nospecialize(sslcert::Union{String, Nothing}), @nospecialize(sslkey::Union{String, Nothing}), @nospecialize(sslcapath::Union{String, Nothing}), @nospecialize(sslservername::Union{String, Nothing}), @nospecialize(statement_timeout::Union{Int, Nothing})) +function connect(host::String, port::Integer, dbname::String, user::String, @nospecialize(password::Union{String, Nothing}), debug::Bool, @nospecialize(application_name::Union{String, Nothing}), @nospecialize(options::Union{String, Nothing}), @nospecialize(connect_timeout::Union{Int, Nothing}), @nospecialize(sslmode::Union{String, Nothing}), @nospecialize(sslrootcert::Union{String, Nothing}), @nospecialize(sslcert::Union{String, Nothing}), @nospecialize(sslkey::Union{String, Nothing}), @nospecialize(sslcapath::Union{String, Nothing}), @nospecialize(sslservername::Union{String, Nothing}), @nospecialize(statement_timeout::Union{Int, Nothing})) # re-assert the @nospecialize'd params to their declared unions: the asserts give # inference the (static) union types without re-introducing per-argument # specialization, so the kwarg NamedTuples below have static types instead of # runtime apply_type — which `juliac --trim` can't resolve password_v = password::Union{String, Nothing} application_name_v = application_name::Union{String, Nothing} + options_v = options::Union{String, Nothing} connect_timeout_v = connect_timeout::Union{Int, Nothing} sslmode_v = sslmode::Union{String, Nothing} sslrootcert_v = sslrootcert::Union{String, Nothing} @@ -756,9 +763,9 @@ function connect(host::String, port::Integer, dbname::String, user::String, @nos end # socket-union isa split (post-TLS-upgrade φ) so the call resolves under --trim if socket isa Reseau.TCP.Conn - writestartupmessage(socket::Reseau.TCP.Conn, debug, user, dbname, application_name_v, statement_timeout_v) + writestartupmessage(socket::Reseau.TCP.Conn, debug, user, dbname, application_name_v, options_v, statement_timeout_v) else - writestartupmessage(socket::Reseau.TLS.Conn, debug, user, dbname, application_name_v, statement_timeout_v) + writestartupmessage(socket::Reseau.TLS.Conn, debug, user, dbname, application_name_v, options_v, statement_timeout_v) end # read initial response mt, len = readheader(socket, debug, MAX_PREAUTH_MESSAGE_LEN) diff --git a/src/connection_string.jl b/src/connection_string.jl index 19c8149..08998ae 100644 --- a/src/connection_string.jl +++ b/src/connection_string.jl @@ -12,7 +12,7 @@ Structured connection options, an alternative to DSN strings: conn = DBInterface.connect(Postgres.Connection, params) Also produced by `Postgres.parse_dsn`. Supported keyword -arguments mirror the connection keywords: `application_name`, +arguments mirror the connection keywords: `application_name`, `options`, `connect_timeout`, `sslmode`, `sslrootcert`, `sslcert`, `sslkey`, `sslcapath`, `sslservername`, `statement_timeout`, `statement_cache_maxsize`, `debug`, and `reconnect`. `numeric_overflow` accepts `:warn` (return out-of-range numeric @@ -26,6 +26,7 @@ struct ConnectionParams password::Union{String, Nothing} dbname::String application_name::Union{String, Nothing} + options::Union{String, Nothing} connect_timeout::Union{Int, Nothing} sslmode::Union{String, Nothing} sslrootcert::Union{String, Nothing} @@ -40,9 +41,9 @@ struct ConnectionParams numeric_overflow::Symbol end -function ConnectionParams(; host::String="localhost", port::Int=5432, user::String="", password::Union{String, Nothing}=nothing, dbname::String="", application_name::Union{String, Nothing}=nothing, connect_timeout::Union{Int, Nothing}=nothing, sslmode::Union{String, Nothing}=nothing, sslrootcert::Union{String, Nothing}=nothing, sslcert::Union{String, Nothing}=nothing, sslkey::Union{String, Nothing}=nothing, sslcapath::Union{String, Nothing}=nothing, sslservername::Union{String, Nothing}=nothing, statement_timeout::Union{Int, Nothing}=nothing, statement_cache_maxsize::Int=100, debug::Bool=false, reconnect::Bool=false, numeric_overflow::Symbol=:warn) +function ConnectionParams(; host::String="localhost", port::Int=5432, user::String="", password::Union{String, Nothing}=nothing, dbname::String="", application_name::Union{String, Nothing}=nothing, options::Union{String, Nothing}=nothing, connect_timeout::Union{Int, Nothing}=nothing, sslmode::Union{String, Nothing}=nothing, sslrootcert::Union{String, Nothing}=nothing, sslcert::Union{String, Nothing}=nothing, sslkey::Union{String, Nothing}=nothing, sslcapath::Union{String, Nothing}=nothing, sslservername::Union{String, Nothing}=nothing, statement_timeout::Union{Int, Nothing}=nothing, statement_cache_maxsize::Int=100, debug::Bool=false, reconnect::Bool=false, numeric_overflow::Symbol=:warn) numeric_overflow in (:warn, :error) || throw(ArgumentError("numeric_overflow must be :warn or :error")) - return ConnectionParams(host, port, user, password, dbname, application_name, connect_timeout, sslmode, sslrootcert, sslcert, sslkey, sslcapath, sslservername, statement_timeout, statement_cache_maxsize, debug, reconnect, numeric_overflow) + return ConnectionParams(host, port, user, password, dbname, application_name, options, connect_timeout, sslmode, sslrootcert, sslcert, sslkey, sslcapath, sslservername, statement_timeout, statement_cache_maxsize, debug, reconnect, numeric_overflow) end function Base.show(io::IO, params::ConnectionParams) @@ -78,6 +79,7 @@ function apply_env_defaults!(values::Dict{String, String}) "dbname" => "PGDATABASE", "password" => "PGPASSWORD", "application_name" => "PGAPPNAME", + "options" => "PGOPTIONS", "connect_timeout" => "PGCONNECT_TIMEOUT", "sslmode" => "PGSSLMODE", "sslrootcert" => "PGSSLROOTCERT", @@ -93,7 +95,7 @@ end const KNOWN_PARAMS = Set([ "host", "port", "user", "password", "dbname", "application_name", - "connect_timeout", "sslmode", "sslrootcert", "sslcert", "sslkey", + "options", "connect_timeout", "sslmode", "sslrootcert", "sslcert", "sslkey", "sslcapath", "sslservername", "statement_timeout", "statement_cache_maxsize", "debug", "reconnect", "numeric_overflow", ]) @@ -103,7 +105,7 @@ const KNOWN_PARAMS = Set([ # the connection URI they hand users, and failing on a DSN that names a real # libpq option would be worse than not honoring it. const IGNORED_PARAMS = Set([ - "channel_binding", "target_session_attrs", "options", "gssencmode", + "channel_binding", "target_session_attrs", "gssencmode", "gsslib", "krbsrvname", "sslnegotiation", "sslcompression", "sslcrl", "sslcrldir", "sslpassword", "requiressl", "requirepeer", "hostaddr", "client_encoding", "passfile", "service", "fallback_application_name", @@ -128,7 +130,6 @@ end const SECURITY_SENSITIVE_IGNORED = Dict( "channel_binding" => ("", "prefer", "disable"), "target_session_attrs" => ("", "any"), - "options" => ("",), "gssencmode" => ("", "prefer", "disable"), "sslnegotiation" => ("", "postgres"), "sslcompression" => ("", "0"), @@ -182,6 +183,7 @@ function params_from_values(values::Dict{String, String}) password=get(merged, "password", nothing), dbname=dbname, application_name=get(merged, "application_name", nothing), + options=get(merged, "options", nothing), connect_timeout=parse_optional_int(get(merged, "connect_timeout", nothing), "connect_timeout"), # deliberately NOT empty-tolerant, matching libpq: an unexpanded # ${PGSSLMODE} that was meant to be verify-full must fail loudly diff --git a/test/runtests.jl b/test/runtests.jl index 725b007..c641c8b 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -594,7 +594,17 @@ include("decimals.jl") # routing guarantee to the caller. @test_throws ArgumentError Postgres.parse_dsn("postgresql://u:p@h/db?sslmode=require&channel_binding=require") @test_throws ArgumentError Postgres.parse_dsn("postgresql://u@h/db?target_session_attrs=read-write") - @test_throws ArgumentError Postgres.parse_dsn("host=h options=-csearch_path=x") + # libpq's `options` (server command-line switches) passes through to the + # startup packet; quoting or URI-escaping carries spaces in the value + @test Postgres.parse_dsn("host=h options=-csearch_path=x").options == "-csearch_path=x" + @test Postgres.parse_dsn("host=h options='-c search_path=x -c geqo=off'").options == "-c search_path=x -c geqo=off" + @test Postgres.parse_dsn("postgresql://u@h/db?options=-c%20search_path%3Dx").options == "-c search_path=x" + @test Postgres.parse_dsn("host=h").options === nothing + withenv("PGOPTIONS" => "-c search_path=envschema") do + @test Postgres.parse_dsn("host=h").options == "-c search_path=envschema" + @test Postgres.parse_dsn("host=h options=-cgeqo=off").options == "-cgeqo=off" + end + @test Postgres.ConnectionParams(host="h", options="-c geqo=off").options == "-c geqo=off" @test_throws ArgumentError Postgres.parse_dsn("host=h sslcrl=/tmp/crl.pem") @test_throws ArgumentError Postgres.parse_dsn("host=h requiressl=1") @test_throws ArgumentError Postgres.parse_dsn("host=h gssencmode=require") @@ -756,6 +766,7 @@ include("decimals.jl") @test_throws Postgres.PostgresInterfaceError Postgres.escape_identifier("a\0b") @test_throws Postgres.PostgresInterfaceError Postgres.escape_literal("a\0b") @test_throws Postgres.PostgresInterfaceError Postgres.Connection(host="127.0.0.1", port=1, user="u\0x") + @test_throws Postgres.PostgresInterfaceError Postgres.Connection(host="127.0.0.1", port=1, user="u", options="-c a\0b") # severity must come from the non-localized 'V' field when the server # sends it: 'S' is translated, so comparing it to "FATAL" would depend @@ -1008,6 +1019,44 @@ include("decimals.jl") end @test isopen(conn) end + @testset "Startup Options" begin + DBInterface.execute(conn, "CREATE SCHEMA IF NOT EXISTS startup_opts") + try + # `options` rides in the startup packet, so it applies before + # the first query and survives a reconnect without replay + opt_conn = DBInterface.connect(Postgres.Connection, cfg.host, cfg.user, cfg.password; dbname=cfg.dbname, port=cfg.port, options="-c search_path=startup_opts", reconnect=true) + @test only(Tables.rowtable(DBInterface.execute(opt_conn, "SHOW search_path"))).search_path == "startup_opts" + close(opt_conn.socket) + @test only(Tables.rowtable(DBInterface.execute(opt_conn, "SHOW search_path"))).search_path == "startup_opts" + DBInterface.close!(opt_conn) + # DSN form, in libpq's space-free spelling, with two switches + dsn_conn = DBInterface.connect(Postgres.Connection, "host=$(cfg.host) port=$(cfg.port) user=$(cfg.user) password=$(cfg.password) dbname=$(cfg.dbname) options='-csearch_path=startup_opts -c geqo=off'") + dsn_row = only(Tables.rowtable(DBInterface.execute(dsn_conn, "SELECT current_setting('search_path') AS sp, current_setting('geqo') AS geqo"))) + @test dsn_row.sp == "startup_opts" + @test dsn_row.geqo == "off" + DBInterface.close!(dsn_conn) + # the driver's own client_encoding startup parameter is applied + # after `options`, so a conflicting switch cannot break decoding + enc_conn = DBInterface.connect(Postgres.Connection, cfg.host, cfg.user, cfg.password; dbname=cfg.dbname, port=cfg.port, options="-c client_encoding=LATIN1") + @test only(Tables.rowtable(DBInterface.execute(enc_conn, "SHOW client_encoding"))).client_encoding == "UTF8" + DBInterface.close!(enc_conn) + # an empty value is not sent at all + empty_conn = DBInterface.connect(Postgres.Connection, cfg.host, cfg.user, cfg.password; dbname=cfg.dbname, port=cfg.port, options="") + @test isopen(empty_conn) + DBInterface.close!(empty_conn) + # a bad switch is a server startup error, surfaced as Postgres.Error + err = try + DBInterface.connect(Postgres.Connection, cfg.host, cfg.user, cfg.password; dbname=cfg.dbname, port=cfg.port, options="-c no_such_parameter=1") + nothing + catch e + e + end + @test err isa Postgres.API.Error + @test err.code == "42704" + finally + DBInterface.execute(conn, "DROP SCHEMA IF EXISTS startup_opts") + end + end @testset "Connection Lifecycle" begin conn2 = DBInterface.connect(Postgres.Connection, cfg.host, cfg.user, cfg.password; dbname=cfg.dbname, port=cfg.port) @test isopen(conn2)