From 80b4ddd383eeebe3ff91d9d2d5b9274d0f5b23b5 Mon Sep 17 00:00:00 2001 From: k4n4ry Date: Mon, 12 Jan 2026 18:24:20 +0900 Subject: [PATCH] feat(qrm): support qrm:"optional" in strict field mapping --- qrm/qrm.go | 7 ++- qrm/scan_context.go | 44 +++++++++++++++--- tests/sqlite/select_test.go | 91 ++++++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 8 deletions(-) diff --git a/qrm/qrm.go b/qrm/qrm.go index c2d1b10..3fb1d3b 100644 --- a/qrm/qrm.go +++ b/qrm/qrm.go @@ -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 diff --git a/qrm/scan_context.go b/qrm/scan_context.go index ea529d0..9841270 100644 --- a/qrm/scan_context.go +++ b/qrm/scan_context.go @@ -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) } diff --git a/tests/sqlite/select_test.go b/tests/sqlite/select_test.go index d1261da..396bab0 100644 --- a/tests/sqlite/select_test.go +++ b/tests/sqlite/select_test.go @@ -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{