From 10fd43690f33bd1c0103081b54888036d7bced63 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sat, 25 Jul 2026 20:49:42 +0100 Subject: [PATCH] feat: embed dal.DB so Database satisfies the sealed interface dal.DB is now sealed by an unexported marker method (dal-go/dalgo v0.64.2), produced only by dal.NewDB. Database used to name its dalgo2sql delegate as a plain field (innerDB dal.DB), which cannot promote that marker method, so *Database stopped satisfying dal.DB. Embedding dal.DB instead promotes it along with the rest of the interface, matching the decorator pattern dal.DB's own doc comment describes. database_dal.go's forwarding methods move from d.innerDB.X to d.DB.X. Set/SetMulti/Insert/Upsert/Delete/DeleteMulti/Update/UpdateMulti need more than a rename: dalgo2sql's backend does not implement dal.WriteSession in full at the database level (no database-level InsertMulti or UpdateRecord), so dal.NewDB never wraps it in the validating write pipeline there, and a plain assertion against d.DB's dynamic type finds none of these methods at all. dal.As recovers dalgo2sql's concrete backend, which does implement them directly, the same way dal.WithoutValidation recovers an unvalidated write session. These direct, non-transactional writes were not run through validation before this change either, since dalgo2sql's database-level write path has no BeforeSave call of its own; writes made through RunReadwriteTransaction are validated, because that goes through d.DB's own RunReadwriteTransaction. Embedding dal.DB alongside dal.ConcurrencyAvailable makes SupportsConcurrentConnections ambiguous (both declare it at the same promotion depth), so Database now defines it explicitly. Bumps github.com/dal-go/dalgo to v0.64.2 and github.com/dal-go/dalgo2sql to v0.10.0, the already-converted release these adapters build on. Adds conformance_test.go wiring dalgotest.RunConformance behind this package's existing DALGO2POSTGRES_TEST_DSN env-gate, matching every other DB-backed test here. No live PostgreSQL server was available to run it in this change; it skips exactly like the rest of the suite until DALGO2POSTGRES_TEST_DSN is set, including in this repo's CI, which has no PostgreSQL service container. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SkkrXdtf8mU2GRo2hHsHT1 Signed-off-by: Alexander Trakhimenok --- conformance_test.go | 47 +++++++++++++++++++++++ database.go | 38 +++++++++++++------ database_dal.go | 91 ++++++++++++++++++++++++++++++++++++--------- go.mod | 8 ++-- go.sum | 16 ++++---- 5 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 conformance_test.go diff --git a/conformance_test.go b/conformance_test.go new file mode 100644 index 0000000..b2bbadf --- /dev/null +++ b/conformance_test.go @@ -0,0 +1,47 @@ +package dalgo2postgres + +import ( + "testing" + + "github.com/dal-go/dalgo/dal" + "github.com/dal-go/dalgo/dalgotest" + "github.com/dal-go/dalgo2sql" +) + +// TestConformance runs the shared dalgotest suite against a live PostgreSQL +// server, the same as every other DB-backed test in this package: it needs +// DALGO2POSTGRES_TEST_DSN (see testDSN in database_test.go) and skips when +// that is not set. +// +// This repo has no CI service container wired up for PostgreSQL, so as of +// this change the suite has been exercised locally only where +// DALGO2POSTGRES_TEST_DSN happened to be set — it has not been run in CI. +// Wiring a PostgreSQL service container into .github/workflows/ci.yml is a +// separate follow-up. +func TestConformance(t *testing.T) { + tbl := uniqueTable(t, "conformance") + opts := dalgo2sql.DbOptions{ + Recordsets: map[string]*dalgo2sql.Recordset{ + tbl: dalgo2sql.NewRecordset(tbl, dalgo2sql.Table, []dal.FieldRef{dal.Field("ID")}), + }, + } + + // Column case matches dalgotest.Record's Go field names (ID, Name): + // dalgo2sql derives column names from the struct via reflection. + // PostgreSQL folds unquoted identifiers to lower case (see quoteIdent), + // so the raw DDL below and the unquoted identifiers dalgo2sql's DML + // emits fold to the same physical column regardless of the case used + // here. + setup := openTestDBWithOpts(t, opts) + if _, err := setup.sqlDB.Exec(`CREATE TABLE ` + quoteIdent(tbl) + ` ( + ID VARCHAR(255) NOT NULL PRIMARY KEY, + Name VARCHAR(255) + )`); err != nil { + t.Fatalf("CREATE TABLE: %v", err) + } + t.Cleanup(func() { dropTable(t, setup, tbl) }) + + dalgotest.RunConformance(t, func(t *testing.T) (dal.DB, func()) { + return openTestDBWithOpts(t, opts), nil + }, dalgotest.WithCollection(tbl)) +} diff --git a/database.go b/database.go index 0c81d5d..c4eb079 100644 --- a/database.go +++ b/database.go @@ -22,10 +22,14 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" // register the "pgx" driver (pure Go, CGO_ENABLED=0) ) -// Database is the dalgo2postgres driver instance. It implements -// [dal.DB] by delegating to an inner [dal.DB] obtained from -// [dalgo2sql.NewDatabase], and adds PostgreSQL-specific dbschema, ddl, -// and concurrency surfaces. +// Database is the dalgo2postgres driver instance. It implements [dal.DB] by +// embedding a [dal.DB] obtained from [dalgo2sql.NewDatabase], and adds +// PostgreSQL-specific dbschema, ddl, and concurrency surfaces. +// +// The embedded dal.DB (rather than a named field) is what lets Database +// satisfy dal.DB itself: dal.DB is sealed by an unexported marker method, +// and embedding is the only way for that method to be promoted onto a +// decorating type — see dal.NewDB's doc comment. // // Construct via [NewDatabase]. Database values are safe for concurrent // use — the underlying PostgreSQL server and pgx connection pool both @@ -33,9 +37,9 @@ import ( type Database struct { dal.ConcurrencyAvailable // SupportsConcurrentConnections() = true - innerDB dal.DB // delegate for the dal.DB surface - sqlDB *sql.DB // direct handle for DDL + introspection queries - dsn string // remembered for diagnostics + dal.DB // delegate for the dal.DB surface + sqlDB *sql.DB // direct handle for DDL + introspection queries + dsn string // remembered for diagnostics } // NewDatabase opens a connection to the PostgreSQL server identified by dsn @@ -80,9 +84,9 @@ func NewDatabaseWithOptions(dsn string, schema dal.Schema, opts dalgo2sql.DbOpti } innerDB := dalgo2sql.NewDatabase(sqlDB, schema, opts) return &Database{ - innerDB: innerDB, - sqlDB: sqlDB, - dsn: dsn, + DB: innerDB, + sqlDB: sqlDB, + dsn: dsn, }, nil } @@ -96,8 +100,18 @@ func (d *Database) Close() error { return d.sqlDB.Close() } +// SupportsConcurrentConnections reports PostgreSQL's own concurrency +// behaviour (always true — see dal.ConcurrencyAvailable), not dalgo2sql's. +// An explicit method is required here: dal.ConcurrencyAvailable and the +// embedded dal.DB (whose Backend requirement embeds dal.ConcurrencyAware) +// both declare this method at the same promotion depth, which Go otherwise +// treats as an ambiguous selector. +func (d *Database) SupportsConcurrentConnections() bool { + return d.ConcurrencyAvailable.SupportsConcurrentConnections() +} + // ID returns the driver-issued database ID (delegated to dalgo2sql). -func (d *Database) ID() string { return d.innerDB.ID() } +func (d *Database) ID() string { return d.DB.ID() } // Adapter returns the driver/version identifier. func (d *Database) Adapter() dal.Adapter { @@ -105,7 +119,7 @@ func (d *Database) Adapter() dal.Adapter { } // Schema returns the dal-level Schema (delegated to dalgo2sql). -func (d *Database) Schema() dal.Schema { return d.innerDB.Schema() } +func (d *Database) Schema() dal.Schema { return d.DB.Schema() } // Version is the dalgo2postgres package version. Updated by hand on // each release; consumed by Adapter.Version(). diff --git a/database_dal.go b/database_dal.go index e8542eb..d1fcdb9 100644 --- a/database_dal.go +++ b/database_dal.go @@ -15,38 +15,54 @@ var _ dal.DB = (*Database)(nil) // --- dal.DB delegation --- func (d *Database) RunReadonlyTransaction(ctx context.Context, f dal.ROTxWorker, opts ...dal.TransactionOption) error { - return d.innerDB.RunReadonlyTransaction(ctx, f, opts...) + return d.DB.RunReadonlyTransaction(ctx, f, opts...) } func (d *Database) RunReadwriteTransaction(ctx context.Context, f dal.RWTxWorker, opts ...dal.TransactionOption) error { - return d.innerDB.RunReadwriteTransaction(ctx, f, opts...) + return d.DB.RunReadwriteTransaction(ctx, f, opts...) } func (d *Database) Get(ctx context.Context, record dalrecord.Record) error { - return d.innerDB.Get(ctx, record) + return d.DB.Get(ctx, record) } func (d *Database) GetMulti(ctx context.Context, records []dalrecord.Record) error { - return d.innerDB.GetMulti(ctx, records) + return d.DB.GetMulti(ctx, records) } func (d *Database) Exists(ctx context.Context, key *dalrecord.Key) (bool, error) { - return d.innerDB.Exists(ctx, key) + return d.DB.Exists(ctx, key) } func (d *Database) ExecuteQueryToRecordsReader(ctx context.Context, query dal.Query) (dal.RecordsReader, error) { - return d.innerDB.ExecuteQueryToRecordsReader(ctx, query) + return d.DB.ExecuteQueryToRecordsReader(ctx, query) } func (d *Database) ExecuteQueryToRecordsetReader(ctx context.Context, query dal.Query, opts ...recordset.Option) (dal.RecordsetReader, error) { - return d.innerDB.ExecuteQueryToRecordsetReader(ctx, query, opts...) + return d.DB.ExecuteQueryToRecordsetReader(ctx, query, opts...) } // --- extra write methods delegated from dalgo2sql --- -// writeDB is the extended interface exposed by dalgo2sql's concrete type. -// Note: UpdateRecord is intentionally excluded — dalgo2sql's database type -// implements it only on transactions, not on the top-level database object. +// writeDB is the extended interface exposed by dalgo2sql's concrete backend +// type. Note: UpdateRecord is intentionally excluded — dalgo2sql's database +// type implements it only on transactions, not on the top-level database +// object. +// +// None of these methods are part of dal.Backend, and dalgo2sql's backend +// does not satisfy dal.WriteSession in full at the database level either +// (it has no database-level InsertMulti or UpdateRecord), so dal.NewDB never +// wraps it in the validating write pipeline at this level: d.DB's dynamic +// type has none of these methods at all, and a plain "d.DB.(writeDB)" +// assertion always fails. dal.As is what recovers the concrete backend that +// does implement them, via dal.BackendOf — visibly and deliberately, the same +// way dal.WithoutValidation recovers an unvalidated write session. +// +// These direct, non-transactional writes were never run through validation +// before the sealed dal.DB change either, since dalgo2sql's database-level +// write path has no BeforeSave call of its own. Writes made through +// RunReadwriteTransaction above are validated, because that goes through +// d.DB's own RunReadwriteTransaction. type writeDB interface { Set(ctx context.Context, record dalrecord.Record) error SetMulti(ctx context.Context, records []dalrecord.Record) error @@ -58,36 +74,75 @@ type writeDB interface { UpdateMulti(ctx context.Context, keys []*dalrecord.Key, updates []update.Update, preconditions ...dal.Precondition) error } +// backendWriter recovers writeDB from dalgo2sql's concrete backend via +// dal.As — see the writeDB doc comment above for why a plain assertion +// against d.DB itself cannot reach it. +func (d *Database) backendWriter() (writeDB, bool) { + return dal.As[writeDB](d.DB) +} + func (d *Database) Set(ctx context.Context, record dalrecord.Record) error { - return d.innerDB.(writeDB).Set(ctx, record) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.Set(ctx, record) } func (d *Database) SetMulti(ctx context.Context, records []dalrecord.Record) error { - return d.innerDB.(writeDB).SetMulti(ctx, records) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.SetMulti(ctx, records) } func (d *Database) Insert(ctx context.Context, record dalrecord.Record, opts ...dal.InsertOption) error { - return d.innerDB.(writeDB).Insert(ctx, record, opts...) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.Insert(ctx, record, opts...) } func (d *Database) Upsert(ctx context.Context, record dalrecord.Record) error { - return d.innerDB.(writeDB).Upsert(ctx, record) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.Upsert(ctx, record) } func (d *Database) Delete(ctx context.Context, key *dalrecord.Key) error { - return d.innerDB.(writeDB).Delete(ctx, key) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.Delete(ctx, key) } func (d *Database) DeleteMulti(ctx context.Context, keys []*dalrecord.Key) error { - return d.innerDB.(writeDB).DeleteMulti(ctx, keys) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.DeleteMulti(ctx, keys) } func (d *Database) Update(ctx context.Context, key *dalrecord.Key, updates []update.Update, preconditions ...dal.Precondition) error { - return d.innerDB.(writeDB).Update(ctx, key, updates, preconditions...) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.Update(ctx, key, updates, preconditions...) } func (d *Database) UpdateMulti(ctx context.Context, keys []*dalrecord.Key, updates []update.Update, preconditions ...dal.Precondition) error { - return d.innerDB.(writeDB).UpdateMulti(ctx, keys, updates, preconditions...) + w, ok := d.backendWriter() + if !ok { + return dal.ErrNotImplementedYet + } + return w.UpdateMulti(ctx, keys, updates, preconditions...) } // UpdateRecord is not supported at the database level by dalgo2sql; use diff --git a/go.mod b/go.mod index 9868b56..f17da80 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/dal-go/dalgo2postgres go 1.26 require ( - github.com/dal-go/dalgo v0.63.1 - github.com/dal-go/dalgo2sql v0.9.7 - github.com/dal-go/record v0.1.0 + github.com/dal-go/dalgo v0.64.2 + github.com/dal-go/dalgo2sql v0.10.0 + github.com/dal-go/record v0.1.1 github.com/jackc/pgx/v5 v5.10.0 ) require ( - github.com/RoaringBitmap/roaring/v2 v2.22.0 // indirect + github.com/RoaringBitmap/roaring/v2 v2.24.0 // indirect github.com/bits-and-blooms/bitset v1.24.6 // indirect github.com/georgysavva/scany/v2 v2.1.4 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 922c0a9..63f8b77 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,17 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/RoaringBitmap/roaring/v2 v2.22.0 h1:aGqjvTSkJSTP7W6q518EHiK9RRRb5gJbCaaciCFr/Lg= -github.com/RoaringBitmap/roaring/v2 v2.22.0/go.mod h1:SfT3of9nYh3vis1dIbCj4Yw6KQGujTN+f345nrN/0JA= +github.com/RoaringBitmap/roaring/v2 v2.24.0 h1:zQkkBZtG3WRP4j+P3A5DO221SvL1Br88TJkhyqEQRZo= +github.com/RoaringBitmap/roaring/v2 v2.24.0/go.mod h1:SfT3of9nYh3vis1dIbCj4Yw6KQGujTN+f345nrN/0JA= github.com/bits-and-blooms/bitset v1.24.6 h1:qcrftZUVBIwfs+m+nhoCBAPT+ZPZZjti8SbHbDQQkZ4= github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= -github.com/dal-go/dalgo v0.63.1 h1:GEJAGlNH5xGLdFasSIRrYdqGfd0+4A9DGQ843qJQbyA= -github.com/dal-go/dalgo v0.63.1/go.mod h1:LtD5XVzb1kAdXaRcWVNy4F2ROC4fqR4jqD3q/GD4fJQ= -github.com/dal-go/dalgo2sql v0.9.7 h1:2m5tDVR6fCZwCq3wezpx8th0eIpsakwQF4vkawqRsIY= -github.com/dal-go/dalgo2sql v0.9.7/go.mod h1:yP20ORPKFDmtNEcHzBfrFVJuJPOCqWLYNYaMdE2nuTQ= -github.com/dal-go/record v0.1.0 h1:hA4143oZwIgtBH/1BRTUEZMCpDfXQr3ONlVXX8USCNg= -github.com/dal-go/record v0.1.0/go.mod h1:quwsVJTT0f6y3Mhx+yHpTobY7luX1M6kyO6fdJ/AFYE= +github.com/dal-go/dalgo v0.64.2 h1:uWCRISCMTpuwjq+VKlymq3kBtdr0c09gcsr75zmeqrw= +github.com/dal-go/dalgo v0.64.2/go.mod h1:PZGzE0AqnaJgPEDTSo7ayfKZaoglhFQ9FxHecF3aQAc= +github.com/dal-go/dalgo2sql v0.10.0 h1:2ijrIkOtKxhKafPIh3zi15av2zYnurC63K61CbfvUl0= +github.com/dal-go/dalgo2sql v0.10.0/go.mod h1:UlGLRAdhq5t0Gg3/GWuZ2/KmKRR5tbM4N5ijOvQIJPM= +github.com/dal-go/record v0.1.1 h1:N2WVDBnm2tOb83h5DJqFyINB5kH0vLjKlYFAgCJWv2g= +github.com/dal-go/record v0.1.1/go.mod h1:quwsVJTT0f6y3Mhx+yHpTobY7luX1M6kyO6fdJ/AFYE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=