diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2026-04-14 00:50:20 +0200 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2026-04-14 00:50:20 +0200 |
| commit | d75031ffd1667c330ccc281ea330503eaeaea88a (patch) | |
| tree | 91a22017fbf2d05335f009fadcb146892e235db1 | |
| parent | 9191e018ff90deb99f3881966a5d356a05027e0f (diff) | |
| download | go-oblast-d75031ffd1667c330ccc281ea330503eaeaea88a.tar.gz | |
fold package internal into package oblast
| -rw-r--r-- | dialect.go | 30 | ||||
| -rw-r--r-- | internal/dialect.go | 41 | ||||
| -rw-r--r-- | internal/mock/mock.go (renamed from internal/mock/driver.go) | 0 | ||||
| -rw-r--r-- | oblast.go | 19 | ||||
| -rw-r--r-- | plan.go (renamed from internal/plan.go) | 84 | ||||
| -rw-r--r-- | plan_test.go (renamed from internal/plan_test.go) | 43 | ||||
| -rw-r--r-- | select.go | 8 |
7 files changed, 101 insertions, 124 deletions
@@ -3,7 +3,10 @@ package oblast -import "go.xyrillian.de/oblast/internal" +import ( + "strconv" + "strings" +) // Dialect accounts for differences between different SQL dialects // that are relevant to query generation within Oblast. @@ -38,10 +41,31 @@ type Dialect interface { // PostgresDialect is the dialect of PostgreSQL databases. func PostgresDialect() Dialect { - return internal.PostgresDialect{} + return postgresDialect{} +} + +type postgresDialect struct{} + +func (postgresDialect) Placeholder(i int) string { return "$" + strconv.Itoa(i+1) } +func (postgresDialect) QuoteIdentifier(name string) string { return `"` + name + `"` } +func (postgresDialect) UsesLastInsertID() bool { return false } + +func (p postgresDialect) InsertSuffixForAutoColumns(columns []string) string { + quotedColumns := make([]string, len(columns)) + for idx, name := range columns { + quotedColumns[idx] = p.QuoteIdentifier(name) + } + return ` RETURNING ` + strings.Join(quotedColumns, ", ") } // SqliteDialect is the dialect of SQLite databases. func SqliteDialect() Dialect { - return internal.SqliteDialect{} + return sqliteDialect{} } + +type sqliteDialect struct{} + +func (sqliteDialect) Placeholder(_ int) string { return "?" } +func (sqliteDialect) QuoteIdentifier(name string) string { return `"` + name + `"` } +func (sqliteDialect) UsesLastInsertID() bool { return true } +func (sqliteDialect) InsertSuffixForAutoColumns(columns []string) string { return "" } diff --git a/internal/dialect.go b/internal/dialect.go deleted file mode 100644 index e6db5b8..0000000 --- a/internal/dialect.go +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> -// SPDX-License-Identifier: Apache-2.0 - -package internal - -import ( - "strconv" - "strings" -) - -// Dialect is a copy of the interface of the same name in package oblast. -// We cannot refer to that interface within this package because that would constitute a cyclic dependency. -type Dialect interface { - Placeholder(i int) string - QuoteIdentifier(name string) string - UsesLastInsertID() bool - InsertSuffixForAutoColumns(columns []string) string -} - -// PostgresDialect is the dialect of PostgreSQL databases. -type PostgresDialect struct{} - -func (PostgresDialect) Placeholder(i int) string { return "$" + strconv.Itoa(i+1) } -func (PostgresDialect) QuoteIdentifier(name string) string { return `"` + name + `"` } -func (PostgresDialect) UsesLastInsertID() bool { return false } - -func (p PostgresDialect) InsertSuffixForAutoColumns(columns []string) string { - quotedColumns := make([]string, len(columns)) - for idx, name := range columns { - quotedColumns[idx] = p.QuoteIdentifier(name) - } - return ` RETURNING ` + strings.Join(quotedColumns, ", ") -} - -// SqliteDialect is the dialect of SQLite databases. -type SqliteDialect struct{} - -func (SqliteDialect) Placeholder(_ int) string { return "?" } -func (SqliteDialect) QuoteIdentifier(name string) string { return `"` + name + `"` } -func (SqliteDialect) UsesLastInsertID() bool { return true } -func (SqliteDialect) InsertSuffixForAutoColumns(columns []string) string { return "" } diff --git a/internal/mock/driver.go b/internal/mock/mock.go index d3358c4..d3358c4 100644 --- a/internal/mock/driver.go +++ b/internal/mock/mock.go @@ -42,24 +42,23 @@ package oblast // import "go.xyrillian.de/oblast" import ( "database/sql" + "fmt" "reflect" - - "go.xyrillian.de/oblast/internal" ) // PlanOption is an option that can be given to NewStore() to influence query planning for a certain type of record. -type PlanOption func(*internal.PlanOpts) +type PlanOption func(*planOpts) // TableNameIs is a PlanOption for record types that correspond to exactly one database table (as opposed to a join of multiple tables). // This option is required to enable any of the methods of [Store] that use partially or fully auto-generated query strings. func TableNameIs(name string) PlanOption { - return func(opts *internal.PlanOpts) { opts.TableName = name } + return func(opts *planOpts) { opts.TableName = name } } // PrimaryKeyIs is a PlanOption for record types that correspond to a database table with a primary key. // This option is required to enable use of the [Store.Update] and [Store.Delete] methods. func PrimaryKeyIs(columnNames ...string) PlanOption { - return func(opts *internal.PlanOpts) { opts.PrimaryKeyColumnNames = columnNames } + return func(opts *planOpts) { opts.PrimaryKeyColumnNames = columnNames } } // Handle is an interface for functions providing direct DB access. @@ -83,7 +82,7 @@ var ( // and can also be used to execute autogenerated queries if the respective [PlanOption] values were provided during [NewStore]. type Store[R any] struct { dialect Dialect - plan internal.Plan + plan plan } // NewStore initializes a store for record type R. @@ -110,11 +109,15 @@ type Store[R any] struct { // Besides the declaration of a column name, the following extra tags are understood (as a comma-separated list following the column name): // - "auto": During [Store.Insert], do not store this field's value. Instead, the database will auto-generate a value, which will be read back into the record. func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) { - var popts internal.PlanOpts + var popts planOpts for _, opt := range opts { opt(&popts) } - plan, err := internal.BuildPlan(reflect.TypeFor[R](), dialect, popts) + plan, err := buildPlan(reflect.TypeFor[R](), dialect, popts) + if err != nil { + var zero R + return Store[R]{}, fmt.Errorf("cannot use type %T for queries: %w", zero, err) + } return Store[R]{dialect, plan}, err } diff --git a/internal/plan.go b/plan.go index b57b8dd..da9f9b5 100644 --- a/internal/plan.go +++ b/plan.go @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> // SPDX-License-Identifier: Apache-2.0 -package internal +package oblast import ( "errors" @@ -11,9 +11,9 @@ import ( "strings" ) -// Plan holds all information that we can derive from reflecting on a given type. +// plan holds all information that we can derive from reflecting on a given type. // The queries held within are only valid within the context of a given SQL dialect. -type Plan struct { +type plan struct { TypeName string // for use in error messages TableName string // from info.TableNameIs marker (if any) AllColumnNames []string // in order of struct fields @@ -28,15 +28,15 @@ type Plan struct { FillIDWithSetInt bool // Planned queries. - Select PlannedQuery // only `SELECT ... FROM ... WHERE `; user supplies the rest during Select{,One}Where() - Insert PlannedQuery - Update PlannedQuery - Delete PlannedQuery + Select plannedQuery // only `SELECT ... FROM ... WHERE `; user supplies the rest during Select{,One}Where() + Insert plannedQuery + Update plannedQuery + Delete plannedQuery } -// PlannedQuery appears in type Plan. -type PlannedQuery struct { - // Empty if the respective query type is not supported by this Plan for lack of the required marker types. +// plannedQuery appears in type plan. +type plannedQuery struct { + // Empty if the respective query type is not supported by this plan for lack of the required marker types. Query string // Arguments for reflect.Value.FieldByIndex() in the correct order for the query arguments of the above query. ArgumentIndexes [][]int @@ -44,27 +44,19 @@ type PlannedQuery struct { ScanIndexes [][]int } -// PlanOpts holds additional arguments to BuildPlan(). -type PlanOpts struct { +// planOpts holds additional arguments to buildPlan(). +type planOpts struct { TableName string PrimaryKeyColumnNames []string } -// BuildPlan creates a new plan for the given struct type. -func BuildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { - p, err := buildPlan(t, dialect, opts) - if err != nil { - return Plan{}, fmt.Errorf("cannot use type %s.%s for queries: %w", t.PkgPath(), t.Name(), err) - } - return p, nil -} - -func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { +// buildPlan creates a new plan for the given struct type. +func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { if t.Kind() != reflect.Struct { - return Plan{}, fmt.Errorf("expected struct type, but got kind %s", t.Kind().String()) + return plan{}, fmt.Errorf("expected struct type, but got kind %s", t.Kind().String()) } - var p = Plan{ + var p = plan{ TypeName: t.Name(), TableName: opts.TableName, PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames, @@ -92,7 +84,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { columnName = field.Name } if otherIndex := p.IndexByColumnName[columnName]; otherIndex != nil { - return Plan{}, fmt.Errorf( + return plan{}, fmt.Errorf( "duplicate tag `db:%q` on field index %v, but also on field index %v", columnName, otherIndex, field.Index, ) @@ -105,7 +97,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { case "auto": p.AutoColumnNames = append(p.AutoColumnNames, columnName) default: - return Plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, field.Index) + return plan{}, fmt.Errorf("unknown tag `db:%q` on field index %v", ","+tag, field.Index) } } } @@ -113,14 +105,14 @@ func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { // validation: defining a primary key only makes sense for records that map onto a single table if len(p.PrimaryKeyColumnNames) > 0 && p.TableName == "" { - return Plan{}, errors.New("cannot declare a primary key without also providing the TableNameIs option") + return plan{}, errors.New("cannot declare a primary key without also providing the TableNameIs option") } // validation: oblast.PrimaryKeyInfo must refer to columns that exist for _, columnName := range p.PrimaryKeyColumnNames { _, ok := p.IndexByColumnName[columnName] if !ok { - return Plan{}, fmt.Errorf("no field has tag `db:%q`, but a field of this name was declared in the primary key", columnName) + return plan{}, fmt.Errorf("no field has tag `db:%q`, but a field of this name was declared in the primary key", columnName) } } @@ -138,13 +130,13 @@ func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: p.FillIDWithSetUint = true default: - return Plan{}, fmt.Errorf( + return plan{}, fmt.Errorf( "column is marked as auto-filled (%s), but this SQL dialect only supports auto-filling struct fields with integer types", strings.Join(p.AutoColumnNames, ", "), ) } default: - return Plan{}, fmt.Errorf( + return plan{}, fmt.Errorf( "multiple columns are marked as auto-filled (%s), but this SQL dialect only supports at most one per table", strings.Join(p.AutoColumnNames, ", "), ) @@ -160,7 +152,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts PlanOpts) (Plan, error) { return p, nil } -func (p Plan) getNonAutoColumnNames() []string { +func (p plan) getNonAutoColumnNames() []string { result := make([]string, 0, len(p.AllColumnNames)-len(p.AutoColumnNames)) for _, columnName := range p.AllColumnNames { if !slices.Contains(p.AutoColumnNames, columnName) { @@ -170,7 +162,7 @@ func (p Plan) getNonAutoColumnNames() []string { return result } -func (p Plan) getNonPrimaryKeyColumnNames() []string { +func (p plan) getNonPrimaryKeyColumnNames() []string { result := make([]string, 0, len(p.AllColumnNames)-len(p.PrimaryKeyColumnNames)) for _, columnName := range p.AllColumnNames { if !slices.Contains(p.PrimaryKeyColumnNames, columnName) { @@ -180,9 +172,9 @@ func (p Plan) getNonPrimaryKeyColumnNames() []string { return result } -func (p Plan) buildSelectQueryIfPossible(dialect Dialect) PlannedQuery { +func (p plan) buildSelectQueryIfPossible(dialect Dialect) plannedQuery { if p.TableName == "" { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } var ( @@ -199,16 +191,16 @@ func (p Plan) buildSelectQueryIfPossible(dialect Dialect) PlannedQuery { strings.Join(quotedColumnNames, ", "), dialect.QuoteIdentifier(p.TableName), ) - return PlannedQuery{query, nil, scanIndexes} + return plannedQuery{query, nil, scanIndexes} } -func (p Plan) buildInsertQueryIfPossible(dialect Dialect) PlannedQuery { +func (p plan) buildInsertQueryIfPossible(dialect Dialect) plannedQuery { if p.TableName == "" || len(p.AllColumnNames) == 0 { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } nonAutoColumnNames := p.getNonAutoColumnNames() if len(nonAutoColumnNames) == 0 { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } var ( @@ -240,16 +232,16 @@ func (p Plan) buildInsertQueryIfPossible(dialect Dialect) PlannedQuery { if len(p.AutoColumnNames) > 0 { query += dialect.InsertSuffixForAutoColumns(p.AutoColumnNames) } - return PlannedQuery{query, argumentIndexes, scanIndexes} + return plannedQuery{query, argumentIndexes, scanIndexes} } -func (p Plan) buildUpdateQueryIfPossible(dialect Dialect) PlannedQuery { +func (p plan) buildUpdateQueryIfPossible(dialect Dialect) plannedQuery { if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } nonPrimaryKeyColumnNames := p.getNonPrimaryKeyColumnNames() if len(nonPrimaryKeyColumnNames) == 0 { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } var ( @@ -276,12 +268,12 @@ func (p Plan) buildUpdateQueryIfPossible(dialect Dialect) PlannedQuery { strings.Join(setClauses, ", "), strings.Join(whereClauses, " AND "), ) - return PlannedQuery{query, slices.Concat(setArgumentIndexes, whereArgumentIndexes), nil} + return plannedQuery{query, slices.Concat(setArgumentIndexes, whereArgumentIndexes), nil} } -func (p Plan) buildDeleteQueryIfPossible(dialect Dialect) PlannedQuery { +func (p plan) buildDeleteQueryIfPossible(dialect Dialect) plannedQuery { if p.TableName == "" || len(p.PrimaryKeyColumnNames) == 0 { - return PlannedQuery{Query: ""} + return plannedQuery{Query: ""} } var ( @@ -298,5 +290,5 @@ func (p Plan) buildDeleteQueryIfPossible(dialect Dialect) PlannedQuery { dialect.QuoteIdentifier(p.TableName), strings.Join(clauses, " AND "), ) - return PlannedQuery{query, argumentIndexes, nil} + return plannedQuery{query, argumentIndexes, nil} } diff --git a/internal/plan_test.go b/plan_test.go index e692556..1095016 100644 --- a/internal/plan_test.go +++ b/plan_test.go @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net> // SPDX-License-Identifier: Apache-2.0 -package internal_test +package oblast + +// ^ NOTE: This is testing internal types and thus must reside in the same package. import ( "reflect" "testing" "time" - "go.xyrillian.de/oblast/internal" "go.xyrillian.de/oblast/internal/assert" ) @@ -37,7 +38,7 @@ func TestPlanFieldTraversal(t *testing.T) { // 5. traverses into "Timestamps" and includes its fields as well // 6. traverses into "yetMoreTimestamps" as well (despite the extra pointer and the type being private) // 7. recognizes "id" as an autofilled column - plan, err := internal.BuildPlan(reflect.TypeFor[Log](), internal.PostgresDialect{}, internal.PlanOpts{ + plan, err := buildPlan(reflect.TypeFor[Log](), PostgresDialect(), planOpts{ TableName: "log_entries", PrimaryKeyColumnNames: []string{"id"}, }) @@ -65,13 +66,13 @@ func TestQueryConstructionBasic(t *testing.T) { Description string CreatedAt time.Time } - opts := internal.PlanOpts{ + opts := planOpts{ TableName: "basic_records", PrimaryKeyColumnNames: []string{"ID"}, } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts) if err != nil { t.Error(err) } @@ -90,7 +91,7 @@ func TestQueryConstructionBasic(t *testing.T) { }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts) if err != nil { t.Error(err) } @@ -114,12 +115,12 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) { FooID int64 `db:"foo_id"` BarID int64 `db:"bar_id"` } - opts := internal.PlanOpts{ + opts := planOpts{ TableName: "foo_bar_relations", } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.PostgresDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[relation](), PostgresDialect(), opts) if err != nil { t.Error(err) } @@ -138,7 +139,7 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) { }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.SqliteDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[relation](), SqliteDialect(), opts) if err != nil { t.Error(err) } @@ -162,11 +163,11 @@ func TestQueryConstructionImpossble(t *testing.T) { Foo int Bar string } - opts := internal.PlanOpts{} + opts := planOpts{} - testWith := func(dialect internal.Dialect) func(*testing.T) { + testWith := func(dialect Dialect) func(*testing.T) { return func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[unstructuredData](), dialect, opts) + plan, err := buildPlan(reflect.TypeFor[unstructuredData](), dialect, opts) if err != nil { t.Error(err) } @@ -186,8 +187,8 @@ func TestQueryConstructionImpossble(t *testing.T) { } } - t.Run("PostgresDialect", testWith(internal.PostgresDialect{})) - t.Run("SqliteDialect", testWith(internal.SqliteDialect{})) + t.Run("PostgresDialect", testWith(PostgresDialect())) + t.Run("SqliteDialect", testWith(SqliteDialect())) } func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) { @@ -196,13 +197,13 @@ func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) { Name string `db:"name"` CreatedAt time.Time `db:"created_at"` } - opts := internal.PlanOpts{ + opts := planOpts{ TableName: "complex_records", PrimaryKeyColumnNames: []string{"group_id", "name"}, } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts) if err != nil { t.Error(err) } @@ -221,7 +222,7 @@ func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) { }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts) if err != nil { t.Error(err) } @@ -246,13 +247,13 @@ func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) { Name string `db:"name"` CreatedAt time.Time `db:"created_at,auto"` } - opts := internal.PlanOpts{ + opts := planOpts{ TableName: "autogenerated_records", PrimaryKeyColumnNames: []string{"id"}, } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) + plan, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts) if err != nil { t.Error(err) } @@ -271,7 +272,7 @@ func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) { }) t.Run("SqliteDialect", func(t *testing.T) { - _, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts) - assert.Equal(t, err.Error(), `cannot use type go.xyrillian.de/oblast/internal_test.record for queries: multiple columns are marked as auto-filled (id, created_at), but this SQL dialect only supports at most one per table`) + _, err := NewStore[record](SqliteDialect()) + assert.Equal(t, err.Error(), `cannot use type oblast.record for queries: multiple columns are marked as auto-filled (id, created_at), but this SQL dialect only supports at most one per table`) }) } @@ -8,8 +8,6 @@ import ( "errors" "fmt" "reflect" - - "go.xyrillian.de/oblast/internal" ) // Select executes the provided SQL query and fills an instance of the record type R for each row in the result set, @@ -79,7 +77,7 @@ func (s Store[R]) SelectWhere(db Handle, partialQuery string, args ...any) (resu return result, nil } -func startSelectQuery(db Handle, plan internal.Plan, query string, args ...any) (returnedRows *sql.Rows, indexes [][]int, returnedError error) { +func startSelectQuery(db Handle, plan plan, query string, args ...any) (returnedRows *sql.Rows, indexes [][]int, returnedError error) { rows, err := db.Query(query, args...) if err != nil { return nil, nil, fmt.Errorf("during Query(): %w", err) @@ -112,7 +110,7 @@ func startSelectQuery(db Handle, plan internal.Plan, query string, args ...any) return rows, indexes, nil } -func startSelectWhereQuery(db Handle, plan internal.Plan, partialQuery string, args ...any) (rows *sql.Rows, indexes [][]int, err error) { +func startSelectWhereQuery(db Handle, plan plan, partialQuery string, args ...any) (rows *sql.Rows, indexes [][]int, err error) { if plan.Select.Query == "" { return nil, nil, errors.New("cannot execute SelectWhere() because query could not be autogenerated") } @@ -175,7 +173,7 @@ func (s Store[R]) SelectOneWhere(db Handle, partialQuery string, args ...any) (r return } -func selectOneWhere(db Handle, plan internal.Plan, v reflect.Value, partialQuery string, args []any) error { +func selectOneWhere(db Handle, plan plan, v reflect.Value, partialQuery string, args []any) error { if plan.Select.Query == "" { return errors.New("cannot execute SelectOneWhere() because query could not be autogenerated") } |
