diff --git a/go.mod b/go.mod index b9f91c0..dd589d0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.4 require ( github.com/Masterminds/squirrel v1.5.4 - github.com/goravel/framework v1.15.2-0.20250609104359-90480ea9b358 + github.com/goravel/framework v1.15.2-0.20250613041935-be5ad877d584 github.com/spf13/cast v1.9.2 github.com/stretchr/testify v1.10.0 gorm.io/driver/postgres v1.6.0 diff --git a/go.sum b/go.sum index 45bf4e2..782aa82 100644 --- a/go.sum +++ b/go.sum @@ -134,8 +134,8 @@ github.com/gookit/validate v1.5.5 h1:jzMrwAYy9tbQa0mH8YFnv3C3JQR/0N4pRTsYiySwRJM github.com/gookit/validate v1.5.5/go.mod h1:p9sRPfpvYB4vXICBpEPzv8FoAky+XhUOhWQghgmmat4= github.com/goravel/file-rotatelogs/v2 v2.4.2 h1:g68AzbePXcm0V2CpUMc9j4qVzcDn7+7aoWSjZ51C0m4= github.com/goravel/file-rotatelogs/v2 v2.4.2/go.mod h1:23VuSW8cBS4ax5cmbV+5AaiLpq25b8UJ96IhbAkdo8I= -github.com/goravel/framework v1.15.2-0.20250609104359-90480ea9b358 h1:itDiQBc39teENLiTK7hQsKp7YGKjDvJ2KHlxMfyDeOg= -github.com/goravel/framework v1.15.2-0.20250609104359-90480ea9b358/go.mod h1:rMwbA0b1HX/2NUaPGxejp9AG4kfBfqy7D5gdRGy2clo= +github.com/goravel/framework v1.15.2-0.20250613041935-be5ad877d584 h1:3jEsEW8Ty13S3Vqt7Phj4e2GyfgrTbNyVPa27u57BEI= +github.com/goravel/framework v1.15.2-0.20250613041935-be5ad877d584/go.mod h1:rMwbA0b1HX/2NUaPGxejp9AG4kfBfqy7D5gdRGy2clo= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= diff --git a/grammar.go b/grammar.go index 2113077..5d4a0d8 100644 --- a/grammar.go +++ b/grammar.go @@ -2,17 +2,18 @@ package postgres import ( "fmt" + "regexp" "slices" + "strconv" "strings" sq "github.com/Masterminds/squirrel" - "github.com/spf13/cast" - "gorm.io/gorm/clause" - "github.com/goravel/framework/contracts/database/driver" "github.com/goravel/framework/database/schema" "github.com/goravel/framework/errors" "github.com/goravel/framework/support/collect" + "github.com/spf13/cast" + "gorm.io/gorm/clause" ) var _ driver.Grammar = &Grammar{} @@ -297,6 +298,67 @@ func (r *Grammar) CompileIndexes(schema, table string) (string, error) { ), nil } +func (r *Grammar) CompileJsonContains(column string, value any, isNot bool) (string, []any, error) { + column = strings.Replace(r.CompileJsonSelector(column), "->>", "->", -1) + binding, err := App.GetJson().Marshal(value) + if err != nil { + return column, nil, err + } + + return r.wrap.Not(fmt.Sprintf("(%s)::jsonb @> ?", column), isNot), []any{string(binding)}, nil +} + +func (r *Grammar) CompileJsonContainsKey(column string, isNot bool) string { + segments := strings.Split(column, "->") + lastSegment := segments[len(segments)-1] + segments = segments[:len(segments)-1] + + var jsonArrayIndex string + if _, err := strconv.Atoi(lastSegment); err == nil { + jsonArrayIndex = lastSegment + } else if matches := regexp.MustCompile(`\[(-?[0-9]+)]$`).FindStringSubmatch(lastSegment); len(matches) == 2 { + segments = append(segments, strings.TrimSuffix(lastSegment, matches[0])) + jsonArrayIndex = matches[1] + } + + column = strings.Replace(r.CompileJsonSelector(strings.Join(segments, "->")), "->>", "->", -1) + if len(jsonArrayIndex) > 0 { + index := cast.ToInt(jsonArrayIndex) + if index < 0 { + index = -index + } else { + index = index + 1 + } + return r.wrap.Not(fmt.Sprintf("case when %s then %s else false end", + fmt.Sprintf("jsonb_typeof((%s)::jsonb) = 'array'", column), + fmt.Sprintf("jsonb_array_length((%s)::jsonb) >= %d", column, index), + ), isNot) + } + + return r.wrap.Not(fmt.Sprintf("coalesce((%s)::jsonb ? %s, false)", column, r.wrap.Quote(strings.ReplaceAll(lastSegment, "'", "''"))), isNot) +} + +func (r *Grammar) CompileJsonLength(column string) string { + column = strings.Replace(r.CompileJsonSelector(column), "->>", "->", -1) + + return fmt.Sprintf("jsonb_array_length((%s)::jsonb)", column) +} + +func (r *Grammar) CompileJsonSelector(column string) string { + path := strings.Split(column, "->") + field := r.wrap.Column(path[0]) + if len(path) == 1 { + return field + } + + wrappedPath := r.wrap.JsonPathAttributes(path[1:]) + if len(wrappedPath) > 1 { + return field + "->" + strings.Join(wrappedPath[:len(wrappedPath)-1], "->") + "->>" + wrappedPath[len(wrappedPath)-1] + } + + return field + "->>" + wrappedPath[0] +} + func (r *Grammar) CompileLockForUpdate(builder sq.SelectBuilder, conditions *driver.Conditions) sq.SelectBuilder { if conditions.LockForUpdate != nil && *conditions.LockForUpdate { builder = builder.Suffix("FOR UPDATE") diff --git a/grammar_test.go b/grammar_test.go index de3ab4b..0cdfe2b 100644 --- a/grammar_test.go +++ b/grammar_test.go @@ -1,16 +1,18 @@ package postgres import ( + "encoding/json" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - contractsdriver "github.com/goravel/framework/contracts/database/driver" "github.com/goravel/framework/database/schema" "github.com/goravel/framework/errors" mocksdriver "github.com/goravel/framework/mocks/database/driver" + mocksfoundation "github.com/goravel/framework/mocks/foundation" "github.com/goravel/framework/support/convert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" ) type GrammarSuite struct { @@ -337,6 +339,153 @@ func (s *GrammarSuite) TestCompileIndex() { } } +func (s *GrammarSuite) TestCompileJsonContains() { + tests := []struct { + name string + column string + value any + isNot bool + expectedSql string + expectedValue []any + hasError bool + }{ + { + name: "invalid value type", + column: "data->details", + value: func() {}, + hasError: true, + }, + { + name: "single path with single value", + column: "data->details", + value: "value1", + expectedSql: `("data"->'details')::jsonb @> ?`, + expectedValue: []any{`"value1"`}, + }, + { + name: "single path with multiple values", + column: "data->details", + value: []string{"value1", "value2"}, + expectedSql: `("data"->'details')::jsonb @> ?`, + expectedValue: []any{`["value1","value2"]`}, + }, + { + name: "nested path with single value", + column: "data->details->subdetails[0]", + value: "value1", + expectedSql: `("data"->'details'->'subdetails'->0)::jsonb @> ?`, + expectedValue: []any{`"value1"`}, + }, + { + name: "nested path with multiple values", + column: "data->details[0]->subdetails", + value: []string{"value1", "value2"}, + expectedSql: `("data"->'details'->0->'subdetails')::jsonb @> ?`, + expectedValue: []any{`["value1","value2"]`}, + }, + { + name: "with is not condition", + column: "data->details", + value: "value1", + isNot: true, + expectedSql: `not ("data"->'details')::jsonb @> ?`, + expectedValue: []any{`"value1"`}, + }, + } + + mockApp := mocksfoundation.NewApplication(s.T()) + mockJson := mocksfoundation.NewJson(s.T()) + originApp := App + App = mockApp + s.T().Cleanup(func() { + App = originApp + }) + + for _, tt := range tests { + s.Run(tt.name, func() { + mockJson.EXPECT().Marshal(mock.Anything).RunAndReturn(func(i interface{}) ([]byte, error) { + return json.Marshal(tt.value) + }).Once() + mockApp.EXPECT().GetJson().Return(mockJson).Once() + actualSql, actualValue, err := s.grammar.CompileJsonContains(tt.column, tt.value, tt.isNot) + if tt.hasError { + s.Error(err) + } else { + s.Equal(tt.expectedSql, actualSql) + s.Equal(tt.expectedValue, actualValue) + s.NoError(err) + } + }) + } +} + +func (s *GrammarSuite) TestCompileJsonContainKey() { + tests := []struct { + name string + column string + isNot bool + expectedSql string + }{ + { + name: "single path", + column: "data->details", + expectedSql: `coalesce(("data")::jsonb ? 'details', false)`, + }, + { + name: "single path with is not", + column: "data->details", + isNot: true, + expectedSql: `not coalesce(("data")::jsonb ? 'details', false)`, + }, + { + name: "nested path", + column: "data->details->subdetails", + expectedSql: `coalesce(("data"->'details')::jsonb ? 'subdetails', false)`, + }, + { + name: "nested path with array index", + column: "data->details[0]->subdetails", + expectedSql: `coalesce(("data"->'details'->0)::jsonb ? 'subdetails', false)`, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.expectedSql, s.grammar.CompileJsonContainsKey(tt.column, tt.isNot)) + }) + } +} + +func (s *GrammarSuite) TestCompileJsonLength() { + tests := []struct { + name string + column string + expectedSql string + }{ + { + name: "single path", + column: "data->details", + expectedSql: `jsonb_array_length(("data"->'details')::jsonb)`, + }, + { + name: "nested path", + column: "data->details->subdetails", + expectedSql: `jsonb_array_length(("data"->'details'->'subdetails')::jsonb)`, + }, + { + name: "nested path with array index", + column: "data->details[0]->subdetails", + expectedSql: `jsonb_array_length(("data"->'details'->0->'subdetails')::jsonb)`, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + s.Equal(tt.expectedSql, s.grammar.CompileJsonLength(tt.column)) + }) + } +} + func (s *GrammarSuite) TestCompilePrimary() { mockBlueprint := mocksdriver.NewBlueprint(s.T()) mockBlueprint.EXPECT().GetTableName().Return("users").Once()