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

codenotary / immudb / 24841644892

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

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%)

115 existing lines in 18 files now uncovered.

44599 of 52298 relevant lines covered (85.28%)

127676.6 hits per line

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

18.53
/pkg/pgsql/server/immudb_functions.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
        "encoding/hex"
21
        "fmt"
22
        "strconv"
23
        "strings"
24

25
        "github.com/codenotary/immudb/embedded/sql"
26
        "github.com/codenotary/immudb/pkg/api/schema"
27
        bm "github.com/codenotary/immudb/pkg/pgsql/server/bmessages"
28
)
29

30
// immudbState handles SELECT immudb_state() — returns the current immutable database state
31
// including transaction ID, hash, and database name.
32
func (s *session) immudbState() error {
1✔
33
        state, err := s.db.CurrentState()
1✔
34
        if err != nil {
1✔
NEW
35
                return err
×
NEW
36
        }
×
37

38
        cols := []sql.ColDescriptor{
1✔
39
                {Column: "db", Type: sql.VarcharType},
1✔
40
                {Column: "tx_id", Type: sql.IntegerType},
1✔
41
                {Column: "tx_hash", Type: sql.VarcharType},
1✔
42
                {Column: "precommitted_tx_id", Type: sql.IntegerType},
1✔
43
                {Column: "precommitted_tx_hash", Type: sql.VarcharType},
1✔
44
                {Column: "signature", Type: sql.VarcharType},
1✔
45
        }
1✔
46

1✔
47
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
1✔
NEW
48
                return err
×
NEW
49
        }
×
50

51
        txHash := hex.EncodeToString(state.TxHash)
1✔
52
        precommittedTxHash := hex.EncodeToString(state.PrecommittedTxHash)
1✔
53

1✔
54
        sig := ""
1✔
55
        if state.Signature != nil {
1✔
NEW
56
                sig = hex.EncodeToString(state.Signature.Signature)
×
NEW
57
        }
×
58

59
        row := &sql.Row{
1✔
60
                ValuesByPosition: []sql.TypedValue{
1✔
61
                        sql.NewVarchar(state.Db),
1✔
62
                        sql.NewInteger(int64(state.TxId)),
1✔
63
                        sql.NewVarchar(txHash),
1✔
64
                        sql.NewInteger(int64(state.PrecommittedTxId)),
1✔
65
                        sql.NewVarchar(precommittedTxHash),
1✔
66
                        sql.NewVarchar(sig),
1✔
67
                },
1✔
68
                ValuesBySelector: map[string]sql.TypedValue{
1✔
69
                        "db":                   sql.NewVarchar(state.Db),
1✔
70
                        "tx_id":               sql.NewInteger(int64(state.TxId)),
1✔
71
                        "tx_hash":             sql.NewVarchar(txHash),
1✔
72
                        "precommitted_tx_id":  sql.NewInteger(int64(state.PrecommittedTxId)),
1✔
73
                        "precommitted_tx_hash": sql.NewVarchar(precommittedTxHash),
1✔
74
                        "signature":           sql.NewVarchar(sig),
1✔
75
                },
1✔
76
        }
1✔
77

1✔
78
        if _, err := s.writeMessage(bm.DataRow([]*sql.Row{row}, len(cols), nil)); err != nil {
1✔
NEW
79
                return err
×
NEW
80
        }
×
81

82
        return nil
1✔
83
}
84

85
// immudbVerifyRow handles SELECT immudb_verify_row(table, pk_value) — performs a verifiable
86
// SQL get and returns verification status, transaction ID, and proof details.
NEW
87
func (s *session) immudbVerifyRow(args string) error {
×
NEW
88
        tableName, pkValues, err := parseVerifyArgs(args)
×
NEW
89
        if err != nil {
×
NEW
90
                return err
×
NEW
91
        }
×
92

NEW
93
        req := &schema.VerifiableSQLGetRequest{
×
NEW
94
                SqlGetRequest: &schema.SQLGetRequest{
×
NEW
95
                        Table:    tableName,
×
NEW
96
                        PkValues: pkValues,
×
NEW
97
                },
×
NEW
98
                ProveSinceTx: 0,
×
NEW
99
        }
×
NEW
100

×
NEW
101
        entry, err := s.db.VerifiableSQLGet(s.ctx, req)
×
NEW
102
        if err != nil {
×
NEW
103
                return s.writeVerifyError(err)
×
NEW
104
        }
×
105

NEW
106
        cols := []sql.ColDescriptor{
×
NEW
107
                {Column: "verified", Type: sql.VarcharType},
×
NEW
108
                {Column: "table_name", Type: sql.VarcharType},
×
NEW
109
                {Column: "tx_id", Type: sql.IntegerType},
×
NEW
110
                {Column: "revision", Type: sql.IntegerType},
×
NEW
111
                {Column: "entry_key", Type: sql.VarcharType},
×
NEW
112
        }
×
NEW
113

×
NEW
114
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
115
                return err
×
NEW
116
        }
