Skip to content

Commit

Permalink
Added support for gorm.datatypes.JSON field
Browse files Browse the repository at this point in the history
  • Loading branch information
glothriel committed Feb 20, 2025
1 parent 6410fc6 commit 99b8a76
Show file tree
Hide file tree
Showing 8 changed files with 124 additions and 64 deletions.
4 changes: 4 additions & 0 deletions docs/docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ Those types are not supported yet, but will be in the future:

`models.SliceField` can be used to store slices, that are encoded to JSON string for storage (implement sql.Scanner and driver.Valuer interfaces). In request and response JSON payloads, the slice is represented as a JSON array. The types of the slice need to be golang built-in basic types. The field provides validation of all the elements in the slice.

### JSON fields

`datatypes.JSON` from `gorm.datatypes` package can be used to store JSON data in a database, in JSON column type native to the database. The field is represented as a JSON object in the request and response JSON payloads.

## Model relations

GRF models by themselves do not directly support relations, but:
Expand Down
7 changes: 5 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,26 @@ require (
github.com/shopspring/decimal v1.3.1
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0
gorm.io/datatypes v1.2.5
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.2
gorm.io/gorm v1.25.12
)

require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
Expand Down Expand Up @@ -60,4 +62,5 @@ require (
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)
78 changes: 20 additions & 58 deletions go.sum

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions pkg/detectors/attributes.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (

"github.com/glothriel/grf/pkg/fields"
"github.com/glothriel/grf/pkg/models"
"gorm.io/datatypes"
)

