├── .github ├── .releaserc.yml ├── renovate.json └── workflows │ ├── pages.yml │ └── ci.yml ├── go.mod ├── .vscode └── launch.json ├── .gitignore ├── internal ├── repository │ ├── sqlite_test.go │ ├── base_test.go │ ├── sqlite.go │ ├── postgres.go │ ├── mssql.go │ ├── mysql.go │ └── base.go ├── schema │ ├── optional.go │ ├── order.go │ └── where.go └── service │ ├── put_bulk.go │ ├── post_bulk.go │ ├── post_single.go │ ├── delete_bulk.go │ ├── delete_single.go │ ├── get_single.go │ ├── get_bulk.go │ ├── base.go │ └── put_single.go ├── example ├── mysql │ └── main.go ├── sqlite │ └── main.go ├── mssql │ └── main.go ├── postgres │ └── main.go ├── go.mod ├── patch │ └── main.go ├── modes │ └── main.go ├── operations │ └── main.go ├── relations │ └── main.go ├── hooks │ └── main.go ├── auth │ └── main.go └── go.sum ├── LICENSE.md ├── mkdocs.yml ├── go.sum ├── gocrud_test.go ├── docs ├── index.md ├── icon.svg ├── getting-started.md ├── advanced-topics.md ├── FAQ.md ├── configuration.md ├── crud-operations.md └── crud-hooks.md ├── README.md ├── gocrud.go └── CHANGELOG.md /.github/.releaserc.yml: -------------------------------------------------------------------------------- 1 | tagFormat: v${version} 2 | branches: 3 | - main 4 | plugins: 5 | - "@semantic-release/commit-analyzer" 6 | - "@semantic-release/release-notes-generator" 7 | - "@semantic-release/changelog" 8 | - "@semantic-release/github" 9 | - - "@semantic-release/git" 10 | - assets: [CHANGELOG.md] 11 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "dependencyDashboard": true, 5 | "enabledManagers": ["gomod", "github-actions"], 6 | "packageRules": [ 7 | { 8 | "matchPackageNames": ["**/gocrud"], 9 | "enabled": false 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ckoliber/gocrud 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/danielgtaylor/huma/v2 v2.32.0 7 | github.com/mattn/go-sqlite3 v1.14.28 8 | github.com/stretchr/testify v1.10.0 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Package", 9 | "type": "go", 10 | "request": "launch", 11 | "mode": "auto", 12 | "program": "${fileDirname}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | -------------------------------------------------------------------------------- /internal/repository/sqlite_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | 8 | _ "github.com/mattn/go-sqlite3" 9 | ) 10 | 11 | func TestSQLiteRepository(t *testing.T) { 12 | db, err := sql.Open("sqlite3", ":memory:?cache=shared") 13 | if err != nil { 14 | panic(err) 15 | } 16 | defer db.Close() 17 | 18 | _, err = db.Exec(` 19 | CREATE TABLE users ( 20 | id INTEGER PRIMARY KEY AUTOINCREMENT, 21 | name TEXT, 22 | age INTEGER 23 | ) 24 | `) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | repo := NewSQLiteRepository[User](db) 30 | 31 | UnitTests(context.Background(), t, repo) 32 | } 33 | -------------------------------------------------------------------------------- /internal/schema/optional.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/danielgtaylor/huma/v2" 7 | ) 8 | 9 | type Optional[Type any] struct { 10 | Value Type 11 | IsSet bool 12 | } 13 | 14 | // Define schema to use wrapped type 15 | func (o *Optional[Type]) Schema(r huma.Registry) *huma.Schema { 16 | return huma.SchemaFromType(r, reflect.TypeOf(o.Value)) 17 | } 18 | 19 | // Expose wrapped value to receive parsed value from Huma 20 | func (o *Optional[Type]) Receiver() reflect.Value { 21 | return reflect.ValueOf(o).Elem().Field(0) 22 | } 23 | 24 | // React to request param being parsed to update internal state 25 | func (o *Optional[Type]) OnParamSet(isSet bool, parsed any) { 26 | o.IsSet = isSet 27 | } 28 | 29 | // Get the value of the wrapped type 30 | func (o *Optional[Type]) Addr() *Type { 31 | return &o.Value 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Pages Pipeline 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: [docs/**/*, mkdocs.yml] 7 | 8 | jobs: 9 | pages: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: 3.x 18 | 19 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 20 | - uses: actions/cache@v4 21 | with: 22 | key: mkdocs-material-${{ env.cache_id }} 23 | path: .cache 24 | restore-keys: | 25 | mkdocs-material- 26 | 27 | - run: pip install mkdocs-material 28 | - run: mkdocs gh-deploy --force 29 | -------------------------------------------------------------------------------- /example/mysql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/go-sql-driver/mysql" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func main() { 24 | mux := http.NewServeMux() 25 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 26 | 27 | api.UseMiddleware() 28 | 29 | db, err := sql.Open("mysql", "root:password@/default") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 35 | 36 | fmt.Printf("Starting server on port 8888...\n") 37 | http.ListenAndServe(":8888", mux) 38 | } 39 | -------------------------------------------------------------------------------- /example/sqlite/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/mattn/go-sqlite3" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func main() { 24 | mux := http.NewServeMux() 25 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 26 | 27 | api.UseMiddleware() 28 | 29 | db, err := sql.Open("sqlite3", "file:locked.sqlite?cache=shared") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 35 | 36 | fmt.Printf("Starting server on port 8888...\n") 37 | http.ListenAndServe(":8888", mux) 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 KoLiBer 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 | -------------------------------------------------------------------------------- /example/mssql/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/microsoft/go-mssqldb" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func main() { 24 | mux := http.NewServeMux() 25 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 26 | 27 | api.UseMiddleware() 28 | 29 | db, err := sql.Open("sqlserver", "sqlserver://sa:P@ssw0rd@localhost:1433?database=master&encrypt=disable") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 35 | 36 | fmt.Printf("Starting server on port 8888...\n") 37 | http.ListenAndServe(":8888", mux) 38 | } 39 | -------------------------------------------------------------------------------- /example/postgres/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/lib/pq" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func main() { 24 | mux := http.NewServeMux() 25 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 26 | 27 | api.UseMiddleware() 28 | 29 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 35 | 36 | fmt.Printf("Starting server on port 8888...\n") 37 | http.ListenAndServe(":8888", mux) 38 | } 39 | -------------------------------------------------------------------------------- /example/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ckoliber/gocrud/examples 2 | 3 | go 1.24.2 4 | 5 | replace github.com/ckoliber/gocrud => ../ 6 | 7 | require ( 8 | github.com/ckoliber/gocrud v0.0.0 9 | github.com/danielgtaylor/huma/v2 v2.32.0 10 | github.com/go-sql-driver/mysql v1.9.2 11 | github.com/lib/pq v1.10.9 12 | github.com/mattn/go-sqlite3 v1.14.28 13 | github.com/microsoft/go-mssqldb v1.8.0 14 | ) 15 | 16 | require ( 17 | filippo.io/edwards25519 v1.1.0 // indirect 18 | github.com/danielgtaylor/mexpr v1.9.0 // indirect 19 | github.com/danielgtaylor/shorthand/v2 v2.2.0 // indirect 20 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 21 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 22 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect 23 | github.com/golang-sql/sqlexp v0.1.0 // indirect 24 | github.com/google/uuid v1.6.0 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/pkg/errors v0.9.1 // indirect 27 | github.com/spf13/cobra v1.8.1 // indirect 28 | github.com/spf13/pflag v1.0.5 // indirect 29 | github.com/x448/float16 v0.8.4 // indirect 30 | golang.org/x/crypto v0.31.0 // indirect 31 | golang.org/x/text v0.21.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /example/patch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | "github.com/danielgtaylor/huma/v2/autopatch" 13 | 14 | _ "github.com/lib/pq" 15 | ) 16 | 17 | type User struct { 18 | _ struct{} `db:"users" json:"-"` 19 | ID *int `db:"id" json:"id" required:"false"` 20 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 21 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 22 | } 23 | 24 | func main() { 25 | mux := http.NewServeMux() 26 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 27 | 28 | api.UseMiddleware() 29 | 30 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 31 | if err != nil { 32 | fmt.Println(err) 33 | } 34 | 35 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 36 | autopatch.AutoPatch(api) 37 | 38 | fmt.Printf("Starting server on port 8888...\n") 39 | http.ListenAndServe(":8888", mux) 40 | } 41 | -------------------------------------------------------------------------------- /example/modes/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/lib/pq" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func main() { 24 | mux := http.NewServeMux() 25 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 26 | 27 | api.UseMiddleware() 28 | 29 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 30 | if err != nil { 31 | fmt.Println(err) 32 | } 33 | 34 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{ 35 | GetMode: gocrud.BulkSingle, 36 | PutMode: gocrud.Single, 37 | PostMode: gocrud.None, 38 | DeleteMode: gocrud.None, 39 | }) 40 | 41 | fmt.Printf("Starting server on port 8888...\n") 42 | http.ListenAndServe(":8888", mux) 43 | } 44 | -------------------------------------------------------------------------------- /internal/service/put_bulk.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type PutBulkInput[Model any] struct { 9 | Body []Model 10 | } 11 | type PutBulkOutput[Model any] struct { 12 | Body []Model 13 | } 14 | 15 | // PutBulk updates multiple resources 16 | func (s *CRUDService[Model]) PutBulk(ctx context.Context, i *PutBulkInput[Model]) (*PutBulkOutput[Model], error) { 17 | slog.Debug("Executing PutBulk operation", slog.Any("input", i)) 18 | 19 | // Execute BeforePut hook if defined 20 | if s.hooks.BeforePut != nil { 21 | if err := s.hooks.BeforePut(ctx, &i.Body); err != nil { 22 | slog.Error("BeforePut hook failed", slog.Any("error", err)) 23 | return nil, err 24 | } 25 | } 26 | 27 | // Update the resources in the repository 28 | result, err := s.repo.Put(ctx, &i.Body) 29 | if err != nil { 30 | slog.Error("Failed to update resources in PutBulk", slog.Any("error", err)) 31 | return nil, err 32 | } 33 | 34 | // Execute AfterPut hook if defined 35 | if s.hooks.AfterPut != nil { 36 | if err := s.hooks.AfterPut(ctx, &result); err != nil { 37 | slog.Error("AfterPut hook failed", slog.Any("error", err)) 38 | return nil, err 39 | } 40 | } 41 | 42 | slog.Debug("Successfully executed PutBulk operation", slog.Any("result", result)) 43 | return &PutBulkOutput[Model]{ 44 | Body: result, 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/service/post_bulk.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | ) 7 | 8 | type PostBulkInput[Model any] struct { 9 | Body []Model 10 | } 11 | type PostBulkOutput[Model any] struct { 12 | Body []Model 13 | } 14 | 15 | // PostBulk creates multiple resources 16 | func (s *CRUDService[Model]) PostBulk(ctx context.Context, i *PostBulkInput[Model]) (*PostBulkOutput[Model], error) { 17 | slog.Debug("Executing PostBulk operation", slog.Any("input", i)) 18 | 19 | // Execute BeforePost hook if defined 20 | if s.hooks.BeforePost != nil { 21 | if err := s.hooks.BeforePost(ctx, &i.Body); err != nil { 22 | slog.Error("BeforePost hook failed", slog.Any("error", err)) 23 | return nil, err 24 | } 25 | } 26 | 27 | // Create resources in the repository 28 | result, err := s.repo.Post(ctx, &i.Body) 29 | if err != nil { 30 | slog.Error("Failed to create resources in PostBulk", slog.Any("error", err)) 31 | return nil, err 32 | } 33 | 34 | // Execute AfterPost hook if defined 35 | if s.hooks.AfterPost != nil { 36 | if err := s.hooks.AfterPost(ctx, &result); err != nil { 37 | slog.Error("AfterPost hook failed", slog.Any("error", err)) 38 | return nil, err 39 | } 40 | } 41 | 42 | slog.Debug("Successfully executed PostBulk operation", slog.Any("result", result)) 43 | return &PostBulkOutput[Model]{ 44 | Body: result, 45 | }, nil 46 | } 47 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: GoCRUD 2 | site_url: https://ckoliber.dev/gocrud 3 | copyright: Copyright © 2025 KoLiBer. 4 | repo_name: ckoliber/gocrud 5 | repo_url: https://github.com/ckoliber/gocrud 6 | edit_uri: edit/main/docs/ 7 | theme: 8 | name: material 9 | logo: icon.svg 10 | palette: 11 | - media: "(prefers-color-scheme: light)" 12 | scheme: default 13 | toggle: 14 | icon: material/brightness-7 15 | name: Switch to dark mode 16 | - media: "(prefers-color-scheme: dark)" 17 | scheme: slate 18 | toggle: 19 | icon: material/brightness-4 20 | name: Switch to system preference 21 | markdown_extensions: 22 | - admonition 23 | - pymdownx.keys 24 | - pymdownx.emoji 25 | - pymdownx.tabbed 26 | - pymdownx.details 27 | - pymdownx.snippets 28 | - pymdownx.tasklist 29 | - pymdownx.inlinehilite 30 | - pymdownx.superfences 31 | - pymdownx.highlight: 32 | anchor_linenums: true 33 | line_spans: __span 34 | pygments_lang_class: true 35 | nav: 36 | - Introduction: index.md 37 | - Getting Started: getting-started.md 38 | - Configuration: configuration.md 39 | - CRUD Operations: crud-operations.md 40 | - CRUD Hooks: crud-hooks.md 41 | - Advanced Topics: advanced-topics.md 42 | - FAQ: FAQ.md 43 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Pipeline 2 | 3 | on: 4 | push: 5 | tags: ["*"] 6 | branches: [main] 7 | pull_request: 8 | branches: [main] 9 | workflow_dispatch: 10 | inputs: {} 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | attestations: write 19 | id-token: write 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Golang 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: "1.24" 28 | 29 | - run: go get 30 | - run: go build 31 | - run: go test ./... 32 | 33 | release: 34 | needs: build 35 | runs-on: ubuntu-latest 36 | if: github.ref_protected && !startsWith(github.event.head_commit.message, 'chore') 37 | steps: 38 | - name: Checkout repository 39 | uses: actions/checkout@v4 40 | with: 41 | persist-credentials: false 42 | 43 | - name: Semantic Release 44 | run: cp .github/.releaserc.yml . 45 | - uses: cycjimmy/semantic-release-action@v4 46 | with: 47 | extra_plugins: "@semantic-release/exec" 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | GITHUB_API: ${{ github.api_url }} 51 | -------------------------------------------------------------------------------- /example/operations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/lib/pq" 14 | ) 15 | 16 | type ID int 17 | 18 | func (_ *ID) Operations() map[string]func(string, ...string) string { 19 | return map[string]func(string, ...string) string{ 20 | "_regexp": func(key string, values ...string) string { 21 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 22 | }, 23 | "_iregexp": func(key string, values ...string) string { 24 | return fmt.Sprintf("%s IREGEXP %s", key, values[0]) 25 | }, 26 | } 27 | } 28 | 29 | type User struct { 30 | _ struct{} `db:"users" json:"-"` 31 | ID *ID `db:"id" json:"id" required:"false"` 32 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 33 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 34 | } 35 | 36 | func main() { 37 | mux := http.NewServeMux() 38 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 39 | 40 | api.UseMiddleware() 41 | 42 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 43 | if err != nil { 44 | fmt.Println(err) 45 | } 46 | 47 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 48 | 49 | fmt.Printf("Starting server on port 8888...\n") 50 | http.ListenAndServe(":8888", mux) 51 | } 52 | -------------------------------------------------------------------------------- /internal/service/post_single.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/danielgtaylor/huma/v2" 8 | ) 9 | 10 | type PostSingleInput[Model any] struct { 11 | Body Model 12 | } 13 | type PostSingleOutput[Model any] struct { 14 | Body Model 15 | } 16 | 17 | // PostSingle creates a single resource 18 | func (s *CRUDService[Model]) PostSingle(ctx context.Context, i *PostSingleInput[Model]) (*PostSingleOutput[Model], error) { 19 | slog.Debug("Executing PostSingle operation", slog.Any("input", i)) 20 | 21 | // Execute BeforePost hook if defined 22 | if s.hooks.BeforePost != nil { 23 | if err := s.hooks.BeforePost(ctx, &[]Model{i.Body}); err != nil { 24 | slog.Error("BeforePost hook failed", slog.Any("error", err)) 25 | return nil, err 26 | } 27 | } 28 | 29 | // Create the resource in the repository 30 | result, err := s.repo.Post(ctx, &[]Model{i.Body}) 31 | if err != nil { 32 | slog.Error("Failed to create resource in PostSingle", slog.Any("error", err)) 33 | return nil, err 34 | } else if len(result) <= 0 { 35 | slog.Error("Entity not found in PostSingle") 36 | return nil, huma.Error404NotFound("entity not found") 37 | } 38 | 39 | // Execute AfterPost hook if defined 40 | if s.hooks.AfterPost != nil { 41 | if err := s.hooks.AfterPost(ctx, &result); err != nil { 42 | slog.Error("AfterPost hook failed", slog.Any("error", err)) 43 | return nil, err 44 | } 45 | } 46 | 47 | slog.Debug("Successfully executed PostSingle operation", slog.Any("result", result)) 48 | return &PostSingleOutput[Model]{ 49 | Body: result[0], 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/danielgtaylor/huma/v2 v2.32.0 h1:ytU9ExG/axC434+soXxwNzv0uaxOb3cyCgjj8y3PmBE= 2 | github.com/danielgtaylor/huma/v2 v2.32.0/go.mod h1:9BxJwkeoPPDEJ2Bg4yPwL1mM1rYpAwCAWFKoo723spk= 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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 6 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 8 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 9 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 10 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 11 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 12 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 13 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 14 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /internal/service/delete_bulk.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ckoliber/gocrud/internal/schema" 8 | ) 9 | 10 | // DeleteBulkInput represents the input for the DeleteBulk operation 11 | type DeleteBulkInput[Model any] struct { 12 | Where schema.Where[Model] `query:"where" doc:"Entity where" example:"{}"` 13 | } 14 | 15 | // DeleteBulkOutput represents the output for the DeleteBulk operation 16 | type DeleteBulkOutput[Model any] struct { 17 | Body []Model 18 | } 19 | 20 | // DeleteBulk deletes multiple resources 21 | func (s *CRUDService[Model]) DeleteBulk(ctx context.Context, i *DeleteBulkInput[Model]) (*DeleteBulkOutput[Model], error) { 22 | slog.Debug("Executing DeleteBulk operation", slog.Any("where", i.Where)) 23 | 24 | // Execute BeforeDelete hook if defined 25 | if s.hooks.BeforeDelete != nil { 26 | if err := s.hooks.BeforeDelete(ctx, i.Where.Addr()); err != nil { 27 | slog.Error("BeforeDelete hook failed", slog.Any("error", err)) 28 | return nil, err 29 | } 30 | } 31 | 32 | // Delete the resources in the repository 33 | result, err := s.repo.Delete(ctx, i.Where.Addr()) 34 | if err != nil { 35 | slog.Error("Failed to delete resources in DeleteBulk", slog.Any("error", err)) 36 | return nil, err 37 | } 38 | 39 | // Execute AfterDelete hook if defined 40 | if s.hooks.AfterDelete != nil { 41 | if err := s.hooks.AfterDelete(ctx, &result); err != nil { 42 | slog.Error("AfterDelete hook failed", slog.Any("error", err)) 43 | return nil, err 44 | } 45 | } 46 | 47 | slog.Debug("Successfully executed DeleteBulk operation", slog.Any("result", result)) 48 | return &DeleteBulkOutput[Model]{ 49 | Body: result, 50 | }, nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/service/delete_single.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ckoliber/gocrud/internal/schema" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type DeleteSingleInput[Model any] struct { 12 | ID string `path:"id" doc:"Entity identifier"` 13 | } 14 | type DeleteSingleOutput[Model any] struct { 15 | Body Model 16 | } 17 | 18 | func (s *CRUDService[Model]) DeleteSingle(ctx context.Context, i *DeleteSingleInput[Model]) (*DeleteSingleOutput[Model], error) { 19 | slog.Debug("Executing DeleteSingle operation", slog.String("id", i.ID)) 20 | 21 | // Define the where clause for the delete operation 22 | where := schema.Where[Model]{s.id: map[string]any{"_eq": i.ID}} 23 | 24 | // Execute BeforeDelete hook if defined 25 | if s.hooks.BeforeDelete != nil { 26 | if err := s.hooks.BeforeDelete(ctx, where.Addr()); err != nil { 27 | slog.Error("BeforeDelete hook failed", slog.Any("error", err)) 28 | return nil, err 29 | } 30 | } 31 | 32 | // Delete the resource in the repository 33 | result, err := s.repo.Delete(ctx, where.Addr()) 34 | if err != nil { 35 | slog.Error("Failed to delete resource in DeleteSingle", slog.Any("error", err)) 36 | return nil, err 37 | } else if len(result) <= 0 { 38 | slog.Warn("Entity not found in DeleteSingle", slog.String("id", i.ID)) 39 | return nil, huma.Error404NotFound("entity not found") 40 | } 41 | 42 | // Execute AfterDelete hook if defined 43 | if s.hooks.AfterDelete != nil { 44 | if err := s.hooks.AfterDelete(ctx, &result); err != nil { 45 | slog.Error("AfterDelete hook failed", slog.Any("error", err)) 46 | return nil, err 47 | } 48 | } 49 | 50 | slog.Debug("Successfully executed DeleteSingle operation", slog.Any("result", result[0])) 51 | return &DeleteSingleOutput[Model]{ 52 | Body: result[0], 53 | }, nil 54 | } 55 | -------------------------------------------------------------------------------- /example/relations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/ckoliber/gocrud" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | "github.com/danielgtaylor/huma/v2/adapters/humago" 12 | 13 | _ "github.com/lib/pq" 14 | ) 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 22 | } 23 | 24 | type Document struct { 25 | _ struct{} `db:"documents" json:"-"` 26 | ID *int `db:"id" json:"id" required:"false"` 27 | Title string `db:"title" json:"title" maxLength:"50" doc:"Document title"` 28 | Content string `db:"content" json:"content" maxLength:"500" doc:"Document content"` 29 | UserID int `db:"userId" json:"userId" doc:"Document userId"` 30 | User User `db:"user" src:"userId" dest:"id" table:"users" json:"-"` 31 | } 32 | 33 | func main() { 34 | mux := http.NewServeMux() 35 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 36 | 37 | api.UseMiddleware() 38 | 39 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 40 | if err != nil { 41 | fmt.Println(err) 42 | } 43 | 44 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{}) 45 | gocrud.Register(api, gocrud.NewSQLRepository[Document](db), &gocrud.Config[Document]{}) 46 | 47 | fmt.Printf("Starting server on port 8888...\n") 48 | http.ListenAndServe(":8888", mux) 49 | } 50 | -------------------------------------------------------------------------------- /internal/service/get_single.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ckoliber/gocrud/internal/schema" 8 | "github.com/danielgtaylor/huma/v2" 9 | ) 10 | 11 | type GetSingleInput[Model any] struct { 12 | ID string `path:"id" doc:"Entity identifier"` 13 | } 14 | type GetSingleOutput[Model any] struct { 15 | Body Model 16 | } 17 | 18 | // GetSingle retrieves a single resource by its ID 19 | func (s *CRUDService[Model]) GetSingle(ctx context.Context, i *GetSingleInput[Model]) (*GetSingleOutput[Model], error) { 20 | slog.Debug("Executing GetSingle operation", slog.String("id", i.ID)) 21 | 22 | // Define the where clause for the get operation 23 | where := schema.Where[Model]{s.id: map[string]any{"_eq": i.ID}} 24 | 25 | // Execute BeforeGet hook if defined 26 | if s.hooks.BeforeGet != nil { 27 | if err := s.hooks.BeforeGet(ctx, where.Addr(), nil, nil, nil); err != nil { 28 | slog.Error("BeforeGet hook failed", slog.Any("error", err)) 29 | return nil, err 30 | } 31 | } 32 | 33 | // Fetch the resource from the repository 34 | result, err := s.repo.Get(ctx, where.Addr(), nil, nil, nil) 35 | if err != nil { 36 | slog.Error("Failed to fetch resource in GetSingle", slog.Any("error", err)) 37 | return nil, err 38 | } else if len(result) <= 0 { 39 | slog.Error("Entity not found in GetSingle", slog.String("id", i.ID)) 40 | return nil, huma.Error404NotFound("entity not found") 41 | } 42 | 43 | // Execute AfterGet hook if defined 44 | if s.hooks.AfterGet != nil { 45 | if err := s.hooks.AfterGet(ctx, &result); err != nil { 46 | slog.Error("AfterGet hook failed", slog.Any("error", err)) 47 | return nil, err 48 | } 49 | } 50 | 51 | slog.Debug("Successfully executed GetSingle operation", slog.Any("result", result)) 52 | return &GetSingleOutput[Model]{ 53 | Body: result[0], 54 | }, nil 55 | } 56 | -------------------------------------------------------------------------------- /example/hooks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/http" 8 | 9 | "github.com/ckoliber/gocrud" 10 | 11 | "github.com/danielgtaylor/huma/v2" 12 | "github.com/danielgtaylor/huma/v2/adapters/humago" 13 | 14 | _ "github.com/lib/pq" 15 | ) 16 | 17 | type User struct { 18 | _ struct{} `db:"users" json:"-"` 19 | ID *int `db:"id" json:"id" required:"false"` 20 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 21 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 22 | } 23 | 24 | func main() { 25 | mux := http.NewServeMux() 26 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 27 | 28 | api.UseMiddleware() 29 | 30 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 31 | if err != nil { 32 | fmt.Println(err) 33 | } 34 | 35 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{ 36 | BeforeGet: func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error { 37 | if *limit > 50 { 38 | *limit = 50 39 | } 40 | 41 | return nil 42 | }, 43 | BeforePut: func(ctx context.Context, models *[]User) error { 44 | return nil 45 | }, 46 | BeforePost: func(ctx context.Context, models *[]User) error { 47 | return nil 48 | }, 49 | BeforeDelete: func(ctx context.Context, where *map[string]any) error { 50 | return nil 51 | }, 52 | AfterGet: func(ctx context.Context, models *[]User) error { 53 | return nil 54 | }, 55 | AfterPut: func(ctx context.Context, models *[]User) error { 56 | return nil 57 | }, 58 | AfterPost: func(ctx context.Context, models *[]User) error { 59 | return nil 60 | }, 61 | AfterDelete: func(ctx context.Context, models *[]User) error { 62 | return nil 63 | }, 64 | }) 65 | 66 | fmt.Printf("Starting server on port 8888...\n") 67 | http.ListenAndServe(":8888", mux) 68 | } 69 | -------------------------------------------------------------------------------- /internal/service/get_bulk.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | 7 | "github.com/ckoliber/gocrud/internal/schema" 8 | ) 9 | 10 | // GetBulkInput defines the input parameters for the GetBulk operation 11 | type GetBulkInput[Model any] struct { 12 | Where schema.Where[Model] `query:"where" doc:"Entity where" example:"{}"` 13 | Order schema.Order[Model] `query:"order" doc:"Entity order" example:"{}"` 14 | Limit schema.Optional[int] `query:"limit" min:"1" doc:"Entity limit" example:"50"` 15 | Skip schema.Optional[int] `query:"skip" min:"0" doc:"Entity skip" example:"0"` 16 | } 17 | 18 | // GetBulkOutput defines the output structure for the GetBulk operation 19 | type GetBulkOutput[Model any] struct { 20 | Body []Model 21 | } 22 | 23 | // GetBulk retrieves multiple resources with filtering and pagination 24 | func (s *CRUDService[Model]) GetBulk(ctx context.Context, i *GetBulkInput[Model]) (*GetBulkOutput[Model], error) { 25 | slog.Debug("Executing GetBulk operation", slog.Any("where", i.Where), slog.Any("order", i.Order), slog.Any("limit", i.Limit), slog.Any("skip", i.Skip)) 26 | 27 | // Execute BeforeGet hook if defined 28 | if s.hooks.BeforeGet != nil { 29 | if err := s.hooks.BeforeGet(ctx, i.Where.Addr(), i.Order.Addr(), i.Limit.Addr(), i.Skip.Addr()); err != nil { 30 | slog.Error("BeforeGet hook failed", slog.Any("error", err)) 31 | return nil, err 32 | } 33 | } 34 | 35 | // Fetch resources from the repository 36 | result, err := s.repo.Get(ctx, i.Where.Addr(), i.Order.Addr(), i.Limit.Addr(), i.Skip.Addr()) 37 | if err != nil { 38 | slog.Error("Failed to fetch resources in GetBulk", slog.Any("error", err)) 39 | return nil, err 40 | } 41 | 42 | // Execute AfterGet hook if defined 43 | if s.hooks.AfterGet != nil { 44 | if err := s.hooks.AfterGet(ctx, &result); err != nil { 45 | slog.Error("AfterGet hook failed", slog.Any("error", err)) 46 | return nil, err 47 | } 48 | } 49 | 50 | slog.Debug("Successfully executed GetBulk operation", slog.Any("result", result)) 51 | return &GetBulkOutput[Model]{ 52 | Body: result, 53 | }, nil 54 | } 55 | -------------------------------------------------------------------------------- /gocrud_test.go: -------------------------------------------------------------------------------- 1 | package gocrud 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | "testing" 7 | 8 | "github.com/danielgtaylor/huma/v2/humatest" 9 | "github.com/stretchr/testify/assert" 10 | 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | var xdb *sql.DB 15 | 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` 18 | ID *int `db:"id" json:"id" required:"false"` 19 | Name string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 20 | Age int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 21 | } 22 | 23 | func TestRegister(t *testing.T) { 24 | // Create a new in-memory SQLite database 25 | db, err := sql.Open("sqlite3", ":memory:?cache=shared") 26 | if err != nil { 27 | panic(err) 28 | } 29 | defer db.Close() 30 | xdb = db 31 | 32 | // Create the users table 33 | _, err = db.Exec("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)") 34 | if err != nil { 35 | panic(err) 36 | } 37 | 38 | // Create a new Huma API 39 | _, api := humatest.New(t) 40 | repo := NewSQLRepository[User](xdb) 41 | Register(api, repo, &Config[User]{}) 42 | 43 | t.Run("POST single", func(t *testing.T) { 44 | // Create a new user 45 | user := User{Name: "David", Age: 25} 46 | resp := api.Post("/user/one", &user) 47 | assert.Equal(t, resp.Code, 200) 48 | 49 | // Check the response 50 | var result User 51 | assert.Empty(t, json.Unmarshal(resp.Body.Bytes(), &result)) 52 | assert.NotEmpty(t, result.ID) 53 | assert.Equal(t, result.Name, user.Name) 54 | assert.Equal(t, result.Age, user.Age) 55 | }) 56 | 57 | t.Run("POST bulk", func(t *testing.T) { 58 | users := []User{ 59 | {Name: "Alice", Age: 25}, 60 | {Name: "Bob", Age: 35}, 61 | {Name: "Charlie", Age: 45}, 62 | } 63 | resp := api.Post("/user", &users) 64 | assert.Equal(t, resp.Code, 200) 65 | 66 | var result []User 67 | assert.Empty(t, json.Unmarshal(resp.Body.Bytes(), &result)) 68 | for i := range users { 69 | assert.NotEmpty(t, result[i].ID) 70 | assert.Equal(t, result[i].Name, users[i].Name) 71 | assert.Equal(t, result[i].Age, users[i].Age) 72 | } 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /example/auth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/ckoliber/gocrud" 11 | 12 | "github.com/danielgtaylor/huma/v2" 13 | "github.com/danielgtaylor/huma/v2/adapters/humago" 14 | 15 | _ "github.com/lib/pq" 16 | ) 17 | 18 | type User struct { 19 | _ struct{} `db:"users" json:"-"` 20 | ID *int `db:"id" json:"id" required:"false"` 21 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 22 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 23 | } 24 | 25 | func NewAuthMiddleware(api huma.API) func(ctx huma.Context, next func(huma.Context)) { 26 | return func(ctx huma.Context, next func(huma.Context)) { 27 | parts := strings.Split(ctx.Operation().OperationID, "-") 28 | 29 | // Skip auth for GET operations 30 | if parts[0] == "get" { 31 | next(ctx) 32 | return 33 | } 34 | 35 | // Check for the Authorization header 36 | token := strings.TrimPrefix(ctx.Header("Authorization"), "Bearer ") 37 | if len(token) == 0 { 38 | huma.WriteErr(api, ctx, http.StatusUnauthorized, "Unauthorized") 39 | return 40 | } 41 | 42 | // Extract role from the token 43 | if token == "" { 44 | huma.WriteErr(api, ctx, http.StatusForbidden, "Forbidden") 45 | return 46 | } 47 | ctx = huma.WithValue(ctx, "user", "12345") 48 | ctx = huma.WithValue(ctx, "role", "admin") 49 | 50 | next(ctx) 51 | return 52 | } 53 | } 54 | 55 | func main() { 56 | mux := http.NewServeMux() 57 | api := humago.New(mux, huma.DefaultConfig("My API", "1.0.0")) 58 | 59 | api.UseMiddleware(NewAuthMiddleware(api)) 60 | 61 | db, err := sql.Open("postgres", "host=127.0.0.1 port=5432 user=postgres password=password dbname=postgres sslmode=disable") 62 | if err != nil { 63 | fmt.Println(err) 64 | } 65 | 66 | gocrud.Register(api, gocrud.NewSQLRepository[User](db), &gocrud.Config[User]{ 67 | BeforeDelete: func(ctx context.Context, where *map[string]any) error { 68 | if ctx.Value("role") == "admin" { 69 | return nil 70 | } 71 | 72 | *where = map[string]any{ 73 | "_and": []map[string]any{ 74 | {"id": map[string]any{"_eq": ctx.Value("user")}}, 75 | *where, 76 | }, 77 | } 78 | 79 | return nil 80 | }, 81 | }) 82 | 83 | fmt.Printf("Starting server on port 8888...\n") 84 | http.ListenAndServe(":8888", mux) 85 | } 86 | -------------------------------------------------------------------------------- /internal/service/base.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/ckoliber/gocrud/internal/repository" 11 | ) 12 | 13 | // CRUDHooks defines hooks that can be executed before and after CRUD operations 14 | type CRUDHooks[Model any] struct { 15 | BeforeGet func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error 16 | BeforePut func(ctx context.Context, models *[]Model) error 17 | BeforePost func(ctx context.Context, models *[]Model) error 18 | BeforeDelete func(ctx context.Context, where *map[string]any) error 19 | 20 | AfterGet func(ctx context.Context, models *[]Model) error 21 | AfterPut func(ctx context.Context, models *[]Model) error 22 | AfterPost func(ctx context.Context, models *[]Model) error 23 | AfterDelete func(ctx context.Context, models *[]Model) error 24 | } 25 | 26 | // CRUDService provides CRUD operations for a given repository 27 | type CRUDService[Model any] struct { 28 | id string 29 | key string 30 | name string 31 | path string 32 | repo repository.Repository[Model] 33 | hooks *CRUDHooks[Model] 34 | } 35 | 36 | // NewCRUDService initializes a new CRUD service 37 | func NewCRUDService[Model any](repo repository.Repository[Model], hooks *CRUDHooks[Model]) *CRUDService[Model] { 38 | // Reflect on the Model type to extract metadata 39 | _type := reflect.TypeFor[Model]() 40 | 41 | // Extract the ID field from the model 42 | idField := _type.Field(0) 43 | if idField.Name == "_" { 44 | idField = _type.Field(1) 45 | } 46 | 47 | result := &CRUDService[Model]{ 48 | id: strings.Split(idField.Tag.Get("json"), ",")[0], 49 | key: idField.Name, 50 | name: strings.ToLower(_type.Name()), 51 | path: fmt.Sprintf("/%s", strings.ToLower(_type.Name())), 52 | repo: repo, 53 | hooks: hooks, 54 | } 55 | 56 | slog.Debug("Initialized CRUDService", slog.String("name", result.name), slog.String("path", result.path), slog.String("id", result.id)) 57 | return result 58 | } 59 | 60 | // GetName returns the name of the resource 61 | func (s *CRUDService[Model]) GetName() string { 62 | slog.Debug("Fetching resource name", slog.String("name", s.name)) 63 | return s.name 64 | } 65 | 66 | // GetPath returns the API path for the resource 67 | func (s *CRUDService[Model]) GetPath() string { 68 | slog.Debug("Fetching resource path", slog.String("path", s.path)) 69 | return s.path 70 | } 71 | -------------------------------------------------------------------------------- /internal/repository/base_test.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | type User struct { 11 | _ struct{} `db:"users" json:"-"` 12 | ID *int `db:"id" json:"id"` 13 | Name string `db:"name" json:"name"` 14 | Age int `db:"age" json:"age"` 15 | } 16 | 17 | func UnitTests(ctx context.Context, t *testing.T, repo Repository[User]) { 18 | t.Run("Post", func(t *testing.T) { 19 | users := []User{ 20 | {Name: "Alice", Age: 25}, 21 | {Name: "Bob", Age: 35}, 22 | {Name: "Charlie", Age: 45}, 23 | } 24 | 25 | result, err := repo.Post(ctx, &users) 26 | assert.NoError(t, err) 27 | assert.Len(t, result, 3) 28 | 29 | for i, user := range result { 30 | assert.NotNil(t, user.ID) 31 | assert.Equal(t, users[i].Name, user.Name) 32 | assert.Equal(t, users[i].Age, user.Age) 33 | } 34 | }) 35 | 36 | t.Run("GetByID", func(t *testing.T) { 37 | where := map[string]any{"id": map[string]any{"_eq": "1"}} 38 | result, err := repo.Get(ctx, &where, nil, nil, nil) 39 | assert.NoError(t, err) 40 | assert.Len(t, result, 1) 41 | }) 42 | 43 | t.Run("GetWithFilters", func(t *testing.T) { 44 | where := map[string]any{"age": map[string]any{"_gt": "25"}} 45 | result, err := repo.Get(ctx, &where, nil, nil, nil) 46 | assert.NoError(t, err) 47 | assert.NotEmpty(t, result) 48 | }) 49 | 50 | t.Run("GetPagination", func(t *testing.T) { 51 | limit := 5 52 | skip := 0 53 | result, err := repo.Get(ctx, nil, nil, &limit, &skip) 54 | assert.NoError(t, err) 55 | assert.LessOrEqual(t, len(result), limit) 56 | }) 57 | 58 | t.Run("Put", func(t *testing.T) { 59 | users := []User{ 60 | {ID: &[]int{1}[0], Name: "Alice Updated", Age: 26}, 61 | {ID: &[]int{2}[0], Name: "Bob Updated", Age: 36}, 62 | {ID: &[]int{3}[0], Name: "Charlie Updated", Age: 46}, 63 | } 64 | 65 | result, err := repo.Put(ctx, &users) 66 | assert.NoError(t, err) 67 | assert.Len(t, result, 3) 68 | 69 | for i, user := range result { 70 | assert.Equal(t, users[i].ID, user.ID) 71 | assert.Equal(t, users[i].Name, user.Name) 72 | assert.Equal(t, users[i].Age, user.Age) 73 | } 74 | }) 75 | 76 | t.Run("DeleteByID", func(t *testing.T) { 77 | where := map[string]any{"id": map[string]any{"_eq": "1"}} 78 | result, err := repo.Delete(ctx, &where) 79 | assert.NoError(t, err) 80 | assert.Len(t, result, 1) 81 | }) 82 | 83 | t.Run("DeleteWithFilters", func(t *testing.T) { 84 | where := map[string]any{"age": map[string]any{"_gt": "30"}} 85 | result, err := repo.Delete(ctx, &where) 86 | assert.NoError(t, err) 87 | assert.NotEmpty(t, result) 88 | }) 89 | } 90 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | ![GoCRUD](./icon.svg) 4 | 5 | Welcome to the **GoCRUD** documentation! This guide will help you understand how to use GoCRUD to build powerful, scalable, and maintainable CRUD APIs with ease. 6 | 7 | ## 📖 Overview 8 | 9 | GoCRUD is a Go module that extends the [Huma](https://huma.rocks/) framework to provide automatic CRUD API generation. It simplifies API development by automating repetitive tasks, allowing you to focus on your application's business logic. 10 | 11 | ### Key Features 12 | 13 | - **Automatic CRUD Generation**: Generate RESTful endpoints for your models with minimal configuration. 14 | - **Input Validation**: Built-in validation for your model fields. 15 | - **Customizable Hooks**: Add custom logic before or after CRUD operations. 16 | - **Relationship Filtering**: Query through model relationships with type-safe filters. 17 | - **Custom Field Operations**: Define custom field-specific filtering operations. 18 | - **Database Agnostic**: Supports PostgreSQL, MySQL, SQLite, and MSSQL. 19 | 20 | ### Relations Support 21 | 22 | Define relationships between your models and query through them: 23 | 24 | ```go 25 | type User struct { 26 | ID *int `db:"id" json:"id"` 27 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 28 | } 29 | 30 | // Query users with specific documents 31 | GET /users?where={"documents":{"title":{"_eq":"Doc4"}}} 32 | ``` 33 | 34 | ### Custom Operations 35 | 36 | Add custom filtering operations to your field types: 37 | 38 | ```go 39 | type ID int 40 | 41 | func (_ *ID) Operations() map[string]func(string, ...string) string { 42 | return map[string]func(string, ...string) string{ 43 | "_regexp": func(key string, values ...string) string { 44 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 45 | }, 46 | } 47 | } 48 | 49 | // Use custom operations in queries 50 | GET /users?where={"id":{"_regexp":"5"}} 51 | ``` 52 | 53 | ## 📚 Documentation Structure 54 | 55 | The documentation is organized as follows: 56 | 57 | - **[Introduction](introduction.md)**: Learn about GoCRUD and its core concepts. 58 | - **[Getting Started](getting-started.md)**: Step-by-step guide to set up and use GoCRUD in your project. 59 | - **[Configuration](configuration.md)**: Detailed explanation of configuration options and examples. 60 | - **[CRUD Operations](crud-operations.md)**: Learn how to use the generated CRUD endpoints. 61 | - **[CRUD Hooks](crud-hooks.md)**: Customize your API behavior with hooks. 62 | - **[Advanced Topics](advanced-topics.md)**: Explore advanced features and future enhancements. 63 | - **[FAQ](FAQ.md)**: Frequently asked questions and troubleshooting tips. 64 | 65 | ## 🚀 Getting Started 66 | 67 | To get started with GoCRUD, check out the [Getting Started](getting-started.md) guide. It will walk you through the installation process, model definition, and API registration. 68 | 69 | ## 🛠️ Contributing 70 | 71 | We welcome contributions to improve GoCRUD and its documentation. If you'd like to contribute, please check out the [Contributing Guide](https://github.com/ckoliber/gocrud/blob/main/CONTRIBUTING.md). 72 | 73 | ## 📝 License 74 | 75 | This project is licensed under the MIT License. See the [LICENSE](https://github.com/ckoliber/gocrud/blob/main/LICENSE.md) file for details. 76 | 77 | --- 78 | 79 | Made with ❤️ by KoLiBer 80 | -------------------------------------------------------------------------------- /internal/schema/order.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log/slog" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | ) 12 | 13 | var orderRegistry huma.Registry 14 | 15 | type Order[Model any] map[string]any 16 | 17 | func (o *Order[Model]) UnmarshalText(text []byte) error { 18 | // Unmarshal the text into the Order map 19 | if err := json.Unmarshal(text, o.Addr()); err != nil { 20 | slog.Error("Failed to unmarshal text into Order", slog.Any("error", err)) 21 | return err 22 | } 23 | 24 | // Validate the unmarshaled data against the schema 25 | name := "Order" + huma.DefaultSchemaNamer(reflect.TypeFor[Model](), "") 26 | schema := orderRegistry.Map()[name] 27 | result := huma.ValidateResult{} 28 | huma.Validate(orderRegistry, schema, huma.NewPathBuffer([]byte(""), 0), huma.ModeReadFromServer, *o.Addr(), &result) 29 | if len(result.Errors) > 0 { 30 | slog.Error("Validation errors in Order", slog.Any("errors", result.Errors)) 31 | return errors.Join(result.Errors...) 32 | } 33 | 34 | slog.Debug("Successfully unmarshaled and validated Order", slog.Any("order", *o)) 35 | return nil 36 | } 37 | 38 | func (o *Order[Model]) Schema(r huma.Registry) *huma.Schema { 39 | // Generate and register the schema for the Order type 40 | name := "Order" + huma.DefaultSchemaNamer(reflect.TypeFor[Model](), "") 41 | schema := &huma.Schema{ 42 | Type: huma.TypeObject, 43 | Properties: map[string]*huma.Schema{}, 44 | AdditionalProperties: false, 45 | } 46 | 47 | // Add field-specific properties to the schema 48 | _type := reflect.TypeFor[Model]() 49 | for idx := range _type.NumField() { 50 | _field := _type.Field(idx) 51 | 52 | // Skip model information field 53 | if _field.Name == "_" { 54 | continue 55 | } 56 | 57 | if tag := _field.Tag.Get("json"); tag != "" { 58 | if _schema := o.FieldSchema(_field); _schema != nil { 59 | if tag != "-" { 60 | // primitive fields detected, name it with the json tag 61 | schema.Properties[strings.Split(tag, ",")[0]] = _schema 62 | } 63 | } 64 | } 65 | } 66 | 67 | // Precompute messages and update the registry 68 | schema.PrecomputeMessages() 69 | r.Map()[name] = schema 70 | orderRegistry = r 71 | 72 | slog.Debug("Schema generated for Order", slog.String("name", name), slog.Any("schema", schema)) 73 | return &huma.Schema{ 74 | Type: huma.TypeString, 75 | } 76 | } 77 | 78 | func (o *Order[Model]) FieldSchema(field reflect.StructField) *huma.Schema { 79 | // Get the field deep inside array or slice or pointer types 80 | _field := field.Type 81 | for _field.Kind() == reflect.Array || _field.Kind() == reflect.Slice || _field.Kind() == reflect.Pointer { 82 | _field = _field.Elem() 83 | } 84 | 85 | switch _field.Kind() { 86 | case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String: 87 | // For fields of primitive types, return a schema with enum values 88 | return &huma.Schema{ 89 | Type: huma.TypeString, 90 | Enum: []any{"ASC", "DESC"}, 91 | } 92 | } 93 | 94 | slog.Debug("Unsupported field type for Order", slog.Any("field", field)) 95 | return nil 96 | } 97 | 98 | func (o *Order[Model]) Addr() *map[string]any { 99 | return (*map[string]any)(o) 100 | } 101 | -------------------------------------------------------------------------------- /docs/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/service/put_single.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "reflect" 7 | "strconv" 8 | 9 | "github.com/danielgtaylor/huma/v2" 10 | ) 11 | 12 | // PutSingleInput represents the input for the PutSingle operation 13 | type PutSingleInput[Model any] struct { 14 | ID string `path:"id" doc:"Entity identifier"` 15 | Body Model 16 | } 17 | 18 | // PutSingleOutput represents the output for the PutSingle operation 19 | type PutSingleOutput[Model any] struct { 20 | Body Model 21 | } 22 | 23 | // PutSingle updates a single resource 24 | func (s *CRUDService[Model]) PutSingle(ctx context.Context, i *PutSingleInput[Model]) (*PutSingleOutput[Model], error) { 25 | slog.Debug("Executing PutSingle operation", slog.String("id", i.ID), slog.Any("body", i.Body)) 26 | 27 | // Get the ID field by name 28 | _field := reflect.ValueOf(&i.Body).Elem().FieldByName(s.key) 29 | for _field.Kind() == reflect.Pointer { 30 | _field = _field.Elem() 31 | } 32 | 33 | // Set model ID field value based on path ID value 34 | switch _field.Kind() { 35 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 36 | value, err := strconv.ParseInt(i.ID, 10, 64) 37 | if err != nil { 38 | slog.Error("Failed to parse ID as integer", slog.Any("error", err)) 39 | return nil, huma.Error422UnprocessableEntity(err.Error()) 40 | } 41 | _field.SetInt(value) 42 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 43 | value, err := strconv.ParseUint(i.ID, 10, 64) 44 | if err != nil { 45 | slog.Error("Failed to parse ID as unsigned integer", slog.Any("error", err)) 46 | return nil, huma.Error422UnprocessableEntity(err.Error()) 47 | } 48 | _field.SetUint(value) 49 | case reflect.Float32, reflect.Float64: 50 | value, err := strconv.ParseFloat(i.ID, 64) 51 | if err != nil { 52 | slog.Error("Failed to parse ID as float", slog.Any("error", err)) 53 | return nil, huma.Error422UnprocessableEntity(err.Error()) 54 | } 55 | _field.SetFloat(value) 56 | case reflect.Complex64, reflect.Complex128: 57 | value, err := strconv.ParseComplex(i.ID, 128) 58 | if err != nil { 59 | slog.Error("Failed to parse ID as complex number", slog.Any("error", err)) 60 | return nil, huma.Error422UnprocessableEntity(err.Error()) 61 | } 62 | _field.SetComplex(value) 63 | case reflect.String: 64 | _field.SetString(i.ID) 65 | default: 66 | slog.Error("Invalid identifier type", slog.String("id", i.ID)) 67 | return nil, huma.Error422UnprocessableEntity("invalid identifier type") 68 | } 69 | 70 | // Execute BeforePut hook if defined 71 | if s.hooks.BeforePut != nil { 72 | if err := s.hooks.BeforePut(ctx, &[]Model{i.Body}); err != nil { 73 | slog.Error("BeforePut hook failed", slog.Any("error", err)) 74 | return nil, err 75 | } 76 | } 77 | 78 | // Update the resource in the repository 79 | result, err := s.repo.Put(ctx, &[]Model{i.Body}) 80 | if err != nil { 81 | slog.Error("Failed to update resource in PutSingle", slog.Any("error", err)) 82 | return nil, err 83 | } else if len(result) <= 0 { 84 | slog.Error("Entity not found", slog.String("id", i.ID)) 85 | return nil, huma.Error404NotFound("entity not found") 86 | } 87 | 88 | // Execute AfterPut hook if defined 89 | if s.hooks.AfterPut != nil { 90 | if err := s.hooks.AfterPut(ctx, &result); err != nil { 91 | slog.Error("AfterPut hook failed", slog.Any("error", err)) 92 | return nil, err 93 | } 94 | } 95 | 96 | slog.Debug("Successfully executed PutSingle operation", slog.Any("result", result[0])) 97 | return &PutSingleOutput[Model]{ 98 | Body: result[0], 99 | }, nil 100 | } 101 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | Install GoCRUD using `go get`: 6 | 7 | ```bash 8 | go get github.com/ckoliber/gocrud 9 | ``` 10 | 11 | ## Basic Usage 12 | 13 | ### 1. Define Your Model 14 | 15 | ```go 16 | type User struct { 17 | _ struct{} `db:"users" json:"-"` // Table name 18 | ID *int `db:"id" json:"id"` // Primary key 19 | Name *string `db:"name" json:"name"` // Regular field 20 | Age *int `db:"age" json:"age"` // Regular field 21 | } 22 | ``` 23 | 24 | ### 2. Initialize Database 25 | 26 | ```go 27 | import ( 28 | "database/sql" 29 | _ "github.com/lib/pq" // Or any other database driver 30 | ) 31 | 32 | db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/dbname") 33 | if err != nil { 34 | panic(err) 35 | } 36 | ``` 37 | 38 | ### 3. Register Your API 39 | 40 | ```go 41 | import ( 42 | "github.com/ckoliber/gocrud" 43 | "github.com/danielgtaylor/huma/v2" 44 | ) 45 | 46 | func main() { 47 | api := huma.New("My API", "1.0.0") 48 | 49 | // Create repository and register routes 50 | repo := gocrud.NewSQLRepository[User](db) 51 | gocrud.Register(api, repo, &gocrud.Config[User]{}) 52 | 53 | api.Serve() 54 | } 55 | ``` 56 | 57 | ## Available Endpoints 58 | 59 | Your API now has these endpoints: 60 | 61 | - `GET /users` - List users (with filtering, pagination) 62 | - `GET /users/{id}` - Get single user 63 | - `PUT /users` - Update multiple users 64 | - `PUT /users/{id}` - Update user 65 | - `POST /users` - Create multiple users 66 | - `POST /users/one` - Create single user 67 | - `DELETE /users` - Delete multiple users (with filtering) 68 | - `DELETE /users/{id}` - Delete user 69 | 70 | ## Query Parameters 71 | 72 | ### Filtering 73 | 74 | Use the `where` parameter for filtering: 75 | 76 | ```http 77 | GET /users?where={"age":{"_gt":18}} 78 | GET /users?where={"name":{"_like":"John%"}} 79 | ``` 80 | 81 | ### Pagination 82 | 83 | Use `limit` and `skip` for pagination: 84 | 85 | ```http 86 | GET /users?limit=10&skip=20 87 | ``` 88 | 89 | ### Sorting 90 | 91 | Use `order` for sorting: 92 | 93 | ```http 94 | GET /users?order={"name":"ASC","age":"DESC"} 95 | ``` 96 | 97 | ## Advanced Models 98 | 99 | ### Relations 100 | 101 | Define models with relationships: 102 | 103 | ```go 104 | type User struct { 105 | _ struct{} `db:"users" json:"-"` 106 | ID *int `db:"id" json:"id"` 107 | Name *string `db:"name" json:"name"` 108 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 109 | } 110 | 111 | type Document struct { 112 | _ struct{} `db:"documents" json:"-"` 113 | ID *int `db:"id" json:"id"` 114 | Title string `db:"title" json:"title"` 115 | UserID int `db:"userId" json:"userId"` 116 | } 117 | ``` 118 | 119 | Query through relations: 120 | 121 | ```http 122 | GET /users?where={"documents":{"title":{"_eq":"Report"}}} 123 | ``` 124 | 125 | ### Custom Operations 126 | 127 | Add custom filtering operations: 128 | 129 | ```go 130 | type ID int 131 | 132 | func (_ *ID) Operations() map[string]func(string, ...string) string { 133 | return map[string]func(string, ...string) string{ 134 | "_regexp": func(key string, values ...string) string { 135 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 136 | }, 137 | } 138 | } 139 | 140 | // Use in queries 141 | GET /users?where={"id":{"_regexp":"^10.*"}} 142 | ``` 143 | 144 | ## Next Steps 145 | 146 | - Check out [CRUD Operations](crud-operations.md) for detailed API usage 147 | - Learn about [Configuration](configuration.md) options 148 | - Explore [CRUD Hooks](crud-hooks.md) for custom logic 149 | -------------------------------------------------------------------------------- /docs/advanced-topics.md: -------------------------------------------------------------------------------- 1 | # Advanced Topics 2 | 3 | This guide covers advanced features and usage patterns in GoCRUD. 4 | 5 | ## Model Relations 6 | 7 | GoCRUD supports one-to-one and one-to-many relationships between models. 8 | 9 | ### Defining Relations 10 | 11 | ```go 12 | type User struct { 13 | _ struct{} `db:"users" json:"-"` 14 | ID *int `db:"id" json:"id"` 15 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` // One-to-many 16 | } 17 | 18 | type Document struct { 19 | _ struct{} `db:"documents" json:"-"` 20 | ID *int `db:"id" json:"id"` 21 | UserID int `db:"userId" json:"userId"` 22 | User User `db:"user" src:"userId" dest:"id" table:"users" json:"-"` // One-to-one 23 | } 24 | ``` 25 | 26 | Relation tags: 27 | 28 | - `db`: Name of the related table 29 | - `src`: Source field in the current model 30 | - `dest`: Destination field in the related model 31 | - `table`: Target table name 32 | - `json`: Usually "-" to exclude from JSON 33 | 34 | ### Querying Relations 35 | 36 | Filter records based on related entities: 37 | 38 | ```http 39 | # Find users who have documents with specific titles 40 | GET /users?where={"documents":{"title":{"_eq":"Report"}}} 41 | 42 | # Find documents belonging to users of a certain age 43 | GET /documents?where={"user":{"age":{"_gt":30}}} 44 | ``` 45 | 46 | ## Custom Field Operations 47 | 48 | Define custom filtering operations for specific field types: 49 | 50 | ```go 51 | type ID int 52 | 53 | func (_ *ID) Operations() map[string]func(string, ...string) string { 54 | return map[string]func(string, ...string) string{ 55 | "_regexp": func(key string, values ...string) string { 56 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 57 | }, 58 | "_iregexp": func(key string, values ...string) string { 59 | return fmt.Sprintf("%s IREGEXP %s", key, values[0]) 60 | }, 61 | } 62 | } 63 | 64 | type User struct { 65 | ID *ID `db:"id" json:"id"` 66 | Name *string `db:"name" json:"name"` 67 | } 68 | ``` 69 | 70 | Using custom operations: 71 | 72 | ```http 73 | GET /users?where={"id":{"_regexp":"^10.*"}} 74 | ``` 75 | 76 | ## Complex Queries 77 | 78 | ### Logical Operators 79 | 80 | Combine multiple conditions: 81 | 82 | ```http 83 | # AND operator 84 | GET /users?where={"_and":[{"age":{"_gt":20}},{"name":{"_like":"J%"}}]} 85 | 86 | # OR operator 87 | GET /users?where={"_or":[{"age":{"_lt":20}},{"age":{"_gt":60}}]} 88 | 89 | # NOT operator 90 | GET /users?where={"_not":{"age":{"_eq":30}}} 91 | ``` 92 | 93 | ### Nested Conditions 94 | 95 | Create complex nested queries: 96 | 97 | ```http 98 | GET /users?where={ 99 | "_or": [ 100 | { 101 | "_and": [ 102 | {"age": {"_gt": 20}}, 103 | {"name": {"_like": "J%"}} 104 | ] 105 | }, 106 | { 107 | "documents": { 108 | "title": {"_like": "Report%"} 109 | } 110 | } 111 | ] 112 | } 113 | ``` 114 | 115 | ## Performance Optimization 116 | 117 | ### Query Optimization 118 | 119 | 1. **Use Appropriate Indexes**: 120 | 121 | - Add indexes for frequently filtered fields 122 | - Add composite indexes for common filter combinations 123 | 124 | 2. **Limit Result Sets**: 125 | - Always use pagination 126 | - Set reasonable default limits 127 | 128 | ```http 129 | GET /users?limit=50&skip=0 130 | ``` 131 | 132 | 3. **Select Specific Fields**: 133 | - Coming soon: Field selection support 134 | - Will allow retrieving only needed fields 135 | 136 | ### Bulk Operations 137 | 138 | Use bulk endpoints for better performance: 139 | 140 | ```http 141 | # Bulk create 142 | POST /users 143 | { 144 | "body": [ 145 | {"name": "User1"}, 146 | {"name": "User2"} 147 | ] 148 | } 149 | 150 | # Bulk update 151 | PUT /users 152 | { 153 | "body": [ 154 | {"id": 1, "name": "Updated1"}, 155 | {"id": 2, "name": "Updated2"} 156 | ] 157 | } 158 | 159 | # Bulk delete 160 | DELETE /users?where={"age":{"_lt":18}} 161 | ``` 162 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # FAQ 2 | 3 | ## General Questions 4 | 5 | ### What databases does GoCRUD support? 6 | 7 | GoCRUD supports: 8 | 9 | - PostgreSQL 10 | - MySQL 11 | - SQLite 12 | - Microsoft SQL Server 13 | 14 | ### Does GoCRUD support PATCH operations? 15 | 16 | Yes, through Huma's autopatch feature. Enable it with: 17 | 18 | ```go 19 | autopatch.AutoPatch(api) 20 | ``` 21 | 22 | ### Can I use custom field types? 23 | 24 | Yes, by implementing the `Operations` method for custom filtering: 25 | 26 | ```go 27 | type CustomID int 28 | 29 | func (_ *CustomID) Operations() map[string]func(string, ...string) string { 30 | return map[string]func(string, ...string) string{ 31 | "_regexp": func(key string, values ...string) string { 32 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 33 | }, 34 | } 35 | } 36 | ``` 37 | 38 | ## Common Issues 39 | 40 | ### Why am I getting "unsupported database driver"? 41 | 42 | Make sure you: 43 | 44 | 1. Import the correct database driver 45 | 2. Use the supported driver package: 46 | - PostgreSQL: `github.com/lib/pq` 47 | - MySQL: `github.com/go-sql-driver/mysql` 48 | - SQLite: `github.com/mattn/go-sqlite3` 49 | - MSSQL: `github.com/microsoft/go-mssqldb` 50 | 51 | ### Why aren't my relations working? 52 | 53 | Check that you: 54 | 55 | 1. Used the correct tag format: 56 | ```go 57 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 58 | ``` 59 | 2. Set up the foreign key fields correctly 60 | 3. Have proper database permissions 61 | 62 | ### Why isn't pagination working? 63 | 64 | Ensure you're using both `limit` and `skip`: 65 | 66 | ```http 67 | GET /users?limit=10&skip=0 68 | ``` 69 | 70 | ## Best Practices 71 | 72 | ### How should I structure my models? 73 | 74 | Follow these guidelines: 75 | 76 | 1. Always define table name using the `_` field: 77 | ```go 78 | _ struct{} `db:"users" json:"-"` 79 | ``` 80 | 2. Make ID fields pointers for proper null handling: 81 | ```go 82 | ID *int `db:"id" json:"id"` 83 | ``` 84 | 3. Use appropriate tags for validation: 85 | ```go 86 | Age *int `db:"age" json:"age" minimum:"0" maximum:"120"` 87 | ``` 88 | 89 | ### How can I optimize performance? 90 | 91 | 1. Use bulk operations when possible 92 | 2. Set appropriate limits on queries 93 | 3. Add database indexes for filtered fields 94 | 4. Use relationship filtering judiciously 95 | 96 | ### How should I handle errors? 97 | 98 | 1. Use hooks for validation: 99 | ```go 100 | BeforePost: func(ctx context.Context, models *[]User) error { 101 | if err := validate(models); err != nil { 102 | return fmt.Errorf("validation failed: %w", err) 103 | } 104 | return nil 105 | } 106 | ``` 107 | 2. Return specific error types 108 | 3. Log errors appropriately 109 | 110 | ## Configuration 111 | 112 | ### How do I disable certain operations? 113 | 114 | Use operation modes in config: 115 | 116 | ```go 117 | config := &gocrud.Config[User]{ 118 | GetMode: gocrud.BulkSingle, 119 | PostMode: gocrud.None, // Disable POST 120 | DeleteMode: gocrud.None, // Disable DELETE 121 | } 122 | ``` 123 | 124 | ### How do I add custom validation? 125 | 126 | Use before hooks: 127 | 128 | ```go 129 | BeforePost: func(ctx context.Context, models *[]User) error { 130 | for _, user := range *models { 131 | if err := customValidation(user); err != nil { 132 | return err 133 | } 134 | } 135 | return nil 136 | } 137 | ``` 138 | 139 | ## Advanced Usage 140 | 141 | ### Can I use transactions? 142 | 143 | Transactions are handled automatically for: 144 | 145 | - Bulk updates 146 | - Bulk deletes 147 | - Operations with hooks 148 | 149 | ### How do I implement custom filtering? 150 | 151 | 1. Define custom operations on field types 152 | 2. Use the operations in queries: 153 | ```http 154 | GET /users?where={"id":{"_regexp":"^10.*"}} 155 | ``` 156 | 157 | ### Can I extend the default operations? 158 | 159 | Yes, by: 160 | 161 | 1. Implementing custom field types 162 | 2. Adding hooks for custom logic 163 | 3. Using the underlying repository interface 164 | 165 | ## Troubleshooting 166 | 167 | ### Common Error Messages 168 | 169 | #### "entity not found" 170 | 171 | - Check if the resource exists 172 | - Verify the ID is correct 173 | - Ensure proper database permissions 174 | 175 | #### "invalid identifier type" 176 | 177 | - Check model field types 178 | - Ensure ID fields are properly defined 179 | - Verify query parameter types 180 | 181 | #### "validation failed" 182 | 183 | - Check input data format 184 | - Verify field constraints 185 | - Look for missing required fields 186 | 187 | ### Debug Tips 188 | 189 | 1. Enable debug logging 190 | 2. Check SQL queries in logs 191 | 3. Verify database connection 192 | 4. Test queries directly in database 193 | 5. Check hook execution order 194 | 195 | ## Getting Help 196 | 197 | ### Where can I find more examples? 198 | 199 | Check the examples directory in the repository: 200 | 201 | - Basic CRUD 202 | - Relations 203 | - Custom operations 204 | - Different databases 205 | - Hooks implementation 206 | 207 | ### How do I report issues? 208 | 209 | 1. Check existing issues on GitHub 210 | 2. Provide minimal reproduction 211 | 3. Include: 212 | - Go version 213 | - Database type and version 214 | - Complete error message 215 | - Example code 216 | -------------------------------------------------------------------------------- /internal/schema/where.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log/slog" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/danielgtaylor/huma/v2" 11 | ) 12 | 13 | var whereRegistry huma.Registry 14 | 15 | type Where[Model any] map[string]any 16 | 17 | func (w *Where[Model]) UnmarshalText(text []byte) error { 18 | // Unmarshal the text into the Where map 19 | if err := json.Unmarshal(text, w.Addr()); err != nil { 20 | slog.Error("Failed to unmarshal text into Where", slog.Any("error", err)) 21 | return err 22 | } 23 | 24 | // Validate the unmarshaled data against the schema 25 | name := "Where" + huma.DefaultSchemaNamer(reflect.TypeFor[Model](), "") 26 | schema := whereRegistry.Map()[name] 27 | result := huma.ValidateResult{} 28 | huma.Validate(whereRegistry, schema, huma.NewPathBuffer([]byte(""), 0), huma.ModeReadFromServer, *w.Addr(), &result) 29 | if len(result.Errors) > 0 { 30 | slog.Error("Validation errors in Where", slog.Any("errors", result.Errors)) 31 | return errors.Join(result.Errors...) 32 | } 33 | 34 | slog.Debug("Successfully unmarshaled and validated Where", slog.Any("where", *w)) 35 | return nil 36 | } 37 | 38 | func (w *Where[Model]) Schema(r huma.Registry) *huma.Schema { 39 | // Generate and register the schema for the Where type 40 | name := "Where" + huma.DefaultSchemaNamer(reflect.TypeFor[Model](), "") 41 | schema := &huma.Schema{ 42 | Type: huma.TypeObject, 43 | Properties: map[string]*huma.Schema{ 44 | "_not": { 45 | Ref: "#/components/schemas/" + name, 46 | }, 47 | "_and": { 48 | Type: huma.TypeArray, 49 | Items: &huma.Schema{ 50 | Ref: "#/components/schemas/" + name, 51 | }, 52 | }, 53 | "_or": { 54 | Type: huma.TypeArray, 55 | Items: &huma.Schema{ 56 | Ref: "#/components/schemas/" + name, 57 | }, 58 | }, 59 | }, 60 | AdditionalProperties: false, 61 | } 62 | 63 | // Add field-specific properties to the schema 64 | _type := reflect.TypeFor[Model]() 65 | for idx := range _type.NumField() { 66 | _field := _type.Field(idx) 67 | 68 | // Skip model information field 69 | if _field.Name == "_" { 70 | continue 71 | } 72 | 73 | if tag := _field.Tag.Get("json"); tag != "" { 74 | if _schema := w.FieldSchema(_field); _schema != nil { 75 | if tag == "-" { 76 | // Relation field detected, name it with the db tag 77 | schema.Properties[_field.Tag.Get("db")] = _schema 78 | } else { 79 | // Primitive fields detected, name it with the json tag 80 | schema.Properties[strings.Split(tag, ",")[0]] = _schema 81 | } 82 | } 83 | } 84 | 85 | } 86 | 87 | // Precompute messages and update the registry 88 | schema.PrecomputeMessages() 89 | r.Map()[name] = schema 90 | whereRegistry = r 91 | 92 | slog.Debug("Schema generated for Where", slog.String("name", name), slog.Any("schema", schema)) 93 | return &huma.Schema{ 94 | Type: huma.TypeString, 95 | } 96 | } 97 | 98 | func (w *Where[Model]) FieldSchema(field reflect.StructField) *huma.Schema { 99 | // Get the field deep inside array or slice or pointer types 100 | _field := field.Type 101 | for _field.Kind() == reflect.Array || _field.Kind() == reflect.Slice || _field.Kind() == reflect.Pointer { 102 | _field = _field.Elem() 103 | } 104 | 105 | switch _field.Kind() { 106 | case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.String: 107 | // For fields of primitive types, return a schema with operations 108 | result := &huma.Schema{ 109 | Type: huma.TypeObject, 110 | Properties: map[string]*huma.Schema{ 111 | "_eq": {Type: huma.TypeString}, 112 | "_neq": {Type: huma.TypeString}, 113 | "_gt": {Type: huma.TypeString}, 114 | "_gte": {Type: huma.TypeString}, 115 | "_lt": {Type: huma.TypeString}, 116 | "_lte": {Type: huma.TypeString}, 117 | "_like": {Type: huma.TypeString}, 118 | "_nlike": {Type: huma.TypeString}, 119 | "_ilike": {Type: huma.TypeString}, 120 | "_nilike": {Type: huma.TypeString}, 121 | "_in": {Type: huma.TypeArray, Items: &huma.Schema{Type: huma.TypeString}}, 122 | "_nin": {Type: huma.TypeArray, Items: &huma.Schema{Type: huma.TypeString}}, 123 | }, 124 | AdditionalProperties: false, 125 | } 126 | 127 | // Check if the field has a method named "Operations" 128 | // Then add its custom defined operations to the field schema 129 | if _method, ok := field.Type.MethodByName("Operations"); ok { 130 | var model Model 131 | value := reflect.ValueOf(model).FieldByName(field.Name) 132 | operations := _method.Func.Call([]reflect.Value{value})[0].Interface() 133 | for key := range operations.(map[string]func(string, ...string) string) { 134 | result.Properties[key] = &huma.Schema{ 135 | Type: huma.TypeString, 136 | Items: &huma.Schema{Type: huma.TypeString}, 137 | } 138 | } 139 | } 140 | 141 | return result 142 | case reflect.Struct: 143 | // For fields of struct types, return a schema with a reference to the struct 144 | name := "Where" + huma.DefaultSchemaNamer(_field, "") 145 | return &huma.Schema{ 146 | Ref: "#/components/schemas/" + name, 147 | } 148 | } 149 | 150 | slog.Debug("Unsupported field type for Where", slog.Any("field", field)) 151 | return nil 152 | } 153 | 154 | func (w *Where[Model]) Addr() *map[string]any { 155 | return (*map[string]any)(w) 156 | } 157 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This guide explains how to configure GoCRUD for your application's needs. 4 | 5 | ## Core Configuration 6 | 7 | When registering your models with GoCRUD, you can provide configuration options through the `Config` struct: 8 | 9 | ```go 10 | type Config[Model any] struct { 11 | GetMode Mode 12 | PutMode Mode 13 | PostMode Mode 14 | DeleteMode Mode 15 | 16 | BeforeGet func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error 17 | BeforePut func(ctx context.Context, models *[]Model) error 18 | BeforePost func(ctx context.Context, models *[]Model) error 19 | BeforeDelete func(ctx context.Context, where *map[string]any) error 20 | 21 | AfterGet func(ctx context.Context, models *[]Model) error 22 | AfterPut func(ctx context.Context, models *[]Model) error 23 | AfterPost func(ctx context.Context, models *[]Model) error 24 | AfterDelete func(ctx context.Context, models *[]Model) error 25 | } 26 | ``` 27 | 28 | ## Operation Modes 29 | 30 | GoCRUD supports three operation modes for each CRUD operation: 31 | 32 | ```go 33 | type Mode int 34 | 35 | const ( 36 | BulkSingle Mode = iota // Both bulk and single operations enabled 37 | Single // Only single operations enabled 38 | None // Operation disabled 39 | ) 40 | ``` 41 | 42 | Example configuration: 43 | 44 | ```go 45 | config := &gocrud.Config[User]{ 46 | GetMode: gocrud.BulkSingle, // Enable both GET /users and GET /users/{id} 47 | PutMode: gocrud.Single, // Enable only PUT /users/{id} 48 | PostMode: gocrud.BulkSingle, // Enable both POST /users and POST /users/one 49 | DeleteMode: gocrud.None, // Disable all DELETE operations 50 | } 51 | ``` 52 | 53 | ## Hook Configuration 54 | 55 | Hooks allow you to add custom logic before and after CRUD operations. 56 | 57 | ### Before Hooks 58 | 59 | Before hooks run before the database operation: 60 | 61 | ```go 62 | config := &gocrud.Config[User]{ 63 | BeforePost: func(ctx context.Context, models *[]User) error { 64 | // Validate age before creating users 65 | for _, user := range *models { 66 | if user.Age != nil && *user.Age < 18 { 67 | return fmt.Errorf("users must be 18 or older") 68 | } 69 | } 70 | return nil 71 | }, 72 | } 73 | ``` 74 | 75 | ### After Hooks 76 | 77 | After hooks run after the database operation but before the response is sent: 78 | 79 | ```go 80 | config := &gocrud.Config[User]{ 81 | AfterGet: func(ctx context.Context, models *[]User) error { 82 | // Modify or enrich user data 83 | for i := range *models { 84 | if (*models)[i].Name == nil { 85 | defaultName := "Anonymous" 86 | (*models)[i].Name = &defaultName 87 | } 88 | } 89 | return nil 90 | }, 91 | } 92 | ``` 93 | 94 | ## Model Configuration 95 | 96 | Models are configured using struct tags: 97 | 98 | ```go 99 | type User struct { 100 | _ struct{} `db:"users" json:"-"` // Table name 101 | ID *int `db:"id" json:"id" required:"false"` // Primary key field 102 | Name *string `db:"name" json:"name,omitempty"` // Regular field 103 | Age *int `db:"age" json:"age" minimum:"0"` // Field with validation 104 | } 105 | ``` 106 | 107 | ### Available Tags 108 | 109 | GoCRUD uses the following core tags: 110 | 111 | - `db`: Database column name or table name (on the `_` field) 112 | - `json`: JSON field name and options (e.g., `-` or `omitempty`) 113 | - `src`: Source field name in relationships 114 | - `dest`: Destination field name in relationships 115 | - `table`: Related table name in relationships 116 | 117 | Additional validation tags (like `required`, `minimum`, `maximum`, etc.) are available through the [Huma framework validation tags](https://huma.rocks/). 118 | 119 | ### Relation Configuration 120 | 121 | For related models, additional tags are used: 122 | 123 | ```go 124 | type User struct { 125 | _ struct{} `db:"users" json:"-"` 126 | ID *int `db:"id" json:"id"` 127 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 128 | } 129 | ``` 130 | 131 | Relation tags: 132 | 133 | - `src`: Source field name in the current table 134 | - `dest`: Destination field name in the related table 135 | - `table`: Related table name 136 | 137 | ## Custom Field Operations 138 | 139 | Define custom operations for field types: 140 | 141 | ```go 142 | type CustomID int 143 | 144 | func (_ *CustomID) Operations() map[string]func(string, ...string) string { 145 | return map[string]func(string, ...string) string{ 146 | "_regexp": func(key string, values ...string) string { 147 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 148 | }, 149 | } 150 | } 151 | ``` 152 | 153 | ## Database Configuration 154 | 155 | GoCRUD automatically configures itself based on the database driver: 156 | 157 | ```go 158 | import ( 159 | "database/sql" 160 | _ "github.com/lib/pq" // PostgreSQL 161 | _ "github.com/go-sql-driver/mysql" // MySQL 162 | _ "github.com/mattn/go-sqlite3" // SQLite 163 | _ "github.com/microsoft/go-mssqldb" // MSSQL 164 | ) 165 | 166 | // Database connection 167 | db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/dbname") 168 | 169 | // Repository creation 170 | repo := gocrud.NewSQLRepository[User](db) 171 | ``` 172 | 173 | The SQL dialect and parameter style are automatically configured based on the driver. 174 | -------------------------------------------------------------------------------- /docs/crud-operations.md: -------------------------------------------------------------------------------- 1 | # CRUD Operations 2 | 3 | This guide details the CRUD operations available in GoCRUD and how to use them effectively. 4 | 5 | ## GET Operations 6 | 7 | ### Get Single Resource 8 | 9 | Retrieves a single resource by its ID. 10 | 11 | ```http 12 | GET /users/{id} 13 | ``` 14 | 15 | Response: 16 | 17 | ```json 18 | { 19 | "body": { 20 | "id": 1, 21 | "name": "John Doe", 22 | "age": 30 23 | } 24 | } 25 | ``` 26 | 27 | ### Get Multiple Resources 28 | 29 | Retrieves multiple resources with filtering, sorting, and pagination. 30 | 31 | ```http 32 | GET /users?where={"age":{"_gt":25}}&order={"name":"ASC"}&limit=10&skip=0 33 | ``` 34 | 35 | Response: 36 | 37 | ```json 38 | { 39 | "body": [ 40 | { 41 | "id": 1, 42 | "name": "Alice", 43 | "age": 28 44 | }, 45 | { 46 | "id": 2, 47 | "name": "Bob", 48 | "age": 32 49 | } 50 | ] 51 | } 52 | ``` 53 | 54 | #### Query Parameters 55 | 56 | - `where`: JSON object for filtering 57 | - `order`: JSON object for sorting 58 | - `limit`: Maximum number of items to return 59 | - `skip`: Number of items to skip 60 | 61 | #### Filtering Operators 62 | 63 | - `_eq`: Equal to 64 | - `_neq`: Not equal to 65 | - `_gt`: Greater than 66 | - `_gte`: Greater than or equal to 67 | - `_lt`: Less than 68 | - `_lte`: Less than or equal to 69 | - `_like`: LIKE pattern matching 70 | - `_nlike`: NOT LIKE pattern matching 71 | - `_ilike`: Case-insensitive LIKE 72 | - `_nilike`: Case-insensitive NOT LIKE 73 | - `_in`: In array 74 | - `_nin`: Not in array 75 | 76 | ## POST Operations 77 | 78 | ### Create Single Resource 79 | 80 | Creates a single resource. 81 | 82 | ```http 83 | POST /users/one 84 | Content-Type: application/json 85 | 86 | { 87 | "body": { 88 | "name": "John Doe", 89 | "age": 30 90 | } 91 | } 92 | ``` 93 | 94 | Response: 95 | 96 | ```json 97 | { 98 | "body": { 99 | "id": 1, 100 | "name": "John Doe", 101 | "age": 30 102 | } 103 | } 104 | ``` 105 | 106 | ### Create Multiple Resources 107 | 108 | Creates multiple resources in a single request. 109 | 110 | ```http 111 | POST /users 112 | Content-Type: application/json 113 | 114 | { 115 | "body": [ 116 | { 117 | "name": "John Doe", 118 | "age": 30 119 | }, 120 | { 121 | "name": "Jane Smith", 122 | "age": 25 123 | } 124 | ] 125 | } 126 | ``` 127 | 128 | Response: 129 | 130 | ```json 131 | { 132 | "body": [ 133 | { 134 | "id": 1, 135 | "name": "John Doe", 136 | "age": 30 137 | }, 138 | { 139 | "id": 2, 140 | "name": "Jane Smith", 141 | "age": 25 142 | } 143 | ] 144 | } 145 | ``` 146 | 147 | ## PUT Operations 148 | 149 | ### Update Single Resource 150 | 151 | Updates a single resource by its ID. 152 | 153 | ```http 154 | PUT /users/{id} 155 | Content-Type: application/json 156 | 157 | { 158 | "body": { 159 | "name": "John Smith", 160 | "age": 31 161 | } 162 | } 163 | ``` 164 | 165 | Response: 166 | 167 | ```json 168 | { 169 | "body": { 170 | "id": 1, 171 | "name": "John Smith", 172 | "age": 31 173 | } 174 | } 175 | ``` 176 | 177 | ### Update Multiple Resources 178 | 179 | Updates multiple resources in a single request. 180 | 181 | ```http 182 | PUT /users 183 | Content-Type: application/json 184 | 185 | { 186 | "body": [ 187 | { 188 | "id": 1, 189 | "name": "John Smith", 190 | "age": 31 191 | }, 192 | { 193 | "id": 2, 194 | "name": "Jane Doe", 195 | "age": 26 196 | } 197 | ] 198 | } 199 | ``` 200 | 201 | Response: 202 | 203 | ```json 204 | { 205 | "body": [ 206 | { 207 | "id": 1, 208 | "name": "John Smith", 209 | "age": 31 210 | }, 211 | { 212 | "id": 2, 213 | "name": "Jane Doe", 214 | "age": 26 215 | } 216 | ] 217 | } 218 | ``` 219 | 220 | ## DELETE Operations 221 | 222 | ### Delete Single Resource 223 | 224 | Deletes a single resource by its ID. 225 | 226 | ```http 227 | DELETE /users/{id} 228 | ``` 229 | 230 | Response: 231 | 232 | ```json 233 | { 234 | "body": { 235 | "id": 1, 236 | "name": "John Smith", 237 | "age": 31 238 | } 239 | } 240 | ``` 241 | 242 | ### Delete Multiple Resources 243 | 244 | Deletes multiple resources based on filtering criteria. 245 | 246 | ```http 247 | DELETE /users?where={"age":{"_lt":25}} 248 | ``` 249 | 250 | Response: 251 | 252 | ```json 253 | { 254 | "body": [ 255 | { 256 | "id": 3, 257 | "name": "Alice Young", 258 | "age": 22 259 | }, 260 | { 261 | "id": 4, 262 | "name": "Bob Junior", 263 | "age": 21 264 | } 265 | ] 266 | } 267 | ``` 268 | 269 | ## Advanced Queries 270 | 271 | ### Relation Filtering 272 | 273 | Filter resources based on related entities: 274 | 275 | ```http 276 | GET /users?where={"documents":{"title":{"_like":"Report%"}}} 277 | ``` 278 | 279 | ### Custom Operations 280 | 281 | Use custom field operations if defined: 282 | 283 | ```http 284 | GET /users?where={"id":{"_regexp":"^10.*"}} 285 | ``` 286 | 287 | ### Complex Filters 288 | 289 | Combine multiple conditions: 290 | 291 | ```http 292 | GET /users?where={"_and":[{"age":{"_gt":20}},{"name":{"_like":"J%"}}]} 293 | ``` 294 | 295 | Use OR conditions: 296 | 297 | ```http 298 | GET /users?where={"_or":[{"age":{"_lt":20}},{"age":{"_gt":60}}]} 299 | ``` 300 | 301 | Use NOT conditions: 302 | 303 | ```http 304 | GET /users?where={"_not":{"age":{"_eq":30}}} 305 | ``` 306 | 307 | ## Error Handling 308 | 309 | Common error responses: 310 | 311 | - `400 Bad Request`: Invalid input data 312 | - `404 Not Found`: Resource not found 313 | - `422 Unprocessable Entity`: Validation error 314 | - `500 Internal Server Error`: Server error 315 | 316 | Error response format: 317 | 318 | ```json 319 | { 320 | "error": { 321 | "code": "NOT_FOUND", 322 | "message": "entity not found" 323 | } 324 | } 325 | ``` 326 | -------------------------------------------------------------------------------- /docs/crud-hooks.md: -------------------------------------------------------------------------------- 1 | # CRUD Hooks 2 | 3 | CRUD hooks allow you to add custom logic that executes before or after CRUD operations. This guide explains how to use hooks effectively in your GoCRUD applications. 4 | 5 | ## Available Hooks 6 | 7 | GoCRUD provides both "before" and "after" hooks for each CRUD operation: 8 | 9 | ### Before Hooks 10 | 11 | - `BeforeGet`: Executes before retrieving resources 12 | - `BeforePut`: Executes before updating resources 13 | - `BeforePost`: Executes before creating resources 14 | - `BeforeDelete`: Executes before deleting resources 15 | 16 | ### After Hooks 17 | 18 | - `AfterGet`: Executes after retrieving resources 19 | - `AfterPut`: Executes after updating resources 20 | - `AfterPost`: Executes after creating resources 21 | - `AfterDelete`: Executes after deleting resources 22 | 23 | ## Hook Signatures 24 | 25 | Each hook type has a specific function signature: 26 | 27 | ```go 28 | // Get operation hooks 29 | BeforeGet func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error 30 | AfterGet func(ctx context.Context, models *[]Model) error 31 | 32 | // Put operation hooks 33 | BeforePut func(ctx context.Context, models *[]Model) error 34 | AfterPut func(ctx context.Context, models *[]Model) error 35 | 36 | // Post operation hooks 37 | BeforePost func(ctx context.Context, models *[]Model) error 38 | AfterPost func(ctx context.Context, models *[]Model) error 39 | 40 | // Delete operation hooks 41 | BeforeDelete func(ctx context.Context, where *map[string]any) error 42 | AfterDelete func(ctx context.Context, models *[]Model) error 43 | ``` 44 | 45 | ## Using Hooks 46 | 47 | ### Basic Hook Configuration 48 | 49 | Here's how to configure hooks when registering your model: 50 | 51 | ```go 52 | gocrud.Register(api, repo, &gocrud.Config[User]{ 53 | BeforePost: func(ctx context.Context, models *[]User) error { 54 | // Add validation logic here 55 | return nil 56 | }, 57 | AfterPost: func(ctx context.Context, models *[]User) error { 58 | // Add post-processing logic here 59 | return nil 60 | }, 61 | }) 62 | ``` 63 | 64 | ### Common Use Cases 65 | 66 | #### Input Validation 67 | 68 | ```go 69 | BeforePost: func(ctx context.Context, models *[]User) error { 70 | for _, user := range *models { 71 | if user.Age != nil && *user.Age < 18 { 72 | return fmt.Errorf("users must be 18 or older") 73 | } 74 | } 75 | return nil 76 | } 77 | ``` 78 | 79 | #### Data Enrichment 80 | 81 | ```go 82 | AfterGet: func(ctx context.Context, models *[]User) error { 83 | for i := range *models { 84 | if (*models)[i].Name == nil { 85 | defaultName := "Anonymous" 86 | (*models)[i].Name = &defaultName 87 | } 88 | } 89 | return nil 90 | } 91 | ``` 92 | 93 | #### Access Control 94 | 95 | ```go 96 | BeforeGet: func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error { 97 | userID := ctx.Value("userID").(string) 98 | if userID == "" { 99 | return fmt.Errorf("unauthorized") 100 | } 101 | return nil 102 | } 103 | ``` 104 | 105 | #### Audit Logging 106 | 107 | ```go 108 | AfterDelete: func(ctx context.Context, models *[]User) error { 109 | for _, user := range *models { 110 | log.Printf("User deleted: ID=%v", *user.ID) 111 | } 112 | return nil 113 | } 114 | ``` 115 | 116 | ## Hook Execution Order 117 | 118 | 1. Before hooks execute first, allowing you to: 119 | 120 | - Validate input 121 | - Modify query parameters 122 | - Check permissions 123 | - Cancel the operation by returning an error 124 | 125 | 2. The main operation executes only if the before hook succeeds 126 | 127 | 3. After hooks execute last, allowing you to: 128 | - Modify returned data 129 | - Trigger side effects 130 | - Log operations 131 | - Send notifications 132 | 133 | ## Error Handling 134 | 135 | - Any error returned from a hook will stop the operation 136 | - Before hook errors prevent the main operation from executing 137 | - After hook errors are returned to the client even though the main operation succeeded 138 | 139 | Example error handling: 140 | 141 | ```go 142 | BeforePut: func(ctx context.Context, models *[]User) error { 143 | for _, user := range *models { 144 | if err := validateUser(user); err != nil { 145 | return fmt.Errorf("validation failed: %w", err) 146 | } 147 | } 148 | return nil 149 | } 150 | ``` 151 | 152 | ## Best Practices 153 | 154 | 1. **Keep Hooks Focused**: Each hook should have a single responsibility 155 | 156 | 2. **Handle Errors Gracefully**: Always return meaningful error messages 157 | 158 | 3. **Use Context**: Leverage context for request-scoped data 159 | 160 | 4. **Consider Performance**: Avoid expensive operations in hooks 161 | 162 | 5. **Be Careful with Mutations**: 163 | - Before hooks: Modify input parameters only when necessary 164 | - After hooks: Be cautious when modifying returned data 165 | 166 | ## Example: Complete Hook Configuration 167 | 168 | ```go 169 | config := &gocrud.Config[User]{ 170 | BeforeGet: func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error { 171 | // Add query restrictions 172 | return nil 173 | }, 174 | AfterGet: func(ctx context.Context, models *[]User) error { 175 | // Enrich returned data 176 | return nil 177 | }, 178 | BeforePost: func(ctx context.Context, models *[]User) error { 179 | // Validate new resources 180 | return nil 181 | }, 182 | AfterPost: func(ctx context.Context, models *[]User) error { 183 | // Send notifications 184 | return nil 185 | }, 186 | BeforePut: func(ctx context.Context, models *[]User) error { 187 | // Validate updates 188 | return nil 189 | }, 190 | AfterPut: func(ctx context.Context, models *[]User) error { 191 | // Log changes 192 | return nil 193 | }, 194 | BeforeDelete: func(ctx context.Context, where *map[string]any) error { 195 | // Check delete permissions 196 | return nil 197 | }, 198 | AfterDelete: func(ctx context.Context, models *[]User) error { 199 | // Cleanup related resources 200 | return nil 201 | }, 202 | } 203 | ``` 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoCRUD 2 | 3 | ![GoCRUD](./docs/icon.svg) 4 | 5 | GoCRUD is a powerful Go module that extends [Huma](https://huma.rocks/) to automatically generate CRUD APIs with built-in support for input validation and customizable hooks. It simplifies API development by automating repetitive tasks, allowing you to focus on your business logic. 6 | 7 | ## 🚀 Features 8 | 9 | - **Seamless Huma Integration**: Works effortlessly with the Huma API framework. 10 | - **Automatic CRUD Generation**: Instantly generate RESTful endpoints for your models. 11 | - **Input Validation**: Automatically validates input data. 12 | - **Customizable Hooks**: Add custom logic before or after CRUD operations. 13 | - **Clean and Maintainable**: Keeps your codebase organized and easy to maintain. 14 | 15 | ## 📋 Prerequisites 16 | 17 | - Go 1.22 or higher 18 | - A project using [Huma](https://huma.rocks/) 19 | 20 | ## 🛠️ Installation 21 | 22 | Install GoCRUD using `go get`: 23 | 24 | ```bash 25 | go get github.com/ckoliber/gocrud 26 | ``` 27 | 28 | ## 🎯 Quick Start 29 | 30 | 1. **Define Your Model**: 31 | 32 | ```go 33 | type User struct { 34 | _ struct{} `db:"users" json:"-"` 35 | ID *int `db:"id" json:"id" required:"false"` 36 | Name *string `db:"name" json:"name" required:"false" maxLength:"30" example:"David" doc:"User name"` 37 | Age *int `db:"age" json:"age" required:"false" minimum:"1" maximum:"120" example:"25" doc:"User age from 1 to 120"` 38 | } 39 | ``` 40 | 41 | 2. **Register Your Model with GoCRUD**: 42 | 43 | ```go 44 | package main 45 | 46 | import ( 47 | "github.com/danielgtaylor/huma/v2" 48 | "github.com/ckoliber/gocrud" 49 | "database/sql" 50 | _ "github.com/lib/pq" // Example: PostgreSQL driver 51 | ) 52 | 53 | func main() { 54 | db, _ := sql.Open("postgres", "your-dsn-here") 55 | api := huma.New("My API", "1.0.0") 56 | 57 | repo := gocrud.NewSQLRepository[User](db) 58 | gocrud.Register(api, repo, &gocrud.Config[User]{}) 59 | 60 | api.Serve() 61 | } 62 | ``` 63 | 64 | 3. **Run Your API**: 65 | 66 | Start your application, and GoCRUD will generate the following endpoints for the `User` model: 67 | 68 | - `GET /users` - List all users 69 | - `POST /users` - Create a new user 70 | - `GET /users/{id}` - Get a specific user 71 | - `PUT /users/{id}` - Update a user 72 | - `DELETE /users/{id}` - Delete a user 73 | 74 | ## 🔧 Configuration Options 75 | 76 | GoCRUD provides a flexible configuration system to customize API behavior: 77 | 78 | ```go 79 | type Config[Model any] struct { 80 | GetMode Mode // Configure GET behavior (e.g., single or bulk) 81 | PutMode Mode // Configure PUT behavior 82 | PostMode Mode // Configure POST behavior 83 | DeleteMode Mode // Configure DELETE behavior 84 | 85 | // Add before hooks for custom logic 86 | BeforeGet func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error 87 | BeforePut func(ctx context.Context, models *[]Model) error 88 | BeforePost func(ctx context.Context, models *[]Model) error 89 | BeforeDelete func(ctx context.Context, where *map[string]any) error 90 | 91 | // Add after hooks for custom logic 92 | AfterGet func(ctx context.Context, models *[]Model) error 93 | AfterPut func(ctx context.Context, models *[]Model) error 94 | AfterPost func(ctx context.Context, models *[]Model) error 95 | AfterDelete func(ctx context.Context, models *[]Model) error 96 | } 97 | ``` 98 | 99 | ### Example: Adding Hooks 100 | 101 | ```go 102 | config := &gocrud.Config[User]{ 103 | BeforePost: func(ctx context.Context, models *[]User) error { 104 | for _, user := range *models { 105 | if user.Age < 18 { 106 | return fmt.Errorf("user must be at least 18 years old") 107 | } 108 | } 109 | return nil 110 | }, 111 | } 112 | ``` 113 | 114 | ## 🔰 Advanced Features 115 | 116 | ### Relation Filtering 117 | 118 | GoCRUD supports filtering through relationships. You can query parent entities based on their related entities' properties: 119 | 120 | ```go 121 | type User struct { 122 | _ struct{} `db:"users" json:"-"` 123 | ID *int `db:"id" json:"id"` 124 | Name *string `db:"name" json:"name"` 125 | Documents []Document `db:"documents" src:"id" dest:"userId" table:"documents" json:"-"` 126 | } 127 | 128 | type Document struct { 129 | _ struct{} `db:"documents" json:"-"` 130 | ID *int `db:"id" json:"id"` 131 | Title string `db:"title" json:"title"` 132 | UserID int `db:"userId" json:"userId"` 133 | } 134 | ``` 135 | 136 | You can then filter users by their documents: 137 | 138 | ```http 139 | GET /users?where={"documents":{"title":{"_eq":"Doc4"}}} 140 | ``` 141 | 142 | This will return users who have documents with title "Doc4". 143 | 144 | ### Custom Field Operations 145 | 146 | You can define custom operations for your model fields by implementing the `Operations` method: 147 | 148 | ```go 149 | type ID int 150 | 151 | func (_ *ID) Operations() map[string]func(string, ...string) string { 152 | return map[string]func(string, ...string) string{ 153 | "_regexp": func(key string, values ...string) string { 154 | return fmt.Sprintf("%s REGEXP %s", key, values[0]) 155 | }, 156 | "_iregexp": func(key string, values ...string) string { 157 | return fmt.Sprintf("%s IREGEXP %s", key, values[0]) 158 | }, 159 | } 160 | } 161 | 162 | type User struct { 163 | _ struct{} `db:"users" json:"-"` 164 | ID *ID `db:"id" json:"id"` 165 | Name *string `db:"name" json:"name"` 166 | } 167 | ``` 168 | 169 | Now you can use these custom operations in your queries: 170 | 171 | ```http 172 | GET /users?where={"id":{"_regexp":"5"}} 173 | ``` 174 | 175 | The operations are type-safe and validated against the field's defined operations. 176 | 177 | ## 🤝 Contributing 178 | 179 | We welcome contributions! To contribute: 180 | 181 | 1. Fork the repository. 182 | 2. Create a feature branch: `git checkout -b feature/my-feature`. 183 | 3. Commit your changes: `git commit -m "Add my feature"`. 184 | 4. Push to the branch: `git push origin feature/my-feature`. 185 | 5. Open a pull request. 186 | 187 | ## 📝 License 188 | 189 | This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details. 190 | 191 | ## ✨ Acknowledgments 192 | 193 | - Built on top of the [Huma](https://huma.rocks/) framework. 194 | - Inspired by best practices in the Go community. 195 | - Thanks to all contributors who have helped shape GoCRUD. 196 | 197 | --- 198 | 199 | Made with ❤️ by KoLiBer 200 | -------------------------------------------------------------------------------- /internal/repository/sqlite.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strings" 10 | ) 11 | 12 | // SQLiteRepository provides CRUD operations for SQLite 13 | type SQLiteRepository[Model any] struct { 14 | db *sql.DB 15 | builder *SQLBuilder[Model] 16 | } 17 | 18 | // NewSQLiteRepository initializes a new SQLiteRepository 19 | func NewSQLiteRepository[Model any](db *sql.DB) *SQLiteRepository[Model] { 20 | // Define SQL operators and helper functions for query building 21 | operations := map[string]func(string, ...string) string{ 22 | "_eq": func(key string, values ...string) string { return fmt.Sprintf("%s = %s", key, values[0]) }, 23 | "_neq": func(key string, values ...string) string { return fmt.Sprintf("%s != %s", key, values[0]) }, 24 | "_gt": func(key string, values ...string) string { return fmt.Sprintf("%s > %s", key, values[0]) }, 25 | "_gte": func(key string, values ...string) string { return fmt.Sprintf("%s >= %s", key, values[0]) }, 26 | "_lt": func(key string, values ...string) string { return fmt.Sprintf("%s < %s", key, values[0]) }, 27 | "_lte": func(key string, values ...string) string { return fmt.Sprintf("%s <= %s", key, values[0]) }, 28 | "_like": func(key string, values ...string) string { return fmt.Sprintf("%s LIKE %s", key, values[0]) }, 29 | "_nlike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT LIKE %s", key, values[0]) }, 30 | "_ilike": func(key string, values ...string) string { return fmt.Sprintf("%s ILIKE %s", key, values[0]) }, 31 | "_nilike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT ILIKE %s", key, values[0]) }, 32 | "_in": func(key string, values ...string) string { 33 | return fmt.Sprintf("%s IN (%s)", key, strings.Join(values, ",")) 34 | }, 35 | "_nin": func(key string, values ...string) string { 36 | return fmt.Sprintf("%s NOT IN (%s)", key, strings.Join(values, ",")) 37 | }, 38 | } 39 | identifier := func(name string) string { 40 | return fmt.Sprintf("\"%s\"", name) 41 | } 42 | parameter := func(value reflect.Value, args *[]any) string { 43 | *args = append(*args, value.Interface()) 44 | return fmt.Sprintf("$%d", len(*args)) 45 | } 46 | 47 | return &SQLiteRepository[Model]{ 48 | db: db, 49 | builder: NewSQLBuilder[Model](operations, identifier, parameter, nil), 50 | } 51 | } 52 | 53 | // Get retrieves records from the database based on the provided filters 54 | func (r *SQLiteRepository[Model]) Get(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) ([]Model, error) { 55 | args := []any{} 56 | query := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 57 | if expr := r.builder.Where(where, &args, nil); expr != "" { 58 | query += fmt.Sprintf(" WHERE %s", expr) 59 | } 60 | if expr := r.builder.Order(order); expr != "" { 61 | query += fmt.Sprintf(" ORDER BY %s", expr) 62 | } 63 | if limit != nil && *limit > 0 { 64 | query += fmt.Sprintf(" LIMIT %d", *limit) 65 | } 66 | if skip != nil && *skip > 0 { 67 | query += fmt.Sprintf(" OFFSET %d", *skip) 68 | } 69 | 70 | slog.Info("Executing Get query", slog.String("query", query), slog.Any("args", args)) 71 | 72 | // Execute the query and scan the results 73 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 74 | if err != nil { 75 | slog.Error("Error executing Get query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 76 | return nil, err 77 | } 78 | 79 | return result, nil 80 | } 81 | 82 | // Put updates existing records in the database 83 | func (r *SQLiteRepository[Model]) Put(ctx context.Context, models *[]Model) ([]Model, error) { 84 | result := []Model{} 85 | 86 | // Begin a transaction 87 | tx, err := r.db.BeginTx(ctx, nil) 88 | if err != nil { 89 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 90 | return nil, err 91 | } 92 | 93 | // Update each model in the database 94 | for _, model := range *models { 95 | args := []any{} 96 | where := map[string]any{} 97 | query := fmt.Sprintf("UPDATE %s SET %s", r.builder.Table(), r.builder.Set(&model, &args, &where)) 98 | if expr := r.builder.Where(&where, &args, nil); expr != "" { 99 | query += fmt.Sprintf(" WHERE %s", expr) 100 | } 101 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 102 | 103 | slog.Info("Executing Put query", slog.String("query", query), slog.Any("args", args)) 104 | 105 | items, err := r.builder.Scan(tx.QueryContext(ctx, query, args...)) 106 | if err != nil { 107 | slog.Error("Error executing Put query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 108 | tx.Rollback() 109 | return nil, err 110 | } 111 | 112 | result = append(result, items...) 113 | } 114 | 115 | // Commit the transaction 116 | if err := tx.Commit(); err != nil { 117 | slog.Error("Error committing transaction for Put", slog.Any("error", err)) 118 | return nil, err 119 | } 120 | 121 | return result, nil 122 | } 123 | 124 | // Post inserts new records into the database 125 | func (r *SQLiteRepository[Model]) Post(ctx context.Context, models *[]Model) ([]Model, error) { 126 | args := []any{} 127 | query := fmt.Sprintf("INSERT INTO %s", r.builder.Table()) 128 | if fields, values := r.builder.Values(models, &args, nil); fields != "" && values != "" { 129 | query += fmt.Sprintf(" (%s) VALUES %s", fields, values) 130 | } 131 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 132 | 133 | slog.Info("Executing Post query", slog.String("query", query), slog.Any("args", args)) 134 | 135 | // Execute the query and scan the results 136 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 137 | if err != nil { 138 | slog.Error("Error executing Post query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 139 | return nil, err 140 | } 141 | 142 | return result, nil 143 | } 144 | 145 | // Delete removes records from the database based on the provided filters 146 | func (r *SQLiteRepository[Model]) Delete(ctx context.Context, where *map[string]any) ([]Model, error) { 147 | args := []any{} 148 | query := fmt.Sprintf("DELETE FROM %s", r.builder.Table()) 149 | if expr := r.builder.Where(where, &args, nil); expr != "" { 150 | query += fmt.Sprintf(" WHERE %s", expr) 151 | } 152 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 153 | 154 | slog.Info("Executing Delete query", slog.String("query", query), slog.Any("args", args)) 155 | 156 | // Execute the query and scan the results 157 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 158 | if err != nil { 159 | slog.Error("Error executing Delete query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 160 | return nil, err 161 | } 162 | 163 | return result, nil 164 | } 165 | -------------------------------------------------------------------------------- /internal/repository/postgres.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strings" 10 | ) 11 | 12 | // PostgresRepository provides CRUD operations for Postgres 13 | type PostgresRepository[Model any] struct { 14 | db *sql.DB 15 | builder *SQLBuilder[Model] 16 | } 17 | 18 | // NewPostgresRepository initializes a new PostgresRepository 19 | func NewPostgresRepository[Model any](db *sql.DB) *PostgresRepository[Model] { 20 | // Define SQL operators and helper functions for query building 21 | operations := map[string]func(string, ...string) string{ 22 | "_eq": func(key string, values ...string) string { return fmt.Sprintf("%s = %s", key, values[0]) }, 23 | "_neq": func(key string, values ...string) string { return fmt.Sprintf("%s != %s", key, values[0]) }, 24 | "_gt": func(key string, values ...string) string { return fmt.Sprintf("%s > %s", key, values[0]) }, 25 | "_gte": func(key string, values ...string) string { return fmt.Sprintf("%s >= %s", key, values[0]) }, 26 | "_lt": func(key string, values ...string) string { return fmt.Sprintf("%s < %s", key, values[0]) }, 27 | "_lte": func(key string, values ...string) string { return fmt.Sprintf("%s <= %s", key, values[0]) }, 28 | "_like": func(key string, values ...string) string { return fmt.Sprintf("%s LIKE %s", key, values[0]) }, 29 | "_nlike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT LIKE %s", key, values[0]) }, 30 | "_ilike": func(key string, values ...string) string { return fmt.Sprintf("%s ILIKE %s", key, values[0]) }, 31 | "_nilike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT ILIKE %s", key, values[0]) }, 32 | "_in": func(key string, values ...string) string { 33 | return fmt.Sprintf("%s IN (%s)", key, strings.Join(values, ",")) 34 | }, 35 | "_nin": func(key string, values ...string) string { 36 | return fmt.Sprintf("%s NOT IN (%s)", key, strings.Join(values, ",")) 37 | }, 38 | } 39 | identifier := func(name string) string { 40 | return fmt.Sprintf("\"%s\"", name) 41 | } 42 | parameter := func(value reflect.Value, args *[]any) string { 43 | *args = append(*args, value.Interface()) 44 | return fmt.Sprintf("$%d", len(*args)) 45 | } 46 | 47 | return &PostgresRepository[Model]{ 48 | db: db, 49 | builder: NewSQLBuilder[Model](operations, identifier, parameter, nil), 50 | } 51 | } 52 | 53 | // Get retrieves records from the database based on the provided filters 54 | func (r *PostgresRepository[Model]) Get(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) ([]Model, error) { 55 | args := []any{} 56 | query := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 57 | if expr := r.builder.Where(where, &args, nil); expr != "" { 58 | query += fmt.Sprintf(" WHERE %s", expr) 59 | } 60 | if expr := r.builder.Order(order); expr != "" { 61 | query += fmt.Sprintf(" ORDER BY %s", expr) 62 | } 63 | if limit != nil && *limit > 0 { 64 | query += fmt.Sprintf(" LIMIT %d", *limit) 65 | } 66 | if skip != nil && *skip > 0 { 67 | query += fmt.Sprintf(" OFFSET %d", *skip) 68 | } 69 | 70 | slog.Info("Executing Get query", slog.String("query", query), slog.Any("args", args)) 71 | 72 | // Execute the query and scan the results 73 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 74 | if err != nil { 75 | slog.Error("Error executing Get query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 76 | return nil, err 77 | } 78 | 79 | return result, nil 80 | } 81 | 82 | // Put updates existing records in the database 83 | func (r *PostgresRepository[Model]) Put(ctx context.Context, models *[]Model) ([]Model, error) { 84 | result := []Model{} 85 | 86 | // Begin a transaction 87 | tx, err := r.db.BeginTx(ctx, nil) 88 | if err != nil { 89 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 90 | return nil, err 91 | } 92 | 93 | // Update each model in the database 94 | for _, model := range *models { 95 | args := []any{} 96 | where := map[string]any{} 97 | query := fmt.Sprintf("UPDATE %s SET %s", r.builder.Table(), r.builder.Set(&model, &args, &where)) 98 | if expr := r.builder.Where(&where, &args, nil); expr != "" { 99 | query += fmt.Sprintf(" WHERE %s", expr) 100 | } 101 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 102 | 103 | slog.Info("Executing Put query", slog.String("query", query), slog.Any("args", args)) 104 | 105 | items, err := r.builder.Scan(tx.QueryContext(ctx, query, args...)) 106 | if err != nil { 107 | slog.Error("Error executing Put query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 108 | tx.Rollback() 109 | return nil, err 110 | } 111 | 112 | result = append(result, items...) 113 | } 114 | 115 | // Commit the transaction 116 | if err := tx.Commit(); err != nil { 117 | slog.Error("Error committing transaction for Put", slog.Any("error", err)) 118 | return nil, err 119 | } 120 | 121 | return result, nil 122 | } 123 | 124 | // Post inserts new records into the database 125 | func (r *PostgresRepository[Model]) Post(ctx context.Context, models *[]Model) ([]Model, error) { 126 | args := []any{} 127 | query := fmt.Sprintf("INSERT INTO %s", r.builder.Table()) 128 | if fields, values := r.builder.Values(models, &args, nil); fields != "" && values != "" { 129 | query += fmt.Sprintf(" (%s) VALUES %s", fields, values) 130 | } 131 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 132 | 133 | slog.Info("Executing Post query", slog.String("query", query), slog.Any("args", args)) 134 | 135 | // Execute the query and scan the results 136 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 137 | if err != nil { 138 | slog.Error("Error executing Post query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 139 | return nil, err 140 | } 141 | 142 | return result, nil 143 | } 144 | 145 | // Delete removes records from the database based on the provided filters 146 | func (r *PostgresRepository[Model]) Delete(ctx context.Context, where *map[string]any) ([]Model, error) { 147 | args := []any{} 148 | query := fmt.Sprintf("DELETE FROM %s", r.builder.Table()) 149 | if expr := r.builder.Where(where, &args, nil); expr != "" { 150 | query += fmt.Sprintf(" WHERE %s", expr) 151 | } 152 | query += fmt.Sprintf(" RETURNING %s", r.builder.Fields("")) 153 | 154 | slog.Info("Executing Delete query", slog.String("query", query), slog.Any("args", args)) 155 | 156 | // Execute the query and scan the results 157 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 158 | if err != nil { 159 | slog.Error("Error executing Delete query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 160 | return nil, err 161 | } 162 | 163 | return result, nil 164 | } 165 | -------------------------------------------------------------------------------- /internal/repository/mssql.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strings" 10 | ) 11 | 12 | // MSSQLRepository provides CRUD operations for MSSQL 13 | type MSSQLRepository[Model any] struct { 14 | db *sql.DB 15 | builder *SQLBuilder[Model] 16 | } 17 | 18 | // NewMSSQLRepository initializes a new MSSQLRepository 19 | func NewMSSQLRepository[Model any](db *sql.DB) *MSSQLRepository[Model] { 20 | // Define SQL operators and helper functions for query building 21 | operations := map[string]func(string, ...string) string{ 22 | "_eq": func(key string, values ...string) string { return fmt.Sprintf("%s = %s", key, values[0]) }, 23 | "_neq": func(key string, values ...string) string { return fmt.Sprintf("%s != %s", key, values[0]) }, 24 | "_gt": func(key string, values ...string) string { return fmt.Sprintf("%s > %s", key, values[0]) }, 25 | "_gte": func(key string, values ...string) string { return fmt.Sprintf("%s >= %s", key, values[0]) }, 26 | "_lt": func(key string, values ...string) string { return fmt.Sprintf("%s < %s", key, values[0]) }, 27 | "_lte": func(key string, values ...string) string { return fmt.Sprintf("%s <= %s", key, values[0]) }, 28 | "_like": func(key string, values ...string) string { return fmt.Sprintf("%s LIKE %s", key, values[0]) }, 29 | "_nlike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT LIKE %s", key, values[0]) }, 30 | "_ilike": func(key string, values ...string) string { return fmt.Sprintf("%s ILIKE %s", key, values[0]) }, 31 | "_nilike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT ILIKE %s", key, values[0]) }, 32 | "_in": func(key string, values ...string) string { 33 | return fmt.Sprintf("%s IN (%s)", key, strings.Join(values, ",")) 34 | }, 35 | "_nin": func(key string, values ...string) string { 36 | return fmt.Sprintf("%s NOT IN (%s)", key, strings.Join(values, ",")) 37 | }, 38 | } 39 | identifier := func(name string) string { 40 | return fmt.Sprintf("[%s]", name) 41 | } 42 | parameter := func(value reflect.Value, args *[]any) string { 43 | *args = append(*args, value.Interface()) 44 | return fmt.Sprintf("@p%d", len(*args)) 45 | } 46 | 47 | return &MSSQLRepository[Model]{ 48 | db: db, 49 | builder: NewSQLBuilder[Model](operations, identifier, parameter, nil), 50 | } 51 | } 52 | 53 | // Get retrieves records from the database based on the provided filters 54 | func (r *MSSQLRepository[Model]) Get(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) ([]Model, error) { 55 | args := []any{} 56 | query := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 57 | if expr := r.builder.Where(where, &args, nil); expr != "" { 58 | query += fmt.Sprintf(" WHERE %s", expr) 59 | } 60 | if expr := r.builder.Order(order); expr != "" { 61 | query += fmt.Sprintf(" ORDER BY %s", expr) 62 | } 63 | if skip != nil && *skip > 0 { 64 | query += fmt.Sprintf(" OFFSET %d ROWS", *skip) 65 | } 66 | if limit != nil && *limit > 0 { 67 | query += fmt.Sprintf(" FETCH NEXT %d ROWS ONLY", *limit) 68 | } 69 | 70 | slog.Info("Executing Get query", slog.String("query", query), slog.Any("args", args)) 71 | 72 | // Execute the query and scan the results 73 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 74 | if err != nil { 75 | slog.Error("Error executing Get query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 76 | return nil, err 77 | } 78 | 79 | return result, nil 80 | } 81 | 82 | // Put updates existing records in the database 83 | func (r *MSSQLRepository[Model]) Put(ctx context.Context, models *[]Model) ([]Model, error) { 84 | result := []Model{} 85 | 86 | // Begin a transaction 87 | tx, err := r.db.BeginTx(ctx, nil) 88 | if err != nil { 89 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 90 | return nil, err 91 | } 92 | 93 | // Update each model in the database 94 | for _, model := range *models { 95 | args := []any{} 96 | where := map[string]any{} 97 | query := fmt.Sprintf("UPDATE %s SET %s", r.builder.Table(), r.builder.Set(&model, &args, &where)) 98 | query += fmt.Sprintf(" OUTPUT %s", r.builder.Fields("INSERTED.")) 99 | if expr := r.builder.Where(&where, &args, nil); expr != "" { 100 | query += fmt.Sprintf(" WHERE %s", expr) 101 | } 102 | 103 | slog.Info("Executing Put query", slog.String("query", query), slog.Any("args", args)) 104 | 105 | items, err := r.builder.Scan(tx.QueryContext(ctx, query, args...)) 106 | if err != nil { 107 | slog.Error("Error executing Put query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 108 | tx.Rollback() 109 | return nil, err 110 | } 111 | 112 | result = append(result, items...) 113 | } 114 | 115 | // Commit the transaction 116 | if err := tx.Commit(); err != nil { 117 | slog.Error("Error committing transaction for Put", slog.Any("error", err)) 118 | return nil, err 119 | } 120 | 121 | return result, nil 122 | } 123 | 124 | // Post inserts new records into the database 125 | func (r *MSSQLRepository[Model]) Post(ctx context.Context, models *[]Model) ([]Model, error) { 126 | args := []any{} 127 | query := fmt.Sprintf("INSERT INTO %s", r.builder.Table()) 128 | if fields, values := r.builder.Values(models, &args, nil); fields != "" && values != "" { 129 | query += fmt.Sprintf(" (%s)", fields) 130 | query += fmt.Sprintf(" OUTPUT %s", r.builder.Fields("INSERTED.")) 131 | query += fmt.Sprintf(" VALUES %s", values) 132 | } 133 | 134 | slog.Info("Executing Post query", slog.String("query", query), slog.Any("args", args)) 135 | 136 | // Execute the query and scan the results 137 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 138 | if err != nil { 139 | slog.Error("Error executing Post query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 140 | return nil, err 141 | } 142 | 143 | return result, nil 144 | } 145 | 146 | // Delete removes records from the database based on the provided filters 147 | func (r *MSSQLRepository[Model]) Delete(ctx context.Context, where *map[string]any) ([]Model, error) { 148 | args := []any{} 149 | query := fmt.Sprintf("DELETE FROM %s", r.builder.Table()) 150 | query += fmt.Sprintf(" OUTPUT %s", r.builder.Fields("DELETED.")) 151 | if expr := r.builder.Where(where, &args, nil); expr != "" { 152 | query += fmt.Sprintf(" WHERE %s", expr) 153 | } 154 | 155 | slog.Info("Executing Delete query", slog.String("query", query), slog.Any("args", args)) 156 | 157 | // Execute the query and scan the results 158 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 159 | if err != nil { 160 | slog.Error("Error executing Delete query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 161 | return nil, err 162 | } 163 | 164 | return result, nil 165 | } 166 | -------------------------------------------------------------------------------- /example/go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1 h1:E+OJmp2tPvt1W+amx48v1eqbjDYsgN+RzP4q16yV5eM= 4 | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.11.1/go.mod h1:a6xsAQUZg+VsS3TJ05SRp524Hs4pZ/AeFSr5ENf0Yjo= 5 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg= 6 | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg= 7 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0 h1:jBQA3cKT4L2rWMpgE7Yt3Hwh2aUj8KXjIGLxjHeYNNo= 8 | github.com/Azure/azure-sdk-for-go/sdk/internal v1.8.0/go.mod h1:4OG6tQ9EOP/MT0NMjDlRzWoVFxfu9rN9B2X+tlSVktg= 9 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1 h1:MyVTgWR8qd/Jw1Le0NZebGBUCLbtak3bJ3z1OlqZBpw= 10 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.0.1/go.mod h1:GpPjLhVR9dnUoJMyHWSPy71xY9/lcmpzIPZXmF0FCVY= 11 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80= 12 | github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI= 13 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= 14 | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 16 | github.com/danielgtaylor/huma/v2 v2.32.0 h1:ytU9ExG/axC434+soXxwNzv0uaxOb3cyCgjj8y3PmBE= 17 | github.com/danielgtaylor/huma/v2 v2.32.0/go.mod h1:9BxJwkeoPPDEJ2Bg4yPwL1mM1rYpAwCAWFKoo723spk= 18 | github.com/danielgtaylor/mexpr v1.9.0 h1:9ZDghCLBJ88ZTUkDn/cxyK4KmAJvStCEe+ECN2EoMa4= 19 | github.com/danielgtaylor/mexpr v1.9.0/go.mod h1:kAivYNRnBeE/IJinqBvVFvLrX54xX//9zFYwADo4Bc8= 20 | github.com/danielgtaylor/shorthand/v2 v2.2.0 h1:hVsemdRq6v3JocP6YRTfu9rOoghZI9PFmkngdKqzAVQ= 21 | github.com/danielgtaylor/shorthand/v2 v2.2.0/go.mod h1:t5QfaNf7DPru9ZLIIhPQSO7Gyvajm3euw7LxB/MTUqE= 22 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 23 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 24 | github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= 25 | github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= 26 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 27 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 28 | github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= 29 | github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 30 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 31 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 32 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= 33 | github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 34 | github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= 35 | github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= 36 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 41 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 42 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 43 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 44 | github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU= 45 | github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 46 | github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= 47 | github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 48 | github.com/microsoft/go-mssqldb v1.8.0 h1:7cyZ/AT7ycDsEoWPIXibd+aVKFtteUNhDGf3aobP+tw= 49 | github.com/microsoft/go-mssqldb v1.8.0/go.mod h1:6znkekS3T2vp0waiMhen4GPU1BiAsrP+iXHcE7a7rFo= 50 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= 51 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= 52 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 53 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 54 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 55 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 56 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 57 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 58 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 59 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 60 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 61 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 62 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 63 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 64 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 65 | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= 66 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 67 | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 68 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 69 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 70 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 71 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 72 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 73 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 74 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 75 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 76 | -------------------------------------------------------------------------------- /gocrud.go: -------------------------------------------------------------------------------- 1 | package gocrud 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "reflect" 10 | 11 | "github.com/danielgtaylor/huma/v2" 12 | 13 | "github.com/ckoliber/gocrud/internal/repository" 14 | "github.com/ckoliber/gocrud/internal/service" 15 | ) 16 | 17 | type Mode int 18 | 19 | const ( 20 | BulkSingle Mode = iota 21 | Single 22 | None 23 | ) 24 | 25 | type Config[Model any] struct { 26 | GetMode Mode 27 | PutMode Mode 28 | PostMode Mode 29 | DeleteMode Mode 30 | 31 | BeforeGet func(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) error 32 | BeforePut func(ctx context.Context, models *[]Model) error 33 | BeforePost func(ctx context.Context, models *[]Model) error 34 | BeforeDelete func(ctx context.Context, where *map[string]any) error 35 | 36 | AfterGet func(ctx context.Context, models *[]Model) error 37 | AfterPut func(ctx context.Context, models *[]Model) error 38 | AfterPost func(ctx context.Context, models *[]Model) error 39 | AfterDelete func(ctx context.Context, models *[]Model) error 40 | } 41 | 42 | // Register sets up CRUD operations for the given API and repository based on the provided configuration. 43 | func Register[Model any](api huma.API, repo repository.Repository[Model], config *Config[Model]) { 44 | // Initialize CRUD service with hooks 45 | svc := service.NewCRUDService(repo, &service.CRUDHooks[Model]{ 46 | BeforeGet: config.BeforeGet, 47 | BeforePut: config.BeforePut, 48 | BeforePost: config.BeforePost, 49 | BeforeDelete: config.BeforeDelete, 50 | AfterGet: config.AfterGet, 51 | AfterPut: config.AfterPut, 52 | AfterPost: config.AfterPost, 53 | AfterDelete: config.AfterDelete, 54 | }) 55 | 56 | // Get path for operations 57 | path := svc.GetPath() 58 | 59 | // Register Get operations 60 | if config.GetMode <= Single { 61 | slog.Debug("Registering GetSingle operation", slog.String("path", path+"/{id}")) 62 | huma.Register(api, huma.Operation{ 63 | OperationID: fmt.Sprintf("get-single-%s", svc.GetName()), 64 | Summary: fmt.Sprintf("Get single-%s", svc.GetName()), 65 | Description: fmt.Sprintf("Retrieves a single %s by its unique identifier. Returns full resource representation.", svc.GetName()), 66 | Path: path + "/{id}", 67 | Method: http.MethodGet, 68 | }, svc.GetSingle) 69 | } 70 | if config.GetMode <= BulkSingle { 71 | slog.Debug("Registering GetBulk operation", slog.String("path", path)) 72 | huma.Register(api, huma.Operation{ 73 | OperationID: fmt.Sprintf("get-bulk-%s", svc.GetName()), 74 | Summary: fmt.Sprintf("Get bulk-%s", svc.GetName()), 75 | Description: fmt.Sprintf("Returns a paginated list of %s resources. Supports filtering, sorting and pagination parameters.", svc.GetName()), 76 | Path: path, 77 | Method: http.MethodGet, 78 | }, svc.GetBulk) 79 | } 80 | 81 | // Register Put operations 82 | if config.PutMode <= Single { 83 | slog.Debug("Registering PutSingle operation", slog.String("path", path+"/{id}")) 84 | huma.Register(api, huma.Operation{ 85 | OperationID: fmt.Sprintf("put-single-%s", svc.GetName()), 86 | Summary: fmt.Sprintf("Put single-%s", svc.GetName()), 87 | Description: fmt.Sprintf("Full update operation for a %s resource. Requires complete resource representation.", svc.GetName()), 88 | Path: path + "/{id}", 89 | Method: http.MethodPut, 90 | }, svc.PutSingle) 91 | } 92 | if config.PutMode <= BulkSingle { 93 | slog.Debug("Registering PutBulk operation", slog.String("path", path)) 94 | huma.Register(api, huma.Operation{ 95 | OperationID: fmt.Sprintf("put-bulk-%s", svc.GetName()), 96 | Summary: fmt.Sprintf("Put bulk-%s", svc.GetName()), 97 | Description: fmt.Sprintf("Batch update operation for multiple %s resources. Each resource requires complete representation.", svc.GetName()), 98 | Path: path, 99 | Method: http.MethodPut, 100 | }, svc.PutBulk) 101 | } 102 | 103 | // Register Post operations 104 | if config.PostMode <= Single { 105 | slog.Debug("Registering PostSingle operation", slog.String("path", path+"/one")) 106 | huma.Register(api, huma.Operation{ 107 | OperationID: fmt.Sprintf("post-single-%s", svc.GetName()), 108 | Summary: fmt.Sprintf("Post single-%s", svc.GetName()), 109 | Description: fmt.Sprintf("Creates a new %s resource. Returns the created resource with generated identifier.", svc.GetName()), 110 | Path: path + "/one", 111 | Method: http.MethodPost, 112 | }, svc.PostSingle) 113 | } 114 | if config.PostMode <= BulkSingle { 115 | slog.Debug("Registering PostBulk operation", slog.String("path", path)) 116 | huma.Register(api, huma.Operation{ 117 | OperationID: fmt.Sprintf("post-bulk-%s", svc.GetName()), 118 | Summary: fmt.Sprintf("Post bulk-%s", svc.GetName()), 119 | Description: fmt.Sprintf("Batch creation operation for multiple %s resources. Returns created resources with generated identifiers.", svc.GetName()), 120 | Path: path, 121 | Method: http.MethodPost, 122 | }, svc.PostBulk) 123 | } 124 | 125 | // Register Delete operations 126 | if config.DeleteMode <= Single { 127 | slog.Debug("Registering DeleteSingle operation", slog.String("path", path+"/{id}")) 128 | huma.Register(api, huma.Operation{ 129 | OperationID: fmt.Sprintf("delete-single-%s", svc.GetName()), 130 | Summary: fmt.Sprintf("Delete single-%s", svc.GetName()), 131 | Description: fmt.Sprintf("Permanently removes a %s resource by its identifier. This operation cannot be undone.", svc.GetName()), 132 | Path: path + "/{id}", 133 | Method: http.MethodDelete, 134 | }, svc.DeleteSingle) 135 | } 136 | if config.DeleteMode <= BulkSingle { 137 | slog.Debug("Registering DeleteBulk operation", slog.String("path", path)) 138 | huma.Register(api, huma.Operation{ 139 | OperationID: fmt.Sprintf("delete-bulk-%s", svc.GetName()), 140 | Summary: fmt.Sprintf("Delete bulk-%s", svc.GetName()), 141 | Description: fmt.Sprintf("Batch deletion operation for multiple %s resources. This operation cannot be undone.", svc.GetName()), 142 | Path: path, 143 | Method: http.MethodDelete, 144 | }, svc.DeleteBulk) 145 | } 146 | } 147 | 148 | // NewSQLRepository initializes a repository based on the SQL database driver. 149 | func NewSQLRepository[Model any](db *sql.DB) repository.Repository[Model] { 150 | // Determine the database driver and initialize the appropriate repository 151 | driverType := reflect.ValueOf(db.Driver()).Type().String() 152 | slog.Debug("Initializing SQL repository", slog.String("driver", driverType)) 153 | 154 | switch driverType { 155 | case "*mysql.MySQLDriver": 156 | slog.Debug("Using MySQL repository") 157 | return repository.NewMySQLRepository[Model](db) 158 | case "*pq.Driver", "pqx.Driver": 159 | slog.Debug("Using Postgres repository") 160 | return repository.NewPostgresRepository[Model](db) 161 | case "*sqlite3.SQLiteDriver": 162 | slog.Debug("Using SQLite repository") 163 | return repository.NewSQLiteRepository[Model](db) 164 | case "*mssql.Driver": 165 | slog.Debug("Using MSSQL repository") 166 | return repository.NewMSSQLRepository[Model](db) 167 | } 168 | 169 | slog.Error("Unsupported database driver", slog.String("driver", driverType)) 170 | panic("unsupported database driver " + driverType) 171 | } 172 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.6.1](https://github.com/ckoliber/gocrud/compare/v1.6.0...v1.6.1) (2025-05-02) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **deps:** update module github.com/stretchr/testify to v1.10.0 ([4db5b67](https://github.com/ckoliber/gocrud/commit/4db5b676f6e52a5e6fd318dc03967de6b915cae9)) 7 | 8 | # [1.6.0](https://github.com/ckoliber/gocrud/compare/v1.5.0...v1.6.0) (2025-05-02) 9 | 10 | 11 | ### Features 12 | 13 | * add tests ([761c950](https://github.com/ckoliber/gocrud/commit/761c950d5dd416e1070a85526decc82f82c59d2b)) 14 | * add tests to CI ([7ac873c](https://github.com/ckoliber/gocrud/commit/7ac873c9fa37fb801b306cfa5ac442c3b1cf0d10)) 15 | 16 | # [1.5.0](https://github.com/ckoliber/gocrud/compare/v1.4.2...v1.5.0) (2025-04-22) 17 | 18 | 19 | ### Features 20 | 21 | * add auth example ([8a01545](https://github.com/ckoliber/gocrud/commit/8a01545bd8d67df7b0119a91e3e726bf593f1393)) 22 | * add base path of operations ([db70b64](https://github.com/ckoliber/gocrud/commit/db70b641733b1cfc88c86ab9c095c01445a9e6b3)) 23 | 24 | ## [1.4.2](https://github.com/ckoliber/gocrud/compare/v1.4.1...v1.4.2) (2025-04-17) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **deps:** update module github.com/mattn/go-sqlite3 to v1.14.28 ([3bbe7cf](https://github.com/ckoliber/gocrud/commit/3bbe7cff5084fadacf15a43b3cdab60fd10e8e2c)) 30 | 31 | ## [1.4.1](https://github.com/ckoliber/gocrud/compare/v1.4.0...v1.4.1) (2025-04-12) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * remove nil Addr on Optional types ([032e5ad](https://github.com/ckoliber/gocrud/commit/032e5ad23bd66a772110ea92dc4fb09261aff3bf)) 37 | 38 | # [1.4.0](https://github.com/ckoliber/gocrud/compare/v1.3.0...v1.4.0) (2025-04-11) 39 | 40 | 41 | ### Features 42 | 43 | * add optional skip, limit parameters type ([1410fb1](https://github.com/ckoliber/gocrud/commit/1410fb17722952137b8f277919cbcbf8c4764f29)) 44 | 45 | # [1.3.0](https://github.com/ckoliber/gocrud/compare/v1.2.1...v1.3.0) (2025-04-11) 46 | 47 | 48 | ### Bug Fixes 49 | 50 | * change relation resolver reflect tags ([eaa33ff](https://github.com/ckoliber/gocrud/commit/eaa33ff1f58fd0a61a93e86b504db7d86d9e8b22)) 51 | * change schema field checking conditions ([e0cecd8](https://github.com/ckoliber/gocrud/commit/e0cecd83d53b05a37d96b8d89c9d00fdd4eb56e3)) 52 | 53 | 54 | ### Features 55 | 56 | * add custom operations per field of model ([2b09395](https://github.com/ckoliber/gocrud/commit/2b093952e3f24cd5d38e7c68130c97bd40cab89e)) 57 | * add relations generator to Order, Where schema ([e7f8c71](https://github.com/ckoliber/gocrud/commit/e7f8c718332e6f3072c108411e094ebae5c5ccbb)) 58 | * add relations to base repository ([b8250ba](https://github.com/ckoliber/gocrud/commit/b8250bab4d7a340bbd95770de9dba7a7322edc95)) 59 | * add runnable method to Where for relation condition resolving (for drivers which doesn't support select in where) ([7723b18](https://github.com/ckoliber/gocrud/commit/7723b183a3c971030806725e9c18b4acfc87d0ed)) 60 | * add SQLBuilder registry and recursive relation resolver ([a335ea1](https://github.com/ckoliber/gocrud/commit/a335ea16eb3f956ec3d3555156f79267487f6fd3)) 61 | 62 | ## [1.2.1](https://github.com/ckoliber/gocrud/compare/v1.2.0...v1.2.1) (2025-04-08) 63 | 64 | 65 | ### Bug Fixes 66 | 67 | * **deps:** update module github.com/ckoliber/gocrud to v1.2.0 ([86d6252](https://github.com/ckoliber/gocrud/commit/86d625247e51675d8f36710d3dfc0071ce315dc2)) 68 | 69 | # [1.2.0](https://github.com/ckoliber/gocrud/compare/v1.1.0...v1.2.0) (2025-04-08) 70 | 71 | 72 | ### Features 73 | 74 | * add docs pages ([b46e728](https://github.com/ckoliber/gocrud/commit/b46e728ab51af8a66273dcc6d12787caf45cad77)) 75 | 76 | # [1.1.0](https://github.com/ckoliber/gocrud/compare/v1.0.0...v1.1.0) (2025-04-08) 77 | 78 | 79 | ### Features 80 | 81 | * add docs ([43761bb](https://github.com/ckoliber/gocrud/commit/43761bb08516b0f15e26a4abd37003030979a1e0)) 82 | 83 | # 1.0.0 (2025-04-08) 84 | 85 | 86 | ### Bug Fixes 87 | 88 | * change CI token ([aa9b742](https://github.com/ckoliber/gocrud/commit/aa9b742b2bc059bbb2d973115da8e50ff8ae86b1)) 89 | * change Post method ([9539b9b](https://github.com/ckoliber/gocrud/commit/9539b9b93697c0682be29f9af1caf4bd74864de1)) 90 | * change tag format ([db93fa0](https://github.com/ckoliber/gocrud/commit/db93fa0fe18f13f85664239b9175e18a00335d15)) 91 | * panic problem in WhereToString ([b26e57c](https://github.com/ckoliber/gocrud/commit/b26e57cccf15bf17fb714485d0d2d95727e50a66)) 92 | * remove unused dependencies ([d9a758d](https://github.com/ckoliber/gocrud/commit/d9a758de88554716022c13b91d6bd3e640395fa1)) 93 | * skip key fields in Set method ([7fed676](https://github.com/ckoliber/gocrud/commit/7fed676375869cb4bdc5b14b590002a8149f400c)) 94 | 95 | 96 | ### Features 97 | 98 | * add CI ([33d8593](https://github.com/ckoliber/gocrud/commit/33d85938269dc1128bd38e1caac9ac84132f240b)) 99 | * add controller, repository, schema internal modules ([e0740a7](https://github.com/ckoliber/gocrud/commit/e0740a75088c15c51d611e3aaee64659b09dab83)) 100 | * add dynamic Register params from model spec ([0e2cda2](https://github.com/ckoliber/gocrud/commit/0e2cda2376e08caf1846882b16f08eccfe0d8bf7)) 101 | * add gocrud Register method ([2800466](https://github.com/ckoliber/gocrud/commit/2800466de243b5e189138c110a77c077460d4e4f)) 102 | * add internal packages ([2149c30](https://github.com/ckoliber/gocrud/commit/2149c302a1257722961d0c4276cada1cd8d0b361)) 103 | * add logs, comments ([3d0e823](https://github.com/ckoliber/gocrud/commit/3d0e8232cdb68ac0b83980f403082086f27401b1)) 104 | * add new api internal module ([af45d1f](https://github.com/ckoliber/gocrud/commit/af45d1f1cb465fc445db42f0f9a16c3b2cc25c00)) 105 | * add order, where query deepObjects ([86de5fe](https://github.com/ckoliber/gocrud/commit/86de5fe4763be8beb1b78a8b6018b947aca191ca)) 106 | * add PostSelect method ([e146d02](https://github.com/ckoliber/gocrud/commit/e146d02823108fc307c4093b3036021bc530296a)) 107 | * add put_single id parameter ([efcd900](https://github.com/ckoliber/gocrud/commit/efcd9003461820e41a85dcb301a2b1624064820e)) 108 | * add request context to repository and hooks ([98e3261](https://github.com/ckoliber/gocrud/commit/98e3261c239ddabb397a612f025b87218075f4d7)) 109 | * add SQL templates to CRUDRepository ([efcb735](https://github.com/ckoliber/gocrud/commit/efcb735f637631e380415fa9963f458f912bb058)) 110 | * add support for array of string type condition operator values ([f01ad91](https://github.com/ckoliber/gocrud/commit/f01ad91ccb554811499c264a2e975bb681e4d2a3)) 111 | * add transactional put ([65f628b](https://github.com/ckoliber/gocrud/commit/65f628bc01f60f190f3a40d994014166c9c4fe23)) 112 | * add where schema ([2a24a4f](https://github.com/ckoliber/gocrud/commit/2a24a4f460d69f3a8d9704f39ee8eb5640ff473e)) 113 | * change folder structure ([414e685](https://github.com/ckoliber/gocrud/commit/414e68541c754c55dc6e3c9c04fcee3cc4e8fd4d)) 114 | * change internal modules structure ([ad01d1d](https://github.com/ckoliber/gocrud/commit/ad01d1d6ae12bf9dd0573ab0b8613445fb3e9129)) 115 | * fix MSSQL syntax problems ([cf1c919](https://github.com/ckoliber/gocrud/commit/cf1c9198c8e63d124c24e4849757b24e7b0211cc)) 116 | * implement cross db CRUD queries for delete, put, post ([9f95d27](https://github.com/ckoliber/gocrud/commit/9f95d27c7b0f6eab10c720fd17ac1c439f33e085)) 117 | * implement CRUDRepository methods ([3f65ab2](https://github.com/ckoliber/gocrud/commit/3f65ab20b8140e8ede071f16f11e7706b6e69377)) 118 | * implement CRUDRepository using go-sqlbuilder ([634b6c7](https://github.com/ckoliber/gocrud/commit/634b6c7af7b1d30db93276c71bd71031fc6c6296)) 119 | * implement CRUDService ([8a0ba22](https://github.com/ckoliber/gocrud/commit/8a0ba22de64bc3a97b8c40bdcb9d3d10098b1c01)) 120 | * implement Fields, Values, Set methods of SQLBuilder ([74aca34](https://github.com/ckoliber/gocrud/commit/74aca348b1e3ed7ad387d75460ab6d76c1e44929)) 121 | * implement generic SQLBuilder ([174e7a0](https://github.com/ckoliber/gocrud/commit/174e7a037f9ef7a2c4947b357845e91cb263ad86)) 122 | * implement MySQL driver ([3133c6d](https://github.com/ckoliber/gocrud/commit/3133c6d3cafbedae063e19c06c7466c187c70ccc)) 123 | * implement order, where schema validations ([2cefb69](https://github.com/ckoliber/gocrud/commit/2cefb694158380260d7bc39529f4d4456f1b87be)) 124 | * implement postgres dialect Put method ([c9b7e8f](https://github.com/ckoliber/gocrud/commit/c9b7e8fa3dd5ad552f1e4f8996269bad9ac784b7)) 125 | * implement SQLite, MSSQL drivers ([300045e](https://github.com/ckoliber/gocrud/commit/300045ef6abe04dcfaaa2c8ea0837de9ba5e848d)) 126 | * init project ([5caf823](https://github.com/ckoliber/gocrud/commit/5caf823664d3d8b3f60eaf7f13f695be834de199)) 127 | * **mysql:** implement Post using LastInsertId and RowsAffected ([3056c65](https://github.com/ckoliber/gocrud/commit/3056c6581ddbefe1411ec4f5fcd5119be779843c)) 128 | * **mysql:** implement two stage Put, Delete methods ([7d5ed9d](https://github.com/ckoliber/gocrud/commit/7d5ed9dcefa953e2080c73bbb91cd2907a4c1d64)) 129 | * remove PATCH method (use autopatch) ([9c3dcdb](https://github.com/ckoliber/gocrud/commit/9c3dcdb6a5cf7c9316f7771d27569187cc40c22d)) 130 | -------------------------------------------------------------------------------- /internal/repository/mysql.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strings" 10 | ) 11 | 12 | // MySQLRepository provides CRUD operations for MySQL 13 | type MySQLRepository[Model any] struct { 14 | db *sql.DB 15 | builder *SQLBuilder[Model] 16 | } 17 | 18 | // NewMySQLRepository initializes a new MySQLRepository 19 | func NewMySQLRepository[Model any](db *sql.DB) *MySQLRepository[Model] { 20 | // Define SQL operators and helper functions for query building 21 | operations := map[string]func(string, ...string) string{ 22 | "_eq": func(key string, values ...string) string { return fmt.Sprintf("%s = %s", key, values[0]) }, 23 | "_neq": func(key string, values ...string) string { return fmt.Sprintf("%s != %s", key, values[0]) }, 24 | "_gt": func(key string, values ...string) string { return fmt.Sprintf("%s > %s", key, values[0]) }, 25 | "_gte": func(key string, values ...string) string { return fmt.Sprintf("%s >= %s", key, values[0]) }, 26 | "_lt": func(key string, values ...string) string { return fmt.Sprintf("%s < %s", key, values[0]) }, 27 | "_lte": func(key string, values ...string) string { return fmt.Sprintf("%s <= %s", key, values[0]) }, 28 | "_like": func(key string, values ...string) string { return fmt.Sprintf("%s LIKE %s", key, values[0]) }, 29 | "_nlike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT LIKE %s", key, values[0]) }, 30 | "_ilike": func(key string, values ...string) string { return fmt.Sprintf("%s ILIKE %s", key, values[0]) }, 31 | "_nilike": func(key string, values ...string) string { return fmt.Sprintf("%s NOT ILIKE %s", key, values[0]) }, 32 | "_in": func(key string, values ...string) string { 33 | return fmt.Sprintf("%s IN (%s)", key, strings.Join(values, ",")) 34 | }, 35 | "_nin": func(key string, values ...string) string { 36 | return fmt.Sprintf("%s NOT IN (%s)", key, strings.Join(values, ",")) 37 | }, 38 | } 39 | identifier := func(name string) string { 40 | return fmt.Sprintf("`%s`", name) 41 | } 42 | parameter := func(value reflect.Value, args *[]any) string { 43 | *args = append(*args, value.Interface()) 44 | return "?" 45 | } 46 | 47 | return &MySQLRepository[Model]{ 48 | db: db, 49 | builder: NewSQLBuilder[Model](operations, identifier, parameter, nil), 50 | } 51 | } 52 | 53 | // Get retrieves records from the database based on the provided filters 54 | func (r *MySQLRepository[Model]) Get(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) ([]Model, error) { 55 | args := []any{} 56 | query := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 57 | if expr := r.builder.Where(where, &args, nil); expr != "" { 58 | query += fmt.Sprintf(" WHERE %s", expr) 59 | } 60 | if expr := r.builder.Order(order); expr != "" { 61 | query += fmt.Sprintf(" ORDER BY %s", expr) 62 | } 63 | if limit != nil && *limit > 0 { 64 | query += fmt.Sprintf(" LIMIT %d", *limit) 65 | } 66 | if skip != nil && *skip > 0 { 67 | query += fmt.Sprintf(" OFFSET %d", *skip) 68 | } 69 | 70 | slog.Info("Executing Get query", slog.String("query", query), slog.Any("args", args)) 71 | 72 | // Execute the query and scan the results 73 | result, err := r.builder.Scan(r.db.QueryContext(ctx, query, args...)) 74 | if err != nil { 75 | slog.Error("Error executing Get query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 76 | return nil, err 77 | } 78 | 79 | return result, nil 80 | } 81 | 82 | // Put updates existing records in the database 83 | func (r *MySQLRepository[Model]) Put(ctx context.Context, models *[]Model) ([]Model, error) { 84 | result := []Model{} 85 | 86 | // Begin a transaction 87 | tx, err := r.db.BeginTx(ctx, nil) 88 | if err != nil { 89 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 90 | return nil, err 91 | } 92 | 93 | // Update each model in the database 94 | for _, model := range *models { 95 | args := []any{} 96 | where := map[string]any{} 97 | query := fmt.Sprintf("UPDATE %s SET %s", r.builder.Table(), r.builder.Set(&model, &args, &where)) 98 | if expr := r.builder.Where(&where, &args, nil); expr != "" { 99 | query += fmt.Sprintf(" WHERE %s", expr) 100 | } 101 | 102 | slog.Info("Executing Put query", slog.String("query", query), slog.Any("args", args)) 103 | 104 | if _, err := tx.ExecContext(ctx, query, args...); err != nil { 105 | slog.Error("Error executing Put query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 106 | tx.Rollback() 107 | return nil, err 108 | } 109 | 110 | getArgs := []any{} 111 | getQuery := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 112 | if expr := r.builder.Where(&where, &getArgs, nil); expr != "" { 113 | getQuery += fmt.Sprintf(" WHERE %s", expr) 114 | } 115 | 116 | items, err := r.builder.Scan(tx.QueryContext(ctx, getQuery, getArgs...)) 117 | if err != nil { 118 | slog.Error("Error executing Put query", slog.String("query", getQuery), slog.Any("args", getArgs), slog.Any("error", err)) 119 | tx.Rollback() 120 | return nil, err 121 | } 122 | 123 | result = append(result, items...) 124 | } 125 | 126 | // Commit the transaction 127 | if err := tx.Commit(); err != nil { 128 | slog.Error("Error committing transaction for Put", slog.Any("error", err)) 129 | return nil, err 130 | } 131 | 132 | return result, nil 133 | } 134 | 135 | // Post inserts new records into the database 136 | func (r *MySQLRepository[Model]) Post(ctx context.Context, models *[]Model) ([]Model, error) { 137 | // Begin a transaction 138 | tx, err := r.db.BeginTx(ctx, nil) 139 | if err != nil { 140 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 141 | return nil, err 142 | } 143 | 144 | args := []any{} 145 | query := fmt.Sprintf("INSERT INTO %s", r.builder.Table()) 146 | if fields, values := r.builder.Values(models, &args, nil); fields != "" && values != "" { 147 | query += fmt.Sprintf(" (%s) VALUES %s", fields, values) 148 | } 149 | 150 | slog.Info("Executing Post query", slog.String("query", query), slog.Any("args", args)) 151 | 152 | // Execute the query 153 | ids := []string{} 154 | if res, err := tx.ExecContext(ctx, query, args...); err != nil { 155 | slog.Error("Error executing Post query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 156 | tx.Rollback() 157 | return nil, err 158 | } else { 159 | if lastId, err := res.LastInsertId(); err == nil { 160 | if numRows, err := res.RowsAffected(); err == nil { 161 | for i := 0; i < int(numRows); i++ { 162 | ids = append(ids, fmt.Sprintf("%d", lastId+int64(i))) 163 | } 164 | } 165 | } 166 | } 167 | 168 | getArgs := []any{} 169 | getWhere := map[string]any{r.builder.keys[0]: map[string]any{"_in": ids}} 170 | getQuery := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 171 | if expr := r.builder.Where(&getWhere, &getArgs, nil); expr != "" { 172 | getQuery += fmt.Sprintf(" WHERE %s", expr) 173 | } 174 | 175 | slog.Info("Executing Post query", slog.String("query", getQuery), slog.Any("args", getArgs)) 176 | 177 | // Execute the query and scan the results 178 | result, err := r.builder.Scan(tx.QueryContext(ctx, getQuery, getArgs...)) 179 | if err != nil { 180 | slog.Error("Error executing Delete query", slog.String("query", getQuery), slog.Any("args", getArgs), slog.Any("error", err)) 181 | tx.Rollback() 182 | return nil, err 183 | } 184 | 185 | // Commit the transaction 186 | if err := tx.Commit(); err != nil { 187 | slog.Error("Error committing transaction for Delete", slog.Any("error", err)) 188 | return nil, err 189 | } 190 | 191 | return result, nil 192 | } 193 | 194 | // Delete removes records from the database based on the provided filters 195 | func (r *MySQLRepository[Model]) Delete(ctx context.Context, where *map[string]any) ([]Model, error) { 196 | // Begin a transaction 197 | tx, err := r.db.BeginTx(ctx, nil) 198 | if err != nil { 199 | slog.Error("Error starting transaction for Put", slog.Any("error", err)) 200 | return nil, err 201 | } 202 | 203 | getArgs := []any{} 204 | getQuery := fmt.Sprintf("SELECT %s FROM %s", r.builder.Fields(""), r.builder.Table()) 205 | if expr := r.builder.Where(where, &getArgs, nil); expr != "" { 206 | getQuery += fmt.Sprintf(" WHERE %s", expr) 207 | } 208 | 209 | slog.Info("Executing Delete query", slog.String("query", getQuery), slog.Any("args", getArgs)) 210 | 211 | // Execute the query and scan the results 212 | result, err := r.builder.Scan(tx.QueryContext(ctx, getQuery, getArgs...)) 213 | if err != nil { 214 | slog.Error("Error executing Delete query", slog.String("query", getQuery), slog.Any("args", getArgs), slog.Any("error", err)) 215 | tx.Rollback() 216 | return nil, err 217 | } 218 | 219 | args := []any{} 220 | query := fmt.Sprintf("DELETE FROM %s", r.builder.Table()) 221 | if expr := r.builder.Where(where, &args, nil); expr != "" { 222 | query += fmt.Sprintf(" WHERE %s", expr) 223 | } 224 | 225 | slog.Info("Executing Delete query", slog.String("query", query), slog.Any("args", args)) 226 | 227 | // Execute the query 228 | if _, err := tx.ExecContext(ctx, query, args...); err != nil { 229 | slog.Error("Error executing Delete query", slog.String("query", query), slog.Any("args", args), slog.Any("error", err)) 230 | tx.Rollback() 231 | return nil, err 232 | } 233 | 234 | // Commit the transaction 235 | if err := tx.Commit(); err != nil { 236 | slog.Error("Error committing transaction for Delete", slog.Any("error", err)) 237 | return nil, err 238 | } 239 | 240 | return result, nil 241 | } 242 | -------------------------------------------------------------------------------- /internal/repository/base.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strings" 10 | ) 11 | 12 | type Repository[Model any] interface { 13 | Get(ctx context.Context, where *map[string]any, order *map[string]any, limit *int, skip *int) ([]Model, error) 14 | Put(ctx context.Context, models *[]Model) ([]Model, error) 15 | Post(ctx context.Context, models *[]Model) ([]Model, error) 16 | Delete(ctx context.Context, where *map[string]any) ([]Model, error) 17 | } 18 | 19 | type Field struct { 20 | idx int 21 | name string 22 | } 23 | 24 | type Relation struct { 25 | one bool 26 | src string 27 | dest string 28 | table string 29 | } 30 | 31 | type SQLBuilder[Model any] struct { 32 | table string 33 | keys []string 34 | fields []Field 35 | relations map[string]Relation 36 | operations map[string]func(string, ...string) string 37 | identifier func(string) string 38 | parameter func(reflect.Value, *[]any) string 39 | generator func(reflect.StructField, *[]any) string 40 | } 41 | 42 | type SQLBuilderInterface interface { 43 | Table() string 44 | Where(where *map[string]any, args *[]any, run func(string) []string) string 45 | } 46 | 47 | var registry = map[string]SQLBuilderInterface{} 48 | 49 | func NewSQLBuilder[Model any](operations map[string]func(string, ...string) string, identifier func(string) string, parameter func(reflect.Value, *[]any) string, generator func(reflect.StructField, *[]any) string) *SQLBuilder[Model] { 50 | // Reflect on the Model type to extract metadata 51 | _type := reflect.TypeFor[Model]() 52 | 53 | table := strings.ToLower(_type.Name()) 54 | fields := []Field{} 55 | relations := map[string]Relation{} 56 | operations_ := map[string]func(string, ...string) string{} 57 | for idx := range _type.NumField() { 58 | _field := _type.Field(idx) 59 | 60 | if _field.Name == "_" { 61 | // Field named "_" is model information 62 | if tag := _field.Tag.Get("db"); tag != "" { 63 | table = strings.Split(tag, ",")[0] 64 | } 65 | } else { 66 | // Other fields are model attributes 67 | if tag := _field.Tag.Get("db"); tag != "" { 68 | if _field.Tag.Get("json") == "-" { 69 | // Relation field detected 70 | relations[tag] = Relation{ 71 | one: _field.Type.Kind() == reflect.Struct, 72 | src: _field.Tag.Get("src"), 73 | dest: _field.Tag.Get("dest"), 74 | table: _field.Tag.Get("table"), 75 | } 76 | } else { 77 | // Primitive fields detected 78 | name := strings.Split(tag, ",")[0] 79 | fields = append(fields, Field{idx, name}) 80 | 81 | // Add base operations for the field 82 | for key, value := range operations { 83 | operations_[name+key] = value 84 | } 85 | 86 | // Check if the field has a method named "Operations" 87 | // Then add its custom defined operations for the field 88 | if _method, ok := _field.Type.MethodByName("Operations"); ok { 89 | var model Model 90 | value := reflect.ValueOf(model).FieldByName(_field.Name) 91 | operations := _method.Func.Call([]reflect.Value{value})[0].Interface() 92 | for key, value := range operations.(map[string]func(string, ...string) string) { 93 | operations_[name+key] = value 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | slog.Debug("SQLBuilder initialized", slog.String("table", table), slog.Any("fields", fields), slog.Any("relations", relations)) 102 | 103 | result := &SQLBuilder[Model]{ 104 | table: table, 105 | keys: []string{fields[0].name}, 106 | fields: fields, 107 | relations: relations, 108 | operations: operations_, 109 | identifier: identifier, 110 | parameter: parameter, 111 | generator: generator, 112 | } 113 | 114 | registry[table] = result 115 | 116 | return result 117 | } 118 | 119 | // Returns the table name with proper identifier formatting 120 | func (b *SQLBuilder[Model]) Table() string { 121 | slog.Debug("Fetching table name", slog.String("table", b.table)) 122 | return b.identifier(b.table) 123 | } 124 | 125 | // Returns a comma-separated list of field names with proper identifier formatting 126 | func (b *SQLBuilder[Model]) Fields(prefix string) string { 127 | result := []string{} 128 | for _, field := range b.fields { 129 | result = append(result, prefix+b.identifier(field.name)) 130 | } 131 | 132 | slog.Debug("Fetching fields", slog.Any("fields", result)) 133 | return strings.Join(result, ",") 134 | } 135 | 136 | // Constructs the VALUES clause for an INSERT query 137 | func (b *SQLBuilder[Model]) Values(values *[]Model, args *[]any, keys *[]any) (string, string) { 138 | if values == nil { 139 | return "", "" 140 | } 141 | 142 | // Generate the field names for the VALUES clause 143 | fields := []string{} 144 | for idx, field := range b.fields { 145 | if idx == 0 { 146 | // The first field is the primary key 147 | if b.generator != nil { 148 | // If a generator function is provided, primary key will be generated 149 | fields = append(fields, b.identifier(field.name)) 150 | } 151 | } else { 152 | // Other fields are added to the VALUES clause 153 | fields = append(fields, b.identifier(field.name)) 154 | } 155 | } 156 | 157 | // Generate the field values for the VALUES clause 158 | result := []string{} 159 | for _, model := range *values { 160 | _type := reflect.TypeOf(model) 161 | _value := reflect.ValueOf(model) 162 | 163 | // Generate the values for the current model 164 | items := []string{} 165 | for idx, field := range b.fields { 166 | if idx == 0 { 167 | // The first field is the primary key 168 | if b.generator != nil { 169 | // If a generator function is provided, use it to generate the key 170 | items = append(items, b.generator(_type.Field(field.idx), keys)) 171 | } 172 | } else { 173 | // Other fields are added to the VALUES clause 174 | items = append(items, b.parameter(_value.Field(field.idx), args)) 175 | } 176 | } 177 | 178 | result = append(result, "("+strings.Join(items, ",")+")") 179 | } 180 | 181 | slog.Debug("Constructed VALUES clause", slog.Any("values", result)) 182 | return strings.Join(fields, ","), strings.Join(result, ",") 183 | } 184 | 185 | // Constructs the SET clause for an UPDATE query 186 | func (b *SQLBuilder[Model]) Set(set *Model, args *[]any, where *map[string]any) string { 187 | if set == nil { 188 | return "" 189 | } 190 | 191 | _value := reflect.ValueOf(*set) 192 | 193 | // Generate the field names for the SET clause 194 | result := []string{} 195 | for idx, field := range b.fields { 196 | if idx == 0 { 197 | // The first field is the primary key 198 | // Use it to construct the WHERE clause 199 | if where != nil { 200 | // Get the field value 201 | _field := _value.Field(field.idx) 202 | for _field.Kind() == reflect.Pointer { 203 | _field = _field.Elem() 204 | } 205 | 206 | // Set the WHERE clause condition based on the field type 207 | switch _field.Kind() { 208 | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 209 | (*where)[field.name] = map[string]any{"_eq": fmt.Sprintf("%d", _field.Int())} 210 | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 211 | (*where)[field.name] = map[string]any{"_eq": fmt.Sprintf("%d", _field.Uint())} 212 | case reflect.Float32, reflect.Float64: 213 | (*where)[field.name] = map[string]any{"_eq": fmt.Sprintf("%f", _field.Float())} 214 | case reflect.Complex64, reflect.Complex128: 215 | (*where)[field.name] = map[string]any{"_eq": fmt.Sprintf("%f", _field.Complex())} 216 | case reflect.String: 217 | (*where)[field.name] = map[string]any{"_eq": _field.String()} 218 | default: 219 | panic("Invalid identifier type") 220 | } 221 | } 222 | } else { 223 | // Other fields are added to the SET clause 224 | result = append(result, field.name+"="+b.parameter(_value.Field(field.idx), args)) 225 | } 226 | } 227 | 228 | slog.Debug("Constructed SET clause", slog.String("set", strings.Join(result, ","))) 229 | return strings.Join(result, ",") 230 | } 231 | 232 | // Constructs the ORDER BY clause for a query 233 | func (b *SQLBuilder[Model]) Order(order *map[string]any) string { 234 | if order == nil { 235 | return "" 236 | } 237 | 238 | // Generate the field names for the ORDER BY clause 239 | result := []string{} 240 | for key, val := range *order { 241 | result = append(result, fmt.Sprintf("%s %s", b.identifier(key), val)) 242 | } 243 | 244 | slog.Debug("Constructed ORDER BY clause", slog.Any("order", result)) 245 | return strings.Join(result, ",") 246 | } 247 | 248 | // Constructs the WHERE clause for a query 249 | func (b *SQLBuilder[Model]) Where(where *map[string]any, args *[]any, run func(string) []string) string { 250 | if where == nil { 251 | return "" 252 | } 253 | 254 | // Check for special conditions 255 | // _not, _and, and _or are used for logical operations 256 | if item, ok := (*where)["_not"]; ok { 257 | expr := item.(map[string]any) 258 | 259 | return "NOT (" + b.Where(&expr, args, run) + ")" 260 | } else if items, ok := (*where)["_and"]; ok { 261 | result := []string{} 262 | for _, item := range items.([]any) { 263 | expr := item.(map[string]any) 264 | result = append(result, b.Where(&expr, args, run)) 265 | } 266 | 267 | return "(" + strings.Join(result, " AND ") + ")" 268 | } else if items, ok := (*where)["_or"]; ok { 269 | result := []string{} 270 | for _, item := range items.([]any) { 271 | expr := item.(map[string]any) 272 | result = append(result, b.Where(&expr, args, run)) 273 | } 274 | 275 | return "(" + strings.Join(result, " OR ") + ")" 276 | } 277 | 278 | // Otherwise, construct the WHERE clause based on the field names and operations 279 | result := []string{} 280 | for key, item := range *where { 281 | for op, value := range item.(map[string]any) { 282 | if handler, ok := b.operations[key+op]; ok { 283 | // Primitive field condition detected 284 | _value := reflect.ValueOf(value) 285 | 286 | if _value.Kind() == reflect.String { 287 | // String values are passed to operation handler as single parameter 288 | result = append(result, handler(b.identifier(key), b.parameter(_value, args))) 289 | } else if _value.Kind() == reflect.Slice || _value.Kind() == reflect.Array { 290 | // Slice or array values are passed to operation handler as a list of parameters 291 | items := []string{} 292 | for i := range _value.Len() { 293 | items = append(items, b.parameter(_value.Index(i), args)) 294 | } 295 | 296 | result = append(result, handler(b.identifier(key), items...)) 297 | } 298 | } else { 299 | // Relation field condition detected 300 | if relation, ok := b.relations[key]; ok { 301 | // Get the target SQLBuilder for the relation 302 | builder := registry[relation.table] 303 | 304 | // Construct the sub-query for the related table 305 | args_ := []any{} 306 | where := item.(map[string]any) 307 | query := fmt.Sprintf("SELECT %s FROM %s", b.identifier(relation.dest), builder.Table()) 308 | if expr := builder.Where(&where, &args_, run); expr != "" { 309 | query += fmt.Sprintf(" WHERE %s", expr) 310 | } 311 | 312 | if run == nil { 313 | // If no run function is provided, sub-query is added to the main query 314 | *args = append(*args, args_...) 315 | result = append(result, b.operations["_in"](b.identifier(relation.src), query)) 316 | } else { 317 | // If a run function is provided, sub-query is executed and its result is added to the main query 318 | result = append(result, b.operations["_in"](b.identifier(relation.src), run(query)...)) 319 | } 320 | } 321 | } 322 | } 323 | } 324 | 325 | slog.Debug("Constructed WHERE clause", slog.Any("where", result)) 326 | return strings.Join(result, " AND ") 327 | } 328 | 329 | // Scans the rows returned by a query into a slice of Model 330 | func (b *SQLBuilder[Model]) Scan(rows *sql.Rows, err error) ([]Model, error) { 331 | if err != nil { 332 | slog.Error("Error during query execution", slog.Any("error", err)) 333 | return nil, err 334 | } 335 | defer rows.Close() 336 | 337 | // Iterate over the rows and scan each one into a Model instance 338 | result := []Model{} 339 | for rows.Next() { 340 | var model Model 341 | _value := reflect.ValueOf(&model).Elem() 342 | 343 | // Create a slice of addresses to scan the values into 344 | _addrs := []any{} 345 | for _, field := range b.fields { 346 | _addrs = append(_addrs, _value.Field(field.idx).Addr().Interface()) 347 | } 348 | 349 | // Scan the row into the addresses 350 | if err := rows.Scan(_addrs...); err != nil { 351 | return nil, err 352 | } 353 | 354 | result = append(result, model) 355 | } 356 | 357 | if err = rows.Err(); err != nil { 358 | slog.Error("Error during row iteration", slog.Any("error", err)) 359 | return nil, err 360 | } 361 | 362 | slog.Debug("Scan completed", slog.Any("result", result)) 363 | return result, nil 364 | } 365 | --------------------------------------------------------------------------------