×
117

NEW
118
        txID := int64(0)
×
NEW
119
        if entry.SqlEntry != nil {
×
NEW
120
                txID = int64(entry.SqlEntry.Tx)
×
NEW
121
        }
×
122

NEW
123
        revision := int64(0)
×
NEW
124
        if entry.SqlEntry != nil && entry.SqlEntry.Metadata != nil && !entry.SqlEntry.Metadata.GetDeleted() {
×
NEW
125
                revision = 1
×
NEW
126
        }
×
127

NEW
128
        entryKey := ""
×
NEW
129
        if entry.SqlEntry != nil {
×
NEW
130
                entryKey = hex.EncodeToString(entry.SqlEntry.Key)
×
NEW
131
        }
×
132

NEW
133
        row := &sql.Row{
×
NEW
134
                ValuesByPosition: []sql.TypedValue{
×
NEW
135
                        sql.NewVarchar("true"),
×
NEW
136
                        sql.NewVarchar(tableName),
×
NEW
137
                        sql.NewInteger(txID),
×
NEW
138
                        sql.NewInteger(revision),
×
NEW
139
                        sql.NewVarchar(entryKey),
×
NEW
140
                },
×
NEW
141
                ValuesBySelector: map[string]sql.TypedValue{
×
NEW
142
                        "verified":   sql.NewVarchar("true"),
×
NEW
143
                        "table_name": sql.NewVarchar(tableName),
×
NEW
144
                        "tx_id":      sql.NewInteger(txID),
×
NEW
145
                        "revision":   sql.NewInteger(revision),
×
NEW
146
                        "entry_key":  sql.NewVarchar(entryKey),
×
NEW
147
                },
×
NEW
148
        }
×
NEW
149

×
NEW
150
        if _, err := s.writeMessage(bm.DataRow([]*sql.Row{row}, len(cols), nil)); err != nil {
×
NEW
151
                return err
×
NEW
152
        }
×
153

NEW
154
        return nil
×
155
}
156

157
// immudbVerifyTx handles SELECT immudb_verify_tx(tx_id) — performs a verifiable
158
// transaction lookup and returns verification status with proof details.
NEW
159
func (s *session) immudbVerifyTx(args string) error {
×
NEW
160
        txID, err := strconv.ParseUint(strings.TrimSpace(args), 10, 64)
×
NEW
161
        if err != nil {
×
NEW
162
                return fmt.Errorf("immudb_verify_tx: invalid transaction ID: %s", args)
×
NEW
163
        }
×
164

NEW
165
        req := &schema.VerifiableTxRequest{
×
NEW
166
                Tx:           txID,
×
NEW
167
                ProveSinceTx: 0,
×
NEW
168
        }
×
NEW
169

×
NEW
170
        vtx, err := s.db.VerifiableTxByID(s.ctx, req)
×
NEW
171
        if err != nil {
×
NEW
172
                return s.writeVerifyError(err)
×
NEW
173
        }
×
174

NEW
175
        cols := []sql.ColDescriptor{
×
NEW
176
                {Column: "verified", Type: sql.VarcharType},
×
NEW
177
                {Column: "tx_id", Type: sql.IntegerType},
×
NEW
178
                {Column: "timestamp", Type: sql.IntegerType},
×
NEW
179
                {Column: "nentries", Type: sql.IntegerType},
×
NEW
180
                {Column: "eh", Type: sql.VarcharType},
×
NEW
181
                {Column: "bl_tx_id", Type: sql.IntegerType},
×
NEW
182
                {Column: "bl_root", Type: sql.VarcharType},
×
NEW
183
        }
×
NEW
184

×
NEW
185
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
186
                return err
×
NEW
187
        }
×
188

NEW
189
        hdr := vtx.Tx.Header
