• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codenotary / immudb / 29190098042

12 Jul 2026 10:59AM UTC coverage: 84.968% (-0.005%) from 84.973%
29190098042

Pull #2110

gh-ci

vchaindz
chore(sql): treat nil row-value slots as SQL NULL in remaining consumers

The projection-pushdown optimization leaves Row.ValuesByPosition[i]
nil for columns a query does not reference. A repo-wide audit after
the fileSorter.encodeRow panic (#2100) found five more consumers that
dereference every positional slot and stay safe only because
projection currently repopulates rows before they run:

- CTE materialization (materializeCTE/materializeRecursiveCTE)
  type-asserted v.(ValueExp) on each slot: hard panic on a nil
  interface. Extracted rowValuesToValueExps, which substitutes a
  typed SQL NULL.
- Row.digest (DISTINCT, diff) called v.Type() on every slot; nil now
  digests the same as NULL.
- setOpRowReader.rowDigest (EXCEPT/INTERSECT) called v.IsNull(); nil
  now digests the same as NULL.
- sqlRowsToProto (gRPC row serialization) dereferenced every slot;
  nil now serializes as SQL NULL.
- embedded/document readers dereferenced positions 0/1 unchecked; nil
  or missing slots now return ErrUnexpectedValue.

None of these is reachable today: every external Resolve passes nil
scanSpecs and SelectStmt.Resolve always projects before returning.
The guards remove the reliance on the "projection always runs first"
invariant, which has already broken once. Unit tests encode the
nil-slot-equals-NULL contract.

The same audit confirmed the empty-statement-slice class from #2100 is
fully closed: every ParseSQL/ParseSQLString call site is now length-
guarded, range-based, or parses hardcoded non-empty input.
Pull Request #2110: fix(pgsql): guard extended-query Parse against zero parsed statements + nil row-value-slot hardening

41 of 62 new or added lines in 7 files covered. (66.13%)

2 existing lines in 1 file now uncovered.

45258 of 53265 relevant lines covered (84.97%)

126001.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

84.97
/pkg/pgsql/server/query_machine.go
1
/*
2
Copyright 2026 Codenotary Inc. All rights reserved.
3

4
SPDX-License-Identifier: BUSL-1.1
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    https://mariadb.com/bsl11/
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package server
18

19
import (
20
        "errors"
21
        "fmt"
22
        "io"
23
        "math"
24
        "regexp"
25
        "sort"
26
        "strconv"
27
        "strings"
28

29
        "github.com/codenotary/immudb/embedded/sql"
30
        "github.com/codenotary/immudb/pkg/api/schema"
31
        pserr "github.com/codenotary/immudb/pkg/pgsql/errors"
32
        bm "github.com/codenotary/immudb/pkg/pgsql/server/bmessages"
33
        fm "github.com/codenotary/immudb/pkg/pgsql/server/fmessages"
34
)
35

36
const (
37
        helpPrefix      = "select relname, nspname, relkind from pg_catalog.pg_class c, pg_catalog.pg_namespace n where relkind in ('r', 'v', 'm', 'f', 'p') and nspname not in ('pg_catalog', 'information_schema', 'pg_toast', 'pg_temp_1') and n.oid = relnamespace order by nspname, relname"
38
        tableHelpPrefix = "select n.nspname, c.relname, a.attname, a.atttypid, t.typname, a.attnum, a.attlen, a.atttypmod, a.attnotnull, c.relhasrules, c.relkind, c.oid, pg_get_expr(d.adbin, d.adrelid), case t.typtype when 'd' then t.typbasetype else 0 end, t.typtypmod, c.relhasoids, '', c.relhassubclass from (((pg_catalog.pg_class c inner join pg_catalog.pg_namespace n on n.oid = c.relnamespace and c.relname like '"
39

40
        maxRowsPerMessage = 1024
41
)
42

43
func (s *session) QueryMachine() error {
119✔
44
        var waitForSync = false
119✔
45

119✔
46
        _, err := s.writeMessage(bm.ReadyForQuery(s.txStatus))
119✔
47
        if err != nil {
120✔
48
                return err
1✔
49
        }
1✔
50

51
        for {
1,462✔
52
                msg, extQueryMode, err := s.nextMessage()
1,344✔
53
                if err != nil {
1,369✔
54
                        if errors.Is(err, io.EOF) {
47✔
55
                                // Normal client disconnect — pq driver pools rotate
22✔
56
                                // connections aggressively. Drop to Debug so production
22✔
57
                                // logs aren't drowned in this benign event.
22✔
58
                                s.log.Debugf("connection is closed")
22✔
59
                                return nil
22✔
60
                        }
22✔
61
                        s.HandleError(err)
3✔
62
                        continue
3✔
63
                }
64

65
                // When an error is detected while processing any extended-query message, the backend issues ErrorResponse,
66
                // then reads and discards messages until a Sync is reached, then issues ReadyForQuery and returns to normal
67
                // message processing. (But note that no skipping occurs if an error is detected while processing Sync — this
68
                // ensures that there is one and only one ReadyForQuery sent for each Sync.)
69
                if waitForSync && extQueryMode {
1,305✔
70
                        if _, ok := msg.(fm.SyncMsg); !ok {
4✔
71
                                continue
2✔
72
                        }
73
                }
74

75
                switch v := msg.(type) {
1,301✔
76
                case fm.TerminateMsg:
80✔
77
                        return s.mr.CloseConnection()
80✔
78
                case fm.QueryMsg:
227✔
79
                        statements := v.GetStatements()
227✔
80

227✔
81
                        if statements == helpPrefix {
227✔
82
                                statements = "show tables"
×
83
                        }
×
84

85
                        if strings.HasPrefix(statements, tableHelpPrefix) {
228✔
86
                                tableName := strings.Split(strings.TrimPrefix(statements, tableHelpPrefix), "'")[0]
1✔
87
                                statements = fmt.Sprintf("select column_name as tq, column_name as tow, column_name as tn, column_name as COLUMN_NAME, type_name as DATA_TYPE, type_name as TYPE_NAME, type_name as p, type_name as l, type_name as s, type_name as r, is_nullable as NULLABLE, column_name as rk, column_name as cd, type_name as SQL_DATA_TYPE, type_name as sts, column_name as coll, type_name as orp, is_nullable as IS_NULLABLE, type_name as dz, type_name as ft, type_name as iau, type_name as pn, column_name as toi, column_name as btd, column_name as tmo, column_name as tin from table(%s)", tableName)
1✔
88
                        }
1✔
89

90
                        // Handle COPY ... FROM stdin
91
                        if strings.Contains(strings.ToUpper(statements), "COPY") {
227✔
92
                                s.log.Infof("pgcompat: found COPY in statement (first 200): %.200q", statements)
×
93
                        }
×
94
                        if table, cols, ok := parseCopyStatement(statements); ok {
227✔
95
                                s.log.Infof("pgcompat: COPY detected for table=%s cols=%d", table, len(cols))
×
96
                                if err := s.handleCopyFromStdin(table, cols); err != nil {
×
97
                                        s.HandleError(err)
×
98
                                }
×
99
                                if _, err = s.writeMessage(bm.ReadyForQuery(s.txStatus)); err != nil {
×
100
                                        waitForSync = extQueryMode
×
101
                                }
×
102
                                continue
×
103
                        }
104

105
                        err := s.fetchAndWriteResults(statements, nil, nil, extQueryMode)
227✔
106
                        if err != nil {
239✔
107
                                waitForSync = extQueryMode
12✔
108
                                s.HandleError(err)
12✔
109
                        }
12✔
110

111
                        if _, err = s.writeMessage(bm.ReadyForQuery(s.txStatus)); err != nil {
233✔
112
                                waitForSync = extQueryMode
6✔
113
                        }
6✔
114
                case fm.ParseMsg:
173✔
115
                        _, ok := s.statements[v.DestPreparedStatementName]
173✔
116
                        // unnamed prepared statement overrides previous
173✔
117
                        if ok && v.DestPreparedStatementName != "" {
174✔
118
                                waitForSync = extQueryMode
1✔
119
                                s.HandleError(fmt.Errorf("statement '%s' already present", v.DestPreparedStatementName))
1✔
120
                                continue
1✔
121
                        }
122

123
                        var paramCols []sql.ColDescriptor
172✔
124
                        var resCols []sql.ColDescriptor
172✔
125
                        var stmt sql.SQLStmt
172✔
126

172✔
127
                        // Apply the same pg → immudb SQL translations (type aliases,
172✔
128
                        // reserved-word renames, etc.) to the Extended Query Parse
172✔
129
                        // message contents that the simple-query path runs. Without
172✔
130
                        // this step, a CREATE TABLE sent via Parse/Bind/Execute
172✔
131
                        // creates a column named "_key" (because quoted "key" gets
172✔
132
                        // renamed only in the simple path), and the subsequent
172✔
133
                        // INSERT or SELECT under Extended Query looks up the
172✔
134
                        // un-renamed "key" and fails with "column does not exist".
172✔
135
                        v.Statements = removePGCatalogReferences(v.Statements)
172✔
136

172✔
137
                        emulableCmd := s.isEmulableInternally(v.Statements)
172✔
138
                        if s.isInBlackList(v.Statements) {
184✔
139
                                // blacklisted — skip parsing
12✔
140
                        } else if emulableCmd != nil {
175✔
141
                                // Emulable (pg_catalog etc) — precompute result columns
3✔
142
                                if probe, ok := emulableCmd.(*pgAdminProbe); ok {
3✔
143
                                        resCols = extractResultCols(probe.sql)
×
144
                                }
×
145
                                if probe, ok := emulableCmd.(*xormColumnsCmd); ok {
4✔
146
                                        resCols = extractResultCols(probe.sql)
1✔
147
                                }
1✔
148
                                if _, ok := emulableCmd.(*pgAttributeForTableCmd); ok {
3✔
149
                                        resCols = pgAttributeResultCols()
×
150
                                }
×
151
                                // Emulated queries don't go through inferParametersPrepared,
152
                                // so paramCols would default to empty. ParameterDescription
153
                                // would then advertise 0 params, and the client's Bind with
154
                                // N>0 values triggers
155
                                //   "got N parameters but the statement requires 0".
156
                                // Scan the SQL text for the highest $N marker and emit
157
                                // AnyType placeholders so the count matches what the client
158
                                // will Bind.
159
                                paramCols = inferParamColsFromSQL(v.Statements)
3✔
160
                        } else if true {
314✔
161
                                stmts, err := sql.ParseSQL(strings.NewReader(v.Statements))
157✔
162
                                if err != nil {
158✔
163
                                        waitForSync = extQueryMode
1✔
164
                                        s.HandleError(err)
1✔
165
                                        continue
1✔
166
                                }
167

168
                                // Note: as stated in the pgsql spec, the query string contained in a Parse message cannot include more than one SQL statement;
169
                                // else a syntax error is reported. This restriction does not exist in the simple-query protocol, but it does exist
170
                                // in the extended protocol, because allowing prepared statements or portals to contain multiple commands would
171
                                // complicate the protocol unduly.
172
                                if len(stmts) > 1 {
157✔
173
                                        waitForSync = extQueryMode
1✔
174
                                        s.HandleError(pserr.ErrMaxStmtNumberExceeded)
1✔
175
                                        continue
1✔
176
                                }
177

178
                                // Since 1.11.0 the grammar accepts empty/comment-only input
179
                                // and ParseSQL returns zero statements without an error
180
                                // (see issue #2100). Parse of an empty query string is valid
181
                                // in the extended protocol: skip inference (no params, no
182
                                // result columns) and register the statement; a later
183
                                // Execute re-parses it in fetchAndWriteResults and answers
184
                                // with EmptyQueryResponse.
185
                                if len(stmts) > 0 {
306✔
186
                                        if paramCols, resCols, err = s.inferParamAndResultCols(stmts[0]); err != nil {
151✔
NEW
187
                                                waitForSync = extQueryMode
×
NEW
188
                                                s.HandleError(err)
×
NEW
189
                                                continue
×
190
                                        }
191
                                }
192
                        }
193

194
                        _, err = s.writeMessage(bm.ParseComplete())
170✔
195
                        if err != nil {
171✔
196
                                waitForSync = extQueryMode
1✔
197
                                continue
1✔
198
                        }
199

200
                        newStatement := &statement{
169✔
201
                                // if no name is provided empty string marks the unnamed prepared statement
169✔
202
                                Name:         v.DestPreparedStatementName,
169✔
203
                                Params:       paramCols,
169✔
204
                                SQLStatement: v.Statements,
169✔
205
                                PreparedStmt: stmt,
169✔
206
                                Results:      resCols,
169✔
207
                        }
169✔
208

169✔
209
                        s.statements[v.DestPreparedStatementName] = newStatement
169✔
210

211
                case fm.DescribeMsg:
161✔
212
                        // The Describe message (statement variant) specifies the name of an existing prepared statement
161✔
213
                        // (or an empty string for the unnamed prepared statement). The response is a ParameterDescription
161✔
214
                        // message describing the parameters needed by the statement, followed by a RowDescription message
161✔
215
                        // describing the rows that will be returned when the statement is eventually executed (or a NoData
161✔
216
                        // message if the statement will not return rows). ErrorResponse is issued if there is no such prepared
161✔
217
                        // statement. Note that since Bind has not yet been issued, the formats to be used for returned columns
161✔
218
                        // are not yet known to the backend; the format code fields in the RowDescription message will be zeroes
161✔
219
                        // in this case.
161✔
220
                        if v.DescType == "S" {
320✔
221
                                st, ok := s.statements[v.Name]
159✔
222
                                if !ok {
160✔
223
                                        waitForSync = extQueryMode
1✔
224
                                        s.HandleError(fmt.Errorf("statement '%s' not found", v.Name))
1✔
225
                                        continue
1✔
226
                                }
227

228
                                if _, err = s.writeMessage(bm.ParameterDescription(st.Params)); err != nil {
159✔
229
                                        waitForSync = extQueryMode
1✔
230
                                        continue
1✔
231
                                }
232

233
                                if s.isEmulableInternally(st.SQLStatement) != nil && st.Results != nil {
158✔
234
                                        // Use plain column names for emulable queries
1✔
235
                                        if _, err := s.writeMessage(buildMultiColRowDescription(st.Results)); err != nil {
1✔
236
                                                waitForSync = extQueryMode
×
237
                                                continue
×
238
                                        }
239
                                } else {
156✔
240
                                        if _, err := s.writeMessage(bm.RowDescription(st.Results, nil)); err != nil {
157✔
241
                                                waitForSync = extQueryMode
1✔
242
                                                continue
1✔
243
                                        }
244
                                }
245
                        }
246
                        // The Describe message (portal variant) specifies the name of an existing portal (or an empty string
247
                        // for the unnamed portal). The response is a RowDescription message describing the rows that will be
248
                        // returned by executing the portal; or a NoData message if the portal does not contain a query that
249
                        // will return rows; or ErrorResponse if there is no such portal.
250
                        if v.DescType == "P" {
160✔
251
                                portal, ok := s.portals[v.Name]
2✔
252
                                if !ok {
3✔
253
                                        waitForSync = extQueryMode
1✔
254
                                        s.HandleError(fmt.Errorf("portal '%s' not found", v.Name))
1✔
255
                                        continue
1✔
256
                                }
257

258
                                if s.isEmulableInternally(portal.Statement.SQLStatement) != nil && portal.Statement.Results != nil {
1✔
259
                                        if _, err = s.writeMessage(buildMultiColRowDescription(portal.Statement.Results)); err != nil {
×
260
                                                waitForSync = extQueryMode
×
261
                                                continue
×
262
                                        }
263
                                } else {
1✔
264
                                        if _, err = s.writeMessage(bm.RowDescription(portal.Statement.Results, portal.ResultColumnFormatCodes)); err != nil {
2✔
265
                                                waitForSync = extQueryMode
1✔
266
                                                continue
1✔
267
                                        }
268
                                }
269
                        }
270
                case fm.SyncMsg:
324✔
271
                        waitForSync = false
324✔
272
                        s.writeMessage(bm.ReadyForQuery(s.txStatus))
324✔
273
                case fm.BindMsg:
170✔
274
                        _, ok := s.portals[v.DestPortalName]
170✔
275
                        // unnamed portal overrides previous
170✔
276
                        if ok && v.DestPortalName != "" {
171✔
277
                                waitForSync = extQueryMode
1✔
278
                                s.HandleError(fmt.Errorf("portal '%s' already present", v.DestPortalName))
1✔
279
                                continue
1✔
280
                        }
281

282
                        st, ok := s.statements[v.PreparedStatementName]
169✔
283
                        if !ok {
170✔
284
                                waitForSync = extQueryMode
1✔
285
                                s.HandleError(fmt.Errorf("statement '%s' not found", v.PreparedStatementName))
1✔
286
                                continue
1✔
287
                        }
288

289
                        encodedParams, err := buildNamedParams(st.Params, v.ParamVals)
168✔
290
                        if err != nil {
169✔
291
                                waitForSync = extQueryMode
1✔
292
                                s.HandleError(err)
1✔
293
                                continue
1✔
294
                        }
295

296
                        if _, err = s.writeMessage(bm.BindComplete()); err != nil {
168✔
297
                                waitForSync = extQueryMode
1✔
298
                                continue
1✔
299
                        }
300

301
                        newPortal := &portal{
166✔
302
                                Name:                    v.DestPortalName,
166✔
303
                                Statement:               st,
166✔
304
                                Parameters:              encodedParams,
166✔
305
                                ResultColumnFormatCodes: v.ResultColumnFormatCodes,
166✔
306
                        }
166✔
307

166✔
308
                        s.portals[v.DestPortalName] = newPortal
166✔
309
                case fm.Execute:
165✔
310
                        //query execution
165✔
311
                        portal, ok := s.portals[v.PortalName]
165✔
312
                        if !ok {
165✔
313
                                waitForSync = extQueryMode
×
314
                                s.HandleError(fmt.Errorf("portal '%s' not found", v.PortalName))
×
315
                                continue
×
316
                        }
317

318
                        delete(s.portals, v.PortalName)
165✔
319

165✔
320
                        err := s.fetchAndWriteResults(portal.Statement.SQLStatement,
165✔
321
                                portal.Parameters,
165✔
322
                                portal.ResultColumnFormatCodes,
165✔
323
                                extQueryMode,
165✔
324
                        )
165✔
325
                        if err != nil {
168✔
326
                                waitForSync = extQueryMode
3✔
327
                                s.HandleError(err)
3✔
328
                        }
3✔
329
                case fm.FlushMsg:
1✔
330
                        // there is no buffer to be flushed
331
                default:
×
332
                        waitForSync = extQueryMode
×
333
                        s.HandleError(pserr.ErrUnknowMessageType)
×
334
                }
335
        }
336
}
337

338
func (s *session) fetchAndWriteResults(statements string, parameters []*schema.NamedParam, resultColumnFormatCodes []int16, extQueryMode bool) error {
392✔
339
        tag := commandTagFor(statements)
392✔
340
        // Track explicit transaction state so the next ReadyForQuery message
392✔
341
        // reports the correct transaction-status byte. Clients (pq, JDBC)
392✔
342
        // gate commit/rollback handling on this byte; staying at 'I' after a
392✔
343
        // BEGIN gives the pq driver "unexpected transaction status idle".
392✔
344
        switch tag {
392✔
345
        case "BEGIN":
2✔
346
                s.txStatus = bm.TxStatusInTx
2✔
347
        case "COMMIT", "ROLLBACK":
2✔
348
                s.txStatus = bm.TxStatusIdle
2✔
349
        }
350
        if s.isInBlackList(statements) {
399✔
351
                _, err := s.writeMessage(bm.CommandComplete([]byte(tag)))
7✔
352
                return err
7✔
353
        }
7✔
354

355
        if i := s.isEmulableInternally(statements); i != nil {
398✔
356
                s.log.Infof("pgcompat: emulating query internally (extQueryMode=%v) sql=%.300s", extQueryMode, statements)
13✔
357
                if probe, ok := i.(*pgAdminProbe); ok && extQueryMode {
13✔
358
                        // Extended protocol: Describe already sent RowDescription, only send DataRow
×
359
                        if err := s.handlePgSystemQueryDataOnly(probe.sql); err != nil {
×
360
                                return err
×
361
                        }
×
362
                        _, err := s.writeMessage(bm.CommandComplete([]byte("ok")))
×
363
                        return err
×
364
                }
365
                if cmd, ok := i.(*pgAttributeForTableCmd); ok {
13✔
366
                        // Same pattern as pgAdminProbe above: in Extended Query mode
×
367
                        // Describe has already sent the RowDescription, so pass the
×
368
                        // flag through to skip the second one.
×
369
                        tableName := cmd.tableName
×
370
                        if tableName == "" && len(parameters) > 0 {
×
371
                                // Parameterised form — the table name is bound to $1
×
372
                                // (value carries the Postgres regclass literal like
×
373
                                // `"users"` — strip the double quotes).
×
374
                                if raw, ok := schema.RawValue(parameters[0].Value).(string); ok {
×
375
                                        tableName = strings.Trim(raw, `"`)
×
376
                                }
×
377
                        }
378
                        if err := s.handlePgAttributeForTable(tableName, extQueryMode); err != nil {
×
379
                                return err
×
380
                        }
×
381
                        _, err := s.writeMessage(bm.CommandComplete([]byte("ok")))
×
382
                        return err
×
383
                }
384
                if cmd, ok := i.(*xormColumnsCmd); ok {
14✔
385
                        // XORM column-introspection emulation. Bind values carry the
1✔
386
                        // table name (`c.relname = $1`) and schema (`s.table_schema = $2`).
1✔
387
                        if err := s.handleXormColumnsQuery(cmd.sql, parameters, extQueryMode); err != nil {
1✔
388
                                return err
×
389
                        }
×
390
                        _, err := s.writeMessage(bm.CommandComplete([]byte("ok")))
1✔
391
                        return err
1✔
392
                }
393
                if err := s.tryToHandleInternally(i); err != nil && err != pserr.ErrMessageCannotBeHandledInternally {
13✔
394
                        return err
1✔
395
                }
1✔
396

397
                _, err := s.writeMessage(bm.CommandComplete([]byte("ok")))
11✔
398
                return err
11✔
399
        }
400

401
        s.log.Infof("pgcompat: executing query via SQL engine: %.300s", statements)
372✔
402
        var err error
372✔
403
        cacheKey := removePGCatalogReferences(statements)
372✔
404
        var stmts []sql.SQLStmt
372✔
405
        if s.stmtCache != nil {
740✔
406
                stmts = s.stmtCache[cacheKey]
368✔
407
        }
368✔
408
        if stmts == nil {
735✔
409
                stmts, err = sql.ParseSQL(strings.NewReader(cacheKey))
363✔
410
                if err != nil {
367✔
411
                        return err
4✔
412
                }
4✔
413
                if s.stmtCache != nil {
717✔
414
                        // Evict the oldest entry when the cache is full (FIFO).
358✔
415
                        if len(s.stmtCache) >= stmtCacheSize {
358✔
416
                                oldest := s.stmtCacheKeys[0]
×
417
                                s.stmtCacheKeys = s.stmtCacheKeys[1:]
×
418
                                delete(s.stmtCache, oldest)
×
419
                        }
×
420
                        s.stmtCache[cacheKey] = stmts
358✔
421
                        s.stmtCacheKeys = append(s.stmtCacheKeys, cacheKey)
358✔
422
                }
423
        }
424

425
        if len(stmts) == 0 {
375✔
426
                // PostgreSQL contract: a Simple Query that is empty or contains
7✔
427
                // only comments returns EmptyQueryResponse, not CommandComplete.
7✔
428
                // k3s/kine issues `-- ping` as a liveness check and expects this.
7✔
429
                _, err := s.writeMessage(bm.EmptyQueryResponse())
7✔
430
                return err
7✔
431
        }
7✔
432

433
        for _, stmt := range stmts {
828✔
434
                switch st := stmt.(type) {
467✔
435
                case *sql.UseDatabaseStmt:
3✔
436
                        if err := s.useDatabase(st.DB); err != nil {
4✔
437
                                return err
1✔
438
                        }
1✔
439
                        continue
2✔
440
                case sql.DataSource:
188✔
441
                        if err = s.query(st, parameters, resultColumnFormatCodes, extQueryMode); err != nil {
191✔
442
                                return err
3✔
443
                        }
3✔
444
                default:
276✔
445
                        if err = s.exec(st, parameters, resultColumnFormatCodes, extQueryMode); err != nil {
278✔
446
                                return err
2✔
447
                        }
2✔
448
                }
449
        }
450

451
        _, err = s.writeMessage(bm.CommandComplete([]byte(tag)))
355✔
452
        if err != nil {
355✔
453
                return err
×
454
        }
×
455

456
        return nil
355✔
457
}
458

459
var pgTypeReplacements = []struct {
460
        re   *regexp.Regexp
461
        repl string
462
}{
463
        // Strip double quotes from identifiers, but keep them if the identifier
464
        // starts with a digit. SQL reserved words get prefixed with underscore.
465
        // Quoted identifiers: prefix digit-start names with t_, prefix reserved words with _
466
        {regexp.MustCompile(`"(\d\w*)"`), "t_$1"},
467
        // Quoted-identifier keyword escape. Whenever a SQL token is wrapped
468
        // in double quotes AND its content matches an immudb-reserved
469
        // keyword, prefix the identifier with an underscore so it parses as
470
        // a regular IDENTIFIER instead of triggering a grammar conflict.
471
        // The list is kept in sync with the keyword map in
472
        // embedded/sql/parser.go (see scripts/dump_immudb_keywords.sh — or
473
        // regenerate by grepping `^\s+"[A-Z_]+":` out of parser.go and
474
        // lowercasing).
475
        {regexp.MustCompile(`"((?i:add|admin|after|all|alter|and|as|asc|auto_increment|avg|before|begin|between|bigint|bigserial|blob|boolean|by|bytea|cascade|case|cast|check|column|commit|conflict|constraint|count|create|cross|database|databases|date|day|decimal|default|delete|desc|diff|distinct|do|double|drop|else|end|except|exists|explain|extract|false|fetch|first|float|for|foreign|from|full|grant|grants|group|having|history|hour|if|ilike|in|index|inner|insert|int|integer|intersect|into|is|join|json|jsonb|key|last|lateral|left|like|limit|max|min|minute|month|natural|not|nothing|null|nulls|numeric|of|offset|on|only|or|order|over|partition|password|primary|privileges|read|readwrite|real|recursive|references|release|rename|returning|revoke|right|rollback|rows|savepoint|second|select|sequence|serial|set|show|since|smallint|snapshot|sum|table|tables|then|timestamp|timestamptz|to|transaction|true|truncate|tx|union|unique|until|update|upsert|use|user|users|using|uuid|values|varchar|view|when|where|with|year))"`), "_$1"},
476
        {regexp.MustCompile(`"(\w+)"`), "$1"},
477

478
        // Strip PG-only ::TYPE casts before the type-name translation below.
479
        // Types natively recognised by immudb's SQL parser via the SCAST
480
        // production (boundexp :: sql_type) — INTEGER/INT/BIGINT, BOOLEAN,
481
        // FLOAT/REAL/NUMERIC/DECIMAL, VARCHAR, BLOB/BYTEA, UUID, JSON/JSONB,
482
        // TIMESTAMP/TIMESTAMPTZ/DATE — are intentionally left intact so the
483
        // engine can evaluate them (e.g. '42'::INTEGER). PG-specific or
484
        // string types are removed: ::text would otherwise become
485
        // ::VARCHAR[4096] after the text rewrite below; ::regclass, ::name,
486
        // ::oid etc. have no immudb equivalent.
487
        {regexp.MustCompile(`(?i)::(?:text|character(?:\s+varying)?|name|oid|regclass|regtype|regproc|regoper|regnamespace|anyarray|anyelement|anynonarray|void|cstring|internal|opaque|unknown|pg_[a-z_]+)(?:\[\])?`), ""},
488

489
        // DEFAULT nextval('...'::regclass) → strip (AUTO_INCREMENT handles it)
490
        {regexp.MustCompile(`(?i)\s*DEFAULT\s+nextval\s*\([^)]+\)`), ""},
491

492
        // Strip `COLLATE <identifier>` column/type modifiers. immudb has no
493
        // collation support — the default byte-wise comparison is always in
494
        // effect. k3s/kine emits `name text COLLATE "C"` to pin Postgres's
495
        // index behavior; without stripping, the unquote pass would leave us
496
        // with `COLLATE C` which still fails to parse. Run before the
497
        // double-quote unquote rule so the quoted form is handled in one go.
498
        {regexp.MustCompile(`(?i)\s+COLLATE\s+(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)`), ""},
499
        // DEFAULT ('now'...) → DEFAULT NOW()
500
        {regexp.MustCompile(`(?i)\s*DEFAULT\s+\(\s*'now'\s*\)\s*`), " DEFAULT NOW() "},
501

502
        // Type aliases — order matters (longer matches first)
503
        {regexp.MustCompile(`(?i)\btimestamp\s*\(\s*\d+\s*\)\s+without\s+time\s+zone\b`), "TIMESTAMP"},
504
        {regexp.MustCompile(`(?i)\btimestamp\s*\(\s*\d+\s*\)\s+with\s+time\s+zone\b`), "TIMESTAMP"},
505
        {regexp.MustCompile(`(?i)\btimestamp\s+without\s+time\s+zone\b`), "TIMESTAMP"},
506
        {regexp.MustCompile(`(?i)\btimestamp\s+with\s+time\s+zone\b`), "TIMESTAMP"},
507
        // Rails emits `timestamp(6)` for datetime columns; immudb TIMESTAMP
508
        // has no fractional-second-precision knob, drop the (N) qualifier.
509
        {regexp.MustCompile(`(?i)\btimestamp\s*\(\s*\d+\s*\)`), "TIMESTAMP"},
510
        {regexp.MustCompile(`(?i)\bcharacter\s+varying\s*\(\s*(\d+)\s*\)`), "VARCHAR[$1]"},
511
        {regexp.MustCompile(`(?i)\bcharacter\s*\(\s*(\d+)\s*\)`), "VARCHAR[$1]"},
512
        // PG's `CHAR(N)` is fixed-length string; immudb has only VARCHAR[N].
513
        // Map `CHAR(N)` (and the bare `CHAR` short form Postgres also accepts)
514
        // to the variable-length equivalent. Storage cost differs marginally
515
        // but the wire-visible behaviour matches.
516
        {regexp.MustCompile(`(?i)\bchar\s*\(\s*(\d+)\s*\)`), "VARCHAR[$1]"},
517
        // Unsized variant: Rails' `t.string` (and Rails's internal
518
        // schema_migrations DDL) emits bare `character varying` without a
519
        // size qualifier. immudb's bare VARCHAR is effectively unlimited
520
        // and therefore cannot be used as an index key. Cap to 256 bytes
521
        // so primary-key / unique-index creation succeeds for well-formed
522
        // Rails DDL. Callers that need a larger text column can use TEXT
523
        // which maps to VARCHAR[4096] elsewhere in this translator.
524
        {regexp.MustCompile(`(?i)\bcharacter\s+varying\b`), "VARCHAR[256]"},
525
        {regexp.MustCompile(`(?i)\bvarchar\b(?:\s*\()`), "VARCHAR_PAREN_FIXME("},
526
        {regexp.MustCompile(`(?i)\bVARCHAR_PAREN_FIXME\(\s*(\d+)\s*\)`), "VARCHAR[$1]"},
527
        {regexp.MustCompile(`(?i)\bdouble\s+precision\b`), "FLOAT"},
528
        {regexp.MustCompile(`(?i)\bbigserial\b`), "INTEGER AUTO_INCREMENT"},
529
        {regexp.MustCompile(`(?i)\bserial\b`), "INTEGER AUTO_INCREMENT"},
530
        {regexp.MustCompile(`(?i)\bsmallint\b`), "INTEGER"},
531
        {regexp.MustCompile(`(?i)\bbigint\b`), "INTEGER"},
532
        {regexp.MustCompile(`(?i)\breal\b`), "FLOAT"},
533
        {regexp.MustCompile(`(?i)\bnumeric\s*\([^)]*\)`), "FLOAT"},
534
        {regexp.MustCompile(`(?i)\bnumeric\b`), "FLOAT"},
535
        {regexp.MustCompile(`(?i)\bdecimal\s*\([^)]*\)`), "FLOAT"},
536
        // PG array types → just use a generous VARCHAR (must come BEFORE text
537
        // mapping). Handles: "text[]", "jsonb[]", "uuid[]", etc.
538
        // Size bumped from 4096 to 1 MB so Gitea's PushCommits JSON payload
539
        // and other unbounded-TEXT columns (workflow dispatch inputs, action
540
        // logs, etc.) fit. Per-db MaxValueLen is 32 MB (pkg/server/db_options.go:135).
541
        {regexp.MustCompile(`(?i)\w+\[\]`), "VARCHAR[1048576]"},
542
        // Array-of-sized-VARCHAR, e.g. "character varying(255)[]" already got
543
        // rewritten by an earlier rule to "VARCHAR[255][]"; collapse so the
544
        // trailing [] doesn't confuse immudb's grammar.
545
        {regexp.MustCompile(`(?i)VARCHAR\[\d+\]\s*\[\s*\]`), "VARCHAR[1048576]"},
546
        // PG's unbounded TEXT → 1 MB VARCHAR. Critical for Gitea's action.content
547
        // (commit JSON for a push of N commits grows O(N) and exceeds 4 KB for
548
        // even modest pushes), issue bodies, workflow YAML blobs, etc.
549
        {regexp.MustCompile(`(?i)\btext\b`), "VARCHAR[1048576]"},
550
        {regexp.MustCompile(`(?i)\bbytea\b`), "BLOB"},
551
        // PG accepts both `BOOL` and `BOOLEAN`; immudb's grammar only knows
552
        // the long form. XORM and several other ORMs emit the short form.
553
        {regexp.MustCompile(`(?i)\bbool\b`), "BOOLEAN"},
554

555
        // (ALTER TABLE … ADD <coldef> → ADD COLUMN <coldef>: handled in
556
        // a Go post-pass below since Go RE2 has no negative lookahead and a
557
        // blanket regex would also rewrite ADD CONSTRAINT / FOREIGN / etc.)
558

559
        // Strip trailing `; COMMENT ON … IS '…'` clauses that XORM and
560
        // JDBC ORMs append to DDL. The standalone `COMMENT ON` form is
561
        // already in pgUnsupportedDDL but the trailing-clause form survives
562
        // the per-statement blacklist check (it's part of a multi-statement
563
        // SQL string sent in one Parse). Drop it here so the leading DDL
564
        // reaches the engine cleanly.
565
        {regexp.MustCompile(`(?is);\s*COMMENT\s+ON\s+[^;]+(?:;|$)`), ""},
566

567
        // `BEGIN READ WRITE`, `BEGIN READ ONLY`, `START TRANSACTION READ
568
        // WRITE` etc. — PG transaction-mode suffixes that immudb's BEGIN
569
        // grammar doesn't accept. Strip the mode; immudb's transactions
570
        // are read/write by default and the only client-visible difference
571
        // from a SELECT-only block is performance, which doesn't matter
572
        // for correctness probing.
573
        {regexp.MustCompile(`(?i)\b(BEGIN|START\s+TRANSACTION)\s+(READ\s+(?:WRITE|ONLY)|ISOLATION\s+LEVEL\s+\S+(?:\s+\S+)?|DEFERRABLE|NOT\s+DEFERRABLE)`), "BEGIN"},
574
        // Explicit `NULL` after a column type is a no-op nullability marker
575
        // in PG (it's the default). immudb's grammar has no such production
576
        // and rejects the bare keyword. Strip it; the column stays nullable
577
        // because nothing else marked it NOT NULL.
578
        {regexp.MustCompile(`(?i)((?:VARCHAR(?:\[\d+\])?|INTEGER|BOOLEAN|BLOB|FLOAT|TIMESTAMP|UUID|JSON|VARCHAR_PAREN_FIXME\([^)]*\)))\s+NULL\b`), "$1"},
579
        // XORM-style `… DEFAULT <value> NULL` — same issue: the trailing
580
        // NULL is a redundant nullability marker after a default. Strip it
581
        // regardless of the default token (TRUE/FALSE/0/numeric/'string'/…).
582
        {regexp.MustCompile(`(?i)(DEFAULT\s+(?:'[^']*'|[^\s,()]+))\s+NULL\b`), "$1"},
583
        {regexp.MustCompile(`(?i)\btsvector\b`), "VARCHAR[1048576]"},
584
        {regexp.MustCompile(`(?i)\bmpaa_rating\b`), "VARCHAR[10]"},
585
        // PG custom domain types from dvdrental
586
        {regexp.MustCompile(`(?i)\byear\b`), "INTEGER"},
587

588
        // (DEFAULT nextval and ::casts already handled above)
589

590
        // Rename bare reserved words used as column names (detected by following type keyword)
591
        {regexp.MustCompile(`(?i)\bpassword\s+(VARCHAR|INTEGER|BOOLEAN|TIMESTAMP|BLOB|FLOAT)`), "_password $1"},
592
        {regexp.MustCompile(`(?i)\bdatabase\s+(VARCHAR|INTEGER|BOOLEAN|TIMESTAMP|BLOB|FLOAT)`), "_database $1"},
593
        {regexp.MustCompile(`(?i)\btransaction\s+(VARCHAR|INTEGER|BOOLEAN|TIMESTAMP|BLOB|FLOAT)`), "_transaction $1"},
594

595
        // immudb doesn't support DEFAULT expr NOT NULL together — strip NOT NULL after DEFAULT
596
        {regexp.MustCompile(`(?i)(DEFAULT\s+\S+(?:\([^)]*\))?)\s+NOT\s+NULL`), "$1"},
597

598
        // PRIMARY KEY columns are already implicitly NOT NULL in immudb's
599
        // grammar, and the parser rejects an explicit `NOT NULL` after the
600
        // PRIMARY KEY keywords with
601
        //   "syntax error: unexpected NOT, expecting ',' or ')'".
602
        // XORM, Hibernate, and several JDBC ORMs emit the redundant pair
603
        // (`PRIMARY KEY NOT NULL`) for PK columns, so strip the trailing
604
        // NOT NULL when it follows PRIMARY KEY.
605
        {regexp.MustCompile(`(?i)(PRIMARY\s+KEY)\s+NOT\s+NULL\b`), "$1"},
606
        // And the symmetric form (`NOT NULL PRIMARY KEY`) is fine, but a
607
        // duplicated NOT NULL on either side of PRIMARY KEY isn't:
608
        {regexp.MustCompile(`(?i)NOT\s+NULL\s+(PRIMARY\s+KEY)\s+NOT\s+NULL\b`), "$1"},
609

610
        // Rails emits `SELECT "table_name".* FROM "table_name" WHERE ...` as
611
        // its standard all-columns projection on ActiveRecord models. immudb
612
        // grammar has no `identifier.*` production (only bare `*`), so strip
613
        // the table prefix. Works for single-table queries (~99 % of AR);
614
        // JOIN queries with multiple `t1.*, t2.*` become `*, *` which is
615
        // technically different but returns the same union of columns.
616
        {regexp.MustCompile(`(?i)\b[A-Za-z_][A-Za-z0-9_]*\s*\.\s*\*`), "*"},
617

618
        // After the `table.* -> *` reduction above, XORM's
619
        //   SELECT project.*, project_issue.issue_id FROM project ...
620
        // becomes `SELECT *, project_issue.issue_id FROM ...`, which
621
        // immudb's grammar rejects (the `opt_targets` production is either
622
        // bare `*` or a comma-separated expression list, never a mix).
623
        // `SELECT *` over a JOIN in immudb already yields all columns from
624
        // all joined tables, so the trailing comma-separated list is
625
        // redundant — collapse it back to a bare `*`.
626
        {regexp.MustCompile(`(?is)(SELECT\s+\*)(?:\s*,[^,]+?)+(\s+FROM\b)`), "$1$2"},
627

628
        // XORM / Gitea's QueryIssueContentHistoryEditedCountMap emits
629
        //   SELECT comment_id, COUNT(1) as history_count ...
630
        //   ... HAVING count(1) > 1
631
        // immudb's aggregate-func grammar accepts COUNT(*) and COUNT(col)
632
        // but not an integer-literal arg, producing "unexpected INTEGER_LIT
633
        // at position 26" at parse time. Postgres/MySQL treat `COUNT(1)`
634
        // and `COUNT(*)` identically (row count), so rewrite.
635
        {regexp.MustCompile(`(?i)\bCOUNT\s*\(\s*1\s*\)`), "COUNT(*)"},
636

637
        // Gitea's eventsource UIDcounts (GetUIDsAndNotificationCounts) emits
638
        //   SELECT user_id, sum(case when status = $1 then 1 else 0 end) AS count
639
        //     FROM notification WHERE <cond> GROUP BY user_id
640
        // immudb's grammar has no expression-based aggregate (AggExp); the
641
        // CASE WHEN inside SUM(…) parses as "unexpected CASE at position 24".
642
        // For this specific shape the 0/1 indicator is equivalent to a
643
        // row count filtered by the CASE predicate, so hoist the predicate
644
        // into the WHERE clause and swap SUM(...) for COUNT(*). Users with
645
        // zero matching rows drop out of the result, which is the correct
646
        // semantics for an unread-notification counter (the Gitea frontend
647
        // treats absence as zero).
648
        {
649
                regexp.MustCompile(`(?is)SELECT\s+(\w+)\s*,\s*sum\s*\(\s*case\s+when\s+(\w+)\s*=\s*\$(\d+)\s+then\s+1\s+else\s+0\s+end\s*\)\s+AS\s+(\w+)\s+FROM\s+(\w+)\s+WHERE\s+`),
650
                `SELECT $1, COUNT(*) AS $4 FROM $5 WHERE $2 = $$$3 AND `,
651
        },
652

653
        // Rails emits Postgres-style `ON CONFLICT (col_list) DO ...` for
654
        // `create_or_find_by!`, `upsert_all`, etc. immudb's grammar accepts
655
        // only the column-less form (`ON CONFLICT DO NOTHING` / `ON CONFLICT
656
        // DO UPDATE SET`), so drop the column list. The resulting statement
657
        // has the same intent: skip / update on any conflict.
658
        {regexp.MustCompile(`(?i)\bON\s+CONFLICT\s*\([^)]*\)\s*(DO)\b`), "ON CONFLICT $1"},
659

660
        // Rails decides whether a table already exists by probing pg_class;
661
        // with immudb's canned emulation that probe always reports "absent",
662
        // so Rails emits bare `CREATE TABLE` rather than the idempotent
663
        // `CREATE TABLE IF NOT EXISTS`. On the first run the plain form
664
        // succeeds. On a restart after a successful schema load the plain
665
        // form fails with "table already exists" and aborts db:prepare.
666
        // Make every `CREATE TABLE` implicitly idempotent by inserting the
667
        // `IF NOT EXISTS` clause when it is missing — Postgres and immudb
668
        // both accept the form, and Rails's control flow is unchanged.
669
        {regexp.MustCompile(`(?i)^(\s*CREATE\s+TABLE)\s+(?:IF\s+NOT\s+EXISTS\s+)?(?P<name>"[^"]+"|\w+)`), "$1 IF NOT EXISTS $2"},
670

671
        // (INSERT-into-schema_migrations idempotency is applied later in a
672
        //  Go-side pass because regex alone cannot express "only add
673
        //  ON CONFLICT if one is not already present".)
674

675
        // Strip CHECK constraints (may be nested parens)
676
        {regexp.MustCompile(`(?i)\bCHECK\s*\([^)]*\)`), ""},
677

678
        // Strip PostgreSQL stored generated (virtual) column clauses.
679
        // Rails' `t.virtual ..., stored: true` emits
680
        //   "col" type GENERATED ALWAYS AS (expression) STORED
681
        // immudb has no generated-column support, so drop the clause and
682
        // leave the column as a regular nullable column. The expression
683
        // may contain arbitrary nested parens; we rely on STORED as the
684
        // (non-greedy) terminator. This works provided the expression
685
        // body does not itself contain the literal identifier "STORED",
686
        // which is safe in practice for ActiveRecord-emitted DDL.
687
        {regexp.MustCompile(`(?is)\s+GENERATED\s+ALWAYS\s+AS\s+\(.*?\)\s+STORED\b`), ""},
688

689
        // Strip CONSTRAINT keyword with name
690
        {regexp.MustCompile(`(?i)\bCONSTRAINT\s+\w+\s+`), ""},
691

692
        // Strip table-level FOREIGN KEY … REFERENCES … constraints BEFORE the
693
        // column-level REFERENCES strip below. Without this, the column-level
694
        // strip removes only the REFERENCES clause, leaving a dangling
695
        // "FOREIGN KEY (col)" that causes "unexpected ')', expecting REFERENCES".
696
        // immudb's SQL engine does accept FOREIGN KEY in CREATE TABLE (parsed
697
        // but not enforced), but stripping here is equivalent and avoids the
698
        // interaction with the REFERENCES rule.
699
        {regexp.MustCompile(`(?i),?\s*\bFOREIGN\s+KEY\s*\([^)]*\)\s*REFERENCES\s+\S+\s*\([^)]*\)(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))*`), ""},
700
        // Strip column-level REFERENCES (inline FK) with optional ON DELETE/UPDATE
701
        {regexp.MustCompile(`(?i)\bREFERENCES\s+\S+\s*\([^)]*\)(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))*`), ""},
702

703
        // Strip the optional index name from `CREATE [UNIQUE] INDEX [IF NOT
704
        // EXISTS] <name> ON …`. PostgreSQL requires (and pg_dump emits) a
705
        // name; immudb's grammar rejects one (sql_grammar.y:390-408 has
706
        // `CREATE INDEX … ON …` with no name). The alternation matches both
707
        // the double-quoted and bare-identifier forms. When the name is
708
        // absent (`CREATE INDEX ON …`, which immudb accepts natively) the
709
        // `<name> ON` suffix can't be satisfied and the pattern does not
710
        // match, so no-op rewrite.
711
        {regexp.MustCompile(`(?i)\bCREATE\s+(UNIQUE\s+)?INDEX\s+(IF\s+NOT\s+EXISTS\s+)?(?:"[^"]+"|[A-Za-z_]\w*)\s+ON\b`), "CREATE ${1}INDEX ${2}ON"},
712

713
        // Strip the optional PostgreSQL column-alias list from
714
        // `CREATE VIEW [IF NOT EXISTS] <name>(col1, col2, …) AS SELECT …`.
715
        // immudb's grammar (sql_grammar.y:355-360) only accepts
716
        // `CREATE VIEW <name> AS dqlstmt`. PG's outer column list renames
717
        // the SELECT's output columns; pg_dump emits both the list AND
718
        // matching `AS` aliases in the SELECT, so dropping the outer list
719
        // preserves names for the common case. Multi-line matching (?is)
720
        // because dumps put each column on its own line. If a list ever
721
        // contains nested parens the pattern will not match and the user
722
        // gets the original pre-fix "unexpected '(', expecting AS" error,
723
        // which is acceptable — PG lists are identifier-only in practice.
724
        {regexp.MustCompile(`(?is)\bCREATE\s+VIEW\s+(IF\s+NOT\s+EXISTS\s+)?("[^"]+"|[A-Za-z_]\w*)\s*\([^)]*\)\s+AS\b`), "CREATE VIEW ${1}${2} AS"},
725

726
        // NOTE: `UNIQUE` inline-on-column / table-level constraints are NOT
727
        // stripped here. They are translated into a trailing
728
        // `CREATE UNIQUE INDEX ON tbl(col)` statement by the Go pass in
729
        // extractUniqueConstraints (unique_ddl.go) which runs before this
730
        // regex pipeline. Stripping the keyword outright would silently drop
731
        // uniqueness guarantees — which is exactly what the buggy prior
732
        // behavior did.
733

734
        // date type — must come after timestamp replacements
735
        // Only match standalone 'date' as a type (after a column name, not in other contexts)
736
        {regexp.MustCompile(`(?i)\bdate\b`), "TIMESTAMP"},
737
}
738

739
var createTableRe = regexp.MustCompile(`(?i)^\s*CREATE\s+TABLE\s+`)
740
var primaryKeyInlineRe = regexp.MustCompile(`(?i)PRIMARY\s+KEY`)
741

742
var insertSchemaMigrationsRe = regexp.MustCompile(`(?is)\A(\s*INSERT\s+INTO\s+(?:"schema_migrations"|schema_migrations)\s+\([^)]*\)\s+VALUES\s+.+?)(\s*;?\s*)\z`)
743

744
// maskStringLiterals replaces every single-quoted SQL string literal in s
745
// with a sentinel placeholder so the downstream regex rewrites in
746
// pgTypeReplacements — which are not SQL-aware — cannot touch the
747
// characters inside. Without this, e.g. the `"(\w+)"` identifier-unquoter
748
// would strip double quotes out of a JSON value passed as a SQL literal
749
// (`INSERT … VALUES('{"k":1}')` → `INSERT … VALUES('{k:1}')`, which fails
750
// validation as invalid JSON).
751
//
752
// The returned restore function substitutes the originals back after all
753
// rewrites have run. SQL string grammar recognised: `''` inside a literal
754
// is an escaped single quote. Anything else (including `"`, backslashes,
755
// newlines) is preserved verbatim.
756
func maskStringLiterals(s string) (string, func(string) string) {
596✔
757
        var literals []string
596✔
758
        var b strings.Builder
596✔
759
        b.Grow(len(s))
596✔
760
        i := 0
596✔
761
        for i < len(s) {
38,651✔
762
                c := s[i]
38,055✔
763
                if c != '\'' {
75,801✔
764
                        b.WriteByte(c)
37,746✔
765
                        i++
37,746✔
766
                        continue
37,746✔
767
                }
768
                j := i + 1
309✔
769
                for j < len(s) {
2,395✔
770
                        if s[j] != '\'' {
3,862✔
771
                                j++
1,776✔
772
                                continue
1,776✔
773
                        }
774
                        if j+1 < len(s) && s[j+1] == '\'' {
312✔
775
                                j += 2
2✔
776
                                continue
2✔
777
                        }
778
                        break
308✔
779
                }
780
                if j >= len(s) {
310✔
781
                        // Unterminated string literal — emit the tail verbatim
1✔
782
                        // and stop; downstream will fail on its own if needed.
1✔
783
                        b.WriteString(s[i:])
1✔
784
                        break
1✔
785
                }
786
                idx := len(literals)
308✔
787
                literals = append(literals, s[i:j+1])
308✔
788
                fmt.Fprintf(&b, "\x01%d\x01", idx)
308✔
789
                i = j + 1
308✔
790
        }
791
        masked := b.String()
596✔
792

596✔
793
        restore := func(m string) string {
1,183✔
794
                if len(literals) == 0 {
973✔
795
                        return m
386✔
796
                }
386✔
797
                return stringLiteralTokenRe.ReplaceAllStringFunc(m, func(tok string) string {
507✔
798
                        idxStr := tok[1 : len(tok)-1]
306✔
799
                        idx, err := strconv.Atoi(idxStr)
306✔
800
                        if err != nil || idx < 0 || idx >= len(literals) {
306✔
801
                                return tok
×
802
                        }
×
803
                        return literals[idx]
306✔
804
                })
805
        }
806
        return masked, restore
596✔
807
}
808

809
var stringLiteralTokenRe = regexp.MustCompile("\x01(\\d+)\x01")
810

811
// commandTagRe extracts the leading SQL verb from a statement so we can
812
// emit the standard Postgres CommandComplete tag (`BEGIN`, `COMMIT`,
813
// `INSERT 0 0`, …) instead of the catch-all `ok`. Some clients (the pq
814
// Go driver, XORM's transaction state machine, several JDBC drivers)
815
// inspect the tag to decide whether a transaction actually committed,
816
// and `ok` confuses them with "unexpected command tag ok".
817
var commandTagRe = regexp.MustCompile(`(?i)^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE\s+TABLE|CREATE\s+INDEX|CREATE\s+UNIQUE\s+INDEX|CREATE\s+DATABASE|CREATE\s+VIEW|DROP\s+TABLE|DROP\s+INDEX|DROP\s+VIEW|DROP\s+DATABASE|ALTER\s+TABLE|TRUNCATE|BEGIN|START\s+TRANSACTION|COMMIT|END|ROLLBACK|ABORT|SAVEPOINT|RELEASE|SET|SHOW|GRANT|REVOKE|EXPLAIN|USE|VACUUM|ANALYZE|COPY|LOCK|FETCH|MOVE|CLOSE|DECLARE|LISTEN|NOTIFY|UNLISTEN|PREPARE|EXECUTE|DEALLOCATE|RESET|CHECKPOINT|REINDEX|DISCARD)\b`)
818

819
// commandTagFor returns the PG-canonical CommandComplete tag for a SQL
820
// statement. Defaults to "ok" if the verb isn't recognised, so existing
821
// behaviour is preserved for unusual statements.
822
func commandTagFor(sqlText string) string {
415✔
823
        m := commandTagRe.FindStringSubmatch(sqlText)
415✔
824
        if m == nil {
443✔
825
                return "ok"
28✔
826
        }
28✔
827
        verb := strings.ToUpper(strings.Join(strings.Fields(m[1]), " "))
387✔
828
        switch verb {
387✔
829
        case "BEGIN", "START TRANSACTION":
6✔
830
                return "BEGIN"
6✔
831
        case "COMMIT", "END":
3✔
832
                return "COMMIT"
3✔
833
        case "ROLLBACK", "ABORT":
3✔
834
                return "ROLLBACK"
3✔
835
        case "INSERT":
65✔
836
                // PG: "INSERT <oid> <count>". Most clients only check the verb;
65✔
837
                // emit a plausible 0-row tag.
65✔
838
                return "INSERT 0 0"
65✔
839
        case "UPDATE", "DELETE", "SELECT", "FETCH", "MOVE", "COPY":
191✔
840
                return verb + " 0"
191✔
841
        }
842
        return verb
119✔
843
}
844

845
// quotedSchemaPrefixRe matches a quoted schema qualifier directly
846
// followed by a dot and a (quoted or unquoted) identifier. The match
847
// covers known schemas only (public, pg_catalog, information_schema)
848
// so it can't accidentally rewrite a quoted column-list element like
849
// `"public", "private"` outside the schema-prefix context.
850
var quotedSchemaPrefixRe = regexp.MustCompile(`"(public|pg_catalog|information_schema)"\s*\.`)
851

852
// stripQuotedSchemaPrefix removes `"public".`, `"pg_catalog".`,
853
// `"information_schema".` prefixes that ORMs (XORM, Hibernate, JDBC)
854
// emit in DDL and DML. immudb's grammar has no schema.table production,
855
// so leaving the dot in place trips the parser with
856
//   "syntax error: unexpected DOT, expecting '('".
857
func stripQuotedSchemaPrefix(s string) string {
586✔
858
        return quotedSchemaPrefixRe.ReplaceAllString(s, "")
586✔
859
}
586✔
860

861
// alterAddRe matches `ALTER TABLE <name> ADD <next-token>` so a Go-side
862
// pass can decide whether to inject `COLUMN` before <next-token>.
863
// Captures: 1=tableName, 2=whitespace-after-ADD, 3=next-token (raw).
864
var alterAddRe = regexp.MustCompile(`(?i)\bALTER\s+TABLE\s+(\S+)\s+ADD(\s+)(\S+)`)
865

866
// addClauseSkipKeywords is the set of tokens that follow ADD in
867
// constraint/index forms (ADD CONSTRAINT, ADD FOREIGN KEY, …) — we
868
// must NOT inject COLUMN ahead of these because immudb's grammar parses
869
// them as a separate ALTER variant (or the rule is blacklisted).
870
var addClauseSkipKeywords = map[string]bool{
871
        "COLUMN": true, "CONSTRAINT": true, "FOREIGN": true,
872
        "PRIMARY": true, "UNIQUE": true, "CHECK": true, "INDEX": true,
873
}
874

875
// injectAddColumnKeyword rewrites `ALTER TABLE x ADD <colspec>` to
876
// `ALTER TABLE x ADD COLUMN <colspec>` when <colspec> begins with a
877
// (quoted or unquoted) identifier rather than a constraint keyword.
878
// PG accepts both forms; immudb's grammar requires the explicit COLUMN.
879
func injectAddColumnKeyword(s string) string {
588✔
880
        return alterAddRe.ReplaceAllStringFunc(s, func(match string) string {
597✔
881
                m := alterAddRe.FindStringSubmatch(match)
9✔
882
                if m == nil {
9✔
883
                        return match
×
884
                }
×
885
                next := strings.ToUpper(strings.Trim(m[3], `"`))
9✔
886
                if addClauseSkipKeywords[next] {
16✔
887
                        return match
7✔
888
                }
7✔
889
                return fmt.Sprintf("ALTER TABLE %s ADD COLUMN%s%s", m[1], m[2], m[3])
2✔
890
        })
891
}
892

893
// paramMarkerRe matches `$N` placeholder references in a SQL statement,
894
// excluding any that fall inside a single-quoted literal (those are
895
// pre-masked by maskStringLiterals, so the leading `$` is preserved
896
// verbatim and we don't accidentally count them here).
897
var paramMarkerRe = regexp.MustCompile(`\$(\d+)`)
898

899
// cleanup regexes used in removePGCatalogReferences — compiled once at init.
900
var (
901
        doubleSpaceRe   = regexp.MustCompile(`  +`)
902
        doubleCommaRe   = regexp.MustCompile(`(?m)^\s*,\s*,`)
903
        trailingCommaRe = regexp.MustCompile(`,\s*\)`)
904
)
905

906
// inferParamColsFromSQL scans a SQL statement for the highest `$N`
907
// placeholder and returns N AnyType ColDescriptors named param1..paramN.
908
// Used in the Extended Query Parse path for queries that the wire layer
909
// emulates (pg_catalog, pg_tables, …) and so never reach the engine's
910
// real inferParameters. Without this, ParameterDescription advertises
911
// zero parameters and the client's subsequent Bind with N>0 values
912
// trips the standard "got N parameters but the statement requires 0"
913
// pq driver error.
914
func inferParamColsFromSQL(stmtSQL string) []sql.ColDescriptor {
9✔
915
        masked, _ := maskStringLiterals(stmtSQL)
9✔
916
        max := 0
9✔
917
        for _, m := range paramMarkerRe.FindAllStringSubmatch(masked, -1) {
15✔
918
                n, err := strconv.Atoi(m[1])
6✔
919
                if err == nil && n > max {
12✔
920
                        max = n
6✔
921
                }
6✔
922
        }
923
        if max == 0 {
14✔
924
                return nil
5✔
925
        }
5✔
926
        cols := make([]sql.ColDescriptor, max)
4✔
927
        for i := 0; i < max; i++ {
12✔
928
                cols[i] = sql.ColDescriptor{Column: fmt.Sprintf("param%d", i+1), Type: sql.AnyType}
8✔
929
        }
8✔
930
        return cols
4✔
931
}
932

933
// psqlOperatorRegexEqRe turns psql's anchored single-name regex match
934
// into equality. psql's \d-family meta-commands emit queries like
935
//   WHERE c.relname OPERATOR(pg_catalog.~) '^(mytable)$'
936
// and immudb's SQL grammar has no `~` regex operator. The anchored
937
// regex body, which psql uses to pin to exactly one name, is
938
// semantically identical to `= 'mytable'`.
939
//
940
// The rule requires a `|`-free body — alternations (`^(a|b|c)$`) are
941
// structural and deferred to the future AST rewriter (Part B).
942
var psqlOperatorRegexEqRe = regexp.MustCompile(
943
        `(?i)\s+OPERATOR\s*\(\s*(?:pg_catalog\.)?~\s*\)\s*'\^\(([^)|]+)\)\$'`)
944

945
// psqlOperatorRegexEqNoParenRe covers the rarer paren-less form some
946
// clients emit: `c.relname ~ '^mytable$'`.
947
var psqlOperatorRegexEqNoParenRe = regexp.MustCompile(
948
        `(?i)\s+OPERATOR\s*\(\s*(?:pg_catalog\.)?~\s*\)\s*'\^([^$|']+)\$'`)
949

950
// psqlOidStringLiteralRe coerces `'N'` back to `N` when compared to
951
// an integer oid column. PostgreSQL accepts both forms via implicit
952
// coercion; immudb's SQL engine does not, so psql's second round-trip
953
// `WHERE c.oid = '16384'` would fail type-check against pg_class.oid
954
// (IntegerType) without this rewrite.
955
//
956
// The column allowlist is bounded to known integer-oid columns across
957
// pg_class / pg_attribute / pg_index so the rewrite can't over-match
958
// into a genuinely string-typed column.
959
var psqlOidStringLiteralRe = regexp.MustCompile(
960
        `(?i)(\.\s*(?:oid|relnamespace|relfilenode|reltoastrelid|reltype|reloftype|relam|relowner|attrelid|atttypid|attcollation|indrelid|indexrelid|indcollation)\s*=\s*)'(\d+)'`)
961

962
// normalizePsqlPatterns performs the subset of query rewriting that
963
// must see string literals intact. Everything else goes through
964
// pgTypeReplacements, which runs after maskStringLiterals hides
965
// literal contents. See removePGCatalogReferences for the flow.
966
//
967
// Each rule is narrowly scoped to a psql meta-command pattern — over-
968
// matching would corrupt non-psql queries, so we prefer leaving
969
// edge cases to the canned-handler fallback.
970
//
971
// Historical note: an earlier incarnation of this function had a
972
// rule (psqlAlwaysZeroOidCaseRe) that collapsed psql \d's mixed-type
973
// CASE expression:
974
//
975
//        CASE WHEN c.reloftype = 0 THEN '' ELSE c.reloftype::…::text END
976
//
977
// That was a regex workaround for an engine-level limitation —
978
// embedded/sql/stmt.go:CaseWhenExp.inferType used to reject any
979
// CASE whose arms disagreed on type outside the INT↔FLOAT pair.
980
// The fix migrated to coerceTypesForCase in the engine, so the
981
// regex rule is no longer needed — every mixed-type CASE
982
// (INT/VARCHAR, FLOAT/VARCHAR, BOOL/VARCHAR, …) now widens at
983
// plan time and is converted at reduce time via the existing
984
// runtime converter matrix at embedded/sql/type_conversion.go.
985
// See TestCaseWhen_MixedTypeWidening for the replacement coverage.
986
func normalizePsqlPatterns(sql string) string {
589✔
987
        sql = psqlOperatorRegexEqRe.ReplaceAllString(sql, " = '$1'")
589✔
988
        sql = psqlOperatorRegexEqNoParenRe.ReplaceAllString(sql, " = '$1'")
589✔
989
        sql = psqlOidStringLiteralRe.ReplaceAllString(sql, "${1}${2}")
589✔
990
        return sql
589✔
991
}
589✔
992

993
// removePGCatalogReferences is the single entry point used across the
994
// pgwire server to normalise incoming SQL before it reaches the immudb
995
// engine (or the cache-key machinery). Historically it was a single
996
// monolithic regex chain; B1 of the Part B roadmap adds an AST-based
997
// alternative gated by a server option (WithSQLRewriter) and kept off
998
// by default. The AST path falls back to the regex chain on any
999
// parser error so we pay no correctness tax while the new code soaks.
1000
//
1001
// See docs/pg-compat-roadmap.md for the programme and
1002
// pkg/pgsql/server/rewrite/ for the AST implementation.
1003
func removePGCatalogReferences(sqlStr string) string {
606✔
1004
        if sqlRewriterMode() == "ast" {
634✔
1005
                if out, ok := astRewrite(sqlStr); ok {
55✔
1006
                        return out
27✔
1007
                }
27✔
1008
                // Parser rejected the input OR the AST path hit a gap. Fall
1009
                // through to the regex chain so every existing caller keeps
1010
                // getting correct output.
1011
        }
1012
        return removePGCatalogReferencesRegex(sqlStr)
579✔
1013
}
1014

1015
// removePGCatalogReferencesRegex is the legacy regex-based rewriter.
1016
// B2 will gradually retire passes from here as their AST equivalents
1017
// land; B3 removes the function entirely once the AST path handles
1018
// every supported query shape.
1019
func removePGCatalogReferencesRegex(sqlStr string) string {
579✔
1020
        // Normalise PG-specific patterns that operate on string literals
579✔
1021
        // *before* masking (maskStringLiterals hides literal contents so
579✔
1022
        // the downstream regex chain can't touch them, but these rules
579✔
1023
        // specifically need to see the literal). See normalizePsqlPatterns
579✔
1024
        // for the rules and why they're kept separate.
579✔
1025
        sqlStr = normalizePsqlPatterns(sqlStr)
579✔
1026

579✔
1027
        // Mask string literals first so regex-based rewrites can't mutate
579✔
1028
        // their contents (see maskStringLiterals for rationale).
579✔
1029
        s, restore := maskStringLiterals(sqlStr)
579✔
1030

579✔
1031
        s = strings.ReplaceAll(s, "pg_catalog.", "")
579✔
1032
        s = strings.ReplaceAll(s, "information_schema.", "information_schema_")
579✔
1033
        s = strings.ReplaceAll(s, "public.", "")
579✔
1034

579✔
1035
        // Strip QUOTED schema-qualified prefixes too — XORM, Hibernate, JDBC
579✔
1036
        // and friends emit `"public"."tablename"` (and `"pg_catalog"."pg_x"`)
579✔
1037
        // in DDL. The plain string ReplaceAll's above don't catch the quoted
579✔
1038
        // form, and the immudb grammar has no schema.table production so the
579✔
1039
        // stray DOT shows up at parse time as
579✔
1040
        //   "syntax error: unexpected DOT, expecting '('".
579✔
1041
        // Run this BEFORE the identifier-unquote pass in pgTypeReplacements
579✔
1042
        // so we eliminate the dot in the same step that drops the prefix.
579✔
1043
        s = stripQuotedSchemaPrefix(s)
579✔
1044

579✔
1045
        // Apply PG type translations
579✔
1046
        for _, r := range pgTypeReplacements {
34,740✔
1047
                s = r.re.ReplaceAllString(s, r.repl)
34,161✔
1048
        }
34,161✔
1049

1050
        // PG `ALTER TABLE … ADD <coldef>` → immudb `ADD COLUMN <coldef>`.
1051
        // Done as a Go pass so we can skip the constraint forms (ADD
1052
        // CONSTRAINT / FOREIGN / PRIMARY / UNIQUE / CHECK / INDEX) which
1053
        // take a different syntax in immudb (or are blacklisted).
1054
        s = injectAddColumnKeyword(s)
579✔
1055

579✔
1056
        // Idempotency for Rails's schema_migrations bulk insert. See
579✔
1057
        // pgTypeReplacements for the long explanation. Using a Go pass here
579✔
1058
        // so we can check "ON CONFLICT already present" safely — the
579✔
1059
        // standard library regex has no lookahead.
579✔
1060
        if m := insertSchemaMigrationsRe.FindStringSubmatchIndex(s); m != nil {
581✔
1061
                if !strings.Contains(strings.ToUpper(s), "ON CONFLICT") {
3✔
1062
                        head := s[m[2]:m[3]]
1✔
1063
                        tail := s[m[4]:m[5]]
1✔
1064
                        s = head + " ON CONFLICT DO NOTHING" + tail
1✔
1065
                }
1✔
1066
        }
1067

1068
        // Auto-add PRIMARY KEY for CREATE TABLE without one
1069
        if createTableRe.MatchString(s) && !primaryKeyInlineRe.MatchString(s) {
587✔
1070
                s = addPrimaryKeyToCreateTable(s)
8✔
1071
        }
8✔
1072

1073
        // Translate inline / table-level UNIQUE constraints into trailing
1074
        // `CREATE UNIQUE INDEX` statements. Must run AFTER
1075
        // addPrimaryKeyToCreateTable — that pass scans for the last ")" in
1076
        // the string, and splitting the CREATE TABLE off from the new
1077
        // CREATE UNIQUE INDEX statements would put that ")" inside the
1078
        // INDEX parens (wrong target).
1079
        s = extractUniqueConstraints(s)
579✔
1080

579✔
1081
        // Clean up double spaces and empty lines
579✔
1082
        s = doubleSpaceRe.ReplaceAllString(s, " ")
579✔
1083
        s = doubleCommaRe.ReplaceAllString(s, ",")
579✔
1084
        // Remove trailing commas before closing paren
579✔
1085
        s = trailingCommaRe.ReplaceAllString(s, "\n)")
579✔
1086

579✔
1087
        return restore(s)
579✔
1088
}
1089

1090
// addPrimaryKeyToCreateTable adds a PRIMARY KEY clause to a CREATE TABLE
1091
// that doesn't have one. Picks the first NOT NULL column, or an id/ID column,
1092
// or the first column.
1093
func addPrimaryKeyToCreateTable(sql string) string {
8✔
1094
        // Find the closing paren of the CREATE TABLE
8✔
1095
        lastParen := strings.LastIndex(sql, ")")
8✔
1096
        if lastParen < 0 {
8✔
1097
                return sql
×
1098
        }
×
1099

1100
        // Extract column definitions between first ( and last )
1101
        firstParen := strings.Index(sql, "(")
8✔
1102
        if firstParen < 0 || firstParen >= lastParen {
8✔
1103
                return sql
×
1104
        }
×
1105

1106
        colSection := sql[firstParen+1 : lastParen]
8✔
1107
        lines := strings.Split(colSection, ",")
8✔
1108

8✔
1109
        var firstCol, notNullCol, idCol string
8✔
1110

8✔
1111
        for _, line := range lines {
21✔
1112
                line = strings.TrimSpace(line)
13✔
1113
                if line == "" {
13✔
1114
                        continue
×
1115
                }
1116
                words := strings.Fields(line)
13✔
1117
                if len(words) < 2 {
14✔
1118
                        continue
1✔
1119
                }
1120
                colName := words[0]
12✔
1121
                // Skip if it looks like a constraint, not a column
12✔
1122
                upper := strings.ToUpper(colName)
12✔
1123
                if upper == "CONSTRAINT" || upper == "PRIMARY" || upper == "FOREIGN" || upper == "CHECK" || upper == "UNIQUE" {
13✔
1124
                        continue
1✔
1125
                }
1126

1127
                if firstCol == "" {
19✔
1128
                        firstCol = colName
8✔
1129
                }
8✔
1130

1131
                if strings.Contains(strings.ToUpper(line), "NOT NULL") && notNullCol == "" {
13✔
1132
                        notNullCol = colName
2✔
1133
                }
2✔
1134

1135
                lowerCol := strings.ToLower(colName)
11✔
1136
                if (lowerCol == "id" || strings.HasSuffix(lowerCol, "_id") || strings.HasSuffix(lowerCol, "id")) && idCol == "" {
14✔
1137
                        idCol = colName
3✔
1138
                }
3✔
1139
        }
1140

1141
        // Pick the best PK column
1142
        pkCol := notNullCol
8✔
1143
        if pkCol == "" {
14✔
1144
                pkCol = idCol
6✔
1145
        }
6✔
1146
        if pkCol == "" {
11✔
1147
                pkCol = firstCol
3✔
1148
        }
3✔
1149
        if pkCol == "" {
8✔
1150
                return sql
×
1151
        }
×
1152

1153
        // Insert PRIMARY KEY before the closing paren
1154
        before := sql[:lastParen]
8✔
1155
        after := sql[lastParen:]
8✔
1156

8✔
1157
        // Remove trailing comma/whitespace from before
8✔
1158
        before = strings.TrimRight(before, " \t\n,")
8✔
1159

8✔
1160
        return before + ",\n    PRIMARY KEY (" + pkCol + ")\n" + after
8✔
1161
}
1162

1163
func (s *session) query(st sql.DataSource, parameters []*schema.NamedParam, resultColumnFormatCodes []int16, skipRowDesc bool) error {
188✔
1164
        tx, err := s.sqlTx()
188✔
1165
        if err != nil {
188✔
1166
                return err
×
1167
        }
×
1168

1169
        reader, err := s.db.SQLQueryPrepared(s.ctx, tx, st, schema.NamedParamsFromProto(parameters))
188✔
1170
        if err != nil {
191✔
1171
                return err
3✔
1172
        }
3✔
1173
        defer reader.Close()
185✔
1174

185✔
1175
        cols, err := reader.Columns(s.ctx)
185✔
1176
        if err != nil {
185✔
1177
                return err
×
1178
        }
×
1179

1180
        if !skipRowDesc {
219✔
1181
                if _, err = s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
34✔
1182
                        return err
×
1183
                }
×
1184
        }
1185

1186
        return sql.ReadRowsBatch(s.ctx, reader, maxRowsPerMessage, func(rowBatch []*sql.Row) error {
363✔
1187
                _, err := s.writeMessage(bm.DataRow(rowBatch, len(cols), resultColumnFormatCodes))
178✔
1188
                return err
178✔
1189
        })
178✔
1190
}
1191

1192
func (s *session) exec(st sql.SQLStmt, namedParams []*schema.NamedParam, resultColumnFormatCodes []int16, skipRowDesc bool) error {
276✔
1193
        params := make(map[string]interface{}, len(namedParams))
276✔
1194

276✔
1195
        for _, p := range namedParams {
295✔
1196
                params[p.Name] = schema.RawValue(p.Value)
19✔
1197
        }
19✔
1198

1199
        tx, err := s.sqlTx()
276✔
1200
        if err != nil {
276✔
1201
                return err
×
1202
        }
×
1203

1204
        ntx, _, err := s.db.SQLExecPrepared(s.ctx, tx, []sql.SQLStmt{st}, params)
276✔
1205
        s.tx = ntx
276✔
1206

276✔
1207
        return err
276✔
1208
}
1209

1210
type portal struct {
1211
        Name                    string
1212
        Statement               *statement
1213
        Parameters              []*schema.NamedParam
1214
        ResultColumnFormatCodes []int16
1215
}
1216

1217
type statement struct {
1218
        Name         string
1219
        SQLStatement string
1220
        PreparedStmt sql.SQLStmt
1221
        Params       []sql.ColDescriptor
1222
        Results      []sql.ColDescriptor
1223
}
1224

1225
func (s *session) inferParamAndResultCols(stmt sql.SQLStmt) ([]sql.ColDescriptor, []sql.ColDescriptor, error) {
151✔
1226
        var resCols []sql.ColDescriptor
151✔
1227

151✔
1228
        ds, ok := stmt.(sql.DataSource)
151✔
1229
        if ok {
297✔
1230
                rr, err := s.db.SQLQueryPrepared(s.ctx, s.tx, ds, nil)
146✔
1231
                if err != nil {
146✔
1232
                        return nil, nil, err
×
1233
                }
×
1234

1235
                resCols, err = rr.Columns(s.ctx)
146✔
1236
                if err != nil {
146✔
1237
                        return nil, nil, err
×
1238
                }
×
1239

1240
                rr.Close()
146✔
1241
        }
1242

1243
        r, err := s.db.InferParametersPrepared(s.ctx, s.tx, stmt)
151✔
1244
        if err != nil {
151✔
1245
                return nil, nil, err
×
1246
        }
×
1247

1248
        if len(r) > math.MaxInt16 {
151✔
1249
                return nil, nil, pserr.ErrMaxParamsNumberExceeded
×
1250
        }
×
1251

1252
        var paramsNameList []string
151✔
1253
        for n := range r {
196✔
1254
                paramsNameList = append(paramsNameList, n)
45✔
1255
        }
45✔
1256
        // Sort by the numeric suffix of "paramN" rather than lexicographically.
1257
        // Lexical sort gives param1, param10, param11, …, param2, param3 —
1258
        // which makes positional Bind values land on the wrong $N in
1259
        // statements with 10+ parameters and silently corrupts inserts.
1260
        sort.Slice(paramsNameList, func(i, j int) bool {
192✔
1261
                ai, aok := paramNumericSuffix(paramsNameList[i])
41✔
1262
                bi, bok := paramNumericSuffix(paramsNameList[j])
41✔
1263
                if aok && bok {
82✔
1264
                        return ai < bi
41✔
1265
                }
41✔
1266
                return paramsNameList[i] < paramsNameList[j]
×
1267
        })
1268

1269
        paramCols := make([]sql.ColDescriptor, 0)
151✔
1270
        for _, n := range paramsNameList {
196✔
1271
                paramCols = append(paramCols, sql.ColDescriptor{Column: n, Type: r[n]})
45✔
1272
        }
45✔
1273
        return paramCols, resCols, nil
151✔
1274
}
1275

1276
// paramNumericSuffix returns the integer suffix of names like "param12".
1277
// Used to sort parameter names by position rather than lexicographically.
1278
func paramNumericSuffix(name string) (int, bool) {
82✔
1279
        if !strings.HasPrefix(name, "param") {
82✔
1280
                return 0, false
×
1281
        }
×
1282
        n, err := strconv.Atoi(name[len("param"):])
82✔
1283
        if err != nil {
82✔
1284
                return 0, false
×
1285
        }
×
1286
        return n, true
82✔
1287
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc