Foundation for the upcoming PostgreSQL-wire server. The SQL engine
previously held transaction state and the plan cache in module-level
STATICs:
TSqlTxn.prg:16-18
STATIC s_aTxnLog := {}
STATIC s_lInTxn := .F.
STATIC s_hSavepoints := NIL
TFiveSQL.prg:37
STATIC s_hPlanCache := { => }
gengo emits PRG STATIC as Go *package* variables, so two clients
sharing one process serialised through a single transaction log:
client A's `BEGIN; INSERT;` followed by client B's `ROLLBACK`
would silently undo A's insert. Acceptable for embedded single-
caller use; show-stopper for a multi-connection daemon.
Moved each of those into instance fields on a new TSqlSession class.
Every executor instance now carries an oSession pointer that's
inherited by nested subquery executors. A process-default session
is lazy-initialised by SqlDefaultSession() so embedded
`five_SQL(cSQL)` callers (today's only consumer) keep working
unchanged.
Changes
-------
* `_FiveSql2/src/TSqlSession.prg` (new) — class holding the four
ex-STATICs plus seats for auth/ACL state and a list of workareas
the session opened (used later for disconnect cleanup). Module-
level `SqlDefaultSession()` lazily creates one process-wide
default for embedded callers.
* `_FiveSql2/src/TSqlTxn.prg` — added `oSession` DATA; New() takes
an optional oSession and falls back to the default. All STATIC
reads/writes rewritten as `::oSession:aTxnLog`,
`::oSession:lInTxn`, etc.
* `_FiveSql2/src/TFiveSQL.prg` — added `oSession` DATA; New() takes
an optional second arg. Plan-cache reads/writes route through
`::oSession:hPlanCache`. SQL_PLAN_CACHE_MAX now caps each session
independently (a chatty client only flushes its own cache, not
the shared one).
* `_FiveSql2/src/TSqlExecutor.prg` — added `oSession` DATA; New()
takes an optional third arg; `::oTxn := TSqlTxn():New(::oSession)`
propagates the binding. Every in-class `TSqlExecutor():New(...)`
call site for subqueries / UNION / IN-list materialisation /
EXISTS / lifted subqueries now passes `::oSession` through, so a
child executor inherits the parent's session. Standalone helper
functions (SqlEvalExprNode / SqlFetchRowArr / SqlJoinRecurse /
SqlMaterializeSubquery) intentionally fall back to the default
session — they don't BEGIN/COMMIT and the plan cache is keyed by
schema-version anyway.
* `_FiveSql2/src/FiveSqlCls.prg` — `five_SQL()` gains an optional
fourth arg `oSession`. Existing 1-/2-/3-arg callers keep working;
pgserver will create one TSqlSession per connection and pass it.
Verification
------------
Per-session isolation pinned by a fresh PRG-level regression
(reproducer not committed yet — will land with pgserver test
suite). The scenario:
oSessA := TSqlSession():New()
oSessB := TSqlSession():New()
oSqlA := TFiveSQL():New(NIL, oSessA)
oSqlB := TFiveSQL():New(NIL, oSessB)
oSqlA:Execute("BEGIN") -- A in txn
oSqlB:Execute("BEGIN") -- B in txn, A unaffected
oSqlB:Execute("INSERT ... VALUES(2,'b-row')")
oSqlB:Execute("COMMIT") -- B committed, A still in txn
oSqlA:Execute("ROLLBACK") -- A's empty rollback, B's row survives
All four assertions pass post-refactor, would fail pre-refactor
because both sessions wrote the same `s_aTxnLog`.
All six release gates green:
go test ./... ✓
FiveSql2 SQL:1999 43/43 ✓
Harbour compat 56/56 ✓
std.ch 17/17 ✓
FRB 7/7 ✓
examples 65/71 ✓ (unchanged baseline)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the new SqlEach RTL into FiveSql2's front-end so users write
the SQL they know and opt into streaming with a familiar Harbour
code block — no manual RTL plumbing.
API:
/* Existing array form — unchanged, 43-test still green */
aR := five_SQL( "SELECT name FROM t" )
/* New block form — zero intermediate rows, 2x raw PRG */
five_SQL( "SELECT id, name FROM t WHERE salary > 50000", NIL,
{|nID, cName| Process(nID, cName)} )
Parameter order (cSQL, aParams, bBlock) keeps backward compatibility
with every existing call site. Passing NIL for aParams when only a
block is needed is standard Harbour idiom.
Routing:
* TFiveSQL:Execute now takes an optional bBlock parameter and
stores it on TSqlExecutor as ::bRowBlock.
* TSqlExecutor:RunSelect's existing Go fast path (same guards as
before: single table, no JOIN/GROUP/aggregate, plain column
projections, WHERE compilable via SqlExprToPrg) branches on
::bRowBlock:
- block present → SqlEach streams rows through the block
- block absent → SqlScan materializes into aRows (current path)
* Post-processing (GROUP BY / ORDER BY / window / DISTINCT / LIMIT)
runs on empty aRows when block mode fires — all are no-ops on
empty input, so the sequence stays harmless.
* RunSelect returns NIL (not {fields, rows}) when ::bRowBlock was
used — signals "streaming semantics, all work done in the block".
Complex queries (JOIN, GROUP BY, subquery, window, ORDER BY not
matchable by an index, LIMIT/OFFSET, etc.) still fall back to the
array path even when a block is supplied — those genuinely require
materialization. Block mode is a fast-path opt-in, not a semantic
change.
End-to-end bench (50k rows, steady state — includes the user-side
loop/block for every row):
Path Time Speedup vs raw
──────────────────────────────────────────────────────────────
Raw PRG DO WHILE !Eof() + WHERE sum 7.6ms 1.00x
five_SQL array + FOR 7.7ms ~same
five_SQL + block (new) 3.7ms 2.05x ← beats raw
──────────────────────────────────────────────────────────────
Raw PRG no WHERE 6.1ms 1.00x
five_SQL + block, no WHERE 2.9ms 2.10x ← beats raw
SQL now pays for itself on end-to-end timing — not just competitive
with hand-rolled RDD loops, but faster than them. The layered cost
of FieldGet's Frame+RTL-dispatch that hand-written loops incur per
call is gone; the block-callback path captures *dbf.DBFArea directly
via FastFieldGetter and uses PcOpFieldGet to bypass dispatch in the
compiled WHERE predicate.
Validation:
- FiveSql2 43/43 (array API unchanged)
- Harbour compat 51/51
- go test ./... ALL PASS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>