×
NEW
190
        row := &sql.Row{
×
NEW
191
                ValuesByPosition: []sql.TypedValue{
×
NEW
192
                        sql.NewVarchar("true"),
×
NEW
193
                        sql.NewInteger(int64(hdr.Id)),
×
NEW
194
                        sql.NewInteger(hdr.Ts),
×
NEW
195
                        sql.NewInteger(int64(hdr.Nentries)),
×
NEW
196
                        sql.NewVarchar(hex.EncodeToString(hdr.EH)),
×
NEW
197
                        sql.NewInteger(int64(hdr.BlTxId)),
×
NEW
198
                        sql.NewVarchar(hex.EncodeToString(hdr.BlRoot)),
×
NEW
199
                },
×
NEW
200
                ValuesBySelector: map[string]sql.TypedValue{
×
NEW
201
                        "verified":  sql.NewVarchar("true"),
×
NEW
202
                        "tx_id":     sql.NewInteger(int64(hdr.Id)),
×
NEW
203
                        "timestamp": sql.NewInteger(hdr.Ts),
×
NEW
204
                        "nentries":  sql.NewInteger(int64(hdr.Nentries)),
×
NEW
205
                        "eh":        sql.NewVarchar(hex.EncodeToString(hdr.EH)),
×
NEW
206
                        "bl_tx_id":  sql.NewInteger(int64(hdr.BlTxId)),
×
NEW
207
                        "bl_root":   sql.NewVarchar(hex.EncodeToString(hdr.BlRoot)),
×
NEW
208
                },
×
NEW
209
        }
×
NEW
210

×
NEW
211
        if _, err := s.writeMessage(bm.DataRow([]*sql.Row{row}, len(cols), nil)); err != nil {
×
NEW
212
                return err
×
NEW
213
        }
×
214

NEW
215
        return nil
×
216
}
217

218
// immudbHistory handles SELECT immudb_history(key) — retrieves all historical
219
// versions of a key with transaction metadata.
NEW
220
func (s *session) immudbHistory(args string) error {
×
NEW
221
        key := strings.TrimSpace(args)
×
NEW
222
        key = trimQuotes(key)
×
NEW
223

×
NEW
224
        req := &schema.HistoryRequest{
×
NEW
225
                Key:   []byte(key),
×
NEW
226
                Limit: 100,
×
NEW
227
                Desc:  true,
×
NEW
228
        }
×
NEW
229

×
NEW
230
        entries, err := s.db.History(s.ctx, req)
×
NEW
231
        if err != nil {
×
NEW
232
                return s.writeVerifyError(err)
×
NEW
233
        }
×
234

NEW
235
        cols := []sql.ColDescriptor{
×
NEW
236
                {Column: "tx_id", Type: sql.IntegerType},
×
NEW
237
                {Column: "key", Type: sql.VarcharType},
×
NEW
238
                {Column: "value", Type: sql.VarcharType},
×
NEW
239
                {Column: "revision", Type: sql.IntegerType},
×
NEW
240
                {Column: "expired", Type: sql.VarcharType},
×
NEW
241
                {Column: "deleted", Type: sql.VarcharType},
×
NEW
242
        }
×
NEW
243

×
NEW
244
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
245
                return err
×
NEW
246
        }
×
247

NEW
248
        rows := make([]*sql.Row, 0, len(entries.Entries))
×
NEW
249
        for _, e := range entries.Entries {
×
NEW
250
                deleted := "false"
×
NEW
251
                if e.Metadata != nil && e.Metadata.GetDeleted() {
×
NEW
252
                        deleted = "true"
×
NEW
253
                }
×
254

NEW
255
                expired := "false"
×
NEW
256
                if e.Expired {
×
NEW
257
                        expired = "true"
×
NEW
258
                }
×
259

NEW
260
                row := &sql.Row{
×
NEW
261
                        ValuesByPosition: []sql.TypedValue{
×
NEW
262
                                sql.NewInteger(int64(e.Tx)),
×
NEW
263
                                sql.NewVarchar(string(e.Key)),
×
NEW
264
                                sql.NewVarchar(hex.EncodeToString(e.Value)),
×
NEW
265
                                sql.NewInteger(int64(e.Revision)),
×
NEW
266
                                sql.NewVarchar(expired),
×
NEW
267
                                sql.NewVarchar(deleted),
×
NEW
268
                        },
×
NEW
269
                        ValuesBySelector: map[string]sql.TypedValue{
×
NEW
270
                                "tx_id":    sql.NewInteger(int64(e.Tx)),
×
NEW
271
                                "key":      sql.NewVarchar(string(e.Key)),
×
NEW
272
                                "value":    sql.NewVarchar(hex.EncodeToString(e.Value)),
×
NEW
273
                                "revision": sql.NewInteger(int64(e.Revision)),
×
NEW
274
                                "expired":  sql.NewVarchar(expired),
×
NEW
275
                                "deleted":  sql.NewVarchar(deleted),
×
NEW
276
                        },
×
NEW
277
                }
×
NEW
278
                rows = append(rows, row)
×
279
        }
