diff --git a/docs/content/docs/3-repositories.md b/docs/content/docs/3-repositories.md index 48b4302..50f3c16 100644 --- a/docs/content/docs/3-repositories.md +++ b/docs/content/docs/3-repositories.md @@ -30,7 +30,7 @@ the name of the function field and **automatically generates the GORM query code A query method name must follow a strict grammatical structure: `[Prefix][Subject]By[Conditions]OrderBy[SortRules]` -1. **Prefix:** Must be `find`, `Find`, `query`, or `Query`. +1. **Prefix:** Must be `find`, `Find`, `query`, `Query`, `list`, `List`, `stream`, or `Stream`. 2. **Subject:** (Optional) Capitalized words describing what is being found (e.g., `ActiveUsers`). 3. **Separator:** Must include `By`. 4. **Conditions:** Field names followed by an optional Operator, separated by `And` or `Or`. @@ -69,7 +69,7 @@ entirely protected against SQL injection attacks by default. Your generated function can define the following return signatures: * **Single Entity:** `*T` or `Optional[*T]` -* **Multiple Entities:** `[]*T` +* **Multiple Entities:** `[]T` or `Stream[T]` (Java 8-style lazy Stream from `github.com/BeamFoundry/rays/pkg/util/stream`) * **Query Builder:** `*gorm.DB` (Returns the query before mapping to an object) * **Error Handling:** You may append `Error` as a second return parameter to any of the above to catch database faults. diff --git a/go.mod b/go.mod index cdc5929..5285e41 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/BeamFoundry/rays-orm go 1.27 require ( - github.com/BeamFoundry/rays v0.2.2 + github.com/BeamFoundry/rays v0.3.0 github.com/onsi/ginkgo/v2 v2.33.1 github.com/onsi/gomega v1.44.0 github.com/orandin/slog-gorm v1.4.0 diff --git a/go.sum b/go.sum index 24bdac6..f613787 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/BeamFoundry/rays v0.2.2 h1:jgh5fGon3pRa1RedXQZil3r1FWfY0YzhLx728sNdLaY= -github.com/BeamFoundry/rays v0.2.2/go.mod h1:5wiQwphroNUY4tWjLrKjoV4B/pHb4ldh8XUFSFnzvQo= +github.com/BeamFoundry/rays v0.3.0 h1:cNAkHcIRfBbHEj9ZhJl0VOw5cft8jdA+y8R5h1/qGUc= +github.com/BeamFoundry/rays v0.3.0/go.mod h1:5wiQwphroNUY4tWjLrKjoV4B/pHb4ldh8XUFSFnzvQo= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/pkg/core/core_suite_test.go b/pkg/core/core_suite_test.go index ce854df..b7c4247 100644 --- a/pkg/core/core_suite_test.go +++ b/pkg/core/core_suite_test.go @@ -17,6 +17,7 @@ import ( . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" . "github.com/BeamFoundry/rays/pkg/testing" + . "github.com/BeamFoundry/rays/pkg/util/stream" ) func TestOrm(t *testing.T) { @@ -69,6 +70,9 @@ type UserRepositoryInterface interface { // FindByUserNameWithAnimals(string) *User // FindByUserNameWithAnimalsOrderByName(string) *User FindAllByName(string) []User + FindStreamByName(string) Stream[User] + StreamAllByName(string) Stream[User] + FindStreamWithErrorByName(string) (Stream[User], Error) FindOneByName(string) Optional[*User] FindAllByAgeIn([]uint) []User FindAllByUserNameAndAge(string, uint) []User @@ -88,6 +92,9 @@ type UserRepository struct { // findByUserNameWithAnimals func(string) *User // findByUserNameWithAnimalsOrderByName func(string) *User findAllByName func(string) []User + findStreamByName func(string) Stream[User] + streamAllByName func(string) Stream[User] + findStreamWithErrorByName func(string) (Stream[User], Error) findOneByName func(string) Optional[*User] findAllByAgeIn func([]uint) []User findAllByUserNameAndAge func(string, uint) []User @@ -120,6 +127,7 @@ type UserRepository struct { findBadPointerByName func(string) *Foo findBadStructByName func(string) Foo findBadSliceByName func(string) []Foo + findBadStreamByName func(string) Stream[Foo] findBadElemByName func(string) string // findDistinctByName func(string) []User findByNameOrderByFoo func(string) []User @@ -142,6 +150,18 @@ func (this *UserRepository) FindWithErrorByUserName(name string) (*User, Error) return this.findWithErrorByUserName(name) } +func (this *UserRepository) FindStreamByName(name string) Stream[User] { + return this.findStreamByName(name) +} + +func (this *UserRepository) StreamAllByName(name string) Stream[User] { + return this.streamAllByName(name) +} + +func (this *UserRepository) FindStreamWithErrorByName(name string) (Stream[User], Error) { + return this.findStreamWithErrorByName(name) +} + /* func (this *UserRepository) FindByUserNameWithAnimals(name string) *User { return this.findByUserNameWithAnimals(name) @@ -249,6 +269,42 @@ var _ = Describe("UserRepository", func() { Expect(u.Name).To(Equal("Dorothy")) }) + It("can find many records returning a Stream", func() { + u := []*User{ + {Name: "Dorothy", UserName: "dorothy2", Age: 30}, + {Name: "Dorothy", UserName: "dorothy3", Age: 40}, + } + _, err := userRepository.SaveAll(u) + Expect(err).NotTo(HaveOccurred()) + + st := userRepository.FindStreamByName("Dorothy") + names := st.Filter(func(user User) bool { return user.Age >= 30 }). + SortedBy(func(user User) int { return int(user.Age) }). + Map(func(user User) string { return user.UserName }). + ToSlice() + Expect(names).To(Equal([]string{"dorothy2", "dorothy3"})) + }) + + It("can use Stream prefix to return a Stream", func() { + st := userRepository.StreamAllByName("Dorothy") + count := st.Count() + Expect(count).To(BeNumerically("==", 1)) + }) + + It("can return a Stream with Error", func() { + st, err := userRepository.FindStreamWithErrorByName("Dorothy") + Expect(err).NotTo(HaveOccurred()) + Expect(st.Count()).To(BeNumerically("==", 1)) + + stEmpty, err := userRepository.FindStreamWithErrorByName("NonExistent") + Expect(err).NotTo(HaveOccurred()) + Expect(stEmpty.Count()).To(BeNumerically("==", 0)) + }) + + It("rejects invalid Stream return type with mismatched entity", func() { + Expect(userRepository.findBadStreamByName).To(BeNil()) + }) + It("can find optionally a record", func() { optional := userRepository.FindOneByName("Dorothy") u, ok := optional.Get() diff --git a/pkg/core/query_builder.go b/pkg/core/query_builder.go index dd0f0d7..f0b3ecc 100644 --- a/pkg/core/query_builder.go +++ b/pkg/core/query_builder.go @@ -9,6 +9,7 @@ import ( . "github.com/BeamFoundry/rays/pkg/core" . "github.com/BeamFoundry/rays/pkg/lang" . "github.com/BeamFoundry/rays/pkg/util" + . "github.com/BeamFoundry/rays/pkg/util/stream" "gorm.io/gorm" ) @@ -118,8 +119,8 @@ func (this *gormQueryBuilder[T, ID]) Build(function *reflect.StructField) (*gorm name := CaseUtils(function.Name).Capitalize().String() // 1. Verify Prefix - if !String(name).StartsWith("Find") && !String(name).StartsWith("Query") && !String(name).StartsWith("List") { - return nil, NewError("IllegalArgumentError", "Function name must start with Find, Query, find, or query") + if !String(name).StartsWith("Find") && !String(name).StartsWith("Query") && !String(name).StartsWith("List") && !String(name).StartsWith("Stream") { + return nil, NewError("IllegalArgumentError", "Function name must start with Find, Query, List, or Stream") } // 2. Find the "By" separator @@ -233,9 +234,10 @@ func (this *gormQuery[T, ID]) validate() Error { sliceTType := reflect.SliceOf(entity) optionalTType := reflect.TypeFor[Optional[*T]]() gormType := reflect.TypeFor[*gorm.DB]() + streamTType := reflect.TypeFor[Stream[T]]() - if retType != ptrTType && retType != sliceTType && retType != optionalTType && retType != gormType { - return NewError("IllegalArgumentError", "Invalid return type; must be *T, []T, Optional[*T] or *gorm.DB") + if retType != ptrTType && retType != sliceTType && retType != optionalTType && retType != gormType && retType != streamTType { + return NewError("IllegalArgumentError", "Invalid return type; must be *T, []T, Optional[*T], Stream[T] or *gorm.DB") } this.logger.Info("Created repository function", "entity", entity, "function", this.function.Name) @@ -247,6 +249,7 @@ func (this *gormQuery[T, ID]) Query(tx GormTransactional) func(in []reflect.Valu one := output == reflect.TypeFor[*T]() multi := output.Kind() == reflect.Slice optional := output == reflect.TypeFor[Optional[*T]]() + stream := output == reflect.TypeFor[Stream[T]]() var hasError bool if this.function.Type.NumOut() > 1 && this.function.Type.Out(1) == reflect.TypeFor[Error]() { @@ -292,11 +295,17 @@ func (this *gormQuery[T, ID]) Query(tx GormTransactional) func(in []reflect.Valu ret := []reflect.Value{} var res *gorm.DB - if multi { + switch { + case multi: var t []T res = db.Find(&t) ret = append(ret, reflect.ValueOf(t)) - } else { + case stream: + var t []T + res = db.Find(&t) + s := Stream[T]{}.FromSlice(t) + ret = append(ret, reflect.ValueOf(s)) + default: var t T res = db.First(&t)