Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.2.2"
version = "2.3.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`, a field whose type Postgres.jl can parse from text (strings, symbols, enums, integers, floats, `Bool`, `Char`, `Date`, `Time`, `DateTime`, `UUID`, `Vector{UInt8}`, lazy JSON, timestamps, decimals, and vectors of these) is decoded from the column text by its declared type, optionally wrapped in `Union{Nothing, …}` or `Union{Missing, …}`. A `String` field receives any column's text, so a `json` or `jsonb` column can be read as its JSON text. An integer field also accepts integral `numeric` values such as `12.00`. A parser registered with `register_type!` still applies when it returns the field's type. Typed reads of these fields compile to static calls, so they work in `juliac --trim` builds. Other field types, and all fields read with a custom driver style, receive the value decoded by the column's type, so custom-style `lift` methods see decoded values.

### Driver Styles

Connection behavior such as query logging, server notices, and asynchronous
Expand Down
22 changes: 13 additions & 9 deletions src/api/API.jl
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,14 @@ _msgsizeof_parts(parts::Tuple) = msgsizeof(first(parts)) + _msgsizeof_parts(Base

writepart(io, x) = write(io, x)
function writepart(io, x::String)
occursin('\0', x) && throw(Postgres.PostgresInterfaceError("PostgreSQL protocol strings cannot contain a NUL byte"))
occursin('\0', x) && throw(PostgresInterfaceError("PostgreSQL protocol strings cannot contain a NUL byte"))
write(io, x)
write(io, UInt8(0))
end
writepart(io, x::Integer) = write(io, hton(x))
function writepart(io, x::Tuple{String, String})
(occursin('\0', x[1]) || occursin('\0', x[2])) &&
throw(Postgres.PostgresInterfaceError("PostgreSQL startup parameters cannot contain a NUL byte"))
throw(PostgresInterfaceError("PostgreSQL startup parameters cannot contain a NUL byte"))
write(io, x[1])
write(io, UInt8(0))
write(io, x[2])
Expand Down 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
64 changes: 55 additions & 9 deletions src/api/types.jl
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,26 @@ 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)

# Integer fields also read integral numeric text such as "12.00" (numeric
# columns, or SUM over one). Out-of-range values throw InexactError, as when
# converting a decoded column value; other 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 +990,35 @@ 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, field types that the lifts below (and StructUtils'
# own String, Symbol and Enum lifts) parse from text decode from the column
# text. The source stays a String, so typed reads compile to static calls
# under `--trim`; OID decoding returns `Any`. Custom styles keep the OID decoder
# so their lifts still receive decoded values, and other field types keep it too.
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 !== Union{} && (S <: Union{TextField, Enum} || S === IntervalType || _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
return _field_text(v.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,16 +1046,16 @@ 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{Date}, s::String) = pg_parse_date_any(s), nothing
StructUtils.lift(::AbstractPostgresStyle, ::Type{Time}, s::String) = pg_parse_time(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
Expand Down
10 changes: 5 additions & 5 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, ..pg_parse_datetime_any, ..pg_parse_char, ..pg_parse_integer

const BRACKET_OPEN = UInt8('[')
const BRACKET_CLOSE = UInt8(']')
Expand Down Expand Up @@ -88,12 +88,12 @@ 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 === Date && return pg_parse_date_any(token)
inner_type === Time && return pg_parse_time(token)
inner_type <: Durations.Timestamp && return pg_parse_timestamp(token, inner_type)
inner_type === DateTime && return pg_parse_datetime_any(token)
Expand Down
19 changes: 19 additions & 0 deletions src/execute.jl
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,19 @@ 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.
function _padded(n::Integer, width::Int)
digits = string(abs(n))
for _ in ncodeunits(digits)+1:width
digits = string('0', digits) # lpad is not trim-safe
end
return n < 0 ? string('-', digits) : digits
end
_param(x::Date)::String = string(_padded(year(x), 4), '-', _padded(month(x), 2), '-', _padded(day(x), 2))
function _param(x::DateTime)::String
text = string(_param(Date(x)), 'T', _padded(hour(x), 2), ':', _padded(minute(x), 2), ':', _padded(second(x), 2))
return iszero(millisecond(x)) ? text : string(text, '.', _padded(millisecond(x), 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 @@ -332,6 +345,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