280

NEW
281
        if len(rows) > 0 {
×
NEW
282
                if _, err := s.writeMessage(bm.DataRow(rows, len(cols), nil)); err != nil {
×
NEW
283
                        return err
×
NEW
284
                }
×
285
        }
286

NEW
287
        return nil
×
288
}
289

290
// immudbTxByID handles SELECT immudb_tx(tx_id) — retrieves transaction details
291
// including header metadata and entry count.
NEW
292
func (s *session) immudbTxByID(args string) error {
×
NEW
293
        txID, err := strconv.ParseUint(strings.TrimSpace(args), 10, 64)
×
NEW
294
        if err != nil {
×
NEW
295
                return fmt.Errorf("immudb_tx: invalid transaction ID: %s", args)
×
NEW
296
        }
×
297

NEW
298
        req := &schema.TxRequest{
×
NEW
299
                Tx: txID,
×
NEW
300
        }
×
NEW
301

×
NEW
302
        tx, err := s.db.TxByID(s.ctx, req)
×
NEW
303
        if err != nil {
×
NEW
304
                return s.writeVerifyError(err)
×
NEW
305
        }
×
306

NEW
307
        cols := []sql.ColDescriptor{
×
NEW
308
                {Column: "tx_id", Type: sql.IntegerType},
×
NEW
309
                {Column: "timestamp", Type: sql.IntegerType},
×
NEW
310
                {Column: "nentries", Type: sql.IntegerType},
×
NEW
311
                {Column: "prev_alh", Type: sql.VarcharType},
×
NEW
312
                {Column: "eh", Type: sql.VarcharType},
×
NEW
313
                {Column: "bl_tx_id", Type: sql.IntegerType},
×
NEW
314
                {Column: "bl_root", Type: sql.VarcharType},
×
NEW
315
                {Column: "version", Type: sql.IntegerType},
×
NEW
316
        }
×
NEW
317

×
NEW
318
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
319
                return err
×
NEW
320
        }
×
321

NEW
322
        hdr := tx.Header
×
NEW
323
        row := &sql.Row{
×
NEW
324
                ValuesByPosition: []sql.TypedValue{
×
NEW
325
                        sql.NewInteger(int64(hdr.Id)),
×
NEW
326
                        sql.NewInteger(hdr.Ts),
×
NEW
327
                        sql.NewInteger(int64(hdr.Nentries)),
×
NEW
328
                        sql.NewVarchar(hex.EncodeToString(hdr.PrevAlh)),
×
NEW
329
                        sql.NewVarchar(hex.EncodeToString(hdr.EH)),
×
NEW
330
                        sql.NewInteger(int64(hdr.BlTxId)),
×
NEW
331
                        sql.NewVarchar(hex.EncodeToString(hdr.BlRoot)),
×
NEW
332
                        sql.NewInteger(int64(hdr.Version)),
×
NEW
333
                },
×
NEW
334
                ValuesBySelector: map[string]sql.TypedValue{
×
NEW
335
                        "tx_id":     sql.NewInteger(int64(hdr.Id)),
×
NEW
336
                        "timestamp": sql.NewInteger(hdr.Ts),
×
NEW
337
                        "nentries":  sql.NewInteger(int64(hdr.Nentries)),
×
NEW
338
                        "prev_alh":  sql.NewVarchar(hex.EncodeToString(hdr.PrevAlh)),
×
NEW
339
                        "eh":        sql.NewVarchar(hex.EncodeToString(hdr.EH)),
×
NEW
340
                        "bl_tx_id":  sql.NewInteger(int64(hdr.BlTxId)),
×
NEW
341
                        "bl_root":   sql.NewVarchar(hex.EncodeToString(hdr.BlRoot)),
×
NEW
342
                        "version":   sql.NewInteger(int64(hdr.Version)),
×
NEW
343
                },
×
NEW
344
        }
×
NEW
345

×
NEW
346
        if _, err := s.writeMessage(bm.DataRow([]*sql.Row{row}, len(cols), nil)); err != nil {
×
NEW
347
                return err
×
NEW
348
        }
×
349

NEW
350
        return nil
×
351
}
352

