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

codenotary / immudb / 24841571249

23 Apr 2026 02:44PM UTC coverage: 85.275% (-4.0%) from 89.306%
24841571249

push

gh-ci

web-flow
feat: v1.11.0 PostgreSQL compatibility and SQL feature expansion (#2090)

* Add structured audit logging with immutable audit trail

Introduces a new --audit-log flag that records all gRPC operations as
structured JSON events in immudb's tamper-proof KV store. Events are
stored under the audit: key prefix in systemdb, queryable via Scan and
verifiable via VerifiableGet. An async buffered writer ensures minimal
latency impact. Configurable event filtering (all/write/admin) via
--audit-log-events flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add PostgreSQL ORM compatibility layer and verification functions

Extend the pgsql wire protocol with immudb verification functions
(immudb_state, immudb_verify_row, immudb_verify_tx, immudb_history,
immudb_tx) accessible via standard SQL SELECT statements.

Add pg_catalog resolvers (pg_attribute, pg_index, pg_constraint,
pg_type, pg_settings, pg_description) and information_schema
resolvers (tables, columns, schemata, key_column_usage) to support
ORM introspection from Django, SQLAlchemy, GORM, and ActiveRecord.

Add PostgreSQL compatibility functions: current_database,
current_schema, current_user, format_type, pg_encoding_to_char,
pg_get_expr, pg_get_constraintdef, obj_description, col_description,
has_table_privilege, has_schema_privilege, and others.

Add SHOW statement emulation for common ORM config queries and
schema-qualified name stripping for information_schema and public
schema references.

* Implement EXISTS and IN subquery support in SQL engine

Replace the previously stubbed ExistsBoolExp and InSubQueryExp
implementations with working non-correlated subquery execution.

EXISTS subqueries resolve the inner SELECT and check if any rows
are returned. IN subqueries resolve the inner SELECT, iterate the
result set, and compare each value against the outer expression.
Both support NOT variants (NOT EXISTS, NOT IN).

Correlated subqueries (referencing outer query columns) ar... (continued)

7254 of 10471 new or added lines in 124 files covered. (69.28%)

119 existing lines in 18 files now uncovered.

44597 of 52298 relevant lines covered (85.27%)

127591.66 hits per line

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

84.94
/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 {
115✔
44
        var waitForSync = false
115✔
45

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

51
        for {
1,402✔
52
                msg, extQueryMode, err := s.nextMessage()
1,288✔
53
                if err != nil {
1,311✔
54
                        if errors.Is(err, io.EOF) {
45✔
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)
1✔
62
                        continue
1✔
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,251✔
70
                        if _, ok := msg.(fm.SyncMsg); !ok {
4✔
71
                                continue
2✔
72
                        }
73
                }
74

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

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

85
                        if strings.HasPrefix(statements, tableHelpPrefix) {
224✔
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") {
223✔
NEW
92
                                s.log.Infof("pgcompat: found COPY in statement (first 200): %.200q", statements)
×
NEW
93
                        }
×
94
                        if table, cols, ok := parseCopyStatement(statements); ok {
223✔
NEW
95
                                s.log.Infof("pgcompat: COPY detected for table=%s cols=%d", table, len(cols))
×
NEW
96
                                if err := s.handleCopyFromStdin(table, cols); err != nil {
×
NEW
97
                                        s.HandleError(err)
×
NEW
98
                                }
×
NEW
99
                                if _, err = s.writeMessage(bm.ReadyForQuery(s.txStatus)); err != nil {
×
NEW
100
                                        waitForSync = extQueryMode
×
NEW
101
                                }
×
NEW
102
                                continue
×
103
                        }
104

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

111
                        if _, err = s.writeMessage(bm.ReadyForQuery(s.txStatus)); err != nil {
229✔
112
                                waitForSync = extQueryMode
6✔
113
                        }
6✔
114
                case fm.ParseMsg:
164✔
115
                        _, ok := s.statements[v.DestPreparedStatementName]
164✔
116
                        // unnamed prepared statement overrides previous
164✔
117
                        if ok && v.DestPreparedStatementName != "" {
165✔
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
163✔
124
                        var resCols []sql.ColDescriptor
163✔
125
                        var stmt sql.SQLStmt
163✔
126

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

163✔
137
                        emulableCmd := s.isEmulableInternally(v.Statements)
163✔
138
                        if s.isInBlackList(v.Statements) {
175✔
139
                                // blacklisted — skip parsing
12✔
140
                        } else if emulableCmd != nil {
166✔
141
                                // Emulable (pg_catalog etc) — precompute result columns
3✔
142
                                if probe, ok := emulableCmd.(*pgAdminProbe); ok {
3✔
NEW
143
                                        resCols = extractResultCols(probe.sql)
×
NEW
144
                                }
×
145
                                if probe, ok := emulableCmd.(*xormColumnsCmd); ok {
4✔
146
                                        resCols = extractResultCols(probe.sql)
1✔
147
                                }
1✔
148
                                if _, ok := emulableCmd.(*pgAttributeForTableCmd); ok {
3✔
NEW
149
                                        resCols = pgAttributeResultCols()
×
NEW
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 {
296✔
161
                                stmts, err := sql.ParseSQL(strings.NewReader(v.Statements))
148✔
162
                                if err != nil {
149✔
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 {
148✔
173
                                        waitForSync = extQueryMode
1✔
174
                                        s.HandleError(pserr.ErrMaxStmtNumberExceeded)
1✔
175
                                        continue
1✔
176
                                }
177
                                if paramCols, resCols, err = s.inferParamAndResultCols(stmts[0]); err != nil {
146✔
178
                                        waitForSync = extQueryMode
×
179
                                        s.HandleError(err)
×
180
                                        continue
×
181
                                }
182
                        }
183

184
                        _, err = s.writeMessage(bm.ParseComplete())
161✔
185
                        if err != nil {
162✔
186
                                waitForSync = extQueryMode
1✔
187
                                continue
1✔
188
                        }
189

190
                        newStatement := &statement{
160✔
191
                                // if no name is provided empty string marks the unnamed prepared statement
160✔
192
                                Name:         v.DestPreparedStatementName,
160✔
193
                                Params:       paramCols,
160✔
194
                                SQLStatement: v.Statements,
160✔
195
                                PreparedStmt: stmt,
160✔
196
                                Results:      resCols,
160✔
197
                        }
160✔
198

160✔
199
                        s.statements[v.DestPreparedStatementName] = newStatement
160✔
200

201
                case fm.DescribeMsg:
154✔
202
                        // The Describe message (statement variant) specifies the name of an existing prepared statement
154✔
203
                        // (or an empty string for the unnamed prepared statement). The response is a ParameterDescription
154✔
204
                        // message describing the parameters needed by the statement, followed by a RowDescription message
154✔
205
                        // describing the rows that will be returned when the statement is eventually executed (or a NoData
154✔
206
                        // message if the statement will not return rows). ErrorResponse is issued if there is no such prepared
154✔
207
                        // statement. Note that since Bind has not yet been issued, the formats to be used for returned columns
154✔
208
                        // are not yet known to the backend; the format code fields in the RowDescription message will be zeroes
154✔
209
                        // in this case.
154✔
210
                        if v.DescType == "S" {
306✔
211
                                st, ok := s.statements[v.Name]
152✔
212
                                if !ok {
153✔
213
                                        waitForSync = extQueryMode
1✔
214
                                        s.HandleError(fmt.Errorf("statement '%s' not found", v.Name))
1✔
215
                                        continue
1✔
216
                                }
217

218
                                if _, err = s.writeMessage(bm.ParameterDescription(st.Params)); err != nil {
152✔
219
                                        waitForSync = extQueryMode
1✔
220
                                        continue
1✔
221
                                }
222

223
                                if s.isEmulableInternally(st.SQLStatement) != nil && st.Results != nil {
151✔
224
                                        // Use plain column names for emulable queries
1✔
225
                                        if _, err := s.writeMessage(buildMultiColRowDescription(st.Results)); err != nil {
1✔
NEW
226
                                                waitForSync = extQueryMode
×
NEW
227
                                                continue
×
228
                                        }
229
                                } else {
149✔
230
                                        if _, err := s.writeMessage(bm.RowDescription(st.Results, nil)); err != nil {
150✔
231
                                                waitForSync = extQueryMode
1✔
232
                                                continue
1✔
233
                                        }
234
                                }
235
                        }
236
                        // The Describe message (portal variant) specifies the name of an existing portal (or an empty string
237
                        // for the unnamed portal). The response is a RowDescription message describing the rows that will be
238
                        // returned by executing the portal; or a NoData message if the portal does not contain a query that
239
                        // will return rows; or ErrorResponse if there is no such portal.
240
                        if v.DescType == "P" {
153✔
241
                                portal, ok := s.portals[v.Name]
2✔
242
                                if !ok {
3✔
243
                                        waitForSync = extQueryMode
1✔
244
                                        s.HandleError(fmt.Errorf("portal '%s' not found", v.Name))
1✔
245
                                        continue
1✔
246
                                }
247

248
                                if s.isEmulableInternally(portal.Statement.SQLStatement) != nil && portal.Statement.Results != nil {
1✔
NEW
249
                                        if _, err = s.writeMessage(buildMultiColRowDescription(portal.Statement.Results)); err != nil {
×
NEW
250
                                                waitForSync = extQueryMode
×
NEW
251
                                                continue
×
252
                                        }
253
                                } else {
1✔
254
                                        if _, err = s.writeMessage(bm.RowDescription(portal.Statement.Results, portal.ResultColumnFormatCodes)); err != nil {
2✔
255
                                                waitForSync = extQueryMode
1✔
256
                                                continue
1✔
257
                                        }
258
                                }
259
                        }
260
                case fm.SyncMsg:
308✔
261
                        waitForSync = false
308✔
262
                        s.writeMessage(bm.ReadyForQuery(s.txStatus))
308✔
263
                case fm.BindMsg:
163✔
264
                        _, ok := s.portals[v.DestPortalName]
163✔
265
                        // unnamed portal overrides previous
163✔
266
                        if ok && v.DestPortalName != "" {
164✔
267
                                waitForSync = extQueryMode
1✔
268
                                s.HandleError(fmt.Errorf("portal '%s' already present", v.DestPortalName))
1✔
269
                                continue
1✔
270
                        }
271

272
                        st, ok := s.statements[v.PreparedStatementName]
162✔
273
                        if !ok {
163✔
274
                                waitForSync = extQueryMode
1✔
275
                                s.HandleError(fmt.Errorf("statement '%s' not found", v.PreparedStatementName))
1✔
276
                                continue
1✔
277
                        }
278

279
                        encodedParams, err := buildNamedParams(st.Params, v.ParamVals)
161✔
280
                        if err != nil {
162✔
281
                                waitForSync = extQueryMode
1✔
282
                                s.HandleError(err)
1✔
283
                                continue
1✔
284
                        }
285

286
                        if _, err = s.writeMessage(bm.BindComplete()); err != nil {
161✔
287
                                waitForSync = extQueryMode
1✔
288
                                continue
1✔
289
                        }
290

291
                        newPortal := &portal{
159✔
292
                                Name:                    v.DestPortalName,
159✔
293
                                Statement:               st,
159✔
294
                                Parameters:              encodedParams,
159✔
295
                                ResultColumnFormatCodes: v.ResultColumnFormatCodes,
159✔
296
                        }
159✔
297

159✔
298
                        s.portals[v.DestPortalName] = newPortal
159✔
299
                case fm.Execute:
158✔
300
                        //query execution
158✔
301
                        portal, ok := s.portals[v.PortalName]
158✔
302
                        if !ok {
158✔
303
                                waitForSync = extQueryMode
×
304
                                s.HandleError(fmt.Errorf("portal '%s' not found", v.PortalName))
×
305
                                continue
×
306
                        }
307

308
                        delete(s.portals, v.PortalName)
158✔
309

158✔
310
                        err := s.fetchAndWriteResults(portal.Statement.SQLStatement,
158✔
311
                                portal.Parameters,
158✔
312
                                portal.ResultColumnFormatCodes,
158✔
313
                                extQueryMode,
158✔
314
                        )
158✔
315
                        if err != nil {
161✔
316
                                waitForSync = extQueryMode
3✔
317
                                s.HandleError(err)
3✔
318
                        }
3✔
319
                case fm.FlushMsg:
1✔
320
                        // there is no buffer to be flushed
321
                default:
×
322
                        waitForSync = extQueryMode
×
323
                        s.HandleError(pserr.ErrUnknowMessageType)
×
324
                }
325
        }
326
}
327

328
func (s *session) fetchAndWriteResults(statements string, parameters []*schema.NamedParam, resultColumnFormatCodes []int16, extQueryMode bool) error {
381✔
329
        tag := commandTagFor(statements)
381✔
330
        // Track explicit transaction state so the next ReadyForQuery message
381✔
331
        // reports the correct transaction-status byte. Clients (pq, JDBC)
381✔
332
        // gate commit/rollback handling on this byte; staying at 'I' after a
381✔
333
        // BEGIN gives the pq driver "unexpected transaction status idle".
381✔
334
        switch tag {
381✔
335
        case "BEGIN":
2✔
336
                s.txStatus = bm.TxStatusInTx
2✔
337
        case "COMMIT", "ROLLBACK":
2✔
338
                s.txStatus = bm.TxStatusIdle
2✔
339
        }
340
        if s.isInBlackList(statements) {
388✔
341
                _, err := s.writeMessage(bm.CommandComplete([]byte(tag)))
7✔
342
                return err
7✔
343
        }
7✔
344

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

387
                _, err := s.writeMessage(bm.CommandComplete([]byte("ok")))
10✔
388
                return err
10✔
389
        }
390

391
        s.log.Infof("pgcompat: executing query via SQL engine: %.300s", statements)
362✔
392
        var err error
362✔
393
        cacheKey := removePGCatalogReferences(statements)
362✔
394
        var stmts []sql.SQLStmt
362✔
395
        if s.stmtCache != nil {
720✔
396
                stmts = s.stmtCache[cacheKey]
358✔
397
        }
358✔
398
        if stmts == nil {
717✔
399
                stmts, err = sql.ParseSQL(strings.NewReader(cacheKey))
355✔
400
                if err != nil {
359✔
401
                        return err
4✔
402
                }
4✔
403
                if s.stmtCache != nil {
701✔
404
                        // Evict the oldest entry when the cache is full (FIFO).
350✔
405
                        if len(s.stmtCache) >= stmtCacheSize {
350✔
NEW
406
                                oldest := s.stmtCacheKeys[0]
×
NEW
407
                                s.stmtCacheKeys = s.stmtCacheKeys[1:]
×
NEW
408
                                delete(s.stmtCache, oldest)
×
NEW
409
                        }
×
410
                        s.stmtCache[cacheKey] = stmts
350✔
411
                        s.stmtCacheKeys = append(s.stmtCacheKeys, cacheKey)
350✔
412
                }
413
        }
414

415
        if len(stmts) == 0 {
363✔
416
                // PostgreSQL contract: a Simple Query that is empty or contains
5✔
417
                // only comments returns EmptyQueryResponse, not CommandComplete.
5✔
418
                // k3s/kine issues `-- ping` as a liveness check and expects this.
5✔
419
                _, err := s.writeMessage(bm.EmptyQueryResponse())
5✔
420
                return err
5✔
421
        }
5✔
422

423
        for _, stmt := range stmts {
812✔
424
                switch st := stmt.(type) {
459✔
425
                case *sql.UseDatabaseStmt:
3✔
426
                        if err := s.useDatabase(st.DB); err != nil {
4✔
427
                                return err
1✔
428
                        }
1✔
429
                        continue
2✔
430
                case sql.DataSource:
185✔
431
                        if err = s.query(st, parameters, resultColumnFormatCodes, extQueryMode); err != nil {
188✔
432
                                return err
3✔
433
                        }
3✔
434
                default:
271✔
435
                        if err = s.exec(st, parameters, resultColumnFormatCodes, extQueryMode); err != nil {
273✔
436
                                return err
2✔
437
                        }
2✔
438
                }
439
        }
440

441
        _, err = s.writeMessage(bm.CommandComplete([]byte(tag)))
347✔
442
        if err != nil {
347✔
443
                return err
×
444
        }
×
445

446
        return nil
347✔
447
}
448

449
var pgTypeReplacements = []struct {
450
        re   *regexp.Regexp
451
        repl string
452
}{
453
        // Strip double quotes from identifiers, but keep them if the identifier
454
        // starts with a digit. SQL reserved words get prefixed with underscore.
455
        // Quoted identifiers: prefix digit-start names with t_, prefix reserved words with _
456
        {regexp.MustCompile(`"(\d\w*)"`), "t_$1"},
457
        // Quoted-identifier keyword escape. Whenever a SQL token is wrapped
458
        // in double quotes AND its content matches an immudb-reserved
459
        // keyword, prefix the identifier with an underscore so it parses as
460
        // a regular IDENTIFIER instead of triggering a grammar conflict.
461
        // The list is kept in sync with the keyword map in
462
        // embedded/sql/parser.go (see scripts/dump_immudb_keywords.sh — or
463
        // regenerate by grepping `^\s+"[A-Z_]+":` out of parser.go and
464
        // lowercasing).
465
        {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"},
466
        {regexp.MustCompile(`"(\w+)"`), "$1"},
467

468
        // Strip PG-only ::TYPE casts before the type-name translation below.
469
        // Types natively recognised by immudb's SQL parser via the SCAST
470
        // production (boundexp :: sql_type) — INTEGER/INT/BIGINT, BOOLEAN,
471
        // FLOAT/REAL/NUMERIC/DECIMAL, VARCHAR, BLOB/BYTEA, UUID, JSON/JSONB,
472
        // TIMESTAMP/TIMESTAMPTZ/DATE — are intentionally left intact so the
473
        // engine can evaluate them (e.g. '42'::INTEGER). PG-specific or
474
        // string types are removed: ::text would otherwise become
475
        // ::VARCHAR[4096] after the text rewrite below; ::regclass, ::name,
476
        // ::oid etc. have no immudb equivalent.
477
        {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_]+)(?:\[\])?`), ""},
478

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

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

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

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

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

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

578
        // (DEFAULT nextval and ::casts already handled above)
579

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

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

588
        // PRIMARY KEY columns are already implicitly NOT NULL in immudb's
589
        // grammar, and the parser rejects an explicit `NOT NULL` after the
590
        // PRIMARY KEY keywords with
591
        //   "syntax error: unexpected NOT, expecting ',' or ')'".
592
        // XORM, Hibernate, and several JDBC ORMs emit the redundant pair
593
        // (`PRIMARY KEY NOT NULL`) for PK columns, so strip the trailing
594
        // NOT NULL when it follows PRIMARY KEY.
595
        {regexp.MustCompile(`(?i)(PRIMARY\s+KEY)\s+NOT\s+NULL\b`), "$1"},
596
        // And the symmetric form (`NOT NULL PRIMARY KEY`) is fine, but a
597
        // duplicated NOT NULL on either side of PRIMARY KEY isn't:
598
        {regexp.MustCompile(`(?i)NOT\s+NULL\s+(PRIMARY\s+KEY)\s+NOT\s+NULL\b`), "$1"},
599

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

608
        // After the `table.* -> *` reduction above, XORM's
609
        //   SELECT project.*, project_issue.issue_id FROM project ...
610
        // becomes `SELECT *, project_issue.issue_id FROM ...`, which
611
        // immudb's grammar rejects (the `opt_targets` production is either
612
        // bare `*` or a comma-separated expression list, never a mix).
613
        // `SELECT *` over a JOIN in immudb already yields all columns from
614
        // all joined tables, so the trailing comma-separated list is
615
        // redundant — collapse it back to a bare `*`.
616
        {regexp.MustCompile(`(?is)(SELECT\s+\*)(?:\s*,[^,]+?)+(\s+FROM\b)`), "$1$2"},
617

618
        // XORM / Gitea's QueryIssueContentHistoryEditedCountMap emits
619
        //   SELECT comment_id, COUNT(1) as history_count ...
620
        //   ... HAVING count(1) > 1
621
        // immudb's aggregate-func grammar accepts COUNT(*) and COUNT(col)
622
        // but not an integer-literal arg, producing "unexpected INTEGER_LIT
623
        // at position 26" at parse time. Postgres/MySQL treat `COUNT(1)`
624
        // and `COUNT(*)` identically (row count), so rewrite.
625
        {regexp.MustCompile(`(?i)\bCOUNT\s*\(\s*1\s*\)`), "COUNT(*)"},
626

627
        // Gitea's eventsource UIDcounts (GetUIDsAndNotificationCounts) emits
628
        //   SELECT user_id, sum(case when status = $1 then 1 else 0 end) AS count
629
        //     FROM notification WHERE <cond> GROUP BY user_id
630
        // immudb's grammar has no expression-based aggregate (AggExp); the
631
        // CASE WHEN inside SUM(…) parses as "unexpected CASE at position 24".
632
        // For this specific shape the 0/1 indicator is equivalent to a
633
        // row count filtered by the CASE predicate, so hoist the predicate
634
        // into the WHERE clause and swap SUM(...) for COUNT(*). Users with
635
        // zero matching rows drop out of the result, which is the correct
636
        // semantics for an unread-notification counter (the Gitea frontend
637
        // treats absence as zero).
638
        {
639
                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+`),
640
                `SELECT $1, COUNT(*) AS $4 FROM $5 WHERE $2 = $$$3 AND `,
641
        },
642

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

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

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

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

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

679
        // Strip CONSTRAINT keyword with name
680
        {regexp.MustCompile(`(?i)\bCONSTRAINT\s+\w+\s+`), ""},
681

682
        // Strip table-level FOREIGN KEY … REFERENCES … constraints BEFORE the
683
        // column-level REFERENCES strip below. Without this, the column-level
684
        // strip removes only the REFERENCES clause, leaving a dangling
685
        // "FOREIGN KEY (col)" that causes "unexpected ')', expecting REFERENCES".
686
        // immudb's SQL engine does accept FOREIGN KEY in CREATE TABLE (parsed
687
        // but not enforced), but stripping here is equivalent and avoids the
688
        // interaction with the REFERENCES rule.
689
        {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))*`), ""},
690
        // Strip column-level REFERENCES (inline FK) with optional ON DELETE/UPDATE
691
        {regexp.MustCompile(`(?i)\bREFERENCES\s+\S+\s*\([^)]*\)(\s+ON\s+(DELETE|UPDATE)\s+(CASCADE|RESTRICT|SET\s+NULL|SET\s+DEFAULT|NO\s+ACTION))*`), ""},
692

693
        // Strip the optional index name from `CREATE [UNIQUE] INDEX [IF NOT
694
        // EXISTS] <name> ON …`. PostgreSQL requires (and pg_dump emits) a
695
        // name; immudb's grammar rejects one (sql_grammar.y:390-408 has
696
        // `CREATE INDEX … ON …` with no name). The alternation matches both
697
        // the double-quoted and bare-identifier forms. When the name is
698
        // absent (`CREATE INDEX ON …`, which immudb accepts natively) the
699
        // `<name> ON` suffix can't be satisfied and the pattern does not
700
        // match, so no-op rewrite.
701
        {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"},
702

703
        // Strip the optional PostgreSQL column-alias list from
704
        // `CREATE VIEW [IF NOT EXISTS] <name>(col1, col2, …) AS SELECT …`.
705
        // immudb's grammar (sql_grammar.y:355-360) only accepts
706
        // `CREATE VIEW <name> AS dqlstmt`. PG's outer column list renames
707
        // the SELECT's output columns; pg_dump emits both the list AND
708
        // matching `AS` aliases in the SELECT, so dropping the outer list
709
        // preserves names for the common case. Multi-line matching (?is)
710
        // because dumps put each column on its own line. If a list ever
711
        // contains nested parens the pattern will not match and the user
712
        // gets the original pre-fix "unexpected '(', expecting AS" error,
713
        // which is acceptable — PG lists are identifier-only in practice.
714
        {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"},
715

716
        // NOTE: `UNIQUE` inline-on-column / table-level constraints are NOT
717
        // stripped here. They are translated into a trailing
718
        // `CREATE UNIQUE INDEX ON tbl(col)` statement by the Go pass in
719
        // extractUniqueConstraints (unique_ddl.go) which runs before this
720
        // regex pipeline. Stripping the keyword outright would silently drop
721
        // uniqueness guarantees — which is exactly what the buggy prior
722
        // behavior did.
723

724
        // date type — must come after timestamp replacements
725
        // Only match standalone 'date' as a type (after a column name, not in other contexts)
726
        {regexp.MustCompile(`(?i)\bdate\b`), "TIMESTAMP"},
727
}
728

729
var createTableRe = regexp.MustCompile(`(?i)^\s*CREATE\s+TABLE\s+`)
730
var primaryKeyInlineRe = regexp.MustCompile(`(?i)PRIMARY\s+KEY`)
731

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

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

577✔
783
        restore := func(m string) string {
1,145✔
784
                if len(literals) == 0 {
936✔
785
                        return m
368✔
786
                }
368✔
787
                return stringLiteralTokenRe.ReplaceAllStringFunc(m, func(tok string) string {
502✔
788
                        idxStr := tok[1 : len(tok)-1]
302✔
789
                        idx, err := strconv.Atoi(idxStr)
302✔
790
                        if err != nil || idx < 0 || idx >= len(literals) {
302✔
NEW
791
                                return tok
×
NEW
792
                        }
×
793
                        return literals[idx]
302✔
794
                })
795
        }
796
        return masked, restore
577✔
797
}
798

799
var stringLiteralTokenRe = regexp.MustCompile("\x01(\\d+)\x01")
800

801
// commandTagRe extracts the leading SQL verb from a statement so we can
802
// emit the standard Postgres CommandComplete tag (`BEGIN`, `COMMIT`,
803
// `INSERT 0 0`, …) instead of the catch-all `ok`. Some clients (the pq
804
// Go driver, XORM's transaction state machine, several JDBC drivers)
805
// inspect the tag to decide whether a transaction actually committed,
806
// and `ok` confuses them with "unexpected command tag ok".
807
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`)
808

809
// commandTagFor returns the PG-canonical CommandComplete tag for a SQL
810
// statement. Defaults to "ok" if the verb isn't recognised, so existing
811
// behaviour is preserved for unusual statements.
812
func commandTagFor(sqlText string) string {
404✔
813
        m := commandTagRe.FindStringSubmatch(sqlText)
404✔
814
        if m == nil {
430✔
815
                return "ok"
26✔
816
        }
26✔
817
        verb := strings.ToUpper(strings.Join(strings.Fields(m[1]), " "))
378✔
818
        switch verb {
378✔
819
        case "BEGIN", "START TRANSACTION":
6✔
820
                return "BEGIN"
6✔
821
        case "COMMIT", "END":
3✔
822
                return "COMMIT"
3✔
823
        case "ROLLBACK", "ABORT":
3✔
824
                return "ROLLBACK"
3✔
825
        case "INSERT":
63✔
826
                // PG: "INSERT <oid> <count>". Most clients only check the verb;
63✔
827
                // emit a plausible 0-row tag.
63✔
828
                return "INSERT 0 0"
63✔
829
        case "UPDATE", "DELETE", "SELECT", "FETCH", "MOVE", "COPY":
186✔
830
                return verb + " 0"
186✔
831
        }
832
        return verb
117✔
833
}
834

835
// quotedSchemaPrefixRe matches a quoted schema qualifier directly
836
// followed by a dot and a (quoted or unquoted) identifier. The match
837
// covers known schemas only (public, pg_catalog, information_schema)
838
// so it can't accidentally rewrite a quoted column-list element like
839
// `"public", "private"` outside the schema-prefix context.
840
var quotedSchemaPrefixRe = regexp.MustCompile(`"(public|pg_catalog|information_schema)"\s*\.`)
841

842
// stripQuotedSchemaPrefix removes `"public".`, `"pg_catalog".`,
843
// `"information_schema".` prefixes that ORMs (XORM, Hibernate, JDBC)
844
// emit in DDL and DML. immudb's grammar has no schema.table production,
845
// so leaving the dot in place trips the parser with
846
//   "syntax error: unexpected DOT, expecting '('".
847
func stripQuotedSchemaPrefix(s string) string {
567✔
848
        return quotedSchemaPrefixRe.ReplaceAllString(s, "")
567✔
849
}
567✔
850

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

856
// addClauseSkipKeywords is the set of tokens that follow ADD in
857
// constraint/index forms (ADD CONSTRAINT, ADD FOREIGN KEY, …) — we
858
// must NOT inject COLUMN ahead of these because immudb's grammar parses
859
// them as a separate ALTER variant (or the rule is blacklisted).
860
var addClauseSkipKeywords = map[string]bool{
861
        "COLUMN": true, "CONSTRAINT": true, "FOREIGN": true,
862
        "PRIMARY": true, "UNIQUE": true, "CHECK": true, "INDEX": true,
863
}
864

865
// injectAddColumnKeyword rewrites `ALTER TABLE x ADD <colspec>` to
866
// `ALTER TABLE x ADD COLUMN <colspec>` when <colspec> begins with a
867
// (quoted or unquoted) identifier rather than a constraint keyword.
868
// PG accepts both forms; immudb's grammar requires the explicit COLUMN.
869
func injectAddColumnKeyword(s string) string {
569✔
870
        return alterAddRe.ReplaceAllStringFunc(s, func(match string) string {
578✔
871
                m := alterAddRe.FindStringSubmatch(match)
9✔
872
                if m == nil {
9✔
NEW
873
                        return match
×
NEW
874
                }
×
875
                next := strings.ToUpper(strings.Trim(m[3], `"`))
9✔
876
                if addClauseSkipKeywords[next] {
16✔
877
                        return match
7✔
878
                }
7✔
879
                return fmt.Sprintf("ALTER TABLE %s ADD COLUMN%s%s", m[1], m[2], m[3])
2✔
880
        })