// Prints a summary with the fields of the model obtained using reflection
Expand Down Expand Up @@ -56,7 +57,8 @@ type fieldSettings struct {

isGRFRepresentable bool
isGRFParsable bool
isForeignKey bool
isRelation bool
isDataTypesJSON bool

isSqlNullInt32 bool
}
Expand Down Expand Up @@ -84,6 +86,7 @@ func getFieldSettings[Model any](fieldName string) *fieldSettings {
_, isGRFRepresentable := theTypeAsAny.(fields.GRFRepresentable)
_, isGRFParsable := theTypeAsAny.(fields.GRFParsable)
_, isSQLNull32 := theTypeAsAny.(*sql.NullInt32)
_, isDataTypesJSON := theTypeAsAny.(*datatypes.JSON)

settings = &fieldSettings{
itsType: reflect.TypeOf(
Expand All @@ -93,7 +96,8 @@ func getFieldSettings[Model any](fieldName string) *fieldSettings {
isEncodingTextUnmarshaler: isEncodingTextUnmarshaler,
isGRFRepresentable: isGRFRepresentable,
isGRFParsable: isGRFParsable,
isForeignKey: fieldMarkedAsRelation,
isRelation: fieldMarkedAsRelation,
isDataTypesJSON: isDataTypesJSON,
isSqlNullInt32: isSQLNull32,
}
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/detectors/detectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type relationshipDetector[Model any] struct {

func (p *relationshipDetector[Model]) ToInternalValue(fieldName string) (fields.InternalValueFunc, error) {
fieldSettings := getFieldSettings[Model](fieldName)
if fieldSettings.isForeignKey {
if fieldSettings.isRelation {
return nil, ErrFieldShouldBeSkipped
}
return p.internalChild.ToInternalValue(fieldName)
Expand All @@ -28,7 +28,7 @@ func (p *relationshipDetector[Model]) ToRepresentation(fieldName string) (fields
return nil, fmt.Errorf("Field `%s` is not present in the model", fieldName)

}
if fieldSettings.isForeignKey {
if fieldSettings.isRelation {
return nil, ErrFieldShouldBeSkipped
}
return p.representationChild.ToRepresentation(fieldName)
Expand Down
26 changes: 26 additions & 0 deletions pkg/detectors/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ package detectors
import (
"database/sql"
"encoding"
"encoding/json"
"fmt"
"reflect"
"time"

"github.com/gin-gonic/gin"
"github.com/glothriel/grf/pkg/fields"
"github.com/glothriel/grf/pkg/types"
"gorm.io/datatypes"
)

type ToInternalValueDetector interface {
Expand Down Expand Up @@ -108,6 +110,29 @@ func (p *encodingTextUnmarshalerToInternalValueDetector[Model]) ToInternalValue(
return nil, fmt.Errorf("Field `%s` is not a encoding.TextUnmarshaler", fieldName)
}

type gormDataTypesJSONToInternalValueDetector[Model any] struct{}

func (p *gormDataTypesJSONToInternalValueDetector[Model]) ToInternalValue(fieldName string) (fields.InternalValueFunc, error) {
fieldSettings := getFieldSettings[Model](fieldName)
if fieldSettings.isDataTypesJSON {
return ConvertFuncToInternalValueFuncAdapter(
func(v any) (any, error) {
vAsBytes, marshalErr := json.Marshal(v)
if marshalErr != nil {
return nil, fmt.Errorf("Failed to marshal field `%s` to JSON: %w", fieldName, marshalErr)
}

Check warning on line 123 in pkg/detectors/internal.go

View check run for this annotation

Codecov / codecov/patch

pkg/detectors/internal.go#L122-L123

Added lines #L122 - L123 were not covered by tests
var dtj datatypes.JSON
unmarshalErr := dtj.UnmarshalJSON(vAsBytes)
if unmarshalErr != nil {
return nil, fmt.Errorf("Failed to unmarshal field `%s` from JSON: %w", fieldName, unmarshalErr)
}

Check warning on line 128 in pkg/detectors/internal.go

View check run for this annotation

Codecov / codecov/patch

pkg/detectors/internal.go#L127-L128

Added lines #L127 - L128 were not covered by tests
return dtj, nil
},
), nil
}
return nil, fmt.Errorf("Field `%s` is not a encoding.TextUnmarshaler", fieldName)
}

type usingSqlNullFieldToInternalValueDetector[Model any, sqlNullType any] struct {
valueFunc func(v any) (any, error)
}
Expand Down Expand Up @@ -168,6 +193,7 @@ func DefaultToInternalValueDetector[Model any]() ToInternalValueDetector {
children: []ToInternalValueDetector{
&usingGRFParsableToInternalValueDetector[Model]{},
&isoTimeTimeToInternalValueDetector[Model]{},
&gormDataTypesJSONToInternalValueDetector[Model]{},
&fromTypeMapperToInternalValueDetector[Model]{
mapper: types.Mapper(),
modelTypeNames: FieldTypes[Model](),
Expand Down
30 changes: 30 additions & 0 deletions pkg/detectors/representation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package detectors
import (
"database/sql"
"encoding"
"encoding/json"
"fmt"
"reflect"
"time"
Expand All @@ -11,6 +12,7 @@ import (
"github.com/glothriel/grf/pkg/fields"
"github.com/glothriel/grf/pkg/models"
"github.com/glothriel/grf/pkg/types"
"gorm.io/datatypes"
)

// ToRepresentationDetector is an interface that allows to detect the representation function for a given field
Expand All @@ -26,6 +28,7 @@ func DefaultToRepresentationDetector[Model any]() ToRepresentationDetector[Model
children: []ToRepresentationDetector[Model]{
&usingGRFRepresentableToRepresentationProvider[Model]{},
&timeTimeToRepresentationProvider[Model]{},
&gormDataTypesJSONToRepresentationProvider[Model]{},
&fromTypeMapperToRepresentationProvider[Model]{
mapper: types.Mapper(),
modelTypeNames: FieldTypes[Model](),
Expand Down Expand Up @@ -213,6 +216,33 @@ func (p encodingTextMarshalerToRepresentationProvider[Model]) ToRepresentation(f
return nil, fmt.Errorf("Field `%s` is not a encoding.TextMarshaler", fieldName)
}

type gormDataTypesJSONToRepresentationProvider[Model any] struct{}

func (p gormDataTypesJSONToRepresentationProvider[Model]) ToRepresentation(fieldName string) (fields.RepresentationFunc, error) {
fieldSettings := getFieldSettings[Model](fieldName)
if fieldSettings != nil && fieldSettings.isDataTypesJSON {
return ConvertFuncToRepresentationFuncAdapter(
func(v any) (any, error) {
vAsJSON, ok := v.(datatypes.JSON)
if !ok {
return nil, fmt.Errorf("Field `%s` is not a datatypes.JSON", fieldName)
}

Check warning on line 229 in pkg/detectors/representation.go

View check run for this annotation

Codecov / codecov/patch

pkg/detectors/representation.go#L228-L229

Added lines #L228 - L229 were not covered by tests
rawJSON, marshalJSONErr := vAsJSON.MarshalJSON()
if marshalJSONErr != nil {
return nil, fmt.Errorf("Failed to marshal field `%s` to JSON: %w", fieldName, marshalJSONErr)
}

Check warning on line 233 in pkg/detectors/representation.go

View check run for this annotation

Codecov / codecov/patch

pkg/detectors/representation.go#L232-L233

Added lines #L232 - L233 were not covered by tests
var ret any
unmarshalErr := json.Unmarshal(rawJSON, &ret)
if unmarshalErr != nil {
return nil, fmt.Errorf("Failed to unmarshal field `%s` from JSON: %w", fieldName, unmarshalErr)
}

Check warning on line 238 in pkg/detectors/representation.go

View check run for this annotation

Codecov / codecov/patch

pkg/detectors/representation.go#L237-L238

Added lines #L237 - L238 were not covered by tests
return ret, nil
},
), nil
}
return nil, fmt.Errorf("Field `%s` is not a encoding.TextMarshaler", fieldName)
}

type chainingToRepresentationDetector[Model any] struct {
children []ToRepresentationDetector[Model]
}
Expand Down
31 changes: 31 additions & 0 deletions pkg/integration/fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/shopspring/decimal"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"gorm.io/datatypes"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
Expand Down Expand Up @@ -93,6 +94,11 @@ type NullFloat64Model struct {
Value sql.NullFloat64 `json:"value" gorm:"column:value"`
}

type NestedJSONModel struct {
models.BaseModel
Value datatypes.JSON `json:"value" gorm:"column:value"`
}

func DoTestTypes(t *testing.T, dialector gorm.Dialector) { // nolint: funlen
tests := []struct {
name string
Expand Down Expand Up @@ -438,6 +444,31 @@ func DoTestTypes(t *testing.T, dialector gorm.Dialector) { // nolint: funlen
return registerModel[NullFloat64Model]("/null_float64_field", dialector)
},
},
{
name: "Nested JSON field",
baseURL: "/nested_json_field",
okBodies: []map[string]any{
{"value": map[string]string{"foo": "bar"}},
{"value": []int{1, 2, 3}},
{"value": []bool{true, false}},
{"value": true},
{"value": "hello world"},
{"value": "1,337"},
{"value": "1.3.37"},
},
okResponses: []map[string]any{
{"value": map[string]any{"foo": "bar"}},
{"value": []any{1.0, 2.0, 3.0}},
{"value": []any{true, false}},
{"value": true},
{"value": "hello world"},
{"value": "1,337"},
{"value": "1.3.37"},
},
router: func() *gin.Engine {
return registerModel[NestedJSONModel]("/nested_json_field", dialector)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down

0 comments on commit 99b8a76

Please sign in to comment.