353
// writeVerifyError writes a verification error as a single-row result with the error message,
354
// so that clients can handle failures as data rather than connection errors.
NEW
355
func (s *session) writeVerifyError(origErr error) error {
×
NEW
356
        cols := []sql.ColDescriptor{
×
NEW
357
                {Column: "verified", Type: sql.VarcharType},
×
NEW
358
                {Column: "error", Type: sql.VarcharType},
×
NEW
359
        }
×
NEW
360

×
NEW
361
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
362
                return err
×
NEW
363
        }
×
364

NEW
365
        row := &sql.Row{
×
NEW
366
                ValuesByPosition: []sql.TypedValue{
×
NEW
367
                        sql.NewVarchar("false"),
×
NEW
368
                        sql.NewVarchar(origErr.Error()),
×
NEW
369
                },
×
NEW
370
                ValuesBySelector: map[string]sql.TypedValue{
×
NEW
371
                        "verified": sql.NewVarchar("false"),
×
NEW
372
                        "error":    sql.NewVarchar(origErr.Error()),
×
NEW
373
                },
×
NEW
374
        }
×
NEW
375

×
NEW
376
        if _, err := s.writeMessage(bm.DataRow([]*sql.Row{row}, len(cols), nil)); err != nil {
×
NEW
377
                return err
×
NEW
378
        }
×
379

NEW
380
        return nil
×
381
}
382

383
// parseVerifyArgs parses "table_name, pk_value1, pk_value2, ..." from the arguments
384
// of immudb_verify_row(). Returns the table name and a list of protobuf-encoded primary key values.
385
func parseVerifyArgs(args string) (string, []*schema.SQLValue, error) {
5✔
386
        parts := strings.Split(args, ",")
5✔
387
        if len(parts) < 2 {
7✔
388
                return "", nil, fmt.Errorf("immudb_verify_row requires at least 2 arguments: table_name, pk_value")
2✔
389
        }
2✔
390

391
        tableName := trimQuotes(strings.TrimSpace(parts[0]))
3✔
392

3✔
393
        pkValues := make([]*schema.SQLValue, 0, len(parts)-1)
3✔
394
        for _, p := range parts[1:] {
7✔
395
                p = strings.TrimSpace(p)
4✔
396
                p = trimQuotes(p)
4✔
397

4✔
398
                // Try integer first
4✔
399
                if intVal, err := strconv.ParseInt(p, 10, 64); err == nil {
7✔
400
                        pkValues = append(pkValues, &schema.SQLValue{Value: &schema.SQLValue_N{N: intVal}})
3✔
401
                        continue
3✔
402
                }
403

404
                // Default to string
405
                pkValues = append(pkValues, &schema.SQLValue{Value: &schema.SQLValue_S{S: p}})
1✔
406
        }
407

408
        return tableName, pkValues, nil
3✔
409
}
410

411
// showSettings maps SHOW parameter names to their values for ORM compatibility.
412
var showSettings = map[string]string{
413
        "server_version":              "14.0",
414
        "server_version_num":          "140000",
415
        "standard_conforming_strings": "on",
416
        "client_encoding":             "UTF8",
417
        "server_encoding":             "UTF8",
418
        "search_path":                 "\"$user\", public",
419
        "transaction_isolation":       "serializable",
420
        "timezone":                    "UTC",
421
        "datestyle":                   "ISO, MDY",
422
        "integer_datetimes":           "on",
423
        "intervalstyle":               "postgres",
424
        "max_identifier_length":       "63",
425
        "default_transaction_isolation": "serializable",
426
        "lc_collate":                  "en_US.UTF-8",
427
        "lc_ctype":                    "en_US.UTF-8",
428
}
429

430
// handleShow handles SHOW <param> statements by returning a single-row result.
431
func (s *session) handleShow(param string) error {
4✔
432
        paramLower := strings.ToLower(param)
4✔
433
        value, ok := showSettings[paramLower]
4✔
434
        if !ok {
4✔
NEW
435
                value = ""
×
NEW
436
        }
×
437

438
        cols := []sql.ColDescriptor{{Column: param, Type: sql.VarcharType}}
4✔
439
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
4✔
NEW
440
                return err
×
NEW
441
        }
×
442

443
        v := sql.NewVarchar(value)
4✔
444
        rows := []*sql.Row{{
4✔
445
                ValuesByPosition: []sql.TypedValue{v},
4✔
446
                ValuesBySelector: map[string]sql.TypedValue{param: v},
4✔
447
        }}