881
}
882

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

889
// cleanup regexes used in removePGCatalogReferences — compiled once at init.
890
var (
891
        doubleSpaceRe   = regexp.MustCompile(`  +`)
892
        doubleCommaRe   = regexp.MustCompile(`(?m)^\s*,\s*,`)
893
        trailingCommaRe = regexp.MustCompile(`,\s*\)`)
894
)
895

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

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

935
// psqlOperatorRegexEqNoParenRe covers the rarer paren-less form some
936
// clients emit: `c.relname ~ '^mytable$'`.
937
var psqlOperatorRegexEqNoParenRe = regexp.MustCompile(
938
        `(?i)\s+OPERATOR\s*\(\s*(?:pg_catalog\.)?~\s*\)\s*'\^([^$|']+)\$'`)
939

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

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

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

1005
// removePGCatalogReferencesRegex is the legacy regex-based rewriter.
1006
// B2 will gradually retire passes from here as their AST equivalents
1007
// land; B3 removes the function entirely once the AST path handles
1008
// every supported query shape.
1009
func removePGCatalogReferencesRegex(sqlStr string) string {
560✔
1010
        // Normalise PG-specific patterns that operate on string literals
560✔
1011
        // *before* masking (maskStringLiterals hides literal contents so
560✔
1012
        // the downstream regex chain can't touch them, but these rules
560✔
1013
        // specifically need to see the literal). See normalizePsqlPatterns
560✔
1014
        // for the rules and why they're kept separate.
560✔
1015
        sqlStr = normalizePsqlPatterns(sqlStr)
560✔
1016

560✔
1017
        // Mask string literals first so regex-based rewrites can't mutate
560✔
1018
        // their contents (see maskStringLiterals for rationale).
560✔
1019
        s, restore := maskStringLiterals(sqlStr)
560✔
1020

560✔
1021
        s = strings.ReplaceAll(s, "pg_catalog.", "")
560✔
1022
        s = strings.ReplaceAll(s, "information_schema.", "information_schema_")
560✔
1023
        s = strings.ReplaceAll(s, "public.", "")
560✔
1024

560✔
1025
        // Strip QUOTED schema-qualified prefixes too — XORM, Hibernate, JDBC
560✔
1026
        // and friends emit `"public"."tablename"` (and `"pg_catalog"."pg_x"`)
560✔
1027
        // in DDL. The plain string ReplaceAll's above don't catch the quoted
560✔
1028
        // form, and the immudb grammar has no schema.table production so the
560✔
1029
        // stray DOT shows up at parse time as
560✔
1030
        //   "syntax error: unexpected DOT, expecting '('".
560✔
1031
        // Run this BEFORE the identifier-unquote pass in pgTypeReplacements
560✔
1032
        // so we eliminate the dot in the same step that drops the prefix.
560✔
1033
        s = stripQuotedSchemaPrefix(s)
560✔
1034

560✔
1035
        // Apply PG type translations
560✔
1036
        for _, r := range pgTypeReplacements {
33,600✔
1037
                s = r.re.ReplaceAllString(s, r.repl)
33,040✔
1038
        }
33,040✔
1039

1040
        // PG `ALTER TABLE … ADD <coldef>` → immudb `ADD COLUMN <coldef>`.
1041
        // Done as a Go pass so we can skip the constraint forms (ADD
1042
        // CONSTRAINT / FOREIGN / PRIMARY / UNIQUE / CHECK / INDEX) which
1043
        // take a different syntax in immudb (or are blacklisted).
1044
        s = injectAddColumnKeyword(s)
560✔
1045

560✔
1046
        // Idempotency for Rails's schema_migrations bulk insert. See
560✔
1047
        // pgTypeReplacements for the long explanation. Using a Go pass here
560✔
1048
        // so we can check "ON CONFLICT already present" safely — the
560✔
1049
        // standard library regex has no lookahead.
560✔
1050
        if m := insertSchemaMigrationsRe.FindStringSubmatchIndex(s); m != nil {
562✔
1051
                if !strings.Contains(strings.ToUpper(s), "ON CONFLICT") {
3✔
1052
                        head := s[m[2]:m[3]]
1✔
1053
                        tail := s[m[4]:m[5]]
1✔
1054
                        s = head + " ON CONFLICT DO NOTHING" + tail
1✔
1055
                }
1✔
1056
        }
1057

1058
        // Auto-add PRIMARY KEY for CREATE TABLE without one
1059
        if createTableRe.MatchString(s) && !primaryKeyInlineRe.MatchString(s) {
568✔
1060
                s = addPrimaryKeyToCreateTable(s)
8✔
1061
        }
8✔
1062

1063
        // Translate inline / table-level UNIQUE constraints into trailing
1064
        // `CREATE UNIQUE INDEX` statements. Must run AFTER
1065
        // addPrimaryKeyToCreateTable — that pass scans for the last ")" in
1066
        // the string, and splitting the CREATE TABLE off from the new
1067
        // CREATE UNIQUE INDEX statements would put that ")" inside the
1068
        // INDEX parens (wrong target).
1069
        s = extractUniqueConstraints(s)
560✔
1070

560✔
1071
        // Clean up double spaces and empty lines
560✔
1072
        s = doubleSpaceRe.ReplaceAllString(s, " ")
560✔
1073
        s = doubleCommaRe.ReplaceAllString(s, ",")
560✔
1074
        // Remove trailing commas before closing paren
560✔
1075
        s = trailingCommaRe.ReplaceAllString(s, "\n)")
560✔
1076

560✔
1077
        return restore(s)
560✔
1078
}
1079

