Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dc8b115
fix: allow disabling PostgreSQL schema comments
buddhism5080 Sep 23, 2026
11ed086
fix: start on gateways that rewrite encoding and catalog names
buddhism5080 Sep 23, 2026
a618308
fix: keep ordinary Postgres on current_database()
buddhism5080 Sep 23, 2026
8c0f38c
fix: recreate the active file index when AND predicates are rejected
buddhism5080 Sep 23, 2026
7f47d98
fix: cover the billing partial unique index Nile rejects
buddhism5080 Sep 23, 2026
777e0ab
fix: use Nile-safe SQL directly when the database host is Nile
buddhism5080 Sep 23, 2026
b04c63a
fix: drop the SCHEMA_COMMENTS setting
buddhism5080 Sep 23, 2026
b0f1ce6
fix: send Nile text parameters in the simple protocol
buddhism5080 Sep 23, 2026
856c53d
fix: use Nile's installed pgvector instead of CREATE EXTENSION
buddhism5080 Sep 23, 2026
054d6f8
fix: speed up Nile column catalog lookups
buddhism5080 Sep 23, 2026
87c45d6
fix: migrate Nile tables concurrently
buddhism5080 Sep 23, 2026
dc67355
fix: build Nile vector indexes without CONCURRENTLY
buddhism5080 Sep 23, 2026
280002d
fix: index Nile vectors from a stored halfvec column
buddhism5080 Sep 23, 2026
61e9ab9
fix: look up Nile vector column types by regclass
buddhism5080 Sep 23, 2026
43172e0
fix: use any instead of interface{} in Nile SQL rewrite
buddhism5080 Sep 24, 2026
80cc175
fix: check Nile vector indexes without pg_get_indexdef
buddhism5080 Sep 24, 2026
33cee1c
fix: look up Nile HNSW indexes by regclass
buddhism5080 Sep 24, 2026
9348a13
fix: compile Nile helpers as package postgres
buddhism5080 Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions backend/internal/infra/persistence/postgres/catalog_dialector.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package postgres

import (
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/migrator"
)

// catalogDialector keeps GORM's PostgreSQL migrator. On Nile, information_schema
// filters by the catalog name "nile", not current_database(). Ordinary Postgres
// is unchanged.
type catalogDialector struct {
*postgres.Dialector
nile bool
}

func newPostgresDialector(pool gorm.ConnPool, nile bool) gorm.Dialector {
base := postgres.New(postgres.Config{Conn: pool}).(*postgres.Dialector)
return &catalogDialector{Dialector: base, nile: nile}
}

func (d *catalogDialector) Migrator(db *gorm.DB) gorm.Migrator {
return catalogMigrator{Migrator: postgres.Migrator{Migrator: migrator.Migrator{Config: migrator.Config{
DB: db,
Dialector: d,
CreateIndexAfterCreateTable: true,
}}}}
}

type catalogMigrator struct {
postgres.Migrator
}

func (m catalogMigrator) CurrentDatabase() (name string) {
if d, ok := m.Dialector.(*catalogDialector); ok && d.nile {
return nileInformationSchemaCatalog
}
return m.Migrator.CurrentDatabase()
}

// StoresVectorIndexColumn reports whether candidate vectors live in their own
// column. Nile rejects functions inside an index expression, so the HNSW index
// is a plain halfvec column written with the row.
func StoresVectorIndexColumn(db *gorm.DB) bool {
if db == nil || db.Dialector == nil {
return false
}
dialector, ok := db.Dialector.(*catalogDialector)
return ok && dialector.nile
}
93 changes: 93 additions & 0 deletions backend/internal/infra/persistence/postgres/client_encoding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package postgres

import (
"context"
"reflect"
"regexp"
"strings"
"time"
"unsafe"

"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/stdlib"
"gorm.io/gorm"
)

// postgresTimeZonePattern matches the timezone handling in gorm.io/driver/postgres.
var postgresTimeZonePattern = regexp.MustCompile(`(time_zone|TimeZone|timezone)=(.*?)($|&| )`)

// openPostgres matches gorm's pgx setup. Nile reports client_encoding as
// "UTF-8", which makes pgx reject later simple-protocol queries. Only a Nile
// host is rewritten, and only when that token is a UTF-8 spelling other than
// "UTF8". Ordinary Postgres already reports "UTF8" and is left untouched.
func openPostgres(dsn string, nile bool) (gorm.ConnPool, error) {
config, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, err
}

if nile {
configureNileSession(config)
}

var timestampLocation *time.Location
if match := postgresTimeZonePattern.FindStringSubmatch(dsn); len(match) > 2 {
config.RuntimeParams["timezone"] = match[2]
timestampLocation, err = time.LoadLocation(match[2])
if err != nil {
return nil, err
}
}

return stdlib.OpenDB(*config, stdlib.OptionAfterConnect(func(ctx context.Context, conn *pgx.Conn) error {
if nile {
normalizeReportedClientEncoding(conn)
}
if timestampLocation == nil {
return nil
}
conn.TypeMap().RegisterType(&pgtype.Type{
Name: "timestamp",
OID: pgtype.TimestampOID,
Codec: &pgtype.TimestampCodec{ScanLocation: timestampLocation},
})
return nil
})), nil
}

// configureNileSession makes parameterized queries use the simple protocol.
// Nile's extended protocol delivers a bound empty string as NULL, so a NOT NULL
// text column rejects it. Inlining the value keeps the empty string.
func configureNileSession(config *pgx.ConnConfig) {
config.DefaultQueryExecMode = pgx.QueryExecModeSimpleProtocol
}

func normalizeReportedClientEncoding(conn *pgx.Conn) {
if conn == nil || conn.PgConn() == nil {
return
}
field := reflect.ValueOf(conn.PgConn()).Elem().FieldByName("parameterStatuses")
if !field.IsValid() || field.Kind() != reflect.Map || field.IsNil() || !field.CanAddr() {
return
}
statuses := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem()
key := reflect.ValueOf("client_encoding")
current := statuses.MapIndex(key)
if !current.IsValid() || current.Kind() != reflect.String {
return
}
normalized := canonicalClientEncoding(current.String())
if normalized == "" || normalized == current.String() {
return
}
statuses.SetMapIndex(key, reflect.ValueOf(normalized))
}

func canonicalClientEncoding(value string) string {
compact := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(value), "-", ""))
if compact == "UTF8" {
return "UTF8"
}
return ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package postgres

import (
"testing"

"github.com/jackc/pgx/v5"
)

func TestCanonicalClientEncoding(t *testing.T) {
tests := []struct {
in string
want string
}{
{in: "UTF8", want: "UTF8"},
{in: "UTF-8", want: "UTF8"},
{in: " utf-8 ", want: "UTF8"},
{in: "SQL_ASCII", want: ""},
{in: "", want: ""},
}
for _, tt := range tests {
if got := canonicalClientEncoding(tt.in); got != tt.want {
t.Fatalf("canonicalClientEncoding(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}

func TestConfigureNileSessionUsesSimpleProtocol(t *testing.T) {
cfg, err := pgx.ParseConfig("postgres://user:secret@127.0.0.1:5432/deeix")
if err != nil {
t.Fatal(err)
}
if cfg.DefaultQueryExecMode == pgx.QueryExecModeSimpleProtocol {
t.Fatal("ordinary postgres must not start in the simple protocol")
}
configureNileSession(cfg)
if cfg.DefaultQueryExecMode != pgx.QueryExecModeSimpleProtocol {
t.Fatalf("Nile query mode = %v", cfg.DefaultQueryExecMode)
}
}
Loading
Loading