4✔
448
        if _, err := s.writeMessage(bm.DataRow(rows, len(cols), nil)); err != nil {
4✔
NEW
449
                return err
×
NEW
450
        }
×
451

452
        return nil
4✔
453
}
454

455
// pgTypeOIDByName maps common PostgreSQL type names to their canonical OIDs.
456
// Used by handleRegtypeOid to answer `SELECT 'foo'::regtype::oid` queries so
457
// Rails / ActiveRecord get usable integer OIDs instead of the literal type
458
// name (which breaks the pg gem's type map and leads to "can't quote Hash").
459
var pgTypeOIDByName = map[string]int64{
460
        "bool":             16,
461
        "boolean":          16,
462
        "bytea":            17,
463
        "char":             18,
464
        "name":             19,
465
        "int8":             20,
466
        "bigint":           20,
467
        "int2":             21,
468
        "smallint":         21,
469
        "int4":             23,
470
        "integer":          23,
471
        "int":              23,
472
        "text":             25,
473
        "oid":              26,
474
        "json":             114,
475
        "xml":              142,
476
        "point":            600,
477
        "float4":           700,
478
        "real":             700,
479
        "float8":           701,
480
        "double precision": 701,
481
        "money":            790,
482
        "bpchar":           1042,
483
        "character":        1042,
484
        "varchar":          1043,
485
        "date":             1082,
486
        "time":             1083,
487
        "timestamp":        1114,
488
        "timestamptz":      1184,
489
        "interval":         1186,
490
        "timetz":           1266,
491
        "bit":              1560,
492
        "varbit":           1562,
493
        "numeric":          1700,
494
        "decimal":          1700,
495
        "uuid":             2950,
496
        "jsonb":            3802,
497
}
498

499
// handleRegtypeOid emulates `SELECT 'typename'::regtype::oid` by returning the
500
// canonical PostgreSQL OID for the requested type. Strips any size/precision
501
// qualifier (e.g. "decimal(19,4)" -> "decimal", "varchar(255)" -> "varchar").
502
// Unknown type names return a zero row set so the client falls back to its
503
// built-in defaults rather than crashing on a bad value.
NEW
504
func (s *session) handleRegtypeOid(typeName string) error {
×
NEW
505
        cols := []sql.ColDescriptor{{Column: "oid", Type: sql.IntegerType}}
×
NEW
506
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
507
                return err
×
NEW
508
        }
×
509

NEW
510
        normalized := strings.ToLower(strings.TrimSpace(typeName))
×
NEW
511
        if idx := strings.IndexByte(normalized, '('); idx > 0 {
×
NEW
512
                normalized = strings.TrimSpace(normalized[:idx])
×
NEW
513
        }
×
514

NEW
515
        oid, ok := pgTypeOIDByName[normalized]
×
NEW
516
        if !ok {
×
NEW
517
                // Empty result: Rails treats this as "type unknown" and skips rather
×
NEW
518
                // than caching a bogus OID. This is the correct Postgres behaviour
×
NEW
519
                // when regtype cast fails for an unknown name, though real Postgres
×
NEW
520
                // would error rather than return empty. Empty is safer here because
×
NEW
521
                // it lets schema load continue past one unrecognised type.
×
NEW
522
                return nil
×
NEW
523
        }
×
524

NEW
525
        v := sql.NewInteger(oid)
×
NEW
526
        rows := []*sql.Row{{
×
NEW
527
                ValuesByPosition: []sql.TypedValue{v},
×
NEW
528
                ValuesBySelector: map[string]sql.TypedValue{"oid": v},
×
NEW
529
        }}
×
NEW
530
        if _, err := s.writeMessage(bm.DataRow(rows, len(cols), nil)); err != nil {
×
NEW
531
                return err
×
NEW
532
        }
×
NEW
533
        return nil
×
534
}
535

536
// immudbToPGType maps an immudb SQLValueType to a Postgres-style type name
537
// and OID. Rails uses both to build its internal column descriptor; the OID
538
// decides which OID::Type subclass handles (de)serialisation for the column.
539
func immudbToPGType(t sql.SQLValueType) (string, int64) {
3✔
540
        switch t {
3✔
541
        case sql.IntegerType:
1✔
542
                return "bigint", 20
1✔
NEW
543
        case sql.Float64Type:
×
NEW
544
                return "double precision", 701
×
545
        case sql.BooleanType:
1✔
546
                return "boolean", 16
1✔
547
        case sql.VarcharType:
1✔
548
                return "text", 25
1✔
NEW
549
        case sql.UUIDType:
×
NEW
550
                return "uuid", 2950
×
NEW
551
        case sql.BLOBType:
×
NEW
552
                return "bytea", 17
×
NEW
553
        case sql.JSONType:
×
NEW
554
                return "jsonb", 3802
×
NEW
555
        case sql.TimestampType:
×
NEW
556
                return "timestamp without time zone", 1114
×
557
        }
NEW
558
        return "text", 25
×
559
}
560