1080
// addPrimaryKeyToCreateTable adds a PRIMARY KEY clause to a CREATE TABLE
1081
// that doesn't have one. Picks the first NOT NULL column, or an id/ID column,
1082
// or the first column.
1083
func addPrimaryKeyToCreateTable(sql string) string {
8✔
1084
        // Find the closing paren of the CREATE TABLE
8✔
1085
        lastParen := strings.LastIndex(sql, ")")
8✔
1086
        if lastParen < 0 {
8✔
NEW
1087
                return sql
×
NEW
1088
        }
×
1089

1090
        // Extract column definitions between first ( and last )
1091
        firstParen := strings.Index(sql, "(")
8✔
1092
        if firstParen < 0 || firstParen >= lastParen {
8✔
NEW
1093
                return sql
×
NEW
1094
        }
×
1095

1096
        colSection := sql[firstParen+1 : lastParen]
8✔
1097
        lines := strings.Split(colSection, ",")
8✔
1098

8✔
1099
        var firstCol, notNullCol, idCol string
8✔
1100

8✔
1101
        for _, line := range lines {
21✔
1102
                line = strings.TrimSpace(line)
13✔
1103
                if line == "" {
13✔
NEW
1104
                        continue
×
1105
                }
1106
                words := strings.Fields(line)
13✔
1107
                if len(words) < 2 {
14✔
1108
                        continue
1✔
1109
                }
1110
                colName := words[0]
12✔
1111
                // Skip if it looks like a constraint, not a column
12✔
1112
                upper := strings.ToUpper(colName)
12✔
1113
                if upper == "CONSTRAINT" || upper == "PRIMARY" || upper == "FOREIGN" || upper == "CHECK" || upper == "UNIQUE" {
13✔
1114
                        continue
1✔
1115
                }
1116

1117
                if firstCol == "" {
19✔
1118
                        firstCol = colName
8✔
1119
                }
8✔
1120

1121
                if strings.Contains(strings.ToUpper(line), "NOT NULL") && notNullCol == "" {
13✔
1122
                        notNullCol = colName
2✔
1123
                }
2✔
1124

1125
                lowerCol := strings.ToLower(colName)
11✔
1126
                if (lowerCol == "id" || strings.HasSuffix(lowerCol, "_id") || strings.HasSuffix(lowerCol, "id")) && idCol == "" {
14✔
1127
                        idCol = colName
3✔
1128
                }
3✔
1129
        }
1130

1131
        // Pick the best PK column
1132
        pkCol := notNullCol
8✔
1133
        if pkCol == "" {
14✔
1134
                pkCol = idCol
6✔
1135
        }
6✔
1136
        if pkCol == "" {
11✔
1137
                pkCol = firstCol
3✔
1138
        }
3✔
1139
        if pkCol == "" {
8✔
NEW
1140
                return sql
×
NEW
1141
        }
×
1142

1143
        // Insert PRIMARY KEY before the closing paren
1144
        before := sql[:lastParen]
8✔
1145
        after := sql[lastParen:]
8✔
1146

8✔
1147
        // Remove trailing comma/whitespace from before
8✔
1148
        before = strings.TrimRight(before, " \t\n,")
8✔
1149

8✔
1150
        return before + ",\n    PRIMARY KEY (" + pkCol + ")\n" + after
8✔
1151
}
1152

