Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
68 changes: 65 additions & 3 deletions grammar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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")
Expand Down
155 changes: 152 additions & 3 deletions grammar_test.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
Expand Down