mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-06 05:16:27 +00:00
fix(bun): ignore scanonly columns in ExcludeColumn
Tests / Integration Tests (push) Skipped
Tests / Unit Tests (push) Successful in 1m37s
Tests / Race Detector (push) Successful in 3m52s
Build , Vet Test, and Lint / Build (push) Successful in 1m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m19s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m19s
Tests / Integration Tests (push) Skipped
Tests / Unit Tests (push) Successful in 1m37s
Tests / Race Detector (push) Successful in 3m52s
Build , Vet Test, and Lint / Build (push) Successful in 1m33s
Build , Vet Test, and Lint / Run Vet Tests (1.24.x) (push) Successful in 2m16s
Build , Vet Test, and Lint / Run Vet Tests (1.23.x) (push) Successful in 2m19s
Build , Vet Test, and Lint / Lint Code (push) Successful in 2m19s
Bun's ExcludeColumn errors with "can't find column" for scanonly fields because they are not in the table's writable fields. Filter the exclude list to writable bun fields so models with scanonly buffers can insert and update again. Add tests for the adapter and reflection.
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/schema"
|
||||||
|
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/common"
|
"github.com/bitechdev/ResolveSpec/pkg/common"
|
||||||
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
"github.com/bitechdev/ResolveSpec/pkg/dbtrace"
|
||||||
@@ -1507,8 +1508,30 @@ func (b *BunInsertQuery) OnConflict(action string) common.InsertQuery {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bunWritableExcludes drops columns bun already leaves out of INSERT/UPDATE
|
||||||
|
// (scanonly fields) or does not know, since bun's ExcludeColumn errors with
|
||||||
|
// "can't find column" for anything that is not in the table's writable fields.
|
||||||
|
func bunWritableExcludes(model bun.Model, columns []string) []string {
|
||||||
|
tm, ok := model.(interface{ Table() *schema.Table })
|
||||||
|
if !ok || tm.Table() == nil {
|
||||||
|
return columns
|
||||||
|
}
|
||||||
|
table := tm.Table()
|
||||||
|
writable := make(map[string]struct{}, len(table.Fields))
|
||||||
|
for _, f := range table.Fields {
|
||||||
|
writable[f.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(columns))
|
||||||
|
for _, c := range columns {
|
||||||
|
if _, ok := writable[c]; ok || c == "*" {
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (b *BunInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
func (b *BunInsertQuery) ExcludeColumn(columns ...string) common.InsertQuery {
|
||||||
if len(columns) > 0 {
|
if columns = bunWritableExcludes(b.query.GetModel(), columns); len(columns) > 0 {
|
||||||
b.query = b.query.ExcludeColumn(columns...)
|
b.query = b.query.ExcludeColumn(columns...)
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
@@ -1627,7 +1650,7 @@ func (b *BunUpdateQuery) SetMap(values map[string]interface{}) common.UpdateQuer
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *BunUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
func (b *BunUpdateQuery) ExcludeColumn(columns ...string) common.UpdateQuery {
|
||||||
if len(columns) > 0 {
|
if columns = bunWritableExcludes(b.query.GetModel(), columns); len(columns) > 0 {
|
||||||
b.query = b.query.ExcludeColumn(columns...)
|
b.query = b.query.ExcludeColumn(columns...)
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
|
|
||||||
|
"github.com/bitechdev/ResolveSpec/pkg/reflection"
|
||||||
|
)
|
||||||
|
|
||||||
|
// adhocBuffer mirrors the real-world DBAdhocBuffer: scanonly fields with both
|
||||||
|
// bun and gorm read-only tags.
|
||||||
|
type adhocBuffer struct {
|
||||||
|
CQL1 string `json:"cql1,omitempty" gorm:"->" bun:",scanonly"`
|
||||||
|
CQL2 string `json:"cql2,omitempty" gorm:"->" bun:",scanonly"`
|
||||||
|
RowNumber int64 `json:"_rownumber,omitempty" gorm:"-" bun:",scanonly"`
|
||||||
|
RecordError string `json:"_error,omitempty" gorm:"-" bun:",scanonly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type excludeModel struct {
|
||||||
|
bun.BaseModel `bun:"table:public.crmnote,alias:crmnote"`
|
||||||
|
ID int `json:"id" bun:"id,pk"`
|
||||||
|
Note string `json:"note" bun:"note,type:citext,"`
|
||||||
|
Norm string `json:"norm" bun:"norm,generated"`
|
||||||
|
|
||||||
|
adhocBuffer `json:",omitempty" bun:",scanonly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func newExcludeDB() *bun.DB {
|
||||||
|
return bun.NewDB(&sql.DB{}, pgdialect.New())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBunExcludeColumnWithNonWritableColumns feeds the reflection output
|
||||||
|
// straight into the adapter, as the handlers do, for insert and update.
|
||||||
|
func TestBunExcludeColumnWithNonWritableColumns(t *testing.T) {
|
||||||
|
db := newExcludeDB()
|
||||||
|
m := &excludeModel{}
|
||||||
|
cols := reflection.NonWritableColumns(m)
|
||||||
|
if len(cols) == 0 {
|
||||||
|
t.Fatal("expected non-writable columns")
|
||||||
|
}
|
||||||
|
|
||||||
|
ins := &BunInsertQuery{query: db.NewInsert().Model(m)}
|
||||||
|
ins.ExcludeColumn(cols...)
|
||||||
|
insSQL, err := ins.query.AppendQuery(db.QueryGen(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
upd := &BunUpdateQuery{query: db.NewUpdate().Model(m).Where("id = 1")}
|
||||||
|
upd.ExcludeColumn(cols...)
|
||||||
|
updSQL, err := upd.query.AppendQuery(db.QueryGen(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, q := range map[string]string{"insert": string(insSQL), "update": string(updSQL)} {
|
||||||
|
for _, bad := range []string{"cql1", "cql2", "_rownumber", "_error", "norm"} {
|
||||||
|
if strings.Contains(q, `"`+bad+`"`) {
|
||||||
|
t.Errorf("%s writes non-writable column %s: %s", name, bad, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(q, `"note"`) {
|
||||||
|
t.Errorf("%s dropped writable column note: %s", name, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBunExcludeColumnIgnoresUnknownAndKeepsWritable(t *testing.T) {
|
||||||
|
db := newExcludeDB()
|
||||||
|
m := &excludeModel{}
|
||||||
|
|
||||||
|
ins := &BunInsertQuery{query: db.NewInsert().Model(m)}
|
||||||
|
ins.ExcludeColumn("does_not_exist", "note")
|
||||||
|
q, err := ins.query.AppendQuery(db.QueryGen(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(q), `"note"`) {
|
||||||
|
t.Errorf("writable column note should have been excluded: %s", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBunExcludeColumnOnlyNonWritable(t *testing.T) {
|
||||||
|
db := newExcludeDB()
|
||||||
|
ins := &BunInsertQuery{query: db.NewInsert().Model(&excludeModel{})}
|
||||||
|
ins.ExcludeColumn("cql1") // everything filtered out: must not error or panic
|
||||||
|
if _, err := ins.query.AppendQuery(db.QueryGen(), nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBunExcludeColumnWithoutModel(t *testing.T) {
|
||||||
|
db := newExcludeDB()
|
||||||
|
ins := &BunInsertQuery{query: db.NewInsert()}
|
||||||
|
ins.ExcludeColumn("cql1") // no model yet: must not panic
|
||||||
|
}
|
||||||
@@ -497,13 +497,13 @@ func TestIsColumnWritableWithEmbedded(t *testing.T) {
|
|||||||
|
|
||||||
// Test models with relations for GetSQLModelColumns
|
// Test models with relations for GetSQLModelColumns
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int `bun:"id,pk" json:"id"`
|
ID int `bun:"id,pk" json:"id"`
|
||||||
Name string `bun:"name" json:"name"`
|
Name string `bun:"name" json:"name"`
|
||||||
Email string `bun:"email" json:"email"`
|
Email string `bun:"email" json:"email"`
|
||||||
ProfileData string `json:"profile_data"` // No bun/gorm tag
|
ProfileData string `json:"profile_data"` // No bun/gorm tag
|
||||||
Posts []Post `bun:"rel:has-many,join:id=user_id" json:"posts"`
|
Posts []Post `bun:"rel:has-many,join:id=user_id" json:"posts"`
|
||||||
Profile *Profile `bun:"rel:has-one,join:id=user_id" json:"profile"`
|
Profile *Profile `bun:"rel:has-one,join:id=user_id" json:"profile"`
|
||||||
RowNumber int64 `bun:",scanonly" json:"_rownumber"`
|
RowNumber int64 `bun:",scanonly" json:"_rownumber"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
@@ -528,8 +528,8 @@ type Tag struct {
|
|||||||
|
|
||||||
// Model with scan-only embedded struct
|
// Model with scan-only embedded struct
|
||||||
type EntityWithScanOnlyEmbedded struct {
|
type EntityWithScanOnlyEmbedded struct {
|
||||||
ID int `bun:"id,pk" json:"id"`
|
ID int `bun:"id,pk" json:"id"`
|
||||||
Name string `bun:"name" json:"name"`
|
Name string `bun:"name" json:"name"`
|
||||||
AdhocBuffer `bun:",scanonly"` // Entire embedded struct is scan-only
|
AdhocBuffer `bun:",scanonly"` // Entire embedded struct is scan-only
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1086,17 +1086,17 @@ func TestGetColumnTypeFromModel_SqlNullWrapper(t *testing.T) {
|
|||||||
|
|
||||||
// Models for relation testing
|
// Models for relation testing
|
||||||
type Author struct {
|
type Author struct {
|
||||||
ID int `bun:"id,pk" json:"id"`
|
ID int `bun:"id,pk" json:"id"`
|
||||||
Name string `bun:"name" json:"name"`
|
Name string `bun:"name" json:"name"`
|
||||||
Books []Book `bun:"rel:has-many,join:id=author_id" json:"books"`
|
Books []Book `bun:"rel:has-many,join:id=author_id" json:"books"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Book struct {
|
type Book struct {
|
||||||
ID int `bun:"id,pk" json:"id"`
|
ID int `bun:"id,pk" json:"id"`
|
||||||
Title string `bun:"title" json:"title"`
|
Title string `bun:"title" json:"title"`
|
||||||
AuthorID int `bun:"author_id" json:"author_id"`
|
AuthorID int `bun:"author_id" json:"author_id"`
|
||||||
Author *Author `bun:"rel:belongs-to,join:author_id=id" json:"author"`
|
Author *Author `bun:"rel:belongs-to,join:author_id=id" json:"author"`
|
||||||
Publisher *Publisher `bun:"rel:has-one,join:id=book_id" json:"publisher"`
|
Publisher *Publisher `bun:"rel:has-one,join:id=book_id" json:"publisher"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Publisher struct {
|
type Publisher struct {
|
||||||
@@ -1106,9 +1106,9 @@ type Publisher struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Student struct {
|
type Student struct {
|
||||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||||
Name string `gorm:"column:name" json:"name"`
|
Name string `gorm:"column:name" json:"name"`
|
||||||
Courses []Course `gorm:"many2many:student_courses" json:"courses"`
|
Courses []Course `gorm:"many2many:student_courses" json:"courses"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Course struct {
|
type Course struct {
|
||||||
@@ -1119,11 +1119,11 @@ type Course struct {
|
|||||||
|
|
||||||
// Recursive relation model
|
// Recursive relation model
|
||||||
type Category struct {
|
type Category struct {
|
||||||
ID int `bun:"id,pk" json:"id"`
|
ID int `bun:"id,pk" json:"id"`
|
||||||
Name string `bun:"name" json:"name"`
|
Name string `bun:"name" json:"name"`
|
||||||
ParentID *int `bun:"parent_id" json:"parent_id"`
|
ParentID *int `bun:"parent_id" json:"parent_id"`
|
||||||
Parent *Category `bun:"rel:belongs-to,join:parent_id=id" json:"parent"`
|
Parent *Category `bun:"rel:belongs-to,join:parent_id=id" json:"parent"`
|
||||||
Children []Category `bun:"rel:has-many,join:id=parent_id" json:"children"`
|
Children []Category `bun:"rel:has-many,join:id=parent_id" json:"children"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetRelationType(t *testing.T) {
|
func TestGetRelationType(t *testing.T) {
|
||||||
@@ -1299,7 +1299,7 @@ func TestGetPrimaryKeyValue_EdgeCases(t *testing.T) {
|
|||||||
expected: nil,
|
expected: nil,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "model without primary key tags - fallback to ID field",
|
name: "model without primary key tags - fallback to ID field",
|
||||||
model: struct {
|
model: struct {
|
||||||
ID int
|
ID int
|
||||||
Name string
|
Name string
|
||||||
@@ -1307,7 +1307,7 @@ func TestGetPrimaryKeyValue_EdgeCases(t *testing.T) {
|
|||||||
expected: 99,
|
expected: 99,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "model without ID field",
|
name: "model without ID field",
|
||||||
model: struct {
|
model: struct {
|
||||||
Name string
|
Name string
|
||||||
}{Name: "Test"},
|
}{Name: "Test"},
|
||||||
@@ -1508,10 +1508,10 @@ func TestGetSQLModelColumns_EdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
// Test models with table:, rel:, join: tags for ExtractColumnFromBunTag
|
// Test models with table:, rel:, join: tags for ExtractColumnFromBunTag
|
||||||
type BunSpecialTagsModel struct {
|
type BunSpecialTagsModel struct {
|
||||||
Table string `bun:"table:users"`
|
Table string `bun:"table:users"`
|
||||||
Relation []Post `bun:"rel:has-many"`
|
Relation []Post `bun:"rel:has-many"`
|
||||||
Join string `bun:"join:id=user_id"`
|
Join string `bun:"join:id=user_id"`
|
||||||
NormalCol string `bun:"normal_col"`
|
NormalCol string `bun:"normal_col"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractColumnFromBunTag_SpecialTags(t *testing.T) {
|
func TestExtractColumnFromBunTag_SpecialTags(t *testing.T) {
|
||||||
@@ -1592,8 +1592,8 @@ func TestGetRelationType_GORMFallback(t *testing.T) {
|
|||||||
func TestGetRelationType_AdditionalCases(t *testing.T) {
|
func TestGetRelationType_AdditionalCases(t *testing.T) {
|
||||||
// Test model with GORM has-one (pointer without foreignKey or with references)
|
// Test model with GORM has-one (pointer without foreignKey or with references)
|
||||||
type Address struct {
|
type Address struct {
|
||||||
ID int `gorm:"column:id;primaryKey"`
|
ID int `gorm:"column:id;primaryKey"`
|
||||||
UserID int `gorm:"column:user_id"`
|
UserID int `gorm:"column:user_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserWithAddress struct {
|
type UserWithAddress struct {
|
||||||
@@ -1609,7 +1609,7 @@ func TestGetRelationType_AdditionalCases(t *testing.T) {
|
|||||||
|
|
||||||
type Employee struct {
|
type Employee struct {
|
||||||
ID int
|
ID int
|
||||||
Company Company // Single struct (not pointer, not slice) - belongs-to
|
Company Company // Single struct (not pointer, not slice) - belongs-to
|
||||||
Coworkers []Employee // Slice without bun/gorm tags - has-many
|
Coworkers []Employee // Slice without bun/gorm tags - has-many
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1963,3 +1963,31 @@ func TestNonWritableColumns(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNonWritableColumns_EmbeddedScanOnlyBuffer(t *testing.T) {
|
||||||
|
type buffer struct {
|
||||||
|
CQL1 string `json:"cql1,omitempty" gorm:"->" bun:",scanonly"`
|
||||||
|
RowNumber int64 `json:"_rownumber,omitempty" gorm:"-" bun:",scanonly"`
|
||||||
|
}
|
||||||
|
type m struct {
|
||||||
|
ID int `json:"id" bun:"id,pk"`
|
||||||
|
Note string `json:"note" bun:"note,type:citext,"`
|
||||||
|
buffer `json:",omitempty" bun:",scanonly"`
|
||||||
|
}
|
||||||
|
got := NonWritableColumns(&m{})
|
||||||
|
has := map[string]bool{}
|
||||||
|
for _, c := range got {
|
||||||
|
has[c] = true
|
||||||
|
}
|
||||||
|
if !has["cql1"] {
|
||||||
|
t.Errorf("cql1 should be non-writable, got %v", got)
|
||||||
|
}
|
||||||
|
if has["id"] || has["note"] {
|
||||||
|
t.Errorf("writable columns reported as non-writable: %v", got)
|
||||||
|
}
|
||||||
|
vals := map[string]interface{}{"id": 1, "note": "x", "cql1": "y"}
|
||||||
|
RemoveNonWritableColumns(&m{}, vals)
|
||||||
|
if _, ok := vals["cql1"]; ok || len(vals) != 2 {
|
||||||
|
t.Errorf("unexpected values: %v", vals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user