1153
func (s *session) query(st sql.DataSource, parameters []*schema.NamedParam, resultColumnFormatCodes []int16, skipRowDesc bool) error {
185✔
1154
        tx, err := s.sqlTx()
185✔
1155
        if err != nil {
185✔
1156
                return err
×
1157
        }
×
1158

1159
        reader, err := s.db.SQLQueryPrepared(s.ctx, tx, st, schema.NamedParamsFromProto(parameters))
185✔
1160
        if err != nil {
188✔
1161
                return err
3✔
1162
        }
3✔
1163
        defer reader.Close()
182✔
1164

182✔
1165
        cols, err := reader.Columns(s.ctx)
182✔
1166
        if err != nil {
182✔
1167
                return err
×
1168
        }
×
1169

1170
        if !skipRowDesc {
216✔
1171
                if _, err = s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
34✔
1172
                        return err
×
1173
                }
×
1174
        }
1175

1176
        return sql.ReadRowsBatch(s.ctx, reader, maxRowsPerMessage, func(rowBatch []*sql.Row) error {
358✔
1177
                _, err := s.writeMessage(bm.DataRow(rowBatch, len(cols), resultColumnFormatCodes))
176✔
1178
                return err
176✔
1179
        })
176✔
1180
}
1181

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

271✔
1185
        for _, p := range namedParams {
286✔
1186
                params[p.Name] = schema.RawValue(p.Value)
15✔
1187
        }
