diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/plan.go | 42 | ||||
| -rw-r--r-- | internal/plan_test.go | 176 |
2 files changed, 97 insertions, 121 deletions
diff --git a/internal/plan.go b/internal/plan.go index 2ed4136..ac199bf 100644 --- a/internal/plan.go +++ b/internal/plan.go @@ -4,12 +4,11 @@ package internal import ( + "errors" "fmt" "reflect" "slices" "strings" - - "go.xyrillian.de/oblast/info" ) // Plan holds all information that we can derive from reflecting on a given type. @@ -39,27 +38,30 @@ type PlannedQuery struct { ArgumentIndexes [][]int } -var ( - tableNameMarkerType = reflect.TypeFor[info.TableNameIs]() - primaryKeyMarkerType = reflect.TypeFor[info.PrimaryKeyIs]() -) +// 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) (Plan, error) { - p, err := buildPlan(t, dialect) +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) (Plan, error) { +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()) } var p = Plan{ - IndexByColumnName: make(map[string][]int), + TableName: opts.TableName, + PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames, + IndexByColumnName: make(map[string][]int), } // discover addressable fields in this type, @@ -71,19 +73,6 @@ func buildPlan(t reflect.Type, dialect Dialect) (Plan, error) { case field.PkgPath != "": // ignore unexported fields (otherwise reflect.Value.Interface() on the field would panic) continue - case field.Type == tableNameMarkerType: - // only consider this marker when directly on `t` itself, not within embedded fields - if len(field.Index) == 1 { - if len(tags) > 1 { - return Plan{}, fmt.Errorf("invalid table name %q (may not contain commas)", field.Tag.Get("db")) - } - p.TableName = tags[0] - } - case field.Type == primaryKeyMarkerType: - // only consider this marker when directly on `t` itself, not within embedded fields - if len(field.Index) == 1 { - p.PrimaryKeyColumnNames = tags - } case field.Anonymous && field.Type.Kind() == reflect.Struct: // for embedded struct fields, only consider their members, not the type itself, as a potential column continue @@ -115,11 +104,16 @@ func buildPlan(t reflect.Type, dialect Dialect) (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") + } + // validation: oblast.PrimaryKeyInfo must refer to columns that exist for _, columnName := range p.PrimaryKeyColumnNames { _, ok := p.IndexByColumnName[columnName] if !ok { - return Plan{}, fmt.Errorf("PrimaryKeyInfo refers to column %[1]q, but no field has tag `db:%[1]q`", columnName) + return Plan{}, fmt.Errorf("no field has tag `db:%q`, but a field of this name was declared in the primary key", columnName) } } diff --git a/internal/plan_test.go b/internal/plan_test.go index c504ace..88afedc 100644 --- a/internal/plan_test.go +++ b/internal/plan_test.go @@ -8,79 +8,52 @@ import ( "testing" "time" - "go.xyrillian.de/oblast/info" "go.xyrillian.de/oblast/internal" "go.xyrillian.de/oblast/internal/assert" ) func TestPlanFieldTraversal(t *testing.T) { + type Timestamps struct { + CreatedAt time.Time `db:"created_at"` + UpdatedAt *time.Time `db:"updated_at"` + } + type yetMoreTimestamps struct { + DeletedAt *time.Time `db:"deleted_at"` + } type Log struct { - info.TableNameIs `db:"log_entries"` - info.PrimaryKeyIs `db:"id"` - ID int64 `db:"id,auto"` - CreatedAt time.Time `db:"created_at"` - Message string - private1 bool `db:"private1"` //nolint:unused - Ignored any `db:"-"` + ID int64 `db:"id,auto"` + Message string + private1 bool `db:"private1"` //nolint:unused + Ignored any `db:"-"` + Timestamps + yetMoreTimestamps } - // assert on interface implementations - var ( - _ info.IsTable = Log{} - _ info.IsTableWithPrimaryKey = Log{} - ) - // check that the plan for Log: // 1. has no IndexByColumnName entries for marker types // 2. uses the field name as a column name for "Message" // 3. ignores "private1" because it cannot be written through reflection // 4. ignores "Ignored" because its column name is "-" - // 5. recognizes "id" as an autofilled column - plan, err := internal.BuildPlan(reflect.TypeFor[Log](), internal.PostgresDialect{}) + // 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{ + TableName: "log_entries", + PrimaryKeyColumnNames: []string{"id"}, + }) if err != nil { t.Error(err) } assert.Equal(t, plan.TableName, "log_entries") - assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "created_at", "Message"}) + assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "Message", "created_at", "updated_at", "deleted_at"}) assert.DeepEqual(t, plan.PrimaryKeyColumnNames, []string{"id"}) assert.DeepEqual(t, plan.AutoColumnNames, []string{"id"}) assert.DeepEqual(t, plan.IndexByColumnName, map[string][]int{ - "id": {2}, - "created_at": {3}, - "Message": {4}, - }) - - type extraTimestampFields struct { - UpdatedAt *time.Time `db:"updated_at"` - DeletedAt *time.Time `db:"deleted_at"` - } - - type record struct { - Log - *extraTimestampFields - Foo bool `db:"foo"` - } - - // check that the plan for record: - // 1. works at all, even though it as a whole is an unexported type - // 2. traverses into Log and includes all of its fields as well - // 3. traverses into *extraTimestampFields (despite the extra pointer and the type being private), too - // 3. completely ignores the marker types in type Log - plan, err = internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}) - if err != nil { - t.Error(err) - } - assert.Equal(t, plan.TableName, "") - assert.DeepEqual(t, plan.AllColumnNames, []string{"id", "created_at", "Message", "updated_at", "deleted_at", "foo"}) - assert.DeepEqual(t, plan.PrimaryKeyColumnNames, nil) - assert.DeepEqual(t, plan.AutoColumnNames, []string{"id"}) // this is okay, it does not bear significance in practice since no queries are generated - assert.DeepEqual(t, plan.IndexByColumnName, map[string][]int{ - "id": {0, 2}, - "created_at": {0, 3}, - "Message": {0, 4}, - "updated_at": {1, 0}, - "deleted_at": {1, 1}, - "foo": {2}, + "id": {0}, + "Message": {1}, + "created_at": {4, 0}, + "updated_at": {4, 1}, + "deleted_at": {5, 0}, }) } @@ -88,54 +61,58 @@ func TestPlanFieldTraversal(t *testing.T) { func TestQueryConstructionBasic(t *testing.T) { type record struct { - info.TableNameIs `db:"basic_records"` - info.PrimaryKeyIs `db:"ID"` - ID int64 `db:",auto"` - Description string - CreatedAt time.Time + ID int64 `db:",auto"` + Description string + CreatedAt time.Time + } + opts := internal.PlanOpts{ + TableName: "basic_records", + PrimaryKeyColumnNames: []string{"ID"}, } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES ($1, $2) RETURNING "ID"`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}, {4}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}}) assert.Equal(t, plan.Update.Query, `UPDATE "basic_records" SET "Description" = $1, "CreatedAt" = $2 WHERE "ID" = $3`) - assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}}) + assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}}) assert.Equal(t, plan.Delete.Query, `DELETE FROM "basic_records" WHERE "ID" = $1`) - assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}}) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}}) }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "basic_records" ("Description", "CreatedAt") VALUES (?, ?)`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}, {4}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}}) assert.Equal(t, plan.Update.Query, `UPDATE "basic_records" SET "Description" = ?, "CreatedAt" = ? WHERE "ID" = ?`) - assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}}) + assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}}) assert.Equal(t, plan.Delete.Query, `DELETE FROM "basic_records" WHERE "ID" = ?`) - assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}}) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}}) }) } func TestQueryConstructionWithoutPrimaryKey(t *testing.T) { type relation struct { - info.TableNameIs `db:"foo_bar_relations"` - FooID int64 `db:"foo_id"` - BarID int64 `db:"bar_id"` + FooID int64 `db:"foo_id"` + BarID int64 `db:"bar_id"` + } + opts := internal.PlanOpts{ + TableName: "foo_bar_relations", } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.PostgresDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.PostgresDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES ($1, $2)`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}}) assert.Equal(t, plan.Update.Query, "") assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil) assert.Equal(t, plan.Delete.Query, "") @@ -143,12 +120,12 @@ func TestQueryConstructionWithoutPrimaryKey(t *testing.T) { }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.SqliteDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[relation](), internal.SqliteDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}, {2}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}}) assert.Equal(t, plan.Update.Query, "") assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil) assert.Equal(t, plan.Delete.Query, "") @@ -161,10 +138,11 @@ func TestQueryConstructionImpossble(t *testing.T) { Foo int Bar string } + opts := internal.PlanOpts{} testWith := func(dialect internal.Dialect) func(*testing.T) { return func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[unstructuredData](), dialect) + plan, err := internal.BuildPlan(reflect.TypeFor[unstructuredData](), dialect, opts) if err != nil { t.Error(err) } @@ -184,64 +162,68 @@ func TestQueryConstructionImpossble(t *testing.T) { func TestQueryConstructionWithMultiplePrimaryKeyColumns(t *testing.T) { type record struct { - info.TableNameIs `db:"complex_records"` - info.PrimaryKeyIs `db:"group_id,name"` - GroupID int64 `db:"group_id"` - Name string `db:"name"` - CreatedAt time.Time `db:"created_at"` + GroupID int64 `db:"group_id"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at"` + } + opts := internal.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{}) + plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES ($1, $2, $3)`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{2}, {3}, {4}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}, {2}}) assert.Equal(t, plan.Update.Query, `UPDATE "complex_records" SET "created_at" = $1 WHERE "group_id" = $2 AND "name" = $3`) - assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{4}, {2}, {3}}) + assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{2}, {0}, {1}}) assert.Equal(t, plan.Delete.Query, `DELETE FROM "complex_records" WHERE "group_id" = $1 AND "name" = $2`) - assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}, {3}}) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}}) }) t.Run("SqliteDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "complex_records" ("group_id", "name", "created_at") VALUES (?, ?, ?)`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{2}, {3}, {4}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}, {2}}) assert.Equal(t, plan.Update.Query, `UPDATE "complex_records" SET "created_at" = ? WHERE "group_id" = ? AND "name" = ?`) - assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{4}, {2}, {3}}) + assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{2}, {0}, {1}}) assert.Equal(t, plan.Delete.Query, `DELETE FROM "complex_records" WHERE "group_id" = ? AND "name" = ?`) - assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}, {3}}) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}}) }) } func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) { type record struct { - info.TableNameIs `db:"autogenerated_records"` - info.PrimaryKeyIs `db:"id"` - ID int64 `db:"id,auto"` - Name string `db:"name"` - CreatedAt time.Time `db:"created_at,auto"` + ID int64 `db:"id,auto"` + Name string `db:"name"` + CreatedAt time.Time `db:"created_at,auto"` + } + opts := internal.PlanOpts{ + TableName: "autogenerated_records", + PrimaryKeyColumnNames: []string{"id"}, } t.Run("PostgresDialect", func(t *testing.T) { - plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}) + plan, err := internal.BuildPlan(reflect.TypeFor[record](), internal.PostgresDialect{}, opts) if err != nil { t.Error(err) } assert.Equal(t, plan.Insert.Query, `INSERT INTO "autogenerated_records" ("name") VALUES ($1) RETURNING "id", "created_at"`) - assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{3}}) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{1}}) assert.Equal(t, plan.Update.Query, `UPDATE "autogenerated_records" SET "name" = $1, "created_at" = $2 WHERE "id" = $3`) - assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{3}, {4}, {2}}) + assert.DeepEqual(t, plan.Update.ArgumentIndexes, [][]int{{1}, {2}, {0}}) assert.Equal(t, plan.Delete.Query, `DELETE FROM "autogenerated_records" WHERE "id" = $1`) - assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{2}}) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}}) }) t.Run("SqliteDialect", func(t *testing.T) { - _, err := internal.BuildPlan(reflect.TypeFor[record](), internal.SqliteDialect{}) + _, 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`) }) } |
