From aaca0bbcaea7d59f7f78d1a9203ab8c9c2b179bb Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sat, 25 Jul 2026 20:51:48 +0100 Subject: [PATCH] feat: embed dal.DB so Database satisfies the sealed interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.NoConcurrency 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 against a fresh pure-Go SQLite file per check (modernc.org/sqlite) — no external service, no env-gate, same as every other test in this package. All 16 subtests run for real and pass, including database-level-writes (a structural no-op here, same as in dalgo2sql itself: neither this package's Database nor dalgo2sql's own backend implement dal.WriteSession in full at the database level, so that specific check has nothing to assert against and passes trivially, matching dalgotest's documented tolerance for that case). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SkkrXdtf8mU2GRo2hHsHT1 Signed-off-by: Alexander Trakhimenok --- conformance_test.go | 45 ++++++++++++++++++++++ database.go | 38 +++++++++++++------ database_dal.go | 91 ++++++++++++++++++++++++++++++++++++--------- go.mod | 8 ++-- go.sum | 18 ++++----- 5 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 conformance_test.go diff --git a/conformance_test.go b/conformance_test.go new file mode 100644 index 0000000..e64c7cc --- /dev/null +++ b/conformance_test.go @@ -0,0 +1,45 @@ +package dalgo2sqlite + +import ( + "path/filepath" + "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 fresh SQLite +// file database (modernc.org/sqlite, pure Go) — no external service and no +// env-gate needed, the same as every other test in this package. +// +// dalgo2sqlite itself validates nothing beyond what dalgo2sql provides. +// Every check here passes because dal.NewDB's write pipeline runs +// BeforeSave validation and hooks before RunReadwriteTransaction ever +// reaches dalgo2sql's transaction, and RunReadwriteTransaction is the path +// every check in this suite writes through — see database_dal.go's +// RunReadwriteTransaction and dalgo2sql's own conformance_test.go. +func TestConformance(t *testing.T) { + opts := dalgo2sql.DbOptions{ + Recordsets: map[string]*dalgo2sql.Recordset{ + dalgotest.DefaultCollection: dalgo2sql.NewRecordset( + dalgotest.DefaultCollection, dalgo2sql.Table, []dal.FieldRef{dal.Field("ID")}, + ), + }, + } + + dalgotest.RunConformance(t, func(t *testing.T) (dal.DB, func()) { + db, err := NewDatabaseWithOptions( + filepath.Join(t.TempDir(), "conformance.db"), dal.NewSchema(nil, nil), opts) + if err != nil { + t.Fatalf("NewDatabaseWithOptions: %v", err) + } + if _, err := db.sqlDB.Exec(`CREATE TABLE ` + dalgotest.DefaultCollection + ` ( + ID TEXT PRIMARY KEY, + Name TEXT + )`); err != nil { + t.Fatalf("CREATE TABLE: %v", err) + } + return db, func() { _ = db.Close() } + }) +} diff --git a/database.go b/database.go index 13877e8..64af49c 100644 --- a/database.go +++ b/database.go @@ -23,10 +23,14 @@ import ( _ "modernc.org/sqlite" // register the "sqlite" driver (pure Go, CGO_ENABLED=0) ) -// Database is the dalgo2sqlite driver instance. It implements -// [dal.DB] by delegating to an inner [dal.DB] obtained from -// [dalgo2sql.NewDatabase], and adds SQLite-specific dbschema, ddl, -// and concurrency surfaces. +// Database is the dalgo2sqlite driver instance. It implements [dal.DB] by +// embedding a [dal.DB] obtained from [dalgo2sql.NewDatabase], and adds +// SQLite-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 only insofar as SQLite itself is — readers can be @@ -34,9 +38,9 @@ import ( type Database struct { dal.NoConcurrency // SupportsConcurrentConnections() = false - innerDB dal.DB // delegate for the dal.DB surface - sqlDB *sql.DB // direct handle for DDL + PRAGMA queries - dbPath string // remembered for diagnostics + dal.DB // delegate for the dal.DB surface + sqlDB *sql.DB // direct handle for DDL + PRAGMA queries + dbPath string // remembered for diagnostics } // NewDatabase opens (or creates) the SQLite file at dbPath using @@ -75,9 +79,9 @@ func NewDatabaseWithOptions(dbPath string, schema dal.Schema, opts dalgo2sql.DbO } innerDB := dalgo2sql.NewDatabase(sqlDB, schema, opts) return &Database{ - innerDB: innerDB, - sqlDB: sqlDB, - dbPath: dbPath, + DB: innerDB, + sqlDB: sqlDB, + dbPath: dbPath, }, nil } @@ -91,8 +95,18 @@ func (d *Database) Close() error { return d.sqlDB.Close() } +// SupportsConcurrentConnections reports SQLite's own concurrency behaviour +// (always false — see dal.NoConcurrency), not dalgo2sql's. An explicit +// method is required here: dal.NoConcurrency 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.NoConcurrency.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 { @@ -100,7 +114,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 dalgo2sqlite package version. Updated by hand on // each release; consumed by Adapter.Version(). diff --git a/database_dal.go b/database_dal.go index 20b7073..a0b6571 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 0f1175c..8f9457c 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module github.com/dal-go/dalgo2sqlite go 1.25.0 require ( - github.com/dal-go/dalgo v0.63.2 - 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 modernc.org/sqlite v1.54.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/dustin/go-humanize v1.0.1 // indirect github.com/georgysavva/scany/v2 v2.1.4 // indirect diff --git a/go.sum b/go.sum index 1d8dc74..228dcb2 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +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/dalgo v0.63.2 h1:L3hDte5QaorZngNK619V1zKach4qfeb9jOQQUsn4vrc= -github.com/dal-go/dalgo v0.63.2/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=