15✔
1188

1189
        tx, err := s.sqlTx()
271✔
1190
        if err != nil {
271✔
1191
                return err
×
1192
        }
×
1193

1194
        ntx, _, err := s.db.SQLExecPrepared(s.ctx, tx, []sql.SQLStmt{st}, params)
271✔
1195
        s.tx = ntx
271✔
1196

271✔
1197
        return err
271✔
1198
}
1199

1200
type portal struct {
1201
        Name                    string
1202
        Statement               *statement
1203
        Parameters              []*schema.NamedParam
1204
        ResultColumnFormatCodes []int16
1205
}
1206

1207
type statement struct {
1208
        Name         string
1209
        SQLStatement string
1210
        PreparedStmt sql.SQLStmt
1211
        Params       []sql.ColDescriptor
1212
        Results      []sql.ColDescriptor
1213
}
1214

1215
func (s *session) inferParamAndResultCols(stmt sql.SQLStmt) ([]sql.ColDescriptor, []sql.ColDescriptor, error) {
146✔
1216
        var resCols []sql.ColDescriptor
146✔
1217

146✔
1218
        ds, ok := stmt.(sql.DataSource)
146✔
1219
        if ok {
289✔
1220
                rr, err := s.db.SQLQueryPrepared(s.ctx, s.tx, ds, nil)
143✔
1221
                if err != nil {
143✔
1222
                        return nil, nil, err
×
1223
                }
×
1224

1225
                resCols, err = rr.Columns(s.ctx)
143✔
1226
                if err != nil {
143✔
1227
                        return nil, nil, err
×
1228
                }
×
1229

1230
                rr.Close()
143✔
1231
        }
1232

1233
        r, err := s.db.InferParametersPrepared(s.ctx, s.tx, stmt)
146✔
1234
        if err != nil {
146✔
1235
                return nil, nil, err
×
1236
        }
×
1237

1238
        if len(r) > math.MaxInt16 {
146✔
1239
                return nil, nil, pserr.ErrMaxParamsNumberExceeded
×
1240
        }
×
1241

1242
        var paramsNameList []string
146✔
1243
        for n := range r {
184✔
1244
                paramsNameList = append(paramsNameList, n)
38✔
1245
        }
38✔
1246
        // Sort by the numeric suffix of "paramN" rather than lexicographically.
1247
        // Lexical sort gives param1, param10, param11, …, param2, param3 —
1248
        // which makes positional Bind values land on the wrong $N in
1249
        // statements with 10+ parameters and silently corrupts inserts.
1250
        sort.Slice(paramsNameList, func(i, j int) bool {
182✔
1251
                ai, aok := paramNumericSuffix(paramsNameList[i])
36✔
1252
                bi, bok := paramNumericSuffix(paramsNameList[j])
36✔
1253
                if aok && bok {
72✔
1254
                        return ai < bi
36✔
1255
                }
36✔
NEW
1256
                return paramsNameList[i] < paramsNameList[j]
×
1257
        })
1258

1259
        paramCols := make([]sql.ColDescriptor, 0)
146✔
1260
        for _, n := range paramsNameList {
184✔
1261
                paramCols = append(paramCols, sql.ColDescriptor{Column: n, Type: r[n]})
38✔
1262
        }
38✔
1263
        return paramCols, resCols, nil
146✔
1264
}
1265

1266
// paramNumericSuffix returns the integer suffix of names like "param12".
1267
// Used to sort parameter names by position rather than lexicographically.
1268
func paramNumericSuffix(name string) (int, bool) {
72✔
1269
        if !strings.HasPrefix(name, "param") {
72✔
NEW
1270
                return 0, false
×
NEW
1271
        }
×
1272
        n, err := strconv.Atoi(name[len("param"):])
72✔
1273
        if err != nil {
72✔
NEW
1274
                return 0, false
×
NEW
1275
        }
×
1276
        return n, true
72✔
1277
}
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