├── .github └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── deep-filtering.go ├── deep-filtering_test.go ├── examples_test.go ├── go.mod ├── go.sum ├── plugin.go └── plugin_test.go /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Go package 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go-version: [ '1.23.0' ] 11 | steps: 12 | - uses: actions/checkout@v3 13 | 14 | - name: Set up Go ${{ matrix.go-version }} 15 | uses: actions/setup-go@v3 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | cache: true 19 | 20 | - name: Test with Go ${{ matrix.go-version }} 21 | run: go test -json > TestResults-${{ matrix.go-version }}.json 22 | 23 | - name: Upload Go test results for ${{ matrix.go-version }} 24 | uses: actions/upload-artifact@v4 25 | with: 26 | name: Go-results-${{ matrix.go-version }} 27 | path: TestResults-${{ matrix.go-version }}.json 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 survivorbat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAKEFLAGS := --no-print-directory --silent 2 | 3 | default: help 4 | 5 | help: 6 | @echo "Please use 'make ' where is one of" 7 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z\._-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 8 | 9 | t: test 10 | test: fmt ## Run unit tests, alias: t 11 | go test ./... -timeout=60s -parallel=10 --cover 12 | 13 | fmt: ## Format go code 14 | @go mod tidy 15 | @go fmt ./... 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🌌 Gorm Deep Filtering Plugin 2 | 3 | [![Go package](https://github.com/survivorbat/gorm-deep-filtering/actions/workflows/test.yaml/badge.svg)](https://github.com/survivorbat/gorm-deep-filtering/actions/workflows/test.yaml) 4 | ![GitHub](https://img.shields.io/github/license/survivorbat/gorm-deep-filtering) 5 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/survivorbat/gorm-deep-filtering) 6 | 7 | Ever wanted to filter objects on a deep level using only maps? This plugin allows you to do just that. 8 | 9 | ```go 10 | package main 11 | 12 | func main () { 13 | filters := map[string]any{ 14 | "name": "abc", 15 | "related_object": map[string]any{ 16 | "title": "engineer", 17 | }, 18 | } 19 | } 20 | ``` 21 | 22 | Is automatically turned into a query that looks like this: 23 | 24 | ```sql 25 | SELECT * FROM employees WHERE related_object_id IN (SELECT id FROM occupations WHERE title = "engineer") 26 | ``` 27 | 28 | ## 💡 Related Libraries 29 | 30 | - [gormlike](https://github.com/survivorbat/gorm-like) turns WHERE-calls into LIkE queries if certain tokens were found 31 | - [gormqonvert](https://github.com/survivorbat/gorm-query-convert) turns WHERE-calls into different queries if certain tokens were found 32 | - [gormcase](https://github.com/survivorbat/gorm-case) adds case insensitivity to WHERE queries 33 | - [gormtestutil](https://github.com/ing-bank/gormtestutil) provides easy utility methods for unit-testing with gorm 34 | 35 | ## ⬇️ Installation 36 | 37 | `go get github.com/survivorbat/gorm-deep-filtering` 38 | 39 | ## 📋 Usage 40 | 41 | ```go 42 | package main 43 | 44 | import ( 45 | "github.com/survivorbat/gorm-deep-filtering" 46 | ) 47 | 48 | func main() { 49 | db, _ := gorm.Open(sqlite.Open("test.db"), &gorm.Config{}) 50 | 51 | // Adds deep filtering 52 | if err := db.Use(deepgorm.New()); err != nil { 53 | panic(err.Error()) 54 | } 55 | } 56 | 57 | ``` 58 | 59 | ## 🔭 Plans 60 | 61 | Better error handling, logging. 62 | -------------------------------------------------------------------------------- /deep-filtering.go: -------------------------------------------------------------------------------- 1 | package deepgorm 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | "sync" 8 | 9 | "github.com/survivorbat/go-tsyncmap" 10 | "gorm.io/gorm/schema" 11 | 12 | "gorm.io/gorm" 13 | ) 14 | 15 | var ( 16 | // Cache mechanism for reflecting database structs, reflection is slow, so we 17 | // cache results for quick lookups. Just remember to reset it in unit tests ;-) 18 | 19 | // cacheDatabaseMap map[string]map[string]*nestedType{} 20 | cacheDatabaseMap = tsyncmap.Map[string, map[string]*nestedType]{} 21 | 22 | // schemaCache is for gorm's schema.Parse 23 | schemaCache = sync.Map{} 24 | 25 | // ErrFieldDoesNotExist is returned if the Where condition contains unknown fields 26 | ErrFieldDoesNotExist = errors.New("field does not exist") 27 | ) 28 | 29 | // AddDeepFilters / addDeepFilter godoc 30 | // 31 | // Gorm supports the following filtering: 32 | // 33 | // type Person struct { 34 | // Name string 35 | // } 36 | // 37 | // map[string]any{ 38 | // "name": "Jake" 39 | // } 40 | // 41 | // Which will return a list of people that are named 'Jake'. This is great for simple filtering 42 | // but for more nested versions like the following it becomes problematic. 43 | // 44 | // type Group struct { 45 | // IDs int 46 | // Name string 47 | // } 48 | // 49 | // type Person struct { 50 | // Name string 51 | // Group Group 52 | // GroupRef int 53 | // } 54 | // 55 | // // Get all the users belonging to 'some group' 56 | // 57 | // map[string]any{ 58 | // "group": map[string]any{ 59 | // "name": "some group", 60 | // }, 61 | // } 62 | // 63 | // Gorm does not understand that we expected to filter users based on their group, it's 64 | // not capable of doing that automatically. For this we need to use subqueries. Find more info here: 65 | // https://gorm.io/docs/advanced_query.html 66 | // 67 | // This function is constructed to automatically convert those nested maps ("group": map[string]...) into 68 | // subqueries. In order to do this, it takes the following steps: 69 | // 70 | // 1. Get all the struct-type fields from the incoming 'object', ignore all simple types and interfaces 71 | // 2. Loop through all the key/values in the incoming map 72 | // 3. Add all the simple types to a simpleMap, GORM can handle these, 73 | // For all the special (nested) structs, add a subquery that uses WHERE on the subquery. 74 | // 4. Add the simple filters to the query and return it. 75 | func AddDeepFilters(db *gorm.DB, objectType any, filters ...map[string]any) (*gorm.DB, error) { 76 | schemaInfo, err := schema.Parse(objectType, &schemaCache, db.NamingStrategy) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | relationalTypesInfo := getDatabaseFieldsOfType(db.NamingStrategy, schemaInfo) 82 | 83 | simpleFilter := map[string]any{} 84 | 85 | // Go through the filters 86 | for _, filterObject := range filters { 87 | // Go through all the keys of the filters 88 | for fieldName, givenFilter := range filterObject { 89 | switch givenFilter.(type) { 90 | // WithFilters for relational objects 91 | case map[string]any: 92 | fieldInfo, ok := relationalTypesInfo[fieldName] 93 | 94 | if !ok { 95 | return nil, fmt.Errorf("failed to add filters for '%s.%s': %w", schemaInfo.Table, fieldName, ErrFieldDoesNotExist) 96 | } 97 | 98 | // We have 2 db objects because if we use 'result' to create subqueries it will cause a stackoverflow. 99 | query, err := addDeepFilter(db, fieldInfo, givenFilter) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | db = query 105 | 106 | // Simple filters (string, int, bool etc.) 107 | default: 108 | if _, ok := schemaInfo.FieldsByDBName[fieldName]; !ok { 109 | return nil, fmt.Errorf("failed to add filters for '%s.%s': %w", schemaInfo.Table, fieldName, ErrFieldDoesNotExist) 110 | } 111 | simpleFilter[fieldName] = givenFilter 112 | } 113 | } 114 | } 115 | 116 | // Add simple filters 117 | db = db.Where(simpleFilter) 118 | 119 | return db, nil 120 | } 121 | 122 | // nestedType Wrapper object used to create subqueries. 123 | // 124 | // NOTICE: We can only do simple many-to-many's with 2 ids right now, I currently (15-06-2021) see no reason 125 | // to add even more advanced options. 126 | type nestedType struct { 127 | // An empty instance of the object, used in db.Model(...) 128 | fieldStructInstance any 129 | fieldForeignKey string 130 | 131 | // Whether this is a manyToOne, oneToMany or manyToMany. oneToOne is taken care of automatically. 132 | relationType string 133 | 134 | ///////////////////////// 135 | // Many to Many fields // 136 | ///////////////////////// 137 | 138 | // The name of the join table 139 | manyToManyTable string 140 | 141 | // The destination field from destinationManyToManyStructInstance 142 | destinationManyToManyForeignKey string 143 | } 144 | 145 | // iKind is an abstraction of reflect.Value and reflect.Type that allows us to make ensureConcrete generic. 146 | type iKind[T any] interface { 147 | Kind() reflect.Kind 148 | Elem() T 149 | } 150 | 151 | // ensureConcrete ensures that the given value is a value and not a pointer, if it is, convert it to its element type 152 | func ensureConcrete[T iKind[T]](value T) T { 153 | if value.Kind() == reflect.Ptr { 154 | return ensureConcrete(value.Elem()) 155 | } 156 | 157 | return value 158 | } 159 | 160 | // ensureNotASlice Ensures that the given value is not a slice, if it is a slice, we use Elem() 161 | // For example: Type []*string will return string. This one is not generic because it doesn't work 162 | // well with reflect.Value. 163 | func ensureNotASlice(value reflect.Type) reflect.Type { 164 | result := ensureConcrete(value) 165 | 166 | if result.Kind() == reflect.Slice { 167 | return ensureNotASlice(result.Elem()) 168 | } 169 | 170 | return result 171 | } 172 | 173 | // getInstanceAndRelationOfField Since db.Model(...) requires an instance, we use this function to instantiate a field type 174 | // and retrieve what kind of relation we assume the object has. 175 | func getInstanceAndRelationOfField(fieldType reflect.Type) (any, string) { 176 | valueType := ensureConcrete(fieldType) 177 | 178 | switch valueType.Kind() { 179 | // If the given field is a struct, we can safely say it's a oneToMany, we instantiate it 180 | // using reflect.New and return it as an object. 181 | case reflect.Struct: 182 | return reflect.New(valueType).Interface(), "oneToMany" 183 | 184 | // If the given field is a slice, it can be either manyToOne or manyToMany. We figure out what 185 | // kind of slice it is and use reflect.New to return it as an object 186 | case reflect.Slice: 187 | elementType := ensureNotASlice(valueType) 188 | return reflect.New(elementType).Interface(), "manyToOne" 189 | 190 | default: 191 | return nil, "" 192 | } 193 | } 194 | 195 | // getNestedType Returns information about the struct field in a nestedType object. Used to figure out 196 | // what database tables need to be queried. 197 | func getNestedType(naming schema.Namer, dbField *schema.Field, ofType reflect.Type) (*nestedType, error) { 198 | // Get empty instance for db.Model() of the given field 199 | sourceStructType, relationType := getInstanceAndRelationOfField(dbField.FieldType) 200 | 201 | result := &nestedType{ 202 | relationType: relationType, 203 | fieldStructInstance: sourceStructType, 204 | } 205 | 206 | sourceForeignKey, ok := dbField.TagSettings["FOREIGNKEY"] 207 | if ok { 208 | result.fieldForeignKey = naming.ColumnName(dbField.Schema.Table, sourceForeignKey) 209 | return result, nil 210 | } 211 | 212 | // No foreign key found, then it must be a manyToMany 213 | manyToMany, ok := dbField.TagSettings["MANY2MANY"] 214 | 215 | if !ok { 216 | return nil, fmt.Errorf("no 'foreignKey:...' or 'many2many:...' found in field %s", dbField.Name) 217 | } 218 | 219 | // Woah it's a many-to-many! 220 | result.relationType = "manyToMany" 221 | result.manyToManyTable = manyToMany 222 | 223 | // Based on the type we can just put _id behind it, again this only works with simple many-to-many structs 224 | result.fieldForeignKey = naming.ColumnName(dbField.Schema.Table, ensureNotASlice(dbField.FieldType).Name()) + "_id" 225 | 226 | // Now the other table that we're getting information from. 227 | result.destinationManyToManyForeignKey = naming.ColumnName(dbField.Schema.Table, ofType.Name()) + "_id" 228 | 229 | return result, nil 230 | } 231 | 232 | // getDatabaseFieldsOfType godoc 233 | // Helper method used in AddDeepFilters to get nestedType objects for specific fields. 234 | // For example, the following struct. 235 | // 236 | // type Tag struct { 237 | // IDs uuid.UUID 238 | // } 239 | // 240 | // type SimpleStruct1 struct { 241 | // Name string 242 | // TagRef uuid.UUID 243 | // Tag Tag `gorm:"foreignKey:TagRef"` 244 | // } 245 | // 246 | // Now when we call getDatabaseFieldsOfType(SimpleStruct1{}) it will return the following 247 | // map of items. 248 | // 249 | // { 250 | // "nestedstruct": { 251 | // fieldStructInstance: Tag{}, 252 | // fieldForeignKey: "NestedStructRef", 253 | // relationType: "oneToMany" 254 | // } 255 | // } 256 | func getDatabaseFieldsOfType(naming schema.Namer, schemaInfo *schema.Schema) map[string]*nestedType { 257 | // First get all the information of the to-be-reflected object 258 | reflectType := ensureConcrete(schemaInfo.ModelType) 259 | reflectTypeName := reflectType.Name() 260 | 261 | // The len(dbFields) check is needed here because when running the unit tests 262 | // it fell into a race condition where it had the map key already stored but not the value yet. 263 | // Resulting in some fields reported falsely as non existent 264 | if dbFields, ok := cacheDatabaseMap.Load(reflectTypeName); ok && len(dbFields) != 0 { 265 | return dbFields 266 | } 267 | 268 | var resultNestedType = map[string]*nestedType{} 269 | 270 | for _, fieldInfo := range schemaInfo.FieldsByName { 271 | // Not interested in these 272 | if kind := ensureConcrete(fieldInfo.FieldType).Kind(); kind != reflect.Struct && kind != reflect.Slice { 273 | continue 274 | } 275 | 276 | nestedTypeResult, err := getNestedType(naming, fieldInfo, reflectType) 277 | if err != nil { 278 | continue 279 | } 280 | 281 | resultNestedType[naming.ColumnName(schemaInfo.Table, fieldInfo.Name)] = nestedTypeResult 282 | } 283 | 284 | // Add to cache 285 | cacheDatabaseMap.Store(reflectTypeName, resultNestedType) 286 | 287 | return resultNestedType 288 | } 289 | 290 | // AddDeepFilters / addDeepFilter godoc 291 | // Refer to AddDeepFilters. 292 | func addDeepFilter(db *gorm.DB, fieldInfo *nestedType, filter any) (*gorm.DB, error) { 293 | cleanDB := db.Session(&gorm.Session{NewDB: true}) 294 | 295 | switch fieldInfo.relationType { 296 | case "oneToMany": 297 | // SELECT * FROM WHERE fieldInfo.fieldForeignKey IN (SELECT id FROM fieldInfo.fieldStructInstance WHERE givenFilter) 298 | whereQuery := fmt.Sprintf("%s IN (?)", fieldInfo.fieldForeignKey) 299 | subQuery, err := AddDeepFilters(cleanDB, fieldInfo.fieldStructInstance, filter.(map[string]any)) 300 | 301 | if err != nil { 302 | return nil, err 303 | } 304 | 305 | return db.Where(whereQuery, cleanDB.Model(fieldInfo.fieldStructInstance).Select("id").Where(subQuery)), nil 306 | 307 | case "manyToOne": 308 | // SELECT * FROM
WHERE id IN (SELECT fieldInfo.fieldStructInstance FROM fieldInfo.fieldStructInstance WHERE filter) 309 | subQuery, err := AddDeepFilters(cleanDB, fieldInfo.fieldStructInstance, filter.(map[string]any)) 310 | 311 | if err != nil { 312 | return nil, err 313 | } 314 | 315 | return db.Where("id IN (?)", cleanDB.Model(fieldInfo.fieldStructInstance).Select(fieldInfo.fieldForeignKey).Where(subQuery)), nil 316 | 317 | case "manyToMany": 318 | // SELECT * FROM
WHERE id IN (SELECT
_id FROM fieldInfo.fieldForeignKey WHERE _id IN (SELECT id FROM WHERE givenFilter)) 319 | 320 | // The one that connects the objects 321 | subWhere := fmt.Sprintf("%s IN (?)", fieldInfo.fieldForeignKey) 322 | subQuery, err := AddDeepFilters(cleanDB, fieldInfo.fieldStructInstance, filter.(map[string]any)) 323 | 324 | if err != nil { 325 | return nil, err 326 | } 327 | 328 | return db.Where("id IN (?)", cleanDB.Table(fieldInfo.manyToManyTable).Select(fieldInfo.destinationManyToManyForeignKey).Where(subWhere, cleanDB.Model(fieldInfo.fieldStructInstance).Select("id").Where(subQuery))), nil 329 | } 330 | 331 | return nil, fmt.Errorf("relationType '%s' unknown", fieldInfo.relationType) 332 | } 333 | -------------------------------------------------------------------------------- /deep-filtering_test.go: -------------------------------------------------------------------------------- 1 | package deepgorm 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "sync" 7 | "testing" 8 | 9 | "github.com/ing-bank/gormtestutil" 10 | "gorm.io/gorm/schema" 11 | 12 | "github.com/google/uuid" 13 | "github.com/stretchr/testify/assert" 14 | "github.com/stretchr/testify/require" 15 | "gorm.io/gorm/clause" 16 | ) 17 | 18 | // Mocks 19 | 20 | type MockModel struct { 21 | ID uuid.UUID 22 | Name string 23 | Metadata *Metadata `gorm:"foreignKey:MetadataID"` 24 | MetadataID *uuid.UUID 25 | } 26 | 27 | type Metadata struct { 28 | ID uuid.UUID 29 | Name string 30 | MockModelID uuid.UUID 31 | MockModel *MockModel `gorm:"foreignKey:MockModelID"` 32 | } 33 | 34 | type ManyA struct { 35 | ID uuid.UUID 36 | A string 37 | ManyBs []*ManyB `gorm:"many2many:a_b"` 38 | } 39 | 40 | type ManyB struct { 41 | ID uuid.UUID 42 | B string 43 | ManyAs []*ManyA `gorm:"many2many:a_b"` 44 | } 45 | 46 | type TagValue struct { 47 | ID uuid.UUID 48 | Value string 49 | } 50 | 51 | type Tag struct { 52 | ID uuid.UUID 53 | Key string 54 | Value string 55 | ComplexStructRef uuid.UUID 56 | TagValueRef uuid.UUID 57 | TagValue *TagValue `gorm:"foreignKey:TagValueRef"` 58 | } 59 | 60 | type SimpleTag struct { 61 | ID uuid.UUID 62 | Key string 63 | Value string 64 | ComplexStructRef uuid.UUID 65 | } 66 | 67 | type NestedStruct4 struct { 68 | ID uuid.UUID 69 | Name string 70 | Occupation string 71 | } 72 | 73 | type ComplexStruct1 struct { 74 | ID uuid.UUID 75 | Value int 76 | Nested *NestedStruct4 `gorm:"foreignKey:NestedRef"` 77 | NestedRef uuid.UUID 78 | } 79 | 80 | type ComplexStruct2 struct { 81 | ID uuid.UUID 82 | Name string 83 | Tags []*SimpleTag `gorm:"foreignKey:ComplexStructRef"` 84 | } 85 | 86 | type ComplexStruct3 struct { 87 | ID uuid.UUID 88 | Name string 89 | Tags []*Tag `gorm:"foreignKey:ComplexStructRef"` 90 | } 91 | 92 | // Tests 93 | 94 | func TestGetDatabaseFieldsOfType_DoesNotReturnSimpleTypes(t *testing.T) { 95 | t.Parallel() 96 | t.Cleanup(cleanupCache) 97 | // Arrange 98 | type SimpleStruct1 struct { 99 | //nolint 100 | Name string 101 | //nolint 102 | Occupation string 103 | } 104 | expectedResult := map[string]*nestedType{} 105 | 106 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 107 | schemaInfo, _ := schema.Parse(&SimpleStruct1{}, &sync.Map{}, naming) 108 | 109 | // Act 110 | result := getDatabaseFieldsOfType(nil, schemaInfo) 111 | 112 | // Assert 113 | assert.Equal(t, expectedResult, result) 114 | } 115 | 116 | func TestGetDatabaseFieldsOfType_ReturnsStructTypeFields(t *testing.T) { 117 | t.Parallel() 118 | t.Cleanup(cleanupCache) 119 | // Arrange 120 | type SimpleStruct2 struct { 121 | ID int 122 | Name string 123 | Occupation string 124 | } 125 | 126 | type TypeWithStruct1 struct { 127 | ID int 128 | NestedStruct SimpleStruct2 `gorm:"foreignKey:NestedStructRef"` 129 | NestedStructRef int 130 | } 131 | 132 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 133 | 134 | schemaInfo, _ := schema.Parse(TypeWithStruct1{}, &sync.Map{}, naming) 135 | 136 | // Act 137 | result := getDatabaseFieldsOfType(naming, schemaInfo) 138 | 139 | // Assert 140 | assert.Len(t, result, 1) 141 | 142 | // Check if expected 'NestedStruct1' exists 143 | if assert.NotNil(t, result["nested_struct"]) { 144 | // Check if it's a SimpleStruct1 145 | assert.IsType(t, &SimpleStruct2{}, result["nested_struct"].fieldStructInstance) 146 | assert.Equal(t, "nested_struct_ref", result["nested_struct"].fieldForeignKey) 147 | assert.Equal(t, "oneToMany", result["nested_struct"].relationType) 148 | } 149 | } 150 | 151 | func TestGetDatabaseFieldsOfType_ReturnsStructTypeOfSliceFields(t *testing.T) { 152 | t.Parallel() 153 | t.Cleanup(cleanupCache) 154 | // Arrange 155 | type SimpleStruct3 struct { 156 | ID int 157 | Name *string 158 | Occupation *string 159 | TypeWithStructRef int 160 | } 161 | 162 | type TypeWithStruct2 struct { 163 | ID int 164 | NestedStruct []*SimpleStruct3 `gorm:"foreignKey:TypeWithStructRef"` 165 | } 166 | 167 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 168 | 169 | schemaInfo, _ := schema.Parse(&TypeWithStruct2{}, &sync.Map{}, naming) 170 | 171 | // Act 172 | result := getDatabaseFieldsOfType(naming, schemaInfo) 173 | 174 | // Assert 175 | assert.Len(t, result, 1) 176 | 177 | // Check if expected 'NestedStruct1' exists 178 | if assert.NotNil(t, result["nested_struct"]) { 179 | // Check if it's a SimpleStruct1 180 | assert.IsType(t, &SimpleStruct3{}, result["nested_struct"].fieldStructInstance) 181 | assert.Equal(t, "type_with_struct_ref", result["nested_struct"].fieldForeignKey) 182 | assert.Equal(t, "manyToOne", result["nested_struct"].relationType) 183 | } 184 | } 185 | 186 | func TestGetDatabaseFieldsOfType_ReturnsStructTypeFieldsOnConsecutiveCalls(t *testing.T) { 187 | t.Parallel() 188 | t.Cleanup(cleanupCache) 189 | // Arrange 190 | type SimpleStruct4 struct { 191 | Name string 192 | Occupation string 193 | } 194 | 195 | type TypeWithStruct3 struct { 196 | NestedStruct SimpleStruct4 `gorm:"foreignKey:NestedStructRef"` 197 | NestedStructRef int 198 | } 199 | 200 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 201 | schemaInfo, _ := schema.Parse(&TypeWithStruct3{}, &sync.Map{}, naming) 202 | 203 | _ = getDatabaseFieldsOfType(naming, schemaInfo) 204 | 205 | // Act 206 | result := getDatabaseFieldsOfType(naming, schemaInfo) 207 | 208 | // Assert 209 | assert.Len(t, result, 1) 210 | 211 | if assert.NotNil(t, result["nested_struct"]) { 212 | assert.IsType(t, &SimpleStruct4{}, result["nested_struct"].fieldStructInstance) 213 | 214 | assert.Equal(t, "nested_struct_ref", result["nested_struct"].fieldForeignKey) 215 | assert.Equal(t, "oneToMany", result["nested_struct"].relationType) 216 | } 217 | } 218 | 219 | func TestEnsureConcrete_TurnsTypeTestAIntoValue(t *testing.T) { 220 | t.Parallel() 221 | t.Cleanup(cleanupCache) 222 | // Arrange 223 | type TestA struct{} 224 | reflectPointer := reflect.TypeOf(&TestA{}) 225 | 226 | // Act 227 | result := ensureConcrete(reflectPointer) 228 | 229 | // Assert 230 | reflectValue := reflect.TypeOf(TestA{}) 231 | 232 | assert.Equal(t, reflectValue, result) 233 | } 234 | 235 | func TestEnsureConcrete_TurnsTypeTestBIntoValue(t *testing.T) { 236 | t.Parallel() 237 | t.Cleanup(cleanupCache) 238 | // Arrange 239 | type TestB struct{} 240 | reflectPointer := reflect.TypeOf(&TestB{}) 241 | 242 | // Act 243 | result := ensureConcrete(reflectPointer) 244 | 245 | // Assert 246 | reflectValue := reflect.TypeOf(TestB{}) 247 | 248 | assert.Equal(t, reflectValue, result) 249 | } 250 | 251 | func TestEnsureConcrete_TurnsTypeTestAIntoValueWithMultiplePointers(t *testing.T) { 252 | t.Parallel() 253 | t.Cleanup(cleanupCache) 254 | // Arrange 255 | type TestA struct{} 256 | 257 | first := &TestA{} 258 | second := &first 259 | third := &second 260 | 261 | reflectPointer := reflect.TypeOf(&third) 262 | 263 | // Act 264 | result := ensureConcrete(reflectPointer) 265 | 266 | // Assert 267 | reflectValue := reflect.TypeOf(TestA{}) 268 | 269 | assert.Equal(t, reflectValue, result) 270 | } 271 | 272 | func TestEnsureConcrete_LeavesValueOfTypeTestAAlone(t *testing.T) { 273 | t.Parallel() 274 | t.Cleanup(cleanupCache) 275 | // Arrange 276 | type TestA struct{} 277 | reflectValue := reflect.TypeOf(TestA{}) 278 | 279 | // Act 280 | result := ensureConcrete(reflectValue) 281 | 282 | // Assert 283 | assert.Equal(t, reflectValue, result) 284 | } 285 | 286 | func TestEnsureConcrete_LeavesValueOfTypeTestBAlone(t *testing.T) { 287 | t.Parallel() 288 | t.Cleanup(cleanupCache) 289 | // Arrange 290 | type TestB struct{} 291 | reflectValue := reflect.TypeOf(TestB{}) 292 | 293 | // Act 294 | result := ensureConcrete(reflectValue) 295 | 296 | // Assert 297 | assert.Equal(t, reflectValue, result) 298 | } 299 | 300 | func TestEnsureNotASlice_LeavesValueOfTypeTestAAlone(t *testing.T) { 301 | t.Parallel() 302 | t.Cleanup(cleanupCache) 303 | // Arrange 304 | type TestA struct{} 305 | reflectValue := reflect.TypeOf([]*TestA{}) 306 | 307 | // Act 308 | result := ensureNotASlice(reflectValue) 309 | 310 | // Assert 311 | expected := reflect.TypeOf(TestA{}) 312 | assert.Equal(t, expected, result) 313 | } 314 | 315 | func TestEnsureNotASlice_LeavesValueOfTypeTestBAlone(t *testing.T) { 316 | t.Parallel() 317 | t.Cleanup(cleanupCache) 318 | // Arrange 319 | type TestB struct{} 320 | reflectValue := reflect.TypeOf(TestB{}) 321 | 322 | // Act 323 | result := ensureNotASlice(reflectValue) 324 | 325 | // Assert 326 | assert.Equal(t, reflectValue, result) 327 | } 328 | 329 | func TestEnsureNotASlice_ReturnsExpectedType(t *testing.T) { 330 | t.Parallel() 331 | t.Cleanup(cleanupCache) 332 | // Arrange 333 | type TestB struct{} 334 | reflectValue := reflect.TypeOf([]TestB{}) 335 | 336 | // Act 337 | result := ensureNotASlice(reflectValue) 338 | 339 | // Assert 340 | expectedReflect := reflect.TypeOf(TestB{}) 341 | assert.Equal(t, expectedReflect, result) 342 | } 343 | 344 | func TestEnsureNotASlice_ReturnsExpectedTypeOnDeepSlice(t *testing.T) { 345 | t.Parallel() 346 | t.Cleanup(cleanupCache) 347 | // Arrange 348 | type TestB struct{} 349 | reflectValue := reflect.TypeOf([][][][][][][][][][]TestB{}) 350 | 351 | // Act 352 | result := ensureNotASlice(reflectValue) 353 | 354 | // Assert 355 | expectedReflect := reflect.TypeOf(TestB{}) 356 | assert.Equal(t, expectedReflect, result) 357 | } 358 | 359 | func TestEnsureNotASlice_ReturnsExpectedTypeOnDeepSliceAndPointers(t *testing.T) { 360 | t.Parallel() 361 | t.Cleanup(cleanupCache) 362 | // Arrange 363 | type TestB struct{} 364 | reflectValue := reflect.TypeOf([]*[][]*[]*[][]*[]*[][]*[]*TestB{}) 365 | 366 | // Act 367 | result := ensureNotASlice(reflectValue) 368 | 369 | // Assert 370 | expectedReflect := reflect.TypeOf(TestB{}) 371 | assert.Equal(t, expectedReflect, result) 372 | } 373 | 374 | func TestGetInstanceAndValueTypeInfoOfField_ReturnsExpectedStructOnStruct(t *testing.T) { 375 | t.Parallel() 376 | t.Cleanup(cleanupCache) 377 | // Arrange 378 | type TestStruct struct{} 379 | input := reflect.TypeOf(TestStruct{}) 380 | 381 | // Act 382 | result, relation := getInstanceAndRelationOfField(input) 383 | 384 | // Assert 385 | assert.Equal(t, &TestStruct{}, result) 386 | assert.Equal(t, "oneToMany", relation) 387 | } 388 | 389 | func TestGetInstanceAndValueTypeInfoOfField_ReturnsExpectedStructOnSlice(t *testing.T) { 390 | t.Parallel() 391 | t.Cleanup(cleanupCache) 392 | // Arrange 393 | type TestStruct struct{} 394 | input := reflect.TypeOf([]TestStruct{}) 395 | 396 | // Act 397 | result, relation := getInstanceAndRelationOfField(input) 398 | 399 | // Assert 400 | assert.Equal(t, &TestStruct{}, result) 401 | assert.Equal(t, "manyToOne", relation) 402 | } 403 | 404 | func TestGetInstanceAndValueTypeInfoOfField_ReturnsNilOnNonStructUnknownType(t *testing.T) { 405 | t.Parallel() 406 | t.Cleanup(cleanupCache) 407 | // Arrange 408 | input := reflect.TypeOf(0) 409 | 410 | // Act 411 | result, relation := getInstanceAndRelationOfField(input) 412 | 413 | // Assert 414 | assert.Equal(t, nil, result) 415 | assert.Equal(t, "", relation) 416 | } 417 | 418 | func TestGetNestedType_ReturnsExpectedTypeInfoOnOneToMany(t *testing.T) { 419 | t.Parallel() 420 | t.Cleanup(cleanupCache) 421 | type NestedStruct1 struct { 422 | ID int 423 | } 424 | 425 | type TestStruct struct { 426 | ID int 427 | 428 | TestAID int 429 | A *TestStruct `gorm:"foreignKey:TestAID"` 430 | 431 | TestBID int 432 | B *NestedStruct1 `gorm:"foreignKey:TestBID"` 433 | } 434 | 435 | tests := map[string]struct { 436 | field string 437 | expectedForeignKey string 438 | expected any 439 | }{ 440 | "first": { 441 | expected: &TestStruct{}, 442 | field: "A", 443 | expectedForeignKey: "test_a_id", 444 | }, 445 | "second": { 446 | expected: &NestedStruct1{}, 447 | field: "B", 448 | expectedForeignKey: "test_b_id", 449 | }, 450 | } 451 | 452 | for name, testData := range tests { 453 | testData := testData 454 | t.Run(name, func(t *testing.T) { 455 | t.Parallel() 456 | // Arrange 457 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 458 | schemaInfo, _ := schema.Parse(TestStruct{}, &sync.Map{}, naming) 459 | field := schemaInfo.FieldsByName[testData.field] 460 | 461 | // Act 462 | result, err := getNestedType(naming, field, reflect.TypeOf(TestStruct{})) 463 | 464 | // Assert 465 | assert.Nil(t, err) 466 | 467 | if assert.NotNil(t, result) { 468 | assert.Equal(t, "oneToMany", result.relationType) 469 | assert.Equal(t, testData.expectedForeignKey, result.fieldForeignKey) 470 | assert.Equal(t, testData.expected, result.fieldStructInstance) 471 | 472 | assert.Equal(t, "", result.destinationManyToManyForeignKey) 473 | assert.Equal(t, "", result.manyToManyTable) 474 | } 475 | }) 476 | } 477 | } 478 | 479 | func TestGetNestedType_ReturnsExpectedTypeInfoOnManyToOne(t *testing.T) { 480 | t.Parallel() 481 | t.Cleanup(cleanupCache) 482 | type NestedStruct2 struct { 483 | ID int 484 | BID int 485 | } 486 | 487 | type TestStruct struct { 488 | ID int 489 | 490 | AID int 491 | A []TestStruct `gorm:"foreignKey:AID"` 492 | B []NestedStruct2 `gorm:"foreignKey:BID"` 493 | } 494 | 495 | tests := map[string]struct { 496 | inputType reflect.Type 497 | field reflect.StructField 498 | expectedForeignKey string 499 | expected any 500 | }{ 501 | "first": { 502 | expected: &TestStruct{}, 503 | inputType: reflect.TypeOf(TestStruct{}), 504 | field: reflect.TypeOf(TestStruct{}).Field(2), 505 | expectedForeignKey: "a_id", 506 | }, 507 | "second": { 508 | expected: &NestedStruct2{}, 509 | inputType: reflect.TypeOf(TestStruct{}), 510 | field: reflect.TypeOf(TestStruct{}).Field(3), 511 | expectedForeignKey: "b_id", 512 | }, 513 | } 514 | 515 | for name, testData := range tests { 516 | testData := testData 517 | t.Run(name, func(t *testing.T) { 518 | t.Parallel() 519 | // Arrange 520 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 521 | schemaInfo, _ := schema.Parse(TestStruct{}, &sync.Map{}, naming) 522 | field := schemaInfo.FieldsByName[testData.field.Name] 523 | 524 | // Act 525 | result, err := getNestedType(naming, field, nil) 526 | 527 | // Assert 528 | assert.Nil(t, err) 529 | 530 | if assert.NotNil(t, result) { 531 | assert.Equal(t, "manyToOne", result.relationType) 532 | assert.Equal(t, testData.expectedForeignKey, result.fieldForeignKey) 533 | assert.Equal(t, testData.expected, result.fieldStructInstance) 534 | 535 | assert.Equal(t, "", result.destinationManyToManyForeignKey) 536 | assert.Equal(t, "", result.manyToManyTable) 537 | } 538 | }) 539 | } 540 | } 541 | 542 | func TestGetNestedType_ReturnsExpectedTypeInfoOnManyToMany(t *testing.T) { 543 | t.Parallel() 544 | t.Cleanup(cleanupCache) 545 | // Arrange 546 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 547 | 548 | schemaInfo, _ := schema.Parse(ManyA{}, &sync.Map{}, naming) 549 | field := schemaInfo.FieldsByName["ManyBs"] 550 | 551 | inputType := reflect.TypeOf(ManyA{}) 552 | 553 | // This is what ManyA should return 554 | expected := &nestedType{ 555 | fieldStructInstance: &ManyB{}, 556 | fieldForeignKey: "many_b_id", 557 | relationType: "manyToMany", 558 | manyToManyTable: "a_b", 559 | destinationManyToManyForeignKey: "many_a_id", 560 | } 561 | 562 | // Act 563 | result, err := getNestedType(naming, field, inputType) 564 | 565 | // Assert 566 | assert.Nil(t, err) 567 | 568 | if assert.NotNil(t, result) { 569 | assert.EqualValues(t, expected, result) 570 | } 571 | } 572 | 573 | func TestGetNestedType_ReturnsErrorOnNoForeignKeys(t *testing.T) { 574 | t.Parallel() 575 | t.Cleanup(cleanupCache) 576 | type NestedStruct3 struct{} 577 | 578 | type TestStruct struct { 579 | A *[]TestStruct `gorm:""` 580 | B *[]NestedStruct3 581 | } 582 | 583 | tests := map[string]struct { 584 | field string 585 | }{ 586 | "first": { 587 | field: "A", 588 | }, 589 | "second": { 590 | field: "B", 591 | }, 592 | } 593 | 594 | for name, testData := range tests { 595 | testData := testData 596 | t.Run(name, func(t *testing.T) { 597 | t.Parallel() 598 | // Arrange 599 | naming := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())).NamingStrategy 600 | schemaInfo, _ := schema.Parse(TestStruct{}, &sync.Map{}, naming) 601 | field := schemaInfo.FieldsByName[testData.field] 602 | 603 | // Act 604 | result, err := getNestedType(naming, field, nil) 605 | 606 | // Assert 607 | assert.Nil(t, result) 608 | 609 | if assert.NotNil(t, err) { 610 | expected := fmt.Sprintf("no 'foreignKey:...' or 'many2many:...' found in field %v", testData.field) 611 | assert.Equal(t, expected, err.Error()) 612 | } 613 | }) 614 | } 615 | } 616 | 617 | func TestAddDeepFilters_ReturnsErrorOnUnknownFieldInformation(t *testing.T) { 618 | t.Parallel() 619 | t.Cleanup(cleanupCache) 620 | type SimpleStruct5 struct { 621 | Name string 622 | Occupation string 623 | } 624 | 625 | tests := map[string]struct { 626 | records []*SimpleStruct5 627 | filterMap map[string]any 628 | fieldName string 629 | tableName string 630 | }{ 631 | "first": { 632 | records: []*SimpleStruct5{ 633 | { 634 | Occupation: "Dev", 635 | Name: "John", 636 | }, 637 | { 638 | Occupation: "Ops", 639 | Name: "Jennifer", 640 | }, 641 | }, 642 | filterMap: map[string]any{ 643 | "probation": map[string]any{}, 644 | }, 645 | fieldName: "probation", 646 | tableName: "simple_struct5", 647 | }, 648 | "second": { 649 | records: []*SimpleStruct5{ 650 | { 651 | Occupation: "Dev", 652 | Name: "John", 653 | }, 654 | { 655 | Occupation: "Ops", 656 | Name: "Roy", 657 | }, 658 | }, 659 | filterMap: map[string]any{ 660 | "does_not_exist": map[string]any{}, 661 | }, 662 | fieldName: "does_not_exist", 663 | tableName: "simple_struct5", 664 | }, 665 | } 666 | 667 | for name, testData := range tests { 668 | testData := testData 669 | t.Run(name, func(t *testing.T) { 670 | t.Parallel() 671 | // Arrange 672 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 673 | _ = database.AutoMigrate(&SimpleStruct5{}) 674 | 675 | database.CreateInBatches(testData.records, len(testData.records)) 676 | 677 | // Act 678 | query, err := AddDeepFilters(database, SimpleStruct5{}, testData.filterMap) 679 | 680 | // Assert 681 | assert.Nil(t, query) 682 | 683 | if assert.NotNil(t, err) { 684 | expectedError := fmt.Sprintf("failed to add filters for '%s.%s': field does not exist", testData.tableName, testData.fieldName) 685 | assert.Equal(t, expectedError, err.Error()) 686 | } 687 | }) 688 | } 689 | } 690 | 691 | func TestAddDeepFilters_AddsSimpleFilters(t *testing.T) { 692 | t.Parallel() 693 | t.Cleanup(cleanupCache) 694 | type SimpleStruct6 struct { 695 | Name string 696 | Occupation string 697 | } 698 | 699 | tests := map[string]struct { 700 | records []*SimpleStruct6 701 | expected []*SimpleStruct6 702 | filterMap map[string]any 703 | }{ 704 | "first": { 705 | records: []*SimpleStruct6{ 706 | { 707 | Occupation: "Dev", 708 | Name: "John", 709 | }, 710 | { 711 | Occupation: "Ops", 712 | Name: "Jennifer", 713 | }, 714 | }, 715 | expected: []*SimpleStruct6{ 716 | { 717 | Occupation: "Ops", 718 | Name: "Jennifer", 719 | }, 720 | }, 721 | filterMap: map[string]any{ 722 | "occupation": "Ops", 723 | }, 724 | }, 725 | "second": { 726 | records: []*SimpleStruct6{ 727 | { 728 | Occupation: "Dev", 729 | Name: "John", 730 | }, 731 | { 732 | Occupation: "Ops", 733 | Name: "Jennifer", 734 | }, 735 | { 736 | Occupation: "Ops", 737 | Name: "Roy", 738 | }, 739 | }, 740 | expected: []*SimpleStruct6{ 741 | { 742 | Occupation: "Ops", 743 | Name: "Jennifer", 744 | }, 745 | { 746 | Occupation: "Ops", 747 | Name: "Roy", 748 | }, 749 | }, 750 | filterMap: map[string]any{ 751 | "occupation": "Ops", 752 | }, 753 | }, 754 | "third": { 755 | records: []*SimpleStruct6{ 756 | { 757 | Occupation: "Dev", 758 | Name: "John", 759 | }, 760 | { 761 | Occupation: "Ops", 762 | Name: "Jennifer", 763 | }, 764 | }, 765 | expected: []*SimpleStruct6{ 766 | { 767 | Occupation: "Ops", 768 | Name: "Jennifer", 769 | }, 770 | }, 771 | filterMap: map[string]any{ 772 | "occupation": "Ops", 773 | "name": "Jennifer", 774 | }, 775 | }, 776 | "fourth": { 777 | records: []*SimpleStruct6{ 778 | { 779 | Occupation: "Dev", 780 | Name: "John", 781 | }, 782 | { 783 | Occupation: "Ops", 784 | Name: "Jennifer", 785 | }, 786 | }, 787 | expected: []*SimpleStruct6{ 788 | { 789 | Occupation: "Dev", 790 | Name: "John", 791 | }, 792 | { 793 | Occupation: "Ops", 794 | Name: "Jennifer", 795 | }, 796 | }, 797 | filterMap: map[string]any{ 798 | "occupation": []string{"Ops", "Dev"}, 799 | }, 800 | }, 801 | } 802 | 803 | for name, testData := range tests { 804 | testData := testData 805 | t.Run(name, func(t *testing.T) { 806 | t.Parallel() 807 | // Arrange 808 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 809 | _ = database.AutoMigrate(&SimpleStruct6{}) 810 | 811 | database.CreateInBatches(testData.records, len(testData.records)) 812 | 813 | // Act 814 | query, err := AddDeepFilters(database, SimpleStruct6{}, testData.filterMap) 815 | 816 | // Assert 817 | assert.Nil(t, err) 818 | 819 | if assert.NotNil(t, query) { 820 | var result []*SimpleStruct6 821 | query.Preload(clause.Associations).Find(&result) 822 | 823 | assert.EqualValues(t, result, testData.expected) 824 | } 825 | }) 826 | } 827 | } 828 | 829 | func TestAddDeepFilters_AddsDeepFiltersWithOneToMany(t *testing.T) { 830 | t.Parallel() 831 | t.Cleanup(cleanupCache) 832 | tests := map[string]struct { 833 | records []*ComplexStruct1 834 | expected []ComplexStruct1 835 | filterMap map[string]any 836 | }{ 837 | "looking for 1 katherina": { 838 | records: []*ComplexStruct1{ 839 | { 840 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 841 | Value: 1, 842 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 843 | Nested: &NestedStruct4{ 844 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 845 | Name: "Johan", 846 | Occupation: "Dev", 847 | }, 848 | }, 849 | { 850 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 851 | Value: 11, 852 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 853 | Nested: &NestedStruct4{ 854 | 855 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 856 | Name: "Katherina", 857 | Occupation: "Dev", 858 | }, 859 | }, 860 | }, 861 | expected: []ComplexStruct1{ 862 | { 863 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 864 | Value: 11, 865 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 866 | Nested: &NestedStruct4{ 867 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 868 | Name: "Katherina", 869 | Occupation: "Dev", 870 | }, 871 | }, 872 | }, 873 | filterMap: map[string]any{ 874 | "nested": map[string]any{ 875 | "name": "Katherina", 876 | }, 877 | }, 878 | }, 879 | "looking for 1 katherina and value 11": { 880 | records: []*ComplexStruct1{ 881 | { 882 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 883 | Value: 1, 884 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 885 | Nested: &NestedStruct4{ 886 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 887 | Name: "Johan", 888 | Occupation: "Dev", 889 | }, 890 | }, 891 | { 892 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 893 | Value: 11, 894 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 895 | Nested: &NestedStruct4{ 896 | 897 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 898 | Name: "Katherina", 899 | Occupation: "Dev", 900 | }, 901 | }, 902 | }, 903 | expected: []ComplexStruct1{ 904 | { 905 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 906 | Value: 11, 907 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 908 | Nested: &NestedStruct4{ 909 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 910 | Name: "Katherina", 911 | Occupation: "Dev", 912 | }, 913 | }, 914 | }, 915 | filterMap: map[string]any{ 916 | "nested": map[string]any{ 917 | "name": "Katherina", 918 | }, 919 | "value": 11, 920 | }, 921 | }, 922 | "looking for 2 vanessas": { 923 | records: []*ComplexStruct1{ 924 | { 925 | ID: uuid.MustParse("c98dc9f2-bfa5-4ab5-9cbb-76800e09e512"), 926 | Value: 4, 927 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 928 | Nested: &NestedStruct4{ 929 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 930 | Name: "Vanessa", 931 | Occupation: "Ops", 932 | }, 933 | }, 934 | { 935 | ID: uuid.MustParse("2ad6a4fe-e0a4-4791-8f10-df6317cdb8b5"), 936 | Value: 193, 937 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 938 | Nested: &NestedStruct4{ 939 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 940 | Name: "Vanessa", 941 | Occupation: "Dev", 942 | }, 943 | }, 944 | { 945 | ID: uuid.MustParse("5cc022ae-43a1-44d8-8ab5-31350e68d0b1"), 946 | Value: 1593, 947 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c5"), // C 948 | Nested: &NestedStruct4{ 949 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c5"), // C 950 | Name: "Derek", 951 | Occupation: "Dev", 952 | }, 953 | }, 954 | }, 955 | expected: []ComplexStruct1{ 956 | { 957 | ID: uuid.MustParse("c98dc9f2-bfa5-4ab5-9cbb-76800e09e512"), 958 | Value: 4, 959 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 960 | Nested: &NestedStruct4{ 961 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 962 | Name: "Vanessa", 963 | Occupation: "Ops", 964 | }, 965 | }, 966 | { 967 | ID: uuid.MustParse("2ad6a4fe-e0a4-4791-8f10-df6317cdb8b5"), 968 | Value: 193, 969 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 970 | Nested: &NestedStruct4{ 971 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 972 | Name: "Vanessa", 973 | Occupation: "Dev", 974 | }, 975 | }, 976 | }, 977 | filterMap: map[string]any{ 978 | "nested": map[string]any{ 979 | "name": "Vanessa", 980 | }, 981 | }, 982 | }, 983 | "looking for both coat and joke": { 984 | records: []*ComplexStruct1{ 985 | { 986 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 987 | Value: 1, 988 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 989 | Nested: &NestedStruct4{ 990 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 991 | Name: "Coat", 992 | Occupation: "Product Owner", 993 | }, 994 | }, 995 | { 996 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 997 | Value: 2, 998 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 999 | Nested: &NestedStruct4{ 1000 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 1001 | Name: "Joke", 1002 | Occupation: "Ops", 1003 | }, 1004 | }, 1005 | }, 1006 | expected: []ComplexStruct1{ 1007 | { 1008 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1009 | Value: 1, 1010 | NestedRef: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 1011 | Nested: &NestedStruct4{ 1012 | ID: uuid.MustParse("71766db4-eb17-4457-a85c-8b89af5a319d"), // A 1013 | Name: "Coat", 1014 | Occupation: "Product Owner", 1015 | }, 1016 | }, 1017 | { 1018 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1019 | Value: 2, 1020 | NestedRef: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 1021 | Nested: &NestedStruct4{ 1022 | ID: uuid.MustParse("4604bb79-ee05-4a09-b874-c3af8964d8c4"), // BObject 1023 | Name: "Joke", 1024 | Occupation: "Ops", 1025 | }, 1026 | }, 1027 | }, 1028 | filterMap: map[string]any{ 1029 | "nested": map[string]any{ 1030 | "name": []string{"Joke", "Coat"}, 1031 | }, 1032 | }, 1033 | }, 1034 | } 1035 | 1036 | for name, testData := range tests { 1037 | testData := testData 1038 | t.Run(name, func(t *testing.T) { 1039 | t.Parallel() 1040 | // Arrange 1041 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1042 | _ = database.AutoMigrate(&ComplexStruct1{}, &NestedStruct4{}) 1043 | 1044 | // Crate records 1045 | database.CreateInBatches(testData.records, len(testData.records)) 1046 | 1047 | // Act 1048 | query, err := AddDeepFilters(database, ComplexStruct1{}, testData.filterMap) 1049 | 1050 | // Assert 1051 | assert.Nil(t, err) 1052 | 1053 | if assert.NotNil(t, query) { 1054 | var result []ComplexStruct1 1055 | res := query.Preload(clause.Associations).Find(&result) 1056 | 1057 | // Handle error 1058 | assert.Nil(t, res.Error) 1059 | 1060 | assert.Equal(t, testData.expected, result) 1061 | } 1062 | }) 1063 | } 1064 | } 1065 | 1066 | func TestAddDeepFilters_AddsDeepFiltersWithManyToOneOnSingleFilter(t *testing.T) { 1067 | t.Parallel() 1068 | t.Cleanup(cleanupCache) 1069 | tests := map[string]struct { 1070 | records []*ComplexStruct2 1071 | expected []ComplexStruct2 1072 | filterMap map[string]any 1073 | }{ 1074 | "looking for python": { 1075 | records: []*ComplexStruct2{ 1076 | { 1077 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1078 | Name: "Python", 1079 | Tags: []*SimpleTag{ 1080 | { 1081 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1082 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1083 | Key: "type", 1084 | Value: "interpreted", 1085 | }, 1086 | }, 1087 | }, 1088 | { 1089 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 1090 | Name: "Go", 1091 | Tags: []*SimpleTag{ 1092 | { 1093 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1094 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1095 | Key: "type", 1096 | Value: "compiled", 1097 | }, 1098 | }, 1099 | }, 1100 | }, 1101 | expected: []ComplexStruct2{ 1102 | { 1103 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1104 | Name: "Python", 1105 | Tags: []*SimpleTag{ 1106 | { 1107 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1108 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1109 | Key: "type", 1110 | Value: "interpreted", 1111 | }, 1112 | }, 1113 | }, 1114 | }, 1115 | filterMap: map[string]any{ 1116 | "tags": map[string]any{ 1117 | "key": "type", 1118 | "value": "interpreted", 1119 | }, 1120 | }, 1121 | }, 1122 | "javascript-like": { 1123 | records: []*ComplexStruct2{ 1124 | { 1125 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1126 | Name: "Typescript", 1127 | Tags: []*SimpleTag{ 1128 | { 1129 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1130 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1131 | Key: "like", 1132 | Value: "javascript", 1133 | }, 1134 | }, 1135 | }, 1136 | { 1137 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1138 | Name: "Javascript", 1139 | Tags: []*SimpleTag{ 1140 | { 1141 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1142 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1143 | Key: "like", 1144 | Value: "javascript", 1145 | }, 1146 | }, 1147 | }, 1148 | { 1149 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 1150 | Name: "Ruby", 1151 | Tags: []*SimpleTag{ 1152 | { 1153 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1154 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1155 | Key: "type", 1156 | Value: "interpret", 1157 | }, 1158 | }, 1159 | }, 1160 | }, 1161 | expected: []ComplexStruct2{ 1162 | { 1163 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1164 | Name: "Typescript", 1165 | Tags: []*SimpleTag{ 1166 | { 1167 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1168 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1169 | Key: "like", 1170 | Value: "javascript", 1171 | }, 1172 | }, 1173 | }, 1174 | { 1175 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1176 | Name: "Javascript", 1177 | Tags: []*SimpleTag{ 1178 | { 1179 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1180 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1181 | Key: "like", 1182 | Value: "javascript", 1183 | }, 1184 | }, 1185 | }, 1186 | }, 1187 | filterMap: map[string]any{ 1188 | "tags": map[string]any{ 1189 | "key": "like", 1190 | "value": "javascript", 1191 | }, 1192 | }, 1193 | }, 1194 | "no results :(": { 1195 | records: []*ComplexStruct2{ 1196 | { 1197 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1198 | Name: "Typescript", 1199 | Tags: []*SimpleTag{ 1200 | { 1201 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1202 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1203 | Key: "like", 1204 | Value: "javascript", 1205 | }, 1206 | }, 1207 | }, 1208 | { 1209 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1210 | Name: "Javascript", 1211 | Tags: []*SimpleTag{ 1212 | { 1213 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1214 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1215 | Key: "like", 1216 | Value: "javascript", 1217 | }, 1218 | }, 1219 | }, 1220 | }, 1221 | expected: []ComplexStruct2{}, 1222 | filterMap: map[string]any{ 1223 | "tags": map[string]any{ 1224 | "key": "other", 1225 | "value": "tag", 1226 | }, 1227 | }, 1228 | }, 1229 | } 1230 | 1231 | for name, testData := range tests { 1232 | testData := testData 1233 | t.Run(name, func(t *testing.T) { 1234 | t.Parallel() 1235 | // Arrange 1236 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1237 | _ = database.AutoMigrate(&ComplexStruct2{}, &SimpleTag{}) 1238 | 1239 | database.CreateInBatches(testData.records, len(testData.records)) 1240 | 1241 | // Act 1242 | query, err := AddDeepFilters(database, ComplexStruct2{}, testData.filterMap) 1243 | 1244 | // Assert 1245 | assert.Nil(t, err) 1246 | 1247 | if assert.NotNil(t, query) { 1248 | var result []ComplexStruct2 1249 | res := query.Preload(clause.Associations).Find(&result) 1250 | 1251 | // Handle error 1252 | assert.Nil(t, res.Error) 1253 | 1254 | assert.EqualValues(t, testData.expected, result) 1255 | } 1256 | }) 1257 | } 1258 | } 1259 | 1260 | func TestAddDeepFilters_AddsDeepFiltersWithManyToOneOnMultiFilter(t *testing.T) { 1261 | t.Parallel() 1262 | t.Cleanup(cleanupCache) 1263 | tests := map[string]struct { 1264 | records []*ComplexStruct3 1265 | expected []ComplexStruct3 1266 | filterMap []map[string]any 1267 | }{ 1268 | "looking for python": { 1269 | records: []*ComplexStruct3{ 1270 | { 1271 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1272 | Name: "Python", 1273 | Tags: []*Tag{ 1274 | { 1275 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1276 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1277 | Key: "type", 1278 | Value: "interpreted", 1279 | TagValue: &TagValue{ 1280 | ID: uuid.MustParse("38769e29-e945-451f-a551-3e5811a5d363"), 1281 | Value: "test-python-value", 1282 | }, 1283 | }, 1284 | }, 1285 | }, 1286 | { 1287 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 1288 | Name: "Go", 1289 | Tags: []*Tag{ 1290 | { 1291 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1292 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1293 | Key: "type", 1294 | Value: "compiled", 1295 | TagValue: &TagValue{ 1296 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1297 | Value: "test-go-value", 1298 | }, 1299 | }, 1300 | }, 1301 | }, 1302 | }, 1303 | expected: []ComplexStruct3{ 1304 | { 1305 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1306 | Name: "Python", 1307 | Tags: []*Tag{ 1308 | { 1309 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1310 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1311 | Key: "type", 1312 | Value: "interpreted", 1313 | TagValueRef: uuid.MustParse("38769e29-e945-451f-a551-3e5811a5d363"), 1314 | }, 1315 | }, 1316 | }, 1317 | }, 1318 | filterMap: []map[string]any{ 1319 | { 1320 | "tags": map[string]any{ 1321 | "key": "type", 1322 | "value": "interpreted", 1323 | }, 1324 | }, 1325 | { 1326 | "name": "Python", 1327 | }, 1328 | }, 1329 | }, 1330 | "javascript-like and not python-like": { 1331 | records: []*ComplexStruct3{ 1332 | { 1333 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1334 | Name: "Typescript", 1335 | Tags: []*Tag{ 1336 | { 1337 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1338 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1339 | Key: "like", 1340 | Value: "javascript", 1341 | TagValue: &TagValue{ 1342 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1343 | Value: "test-js-value", 1344 | }, 1345 | }, 1346 | { 1347 | ID: uuid.MustParse("8977cd8b-ebb8-4119-93d5-cbe605d8f668"), 1348 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1349 | Key: "not-like", 1350 | Value: "python", 1351 | TagValue: &TagValue{ 1352 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1353 | Value: "test-python-value", 1354 | }, 1355 | }, 1356 | }, 1357 | }, 1358 | { 1359 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1360 | Name: "Javascript", 1361 | Tags: []*Tag{ 1362 | { 1363 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1364 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1365 | Key: "like", 1366 | Value: "javascript", 1367 | TagValue: &TagValue{ 1368 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1369 | Value: "test-js-value", 1370 | }, 1371 | }, 1372 | }, 1373 | }, 1374 | { 1375 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 1376 | Name: "Ruby", 1377 | Tags: []*Tag{ 1378 | { 1379 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1380 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1381 | Key: "type", 1382 | Value: "interpret", 1383 | TagValue: &TagValue{ 1384 | ID: uuid.MustParse("e01390c4-485d-459f-958a-3d264659a70d"), 1385 | Value: "test-ruby-value", 1386 | }, 1387 | }, 1388 | { 1389 | ID: uuid.MustParse("8927cd8b-ebb8-4119-93d5-cbe605d8f668"), 1390 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1391 | Key: "not-like", 1392 | Value: "python", 1393 | TagValue: &TagValue{ 1394 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1395 | Value: "test-python-value", 1396 | }, 1397 | }, 1398 | }, 1399 | }, 1400 | }, 1401 | expected: []ComplexStruct3{ 1402 | { 1403 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1404 | Name: "Typescript", 1405 | Tags: []*Tag{ 1406 | { 1407 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1408 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1409 | Key: "like", 1410 | Value: "javascript", 1411 | TagValueRef: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1412 | }, 1413 | { 1414 | ID: uuid.MustParse("8977cd8b-ebb8-4119-93d5-cbe605d8f668"), 1415 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1416 | Key: "not-like", 1417 | Value: "python", 1418 | TagValueRef: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1419 | }, 1420 | }, 1421 | }, 1422 | }, 1423 | filterMap: []map[string]any{ 1424 | { 1425 | "tags": map[string]any{ 1426 | "key": "like", 1427 | "value": "javascript", 1428 | }, 1429 | }, 1430 | { 1431 | "tags": map[string]any{ 1432 | "key": "not-like", 1433 | "value": "python", 1434 | }, 1435 | }, 1436 | }, 1437 | }, 1438 | "no results :(": { 1439 | records: []*ComplexStruct3{ 1440 | { 1441 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1442 | Name: "Typescript", 1443 | Tags: []*Tag{ 1444 | { 1445 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1446 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1447 | Key: "like", 1448 | Value: "javascript", 1449 | TagValue: &TagValue{ 1450 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1451 | Value: "test-js-value", 1452 | }, 1453 | }, 1454 | }, 1455 | }, 1456 | { 1457 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1458 | Name: "Javascript", 1459 | Tags: []*Tag{ 1460 | { 1461 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1462 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1463 | Key: "like", 1464 | Value: "javascript", 1465 | TagValue: &TagValue{ 1466 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1467 | Value: "test-js-value", 1468 | }, 1469 | }, 1470 | }, 1471 | }, 1472 | }, 1473 | expected: []ComplexStruct3{}, 1474 | filterMap: []map[string]any{ 1475 | { 1476 | "tags": map[string]any{ 1477 | "key": "like", 1478 | "value": "javascript", 1479 | }, 1480 | }, 1481 | { 1482 | "tags": map[string]any{ 1483 | "key": "not-like", 1484 | "value": "javascript", 1485 | }, 1486 | }, 1487 | }, 1488 | }, 1489 | } 1490 | 1491 | for name, testData := range tests { 1492 | testData := testData 1493 | t.Run(name, func(t *testing.T) { 1494 | t.Parallel() 1495 | // Arrange 1496 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1497 | _ = database.AutoMigrate(&ComplexStruct3{}, &Tag{}) 1498 | 1499 | database.CreateInBatches(testData.records, len(testData.records)) 1500 | 1501 | // Act 1502 | query, err := AddDeepFilters(database, ComplexStruct3{}, testData.filterMap...) 1503 | 1504 | // Assert 1505 | assert.Nil(t, err) 1506 | 1507 | if assert.NotNil(t, query) { 1508 | var result []ComplexStruct3 1509 | 1510 | res := query.Preload(clause.Associations).Find(&result) 1511 | 1512 | // Handle error 1513 | assert.Nil(t, res.Error) 1514 | 1515 | assert.EqualValues(t, testData.expected, result) 1516 | } 1517 | }) 1518 | } 1519 | } 1520 | 1521 | func TestAddDeepFilters_AddsDeepFiltersMultipleLayersOfNesting(t *testing.T) { 1522 | t.Parallel() 1523 | t.Cleanup(cleanupCache) 1524 | tests := map[string]struct { 1525 | records []*ComplexStruct3 1526 | expected []ComplexStruct3 1527 | filterMap []map[string]any 1528 | }{ 1529 | "single query": { 1530 | records: []*ComplexStruct3{ 1531 | { 1532 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1533 | Name: "Python", 1534 | Tags: []*Tag{ 1535 | { 1536 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1537 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1538 | Key: "type", 1539 | Value: "interpreted", 1540 | TagValue: &TagValue{ 1541 | ID: uuid.MustParse("38769e29-e945-451f-a551-3e5811a5d363"), 1542 | Value: "test-python-value", 1543 | }, 1544 | }, 1545 | }, 1546 | }, 1547 | { 1548 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 1549 | Name: "Go", 1550 | Tags: []*Tag{ 1551 | { 1552 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1553 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1554 | Key: "type", 1555 | Value: "compiled", 1556 | TagValue: &TagValue{ 1557 | ID: uuid.MustParse("e75a2f7e-0e1c-4f9c-a8ce-af90f1b64baa"), 1558 | Value: "test-go-value", 1559 | }, 1560 | }, 1561 | }, 1562 | }, 1563 | }, 1564 | expected: []ComplexStruct3{ 1565 | { 1566 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1567 | Name: "Python", 1568 | Tags: []*Tag{ 1569 | { 1570 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1571 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1572 | Key: "type", 1573 | Value: "interpreted", 1574 | TagValueRef: uuid.MustParse("38769e29-e945-451f-a551-3e5811a5d363"), 1575 | }, 1576 | }, 1577 | }, 1578 | }, 1579 | filterMap: []map[string]any{ 1580 | { 1581 | "tags": map[string]any{ 1582 | "tag_value": map[string]any{ 1583 | "value": "test-python-value", 1584 | }, 1585 | }, 1586 | }, 1587 | }, 1588 | }, 1589 | "multi query": { 1590 | records: []*ComplexStruct3{ 1591 | { 1592 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1593 | Name: "Typescript", 1594 | Tags: []*Tag{ 1595 | { 1596 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1597 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1598 | Key: "like", 1599 | Value: "javascript", 1600 | TagValue: &TagValue{ 1601 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1602 | Value: "test-js-value", 1603 | }, 1604 | }, 1605 | { 1606 | ID: uuid.MustParse("8977cd8b-ebb8-4119-93d5-cbe605d8f668"), 1607 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1608 | Key: "not-like", 1609 | Value: "python", 1610 | TagValue: &TagValue{ 1611 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1612 | Value: "test-python-value", 1613 | }, 1614 | }, 1615 | }, 1616 | }, 1617 | { 1618 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1619 | Name: "Javascript", 1620 | Tags: []*Tag{ 1621 | { 1622 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1623 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1624 | Key: "like", 1625 | Value: "javascript", 1626 | TagValue: &TagValue{ 1627 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1628 | Value: "test-js-value", 1629 | }, 1630 | }, 1631 | }, 1632 | }, 1633 | { 1634 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 1635 | Name: "Ruby", 1636 | Tags: []*Tag{ 1637 | { 1638 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1639 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1640 | Key: "type", 1641 | Value: "interpret", 1642 | TagValue: &TagValue{ 1643 | ID: uuid.MustParse("e01390c4-485d-459f-958a-3d264659a70d"), 1644 | Value: "test-ruby-value", 1645 | }, 1646 | }, 1647 | { 1648 | ID: uuid.MustParse("8927cd8b-ebb8-4119-93d5-cbe605d8f668"), 1649 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1650 | Key: "not-like", 1651 | Value: "python", 1652 | TagValue: &TagValue{ 1653 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1654 | Value: "test-python-value", 1655 | }, 1656 | }, 1657 | }, 1658 | }, 1659 | }, 1660 | expected: []ComplexStruct3{ 1661 | { 1662 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1663 | Name: "Typescript", 1664 | Tags: []*Tag{ 1665 | { 1666 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1667 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1668 | Key: "like", 1669 | Value: "javascript", 1670 | TagValueRef: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1671 | }, 1672 | { 1673 | ID: uuid.MustParse("8977cd8b-ebb8-4119-93d5-cbe605d8f668"), 1674 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1675 | Key: "not-like", 1676 | Value: "python", 1677 | TagValueRef: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1678 | }, 1679 | }, 1680 | }, 1681 | }, 1682 | filterMap: []map[string]any{ 1683 | { 1684 | "tags": map[string]any{ 1685 | "tag_value": map[string]any{ 1686 | "value": "test-python-value", 1687 | }, 1688 | }, 1689 | }, 1690 | { 1691 | "tags": map[string]any{ 1692 | "tag_value": map[string]any{ 1693 | "value": "test-js-value", 1694 | }, 1695 | }, 1696 | }, 1697 | }, 1698 | }, 1699 | } 1700 | 1701 | for name, testData := range tests { 1702 | testData := testData 1703 | t.Run(name, func(t *testing.T) { 1704 | t.Parallel() 1705 | // Arrange 1706 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1707 | _ = database.AutoMigrate(&TagValue{}, &Tag{}, &ComplexStruct3{}) 1708 | 1709 | database.CreateInBatches(testData.records, len(testData.records)) 1710 | 1711 | // Act 1712 | query, err := AddDeepFilters(database, ComplexStruct3{}, testData.filterMap...) 1713 | 1714 | // Assert 1715 | assert.Nil(t, err) 1716 | 1717 | if assert.NotNil(t, query) { 1718 | var result []ComplexStruct3 1719 | 1720 | res := query.Preload(clause.Associations).Find(&result) 1721 | 1722 | // Handle error 1723 | assert.Nil(t, res.Error) 1724 | 1725 | assert.EqualValues(t, testData.expected, result) 1726 | } 1727 | }) 1728 | } 1729 | } 1730 | 1731 | func TestAddDeepFilters_ReturnsErrorOnNonExistingFields(t *testing.T) { 1732 | t.Parallel() 1733 | t.Cleanup(cleanupCache) 1734 | tests := map[string]struct { 1735 | records []*ComplexStruct3 1736 | filterMap []map[string]any 1737 | expectedErrorMsg string 1738 | }{ 1739 | "one to many filter": { 1740 | records: []*ComplexStruct3{ 1741 | { 1742 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1743 | Name: "Python", 1744 | Tags: []*Tag{ 1745 | { 1746 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1747 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1748 | Key: "type", 1749 | Value: "interpreted", 1750 | TagValue: &TagValue{ 1751 | ID: uuid.MustParse("38769e29-e945-451f-a551-3e5811a5d363"), 1752 | Value: "test-python-value", 1753 | }, 1754 | }, 1755 | }, 1756 | }, 1757 | { 1758 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 1759 | Name: "Go", 1760 | Tags: []*Tag{ 1761 | { 1762 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1763 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1764 | Key: "type", 1765 | Value: "compiled", 1766 | TagValue: &TagValue{ 1767 | ID: uuid.MustParse("e75a2f7e-0e1c-4f9c-a8ce-af90f1b64baa"), 1768 | Value: "test-go-value", 1769 | }, 1770 | }, 1771 | }, 1772 | }, 1773 | }, 1774 | filterMap: []map[string]any{ 1775 | { 1776 | "tags": map[string]any{ 1777 | "tag_value": map[string]any{ 1778 | "key": "test-python-value", 1779 | }, 1780 | }, 1781 | }, 1782 | }, 1783 | expectedErrorMsg: "failed to add filters for 'tag_values.key': field does not exist", 1784 | }, 1785 | "many to one filter": { 1786 | records: []*ComplexStruct3{ 1787 | { 1788 | ID: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), // A 1789 | Name: "Typescript", 1790 | Tags: []*Tag{ 1791 | { 1792 | ID: uuid.MustParse("451d635a-83f2-47da-b12c-50ec49e45509"), 1793 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1794 | Key: "like", 1795 | Value: "javascript", 1796 | TagValue: &TagValue{ 1797 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1798 | Value: "test-js-value", 1799 | }, 1800 | }, 1801 | { 1802 | ID: uuid.MustParse("8977cd8b-ebb8-4119-93d5-cbe605d8f668"), 1803 | ComplexStructRef: uuid.MustParse("411ed385-c1ca-432d-b577-6d6138450264"), 1804 | Key: "not-like", 1805 | Value: "python", 1806 | TagValue: &TagValue{ 1807 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1808 | Value: "test-python-value", 1809 | }, 1810 | }, 1811 | }, 1812 | }, 1813 | { 1814 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // BObject 1815 | Name: "Javascript", 1816 | Tags: []*Tag{ 1817 | { 1818 | ID: uuid.MustParse("1c83a7c9-e95d-4dba-b858-5eb4e34ebcf2"), 1819 | ComplexStructRef: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), 1820 | Key: "like", 1821 | Value: "javascript", 1822 | TagValue: &TagValue{ 1823 | ID: uuid.MustParse("a825637d-9eae-4855-9ee3-a69f1ee65a46"), 1824 | Value: "test-js-value", 1825 | }, 1826 | }, 1827 | }, 1828 | }, 1829 | { 1830 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 1831 | Name: "Ruby", 1832 | Tags: []*Tag{ 1833 | { 1834 | ID: uuid.MustParse("17983ba8-2d26-4e36-bb6b-6c5a04b6606e"), 1835 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1836 | Key: "type", 1837 | Value: "interpret", 1838 | TagValue: &TagValue{ 1839 | ID: uuid.MustParse("e01390c4-485d-459f-958a-3d264659a70d"), 1840 | Value: "test-ruby-value", 1841 | }, 1842 | }, 1843 | { 1844 | ID: uuid.MustParse("8927cd8b-ebb8-4119-93d5-cbe605d8f668"), 1845 | ComplexStructRef: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), 1846 | Key: "not-like", 1847 | Value: "python", 1848 | TagValue: &TagValue{ 1849 | ID: uuid.MustParse("db712c68-7faf-416d-b361-db77c8307c2b"), 1850 | Value: "test-python-value", 1851 | }, 1852 | }, 1853 | }, 1854 | }, 1855 | }, 1856 | filterMap: []map[string]any{ 1857 | { 1858 | "tags": map[string]any{ 1859 | "tag_key": map[string]any{ 1860 | "value": "test-python-value", 1861 | }, 1862 | }, 1863 | }, 1864 | }, 1865 | expectedErrorMsg: "failed to add filters for 'tags.tag_key': field does not exist", 1866 | }, 1867 | } 1868 | 1869 | for name, testData := range tests { 1870 | testData := testData 1871 | t.Run(name, func(t *testing.T) { 1872 | t.Parallel() 1873 | // Arrange 1874 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1875 | _ = database.AutoMigrate(&ComplexStruct3{}, &Tag{}, &TagValue{}) 1876 | 1877 | database.CreateInBatches(testData.records, len(testData.records)) 1878 | 1879 | // Act 1880 | _, err := AddDeepFilters(database, ComplexStruct3{}, testData.filterMap...) 1881 | 1882 | // Assert 1883 | require.ErrorContains(t, err, testData.expectedErrorMsg) 1884 | }) 1885 | } 1886 | } 1887 | 1888 | func TestAddDeepFilters_ReturnsErrorOnNonExistingFieldsManyToMany(t *testing.T) { 1889 | t.Parallel() 1890 | t.Cleanup(cleanupCache) 1891 | tests := map[string]struct { 1892 | records []*ManyA 1893 | filterMap []map[string]any 1894 | expectedErrorMsg string 1895 | }{ 1896 | "simple filter": { 1897 | records: []*ManyA{ 1898 | { 1899 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1900 | A: "hello", 1901 | ManyBs: []*ManyB{ 1902 | { 1903 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 1904 | B: "world", 1905 | }, 1906 | }, 1907 | }, 1908 | }, 1909 | filterMap: []map[string]any{ 1910 | { 1911 | "many_bs": map[string]any{ 1912 | "a": "world", 1913 | }, 1914 | }, 1915 | }, 1916 | expectedErrorMsg: "failed to add filters for 'many_bs.a': field does not exist", 1917 | }, 1918 | "nested filter": { 1919 | records: []*ManyA{ 1920 | { 1921 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1922 | A: "hello", 1923 | ManyBs: []*ManyB{ 1924 | { 1925 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 1926 | B: "world", 1927 | }, 1928 | }, 1929 | }, 1930 | }, 1931 | filterMap: []map[string]any{ 1932 | { 1933 | "many_b": map[string]any{ 1934 | "b": "world", 1935 | }, 1936 | }, 1937 | }, 1938 | expectedErrorMsg: "failed to add filters for 'many_as.many_b': field does not exist", 1939 | }, 1940 | } 1941 | 1942 | for name, testData := range tests { 1943 | testData := testData 1944 | t.Run(name, func(t *testing.T) { 1945 | t.Parallel() 1946 | // Arrange 1947 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 1948 | _ = database.AutoMigrate(&ManyA{}, &ManyB{}) 1949 | 1950 | database.CreateInBatches(testData.records, len(testData.records)) 1951 | 1952 | // Act 1953 | _, err := AddDeepFilters(database, ManyA{}, testData.filterMap...) 1954 | 1955 | // Assert 1956 | require.ErrorContains(t, err, testData.expectedErrorMsg) 1957 | }) 1958 | } 1959 | } 1960 | 1961 | func TestAddDeepFilters_AddsDeepFiltersWithManyToManyOnSingleFilter(t *testing.T) { 1962 | t.Parallel() 1963 | t.Cleanup(cleanupCache) 1964 | tests := map[string]struct { 1965 | records []*ManyA 1966 | expected []ManyA 1967 | filterMap map[string]any 1968 | }{ 1969 | "looking for 1 world": { 1970 | records: []*ManyA{ 1971 | { 1972 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1973 | A: "Hello", 1974 | ManyBs: []*ManyB{ 1975 | { 1976 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 1977 | B: "world", 1978 | }, 1979 | }, 1980 | }, 1981 | { 1982 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 1983 | A: "Goodbye", 1984 | ManyBs: []*ManyB{ 1985 | { 1986 | ID: uuid.MustParse("33cac758-83b2-4f16-8704-06ed33a29f69"), 1987 | B: "space", 1988 | }, 1989 | }, 1990 | }, 1991 | }, 1992 | expected: []ManyA{ 1993 | { 1994 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 1995 | A: "Hello", 1996 | ManyBs: []*ManyB{ 1997 | { 1998 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 1999 | B: "world", 2000 | }, 2001 | }, 2002 | }, 2003 | }, 2004 | filterMap: map[string]any{ 2005 | "many_bs": map[string]any{ 2006 | "b": "world", 2007 | }, 2008 | }, 2009 | }, 2010 | "looking for 2 worlds": { 2011 | records: []*ManyA{ 2012 | { 2013 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2014 | A: "Hello", 2015 | ManyBs: []*ManyB{ 2016 | { 2017 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2018 | B: "world", 2019 | }, 2020 | }, 2021 | }, 2022 | { 2023 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2024 | A: "Next", 2025 | ManyBs: []*ManyB{ 2026 | { 2027 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad10"), 2028 | B: "world", 2029 | }, 2030 | }, 2031 | }, 2032 | { 2033 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 2034 | A: "Goodbye", 2035 | ManyBs: []*ManyB{ 2036 | { 2037 | ID: uuid.MustParse("33cac758-83b2-4f16-8704-06ed33a29f69"), 2038 | B: "space", 2039 | }, 2040 | }, 2041 | }, 2042 | }, 2043 | expected: []ManyA{ 2044 | { 2045 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2046 | A: "Hello", 2047 | ManyBs: []*ManyB{ 2048 | { 2049 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2050 | B: "world", 2051 | }, 2052 | }, 2053 | }, 2054 | { 2055 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2056 | A: "Next", 2057 | ManyBs: []*ManyB{ 2058 | { 2059 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad10"), 2060 | B: "world", 2061 | }, 2062 | }, 2063 | }, 2064 | }, 2065 | filterMap: map[string]any{ 2066 | "many_bs": map[string]any{ 2067 | "b": "world", 2068 | }, 2069 | }, 2070 | }, 2071 | "looking for sand or ruins": { 2072 | records: []*ManyA{ 2073 | { 2074 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2075 | A: "Mars", 2076 | ManyBs: []*ManyB{ 2077 | { 2078 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2079 | B: "gravel", 2080 | }, 2081 | { 2082 | ID: uuid.MustParse("3fc686ac-7847-405e-b569-df46b9ea78fe"), 2083 | B: "nothing", 2084 | }, 2085 | }, 2086 | }, 2087 | { 2088 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2089 | A: "Pluto", 2090 | ManyBs: []*ManyB{ 2091 | { 2092 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2093 | B: "sand", 2094 | }, 2095 | { 2096 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2097 | B: "ruins", 2098 | }, 2099 | }, 2100 | }, 2101 | }, 2102 | expected: []ManyA{ 2103 | { 2104 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2105 | A: "Pluto", 2106 | ManyBs: []*ManyB{ 2107 | { 2108 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2109 | B: "sand", 2110 | }, 2111 | { 2112 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2113 | B: "ruins", 2114 | }, 2115 | }, 2116 | }, 2117 | }, 2118 | filterMap: map[string]any{ 2119 | "many_bs": map[string]any{ 2120 | "b": []string{"sand", "ruins"}, 2121 | }, 2122 | }, 2123 | }, 2124 | "looking for chalk that has apples": { 2125 | records: []*ManyA{ 2126 | { 2127 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2128 | A: "Chalk", 2129 | ManyBs: []*ManyB{ 2130 | { 2131 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2132 | B: "apples", 2133 | }, 2134 | }, 2135 | }, 2136 | { 2137 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2138 | A: "Board", 2139 | ManyBs: []*ManyB{ 2140 | { 2141 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2142 | B: "pears", 2143 | }, 2144 | { 2145 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2146 | B: "bananas", 2147 | }, 2148 | }, 2149 | }, 2150 | }, 2151 | expected: []ManyA{ 2152 | { 2153 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2154 | A: "Chalk", 2155 | ManyBs: []*ManyB{ 2156 | { 2157 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2158 | B: "apples", 2159 | }, 2160 | }, 2161 | }, 2162 | }, 2163 | filterMap: map[string]any{ 2164 | "a": "Chalk", 2165 | "many_bs": map[string]any{ 2166 | "b": []string{"apples"}, 2167 | }, 2168 | }, 2169 | }, 2170 | } 2171 | 2172 | for name, testData := range tests { 2173 | testData := testData 2174 | t.Run(name, func(t *testing.T) { 2175 | t.Parallel() 2176 | // Arrange 2177 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 2178 | _ = database.AutoMigrate(&ManyA{}, &ManyB{}) 2179 | 2180 | database.CreateInBatches(testData.records, len(testData.records)) 2181 | 2182 | // Act 2183 | query, err := AddDeepFilters(database, ManyA{}, testData.filterMap) 2184 | 2185 | // Assert 2186 | assert.Nil(t, err) 2187 | 2188 | if assert.NotNil(t, query) { 2189 | var result []ManyA 2190 | res := query.Preload(clause.Associations).Find(&result) 2191 | 2192 | // Handle error 2193 | assert.Nil(t, res.Error) 2194 | 2195 | assert.EqualValues(t, testData.expected, result) 2196 | } 2197 | }) 2198 | } 2199 | } 2200 | 2201 | func TestAddDeepFilters_AddsDeepFiltersWithManyToManyOnMultiFilter(t *testing.T) { 2202 | t.Parallel() 2203 | t.Cleanup(cleanupCache) 2204 | tests := map[string]struct { 2205 | records []*ManyA 2206 | expected []ManyA 2207 | filterMap []map[string]any 2208 | }{ 2209 | "looking for 1 world": { 2210 | records: []*ManyA{ 2211 | { 2212 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2213 | A: "Hello", 2214 | ManyBs: []*ManyB{ 2215 | { 2216 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2217 | B: "world", 2218 | }, 2219 | }, 2220 | }, 2221 | { 2222 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // BObject 2223 | A: "Goodbye", 2224 | ManyBs: []*ManyB{ 2225 | { 2226 | ID: uuid.MustParse("33cac758-83b2-4f16-8704-06ed33a29f69"), 2227 | B: "space", 2228 | }, 2229 | }, 2230 | }, 2231 | }, 2232 | expected: []ManyA{ 2233 | { 2234 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2235 | A: "Hello", 2236 | ManyBs: []*ManyB{ 2237 | { 2238 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2239 | B: "world", 2240 | }, 2241 | }, 2242 | }, 2243 | }, 2244 | filterMap: []map[string]any{ 2245 | { 2246 | "many_bs": map[string]any{ 2247 | "b": "world", 2248 | }, 2249 | }, 2250 | { 2251 | "a": "Hello", 2252 | }, 2253 | }, 2254 | }, 2255 | "looking for world and planet": { 2256 | records: []*ManyA{ 2257 | { 2258 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2259 | A: "Hello", 2260 | ManyBs: []*ManyB{ 2261 | { 2262 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2263 | B: "world", 2264 | }, 2265 | { 2266 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad11"), 2267 | B: "planet", 2268 | }, 2269 | }, 2270 | }, 2271 | { 2272 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2273 | A: "Next", 2274 | ManyBs: []*ManyB{ 2275 | { 2276 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575624e1"), 2277 | B: "world", 2278 | }, 2279 | { 2280 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad10"), 2281 | B: "planet", 2282 | }, 2283 | }, 2284 | }, 2285 | { 2286 | ID: uuid.MustParse("23292d51-4768-4c41-8475-6d4c9f0c6f69"), // C 2287 | A: "Goodbye", 2288 | ManyBs: []*ManyB{ 2289 | { 2290 | ID: uuid.MustParse("33cac758-83b2-4f16-8704-06ed33a29f69"), 2291 | B: "space", 2292 | }, 2293 | }, 2294 | }, 2295 | }, 2296 | expected: []ManyA{ 2297 | { 2298 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2299 | A: "Hello", 2300 | ManyBs: []*ManyB{ 2301 | { 2302 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad11"), 2303 | B: "planet", 2304 | }, 2305 | { 2306 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2307 | B: "world", 2308 | }, 2309 | }, 2310 | }, 2311 | { 2312 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2313 | A: "Next", 2314 | ManyBs: []*ManyB{ 2315 | { 2316 | ID: uuid.MustParse("967d53a0-67db-4144-8800-7e3cf5c2ad10"), 2317 | B: "planet", 2318 | }, 2319 | { 2320 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575624e1"), 2321 | B: "world", 2322 | }, 2323 | }, 2324 | }, 2325 | }, 2326 | filterMap: []map[string]any{ 2327 | { 2328 | "many_bs": map[string]any{ 2329 | "b": "world", 2330 | }, 2331 | }, 2332 | { 2333 | "many_bs": map[string]any{ 2334 | "b": "planet", 2335 | }, 2336 | }, 2337 | }, 2338 | }, 2339 | "looking for sand or ruins": { 2340 | records: []*ManyA{ 2341 | { 2342 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2343 | A: "Mars", 2344 | ManyBs: []*ManyB{ 2345 | { 2346 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2347 | B: "gravel", 2348 | }, 2349 | { 2350 | ID: uuid.MustParse("3fc686ac-7847-405e-b569-df46b9ea78fe"), 2351 | B: "nothing", 2352 | }, 2353 | }, 2354 | }, 2355 | { 2356 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2357 | A: "Pluto", 2358 | ManyBs: []*ManyB{ 2359 | { 2360 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2361 | B: "sand", 2362 | }, 2363 | { 2364 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2365 | B: "ruins", 2366 | }, 2367 | }, 2368 | }, 2369 | }, 2370 | expected: []ManyA{ 2371 | { 2372 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2373 | A: "Pluto", 2374 | ManyBs: []*ManyB{ 2375 | { 2376 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2377 | B: "sand", 2378 | }, 2379 | { 2380 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2381 | B: "ruins", 2382 | }, 2383 | }, 2384 | }, 2385 | }, 2386 | filterMap: []map[string]any{ 2387 | { 2388 | "many_bs": map[string]any{ 2389 | "b": []string{"sand", "ruins"}, 2390 | }, 2391 | }, 2392 | { 2393 | "a": "Pluto", 2394 | }, 2395 | }, 2396 | }, 2397 | "looking for chalk that has apples": { 2398 | records: []*ManyA{ 2399 | { 2400 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2401 | A: "Chalk", 2402 | ManyBs: []*ManyB{ 2403 | { 2404 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2405 | B: "apples", 2406 | }, 2407 | { 2408 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b01499"), 2409 | B: "pears", 2410 | }, 2411 | }, 2412 | }, 2413 | { 2414 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2415 | A: "Chalk", 2416 | ManyBs: []*ManyB{ 2417 | { 2418 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2419 | B: "bananas", 2420 | }, 2421 | { 2422 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2423 | B: "pears", 2424 | }, 2425 | { 2426 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e17"), 2427 | B: "apples", 2428 | }, 2429 | }, 2430 | }, 2431 | }, 2432 | expected: []ManyA{ 2433 | { 2434 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2435 | A: "Chalk", 2436 | ManyBs: []*ManyB{ 2437 | { 2438 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b01499"), 2439 | B: "pears", 2440 | }, 2441 | { 2442 | ID: uuid.MustParse("9f1baf72-6ca5-4d43-8a01-d845575620e1"), 2443 | B: "apples", 2444 | }, 2445 | }, 2446 | }, 2447 | { 2448 | ID: uuid.MustParse("eeb25c63-be10-4d88-b256-255e7f022a9c"), // BObject 2449 | A: "Chalk", 2450 | ManyBs: []*ManyB{ 2451 | { 2452 | ID: uuid.MustParse("3e4fc93a-20b0-4716-809a-d81ec4b11499"), 2453 | B: "pears", 2454 | }, 2455 | { 2456 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e07"), 2457 | B: "bananas", 2458 | }, 2459 | { 2460 | ID: uuid.MustParse("9b87bfed-6820-4234-8cc7-6772cf036e17"), 2461 | B: "apples", 2462 | }, 2463 | }, 2464 | }, 2465 | }, 2466 | filterMap: []map[string]any{ 2467 | { 2468 | "a": "Chalk", 2469 | "many_bs": map[string]any{ 2470 | "b": []string{"apples"}, 2471 | }, 2472 | }, 2473 | { 2474 | "many_bs": map[string]any{ 2475 | "b": []string{"pears"}, 2476 | }, 2477 | }, 2478 | }, 2479 | }, 2480 | } 2481 | 2482 | for name, testData := range tests { 2483 | testData := testData 2484 | t.Run(name, func(t *testing.T) { 2485 | t.Parallel() 2486 | // Arrange 2487 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 2488 | _ = database.AutoMigrate(&ManyA{}, &ManyB{}) 2489 | 2490 | database.CreateInBatches(testData.records, len(testData.records)) 2491 | 2492 | // Act 2493 | query, err := AddDeepFilters(database, ManyA{}, testData.filterMap...) 2494 | 2495 | // Assert 2496 | assert.Nil(t, err) 2497 | 2498 | if assert.NotNil(t, query) { 2499 | var result []ManyA 2500 | res := query.Preload(clause.Associations).Find(&result) 2501 | 2502 | // Handle error 2503 | assert.Nil(t, res.Error) 2504 | 2505 | assert.Len(t, result, len(testData.expected)) 2506 | 2507 | for index, item := range result { 2508 | assert.Equal(t, testData.expected[index].ID, item.ID) 2509 | assert.Equal(t, testData.expected[index].A, item.A) 2510 | 2511 | for deepIndex, deepItem := range item.ManyBs { 2512 | assert.Equal(t, testData.expected[index].ManyBs[deepIndex].ID, deepItem.ID) 2513 | assert.Equal(t, testData.expected[index].ManyBs[deepIndex].B, deepItem.B) 2514 | } 2515 | } 2516 | } 2517 | }) 2518 | } 2519 | } 2520 | 2521 | func TestAddDeepFilters_AddsDeepFiltersWithManyToMany2(t *testing.T) { 2522 | t.Parallel() 2523 | t.Cleanup(cleanupCache) 2524 | type Tag struct { 2525 | ID uuid.UUID 2526 | Key string 2527 | Value string 2528 | } 2529 | 2530 | type Resource struct { 2531 | ID uuid.UUID 2532 | Name string 2533 | Tags []*Tag `gorm:"many2many:resource_tags"` 2534 | } 2535 | 2536 | tests := map[string]struct { 2537 | records []*Resource 2538 | expected []Resource 2539 | filterMap map[string]any 2540 | }{ 2541 | "looking for 1 resource": { 2542 | records: []*Resource{ 2543 | { 2544 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2545 | Name: "TestResource", 2546 | Tags: []*Tag{ 2547 | { 2548 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2549 | Key: "tenant", 2550 | Value: "InfraNL", 2551 | }, 2552 | }, 2553 | }, 2554 | }, 2555 | expected: []Resource{ 2556 | { 2557 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2558 | Name: "TestResource", 2559 | Tags: []*Tag{ 2560 | { 2561 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2562 | Key: "tenant", 2563 | Value: "InfraNL", 2564 | }, 2565 | }, 2566 | }, 2567 | }, 2568 | filterMap: map[string]any{ 2569 | "tags": map[string]any{ 2570 | "key": "tenant", 2571 | "value": "InfraNL", 2572 | }, 2573 | }, 2574 | }, 2575 | "looking for 2 resource(s)": { 2576 | records: []*Resource{ 2577 | { 2578 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2579 | Name: "A", 2580 | Tags: []*Tag{ 2581 | { 2582 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2583 | Key: "tenant", 2584 | Value: "InfraNL", 2585 | }, 2586 | }, 2587 | }, 2588 | { 2589 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), // BObject 2590 | Name: "BObject", 2591 | Tags: []*Tag{ 2592 | { 2593 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be350090"), 2594 | Key: "tenant", 2595 | Value: "OutraNL", 2596 | }, 2597 | }, 2598 | }, 2599 | { 2600 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-020650481688"), // BObject 2601 | Name: "C", 2602 | Tags: []*Tag{ 2603 | { 2604 | ID: uuid.MustParse("0e2cdda8-734d-421f-847a-d5e7be350090"), 2605 | Key: "tenant", 2606 | Value: "OutraBE", 2607 | }, 2608 | }, 2609 | }, 2610 | }, 2611 | expected: []Resource{ 2612 | { 2613 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2614 | Name: "A", 2615 | Tags: []*Tag{ 2616 | { 2617 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2618 | Key: "tenant", 2619 | Value: "InfraNL", 2620 | }, 2621 | }, 2622 | }, 2623 | { 2624 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), // BObject 2625 | Name: "BObject", 2626 | Tags: []*Tag{ 2627 | { 2628 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be350090"), 2629 | Key: "tenant", 2630 | Value: "OutraNL", 2631 | }, 2632 | }, 2633 | }, 2634 | }, 2635 | filterMap: map[string]any{ 2636 | "tags": map[string]any{ 2637 | "key": "tenant", 2638 | "value": []string{"InfraNL", "OutraNL"}, 2639 | }, 2640 | }, 2641 | }, 2642 | } 2643 | 2644 | for name, testData := range tests { 2645 | testData := testData 2646 | t.Run(name, func(t *testing.T) { 2647 | t.Parallel() 2648 | // Arrange 2649 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 2650 | _ = database.AutoMigrate(&Resource{}, &Tag{}) 2651 | 2652 | database.CreateInBatches(testData.records, len(testData.records)) 2653 | 2654 | // Act 2655 | query, err := AddDeepFilters(database, Resource{}, testData.filterMap) 2656 | 2657 | // Assert 2658 | assert.Nil(t, err) 2659 | 2660 | if assert.NotNil(t, query) { 2661 | var result []Resource 2662 | res := query.Preload(clause.Associations).Find(&result) 2663 | 2664 | // Handle error 2665 | assert.Nil(t, res.Error) 2666 | 2667 | assert.EqualValues(t, testData.expected, result) 2668 | } 2669 | }) 2670 | } 2671 | } 2672 | 2673 | func TestAddDeepFilters_AddsDeepFiltersWithManyToMany2OnMultiFilter(t *testing.T) { 2674 | t.Parallel() 2675 | t.Cleanup(cleanupCache) 2676 | type Tag struct { 2677 | ID uuid.UUID 2678 | Key string 2679 | Value string 2680 | } 2681 | 2682 | type Resource struct { 2683 | ID uuid.UUID 2684 | Name string 2685 | Tags []*Tag `gorm:"many2many:resource_tags"` 2686 | } 2687 | 2688 | tests := map[string]struct { 2689 | records []*Resource 2690 | expected []Resource 2691 | filterMap []map[string]any 2692 | }{ 2693 | "looking for 1 resource": { 2694 | records: []*Resource{ 2695 | { 2696 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2697 | Name: "TestResource", 2698 | Tags: []*Tag{ 2699 | { 2700 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2701 | Key: "tenant", 2702 | Value: "InfraNL", 2703 | }, 2704 | { 2705 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359091"), 2706 | Key: "pcode", 2707 | Value: "P02012", 2708 | }, 2709 | }, 2710 | }, 2711 | }, 2712 | expected: []Resource{ 2713 | { 2714 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2715 | Name: "TestResource", 2716 | Tags: []*Tag{ 2717 | { 2718 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2719 | Key: "tenant", 2720 | Value: "InfraNL", 2721 | }, 2722 | { 2723 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359091"), 2724 | Key: "pcode", 2725 | Value: "P02012", 2726 | }, 2727 | }, 2728 | }, 2729 | }, 2730 | filterMap: []map[string]any{ 2731 | { 2732 | "tags": map[string]any{ 2733 | "key": "tenant", 2734 | "value": "InfraNL", 2735 | }, 2736 | }, 2737 | { 2738 | "tags": map[string]any{ 2739 | "key": "pcode", 2740 | "value": "P02012", 2741 | }, 2742 | }, 2743 | }, 2744 | }, 2745 | "looking for 2 resource(s)": { 2746 | records: []*Resource{ 2747 | { 2748 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2749 | Name: "A", 2750 | Tags: []*Tag{ 2751 | { 2752 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2753 | Key: "tenant", 2754 | Value: "InfraNL", 2755 | }, 2756 | { 2757 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359091"), 2758 | Key: "pcode", 2759 | Value: "P02012", 2760 | }, 2761 | }, 2762 | }, 2763 | { 2764 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), // BObject 2765 | Name: "BObject", 2766 | Tags: []*Tag{ 2767 | { 2768 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be350090"), 2769 | Key: "tenant", 2770 | Value: "OutraNL", 2771 | }, 2772 | { 2773 | ID: uuid.MustParse("0e2cdda8-736d-421f-897a-d5e7be359091"), 2774 | Key: "pcode", 2775 | Value: "P02329", 2776 | }, 2777 | }, 2778 | }, 2779 | { 2780 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-020650481688"), // BObject 2781 | Name: "C", 2782 | Tags: []*Tag{ 2783 | { 2784 | ID: uuid.MustParse("0e2cdda8-734d-421f-847a-d5e7be350090"), 2785 | Key: "tenant", 2786 | Value: "OutraBE", 2787 | }, 2788 | { 2789 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359099"), 2790 | Key: "pcode", 2791 | Value: "P02329", 2792 | }, 2793 | }, 2794 | }, 2795 | }, 2796 | expected: []Resource{ 2797 | { 2798 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481687"), // A 2799 | Name: "A", 2800 | Tags: []*Tag{ 2801 | { 2802 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359090"), 2803 | Key: "tenant", 2804 | Value: "InfraNL", 2805 | }, 2806 | { 2807 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be359091"), 2808 | Key: "pcode", 2809 | Value: "P02012", 2810 | }, 2811 | }, 2812 | }, 2813 | { 2814 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), // BObject 2815 | Name: "BObject", 2816 | Tags: []*Tag{ 2817 | { 2818 | ID: uuid.MustParse("0e2cdda8-734d-421f-897a-d5e7be350090"), 2819 | Key: "tenant", 2820 | Value: "OutraNL", 2821 | }, 2822 | { 2823 | ID: uuid.MustParse("0e2cdda8-736d-421f-897a-d5e7be359091"), 2824 | Key: "pcode", 2825 | Value: "P02329", 2826 | }, 2827 | }, 2828 | }, 2829 | }, 2830 | filterMap: []map[string]any{ 2831 | { 2832 | "tags": map[string]any{ 2833 | "key": "tenant", 2834 | "value": []string{"InfraNL", "OutraNL"}, 2835 | }, 2836 | }, 2837 | { 2838 | "tags": map[string]any{ 2839 | "key": "pcode", 2840 | "value": []string{"P02012", "P02329"}, 2841 | }, 2842 | }, 2843 | }, 2844 | }, 2845 | } 2846 | 2847 | for name, testData := range tests { 2848 | testData := testData 2849 | t.Run(name, func(t *testing.T) { 2850 | t.Parallel() 2851 | // Arrange 2852 | database := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 2853 | _ = database.AutoMigrate(&Resource{}, &Tag{}) 2854 | 2855 | database.CreateInBatches(testData.records, len(testData.records)) 2856 | 2857 | // Act 2858 | query, err := AddDeepFilters(database, Resource{}, testData.filterMap...) 2859 | 2860 | // Assert 2861 | assert.Nil(t, err) 2862 | 2863 | if assert.NotNil(t, query) { 2864 | var result []Resource 2865 | res := query.Preload(clause.Associations).Find(&result) 2866 | 2867 | // Handle error 2868 | assert.Nil(t, res.Error) 2869 | 2870 | assert.EqualValues(t, testData.expected, result) 2871 | } 2872 | }) 2873 | } 2874 | } 2875 | 2876 | func cleanupCache() { 2877 | cacheDatabaseMap.Clear() 2878 | schemaCache.Clear() 2879 | } 2880 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | package deepgorm 2 | 3 | import ( 4 | "fmt" 5 | "gorm.io/driver/sqlite" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | func ExampleNew() { 10 | db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) 11 | 12 | _ = db.Use(New()) 13 | } 14 | 15 | // Get all ObjectAs that are connected to ObhectB with Id 50 16 | func ExampleAddDeepFilters() { 17 | type ObjectB struct { 18 | ID int 19 | } 20 | 21 | type ObjectA struct { 22 | ID int 23 | 24 | ObjectB *ObjectB `gorm:"foreignKey:ObjectBID"` 25 | ObjectBID int 26 | } 27 | 28 | db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) 29 | _ = db.Use(New()) 30 | 31 | db.Create(&ObjectA{ 32 | ID: 1, 33 | ObjectB: &ObjectB{ 34 | ID: 50, 35 | }, 36 | }) 37 | 38 | filters := map[string]any{ 39 | "object_b": map[string]any{ 40 | "id": 50, 41 | }, 42 | } 43 | 44 | var result ObjectA 45 | db.Where(filters).Find(&result) 46 | fmt.Println(result) 47 | } 48 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/survivorbat/gorm-deep-filtering 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/google/uuid v1.3.0 7 | github.com/ing-bank/gormtestutil v0.0.0 8 | github.com/stretchr/testify v1.8.1 9 | github.com/survivorbat/go-tsyncmap v0.0.0 10 | gorm.io/driver/sqlite v1.5.2 11 | gorm.io/gorm v1.30.0 12 | ) 13 | 14 | require ( 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/jinzhu/inflection v1.0.0 // indirect 17 | github.com/jinzhu/now v1.1.5 // indirect 18 | github.com/kr/text v0.2.0 // indirect 19 | github.com/mattn/go-sqlite3 v1.14.17 // indirect 20 | github.com/pmezard/go-difflib v1.0.0 // indirect 21 | golang.org/x/text v0.25.0 // indirect 22 | gopkg.in/yaml.v3 v3.0.1 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 6 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/ing-bank/gormtestutil v0.0.0 h1:8XfpDUiqTXjRk9eBgdYZymtXYWRSqpVpHV2Pb6dQ5Es= 8 | github.com/ing-bank/gormtestutil v0.0.0/go.mod h1:8fuPIQW304AMBmeBO3LGgrwBGPOCdn3WFVO4fOu0+dA= 9 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 10 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 11 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 12 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 13 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 14 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 15 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 16 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 17 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= 18 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 19 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 20 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 21 | github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= 22 | github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= 23 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 24 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 25 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 26 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 27 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 28 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 29 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 30 | github.com/survivorbat/go-tsyncmap v0.0.0 h1:XTc1+uXyuw//1Hhpg4IxW6tEe3Tvd2d5vM/6IPqmkeg= 31 | github.com/survivorbat/go-tsyncmap v0.0.0/go.mod h1:zKe2CuXEo+c1d9DVT5L7AG2jPTdWi7QQN/Gk+26Vecg= 32 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 33 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 36 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 37 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 38 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 39 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 40 | gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc= 41 | gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= 42 | gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= 43 | gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= 44 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package deepgorm 2 | 3 | import ( 4 | "reflect" 5 | 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/clause" 8 | ) 9 | 10 | // Compile-time interface check 11 | var _ gorm.Plugin = new(deepGorm) 12 | 13 | // New creates a new instance of the plugin that can be registered in gorm. 14 | func New() gorm.Plugin { 15 | return &deepGorm{} 16 | } 17 | 18 | type deepGorm struct { 19 | } 20 | 21 | func (d *deepGorm) Name() string { 22 | return "deepgorm" 23 | } 24 | 25 | func (d *deepGorm) Initialize(db *gorm.DB) error { 26 | return db.Callback().Query().Before("gorm:query").Register("deepgorm:query", queryCallback) 27 | } 28 | 29 | func queryCallback(db *gorm.DB) { 30 | exp, ok := db.Statement.Clauses["WHERE"].Expression.(clause.Where) 31 | if !ok { 32 | return 33 | } 34 | 35 | createDeepFilterRecursively(exp.Exprs, db) 36 | } 37 | 38 | func createDeepFilterRecursively(exprs []clause.Expression, db *gorm.DB) { 39 | for index, cond := range exprs { 40 | switch cond := cond.(type) { 41 | case clause.AndConditions: 42 | createDeepFilterRecursively(exprs[index].(clause.AndConditions).Exprs, db) 43 | case clause.OrConditions: 44 | createDeepFilterRecursively(exprs[index].(clause.OrConditions).Exprs, db) 45 | case clause.Eq: 46 | switch value := cond.Value.(type) { 47 | case map[string]any: 48 | concreteType := ensureNotASlice(reflect.TypeOf(db.Statement.Model)) 49 | inputObject := ensureConcrete(reflect.New(concreteType)).Interface() 50 | 51 | column := cond.Column.(clause.Column) 52 | columnName := column.Name 53 | 54 | applied, err := AddDeepFilters(db.Session(&gorm.Session{NewDB: true}), inputObject, map[string]any{columnName: value}) 55 | 56 | if err != nil { 57 | _ = db.AddError(err) 58 | return 59 | } 60 | 61 | // Replace the map filter with the newly created deep-filter 62 | exprs[index] = applied.Statement.Clauses["WHERE"].Expression.(clause.Where).Exprs[0] 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /plugin_test.go: -------------------------------------------------------------------------------- 1 | package deepgorm 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/uuid" 7 | "github.com/ing-bank/gormtestutil" 8 | "github.com/stretchr/testify/assert" 9 | "gorm.io/gorm/clause" 10 | ) 11 | 12 | func TestDeepGorm_Name_ReturnsExpectedName(t *testing.T) { 13 | t.Parallel() 14 | // Arrange 15 | plugin := New() 16 | 17 | // Act 18 | result := plugin.Name() 19 | 20 | // Assert 21 | assert.Equal(t, "deepgorm", result) 22 | } 23 | 24 | func TestDeepGorm_Initialize_RegistersCallback(t *testing.T) { 25 | t.Parallel() 26 | // Arrange 27 | db := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 28 | plugin := New() 29 | 30 | // Act 31 | err := plugin.Initialize(db) 32 | 33 | // Assert 34 | assert.Nil(t, err) 35 | assert.NotNil(t, db.Callback().Query().Get("deepgorm:query")) 36 | } 37 | 38 | type ObjectB struct { 39 | ID uuid.UUID 40 | Name string 41 | 42 | ObjectA *ObjectA `gorm:"foreignKey:ObjectAID"` 43 | ObjectAID uuid.UUID 44 | } 45 | 46 | type ObjectA struct { 47 | ID uuid.UUID 48 | Name string 49 | 50 | ObjectBs []ObjectB `gorm:"foreignKey:ObjectAID"` 51 | } 52 | 53 | func TestDeepGorm_Initialize_TriggersFilteringCorrectly(t *testing.T) { 54 | t.Parallel() 55 | 56 | tests := map[string]struct { 57 | filter map[string]any 58 | existing []ObjectA 59 | expected []ObjectA 60 | }{ 61 | "nothing": { 62 | expected: []ObjectA{}, 63 | }, 64 | "no filter": { 65 | filter: map[string]any{}, 66 | existing: []ObjectA{ 67 | {ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688")}, 68 | {ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460")}, 69 | {ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d")}, 70 | }, 71 | expected: []ObjectA{ 72 | {ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), ObjectBs: []ObjectB{}}, 73 | {ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), ObjectBs: []ObjectB{}}, 74 | {ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), ObjectBs: []ObjectB{}}, 75 | }, 76 | }, 77 | "simple filter": { 78 | filter: map[string]any{ 79 | "id": uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 80 | }, 81 | existing: []ObjectA{ 82 | {ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688")}, 83 | {ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460")}, 84 | {ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d")}, 85 | }, 86 | expected: []ObjectA{ 87 | {ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), ObjectBs: []ObjectB{}}, 88 | }, 89 | }, 90 | "deep filter": { 91 | filter: map[string]any{ 92 | "object_bs": map[string]any{ 93 | "name": "abc", 94 | }, 95 | }, 96 | existing: []ObjectA{ 97 | { 98 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 99 | ObjectBs: []ObjectB{ 100 | { 101 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 102 | Name: "def", 103 | }, 104 | }, 105 | }, 106 | { 107 | ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 108 | ObjectBs: []ObjectB{ 109 | { 110 | ID: uuid.MustParse("83aaf47d-a167-4a49-8b7c-3516ced56e8a"), 111 | Name: "abc", 112 | }, 113 | }, 114 | }, 115 | { 116 | ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), 117 | ObjectBs: []ObjectB{ 118 | { 119 | ID: uuid.MustParse("3b35e207-c544-424e-b029-be31d5fe8bad"), 120 | Name: "abc", 121 | }, 122 | }, 123 | }, 124 | }, 125 | expected: []ObjectA{ 126 | { 127 | ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 128 | ObjectBs: []ObjectB{ 129 | { 130 | ID: uuid.MustParse("83aaf47d-a167-4a49-8b7c-3516ced56e8a"), 131 | Name: "abc", 132 | ObjectAID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 133 | }, 134 | }, 135 | }, 136 | { 137 | ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), 138 | ObjectBs: []ObjectB{ 139 | { 140 | ID: uuid.MustParse("3b35e207-c544-424e-b029-be31d5fe8bad"), 141 | Name: "abc", 142 | ObjectAID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), 143 | }, 144 | }, 145 | }, 146 | }, 147 | }, 148 | "multi filter": { 149 | filter: map[string]any{ 150 | "name": "ghi", 151 | "object_bs": map[string]any{ 152 | "name": "def", 153 | }, 154 | }, 155 | existing: []ObjectA{ 156 | { 157 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 158 | Name: "ghi", 159 | ObjectBs: []ObjectB{ 160 | { 161 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 162 | Name: "def", 163 | }, 164 | }, 165 | }, 166 | { 167 | ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 168 | Name: "nope", 169 | ObjectBs: []ObjectB{ 170 | { 171 | ID: uuid.MustParse("83aaf47d-a167-4a49-8b7c-3516ced56e8a"), 172 | Name: "abc", 173 | }, 174 | }, 175 | }, 176 | { 177 | ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), 178 | Name: "Maybe", 179 | ObjectBs: []ObjectB{ 180 | { 181 | ID: uuid.MustParse("3b35e207-c544-424e-b029-be31d5fe8bad"), 182 | Name: "abc", 183 | }, 184 | }, 185 | }, 186 | }, 187 | expected: []ObjectA{ 188 | { 189 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 190 | Name: "ghi", 191 | ObjectBs: []ObjectB{ 192 | { 193 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 194 | Name: "def", 195 | ObjectAID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 196 | }, 197 | }, 198 | }, 199 | }, 200 | }, 201 | } 202 | 203 | for name, testData := range tests { 204 | testData := testData 205 | t.Run(name, func(t *testing.T) { 206 | t.Parallel() 207 | // Arrange 208 | db := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 209 | _ = db.AutoMigrate(&ObjectA{}, &ObjectB{}) 210 | plugin := New() 211 | 212 | if err := db.CreateInBatches(testData.existing, 10).Error; err != nil { 213 | t.Error(err) 214 | t.FailNow() 215 | } 216 | 217 | // Act 218 | err := db.Use(plugin) 219 | 220 | // Assert 221 | assert.Nil(t, err) 222 | 223 | var actual []ObjectA 224 | err = db.Where(testData.filter).Preload(clause.Associations).Find(&actual).Error 225 | assert.Nil(t, err) 226 | 227 | assert.Equal(t, testData.expected, actual) 228 | }) 229 | } 230 | } 231 | 232 | func TestDeepGorm_Initialize_TriggersFilteringCorrectlyWithOrQuery(t *testing.T) { 233 | t.Parallel() 234 | filter1 := map[string]any{ 235 | "object_bs": map[string]any{ 236 | "name": "def", 237 | }, 238 | } 239 | filter2 := map[string]any{ 240 | "object_bs": map[string]any{ 241 | "name": "abc", 242 | }, 243 | } 244 | existing := []ObjectA{ 245 | { 246 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 247 | Name: "ghi", 248 | ObjectBs: []ObjectB{ 249 | { 250 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 251 | Name: "def", 252 | }, 253 | }, 254 | }, 255 | { 256 | ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 257 | Name: "nope", 258 | ObjectBs: []ObjectB{ 259 | { 260 | ID: uuid.MustParse("83aaf47d-a167-4a49-8b7c-3516ced56e8a"), 261 | Name: "abc", 262 | }, 263 | }, 264 | }, 265 | { 266 | ID: uuid.MustParse("383e9a9b-ef95-421d-a89e-60f0344ee29d"), 267 | Name: "Maybe", 268 | ObjectBs: []ObjectB{ 269 | { 270 | ID: uuid.MustParse("3b35e207-c544-424e-b029-be31d5fe8bad"), 271 | Name: "cba", 272 | }, 273 | }, 274 | }, 275 | } 276 | expected := []ObjectA{ 277 | { 278 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 279 | Name: "ghi", 280 | ObjectBs: []ObjectB{ 281 | { 282 | ID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 283 | Name: "def", 284 | ObjectAID: uuid.MustParse("59aa5a8f-c5de-44fa-9355-080650481688"), 285 | }, 286 | }, 287 | }, 288 | { 289 | ID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 290 | Name: "nope", 291 | ObjectBs: []ObjectB{ 292 | { 293 | ID: uuid.MustParse("83aaf47d-a167-4a49-8b7c-3516ced56e8a"), 294 | Name: "abc", 295 | ObjectAID: uuid.MustParse("3415d786-bc03-4543-aa3c-5ec9e55aa460"), 296 | }, 297 | }, 298 | }, 299 | } 300 | 301 | db := gormtestutil.NewMemoryDatabase(t, gormtestutil.WithName(t.Name())) 302 | _ = db.AutoMigrate(&ObjectA{}, &ObjectB{}) 303 | plugin := New() 304 | 305 | if err := db.CreateInBatches(existing, 10).Error; err != nil { 306 | t.Error(err) 307 | t.FailNow() 308 | } 309 | 310 | // Act 311 | err := db.Use(plugin) 312 | 313 | // Assert 314 | assert.Nil(t, err) 315 | 316 | var actual []ObjectA 317 | err = db.Where(filter1).Or(filter2).Preload(clause.Associations).Find(&actual).Error 318 | assert.Nil(t, err) 319 | 320 | assert.Equal(t, expected, actual) 321 | } 322 | --------------------------------------------------------------------------------