561
// pgAttributeResultCols returns the column descriptor list that handles
562
// Rails's pg_attribute introspection query. Exported as its own helper so
563
// ParseMsg can precompute it (via st.Results) — necessary for the
564
// Extended Query Describe message to send a correct RowDescription before
565
// Execute runs and emits DataRow.
NEW
566
func pgAttributeResultCols() []sql.ColDescriptor {
×
NEW
567
        return []sql.ColDescriptor{
×
NEW
568
                {Column: "attname", Type: sql.VarcharType},
×
NEW
569
                {Column: "format_type", Type: sql.VarcharType},
×
NEW
570
                {Column: "pg_get_expr", Type: sql.VarcharType},
×
NEW
571
                {Column: "attnotnull", Type: sql.BooleanType},
×
NEW
572
                {Column: "atttypid", Type: sql.IntegerType},
×
NEW
573
                {Column: "atttypmod", Type: sql.IntegerType},
×
NEW
574
                {Column: "collname", Type: sql.VarcharType},
×
NEW
575
                {Column: "comment", Type: sql.VarcharType},
×
NEW
576
                {Column: "identity", Type: sql.VarcharType},
×
NEW
577
                {Column: "attgenerated", Type: sql.VarcharType},
×
NEW
578
        }
×
NEW
579
}
×
580

581
// handlePgAttributeForTable answers Rails's pg_attribute introspection query
582
// with real column data sourced from immudb's catalog. Without this, Rails
583
// cannot resolve column types for ActiveRecord `enum :col, ...` declarations
584
// (which require a backing database column) and every model with an enum
585
// raises "Undeclared attribute type for enum 'col'".
586
//
587
// Rails expects exactly these columns in order:
588
//
589
//        attname, format_type, pg_get_expr, attnotnull,
590
//        atttypid, atttypmod, collname, comment, identity, attgenerated
NEW
591
func (s *session) handlePgAttributeForTable(tableName string, extQueryMode bool) error {
×
NEW
592
        cols := pgAttributeResultCols()
×
NEW
593
        // In Extended Query mode the preceding Describe has already sent
×
NEW
594
        // RowDescription; emitting it again confuses the client (Rails' pg
×
NEW
595
        // gem skips the "real" DataRow thinking the second RowDescription
×
NEW
596
        // is the start of a new result set).
×
NEW
597
        if !extQueryMode {
×
NEW
598
                if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
×
NEW
599
                        return err
×
NEW
600
                }
×
601
        }
602

NEW
603
        tx, err := s.db.NewSQLTx(s.ctx, sql.DefaultTxOptions().WithReadOnly(true))
×
NEW
604
        if err != nil {
×
NEW
605
                s.log.Infof("pgcompat: pg_attribute intercept: begin tx: %v", err)
×
NEW
606
                return nil // empty rows — Rails falls back, not strictly an error
×
NEW
607
        }
×
NEW
608
        defer tx.Cancel()
×
NEW
609

×
NEW
610
        catalog := tx.Catalog()
×
NEW
611
        table, err := catalog.GetTableByName(tableName)
×
NEW
612
        if err != nil {
×
NEW
613
                s.log.Infof("pgcompat: pg_attribute intercept: table %q absent (%v); returning 0 rows", tableName, err)
×
NEW
614
                return nil
×
NEW
615
        }
×
616

NEW
617
        rows := make([]*sql.Row, 0, len(table.Cols()))
