From 176f4e5cf5debaa9f9e3af5eb1606a8fa1d3bfbc Mon Sep 17 00:00:00 2001 From: Charles KWON OhJun Date: Wed, 27 May 2026 11:09:49 +0900 Subject: [PATCH] feat(pgrtl): minimal PostgreSQL client RTL (pgxpool + 4 HB_FUNCs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/pg_test.prg | 45 +++++++ cmd/fnode/main.go | 1 + go.mod | 9 ++ go.sum | 19 +++ hbrtl_ext/pgrtl/pg.go | 288 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 app/pg_test.prg create mode 100644 go.sum create mode 100644 hbrtl_ext/pgrtl/pg.go diff --git a/app/pg_test.prg b/app/pg_test.prg new file mode 100644 index 0000000..d68f337 --- /dev/null +++ b/app/pg_test.prg @@ -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 diff --git a/cmd/fnode/main.go b/cmd/fnode/main.go index 7973371..02b8964 100644 --- a/cmd/fnode/main.go +++ b/cmd/fnode/main.go @@ -37,6 +37,7 @@ var defaultRTL = []string{ "fivenode_go/hbrtl_ext/hello", "fivenode_go/hbrtl_ext/httpserver", "fivenode_go/hbrtl_ext/bridge_capi", + "fivenode_go/hbrtl_ext/pgrtl", } func main() { diff --git a/go.mod b/go.mod index e9c888e..968cef7 100644 --- a/go.mod +++ b/go.mod @@ -4,4 +4,13 @@ go 1.25.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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ef824ba --- /dev/null +++ b/go.sum @@ -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= diff --git a/hbrtl_ext/pgrtl/pg.go b/hbrtl_ext/pgrtl/pg.go new file mode 100644 index 0000000..bf10d8a --- /dev/null +++ b/hbrtl_ext/pgrtl/pg.go @@ -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)) +}