feat(qrm): support qrm:"optional" in strict field mapping

This commit is contained in:
k4n4ry 2026-01-12 18:24:20 +09:00
parent f33c2ee357
commit 80b4ddd383
3 changed files with 134 additions and 8 deletions

View file

@ -22,7 +22,12 @@ type Config struct {
// StrictFieldMapping, when true, causes the scanning function to panic if it encounters any
// destination struct fields that do not have matching columns in the SQL query result.
// This check applies only to fields that are mapped from a single column (simple/scanner/json_column).
// Complex fields (struct/slice) are excluded because they are populated recursively and can be optional.
// Complex fields (struct/slice) are excluded because they are not mapped from a single column.
//
// Optional fields:
// If a destination field (including struct/slice fields) is not always selected by a query,
// it can be marked as optional using `qrm:"optional"`. When StrictFieldMapping is enabled,
// unmapped fields under an optional field will not trigger a panic.
// Does not apply to statements build with SELECT_JSON_OBJ or SELECT_JSON_ARR
StrictFieldMapping bool

View file

@ -83,19 +83,19 @@ func (s *ScanContext) EnsureEveryColumnRead() {
func (s *ScanContext) recordUnmappedField(structType reflect.Type, parentField *reflect.StructField, field reflect.StructField) {
// skip private/unsettable fields (those are ignored by mapRowToStruct anyway)
if field.PkgPath != "" {
if !field.IsExported() {
return
}
// NOTE: For unnamed/anonymous structs, Name() is empty, so String() is used for readability/uniqueness.
typeName := structType.String()
if structType.Name() != "" {
typeName = structType.Name()
typeName := structType.Name()
if typeName == "" {
typeName = structType.String()
}
fieldIdent := fmt.Sprintf("%s.%s", typeName, field.Name)
if parentField != nil {
fieldIdent = fmt.Sprintf("%s.%s.%s", typeName, parentField.Name, field.Name)
fieldIdent = fmt.Sprintf("%s %s.%s", parentField.Name, typeName, field.Name)
}
s.unmappedFields = append(s.unmappedFields, fmt.Sprintf("'%s'", fieldIdent))
@ -108,6 +108,38 @@ func (s *ScanContext) EnsureEveryFieldMapped() {
panic("jet: fields never mapped: " + strings.Join(s.unmappedFields, ", "))
}
func isOptionalQrmField(field *reflect.StructField) bool {
if field == nil {
return false
}
tag := field.Tag.Get("qrm")
if tag == "" {
return false
}
for _, part := range strings.Split(tag, ",") {
if strings.TrimSpace(part) == "optional" {
return true
}
}
return false
}
func shouldRecordUnmappedField(parentField *reflect.StructField, field reflect.StructField, fieldMap fieldMapping) bool {
if !GlobalConfig.StrictFieldMapping {
return false
}
if fieldMap.Type == complexType {
return false
}
if fieldMap.rowIndex != -1 {
return false
}
if isOptionalQrmField(parentField) || isOptionalQrmField(&field) {
return false
}
return true
}
func createScanSlice(columnCount int) []interface{} {
scanPtrSlice := make([]interface{}, columnCount)
@ -173,7 +205,7 @@ func (s *ScanContext) getTypeInfo(structType reflect.Type, parentField *reflect.
fieldMap.Type = simpleType
}
if GlobalConfig.StrictFieldMapping && fieldMap.rowIndex == -1 && fieldMap.Type != complexType {
if shouldRecordUnmappedField(parentField, field, fieldMap) {
s.recordUnmappedField(structType, parentField, field)
}

View file

@ -160,7 +160,7 @@ LIMIT ?;
}
requireStrictFieldMapping(func() {
require.PanicsWithValue(t, "jet: fields never mapped: 'Inner.Child.Missing'", func() {
require.PanicsWithValue(t, "jet: fields never mapped: 'Child Inner.Missing'", func() {
var dest []Outer
_ = queryAll.Query(db, &dest)
})
@ -187,6 +187,95 @@ LIMIT ?;
_ = rows.Close()
})
})
t.Run("missing joined table columns panics for nested struct field", func(t *testing.T) {
filmOnly := SELECT(Film.AllColumns).FROM(Film).LIMIT(1)
type Dest struct {
model.Film
Actor model.Actor
}
allowUnmappedFields(func() {
var dest []Dest
require.NoError(t, filmOnly.Query(db, &dest))
require.Len(t, dest, 1)
})
requireStrictFieldMapping(func() {
require.PanicsWithValue(t, "jet: fields never mapped: 'Actor Actor.ActorID', 'Actor Actor.FirstName', 'Actor Actor.LastName', 'Actor Actor.LastUpdate'", func() {
var dest []Dest
_ = filmOnly.Query(db, &dest)
})
})
})
t.Run("missing joined table columns do not panic when nested struct field is optional", func(t *testing.T) {
filmOnly := SELECT(Film.AllColumns).FROM(Film).LIMIT(1)
type Dest struct {
model.Film
Actor model.Actor `qrm:"optional"`
}
requireStrictFieldMapping(func() {
var dest []Dest
require.NoError(t, filmOnly.Query(db, &dest))
require.Len(t, dest, 1)
})
})
t.Run("missing joined table columns panics for nested slice field", func(t *testing.T) {
filmOnly := SELECT(Film.AllColumns).FROM(Film).LIMIT(1)
type Dest struct {
model.Film
Actor []model.Actor
}
allowUnmappedFields(func() {
var dest []Dest
require.NoError(t, filmOnly.Query(db, &dest))
require.Len(t, dest, 1)
})
requireStrictFieldMapping(func() {
require.PanicsWithValue(t, "jet: fields never mapped: 'Actor Actor.ActorID', 'Actor Actor.FirstName', 'Actor Actor.LastName', 'Actor Actor.LastUpdate'", func() {
var dest []Dest
_ = filmOnly.Query(db, &dest)
})
})
})
t.Run("missing joined table columns do not panic when nested slice field is optional", func(t *testing.T) {
filmOnly := SELECT(Film.AllColumns).FROM(Film).LIMIT(1)
type Dest struct {
model.Film
Actor []model.Actor `qrm:"optional"`
}
requireStrictFieldMapping(func() {
var dest []Dest
require.NoError(t, filmOnly.Query(db, &dest))
require.Len(t, dest, 1)
})
})
t.Run("optional tag skips strict field mapping for missing simple field", func(t *testing.T) {
query := SELECT(Actor.ActorID).FROM(Actor).WHERE(Actor.ActorID.EQ(Int(2))).LIMIT(1)
type DestOptional struct {
ActorID int32 `alias:"actor.actor_id"`
OptionalMissing string `alias:"actor.missing_column" qrm:"optional"`
}
requireStrictFieldMapping(func() {
var dest []DestOptional
require.NoError(t, query.Query(db, &dest))
require.Len(t, dest, 1)
})
})
}
var actor2 = model.Actor{