×
NEW
618
        for _, c := range table.Cols() {
×
NEW
619
                pgName, pgOID := immudbToPGType(c.Type())
×
NEW
620

×
NEW
621
                // format_type mimics Postgres' pretty-printed column type. For
×
NEW
622
                // VARCHAR we include the length; others stay bare.
×
NEW
623
                formatted := pgName
×
NEW
624
                typmod := int64(-1)
×
NEW
625
                if c.Type() == sql.VarcharType && c.MaxLen() > 0 {
×
NEW
626
                        formatted = fmt.Sprintf("character varying(%d)", c.MaxLen())
×
NEW
627
                        typmod = int64(c.MaxLen() + 4) // PG stores varchar typmod as len + 4
×
NEW
628
                }
×
629

NEW
630
                var defaultExpr sql.TypedValue = sql.NewNull(sql.VarcharType)
×
NEW
631
                if c.HasDefault() {
×
NEW
632
                        raw := c.DefaultValue().String()
×
NEW
633
                        // Format defaults the way Postgres returns them so Rails' /
×
NEW
634
                        // SQLAlchemy's column-default parser can recognise them.
×
NEW
635
                        // Plain SQL literals like `'en'` become `'en'::character
×
NEW
636
                        // varying` for varchars; bare bools/ints / function calls
×
NEW
637
                        // (gen_random_uuid()) pass through unchanged. Without the
×
NEW
638
                        // type cast Rails treats the parsed default as nil and a
×
NEW
639
                        // NOT NULL column ends up failing validation on every new
×
NEW
640
                        // record.
×
NEW
641
                        switch c.Type() {
×
NEW
642
                        case sql.VarcharType:
×
NEW
643
                                if strings.HasPrefix(raw, "'") && strings.HasSuffix(raw, "'") {
×
NEW
644
                                        raw = raw + "::character varying"
×
NEW
645
                                }
×
NEW
646
                        case sql.JSONType:
×
NEW
647
                                if strings.HasPrefix(raw, "'") && strings.HasSuffix(raw, "'") {
×
NEW
648
                                        raw = raw + "::jsonb"
×
NEW
649
                                }
×
NEW
650
                        case sql.TimestampType:
×
NEW
651
                                if strings.HasPrefix(raw, "'") && strings.HasSuffix(raw, "'") {
×
NEW
652
                                        raw = raw + "::timestamp without time zone"
×
NEW
653
                                }
×
654
                        }
NEW
655
                        defaultExpr = sql.NewVarchar(raw)
×
656
                }
657

NEW
658
                rowVals := []sql.TypedValue{
×
NEW
659
                        sql.NewVarchar(c.Name()),
×
NEW
660
                        sql.NewVarchar(formatted),
×
NEW
661
                        defaultExpr,
×
NEW
662
                        sql.NewBool(!c.IsNullable()),
×
NEW
663
                        sql.NewInteger(pgOID),
×
NEW
664
                        sql.NewInteger(typmod),
×
NEW
665
                        sql.NewNull(sql.VarcharType), // collname
×
NEW
666
                        sql.NewNull(sql.VarcharType), // comment
×
NEW
667
                        sql.NewVarchar(""),           // identity
×
NEW
668
                        sql.NewVarchar(""),           // attgenerated
×
NEW
669
                }
×
NEW
670
                rows = append(rows, &sql.Row{ValuesByPosition: rowVals})
×
671
        }
672

NEW
673
        if _, err := s.writeMessage(bm.DataRow(rows, len(cols), nil)); err != nil {
×
NEW
674
                return err
×
NEW
675
        }
×
NEW
676
        return nil
×
677
}
678

679
// handlePgAdvisoryLock answers Rails's migration-lock queries
680
// (SELECT pg_try_advisory_lock / pg_advisory_unlock) with a single TRUE row.
681
// immudb has no advisory-lock subsystem, and a single-Rails-container
682
// deployment does not need one. Returning true is the documented Postgres
683
// response for "lock acquired", which lets Rails's migration logic proceed.
684
func (s *session) handlePgAdvisoryLock() error {
2✔
685
        cols := []sql.ColDescriptor{{Column: "result", Type: sql.BooleanType}}
2✔
686
        if _, err := s.writeMessage(bm.RowDescription(cols, nil)); err != nil {
2✔
NEW
687
                return err
×
NEW
688
        }
×
689
        v := sql.NewBool(true)
2✔
690
        rows := []*sql.Row{{ValuesByPosition: []sql.TypedValue{v}}}
2✔
691
        if _, err := s.writeMessage(bm.DataRow(rows, len(cols), nil)); err != nil {
2✔
NEW
692
                return err
×
NEW
693
        }
×
694
        return nil
2✔
695
}
696

697
// trimQuotes removes surrounding single or double quotes from a string.
698
func trimQuotes(s string) string {
13✔
699
        if len(s) >= 2 {
22✔
700
                if (s[0] == '\'' && s[len(s)-1] == '\'') || (s[0] == '"' && s[len(s)-1] == '"') {
16✔
701
                        return s[1 : len(s)-1]
7✔
702
                }
7✔
703
        }
704
        return s
6✔
705
}
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