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=