mirror of
https://github.com/jackc/pgx.git
synced 2025-05-08 08:30:12 +00:00
I am working on an application that uses hstore types, and we found that returning the values is slow, particularly when using the text protocol, such as when using database/sql. This improves parsing to be about 6X faster (currently faster than binary). The changes are: * referencing the original string instead of copying into new strings (very large win) * using string.IndexByte to scan double quoted strings: it has architecture-specific assembly implementations, and most of the time is spent in key/value strings. * estimating the number of key/value pairs to allocate the correct size of the slice and map up front. This reduces the number of allocations and bytes allocated by a factor of 2, and was a small CPU win. * parsing directly into the Hstore, rather than copying into it. This parser is stricter than the old one. It only accepts hstore strings serialized by Postgres. The old one was already stricter than Postgres's own parser, but previously accepted any whitespace character after a comma. This one only accepts space. Example: "k1"=>"v1",\t"k2"=>"v2" Postgres only ever uses ", " as the separator. See hstore_out: https://github.com/postgres/postgres/blob/master/contrib/hstore/hstore_io.c The result of using benchstat to compare the benchmark on my M1 Pro with the following command line in below. The new text parser is now faster than the binary parser. I will improve the binary parser in a separate change. for i in $(seq 10); do go test ./pgtype -run=none -bench=BenchmarkHstoreScan -benchtime=1s >> new.txt; done goos: darwin goarch: arm64 pkg: github.com/jackc/pgx/v5/pgtype │ orig.txt │ new.txt │ │ sec/op │ sec/op vs base │ HstoreScan/databasesql.Scan-10 82.11µ ± 1% 10.51µ ± 0% -87.20% (p=0.000 n=10) HstoreScan/text-10 83.30µ ± 1% 11.49µ ± 1% -86.20% (p=0.000 n=10) HstoreScan/binary-10 15.99µ ± 2% 15.77µ ± 1% -1.35% (p=0.007 n=10) geomean 47.82µ 12.40µ -74.08% │ orig.txt │ new.txt │ │ B/op │ B/op vs base │ HstoreScan/databasesql.Scan-10 56.23Ki ± 0% 11.68Ki ± 0% -79.23% (p=0.000 n=10) HstoreScan/text-10 65.12Ki ± 0% 20.58Ki ± 0% -68.40% (p=0.000 n=10) HstoreScan/binary-10 21.09Ki ± 0% 21.09Ki ± 0% ~ (p=0.378 n=10) geomean 42.58Ki 17.18Ki -59.66% │ orig.txt │ new.txt │ │ allocs/op │ allocs/op vs base │ HstoreScan/databasesql.Scan-10 744.00 ± 0% 44.00 ± 0% -94.09% (p=0.000 n=10) HstoreScan/text-10 743.00 ± 0% 44.00 ± 0% -94.08% (p=0.000 n=10) HstoreScan/binary-10 464.0 ± 0% 464.0 ± 0% ~ (p=1.000 n=10) ¹ geomean 635.4 96.49 -84.81% ¹ all samples are equal
484 lines
11 KiB
Go
484 lines
11 KiB
Go
package pgtype
|
|
|
|
import (
|
|
"database/sql/driver"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/internal/pgio"
|
|
)
|
|
|
|
type HstoreScanner interface {
|
|
ScanHstore(v Hstore) error
|
|
}
|
|
|
|
type HstoreValuer interface {
|
|
HstoreValue() (Hstore, error)
|
|
}
|
|
|
|
// Hstore represents an hstore column that can be null or have null values
|
|
// associated with its keys.
|
|
type Hstore map[string]*string
|
|
|
|
func (h *Hstore) ScanHstore(v Hstore) error {
|
|
*h = v
|
|
return nil
|
|
}
|
|
|
|
func (h Hstore) HstoreValue() (Hstore, error) {
|
|
return h, nil
|
|
}
|
|
|
|
// Scan implements the database/sql Scanner interface.
|
|
func (h *Hstore) Scan(src any) error {
|
|
if src == nil {
|
|
*h = nil
|
|
return nil
|
|
}
|
|
|
|
switch src := src.(type) {
|
|
case string:
|
|
return scanPlanTextAnyToHstoreScanner{}.scanString(src, h)
|
|
}
|
|
|
|
return fmt.Errorf("cannot scan %T", src)
|
|
}
|
|
|
|
// Value implements the database/sql/driver Valuer interface.
|
|
func (h Hstore) Value() (driver.Value, error) {
|
|
if h == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
buf, err := HstoreCodec{}.PlanEncode(nil, 0, TextFormatCode, h).Encode(h, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return string(buf), err
|
|
}
|
|
|
|
type HstoreCodec struct{}
|
|
|
|
func (HstoreCodec) FormatSupported(format int16) bool {
|
|
return format == TextFormatCode || format == BinaryFormatCode
|
|
}
|
|
|
|
func (HstoreCodec) PreferredFormat() int16 {
|
|
return BinaryFormatCode
|
|
}
|
|
|
|
func (HstoreCodec) PlanEncode(m *Map, oid uint32, format int16, value any) EncodePlan {
|
|
if _, ok := value.(HstoreValuer); !ok {
|
|
return nil
|
|
}
|
|
|
|
switch format {
|
|
case BinaryFormatCode:
|
|
return encodePlanHstoreCodecBinary{}
|
|
case TextFormatCode:
|
|
return encodePlanHstoreCodecText{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type encodePlanHstoreCodecBinary struct{}
|
|
|
|
func (encodePlanHstoreCodecBinary) Encode(value any, buf []byte) (newBuf []byte, err error) {
|
|
hstore, err := value.(HstoreValuer).HstoreValue()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if hstore == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
buf = pgio.AppendInt32(buf, int32(len(hstore)))
|
|
|
|
for k, v := range hstore {
|
|
buf = pgio.AppendInt32(buf, int32(len(k)))
|
|
buf = append(buf, k...)
|
|
|
|
if v == nil {
|
|
buf = pgio.AppendInt32(buf, -1)
|
|
} else {
|
|
buf = pgio.AppendInt32(buf, int32(len(*v)))
|
|
buf = append(buf, (*v)...)
|
|
}
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
type encodePlanHstoreCodecText struct{}
|
|
|
|
func (encodePlanHstoreCodecText) Encode(value any, buf []byte) (newBuf []byte, err error) {
|
|
hstore, err := value.(HstoreValuer).HstoreValue()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if hstore == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
firstPair := true
|
|
|
|
for k, v := range hstore {
|
|
if firstPair {
|
|
firstPair = false
|
|
} else {
|
|
buf = append(buf, ',')
|
|
}
|
|
|
|
// unconditionally quote hstore keys/values like Postgres does
|
|
// this avoids a Mac OS X Postgres hstore parsing bug:
|
|
// https://www.postgresql.org/message-id/CA%2BHWA9awUW0%2BRV_gO9r1ABZwGoZxPztcJxPy8vMFSTbTfi4jig%40mail.gmail.com
|
|
buf = append(buf, '"')
|
|
buf = append(buf, quoteArrayReplacer.Replace(k)...)
|
|
buf = append(buf, '"')
|
|
buf = append(buf, "=>"...)
|
|
|
|
if v == nil {
|
|
buf = append(buf, "NULL"...)
|
|
} else {
|
|
buf = append(buf, '"')
|
|
buf = append(buf, quoteArrayReplacer.Replace(*v)...)
|
|
buf = append(buf, '"')
|
|
}
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
func (HstoreCodec) PlanScan(m *Map, oid uint32, format int16, target any) ScanPlan {
|
|
|
|
switch format {
|
|
case BinaryFormatCode:
|
|
switch target.(type) {
|
|
case HstoreScanner:
|
|
return scanPlanBinaryHstoreToHstoreScanner{}
|
|
}
|
|
case TextFormatCode:
|
|
switch target.(type) {
|
|
case HstoreScanner:
|
|
return scanPlanTextAnyToHstoreScanner{}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type scanPlanBinaryHstoreToHstoreScanner struct{}
|
|
|
|
func (scanPlanBinaryHstoreToHstoreScanner) Scan(src []byte, dst any) error {
|
|
scanner := (dst).(HstoreScanner)
|
|
|
|
if src == nil {
|
|
return scanner.ScanHstore(Hstore(nil))
|
|
}
|
|
|
|
rp := 0
|
|
|
|
if len(src[rp:]) < 4 {
|
|
return fmt.Errorf("hstore incomplete %v", src)
|
|
}
|
|
pairCount := int(int32(binary.BigEndian.Uint32(src[rp:])))
|
|
rp += 4
|
|
|
|
hstore := make(Hstore, pairCount)
|
|
|
|
for i := 0; i < pairCount; i++ {
|
|
if len(src[rp:]) < 4 {
|
|
return fmt.Errorf("hstore incomplete %v", src)
|
|
}
|
|
keyLen := int(int32(binary.BigEndian.Uint32(src[rp:])))
|
|
rp += 4
|
|
|
|
if len(src[rp:]) < keyLen {
|
|
return fmt.Errorf("hstore incomplete %v", src)
|
|
}
|
|
key := string(src[rp : rp+keyLen])
|
|
rp += keyLen
|
|
|
|
if len(src[rp:]) < 4 {
|
|
return fmt.Errorf("hstore incomplete %v", src)
|
|
}
|
|
valueLen := int(int32(binary.BigEndian.Uint32(src[rp:])))
|
|
rp += 4
|
|
|
|
var valueBuf []byte
|
|
if valueLen >= 0 {
|
|
valueBuf = src[rp : rp+valueLen]
|
|
rp += valueLen
|
|
}
|
|
|
|
var value Text
|
|
err := scanPlanTextAnyToTextScanner{}.Scan(valueBuf, &value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if value.Valid {
|
|
hstore[key] = &value.String
|
|
} else {
|
|
hstore[key] = nil
|
|
}
|
|
}
|
|
|
|
return scanner.ScanHstore(hstore)
|
|
}
|
|
|
|
type scanPlanTextAnyToHstoreScanner struct{}
|
|
|
|
func (s scanPlanTextAnyToHstoreScanner) Scan(src []byte, dst any) error {
|
|
scanner := (dst).(HstoreScanner)
|
|
|
|
if src == nil {
|
|
return scanner.ScanHstore(Hstore(nil))
|
|
}
|
|
return s.scanString(string(src), scanner)
|
|
}
|
|
|
|
// scanString does not return nil hstore values because string cannot be nil.
|
|
func (scanPlanTextAnyToHstoreScanner) scanString(src string, scanner HstoreScanner) error {
|
|
hstore, err := parseHstore(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return scanner.ScanHstore(hstore)
|
|
}
|
|
|
|
func (c HstoreCodec) DecodeDatabaseSQLValue(m *Map, oid uint32, format int16, src []byte) (driver.Value, error) {
|
|
return codecDecodeToTextFormat(c, m, oid, format, src)
|
|
}
|
|
|
|
func (c HstoreCodec) DecodeValue(m *Map, oid uint32, format int16, src []byte) (any, error) {
|
|
if src == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
var hstore Hstore
|
|
err := codecScan(c, m, oid, format, src, &hstore)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return hstore, nil
|
|
}
|
|
|
|
type hstoreParser struct {
|
|
str string
|
|
pos int
|
|
nextBackslash int
|
|
}
|
|
|
|
func newHSP(in string) *hstoreParser {
|
|
return &hstoreParser{
|
|
pos: 0,
|
|
str: in,
|
|
nextBackslash: strings.IndexByte(in, '\\'),
|
|
}
|
|
}
|
|
|
|
func (p *hstoreParser) atEnd() bool {
|
|
return p.pos >= len(p.str)
|
|
}
|
|
|
|
// consume returns the next byte of the string, or end if the string is done.
|
|
func (p *hstoreParser) consume() (b byte, end bool) {
|
|
if p.pos >= len(p.str) {
|
|
return 0, true
|
|
}
|
|
b = p.str[p.pos]
|
|
p.pos++
|
|
return b, false
|
|
}
|
|
|
|
func unexpectedByteErr(actualB byte, expectedB byte) error {
|
|
return fmt.Errorf("expected '%c' ('%#v'); found '%c' ('%#v')", expectedB, expectedB, actualB, actualB)
|
|
}
|
|
|
|
// consumeExpectedByte consumes expectedB from the string, or returns an error.
|
|
func (p *hstoreParser) consumeExpectedByte(expectedB byte) error {
|
|
nextB, end := p.consume()
|
|
if end {
|
|
return fmt.Errorf("expected '%c' ('%#v'); found end", expectedB, expectedB)
|
|
}
|
|
if nextB != expectedB {
|
|
return unexpectedByteErr(nextB, expectedB)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// consumeExpected2 consumes two expected bytes or returns an error.
|
|
// This was a bit faster than using a string argument (better inlining? Not sure).
|
|
func (p *hstoreParser) consumeExpected2(one byte, two byte) error {
|
|
if p.pos+2 > len(p.str) {
|
|
return errors.New("unexpected end of string")
|
|
}
|
|
if p.str[p.pos] != one {
|
|
return unexpectedByteErr(p.str[p.pos], one)
|
|
}
|
|
if p.str[p.pos+1] != two {
|
|
return unexpectedByteErr(p.str[p.pos+1], two)
|
|
}
|
|
p.pos += 2
|
|
return nil
|
|
}
|
|
|
|
var errEOSInQuoted = errors.New(`found end before closing double-quote ('"')`)
|
|
|
|
// consumeDoubleQuoted consumes a double-quoted string from p. The double quote must have been
|
|
// parsed already.
|
|
func (p *hstoreParser) consumeDoubleQuoted() (string, error) {
|
|
// fast path: assume most keys/values do not contain escapes
|
|
nextDoubleQuote := strings.IndexByte(p.str[p.pos:], '"')
|
|
if nextDoubleQuote == -1 {
|
|
return "", errEOSInQuoted
|
|
}
|
|
nextDoubleQuote += p.pos
|
|
if p.nextBackslash == -1 || p.nextBackslash > nextDoubleQuote {
|
|
// no escapes in this string
|
|
s := p.str[p.pos:nextDoubleQuote]
|
|
p.pos = nextDoubleQuote + 1
|
|
return s, nil
|
|
}
|
|
|
|
// slow path: string contains escapes
|
|
s, err := p.consumeDoubleQuotedWithEscapes(p.nextBackslash)
|
|
p.nextBackslash = strings.IndexByte(p.str[p.pos:], '\\')
|
|
if p.nextBackslash != -1 {
|
|
p.nextBackslash += p.pos
|
|
}
|
|
return s, err
|
|
}
|
|
|
|
// consumeDoubleQuotedWithEscapes consumes a double-quoted string containing escapes, starting
|
|
// at p.pos, and with the first backslash at firstBackslash.
|
|
func (p *hstoreParser) consumeDoubleQuotedWithEscapes(firstBackslash int) (string, error) {
|
|
// copy the prefix that does not contain backslashes
|
|
var builder strings.Builder
|
|
builder.WriteString(p.str[p.pos:firstBackslash])
|
|
|
|
// skip to the backslash
|
|
p.pos = firstBackslash
|
|
|
|
// copy bytes until the end, unescaping backslashes
|
|
for {
|
|
nextB, end := p.consume()
|
|
if end {
|
|
return "", errEOSInQuoted
|
|
} else if nextB == '"' {
|
|
break
|
|
} else if nextB == '\\' {
|
|
// escape: skip the backslash and copy the char
|
|
nextB, end = p.consume()
|
|
if end {
|
|
return "", errEOSInQuoted
|
|
}
|
|
if !(nextB == '\\' || nextB == '"') {
|
|
return "", fmt.Errorf("unexpected escape in quoted string: found '%#v'", nextB)
|
|
}
|
|
builder.WriteByte(nextB)
|
|
} else {
|
|
// normal byte: copy it
|
|
builder.WriteByte(nextB)
|
|
}
|
|
}
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// consumePairSeparator consumes the Hstore pair separator ", " or returns an error.
|
|
func (p *hstoreParser) consumePairSeparator() error {
|
|
return p.consumeExpected2(',', ' ')
|
|
}
|
|
|
|
// consumeKVSeparator consumes the Hstore key/value separator "=>" or returns an error.
|
|
func (p *hstoreParser) consumeKVSeparator() error {
|
|
return p.consumeExpected2('=', '>')
|
|
}
|
|
|
|
// consumeDoubleQuotedOrNull consumes the Hstore key/value separator "=>" or returns an error.
|
|
func (p *hstoreParser) consumeDoubleQuotedOrNull() (Text, error) {
|
|
// peek at the next byte
|
|
if p.atEnd() {
|
|
return Text{}, errors.New("found end instead of value")
|
|
}
|
|
next := p.str[p.pos]
|
|
if next == 'N' {
|
|
// must be the exact string NULL: use consumeExpected2 twice
|
|
err := p.consumeExpected2('N', 'U')
|
|
if err != nil {
|
|
return Text{}, err
|
|
}
|
|
err = p.consumeExpected2('L', 'L')
|
|
if err != nil {
|
|
return Text{}, err
|
|
}
|
|
return Text{String: "", Valid: false}, nil
|
|
} else if next != '"' {
|
|
return Text{}, unexpectedByteErr(next, '"')
|
|
}
|
|
|
|
// skip the double quote
|
|
p.pos += 1
|
|
s, err := p.consumeDoubleQuoted()
|
|
if err != nil {
|
|
return Text{}, err
|
|
}
|
|
return Text{String: s, Valid: true}, nil
|
|
}
|
|
|
|
func parseHstore(s string) (Hstore, error) {
|
|
p := newHSP(s)
|
|
|
|
// This is an over-estimate of the number of key/value pairs. Use '>' because I am guessing it
|
|
// is less likely to occur in keys/values than '=' or ','.
|
|
numPairsEstimate := strings.Count(s, ">")
|
|
// makes one allocation of strings for the entire Hstore, rather than one allocation per value.
|
|
valueStrings := make([]string, 0, numPairsEstimate)
|
|
result := make(Hstore, numPairsEstimate)
|
|
first := true
|
|
for !p.atEnd() {
|
|
if !first {
|
|
err := p.consumePairSeparator()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
first = false
|
|
}
|
|
|
|
err := p.consumeExpectedByte('"')
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
key, err := p.consumeDoubleQuoted()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = p.consumeKVSeparator()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
value, err := p.consumeDoubleQuotedOrNull()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if value.Valid {
|
|
valueStrings = append(valueStrings, value.String)
|
|
result[key] = &valueStrings[len(valueStrings)-1]
|
|
} else {
|
|
result[key] = nil
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|