diff options
| -rw-r--r-- | internal/assert/assert.go | 12 | ||||
| -rw-r--r-- | plan.go | 11 | ||||
| -rw-r--r-- | plan_test.go | 135 | ||||
| -rw-r--r-- | select_test.go | 16 |
4 files changed, 156 insertions, 18 deletions
diff --git a/internal/assert/assert.go b/internal/assert/assert.go index 99af59c..26f91ff 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -12,7 +12,8 @@ import ( func Equal[V comparable](t testing.TB, actual, expected V) { t.Helper() if actual != expected { - t.Errorf("expected %#v, but got %#v", expected, actual) + t.Errorf("expected %#v", expected) + t.Errorf(" but got %#v", actual) } } @@ -20,7 +21,8 @@ func Equal[V comparable](t testing.TB, actual, expected V) { func DeepEqual[V any](t testing.TB, actual, expected V) { t.Helper() if !reflect.DeepEqual(actual, expected) { - t.Errorf("expected %#v, but got %#v", expected, actual) + t.Errorf("expected %#v", expected) + t.Errorf(" but got %#v", actual) } } @@ -32,7 +34,8 @@ func SliceEqual[V comparable](t testing.TB, actual []V, expected ...V) { } for idx := range actual { if actual[idx] != expected[idx] { - t.Errorf("element %d: expected %#v, but got %#v", idx, expected[idx], actual[idx]) + t.Errorf("element %d: expected %#v", idx, expected[idx]) + t.Errorf("element %d: but got %#v", idx, actual[idx]) } } } @@ -45,7 +48,8 @@ func SliceDeepEqual[V any](t testing.TB, actual []V, expected ...V) { } for idx := range actual { if !reflect.DeepEqual(actual[idx], expected[idx]) { - t.Errorf("element %d: expected %#v, but got %#v", idx, expected[idx], actual[idx]) + t.Errorf("element %d: expected %#v", idx, expected[idx]) + t.Errorf("element %d: but got %#v", idx, actual[idx]) } } } @@ -56,7 +56,7 @@ type planOpts struct { // 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 %q", t.Kind().String()) } var p = plan{ @@ -134,7 +134,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 option `db:%q` on field %q", ","+tag, field.Name) } } } @@ -146,7 +146,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { for _, index := range indexesOfUnusedTransparentStructs { field := t.FieldByIndex(index) return plan{}, fmt.Errorf( - "field %q of type %s does not contain any mapped fields (to map this entire field to a DB column, add an explicit `db:\"...\"` tag)", + "field %q of type %s does not contain any mapped fields (to map this whole field to a DB column, add an explicit `db:\"...\"` tag)", field.Name, field.Type.String(), ) } @@ -179,9 +179,8 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) { p.FillIDWithSetUint = true default: 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, ", "), - ) + "column %q is marked as auto-filled, but this SQL dialect only supports auto-filling struct fields with integer types", + columnName) } default: return plan{}, fmt.Errorf( diff --git a/plan_test.go b/plan_test.go index e9d8dea..772c14a 100644 --- a/plan_test.go +++ b/plan_test.go @@ -127,6 +127,74 @@ func TestQueryConstructionBasic(t *testing.T) { }) } +func TestQueryConstructionWithOnlyPrimaryKey(t *testing.T) { + type relation struct { + FooID int64 `db:"foo_id"` + BarID int64 `db:"bar_id"` + } + opts := planOpts{ + TableName: "foo_bar_relations", + PrimaryKeyColumnNames: []string{"foo_id", "bar_id"}, + } + + t.Run("MysqlDialect", func(t *testing.T) { + plan, err := buildPlan(reflect.TypeFor[relation](), MysqlDialect(), opts) + if err != nil { + t.Error(err) + } + assert.Equal(t, plan.Select.Query, "SELECT `foo_id`, `bar_id` FROM `foo_bar_relations` WHERE ") + assert.DeepEqual(t, plan.Select.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Select.ScanIndexes, [][]int{{0}, {1}}) + assert.Equal(t, plan.Insert.Query, "INSERT INTO `foo_bar_relations` (`foo_id`, `bar_id`) VALUES (?, ?)") + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}}) + assert.DeepEqual(t, plan.Insert.ScanIndexes, nil) + assert.Equal(t, plan.Update.Query, "") + assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Update.ScanIndexes, nil) + assert.Equal(t, plan.Delete.Query, "DELETE FROM `foo_bar_relations` WHERE `foo_id` = ? AND `bar_id` = ?") + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}}) + assert.DeepEqual(t, plan.Delete.ScanIndexes, nil) + }) + + t.Run("PostgresDialect", func(t *testing.T) { + plan, err := buildPlan(reflect.TypeFor[relation](), PostgresDialect(), opts) + if err != nil { + t.Error(err) + } + assert.Equal(t, plan.Select.Query, `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `) + assert.DeepEqual(t, plan.Select.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Select.ScanIndexes, [][]int{{0}, {1}}) + 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{{0}, {1}}) + assert.DeepEqual(t, plan.Insert.ScanIndexes, nil) + assert.Equal(t, plan.Update.Query, "") + assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Update.ScanIndexes, nil) + assert.Equal(t, plan.Delete.Query, `DELETE FROM "foo_bar_relations" WHERE "foo_id" = $1 AND "bar_id" = $2`) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}}) + assert.DeepEqual(t, plan.Delete.ScanIndexes, nil) + }) + + t.Run("SqliteDialect", func(t *testing.T) { + plan, err := buildPlan(reflect.TypeFor[relation](), SqliteDialect(), opts) + if err != nil { + t.Error(err) + } + assert.Equal(t, plan.Select.Query, `SELECT "foo_id", "bar_id" FROM "foo_bar_relations" WHERE `) + assert.DeepEqual(t, plan.Select.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Select.ScanIndexes, [][]int{{0}, {1}}) + assert.Equal(t, plan.Insert.Query, `INSERT INTO "foo_bar_relations" ("foo_id", "bar_id") VALUES (?, ?)`) + assert.DeepEqual(t, plan.Insert.ArgumentIndexes, [][]int{{0}, {1}}) + assert.DeepEqual(t, plan.Insert.ScanIndexes, nil) + assert.Equal(t, plan.Update.Query, "") + assert.DeepEqual(t, plan.Update.ArgumentIndexes, nil) + assert.DeepEqual(t, plan.Update.ScanIndexes, nil) + assert.Equal(t, plan.Delete.Query, `DELETE FROM "foo_bar_relations" WHERE "foo_id" = ? AND "bar_id" = ?`) + assert.DeepEqual(t, plan.Delete.ArgumentIndexes, [][]int{{0}, {1}}) + assert.DeepEqual(t, plan.Delete.ScanIndexes, nil) + }) +} + func TestQueryConstructionWithoutPrimaryKey(t *testing.T) { type relation struct { FooID int64 `db:"foo_id"` @@ -337,3 +405,70 @@ func TestQueryConstructionWithMultipleAutoColumns(t *testing.T) { 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`) }) } + +func TestPlanErrorCases(t *testing.T) { + type recordUsedViaPointer struct { + ID int64 `db:"id"` + } + + _, err := NewStore[*recordUsedViaPointer](SqliteDialect()) + assert.Equal(t, err.Error(), `cannot use type *oblast.recordUsedViaPointer for queries: `+ + `expected struct type, but got kind "ptr"`) + + type recordWithDuplicateTags struct { + Foo int64 `db:"Bar"` + Qux float64 + Bar string + } + _, err = NewStore[recordWithDuplicateTags](SqliteDialect()) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithDuplicateTags for queries: `+ + "duplicate tag `db:\"Bar\"` on field index [0], but also on field index [2]") + + type recordWithUnusedTransparentStruct struct { + ID int64 + CreatedAt time.Time // has no exported fields! + } + _, err = NewStore[recordWithUnusedTransparentStruct](SqliteDialect()) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithUnusedTransparentStruct for queries: `+ + "field \"CreatedAt\" of type time.Time does not contain any mapped fields (to map this whole field to a DB column, add an explicit `db:\"...\"` tag)") + + type recordWithPKButNoTableName struct { + ID int64 `db:"id"` + Name string `db:"name"` + } + _, err = NewStore[recordWithPKButNoTableName](SqliteDialect(), + PrimaryKeyIs("id"), + ) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithPKButNoTableName for queries: `+ + `cannot declare a primary key without also providing the TableNameIs option`) + + type recordWithUnknownPK struct { + ID int64 `db:"id"` + Name string `db:"name"` + } + _, err = NewStore[recordWithUnknownPK](SqliteDialect(), + TableNameIs("records"), + PrimaryKeyIs("record_id"), + ) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithUnknownPK for queries: `+ + "no field has tag `db:\"record_id\"`, but a field of this name was declared in the primary key") + + type recordWithNonintegerAutoKey struct { + CreatedAt time.Time `db:"created_at,auto"` + Name string `db:"name"` + } + _, err = NewStore[recordWithNonintegerAutoKey](SqliteDialect(), + TableNameIs("records"), + ) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithNonintegerAutoKey for queries: `+ + `column "created_at" is marked as auto-filled, but this SQL dialect only supports auto-filling struct fields with integer types`) + + type recordWithWeirdTagOption struct { + ID int64 `db:",auto"` + Name string `db:",unique"` + Description string + } + _, err = NewStore[recordWithWeirdTagOption](SqliteDialect()) + assert.Equal(t, err.Error(), `cannot use type oblast.recordWithWeirdTagOption for queries: `+ + "unknown option `db:\",unique\"` on field \"Name\"") +} diff --git a/select_test.go b/select_test.go index 9fcecc3..f364e1c 100644 --- a/select_test.go +++ b/select_test.go @@ -22,11 +22,11 @@ func TestSelectReturningSomeRecords(t *testing.T) { ID int64 `db:"id"` Name string `db:"name"` } - store := must.Return(oblast.NewStore[basicRecord]( + store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), - ))(t) + ) t.Run("using Store.Select", func(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). @@ -83,11 +83,11 @@ func TestSelectReturningNoRecords(t *testing.T) { ID int64 `db:"id"` Name string `db:"name"` } - store := must.Return(oblast.NewStore[basicRecord]( + store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), - ))(t) + ) t.Run("using Store.Select", func(t *testing.T) { md.ForQuery(`SELECT * FROM basic_records WHERE id < ?`). @@ -130,11 +130,11 @@ func TestSelectIntoUnexpectedField(t *testing.T) { ID int64 `db:"id"` Description string `db:"desc"` // but DB knows only the field "name"! } - store := must.Return(oblast.NewStore[basicRecord]( + store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), - ))(t) + ) expectedError := "result has column \"name\" in position 0, but no field in type basicRecord has `db:\"name\"`" @@ -169,11 +169,11 @@ func TestSelectWithScanError(t *testing.T) { ID int64 `db:"id"` CreatedAt time.Time `db:"created_at"` // but the DB will give us strings that are not timestamps } - store := must.Return(oblast.NewStore[basicRecord]( + store := oblast.MustNewStore[basicRecord]( oblast.SqliteDialect(), oblast.TableNameIs("basic_records"), oblast.PrimaryKeyIs("id"), - ))(t) + ) expectedError := `sql: Scan error on column index 1, name "created_at": unsupported Scan, storing driver.Value type string into type *time.Time` |
