feat(pgrtl): minimal PostgreSQL client RTL (pgxpool + 4 HB_FUNCs)
PG_OPEN(cDsn) -> integer handle, -1 on failure
PG_CLOSE(nH) -> NIL
PG_QUERY(nH, cSQL [, aArgs]) -> array of { col => val } hashes
PG_EXEC (nH, cSQL [, aArgs]) -> rows affected, -1 on error
PG_LAST_ERROR(nH) -> last error string
Backed by github.com/jackc/pgx/v5/pgxpool, which is already in Five's
indirect dep tree (pgserver uses pgproto3 from the same repo). Pool
limits: MaxConns 8, MinConns 1, 5-min idle. Query timeout is capped at
30s so a runaway query can't pin a goroutine forever.
aArgs uses standard Postgres $1/$2/... placeholders — pgx parameter
binding prevents SQL injection. Never concatenate user input into cSQL.
Smoke-tested with app/pg_test.prg: bad DSN returns -1 cleanly (no
panic), the error path prints the expected fallback message, and the
real round-trip path is wired so setting LABDB_DSN to a live database
exercises SELECT + parameter binding + multi-row return without any
further code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
45
app/pg_test.prg
Normal file
45
app/pg_test.prg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// app/pg_test.prg — pgrtl RTL smoke test.
|
||||||
|
//
|
||||||
|
// Tries to connect to a local Postgres; if none is up the test still
|
||||||
|
// passes by checking the failure path. With LABDB_DSN set in the
|
||||||
|
// environment it runs a real round-trip query against labdb.
|
||||||
|
FUNCTION Main()
|
||||||
|
LOCAL cDsn := hb_GetEnv( "LABDB_DSN", "postgres://nope:nope@127.0.0.1:1/nope" )
|
||||||
|
LOCAL nH, aRows, n, hRow, i
|
||||||
|
|
||||||
|
? "Trying:", cDsn
|
||||||
|
nH := PG_OPEN( cDsn )
|
||||||
|
? "PG_OPEN handle:", nH
|
||||||
|
|
||||||
|
IF nH < 0
|
||||||
|
? "(no live Postgres at that DSN — expected when LABDB_DSN unset)"
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
? "--- SELECT 1 round trip ---"
|
||||||
|
aRows := PG_QUERY( nH, "SELECT 1 AS one, 'hello'::text AS greet, NULL AS empty" )
|
||||||
|
IF aRows == NIL
|
||||||
|
? "PG_QUERY failed:", PG_LAST_ERROR( nH )
|
||||||
|
PG_CLOSE( nH )
|
||||||
|
RETURN NIL
|
||||||
|
ENDIF
|
||||||
|
? "rows:", Len( aRows )
|
||||||
|
FOR i := 1 TO Len( aRows )
|
||||||
|
hRow := aRows[ i ]
|
||||||
|
? " one :", hRow[ "one" ], ValType( hRow[ "one" ] )
|
||||||
|
? " greet:", hRow[ "greet" ], ValType( hRow[ "greet" ] )
|
||||||
|
? " empty:", hRow[ "empty" ], ValType( hRow[ "empty" ] )
|
||||||
|
NEXT
|
||||||
|
|
||||||
|
? "--- parameter binding ---"
|
||||||
|
aRows := PG_QUERY( nH, "SELECT $1::int AS n, $2::text AS s", { 42, "answer" } )
|
||||||
|
IF aRows != NIL .AND. Len( aRows ) > 0
|
||||||
|
? " n:", aRows[ 1 ][ "n" ]
|
||||||
|
? " s:", aRows[ 1 ][ "s" ]
|
||||||
|
ELSE
|
||||||
|
? "param query failed:", PG_LAST_ERROR( nH )
|
||||||
|
ENDIF
|
||||||
|
|
||||||
|
PG_CLOSE( nH )
|
||||||
|
? "closed."
|
||||||
|
RETURN NIL
|
||||||
@@ -37,6 +37,7 @@ var defaultRTL = []string{
|
|||||||
"fivenode_go/hbrtl_ext/hello",
|
"fivenode_go/hbrtl_ext/hello",
|
||||||
"fivenode_go/hbrtl_ext/httpserver",
|
"fivenode_go/hbrtl_ext/httpserver",
|
||||||
"fivenode_go/hbrtl_ext/bridge_capi",
|
"fivenode_go/hbrtl_ext/bridge_capi",
|
||||||
|
"fivenode_go/hbrtl_ext/pgrtl",
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -4,4 +4,13 @@ go 1.25.0
|
|||||||
|
|
||||||
require five v0.0.0
|
require five v0.0.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
replace five => ../../fivedev/five
|
replace five => ../../fivedev/five
|
||||||
|
|||||||
19
go.sum
Normal file
19
go.sum
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
288
hbrtl_ext/pgrtl/pg.go
Normal file
288
hbrtl_ext/pgrtl/pg.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
// Package pgrtl exposes a small PostgreSQL client surface to PRG.
|
||||||
|
// Backed by github.com/jackc/pgx/v5 — already in Five's indirect
|
||||||
|
// dependency tree because pgserver uses pgproto3 from the same repo.
|
||||||
|
//
|
||||||
|
// PRG surface
|
||||||
|
//
|
||||||
|
// nH := PG_OPEN(cConnStr) -> integer handle, -1 on error
|
||||||
|
// PG_CLOSE(nH) -> NIL
|
||||||
|
// aRows := PG_QUERY(nH, cSQL [, aArgs]) -> array of hashes, NIL on error
|
||||||
|
// n := PG_EXEC(nH, cSQL [, aArgs]) -> rows affected (-1 on error)
|
||||||
|
// cErr := PG_LAST_ERROR(nH) -> last error message ("" if none)
|
||||||
|
//
|
||||||
|
// aArgs (optional) is a PRG array of scalar values; positions in cSQL
|
||||||
|
// use the Postgres $1, $2, ... numeric placeholders. The Go side does
|
||||||
|
// SQL injection prevention via pgx parameter binding — never string-
|
||||||
|
// concatenate user input into cSQL.
|
||||||
|
//
|
||||||
|
// PG_QUERY returns rows as an array of { columnName => value } hashes.
|
||||||
|
// Column names are lower-cased to match PostgreSQL's default casefold.
|
||||||
|
// Values come back typed: strings, numerics, booleans, nil; arrays /
|
||||||
|
// json fields arrive as their text representation (PRG can decode with
|
||||||
|
// hb_jsonDecode if needed).
|
||||||
|
//
|
||||||
|
// Handles are integers indexing into an internal map; PG_CLOSE removes
|
||||||
|
// the entry so the pool is GC'd. Concurrent PG_QUERY calls on the same
|
||||||
|
// handle are safe — pgxpool serialises through the connection pool.
|
||||||
|
package pgrtl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"five/hbrt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pool struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
lastErr string
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
pools sync.Map // map[int64]*pool
|
||||||
|
nextID atomic.Int64
|
||||||
|
queryCtx = func() context.Context {
|
||||||
|
// 30-second default cap so a runaway query can't pin a
|
||||||
|
// goroutine forever. Apps that need longer queries can run
|
||||||
|
// their own goroutine outside this RTL.
|
||||||
|
c, _ := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
hbrt.HB_FUNC("PG_OPEN", pgOpen)
|
||||||
|
hbrt.HB_FUNC("PG_CLOSE", pgClose)
|
||||||
|
hbrt.HB_FUNC("PG_QUERY", pgQuery)
|
||||||
|
hbrt.HB_FUNC("PG_EXEC", pgExec)
|
||||||
|
hbrt.HB_FUNC("PG_LAST_ERROR", pgLastError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPool(handle int64) *pool {
|
||||||
|
if v, ok := pools.Load(handle); ok {
|
||||||
|
return v.(*pool)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setErr(p *pool, msg string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.lastErr = msg
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgOpen(ctx *hbrt.HBContext) {
|
||||||
|
if ctx.PCount() < 1 || !ctx.IsChar(1) {
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn := ctx.ParC(1)
|
||||||
|
cfg, err := pgxpool.ParseConfig(conn)
|
||||||
|
if err != nil {
|
||||||
|
// No handle yet — surface via return value only.
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.MaxConns = 8
|
||||||
|
cfg.MinConns = 1
|
||||||
|
cfg.MaxConnIdleTime = 5 * time.Minute
|
||||||
|
dialCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
pp, err := pgxpool.NewWithConfig(dialCtx, cfg)
|
||||||
|
if err != nil {
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := pp.Ping(dialCtx); err != nil {
|
||||||
|
pp.Close()
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := nextID.Add(1)
|
||||||
|
pools.Store(h, &pool{pool: pp})
|
||||||
|
ctx.RetNI(int(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgClose(ctx *hbrt.HBContext) {
|
||||||
|
if ctx.PCount() < 1 || !ctx.IsNumeric(1) {
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := int64(ctx.ParNI(1))
|
||||||
|
if v, ok := pools.LoadAndDelete(h); ok {
|
||||||
|
v.(*pool).pool.Close()
|
||||||
|
}
|
||||||
|
ctx.RetNil()
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgLastError(ctx *hbrt.HBContext) {
|
||||||
|
if ctx.PCount() < 1 || !ctx.IsNumeric(1) {
|
||||||
|
ctx.RetC("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := loadPool(int64(ctx.ParNI(1)))
|
||||||
|
if p == nil {
|
||||||
|
ctx.RetC("invalid handle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
v := p.lastErr
|
||||||
|
p.mu.Unlock()
|
||||||
|
ctx.RetC(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgQuery(ctx *hbrt.HBContext) {
|
||||||
|
if ctx.PCount() < 2 || !ctx.IsNumeric(1) || !ctx.IsChar(2) {
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := loadPool(int64(ctx.ParNI(1)))
|
||||||
|
if p == nil {
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sql := ctx.ParC(2)
|
||||||
|
args := extractArgs(ctx, 3)
|
||||||
|
|
||||||
|
qCtx := queryCtx()
|
||||||
|
rows, err := p.pool.Query(qCtx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
setErr(p, err.Error())
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
fields := rows.FieldDescriptions()
|
||||||
|
colNames := make([]hbrt.Value, len(fields))
|
||||||
|
for i, f := range fields {
|
||||||
|
colNames[i] = hbrt.MakeString(strings.ToLower(string(f.Name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := hbrt.MakeArray(0)
|
||||||
|
resultArr := result.AsArray()
|
||||||
|
for rows.Next() {
|
||||||
|
vals, err := rows.Values()
|
||||||
|
if err != nil {
|
||||||
|
setErr(p, err.Error())
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row := hbrt.MakeHash()
|
||||||
|
rh := row.AsHash()
|
||||||
|
for i, raw := range vals {
|
||||||
|
rh.Set(colNames[i], goToHbValue(raw))
|
||||||
|
}
|
||||||
|
resultArr.Items = append(resultArr.Items, row)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
setErr(p, err.Error())
|
||||||
|
ctx.RetNil()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setErr(p, "")
|
||||||
|
ctx.RetVal(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pgExec(ctx *hbrt.HBContext) {
|
||||||
|
if ctx.PCount() < 2 || !ctx.IsNumeric(1) || !ctx.IsChar(2) {
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p := loadPool(int64(ctx.ParNI(1)))
|
||||||
|
if p == nil {
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sql := ctx.ParC(2)
|
||||||
|
args := extractArgs(ctx, 3)
|
||||||
|
|
||||||
|
qCtx := queryCtx()
|
||||||
|
tag, err := p.pool.Exec(qCtx, sql, args...)
|
||||||
|
if err != nil {
|
||||||
|
setErr(p, err.Error())
|
||||||
|
ctx.RetNI(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setErr(p, "")
|
||||||
|
ctx.RetNI(int(tag.RowsAffected()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractArgs walks the PRG array passed at argIdx and converts each
|
||||||
|
// element into a Go scalar suitable for pgx parameter binding. Missing
|
||||||
|
// or non-array arg returns an empty slice (a no-param query).
|
||||||
|
func extractArgs(ctx *hbrt.HBContext, argIdx int) []interface{} {
|
||||||
|
if ctx.PCount() < argIdx {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := ctx.Param(argIdx)
|
||||||
|
arr := v.AsArray()
|
||||||
|
if arr == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]interface{}, len(arr.Items))
|
||||||
|
for i, item := range arr.Items {
|
||||||
|
out[i] = hbValueToGo(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hbValueToGo turns a PRG Value into the most natural Go type for pgx.
|
||||||
|
// Strings, integers, doubles, booleans, and NIL cover the common
|
||||||
|
// labdb query parameters; anything fancier (arrays, hashes, dates)
|
||||||
|
// falls through to its fmt.Stringer form which pgx will likely reject
|
||||||
|
// — callers should pre-serialise those into JSON or text.
|
||||||
|
func hbValueToGo(v hbrt.Value) interface{} {
|
||||||
|
switch {
|
||||||
|
case v.IsNil():
|
||||||
|
return nil
|
||||||
|
case v.IsString():
|
||||||
|
return v.AsString()
|
||||||
|
case v.IsLogical():
|
||||||
|
return v.AsBool()
|
||||||
|
case v.IsNumeric():
|
||||||
|
if v.AsNumDouble() == float64(v.AsNumInt()) {
|
||||||
|
return v.AsNumInt()
|
||||||
|
}
|
||||||
|
return v.AsNumDouble()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// goToHbValue converts a pgx-decoded value into the PRG Value the row
|
||||||
|
// hash will store. Lossy for advanced PG types (numeric, json, uuid,
|
||||||
|
// arrays) which arrive here as their pgx-default Go form — callers
|
||||||
|
// can layer hb_jsonDecode / Val() on top if needed.
|
||||||
|
func goToHbValue(g interface{}) hbrt.Value {
|
||||||
|
if g == nil {
|
||||||
|
return hbrt.MakeNil()
|
||||||
|
}
|
||||||
|
switch v := g.(type) {
|
||||||
|
case string:
|
||||||
|
return hbrt.MakeString(v)
|
||||||
|
case []byte:
|
||||||
|
return hbrt.MakeString(string(v))
|
||||||
|
case bool:
|
||||||
|
return hbrt.MakeBool(v)
|
||||||
|
case int:
|
||||||
|
return hbrt.MakeInt(v)
|
||||||
|
case int32:
|
||||||
|
return hbrt.MakeInt(int(v))
|
||||||
|
case int64:
|
||||||
|
return hbrt.MakeInt(int(v))
|
||||||
|
case float32:
|
||||||
|
return hbrt.MakeDouble(float64(v), 0, 0)
|
||||||
|
case float64:
|
||||||
|
return hbrt.MakeDouble(v, 0, 0)
|
||||||
|
case time.Time:
|
||||||
|
return hbrt.MakeString(v.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
return hbrt.MakeString(fmt.Sprintf("%v", g))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user