Skip to content
6 changes: 4 additions & 2 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "Postgres"
uuid = "8f23287e-300e-4f50-bc2b-9f1dfe95da84"
version = "2.3.1"
version = "2.4.0"

[deps]
ConcurrentUtilities = "f0e56b4a-5159-44fe-b623-3e5288b988bb"
Expand Down Expand Up @@ -31,6 +31,7 @@ Harbor = "1.1"
JSON = "1"
MD5 = "0.2"
Parsers = "2.7, 3"
Pkg = "1.10"
Random = "1.10"
# Reseau 1.5 adds non-blocking tryread! behind isvalid.
Reseau = "1.5"
Expand All @@ -47,8 +48,9 @@ julia = "1.10"
Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b"
Harbor = "af79dbb9-1a80-47ad-8928-192a4af69376"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
Sockets = "6462fe0b-24de-5631-8697-dd941f90decc"
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Aqua", "Distributed", "Harbor", "Sockets", "Test"]
test = ["Aqua", "Distributed", "Harbor", "Pkg", "Sockets", "Test"]
2 changes: 2 additions & 0 deletions docs/src/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ profiles = DBInterface.execute(conn, """

The `postgres=(name=:column_name,)` tag is only needed when a column should map to a differently named field. Columns such as `id` or `name` can be left untagged because they already match the Julia field name.

With the default `Postgres.PostgresStyle`, fields of common types are parsed from the column text by their declared type: strings, symbols, enums, integers, floats, `Bool`, `Char`, `Date`, `Time`, `DateTime`, `UUID`, `Vector{UInt8}`, lazy JSON, timestamps, decimals, vectors of these, and any of them in a `Union` with `Nothing` or `Missing`. A `String` field receives any column's text, so a `json` column reads as its JSON text. Integer fields accept integral `numeric` values such as `12.00`, and `Date` and `Time` fields over timestamp columns get the UTC date or time of day. A parser registered with `register_type!` still applies when it returns the field's type. These reads need no runtime dispatch, which `juliac --trim` builds require. Other field types, and fields read with a custom driver style, receive the value decoded for the column type.

### Driver Styles

Connection behavior such as query logging, server notices, and asynchronous
Expand Down
10 changes: 8 additions & 2 deletions docs/src/support.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,11 @@ represented as a Boolean and raise an error. Select them as text, for example

## Native Compilation

JuliaC `--trim` compilation is not supported in Postgres.jl. Normal Julia
package precompilation is supported and is part of CI.
JuliaC `--trim` builds work with `--trim=unsafe-warn`. Postgres.jl itself has
no trim verifier errors on Julia 1.12 and later, but it reaches parts of
released Julia's Base64 (SCRAM authentication) and `Dates.CompoundPeriod`
(interval decoding) that are not trim-safe, so `--trim=safe` rejects the build.
CI compiles a workload of connections, prepared statements, transactions,
parameters, and typed reads, and runs it against PostgreSQL with trust
authentication. Typed reads into general unions, and reads with a custom driver
style, use runtime dispatch.
18 changes: 11 additions & 7 deletions src/api/API.jl
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,14 @@ function writestartupmessage(
user::String,
dbname::String,
application_name::Union{Nothing, String},
options::Union{Nothing, String},
statement_timeout::Union{Nothing, Int},
options::String,
)::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)
send_options = !isempty(options)
len = 8 + msgsizeof(("user", user)) + msgsizeof(("database", dbname)) +
msgsizeof(("client_encoding", "UTF8")) + 1
application_name !== nothing && (len += msgsizeof(("application_name", application_name)))
Expand All @@ -329,11 +328,16 @@ function writestartupmessage(
return nothing
end

_write_messages_to_buffer(buf, debug, ::Tuple{}) = nothing
function _write_messages_to_buffer(buf, debug, msgs::Tuple)
_write_message_to_buffer(buf, debug, first(msgs))
_write_messages_to_buffer(buf, debug, Base.tail(msgs))
return nothing
end

function writemessages(socket, debug::Bool, msgs::Vararg{Tuple, N}) where {N}
buf = IOBuffer()
for msg in msgs
_write_message_to_buffer(buf, debug, msg)
end
_write_messages_to_buffer(buf, debug, msgs)
write(socket, take!(buf))
flush(socket)
return
Expand Down Expand Up @@ -829,7 +833,7 @@ function _startup!(socket, debug::Bool, user::String, dbname::String, @nospecial
application_name_v = application_name::Union{String, Nothing}
options_v = options::Union{String, Nothing}
statement_timeout_v = statement_timeout::Union{Int, Nothing}
writestartupmessage(socket, debug, user, dbname, application_name_v, options_v, statement_timeout_v)
writestartupmessage(socket, debug, user, dbname, application_name_v, something(options_v, ""))
# read initial response
mt, len = readheader(socket, debug, MAX_PREAUTH_MESSAGE_LEN)
if mt == UInt8('E')
Expand Down
85 changes: 75 additions & 10 deletions src/api/types.jl
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,32 @@ function pg_parse_timestamp(s::AbstractString, ::Type{Durations.Timestamp{P}}) w
return Durations.Timestamp{P}(Dates.UTInstant(P(Int64(ticks))))
end

# timestamp(tz) column into a Date field: the UTC date, as for DateTime fields
pg_parse_date_any(s::AbstractString)::Date = occursin(':', s) ? Date(pg_parse_datetime_any(s)) : pg_parse_date(s)

# timestamp(tz) column into a Time field: the UTC time of day
function pg_parse_time_any(s::AbstractString)::Time
# "infinity" has no space and would otherwise reach the time parser
_check_temporal_special(s, "timestamp")
return occursin(' ', s) ? convert(Time, pg_parse_timestamp(s)) : pg_parse_time(s)
end

# Integer fields also read integral numeric text such as "12.00". Out-of-range
# values throw InexactError; other non-integer text throws the parser's error.
function pg_parse_integer(::Type{T}, s::AbstractString) where {T <: Integer}
x = Parsers.tryparse(T, s)
x === nothing || return x
wide = Parsers.tryparse(Int128, s)
if wide === nothing
dot = findfirst(==('.'), s)
if dot !== nothing && all(==('0'), SubString(s, dot + 1))
wide = Parsers.tryparse(Int128, SubString(s, 1, dot - 1))
end
end
wide === nothing && return Parsers.parse(T, s)
return convert(T, wide)
end

# timestamptz column into a DateTime field: sniff a trailing offset/Z
function pg_parse_datetime_any(s::AbstractString)::DateTime
isempty(s) && throw(ArgumentError("invalid postgres timestamp"))
Expand Down Expand Up @@ -970,9 +996,48 @@ end
const WireFieldScalar = Union{Durations.Timestamp, DataDecimals.AbstractDecimal, DateTime}
_wire_field(::Type{T}) where {T} = T <: Union{Missing, WireFieldScalar}
_wire_field(::Type{<:AbstractVector{T}}) where {T} = T <: WireFieldScalar
_field_source(::Type{T}, v::FieldValue) where {T} = _wire_field(T) ? _field_text(v.text) : parse_value(v.oid, v.text, v.registry)
StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue) where {T} = StructUtils.make(st, T, _field_source(T, v))
StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue, tags) where {T} = StructUtils.make(st, T, _field_source(T, v), tags)

_field_source(::AbstractPostgresStyle, ::Type{T}, v::FieldValue) where {T} =
_wire_field(T) ? _field_text(v.text) : parse_value(v.oid, v.text, v.registry)

# With the default style, fields of these types (and enums) are lifted from the
# column text, so typed reads infer and compile under `--trim`; `parse_value`
# returns `Any`. Other field types, general unions, and custom styles (whose
# lifts expect decoded values) keep the OID decoder.
const TextField = Union{String, Symbol, Bool, Char, Int8, Int16, Int32, Int64, Cuint, Float32, Float64,
Date, Time, DateTime, UUID, Vector{UInt8}, JSONType, Vector{String}, Vector{Int16}, Vector{Int32},
Vector{Int64}, Vector{Float32}, Vector{Float64}, Vector{Bool}, Vector{Date}, Vector{Time},
Vector{DateTime}, Vector{UUID}, Vector{Char}}
_text_field(::Type{S}) where {S} = S === IntervalType || (S !== Union{} && !(S isa Union) && (S <: Union{TextField, Enum} || _wire_field(S)))

function _field_source(::PostgresStyle, ::Type{T}, v::FieldValue) where {T}
S = Base.nonnothingtype(Base.nonmissingtype(T))
_text_field(S) || return parse_value(v.oid, v.text, v.registry)
info = get(v.registry, v.oid, nothing)
if !_wire_field(S) && info !== nothing && info.parser !== nothing && info.julia_type <: S
# a parser registered for this column still applies when it produces
# the field's type (timestamps and decimals always parse the text)
x = info.parser(_field_text(v.text), v.registry)
x isa S && return x
end
text = _field_text(v.text)
# Parse float columns at their own precision, then convert: large values
# print in exponent form ("1e+15"), and float4 widened to Float64 keeps its
# Float32 value.
if S <: Union{Signed, Unsigned, AbstractFloat}
v.oid == 700 && return convert(S, Parsers.parse(Float32, text))
v.oid == 701 && return convert(S, Parsers.parse(Float64, text))
elseif S <: Union{Vector{<:Signed}, Vector{<:Unsigned}, Vector{<:AbstractFloat}}
v.oid == 1021 && return convert(S, parse_array(text, Float32)::Vector{Float32})
v.oid == 1022 && return convert(S, parse_array(text, Float64)::Vector{Float64})
# the Vector{UInt8} lift decodes bytea text; other arrays convert by element
S === Vector{UInt8} && v.oid != 17 && info !== nothing && info.julia_type <: AbstractVector &&
return convert(S, parse_array(text, Int64)::Vector{Int64})
end
return text
end
StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue) where {T} = StructUtils.make(st, T, _field_source(st, T, v))
StructUtils.make(st::AbstractPostgresStyle, ::Type{T}, v::FieldValue, tags) where {T} = StructUtils.make(st, T, _field_source(st, T, v), tags)

@static if isdefined(StructUtils, :InterpClosure) && isdefined(StructUtils, :HotStructClosure)
@inline function applycast(f::Union{StructUtils.InterpClosure, StructUtils.HotStructClosure}, name, typeId, val, registry::Dict{Int, TypeInfo})
Expand Down Expand Up @@ -1000,17 +1065,17 @@ end
return
end

StructUtils.lift(::AbstractPostgresStyle, ::Type{Int8}, s::String) = Parsers.parse(Int8, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int8}, s::String) = pg_parse_integer(Int8, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Bool}, s::String) = parse_boolean(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Char}, s::String) = pg_parse_char(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int16}, s::String) = Parsers.parse(Int16, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int32}, s::String) = Parsers.parse(Int32, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int64}, s::String) = Parsers.parse(Int64, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Cuint}, s::String) = Parsers.parse(Cuint, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int16}, s::String) = pg_parse_integer(Int16, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int32}, s::String) = pg_parse_integer(Int32, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Int64}, s::String) = pg_parse_integer(Int64, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Cuint}, s::String) = pg_parse_integer(Cuint, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Float32}, s::String) = Parsers.parse(Float32, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Float64}, s::String) = Parsers.parse(Float64, s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Date}, s::String) = pg_parse_date(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Time}, s::String) = pg_parse_time(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Date}, s::String) = pg_parse_date_any(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Time}, s::String) = pg_parse_time_any(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{DateTime}, s::String) = pg_parse_datetime_any(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{UUID}, s::String) = UUID(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{IntervalType}, s::String) = parse_interval(s), nothing
Expand Down
12 changes: 6 additions & 6 deletions src/array_parsing.jl
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ using Parsers, Dates, UUIDs
import Durations, DataDecimals
import ..parse_decimal
import ..pg_parse_timestamp
import ..pg_parse_date, ..pg_parse_time, ..pg_parse_datetime_any, ..pg_parse_char
import ..pg_parse_date, ..pg_parse_date_any, ..pg_parse_time_any, ..pg_parse_datetime_any, ..pg_parse_char, ..pg_parse_integer

const BRACKET_OPEN = UInt8('[')
const BRACKET_CLOSE = UInt8(']')
Expand Down Expand Up @@ -88,13 +88,13 @@ function parse_scalar(token::String, inner_type::Type{T}, quoted::Bool) where {T
!quoted && token == NULL_STR && return missing
inner_type === String && return token
inner_type === Bool && return parse_bool_token(token)
inner_type === Int16 && return Parsers.parse(Int16, token)
inner_type === Int32 && return Parsers.parse(Int32, token)
inner_type === Int64 && return Parsers.parse(Int64, token)
inner_type === Int16 && return pg_parse_integer(Int16, token)
inner_type === Int32 && return pg_parse_integer(Int32, token)
inner_type === Int64 && return pg_parse_integer(Int64, token)
inner_type === Float32 && return Parsers.parse(Float32, token)
inner_type === Float64 && return Parsers.parse(Float64, token)
inner_type === Date && return pg_parse_date(token)
inner_type === Time && return pg_parse_time(token)
inner_type === Date && return pg_parse_date_any(token)
inner_type === Time && return pg_parse_time_any(token)
inner_type <: Durations.Timestamp && return pg_parse_timestamp(token, inner_type)
inner_type === DateTime && return pg_parse_datetime_any(token)
inner_type === UUID && return UUID(token)
Expand Down
12 changes: 12 additions & 0 deletions src/execute.jl
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,12 @@ Base.close(cursor::Cursor) = DBInterface.close!(cursor)

_param(x::AbstractString)::String = String(x)
_param(x)::String = string(x)
# Same text as `string(x)`, whose Dates formatting is not trim-safe.
_param(x::Date)::String = string(string(year(x); pad=4), '-', string(month(x); pad=2), '-', string(day(x); pad=2))
function _param(x::DateTime)::String
text = string(_param(Date(x)), 'T', string(hour(x); pad=2), ':', string(minute(x); pad=2), ':', string(second(x); pad=2))
return iszero(millisecond(x)) ? text : string(text, '.', string(millisecond(x); pad=3))
end
function _param(x::Durations.Timestamp)::String
# PostgreSQL would round finer fractions. Require an exact conversion.
timestamp = x isa Durations.Timestamp{Dates.Nanosecond} ? convert(API.PGTimestamp, x) : x
Expand Down Expand Up @@ -336,6 +342,12 @@ function build_params(params, nparams::Int, sql::AbstractString)
return dest
end

function build_unchecked_params(params::Tuple)
dest = Vector{Union{String, Missing}}(undef, length(params))
bind_tuple_params!(dest, params)
return dest
end

function build_unchecked_params(params)
dest = Union{String, Missing}[]
params === nothing && return dest
Expand Down
6 changes: 6 additions & 0 deletions test/gssapi.jl
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,12 @@ function test_kerberos_integration()
err = connect_err(() -> DBInterface.connect(Postgres.Connection, cfg.host, KRB_USER, nothing; dbname=cfg.dbname, port=cfg.port, gssencmode="prefer", krbsrvname="nosuchservice"))
@test err isa GSSAPI.GSSError
finally
# a closed session's temp tables are dropped as its backend exits,
# which can trail close!; DROP ROLE fails while the role owns them
for _ in 1:100
only(DBInterface.execute(admin, "SELECT count(*) AS n FROM pg_stat_activity WHERE usename = '$KRB_USER'")).n == 0 && break
sleep(0.1)
end
DBInterface.execute(admin, "DROP ROLE IF EXISTS $KRB_USER")
DBInterface.close!(admin)
end
Expand Down
Loading
Loading