mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-04-12 19:03:51 +00:00
feat(reflection): add JSON to DB column name mapping functions
* Implement BuildJSONToDBColumnMap for translating JSON keys to DB column names * Enhance GetColumnName to extract column names with priority * Update filterValidFields to utilize new mapping for improved data handling * Fix TestToSnakeCase expected values for consistency
This commit is contained in:
@@ -98,8 +98,8 @@ func (p *NestedCUDProcessor) ProcessNestedCUD(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter regularData to only include fields that exist in the model
|
// Filter regularData to only include fields that exist in the model,
|
||||||
// Use MapToStruct to validate and filter fields
|
// and translate JSON keys to their actual database column names.
|
||||||
regularData = p.filterValidFields(regularData, model)
|
regularData = p.filterValidFields(regularData, model)
|
||||||
|
|
||||||
// Inject parent IDs for foreign key resolution
|
// Inject parent IDs for foreign key resolution
|
||||||
@@ -191,14 +191,15 @@ func (p *NestedCUDProcessor) extractCRUDRequest(data map[string]interface{}) str
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// filterValidFields filters input data to only include fields that exist in the model
|
// filterValidFields filters input data to only include fields that exist in the model,
|
||||||
// Uses reflection.MapToStruct to validate fields and extract only those that match the model
|
// and translates JSON key names to their actual database column names.
|
||||||
|
// For example, a field tagged `json:"_changed_date" bun:"changed_date"` will be
|
||||||
|
// included in the result as "changed_date", not "_changed_date".
|
||||||
func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, model interface{}) map[string]interface{} {
|
func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, model interface{}) map[string]interface{} {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new instance of the model to use with MapToStruct
|
|
||||||
modelType := reflect.TypeOf(model)
|
modelType := reflect.TypeOf(model)
|
||||||
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
for modelType != nil && (modelType.Kind() == reflect.Ptr || modelType.Kind() == reflect.Slice || modelType.Kind() == reflect.Array) {
|
||||||
modelType = modelType.Elem()
|
modelType = modelType.Elem()
|
||||||
@@ -208,25 +209,16 @@ func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, mode
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new instance of the model
|
// Build a mapping from JSON key -> DB column name for all writable fields.
|
||||||
tempModel := reflect.New(modelType).Interface()
|
// This both validates which fields belong to the model and translates their names
|
||||||
|
// to the correct column names for use in SQL insert/update queries.
|
||||||
|
jsonToDBCol := reflection.BuildJSONToDBColumnMap(modelType)
|
||||||
|
|
||||||
// Use MapToStruct to map the data - this will only map valid fields
|
|
||||||
err := reflection.MapToStruct(data, tempModel)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debug("Error mapping data to model: %v", err)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the mapped fields back into a map
|
|
||||||
// This effectively filters out any fields that don't exist in the model
|
|
||||||
filteredData := make(map[string]interface{})
|
filteredData := make(map[string]interface{})
|
||||||
tempModelValue := reflect.ValueOf(tempModel).Elem()
|
|
||||||
|
|
||||||
for key, value := range data {
|
for key, value := range data {
|
||||||
// Check if the field was successfully mapped
|
dbColName, exists := jsonToDBCol[key]
|
||||||
if fieldWasMapped(tempModelValue, modelType, key) {
|
if exists {
|
||||||
filteredData[key] = value
|
filteredData[dbColName] = value
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Skipping invalid field '%s' - not found in model %v", key, modelType)
|
logger.Debug("Skipping invalid field '%s' - not found in model %v", key, modelType)
|
||||||
}
|
}
|
||||||
@@ -235,72 +227,9 @@ func (p *NestedCUDProcessor) filterValidFields(data map[string]interface{}, mode
|
|||||||
return filteredData
|
return filteredData
|
||||||
}
|
}
|
||||||
|
|
||||||
// fieldWasMapped checks if a field with the given key was mapped to the model
|
|
||||||
func fieldWasMapped(modelValue reflect.Value, modelType reflect.Type, key string) bool {
|
|
||||||
// Look for the field by JSON tag or field name
|
|
||||||
for i := 0; i < modelType.NumField(); i++ {
|
|
||||||
field := modelType.Field(i)
|
|
||||||
|
|
||||||
// Skip unexported fields
|
// injectForeignKeys injects parent IDs into data for foreign key fields.
|
||||||
if !field.IsExported() {
|
// data is expected to be keyed by DB column names (as returned by filterValidFields).
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check JSON tag
|
|
||||||
jsonTag := field.Tag.Get("json")
|
|
||||||
if jsonTag != "" && jsonTag != "-" {
|
|
||||||
parts := strings.Split(jsonTag, ",")
|
|
||||||
if len(parts) > 0 && parts[0] == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bun tag
|
|
||||||
bunTag := field.Tag.Get("bun")
|
|
||||||
if bunTag != "" && bunTag != "-" {
|
|
||||||
if colName := reflection.ExtractColumnFromBunTag(bunTag); colName == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check gorm tag
|
|
||||||
gormTag := field.Tag.Get("gorm")
|
|
||||||
if gormTag != "" && gormTag != "-" {
|
|
||||||
if colName := reflection.ExtractColumnFromGormTag(gormTag); colName == key {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check lowercase field name
|
|
||||||
if strings.EqualFold(field.Name, key) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle embedded structs recursively
|
|
||||||
if field.Anonymous {
|
|
||||||
fieldType := field.Type
|
|
||||||
if fieldType.Kind() == reflect.Ptr {
|
|
||||||
fieldType = fieldType.Elem()
|
|
||||||
}
|
|
||||||
if fieldType.Kind() == reflect.Struct {
|
|
||||||
embeddedValue := modelValue.Field(i)
|
|
||||||
if embeddedValue.Kind() == reflect.Ptr {
|
|
||||||
if embeddedValue.IsNil() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
embeddedValue = embeddedValue.Elem()
|
|
||||||
}
|
|
||||||
if fieldWasMapped(embeddedValue, fieldType, key) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// injectForeignKeys injects parent IDs into data for foreign key fields
|
|
||||||
func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, modelType reflect.Type, parentIDs map[string]interface{}) {
|
func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, modelType reflect.Type, parentIDs map[string]interface{}) {
|
||||||
if len(parentIDs) == 0 {
|
if len(parentIDs) == 0 {
|
||||||
return
|
return
|
||||||
@@ -319,10 +248,11 @@ func (p *NestedCUDProcessor) injectForeignKeys(data map[string]interface{}, mode
|
|||||||
if strings.EqualFold(jsonName, parentKey+"_id") ||
|
if strings.EqualFold(jsonName, parentKey+"_id") ||
|
||||||
strings.EqualFold(jsonName, parentKey+"id") ||
|
strings.EqualFold(jsonName, parentKey+"id") ||
|
||||||
strings.EqualFold(field.Name, parentKey+"ID") {
|
strings.EqualFold(field.Name, parentKey+"ID") {
|
||||||
// Only inject if not already present
|
// Use the DB column name as the key, since data is keyed by DB column names
|
||||||
if _, exists := data[jsonName]; !exists {
|
dbColName := reflection.GetColumnName(field)
|
||||||
logger.Debug("Injecting foreign key: %s = %v", jsonName, parentID)
|
if _, exists := data[dbColName]; !exists {
|
||||||
data[jsonName] = parentID
|
logger.Debug("Injecting foreign key: %s = %v", dbColName, parentID)
|
||||||
|
data[dbColName] = parentID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,92 @@ func collectColumnsFromType(typ reflect.Type, columns *[]string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetColumnName extracts the database column name from a struct field.
|
||||||
|
// Priority: bun tag -> gorm tag -> json tag -> lowercase field name.
|
||||||
|
// This is the exported version for use by other packages.
|
||||||
|
func GetColumnName(field reflect.StructField) string {
|
||||||
|
return getColumnNameFromField(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildJSONToDBColumnMap returns a map from JSON key names to database column names
|
||||||
|
// for the given model type. Only writable, non-relation fields are included.
|
||||||
|
// This is used to translate incoming request data (keyed by JSON names) into
|
||||||
|
// properly named database columns before insert/update operations.
|
||||||
|
func BuildJSONToDBColumnMap(modelType reflect.Type) map[string]string {
|
||||||
|
result := make(map[string]string)
|
||||||
|
buildJSONToDBMap(modelType, result, false)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJSONToDBMap(modelType reflect.Type, result map[string]string, scanOnly bool) {
|
||||||
|
for i := 0; i < modelType.NumField(); i++ {
|
||||||
|
field := modelType.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
bunTag := field.Tag.Get("bun")
|
||||||
|
gormTag := field.Tag.Get("gorm")
|
||||||
|
|
||||||
|
// Handle embedded structs
|
||||||
|
if field.Anonymous {
|
||||||
|
ft := field.Type
|
||||||
|
if ft.Kind() == reflect.Ptr {
|
||||||
|
ft = ft.Elem()
|
||||||
|
}
|
||||||
|
isScanOnly := scanOnly
|
||||||
|
if bunTag != "" && isBunFieldScanOnly(bunTag) {
|
||||||
|
isScanOnly = true
|
||||||
|
}
|
||||||
|
if ft.Kind() == reflect.Struct {
|
||||||
|
buildJSONToDBMap(ft, result, isScanOnly)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if scanOnly {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip explicitly excluded fields
|
||||||
|
if bunTag == "-" || gormTag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip scan-only fields
|
||||||
|
if bunTag != "" && isBunFieldScanOnly(bunTag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip bun relation fields
|
||||||
|
if bunTag != "" && (strings.Contains(bunTag, "rel:") || strings.Contains(bunTag, "join:") || strings.Contains(bunTag, "m2m:")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip gorm relation fields
|
||||||
|
if gormTag != "" && (strings.Contains(gormTag, "foreignKey:") || strings.Contains(gormTag, "references:") || strings.Contains(gormTag, "many2many:")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get JSON key (how the field appears in incoming request data)
|
||||||
|
jsonKey := ""
|
||||||
|
if jsonTag := field.Tag.Get("json"); jsonTag != "" && jsonTag != "-" {
|
||||||
|
parts := strings.Split(jsonTag, ",")
|
||||||
|
if len(parts) > 0 && parts[0] != "" {
|
||||||
|
jsonKey = parts[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if jsonKey == "" {
|
||||||
|
jsonKey = strings.ToLower(field.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual DB column name (bun > gorm > json > field name)
|
||||||
|
dbColName := getColumnNameFromField(field)
|
||||||
|
|
||||||
|
result[jsonKey] = dbColName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getColumnNameFromField extracts the column name from a struct field
|
// getColumnNameFromField extracts the column name from a struct field
|
||||||
// Priority: bun tag -> gorm tag -> json tag -> lowercase field name
|
// Priority: bun tag -> gorm tag -> json tag -> lowercase field name
|
||||||
func getColumnNameFromField(field reflect.StructField) string {
|
func getColumnNameFromField(field reflect.StructField) string {
|
||||||
|
|||||||
@@ -823,12 +823,12 @@ func TestToSnakeCase(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "UserID",
|
name: "UserID",
|
||||||
input: "UserID",
|
input: "UserID",
|
||||||
expected: "user_i_d",
|
expected: "user_id",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "HTTPServer",
|
name: "HTTPServer",
|
||||||
input: "HTTPServer",
|
input: "HTTPServer",
|
||||||
expected: "h_t_t_p_server",
|
expected: "http_server",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "lowercase",
|
name: "lowercase",
|
||||||
@@ -838,7 +838,7 @@ func TestToSnakeCase(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "UPPERCASE",
|
name: "UPPERCASE",
|
||||||
input: "UPPERCASE",
|
input: "UPPERCASE",
|
||||||
expected: "u_p_p_e_r_c_a_s_e",
|
expected: "uppercase",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Single",
|
name: "Single",
|
||||||
|
|||||||
Reference in New Issue
Block a user