├── .gitignore ├── examples └── README.md ├── cache ├── helpers.go ├── cache_test.go ├── sqlboiler_parse.go └── cache.go ├── helpers_test.go ├── go.mod ├── LICENSE ├── customization └── customization.go ├── plugin_model.go ├── helpers.go ├── template_files ├── generated_convert_batch.gotpl ├── generated_preload.gotpl ├── generated_crud.gotpl ├── generated_convert_input.gotpl ├── generated_convert.gotpl ├── generated_sort.gotpl ├── generated_resolver.gotpl └── generated_filter.gotpl ├── CODE_OF_CONDUCT.md ├── structs └── structs.go ├── templates └── templates.go ├── go.sum ├── plugin_convert.go ├── llm.txt ├── plugin_resolver.go ├── README.md └── sqlboiler_graphql_schema.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | [https://github.com/web-ridge/gqlgen-sqlboiler-examples](https://github.com/web-ridge/gqlgen-sqlboiler-examples) 2 | -------------------------------------------------------------------------------- /cache/helpers.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | func AppendIfMissing(slice []string, v string) []string { 4 | if SliceContains(slice, v) { 5 | return slice 6 | } 7 | return append(slice, v) 8 | } 9 | 10 | func SliceContains(slice []string, v string) bool { 11 | for _, s := range slice { 12 | if s == v { 13 | return true 14 | } 15 | } 16 | return false 17 | } 18 | -------------------------------------------------------------------------------- /helpers_test.go: -------------------------------------------------------------------------------- 1 | package gbgen 2 | 3 | import "testing" 4 | 5 | func Test_gopathImport(t *testing.T) { 6 | type args struct { 7 | dir string 8 | } 9 | tests := []struct { 10 | name string 11 | args args 12 | want string 13 | }{ 14 | { 15 | name: "in GOPATH", 16 | args: args{ 17 | dir: "/Users/someonefamous/go/src/github.com/someonefamous/famous-project", 18 | }, 19 | want: "github.com/someonefamous/famous-project", 20 | }, 21 | } 22 | for i := range tests { 23 | tt := tests[i] 24 | t.Run(tt.name, func(t *testing.T) { 25 | if got := gopathImport(tt.args.dir); got != tt.want { 26 | t.Errorf("gopathImport() = %v, want %v", got, tt.want) 27 | } 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/web-ridge/gqlgen-sqlboiler/v3 2 | 3 | go 1.24.4 4 | 5 | require ( 6 | github.com/99designs/gqlgen v0.17.75 7 | github.com/aarondl/strmangle v0.0.9 8 | github.com/iancoleman/strcase v0.3.0 9 | github.com/rs/zerolog v1.34.0 10 | github.com/vektah/gqlparser/v2 v2.5.28 11 | golang.org/x/mod v0.25.0 12 | golang.org/x/tools v0.34.0 13 | ) 14 | 15 | require ( 16 | github.com/aarondl/inflect v0.0.2 // indirect 17 | github.com/agnivade/levenshtein v1.2.1 // indirect 18 | github.com/mattn/go-colorable v0.1.14 // indirect 19 | github.com/mattn/go-isatty v0.0.20 // indirect 20 | golang.org/x/sync v0.15.0 // indirect 21 | golang.org/x/sys v0.33.0 // indirect 22 | golang.org/x/text v0.26.0 // indirect 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestShortType(t *testing.T) { 8 | testShortType(t, "gitlab.com/product/app/backend/graphql_models.FlowWhere", "FlowWhere") 9 | testShortType(t, "*gitlab.com/product/app/backend/graphql_models.FlowWhere", "*FlowWhere") 10 | testShortType(t, "*github.com/web-ridge/go-utils/boilergql/boilergql.GeoPoint", "*GeoPoint") 11 | testShortType(t, "github.com/web-ridge/go-utils/boilergql/boilergql.GeoPoint", "GeoPoint") 12 | testShortType(t, "*string", "*string") 13 | testShortType(t, "string", "string") 14 | testShortType(t, "*time.Time", "*time.Time") 15 | } 16 | 17 | func testShortType(t *testing.T, input, output string) { 18 | result := getShortType(input, []string{}) 19 | if result != output { 20 | t.Errorf("%v should result in %v but did result in %v", input, output, result) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 webRidge design. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /customization/customization.go: -------------------------------------------------------------------------------- 1 | package customization 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "os" 9 | ) 10 | 11 | func GetFunctionNamesFromDir(dir string, ignore []string) ([]string, error) { 12 | var a []string 13 | set := token.NewFileSet() 14 | // Use filter function to skip ignored files DURING parsing, not after 15 | // This prevents parse errors from generated files that may be empty/malformed 16 | filterFunc := func(info os.FileInfo) bool { 17 | return !contains(ignore, info.Name()) 18 | } 19 | packs, err := parser.ParseDir(set, dir, filterFunc, 0) 20 | if err != nil { 21 | if os.IsNotExist(err) { 22 | return nil, nil 23 | } 24 | return nil, fmt.Errorf("failed to parse package: %v", err) 25 | } 26 | 27 | for _, pack := range packs { 28 | for _, file := range pack.Files { 29 | a = append(a, GetFunctionNamesFromAstFile(file)...) 30 | } 31 | } 32 | return a, nil 33 | } 34 | 35 | func GetFunctionNamesFromAstFile(node *ast.File) []string { 36 | var a []string 37 | 38 | ast.Inspect(node, func(n ast.Node) bool { 39 | fn, ok := n.(*ast.FuncDecl) 40 | if ok { 41 | a = append(a, fn.Name.Name) 42 | } 43 | return true 44 | }) 45 | return a 46 | } 47 | 48 | func contains(s []string, e string) bool { 49 | for _, a := range s { 50 | if a == e { 51 | return true 52 | } 53 | } 54 | return false 55 | } 56 | -------------------------------------------------------------------------------- /plugin_model.go: -------------------------------------------------------------------------------- 1 | package gbgen 2 | 3 | import ( 4 | "fmt" 5 | "syscall" 6 | 7 | "github.com/99designs/gqlgen/codegen" 8 | "github.com/99designs/gqlgen/codegen/config" 9 | "github.com/99designs/gqlgen/plugin" 10 | "github.com/99designs/gqlgen/plugin/modelgen" 11 | ) 12 | 13 | func NewModelPlugin() *ModelPlugin { 14 | return &ModelPlugin{} 15 | } 16 | 17 | type ModelPlugin struct { 18 | } 19 | 20 | func (m *ModelPlugin) GenerateCode(cfg *config.Config) (*codegen.Data, error) { 21 | _ = syscall.Unlink(cfg.Exec.Filename) 22 | if cfg.Model.IsDefined() { 23 | _ = syscall.Unlink(cfg.Model.Filename) 24 | } 25 | 26 | // LoadSchema again now we have everything 27 | if err := cfg.LoadSchema(); err != nil { 28 | return nil, fmt.Errorf("failed to load schema: %w", err) 29 | } 30 | if err := cfg.Init(); err != nil { 31 | return nil, fmt.Errorf("generating core failed: %w", err) 32 | } 33 | 34 | p := modelgen.New() 35 | if mut, ok := p.(plugin.ConfigMutator); ok { 36 | err := mut.MutateConfig(cfg) 37 | if err != nil { 38 | return nil, err 39 | } 40 | } 41 | 42 | // Merge again now that the generated structs have been injected into the typemap 43 | data, err := codegen.BuildData(cfg) 44 | if err != nil { 45 | return nil, fmt.Errorf("merging type systems failed: %w", err) 46 | } 47 | 48 | if err = codegen.GenerateCode(data); err != nil { 49 | return nil, fmt.Errorf("generating core failed: %w", err) 50 | } 51 | return data, nil 52 | } 53 | -------------------------------------------------------------------------------- /helpers.go: -------------------------------------------------------------------------------- 1 | package gbgen 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "strings" 9 | 10 | "github.com/rs/zerolog/log" 11 | 12 | "golang.org/x/mod/modfile" 13 | ) 14 | 15 | func getRootImportPath() string { 16 | importPath, err := rootImportPath() 17 | if err != nil { 18 | log.Err(err).Msg( 19 | "could not detect root import path %v") 20 | return "" 21 | } 22 | return importPath 23 | } 24 | 25 | func rootImportPath() (string, error) { 26 | projectPath, err := getWorkingPath() 27 | if err != nil { 28 | // TODO: adhering to your original error handling 29 | // should consider doing something here rather than continuing 30 | // since this step occurs during generation, panicing or fatal error should be okay 31 | return "", fmt.Errorf("error while getting working directory %w", err) 32 | } 33 | if hasGoMod(projectPath) { 34 | modulePath, err := getModulePath(projectPath) 35 | if err != nil { 36 | // TODO: adhering to your original error handling 37 | // should consider doing something here rather than continuing 38 | // since this step occurs during generation, panicing or fatal error should be okay 39 | return "", fmt.Errorf("error while getting module path %w", err) 40 | } 41 | return modulePath, nil 42 | } 43 | 44 | return gopathImport(projectPath), nil 45 | } 46 | 47 | // getWorkingPath gets the current working directory 48 | func getWorkingPath() (string, error) { 49 | wd, err := os.Getwd() 50 | if err != nil { 51 | return "", err 52 | } 53 | return wd, nil 54 | } 55 | 56 | func hasGoMod(projectPath string) bool { 57 | filePath := path.Join(projectPath, "go.mod") 58 | return fileExists(filePath) 59 | } 60 | 61 | // fileExists checks if a file exists and is not a directory before we 62 | // try using it to prevent further errors. 63 | func fileExists(filename string) bool { 64 | info, err := os.Stat(filename) 65 | if os.IsNotExist(err) { 66 | return false 67 | } 68 | return !info.IsDir() 69 | } 70 | 71 | func getModulePath(projectPath string) (string, error) { 72 | filePath := path.Join(projectPath, "go.mod") 73 | file, err := ioutil.ReadFile(filePath) 74 | if err != nil { 75 | return "", fmt.Errorf("error while trying to read go mods path %w", err) 76 | } 77 | 78 | modPath := modfile.ModulePath(file) 79 | if modPath == "" { 80 | return "", fmt.Errorf("could not determine mod path") 81 | } 82 | return modPath, nil 83 | } 84 | 85 | func gopathImport(dir string) string { 86 | return strings.TrimPrefix(pathRegex.FindString(dir), "src/") 87 | } 88 | -------------------------------------------------------------------------------- /template_files/generated_convert_batch.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | 22 | "github.com/ericlagergren/decimal" 23 | "github.com/aarondl/sqlboiler/v4/boil" 24 | "github.com/aarondl/sqlboiler/v4/queries" 25 | "github.com/aarondl/sqlboiler/v4/queries/qm" 26 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 27 | "github.com/aarondl/sqlboiler/v4/types" 28 | "github.com/aarondl/null/v8" 29 | 30 | "database/sql" 31 | {{ range $import := .Imports }} 32 | {{ $import.Alias }} "{{ $import.ImportPath }}" 33 | {{ end }} 34 | ) 35 | 36 | const batchInsertStatement = "INSERT INTO %s (%s) VALUES %s" 37 | {{ range $model := .Models }} 38 | {{ if .IsCreateInput }} 39 | 40 | var {{ lcFirst .BoilerModel.PluralName }}BatchCreateColumns = []string{ 41 | {{- range $field := .BoilerModel.Fields -}} 42 | {{- if $field.InTableNotID -}} 43 | {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{- $field.Name }}, 44 | {{- end -}} 45 | {{- end -}} 46 | } 47 | 48 | var {{ lcFirst .BoilerModel.PluralName }}BatchCreateColumnsMarks = boilergql.GetQuestionMarksForColumns({{ lcFirst .BoilerModel.PluralName }}BatchCreateColumns) 49 | 50 | func {{ lcFirst .BoilerModel.Name }}ToBatchCreateValues(e *{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}) []interface{} { 51 | return []interface{}{ 52 | {{- range $field := .BoilerModel.Fields -}} 53 | {{- if $field.InTableNotID -}} 54 | e.{{- $field.Name }}, 55 | {{- end -}} 56 | {{- end -}} 57 | } 58 | } 59 | 60 | func {{ lcFirst .BoilerModel.PluralName }}ToBatchCreate(a []*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}) ([]string, []interface{}) { 61 | queryMarks := make([]string, len(a)) 62 | // nolint:prealloc 63 | var values []interface{} 64 | for i, boilerRow := range a { 65 | queryMarks[i] = {{ lcFirst .BoilerModel.PluralName }}BatchCreateColumnsMarks 66 | values = append(values, {{ lcFirst .BoilerModel.Name }}ToBatchCreateValues(boilerRow)...) 67 | } 68 | return queryMarks, values 69 | } 70 | 71 | func {{ .BoilerModel.PluralName }}ToBatchCreateQuery(a []*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}) (string, []interface{}) { 72 | queryMarks, values := {{ lcFirst .BoilerModel.PluralName }}ToBatchCreate(a) 73 | // nolint: gosec -> remove warning because no user input without questions marks 74 | return fmt.Sprintf(batchInsertStatement, 75 | {{ $.Backend.PackageName }}.{{- .TableNameResolverName }}.{{ .BoilerModel.Name }}, 76 | strings.Join({{ lcFirst .BoilerModel.PluralName }}BatchCreateColumns, ", "), 77 | strings.Join(queryMarks, ", "), 78 | ), values 79 | } 80 | 81 | {{ end }} 82 | {{ end }} -------------------------------------------------------------------------------- /template_files/generated_preload.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | "github.com/ericlagergren/decimal" 22 | "github.com/aarondl/sqlboiler/v4/boil" 23 | "github.com/aarondl/sqlboiler/v4/queries" 24 | "github.com/aarondl/sqlboiler/v4/queries/qm" 25 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 26 | "github.com/aarondl/sqlboiler/v4/types" 27 | "github.com/aarondl/null/v8" 28 | 29 | "database/sql" 30 | {{ range $import := .Imports }} 31 | {{ $import.Alias }} "{{ $import.ImportPath }}" 32 | {{ end }} 33 | ) 34 | 35 | 36 | 37 | var TablePreloadMap = map[string]map[string]boilergql.ColumnSetting{ 38 | {{ range $model := .Models -}} 39 | {{ if $model.IsPreloadable -}} 40 | {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }}: { 41 | {{- range $value := $model.PreloadArray }} 42 | "{{$value.Key}}": { 43 | Name: {{$value.ColumnSetting.Name}}, 44 | RelationshipModelName: {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{$value.ColumnSetting.RelationshipModelName}}, 45 | IDAvailable: {{$value.ColumnSetting.IDAvailable}}, 46 | }, 47 | {{- end }} 48 | }, 49 | {{ end -}} 50 | {{ end -}} 51 | } 52 | 53 | {{ range $model := .Models }} 54 | {{ if $model.IsPreloadable -}} 55 | func Get{{ .Name }}PreloadMods(ctx context.Context) (queryMods []qm.QueryMod) { 56 | return boilergql.GetPreloadModsWithLevel(ctx, TablePreloadMap, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $model.BoilerModel.TableName }}, "") 57 | } 58 | func Get{{ .Name }}NodePreloadMods(ctx context.Context) (queryMods []qm.QueryMod) { 59 | return boilergql.GetPreloadModsWithLevel(ctx, TablePreloadMap, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $model.BoilerModel.TableName }}, DefaultLevels.EdgesNode) 60 | } 61 | func Get{{ .Name }}PreloadModsWithLevel(ctx context.Context, level string) (queryMods []qm.QueryMod) { 62 | return boilergql.GetPreloadModsWithLevel(ctx, TablePreloadMap, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $model.BoilerModel.TableName }}, level) 63 | } 64 | {{ end -}} 65 | {{- end }} 66 | {{ range $model := .Models }} 67 | {{ if .IsPayload -}} 68 | var {{ .Name }}PreloadLevels = struct { 69 | {{ range $field := .Fields }} 70 | {{- if $field.IsObject -}} 71 | {{- $field.Name }} string 72 | {{- end }} 73 | {{- end }} 74 | }{ 75 | {{ range $field := .Fields }} 76 | {{- if $field.IsObject -}} 77 | {{- $field.Name }}: "{{- $field.JSONName }}", 78 | {{- end }} 79 | {{- end }} 80 | } 81 | {{ end }} 82 | {{- end }} 83 | 84 | var DefaultLevels = struct { 85 | EdgesNode string 86 | }{ 87 | EdgesNode: "edges.node", 88 | } 89 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at info@webridge.nl. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /structs/structs.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | import ( 4 | "go/types" 5 | 6 | "github.com/vektah/gqlparser/v2/ast" 7 | ) 8 | 9 | type ConvertConfig struct { 10 | IsCustom bool 11 | ToBoiler string 12 | ToGraphQL string 13 | GraphTypeAsText string 14 | BoilerTypeAsText string 15 | } 16 | 17 | type Interface struct { 18 | Description string 19 | Name string 20 | } 21 | 22 | type Preload struct { 23 | Key string 24 | ColumnSetting ColumnSetting 25 | } 26 | 27 | type Model struct { //nolint:maligned 28 | Name string 29 | JSONName string 30 | PluralName string 31 | BoilerModel *BoilerModel 32 | HasBoilerModel bool 33 | PrimaryKeyType string 34 | Fields []*Field 35 | IsNormal bool 36 | IsInput bool 37 | IsCreateInput bool 38 | IsUpdateInput bool 39 | IsNormalInput bool 40 | IsPayload bool 41 | IsConnection bool 42 | IsEdge bool 43 | IsOrdering bool 44 | IsWhere bool 45 | IsFilter bool 46 | IsPreloadable bool 47 | PreloadArray []Preload 48 | HasDeletedAt bool 49 | HasPrimaryStringID bool 50 | // other stuff 51 | Description string 52 | PureFields []*ast.FieldDefinition 53 | Implements []string 54 | TableNameResolverName string 55 | } 56 | 57 | type ColumnSetting struct { 58 | Name string 59 | RelationshipModelName string 60 | IDAvailable bool 61 | } 62 | 63 | type Field struct { //nolint:maligned 64 | Name string 65 | JSONName string 66 | PluralName string 67 | Type string 68 | TypeWithoutPointer string 69 | IsNumberID bool 70 | IsPrimaryNumberID bool 71 | IsPrimaryStringID bool 72 | IsPrimaryID bool 73 | IsRequired bool 74 | IsPlural bool 75 | ConvertConfig ConvertConfig 76 | Enum *Enum 77 | // relation stuff 78 | IsRelation bool 79 | IsRelationAndNotForeignKey bool 80 | IsObject bool 81 | // boiler relation stuff is inside this field 82 | BoilerField BoilerField 83 | // graphql relation ship can be found here 84 | Relationship *Model 85 | IsOr bool 86 | IsAnd bool 87 | IsWithDeleted bool 88 | 89 | // Some stuff 90 | Description string 91 | OriginalType types.Type 92 | } 93 | 94 | type Enum struct { 95 | Description string 96 | Name string 97 | PluralName string 98 | Values []*EnumValue 99 | HasBoilerEnum bool 100 | HasFilter bool 101 | BoilerEnum *BoilerEnum 102 | } 103 | 104 | type EnumValue struct { 105 | Description string 106 | Name string 107 | NameLower string 108 | BoilerEnumValue *BoilerEnumValue 109 | } 110 | 111 | type BoilerModel struct { 112 | Name string 113 | TableName string 114 | PluralName string 115 | Fields []*BoilerField 116 | Enums []*BoilerEnum 117 | HasPrimaryStringID bool 118 | HasDeletedAt bool 119 | IsView bool 120 | } 121 | 122 | type BoilerField struct { 123 | Name string 124 | PluralName string 125 | Type string 126 | IsForeignKey bool 127 | IsRequired bool 128 | IsArray bool 129 | IsEnum bool 130 | IsRelation bool 131 | InTable bool 132 | InTableNotID bool 133 | Enum BoilerEnum 134 | RelationshipName string 135 | Relationship *BoilerModel 136 | } 137 | 138 | type BoilerEnum struct { 139 | Name string 140 | ModelName string 141 | ModelFieldKey string 142 | Values []*BoilerEnumValue 143 | } 144 | 145 | type BoilerEnumValue struct { 146 | Name string 147 | } 148 | 149 | type BoilerType struct { 150 | Name string 151 | Type string 152 | } 153 | 154 | type Config struct { 155 | Directory string 156 | PackageName string 157 | } 158 | -------------------------------------------------------------------------------- /templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "go/ast" 7 | "go/format" 8 | "go/parser" 9 | "go/printer" 10 | "go/token" 11 | "os" 12 | "strings" 13 | "text/template" 14 | 15 | "github.com/rs/zerolog/log" 16 | 17 | "github.com/iancoleman/strcase" 18 | 19 | "golang.org/x/tools/imports" 20 | 21 | gqlgenTemplates "github.com/99designs/gqlgen/codegen/templates" 22 | ) 23 | 24 | type Options struct { 25 | // PackageName is a helper that specifies the package header declaration. 26 | // In other words, when you write the template you don't need to specify `package X` 27 | // at the top of the file. By providing PackageName in the Options, the Render 28 | // function will do that for you. 29 | PackageName string 30 | // Template is a string of the entire template that 31 | // will be parsed and rendered. If it's empty, 32 | // the plugin processor will look for .gotpl files 33 | // in the same directory of where you wrote the plugin. 34 | Template string 35 | // UserDefinedFunctions is used to rewrite in the the file so we can use custom functions 36 | // The struct is still available for use in private but will be rewritten to 37 | // a private function with original in front of it 38 | UserDefinedFunctions []string 39 | // Data will be passed to the template execution. 40 | Data interface{} 41 | } 42 | 43 | func init() { // nolint:gochecknoinits 44 | strcase.ConfigureAcronym("QR", "qr") 45 | strcase.ConfigureAcronym("KVK", "kvk") 46 | strcase.ConfigureAcronym("URL", "url") 47 | strcase.ConfigureAcronym("FTP", "ftp") 48 | strcase.ConfigureAcronym("FCM", "fcm") 49 | } 50 | 51 | func WriteTemplateFile(fileName string, cfg Options) error { 52 | content, contentError := GetTemplateContent(cfg) 53 | importFixedContent, importsError := imports.Process(fileName, []byte(content), nil) 54 | 55 | fSet := token.NewFileSet() 56 | node, err := parser.ParseFile(fSet, "src.go", string(importFixedContent), 0) 57 | if err != nil { 58 | log.Error().Err(err).Msg("could not parse golang file") 59 | } 60 | 61 | ast.Inspect(node, func(n ast.Node) bool { 62 | fn, ok := n.(*ast.FuncDecl) 63 | if ok && isFunctionOverriddenByUser(fn.Name.Name, cfg.UserDefinedFunctions) { 64 | fn.Name.Name = "original" + fn.Name.Name 65 | } 66 | return true 67 | }) 68 | 69 | // write new ast to file 70 | f, writeError := os.Create(fileName) 71 | defer func() { 72 | if err := f.Close(); err != nil { 73 | log.Error().Err(err).Str("fileName", fileName).Msg("could not close file") 74 | } 75 | }() 76 | 77 | if err := printer.Fprint(f, fSet, node); err != nil { 78 | return fmt.Errorf("errors while printing template to %v %v", fileName, err) 79 | } 80 | 81 | if contentError != nil || writeError != nil || importsError != nil { 82 | // write fallback to file with content that could not be formatted 83 | if err := os.WriteFile(fileName, []byte(content), 0o644); err != nil { 84 | return fmt.Errorf("errors while writing template to %v %v", fileName, err) 85 | } 86 | return fmt.Errorf( 87 | "errors while writing template to %v writeError: %v, contentError: %v, importError: %v", 88 | fileName, writeError, contentError, importsError) 89 | } 90 | 91 | return nil 92 | } 93 | 94 | func GetTemplateContent(cfg Options) (string, error) { 95 | tpl, err := template.New("").Funcs(template.FuncMap{ 96 | "go": gqlgenTemplates.ToGo, 97 | "lcFirst": gqlgenTemplates.LcFirst, 98 | "ucFirst": gqlgenTemplates.UcFirst, 99 | "trimSuffix": strings.TrimSuffix, 100 | }).Parse(cfg.Template) 101 | if err != nil { 102 | return "", fmt.Errorf("parse: %v", err) 103 | } 104 | 105 | var content bytes.Buffer 106 | err = tpl.Execute(&content, cfg.Data) 107 | if err != nil { 108 | return "", fmt.Errorf("execute: %v", err) 109 | } 110 | 111 | contentBytes := content.Bytes() 112 | formattedContent, err := format.Source(contentBytes) 113 | if err != nil { 114 | return string(contentBytes), fmt.Errorf("formatting: %v", err) 115 | } 116 | 117 | return string(formattedContent), nil 118 | } 119 | 120 | func isFunctionOverriddenByUser(functionName string, userDefinedFunctions []string) bool { 121 | for _, userDefinedFunction := range userDefinedFunctions { 122 | if userDefinedFunction == functionName { 123 | return true 124 | } 125 | } 126 | return false 127 | } 128 | 129 | func ToGo(name string) string { 130 | return strcase.ToCamel(name) 131 | } 132 | 133 | func ToLowerAndGo(name string) string { 134 | return ToGo(strings.ToLower(name)) 135 | } 136 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/99designs/gqlgen v0.17.75 h1:GwHJsptXWLHeY7JO8b7YueUI4w9Pom6wJTICosDtQuI= 2 | github.com/99designs/gqlgen v0.17.75/go.mod h1:p7gbTpdnHyl70hmSpM8XG8GiKwmCv+T5zkdY8U8bLog= 3 | github.com/aarondl/inflect v0.0.2 h1:XvH8K5g1wKS921tMmDOUsZ3zS1Eo8WwK5RHC0IGGT2s= 4 | github.com/aarondl/inflect v0.0.2/go.mod h1:zjmCfdXHUDQ9jFOV6SeHknpo0Au6rQhV8GchS4Vzv/0= 5 | github.com/aarondl/strmangle v0.0.9 h1:VCT+O1FqRSE9DTK3qR0zRHtB384fdRzuyKfx2ux2xms= 6 | github.com/aarondl/strmangle v0.0.9/go.mod h1:ezNIwvvnuVGuKedP5qt2T+wvzPD8yuOoMzamifXNMlk= 7 | github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= 8 | github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= 9 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= 10 | github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= 11 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 12 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 13 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= 17 | github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 18 | github.com/friendsofgo/errors v0.9.2 h1:X6NYxef4efCBdwI7BgS820zFaN7Cphrmb+Pljdzjtgk= 19 | github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4Pn44IGoTOI= 20 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 21 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 22 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 23 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 24 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 25 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 26 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 27 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 28 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 29 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 30 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 31 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 32 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 33 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 34 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 35 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 36 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 37 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 38 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= 39 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= 40 | github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= 41 | github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 42 | github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= 43 | github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= 44 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 45 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 46 | github.com/vektah/gqlparser/v2 v2.5.28 h1:bIulcl3LF69ba6EiZVGD88y4MkM+Jxrf3P2MX8xLRkY= 47 | github.com/vektah/gqlparser/v2 v2.5.28/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= 48 | golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= 49 | golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 50 | golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= 51 | golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 52 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 53 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 54 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 55 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 56 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 57 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 58 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 59 | golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= 60 | golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 61 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 h1:9zdDQZ7Thm29KFXgAX/+yaf3eVbP7djjWp/dXAppNCc= 62 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 63 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 64 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 65 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 66 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 67 | -------------------------------------------------------------------------------- /plugin_convert.go: -------------------------------------------------------------------------------- 1 | package gbgen 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "regexp" 10 | "runtime" 11 | 12 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 13 | 14 | "github.com/web-ridge/gqlgen-sqlboiler/v3/cache" 15 | 16 | "github.com/web-ridge/gqlgen-sqlboiler/v3/customization" 17 | 18 | "github.com/rs/zerolog" 19 | "github.com/rs/zerolog/log" 20 | "github.com/web-ridge/gqlgen-sqlboiler/v3/templates" 21 | ) 22 | 23 | var pathRegex *regexp.Regexp //nolint:gochecknoglobals 24 | 25 | func init() { //nolint:gochecknoinits 26 | pathRegex = regexp.MustCompile(`src/(.*)`) 27 | 28 | // Default level for this example is info, unless debug flag is present 29 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 30 | 31 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) 32 | } 33 | 34 | type Import struct { 35 | Alias string 36 | ImportPath string 37 | } 38 | 39 | type ConvertTemplateData struct { 40 | Backend structs.Config 41 | Frontend structs.Config 42 | PluginConfig ConvertPluginConfig 43 | PackageName string 44 | Interfaces []*structs.Interface 45 | Models []*structs.Model 46 | Enums []*structs.Enum 47 | Scalars []string 48 | AuthorizationScopes []*AuthorizationScope 49 | } 50 | 51 | func (t ConvertTemplateData) Imports() []Import { 52 | imports := []Import{ 53 | { 54 | Alias: t.Frontend.PackageName, 55 | ImportPath: t.Frontend.Directory, 56 | }, 57 | { 58 | Alias: t.Backend.PackageName, 59 | ImportPath: t.Backend.Directory, 60 | }, 61 | } 62 | // Add auth scope imports for FK validation 63 | seen := make(map[string]bool) 64 | for _, scope := range t.AuthorizationScopes { 65 | if !seen[scope.ImportAlias] { 66 | imports = append(imports, Import{ 67 | Alias: scope.ImportAlias, 68 | ImportPath: scope.ImportPath, 69 | }) 70 | seen[scope.ImportAlias] = true 71 | } 72 | } 73 | return imports 74 | } 75 | 76 | func NewConvertPlugin(modelCache *cache.ModelCache, pluginConfig ConvertPluginConfig) *ConvertPlugin { 77 | return &ConvertPlugin{ 78 | ModelCache: modelCache, 79 | PluginConfig: pluginConfig, 80 | rootImportPath: getRootImportPath(), 81 | } 82 | } 83 | 84 | type ConvertPlugin struct { 85 | BoilerCache *cache.BoilerCache 86 | ModelCache *cache.ModelCache 87 | PluginConfig ConvertPluginConfig 88 | rootImportPath string 89 | } 90 | 91 | // DatabaseDriver defines which data syntax to use for some of the converts 92 | type DatabaseDriver string 93 | 94 | const ( 95 | // MySQL is the default 96 | MySQL DatabaseDriver = "mysql" 97 | // PostgreSQL is the default 98 | PostgreSQL DatabaseDriver = "postgres" 99 | ) 100 | 101 | type ConvertPluginConfig struct { 102 | DatabaseDriver DatabaseDriver 103 | } 104 | 105 | func (m *ConvertPlugin) GenerateCode(authScopes []*AuthorizationScope) error { 106 | data := &ConvertTemplateData{ 107 | PackageName: m.ModelCache.Output.PackageName, 108 | Backend: structs.Config{ 109 | Directory: path.Join(m.rootImportPath, m.ModelCache.Backend.Directory), 110 | PackageName: m.ModelCache.Backend.PackageName, 111 | }, 112 | Frontend: structs.Config{ 113 | Directory: path.Join(m.rootImportPath, m.ModelCache.Frontend.Directory), 114 | PackageName: m.ModelCache.Frontend.PackageName, 115 | }, 116 | PluginConfig: m.PluginConfig, 117 | Interfaces: m.ModelCache.Interfaces, 118 | Models: m.ModelCache.Models, 119 | Enums: m.ModelCache.Enums, 120 | Scalars: m.ModelCache.Scalars, 121 | AuthorizationScopes: authScopes, 122 | } 123 | 124 | if err := os.MkdirAll(m.ModelCache.Output.Directory, os.ModePerm); err != nil { 125 | log.Error().Err(err).Str("directory", m.ModelCache.Output.Directory).Msg("could not create directories") 126 | } 127 | 128 | if m.PluginConfig.DatabaseDriver == "" { 129 | fmt.Println("Please specify database driver, see README on github") 130 | } 131 | 132 | if len(m.ModelCache.Models) == 0 { 133 | log.Warn().Msg("no structs found in graphql so skipping generation") 134 | return nil 135 | } 136 | 137 | filesToGenerate := []string{ 138 | "generated_convert.go", 139 | "generated_convert_batch.go", 140 | "generated_convert_input.go", 141 | "generated_crud.go", 142 | "generated_filter.go", 143 | "generated_preload.go", 144 | "generated_sort.go", 145 | } 146 | 147 | // We get all function names from helper repository to check if any customizations are available 148 | // we ignore the files we generated by this plugin 149 | userDefinedFunctions, err := customization.GetFunctionNamesFromDir(m.ModelCache.Output.PackageName, filesToGenerate) 150 | if err != nil { 151 | log.Err(err).Msg("could not parse user defined functions") 152 | } 153 | 154 | for _, fn := range filesToGenerate { 155 | m.generateFile(data, fn, userDefinedFunctions) 156 | } 157 | return nil 158 | } 159 | 160 | func (m *ConvertPlugin) generateFile(data *ConvertTemplateData, fileName string, userDefinedFunctions []string) { 161 | templateName := fileName + "tpl" 162 | // log.Debug().Msg("[convert] render " + templateName) 163 | 164 | templateContent, err := getTemplateContent(templateName) 165 | if err != nil { 166 | log.Err(err).Msg("error when reading " + templateName) 167 | } 168 | 169 | if renderError := templates.WriteTemplateFile( 170 | m.ModelCache.Output.Directory+"/"+fileName, 171 | templates.Options{ 172 | Template: templateContent, 173 | PackageName: m.ModelCache.Output.PackageName, 174 | Data: data, 175 | UserDefinedFunctions: userDefinedFunctions, 176 | }); renderError != nil { 177 | log.Err(renderError).Msg("error while rendering " + templateName) 178 | } 179 | log.Debug().Msg("[convert] generated " + templateName) 180 | } 181 | 182 | func getTemplateContent(filename string) (string, error) { 183 | // load path relative to calling source file 184 | _, callerFile, _, _ := runtime.Caller(1) //nolint:dogsled 185 | rootDir := filepath.Dir(callerFile) 186 | content, err := ioutil.ReadFile(path.Join(rootDir, "template_files", filename)) 187 | if err != nil { 188 | return "", fmt.Errorf("could not read template file: %v", err) 189 | } 190 | return string(content), nil 191 | } 192 | -------------------------------------------------------------------------------- /template_files/generated_crud.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | "github.com/ericlagergren/decimal" 22 | "github.com/aarondl/sqlboiler/v4/boil" 23 | "github.com/aarondl/sqlboiler/v4/queries" 24 | "github.com/aarondl/sqlboiler/v4/queries/qm" 25 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 26 | "github.com/aarondl/sqlboiler/v4/types" 27 | "github.com/aarondl/null/v8" 28 | 29 | "database/sql" 30 | {{ range $import := .Imports }} 31 | {{ $import.Alias }} "{{ $import.ImportPath }}" 32 | {{ end }} 33 | ) 34 | 35 | {{ range $model := .Models }} 36 | {{ if and .IsNormal .BoilerModel -}} 37 | 38 | // Fetch{{ .Name }} fetches a single {{ .Name }} by ID with preloads and authorization 39 | func Fetch{{ .Name }}(ctx context.Context, db boil.ContextExecutor, id string, preloadLevel string) (*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, error) { 40 | dbID := {{ .Name }}ID(id) 41 | mods := Get{{ .Name }}PreloadModsWithLevel(ctx, preloadLevel) 42 | mods = append(mods, {{ $.Backend.PackageName }}.{{ .Name }}Where.ID.EQ(dbID)) 43 | {{- range $scope := $.AuthorizationScopes }} 44 | {{- if (call $scope.AddHook $model.BoilerModel nil "singleWhere") }} 45 | mods = append(mods, {{ $.Backend.PackageName }}.{{ $model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx))) 46 | {{- end }} 47 | {{- end }} 48 | return {{ $.Backend.PackageName }}.{{ .PluralName }}(mods...).One(ctx, db) 49 | } 50 | 51 | {{- if not .BoilerModel.IsView }} 52 | // Delete{{ .Name }} deletes a {{ .Name }} by ID with authorization (hard delete) 53 | func Delete{{ .Name }}(ctx context.Context, db boil.ContextExecutor, id string) error { 54 | dbID := {{ .Name }}ID(id) 55 | _, err := {{ $.Backend.PackageName }}.{{ .PluralName }}( 56 | {{ $.Backend.PackageName }}.{{ .Name }}Where.ID.EQ(dbID), 57 | {{- range $scope := $.AuthorizationScopes }} 58 | {{- if (call $scope.AddHook $model.BoilerModel nil "deleteWhere") }} 59 | {{ $.Backend.PackageName }}.{{ $model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 60 | {{- end }} 61 | {{- end }} 62 | ).DeleteAll(ctx, db{{ if .BoilerModel.HasDeletedAt }}, true{{ end }}) 63 | return err 64 | } 65 | 66 | {{ if .BoilerModel.HasDeletedAt -}} 67 | // SoftDelete{{ .Name }} soft deletes a {{ .Name }} by ID with authorization 68 | func SoftDelete{{ .Name }}(ctx context.Context, db boil.ContextExecutor, id string) error { 69 | dbID := {{ .Name }}ID(id) 70 | _, err := {{ $.Backend.PackageName }}.{{ .PluralName }}( 71 | {{ $.Backend.PackageName }}.{{ .Name }}Where.ID.EQ(dbID), 72 | {{- range $scope := $.AuthorizationScopes }} 73 | {{- if (call $scope.AddHook $model.BoilerModel nil "deleteWhere") }} 74 | {{ $.Backend.PackageName }}.{{ $model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 75 | {{- end }} 76 | {{- end }} 77 | ).DeleteAll(ctx, db, false) 78 | return err 79 | } 80 | {{- end }} 81 | {{- end }} 82 | 83 | {{ end -}} 84 | {{ end }} 85 | 86 | {{ range $model := .Models }} 87 | {{ if and .IsCreateInput .BoilerModel -}} 88 | {{ $modelName := trimSuffix .Name "CreateInput" -}} 89 | {{- /* ID type conversion: find ID field type in BoilerModel.Fields */ -}} 90 | {{- $idExpr := "m.ID" -}} 91 | {{- if .BoilerModel -}} 92 | {{- range $field := .BoilerModel.Fields -}} 93 | {{- if eq $field.Name "ID" -}} 94 | {{- if and (ne $field.Type "uint") (ne $field.Type "string") -}} 95 | {{- $idExpr = "uint(m.ID)" -}} 96 | {{- end -}} 97 | {{- end -}} 98 | {{- end -}} 99 | {{- end -}} 100 | 101 | // Create{{ $modelName }} creates a new {{ $modelName }} and returns the created record with preloads 102 | func Create{{ $modelName }}(ctx context.Context, db boil.ContextExecutor, input {{ $.Frontend.PackageName }}.{{ .Name }}, preloadLevel string) (*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, error) { 103 | m := {{ .Name }}ToBoiler(ctx, db, &input) 104 | 105 | {{ if gt (len $.AuthorizationScopes) 0 -}} 106 | // Validate foreign keys belong to user's scope 107 | if err := Validate{{ .Name }}ForeignKeys(ctx, db, &input); err != nil { 108 | return nil, err 109 | } 110 | {{- end }} 111 | 112 | {{ range $scope := $.AuthorizationScopes -}} 113 | {{- if (call $scope.AddHook $model.BoilerModel nil "createInput") }} 114 | m.{{ $scope.BoilerColumnName }} = {{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx) 115 | {{- end }} 116 | {{- end }} 117 | 118 | if err := m.Insert(ctx, db, boil.Infer()); err != nil { 119 | return nil, err 120 | } 121 | 122 | return Fetch{{ $modelName }}(ctx, db, {{ $modelName }}IDToGraphQL({{ $idExpr }}), preloadLevel) 123 | } 124 | 125 | {{ end -}} 126 | {{ end }} 127 | 128 | {{ range $model := .Models }} 129 | {{ if and .IsUpdateInput .BoilerModel -}} 130 | {{ $modelName := trimSuffix .Name "UpdateInput" -}} 131 | {{- /* BoilerModel.PluralName has correct pluralization from sqlboiler */ -}} 132 | 133 | // Update{{ $modelName }} updates an existing {{ $modelName }} and returns the updated record with preloads 134 | func Update{{ $modelName }}(ctx context.Context, db boil.ContextExecutor, id string, input {{ $.Frontend.PackageName }}.{{ .Name }}, preloadLevel string) (*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, error) { 135 | m := {{ .Name }}ToModelM(ctx, db, boilergql.GetInputFromContext(ctx, "input"), input) 136 | 137 | {{ if gt (len $.AuthorizationScopes) 0 -}} 138 | // Validate foreign keys belong to user's scope 139 | if err := Validate{{ .Name }}ForeignKeys(ctx, db, &input); err != nil { 140 | return nil, err 141 | } 142 | {{- end }} 143 | 144 | dbID := {{ $modelName }}ID(id) 145 | if _, err := {{ $.Backend.PackageName }}.{{ .BoilerModel.PluralName }}( 146 | {{ $.Backend.PackageName }}.{{ $modelName }}Where.ID.EQ(dbID), 147 | {{- range $scope := $.AuthorizationScopes }} 148 | {{- if (call $scope.AddHook $model.BoilerModel nil "updateWhere") }} 149 | {{ $.Backend.PackageName }}.{{ $modelName }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 150 | {{- end }} 151 | {{- end }} 152 | ).UpdateAll(ctx, db, m); err != nil { 153 | return nil, err 154 | } 155 | 156 | return Fetch{{ $modelName }}(ctx, db, id, preloadLevel) 157 | } 158 | 159 | {{ end -}} 160 | {{ end }} 161 | -------------------------------------------------------------------------------- /template_files/generated_convert_input.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | 22 | "github.com/ericlagergren/decimal" 23 | "github.com/aarondl/sqlboiler/v4/boil" 24 | "github.com/aarondl/sqlboiler/v4/queries" 25 | "github.com/aarondl/sqlboiler/v4/queries/qm" 26 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 27 | "github.com/aarondl/sqlboiler/v4/types" 28 | "github.com/aarondl/null/v8" 29 | 30 | "database/sql" 31 | {{ range $import := .Imports }} 32 | {{ $import.Alias }} "{{ $import.ImportPath }}" 33 | {{ end }} 34 | ) 35 | 36 | 37 | 38 | {{ range $model := .Models }} 39 | 40 | {{- if .IsInput }} 41 | 42 | func {{ .PluralName }}ToBoiler(ctx context.Context, db boil.ContextExecutor, am []*{{ $.Frontend.PackageName }}.{{ .Name }})( []*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}) { 43 | ar := make([]*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, len(am)) 44 | for i,m := range am { 45 | ar[i] = {{ .Name }}ToBoiler(ctx, db, m) 46 | } 47 | return ar 48 | } 49 | 50 | func {{ .Name }}ToBoiler(ctx context.Context, db boil.ContextExecutor, m *{{ $.Frontend.PackageName }}.{{ .Name }})( *{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}) { 51 | if m == nil { 52 | return nil 53 | } 54 | 55 | r := &{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}{ 56 | {{ range $field := .Fields -}} 57 | {{- if $field.ConvertConfig.IsCustom -}} 58 | {{- if $field.IsPrimaryID -}} 59 | {{- $field.BoilerField.Name }} : {{ $field.ConvertConfig.ToBoiler }}, 60 | {{- else if and $field.IsNumberID $field.BoilerField.IsRelation -}} 61 | {{- $field.BoilerField.Name }} : {{ $field.ConvertConfig.ToBoiler }}, 62 | {{- else if $field.IsRelation -}} 63 | {{- else -}} 64 | {{- $field.BoilerField.Name }} : {{ $field.ConvertConfig.ToBoiler }}(m.{{ $field.Name }}), 65 | {{- end }} 66 | {{- else if $field.IsRelation -}} 67 | {{- else -}} 68 | {{- $field.BoilerField.Name }}: m.{{ $field.Name }}, 69 | {{- end }} 70 | {{ end }} 71 | } 72 | return r 73 | } 74 | 75 | func {{ .Name }}ToModelM( 76 | ctx context.Context, 77 | db boil.ContextExecutor, 78 | input map[string]interface{}, 79 | m {{ $.Frontend.PackageName }}.{{ .Name }}, 80 | ) {{ $.Backend.PackageName }}.M { 81 | model := {{ .Name }}ToBoiler(ctx, db, &m) 82 | modelM := {{ $.Backend.PackageName }}.M{} 83 | for key := range input { 84 | switch key { 85 | {{ range $field := .Fields }} 86 | {{ if $field.IsRelationAndNotForeignKey}} 87 | {{ else }} 88 | case "{{ $field.JSONName }}": 89 | modelM[{{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{- $field.BoilerField.Name }}] = model.{{ $field.Name }} 90 | {{ end }} 91 | {{ end }} 92 | } 93 | } 94 | return modelM 95 | } 96 | 97 | func {{ .Name }}ToBoilerWhitelist(input map[string]interface{}, extraColumns ...string) boil.Columns { 98 | var columnsWhichAreSet []string 99 | for key := range input { 100 | switch key { 101 | {{ range $field := .Fields -}} 102 | case "{{ $field.JSONName }}": 103 | columnsWhichAreSet = append(columnsWhichAreSet, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{- $field.BoilerField.Name }}) 104 | {{ end -}} 105 | } 106 | } 107 | columnsWhichAreSet = append(columnsWhichAreSet, extraColumns...) 108 | return boil.Whitelist(columnsWhichAreSet...) 109 | } 110 | 111 | {{ $inputModel := . -}} 112 | // Validate{{ .Name }}ForeignKeys validates that foreign key references belong to user's scope 113 | // When AuthorizationScopes is nil/empty, this is a no-op for backwards compatibility 114 | func Validate{{ .Name }}ForeignKeys( 115 | ctx context.Context, 116 | db boil.ContextExecutor, 117 | m *{{ $.Frontend.PackageName }}.{{ .Name }}, 118 | ) error { 119 | if m == nil { 120 | return nil 121 | } 122 | {{- range $field := .Fields }} 123 | {{- if and $field.IsNumberID $field.BoilerField.IsRelation }} 124 | {{- $relatedModel := $field.BoilerField.Relationship }} 125 | {{- /* Skip if relationship model is nil */ -}} 126 | {{- if $relatedModel }} 127 | {{- /* Count applicable scopes to know if we need validation */ -}} 128 | {{- $hasAnyScope := false }} 129 | {{- range $scope := $.AuthorizationScopes }} 130 | {{- range $relField := $relatedModel.Fields }} 131 | {{- if eq $relField.Name $scope.BoilerColumnName }} 132 | {{- if (call $scope.AddHook $relatedModel nil "validateForeignKey") }} 133 | {{- $hasAnyScope = true }} 134 | {{- end }} 135 | {{- end }} 136 | {{- end }} 137 | {{- end }} 138 | {{- if $hasAnyScope }} 139 | {{- /* Check if field is a pointer by comparing Type with TypeWithoutPointer */ -}} 140 | {{- $isPointer := ne $field.Type $field.TypeWithoutPointer }} 141 | {{- if not $isPointer }} 142 | { 143 | // Validate {{ $field.Name }} references a {{ $relatedModel.Name }} in user's scope 144 | exists, err := {{ $.Backend.PackageName }}.{{ $relatedModel.PluralName }}( 145 | {{ $.Backend.PackageName }}.{{ $relatedModel.Name }}Where.ID.EQ({{ $relatedModel.Name }}ID(m.{{ $field.Name }})), 146 | {{- range $scope := $.AuthorizationScopes }} 147 | {{- range $relField := $relatedModel.Fields }} 148 | {{- if eq $relField.Name $scope.BoilerColumnName }} 149 | {{- if (call $scope.AddHook $relatedModel nil "validateForeignKey") }} 150 | {{ $.Backend.PackageName }}.{{ $relatedModel.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 151 | {{- end }} 152 | {{- end }} 153 | {{- end }} 154 | {{- end }} 155 | ).Exists(ctx, db) 156 | if err != nil { 157 | return fmt.Errorf("{{ $field.JSONName }}: %w", err) 158 | } 159 | if !exists { 160 | return fmt.Errorf("{{ $field.JSONName }}: referenced {{ $relatedModel.Name }} not found or access denied") 161 | } 162 | } 163 | {{- else }} 164 | if m.{{ $field.Name }} != nil { 165 | // Validate {{ $field.Name }} references a {{ $relatedModel.Name }} in user's scope 166 | exists, err := {{ $.Backend.PackageName }}.{{ $relatedModel.PluralName }}( 167 | {{ $.Backend.PackageName }}.{{ $relatedModel.Name }}Where.ID.EQ({{ $relatedModel.Name }}ID(*m.{{ $field.Name }})), 168 | {{- range $scope := $.AuthorizationScopes }} 169 | {{- range $relField := $relatedModel.Fields }} 170 | {{- if eq $relField.Name $scope.BoilerColumnName }} 171 | {{- if (call $scope.AddHook $relatedModel nil "validateForeignKey") }} 172 | {{ $.Backend.PackageName }}.{{ $relatedModel.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 173 | {{- end }} 174 | {{- end }} 175 | {{- end }} 176 | {{- end }} 177 | ).Exists(ctx, db) 178 | if err != nil { 179 | return fmt.Errorf("{{ $field.JSONName }}: %w", err) 180 | } 181 | if !exists { 182 | return fmt.Errorf("{{ $field.JSONName }}: referenced {{ $relatedModel.Name }} not found or access denied") 183 | } 184 | } 185 | {{- end }} 186 | {{- end }} 187 | {{- end }} 188 | {{- end }} 189 | {{- end }} 190 | return nil 191 | } 192 | {{- end }} 193 | {{- end }} 194 | -------------------------------------------------------------------------------- /template_files/generated_convert.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | 22 | "github.com/ericlagergren/decimal" 23 | "github.com/aarondl/sqlboiler/v4/boil" 24 | "github.com/aarondl/sqlboiler/v4/queries" 25 | "github.com/aarondl/sqlboiler/v4/queries/qm" 26 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 27 | "github.com/aarondl/sqlboiler/v4/types" 28 | "github.com/aarondl/null/v8" 29 | 30 | "database/sql" 31 | {{ range $import := .Imports }} 32 | {{ $import.Alias }} "{{ $import.ImportPath }}" 33 | {{ end }} 34 | ) 35 | 36 | {{ range $enum := .Enums }} 37 | 38 | {{- if $enum.HasBoilerEnum }} 39 | var {{$enum.Name}}DBValue = map[{{ $.Frontend.PackageName }}.{{ .Name }}]string{ 40 | {{- range $value := .Values }} 41 | {{- if .BoilerEnumValue }} 42 | {{ $.Frontend.PackageName }}.{{$enum.Name|go}}{{ .Name|go }}: {{ $.Backend.PackageName }}.{{ .BoilerEnumValue.Name }}, 43 | {{- end }} 44 | {{- end }} 45 | } 46 | 47 | var {{$enum.Name}}APIValue = map[string]{{ $.Frontend.PackageName }}.{{ .Name }}{ 48 | {{- range $value := .Values }} 49 | {{- if .BoilerEnumValue }} 50 | {{ $.Backend.PackageName }}.{{ .BoilerEnumValue.Name }}: {{ $.Frontend.PackageName }}.{{$enum.Name|go}}{{ .Name|go }}, 51 | {{- end }} 52 | {{- end }} 53 | } 54 | {{- else }} 55 | type {{$enum.Name}} string 56 | const ( 57 | {{- range $value := .Values }} 58 | {{$enum.Name|go}}{{ .Name|go }} {{$enum.Name}} = "{{ .NameLower }}" 59 | {{- end }} 60 | ) 61 | 62 | var {{$enum.Name}}DBValue = map[{{ $.Frontend.PackageName }}.{{ .Name }}]{{$enum.Name}}{ 63 | {{- range $value := .Values }} 64 | {{ $.Frontend.PackageName }}.{{$enum.Name|go}}{{ .Name|go }}: {{$enum.Name|go}}{{ .Name|go }}, 65 | {{- end }} 66 | } 67 | 68 | var {{$enum.Name}}APIValue = map[{{$enum.Name}}]{{ $.Frontend.PackageName }}.{{ .Name }}{ 69 | {{- range $value := .Values }} 70 | {{$enum.Name|go}}{{ .Name|go }}: {{ $.Frontend.PackageName }}.{{$enum.Name|go}}{{ .Name|go }}, 71 | {{- end }} 72 | } 73 | {{- end }} 74 | 75 | func NullDotStringToPointer{{ .Name }}(v null.String) *{{ $.Frontend.PackageName }}.{{ .Name }} { 76 | s := StringTo{{ .Name }}(v.String) 77 | if s == "" { 78 | return nil 79 | } 80 | return &s 81 | } 82 | 83 | func NullDotStringTo{{ .Name }}(v null.String) {{ $.Frontend.PackageName }}.{{ .Name }} { 84 | if !v.Valid { 85 | return "" 86 | } 87 | return StringTo{{ .Name }}(v.String) 88 | } 89 | 90 | func StringTo{{ .Name }}(v string) {{ $.Frontend.PackageName }}.{{ .Name }} { 91 | {{- if $enum.HasBoilerEnum }} 92 | return {{$enum.Name}}APIValue[v] 93 | {{- else }} 94 | return {{$enum.Name}}APIValue[{{ .Name }}(v)] 95 | {{- end }} 96 | } 97 | 98 | func StringToPointer{{ .Name }}(v string) *{{ $.Frontend.PackageName }}.{{ .Name }} { 99 | s := StringTo{{ .Name }}(v) 100 | if s == "" { 101 | return nil 102 | } 103 | return &s 104 | } 105 | 106 | func Pointer{{ .Name }}ToString(v *{{ $.Frontend.PackageName }}.{{ .Name }}) string { 107 | if v == nil { 108 | return "" 109 | } 110 | return {{ .Name }}ToString(*v) 111 | } 112 | 113 | func Pointer{{ .Name }}ToNullDotString(v *{{ $.Frontend.PackageName }}.{{ .Name }}) null.String { 114 | if v == nil { 115 | return null.NewString("", false) 116 | } 117 | return {{ .Name }}ToNullDotString(*v) 118 | } 119 | 120 | func {{ .Name }}ToNullDotString(v {{ $.Frontend.PackageName }}.{{ .Name }}) null.String { 121 | s := {{ .Name }}ToString(v) 122 | return null.NewString(s, s != "") 123 | } 124 | 125 | func {{ .Name }}ToString(v {{ $.Frontend.PackageName }}.{{ .Name }}) string { 126 | {{- if $enum.HasBoilerEnum }} 127 | return {{$enum.Name}}DBValue[v] 128 | {{- else }} 129 | return string({{$enum.Name}}DBValue[v]) 130 | {{- end }} 131 | } 132 | 133 | func {{ .PluralName }}ToInterfaceArray(va []{{ $.Frontend.PackageName }}.{{ .Name }}) []interface{} { 134 | var a []interface{} 135 | for _, v := range va { 136 | rv, ok := {{ .Name }}DBValue[v] 137 | if ok { 138 | a = append(a, rv) 139 | } 140 | } 141 | return a 142 | } 143 | 144 | {{ end }} 145 | 146 | {{ range $model := .Models }} 147 | 148 | {{- if .IsNormal -}} 149 | 150 | {{- if .HasPrimaryStringID }} 151 | func {{ .Name }}WithStringID(id string) *{{ $.Frontend.PackageName }}.{{ .Name }} { 152 | return &{{ $.Frontend.PackageName }}.{{ .Name }}{ 153 | ID: {{ $model.Name }}IDToGraphQL(id), 154 | } 155 | } 156 | 157 | func {{ .Name }}WithNullDotStringID(id null.String) *{{ $.Frontend.PackageName }}.{{ .Name }} { 158 | return {{ .Name }}WithStringID(id.String) 159 | } 160 | {{- else }} 161 | func {{ .Name }}WithUintID(id uint) *{{ $.Frontend.PackageName }}.{{ .Name }} { 162 | return &{{ $.Frontend.PackageName }}.{{ .Name }}{ 163 | ID: {{ $model.Name }}IDToGraphQL(id), 164 | } 165 | } 166 | 167 | func {{ .Name }}WithIntID(id int) *{{ $.Frontend.PackageName }}.{{ .Name }} { 168 | return {{ .Name }}WithUintID(uint(id)) 169 | } 170 | 171 | func {{ .Name }}WithNullDotUintID(id null.Uint) *{{ $.Frontend.PackageName }}.{{ .Name }} { 172 | return {{ .Name }}WithUintID(id.Uint) 173 | } 174 | 175 | func {{ .Name }}WithNullDotIntID(id null.Int) *{{ $.Frontend.PackageName }}.{{ .Name }} { 176 | return {{ .Name }}WithUintID(uint(id.Int)) 177 | } 178 | 179 | {{- end }} 180 | 181 | func {{ .PluralName }}ToGraphQL(ctx context.Context, db boil.ContextExecutor, am []*{{ $.Backend.PackageName }}.{{ .Name }})( []*{{ $.Frontend.PackageName }}.{{ .Name }}) { 182 | ar := make([]*{{ $.Frontend.PackageName }}.{{ .Name }}, len(am)) 183 | for i,m := range am { 184 | ar[i] = {{ .Name }}ToGraphQL(ctx, db, m) 185 | } 186 | return ar 187 | } 188 | 189 | {{ range $field := .Fields }} 190 | {{- if $field.IsPrimaryNumberID -}} 191 | func {{ $model.Name }}IDToGraphQL(v uint) string { 192 | return boilergql.IDToGraphQL(v, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $model.BoilerModel.TableName }}) 193 | } 194 | {{- end -}} 195 | {{- if $field.IsPrimaryStringID -}} 196 | func {{ $model.Name }}IDToGraphQL(v string) string { 197 | return boilergql.StringIDToGraphQL(v, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $model.BoilerModel.TableName }}) 198 | } 199 | {{- end -}} 200 | {{- end }} 201 | 202 | 203 | func {{ .Name }}ToGraphQL(ctx context.Context, db boil.ContextExecutor, m *{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }})( *{{ $.Frontend.PackageName }}.{{ .Name }}) { 204 | if m == nil { 205 | return nil 206 | } 207 | 208 | r := &{{ $.Frontend.PackageName }}.{{ .Name }}{ 209 | {{ range $field := .Fields -}} 210 | {{- if $field.ConvertConfig.IsCustom -}} 211 | {{- if $field.IsPrimaryID -}} 212 | {{- $field.Name }}: {{ $field.ConvertConfig.ToGraphQL }}, 213 | {{- else if and $field.IsNumberID $field.BoilerField.IsRelation -}} 214 | {{- $field.Name }}: {{ $field.ConvertConfig.ToGraphQL }}, 215 | {{- else if $field.IsRelation -}} 216 | // {{- $field.Name }}: ???? 217 | {{- else -}} 218 | {{- $field.Name }}: {{ $field.ConvertConfig.ToGraphQL }}(m.{{ $field.BoilerField.Name }}), 219 | {{- end }} 220 | {{- else if $field.IsRelation -}} 221 | {{- else -}} 222 | {{- $field.Name }}: m.{{ $field.BoilerField.Name }}, 223 | {{- end }} 224 | {{ end }} 225 | } 226 | 227 | {{ range $field := .Fields }} 228 | 229 | {{- if $field.IsRelation }} 230 | 231 | {{- if $field.IsPlural }} 232 | if m.R != nil && m.R.{{ $field.BoilerField.Name }} != nil { 233 | r.{{ $field.Name }} = {{ $field.BoilerField.Relationship.PluralName }}ToGraphQL(ctx, db, m.R.{{ $field.BoilerField.Name }}) 234 | } 235 | {{- else }} 236 | {{- if $field.BoilerField.IsForeignKey }} 237 | if boilergql.{{ $field.ConvertConfig.BoilerTypeAsText }}IsFilled(m.{{ $field.Name }}ID) { 238 | if m.R != nil && m.R.{{ $field.Name }} != nil { 239 | r.{{ $field.Name }} = {{ $field.BoilerField.Relationship.Name }}ToGraphQL(ctx, db, m.R.{{ $field.Name }}) 240 | } else { 241 | r.{{ $field.Name }} = {{ $field.BoilerField.Relationship.Name }}With{{ $field.ConvertConfig.BoilerTypeAsText }}ID(m.{{ $field.Name }}ID) 242 | } 243 | } 244 | {{- else }} 245 | if m.R != nil && m.R.{{ $field.BoilerField.Name }} != nil { 246 | r.{{ $field.Name }} = {{ $field.BoilerField.Relationship.Name }}ToGraphQL(ctx, db, m.R.{{ $field.BoilerField.Name }}) 247 | } 248 | {{- end -}} 249 | {{- end -}} 250 | {{end -}} 251 | {{- end }} 252 | 253 | return r 254 | } 255 | 256 | {{ range $field := .Fields }} 257 | {{- if $field.IsPrimaryNumberID }} 258 | func {{ $model.Name }}ID(v string) {{ $field.BoilerField.Type }} { 259 | return boilergql.IDToBoiler{{ $field.BoilerField.Type|go }}(v) 260 | } 261 | 262 | func {{ $model.Name }}IDs(a []string) []{{ $field.BoilerField.Type }} { 263 | return boilergql.IDsToBoiler{{ $field.BoilerField.Type|go }}(a) 264 | } 265 | 266 | {{- end -}} 267 | {{- if $field.IsPrimaryStringID }} 268 | func {{ $model.Name }}ID(v string) {{ $field.BoilerField.Type }} { 269 | return boilergql.StringIDToBoiler{{ $field.BoilerField.Type|go }}(v) 270 | } 271 | 272 | func {{ $model.Name }}IDs(a []string) []{{ $field.BoilerField.Type }} { 273 | return boilergql.StringIDsToBoiler{{ $field.BoilerField.Type|go }}(a) 274 | } 275 | 276 | {{- end -}} 277 | {{- end }} 278 | {{ end }} 279 | {{- end }} 280 | -------------------------------------------------------------------------------- /llm.txt: -------------------------------------------------------------------------------- 1 | # gqlgen-sqlboiler 2 | 3 | A Go code generator that bridges gqlgen (GraphQL) and sqlboiler (database ORM) by automatically generating type-safe resolvers, converters, filters, and preloaders. 4 | 5 | ## What It Does 6 | 7 | This library generates boilerplate code that connects your GraphQL API to your database: 8 | 9 | 1. **GraphQL Schema** - Generates `schema.graphql` from your database models 10 | 2. **Resolvers** - Generates CRUD mutation and query resolvers 11 | 3. **Converters** - Generates functions to convert between GraphQL types and database models 12 | 4. **Filters** - Generates filter input types and their SQL implementations 13 | 5. **Preloaders** - Generates smart preloading based on GraphQL selection sets 14 | 6. **Sorting** - Generates sorting/ordering implementations 15 | 16 | ## Generated Files 17 | 18 | When you run the generator, it creates: 19 | 20 | - `generated_convert.go` - Convert database models to GraphQL types 21 | - `generated_convert_input.go` - Convert GraphQL input types to database models 22 | - `generated_convert_batch.go` - Batch conversion helpers 23 | - `generated_crud.go` - Reusable CRUD helper functions (Fetch, Create, Update, Delete) 24 | - `generated_filter.go` - Filter implementations for WHERE clauses 25 | - `generated_preload.go` - Preload logic based on GraphQL queries 26 | - `generated_sort.go` - Sorting implementations 27 | - `all_generated_resolvers.go` - Query and mutation resolver implementations 28 | 29 | ## How to Configure 30 | 31 | Configuration is done in a `convert/convert.go` file that you run with `go run convert.go`: 32 | 33 | ```go 34 | package main 35 | 36 | import ( 37 | gbgen "github.com/web-ridge/gqlgen-sqlboiler/v3" 38 | "github.com/web-ridge/gqlgen-sqlboiler/v3/cache" 39 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 40 | ) 41 | 42 | func main() { 43 | // Define paths 44 | output := structs.Config{Directory: "helpers", PackageName: "helpers"} 45 | backend := structs.Config{Directory: "models/dm", PackageName: "dm"} 46 | frontend := structs.Config{Directory: "models/fm", PackageName: "fm"} 47 | 48 | // Initialize caches 49 | boilerCache := cache.InitializeBoilerCache(backend) 50 | modelCache := cache.InitializeModelCache(cfg, boilerCache, output, backend, frontend) 51 | 52 | // Generate converts, filters, preloads 53 | gbgen.NewConvertPlugin(modelCache, gbgen.ConvertPluginConfig{ 54 | DatabaseDriver: gbgen.MySQL, // or gbgen.PostgreSQL 55 | }).GenerateCode(authScopes) 56 | 57 | // Generate resolvers 58 | gbgen.NewResolverPlugin( 59 | config.ResolverConfig{ 60 | Filename: "resolvers/all_generated_resolvers.go", 61 | Package: "resolvers", 62 | Type: "Resolver", 63 | }, 64 | output, boilerCache, modelCache, 65 | gbgen.ResolverPluginConfig{ 66 | EnableSoftDeletes: true, 67 | AuthorizationScopes: authScopes, 68 | }, 69 | ).GenerateCode(data) 70 | } 71 | ``` 72 | 73 | ## How to Override Generated Resolvers 74 | 75 | The generator detects user-defined resolver functions and skips generating them. To override: 76 | 77 | 1. Create a new file in your resolvers directory (e.g., `resolvers/custom_user.go`) 78 | 2. Define a function with the exact same name as the generated one 79 | 3. Re-run the generator - it will skip generating that function 80 | 81 | ```go 82 | // resolvers/custom_user.go 83 | package resolvers 84 | 85 | func (r *mutationResolver) CreateUser(ctx context.Context, input fm.UserCreateInput) (*fm.UserPayload, error) { 86 | // Your custom implementation 87 | // The generated version of this function will be skipped 88 | } 89 | ``` 90 | 91 | ## How to Override Generated Converters 92 | 93 | Similar to resolvers, you can override convert functions: 94 | 95 | 1. Create a file in your helpers directory (e.g., `helpers/convert_override_user.go`) 96 | 2. Define the same function name - the generator renames the original to `original*` 97 | 98 | ```go 99 | // helpers/convert_override_user.go 100 | package helpers 101 | 102 | func UserCreateInputToBoiler(m *graphql_models.UserCreateInput) *models.User { 103 | // Call the original if needed 104 | original := originalUserCreateInputToBoiler(m) 105 | // Add custom logic (e.g., hash password) 106 | return original 107 | } 108 | ``` 109 | 110 | ## Authorization Scopes 111 | 112 | Authorization scopes automatically inject tenant/user filtering into queries and mutations: 113 | 114 | ```go 115 | authScopes := []*gbgen.AuthorizationScope{ 116 | { 117 | ImportPath: "github.com/my-app/auth", 118 | ImportAlias: "auth", 119 | ScopeResolverName: "OrganizationIDFromContext", // Function to get value from context 120 | BoilerColumnName: "OrganizationID", // Database column to filter on 121 | AddHook: func(model *structs.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool { 122 | // Return true to apply this scope to the model/resolver 123 | // templateKey tells you where it's being applied: 124 | // - "singleWhere" - Single record queries 125 | // - "listWhere" - List queries 126 | // - "createInput" - Create mutations (sets the column) 127 | // - "updateWhere" - Update mutations (filters) 128 | // - "deleteWhere" - Delete mutations (filters) 129 | // - "validateForeignKey" - FK validation on create/update 130 | return hasColumn(model, "OrganizationID") 131 | }, 132 | }, 133 | } 134 | ``` 135 | 136 | ### templateKey Values 137 | 138 | The `AddHook` function receives a `templateKey` that identifies where the scope is being applied: 139 | 140 | | templateKey | Operation | What It Does | 141 | |-------------|-----------|--------------| 142 | | `singleWhere` | Query single | Adds WHERE clause to single record fetch | 143 | | `listWhere` | Query list | Adds WHERE clause to list queries | 144 | | `createInput` | Create mutation | Sets column value on new record | 145 | | `createRelationInput` | Create nested | Sets column on nested relation create | 146 | | `updateWhere` | Update mutation | Adds WHERE clause to update | 147 | | `updateRelationWhere` | Update nested | Adds WHERE clause to nested update | 148 | | `updateAfterWhere` | Update refetch | Adds WHERE when refetching after update | 149 | | `deleteWhere` | Delete mutation | Adds WHERE clause to delete | 150 | | `batchUpdateWhere` | Batch update | Adds WHERE to batch updates | 151 | | `batchDeleteWhere` | Batch delete | Adds WHERE to batch deletes | 152 | | `validateForeignKey` | FK validation | Validates FK references are in scope | 153 | 154 | ## Foreign Key Validation 155 | 156 | When `validateForeignKey` is enabled, the generator creates validation functions that verify foreign keys reference records within the user's authorization scope: 157 | 158 | ```go 159 | // Generated function 160 | func ValidatePostCreateInputForeignKeys(ctx context.Context, db boil.ContextExecutor, m *fm.PostCreateInput) error { 161 | if m.AuthorID != nil { 162 | // Check that the referenced User has matching OrganizationID 163 | exists, err := dm.Users( 164 | dm.UserWhere.ID.EQ(UserID(*m.AuthorID)), 165 | dm.UserWhere.OrganizationID.EQ(auth.OrganizationIDFromContext(ctx)), 166 | ).Exists(ctx, db) 167 | if !exists { 168 | return fmt.Errorf("authorId: referenced User not found or access denied") 169 | } 170 | } 171 | return nil 172 | } 173 | ``` 174 | 175 | This is called automatically in create/update resolvers before database writes. 176 | 177 | ## Schema Generation Hooks 178 | 179 | Control schema generation with hooks: 180 | 181 | ```go 182 | gbgen.SchemaWrite(gbgen.SchemaConfig{ 183 | // Skip certain models from schema 184 | HookShouldAddModel: func(model gbgen.SchemaModel) bool { 185 | return model.Name != "InternalConfig" 186 | }, 187 | 188 | // Skip certain fields 189 | HookShouldAddField: func(model gbgen.SchemaModel, field gbgen.SchemaField) bool { 190 | return field.Name != "internalField" 191 | }, 192 | 193 | // Modify fields (e.g., skip from input) 194 | HookChangeField: func(model *gbgen.SchemaModel, field *gbgen.SchemaField) { 195 | if field.Name == "userId" { 196 | field.SkipInput = true // Don't include in create/update inputs 197 | } 198 | }, 199 | 200 | // Modify all fields for a model 201 | HookChangeFields: func(model *gbgen.SchemaModel, fields []*gbgen.SchemaField, parentType gbgen.ParentType) []*gbgen.SchemaField { 202 | return fields 203 | }, 204 | }) 205 | ``` 206 | 207 | ## Key Types 208 | 209 | ### BoilerModel 210 | Represents a database table/model from sqlboiler: 211 | - `Name` - Model name (e.g., "User") 212 | - `TableName` - Database table name 213 | - `PluralName` - Pluralized name (e.g., "Users") 214 | - `Fields` - List of fields/columns 215 | 216 | ### BoilerField 217 | Represents a database column: 218 | - `Name` - Field name 219 | - `Type` - Go type 220 | - `IsForeignKey` - Whether it's a FK 221 | - `IsRelation` - Whether it references another table 222 | - `Relationship` - The related BoilerModel (if FK) 223 | 224 | ### Resolver 225 | Represents a GraphQL resolver being generated: 226 | - `IsCreate`, `IsUpdate`, `IsDelete` - Mutation type flags 227 | - `IsSingle`, `IsList` - Query type flags 228 | - `IsBatchCreate`, `IsBatchUpdate`, `IsBatchDelete` - Batch operation flags 229 | - `Model` - The model this resolver operates on 230 | - `InputModel` - The input type for mutations 231 | 232 | ## Reusable CRUD Helpers 233 | 234 | The generator creates CRUD helper functions in `generated_crud.go` that can be called from custom resolvers: 235 | 236 | ```go 237 | // Fetch with preloads and authorization 238 | func FetchActivity(ctx context.Context, db boil.ContextExecutor, id string, preloadLevel string) (*dm.Activity, error) 239 | 240 | // Create with FK validation and authorization 241 | func CreateActivity(ctx context.Context, db boil.ContextExecutor, input fm.ActivityCreateInput, preloadLevel string) (*dm.Activity, error) 242 | 243 | // Update with FK validation and authorization 244 | func UpdateActivity(ctx context.Context, db boil.ContextExecutor, id string, input fm.ActivityUpdateInput, preloadLevel string) (*dm.Activity, error) 245 | 246 | // Delete with authorization 247 | func DeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) error 248 | 249 | // Soft delete (only if model has deleted_at column) 250 | func SoftDeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) error 251 | ``` 252 | 253 | These helpers: 254 | - Include FK validation (checks referenced records are in user's scope) 255 | - Apply authorization scopes automatically 256 | - Accept preload level for flexible relationship loading 257 | - Return boiler models (convert to GraphQL in your resolver) 258 | 259 | ## Dependencies 260 | 261 | - [gqlgen](https://github.com/99designs/gqlgen) - GraphQL server generator 262 | - [sqlboiler](https://github.com/volatiletech/sqlboiler) - Database ORM generator 263 | - [boilergql](https://github.com/web-ridge/utils-go/tree/main/boilergql) - Utility functions for pagination, filtering, etc. 264 | -------------------------------------------------------------------------------- /template_files/generated_sort.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/web-ridge/utils-go/boilergql/v3" 16 | "github.com/vektah/gqlparser/v2" 17 | "github.com/vektah/gqlparser/v2/ast" 18 | "github.com/99designs/gqlgen/graphql" 19 | "github.com/99designs/gqlgen/graphql/introspection" 20 | 21 | 22 | "github.com/ericlagergren/decimal" 23 | "github.com/aarondl/sqlboiler/v4/boil" 24 | "github.com/aarondl/sqlboiler/v4/queries" 25 | "github.com/aarondl/sqlboiler/v4/queries/qm" 26 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 27 | "github.com/aarondl/sqlboiler/v4/types" 28 | "github.com/aarondl/null/v8" 29 | 30 | "database/sql" 31 | {{ range $import := .Imports }} 32 | {{ $import.Alias }} "{{ $import.ImportPath }}" 33 | {{ end }} 34 | ) 35 | 36 | 37 | 38 | {{ range $model := .Models }} 39 | 40 | {{- if .IsOrdering -}} 41 | 42 | {{- range $field := .Fields -}} 43 | {{- if eq $field.Name "Sort" -}} 44 | var {{ $field.Enum.Name }}Column = map[{{ $.Frontend.PackageName }}.{{$field.Enum.Name}}]string{ 45 | {{- range $value := $field.Enum.Values}} 46 | {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }}: {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $value.Name|go }}, 47 | {{- end }} 48 | } 49 | 50 | func {{ $model.BoilerModel.Name }}SortValueFromCursorValue(cursorValue string) (string, interface{}) { 51 | key, value := boilergql.FromCursorValue(cursorValue) 52 | column := {{ $model.BoilerModel.Name }}SortColumn[{{ $.Frontend.PackageName }}.{{ $model.BoilerModel.Name }}Sort(key)] 53 | 54 | 55 | {{ range $value := $field.Enum.Values}} 56 | {{- if eq $value.Name "ID" -}} 57 | if {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}(key) == {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }} { 58 | return column, boilergql.GetIDFromCursor(value) 59 | } 60 | {{- end -}} 61 | {{ end }} 62 | 63 | return column, boilergql.StringToInterface(value) 64 | } 65 | 66 | func {{ $model.BoilerModel.Name }}SortCursorValue(sort {{ $.Frontend.PackageName }}.{{ $model.BoilerModel.Name }}Sort, m *{{ $.Frontend.PackageName }}.{{ $model.BoilerModel.Name }}) interface{} { 67 | switch sort { 68 | {{- range $value := $field.Enum.Values }} 69 | case {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }}: 70 | return m.{{ .Name|go }} 71 | {{- end }} 72 | } 73 | return nil 74 | } 75 | {{ end }} 76 | {{- end }} 77 | 78 | 79 | func {{ .BoilerModel.Name }}SortDirection(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering) boilergql.SortDirection { 80 | for _, o := range ordering { 81 | return o.Direction 82 | } 83 | return boilergql.SortDirectionAsc 84 | } 85 | 86 | 87 | func From{{ .BoilerModel.Name }}Cursor(cursor string, comparisonSign boilergql.ComparisonSign) []qm.QueryMod { 88 | var columns []string 89 | var values []interface{} 90 | 91 | for _, cursorValue := range boilergql.CursorStringToValues(cursor) { 92 | column, value := {{ .BoilerModel.Name }}SortValueFromCursorValue(cursorValue) 93 | if column != "" && value != nil { 94 | columns = append(columns, dm.{{- .TableNameResolverName }}.{{ .BoilerModel.TableName }}+"."+column) 95 | values = append(values, value) 96 | } 97 | } 98 | 99 | if len(columns) > 0 { 100 | return []qm.QueryMod{ 101 | qm.Where(boilergql.GetCursorWhere(comparisonSign, columns, values), values...), 102 | } 103 | } 104 | return nil 105 | } 106 | 107 | func To{{ .BoilerModel.Name }}Cursor(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, m *{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}) string { 108 | var a []string 109 | var handledID bool 110 | 111 | for _, order := range ordering { 112 | {{- range $field := .Fields -}} 113 | {{- if eq $field.Name "Sort" -}} 114 | {{- range $value := $field.Enum.Values -}} 115 | {{ if eq $value.Name "ID" }} 116 | if order.Sort == {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }} { 117 | handledID = true 118 | } 119 | {{ end }} 120 | {{- end -}} 121 | {{- end -}} 122 | {{- end -}} 123 | value := {{ .BoilerModel.Name }}SortCursorValue(order.Sort, m) 124 | if value != nil { 125 | a = append(a, boilergql.ToCursorValue(string(order.Sort), value)) 126 | } 127 | } 128 | 129 | {{- range $field := .Fields -}} 130 | {{- if eq $field.Name "Sort" -}} 131 | {{- range $value := $field.Enum.Values}} 132 | {{ if eq $value.Name "ID" }} 133 | if !handledID { 134 | a = append(a, boilergql.ToCursorValue(string({{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }}), m.ID)) 135 | } 136 | {{ end }} 137 | {{- end -}} 138 | {{- end -}} 139 | {{- end -}} 140 | 141 | 142 | return boilergql.CursorValuesToString(a) 143 | } 144 | 145 | func {{ .BoilerModel.Name }}CursorType(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering) boilergql.CursorType { 146 | countDirection, result := boilergql.CursorTypeCounter() 147 | for _, o := range ordering { 148 | countDirection(o.Direction) 149 | } 150 | return result() 151 | } 152 | 153 | func {{ .BoilerModel.Name }}CursorMods(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, cursor *string, sign boilergql.ComparisonSign) []qm.QueryMod { 154 | if cursor != nil { 155 | if {{ .BoilerModel.Name }}CursorType(ordering) == boilergql.CursorTypeCursor { 156 | return From{{ .BoilerModel.Name }}Cursor(*cursor, sign) 157 | } 158 | return boilergql.FromOffsetCursor(*cursor) 159 | } 160 | return nil 161 | } 162 | 163 | func {{ .BoilerModel.Name }}SortMods(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, reverse bool, defaultDirection boilergql.SortDirection) []qm.QueryMod { 164 | var a []qm.QueryMod 165 | 166 | var handledID bool 167 | for _, order := range ordering { 168 | {{- range $field := .Fields -}} 169 | {{- if eq $field.Name "Sort" -}} 170 | {{- range $value := $field.Enum.Values -}} 171 | {{ if eq $value.Name "ID" }} 172 | if order.Sort == {{ $.Frontend.PackageName }}.{{ $field.Enum.Name|go }}{{ .Name|go }} { 173 | handledID = true 174 | } 175 | {{ end }} 176 | {{- end -}} 177 | {{- end -}} 178 | {{- end -}} 179 | 180 | column := {{ .BoilerModel.Name }}SortColumn[order.Sort] 181 | if column != "" { 182 | a = append(a, qm.OrderBy(boilergql.GetOrderBy( 183 | column, 184 | boilergql.GetDirection(order.Direction, reverse), 185 | ))) 186 | } 187 | } 188 | if !handledID { 189 | a = append(a, qm.OrderBy(boilergql.GetOrderBy( 190 | {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.ID, 191 | boilergql.GetDirection(defaultDirection, reverse), 192 | ))) 193 | } 194 | return a 195 | } 196 | 197 | 198 | func {{ .BoilerModel.Name }}PaginationModsBase(pagination boilergql.ConnectionPagination, ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, reverse bool, limit int) (*string, []qm.QueryMod) { 199 | direction := {{ .BoilerModel.Name }}SortDirection(ordering) 200 | cursor := boilergql.GetCursor(pagination.Forward, pagination.Backward) 201 | sign := boilergql.GetComparison(pagination.Forward, pagination.Backward, reverse, direction) 202 | 203 | var mods []qm.QueryMod 204 | mods = append(mods, {{ .BoilerModel.Name }}CursorMods(ordering, cursor, sign)...) 205 | mods = append(mods, {{ .BoilerModel.Name }}SortMods(ordering, reverse, direction)...) 206 | mods = append(mods, qm.Limit(limit)) 207 | return cursor, mods 208 | } 209 | 210 | func {{ .BoilerModel.Name }}PaginationMods(pagination boilergql.ConnectionPagination, ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering) ([]qm.QueryMod, error) { 211 | if pagination.Forward != nil && pagination.Backward != nil { 212 | return nil, errors.New("can not use forward and backward pagination at once") 213 | } 214 | if pagination.Forward == nil && pagination.Backward == nil { 215 | return nil, errors.New("no forward or backward pagination provided") 216 | } 217 | 218 | reverse := pagination.Backward != nil 219 | limit := boilergql.GetLimit(pagination.Forward, pagination.Backward) 220 | _, mods := {{ .BoilerModel.Name }}PaginationModsBase(pagination, ordering, reverse, limit) 221 | return mods, nil 222 | } 223 | 224 | func To{{ .BoilerModel.Name }}CursorSwitch(ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, m *{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}, cursorType boilergql.CursorType, offset int, index int) string { 225 | switch cursorType { 226 | case boilergql.CursorTypeOffset: 227 | return boilergql.ToOffsetCursor(offset + index) 228 | case boilergql.CursorTypeCursor: 229 | return To{{ .BoilerModel.Name }}Cursor(ordering, m) 230 | } 231 | return "" 232 | } 233 | 234 | func {{ .BoilerModel.Name }}ReversePageInformation( 235 | ctx context.Context, 236 | db *sql.DB, 237 | pagination boilergql.ConnectionPagination, 238 | ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, 239 | ) (bool, error) { 240 | reverse := pagination.Forward != nil 241 | cursor, reverseMods := {{ .BoilerModel.Name }}PaginationModsBase(pagination, ordering, reverse, 1) 242 | cursorType := {{ .BoilerModel.Name }}CursorType(ordering) 243 | return boilergql.HasReversePage(cursor, pagination, cursorType, func() (int64, error) { 244 | return {{ $.Backend.PackageName }}.{{ .BoilerModel.PluralName }}(reverseMods...).Count(ctx, db) 245 | }) 246 | } 247 | 248 | func {{ .BoilerModel.Name }}EdgeConverter(ctx context.Context, db boil.ContextExecutor, pagination boilergql.ConnectionPagination, ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering) func(*{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, int) *{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Edge { 249 | cursor, cursorType := boilergql.GetCursor(pagination.Forward, pagination.Backward), {{ .BoilerModel.Name }}CursorType(ordering) 250 | offset := boilergql.GetOffsetFromCursor(cursor) 251 | return func(m *{{ $.Backend.PackageName }}.{{ .BoilerModel.Name }}, i int) *{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Edge { 252 | n := {{ .BoilerModel.Name }}ToGraphQL(ctx, db, m) 253 | return &{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Edge{ 254 | Cursor: To{{ .BoilerModel.Name }}CursorSwitch(ordering, n, cursorType, offset, i), 255 | Node: n, 256 | } 257 | } 258 | } 259 | 260 | func {{ .BoilerModel.Name }}StartEndCursor(edges []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Edge) (*string, *string) { 261 | var startCursor, endCursor *string 262 | if len(edges) >= 2 { 263 | s, e := edges[0].Cursor, edges[len(edges)-1].Cursor 264 | startCursor = &s 265 | endCursor = &e 266 | } else if len(edges) == 1 { 267 | c := edges[0].Cursor 268 | startCursor = &c 269 | endCursor = &c 270 | } 271 | return startCursor, endCursor 272 | } 273 | 274 | func {{ .BoilerModel.Name }}Connection( 275 | ctx context.Context, 276 | db *sql.DB, 277 | originalMods []qm.QueryMod, 278 | pagination boilergql.ConnectionPagination, 279 | ordering []*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Ordering, 280 | ) (*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Connection, error) { 281 | paginationMods, err := {{ .BoilerModel.Name }}PaginationMods(pagination, ordering) 282 | if err != nil { 283 | return nil, err 284 | } 285 | 286 | hasMoreReversed, err := {{ .BoilerModel.Name }}ReversePageInformation(ctx, db, pagination, ordering) 287 | if err != nil { 288 | return nil, err 289 | } 290 | 291 | a, err := {{ $.Backend.PackageName }}.{{ .BoilerModel.PluralName }}(append(originalMods, paginationMods...)...).All(ctx, db) 292 | if err != nil { 293 | return nil, err 294 | } 295 | edges := make([]*{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Edge, 0, boilergql.EdgeLength(pagination, len(a))) 296 | edgeConverter := {{ .BoilerModel.Name }}EdgeConverter(ctx, db, pagination, ordering) 297 | hasMore := boilergql.BaseConnection(pagination, len(a), func(i int) { 298 | edges = append(edges, edgeConverter(a[i], i)) 299 | }) 300 | startCursor, endCursor := {{ .BoilerModel.Name }}StartEndCursor(edges) 301 | hasNextPage, hasPreviousPage := boilergql.HasNextAndPreviousPage(pagination, hasMore, hasMoreReversed) 302 | return &{{ $.Frontend.PackageName }}.{{ .BoilerModel.Name }}Connection{ 303 | Edges: edges, 304 | PageInfo: &{{ $.Frontend.PackageName }}.PageInfo{ 305 | HasNextPage: hasNextPage, 306 | HasPreviousPage: hasPreviousPage, 307 | StartCursor: startCursor, 308 | EndCursor: endCursor, 309 | }, 310 | }, nil 311 | } 312 | 313 | 314 | 315 | {{ end }} 316 | {{ end }} 317 | -------------------------------------------------------------------------------- /plugin_resolver.go: -------------------------------------------------------------------------------- 1 | package gbgen 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 10 | 11 | "github.com/web-ridge/gqlgen-sqlboiler/v3/cache" 12 | "github.com/web-ridge/gqlgen-sqlboiler/v3/customization" 13 | 14 | "github.com/rs/zerolog/log" 15 | 16 | "github.com/99designs/gqlgen/codegen" 17 | "github.com/99designs/gqlgen/codegen/config" 18 | "github.com/iancoleman/strcase" 19 | "github.com/web-ridge/gqlgen-sqlboiler/v3/templates" 20 | ) 21 | 22 | func NewResolverPlugin(resolverConfig config.ResolverConfig, output structs.Config, boilerCache *cache.BoilerCache, modelCache *cache.ModelCache, resolverPluginConfig ResolverPluginConfig) *ResolverPlugin { 23 | return &ResolverPlugin{ 24 | resolverConfig: resolverConfig, 25 | output: output, 26 | BoilerCache: boilerCache, 27 | ModelCache: modelCache, 28 | pluginConfig: resolverPluginConfig, 29 | rootImportPath: getRootImportPath(), 30 | } 31 | } 32 | 33 | type AuthorizationScope struct { 34 | ImportPath string 35 | ImportAlias string 36 | ScopeResolverName string 37 | BoilerColumnName string 38 | AddHook func(model *structs.BoilerModel, resolver *Resolver, templateKey string) bool 39 | } 40 | 41 | type ResolverPluginConfig struct { 42 | EnableSoftDeletes bool 43 | AuthorizationScopes []*AuthorizationScope 44 | } 45 | 46 | type ResolverPlugin struct { 47 | resolverConfig config.ResolverConfig 48 | BoilerCache *cache.BoilerCache 49 | ModelCache *cache.ModelCache 50 | output structs.Config 51 | pluginConfig ResolverPluginConfig 52 | rootImportPath string 53 | } 54 | 55 | func (m *ResolverPlugin) GenerateCode(data *codegen.Data) error { 56 | err := m.generateSingleFile(data, m.ModelCache.Models, m.BoilerCache.BoilerModels) 57 | return err 58 | } 59 | 60 | func (m *ResolverPlugin) generateSingleFile(data *codegen.Data, models []*structs.Model, _ []*structs.BoilerModel) error { 61 | file := File{} 62 | 63 | file.Imports = append(file.Imports, Import{ 64 | Alias: ".", 65 | ImportPath: path.Join(m.rootImportPath, m.output.Directory), 66 | }) 67 | 68 | file.Imports = append(file.Imports, Import{ 69 | Alias: "dm", 70 | ImportPath: path.Join(m.rootImportPath, m.ModelCache.Backend.Directory), 71 | }) 72 | 73 | file.Imports = append(file.Imports, Import{ 74 | Alias: "fm", 75 | ImportPath: path.Join(m.rootImportPath, m.ModelCache.Frontend.Directory), 76 | }) 77 | 78 | file.Imports = append(file.Imports, Import{ 79 | Alias: "gm", 80 | ImportPath: buildImportPath(m.rootImportPath, data.Config.Exec.ImportPath()), 81 | }) 82 | 83 | addedAliases := make(map[string]bool) 84 | for _, scope := range m.pluginConfig.AuthorizationScopes { 85 | 86 | if !addedAliases[scope.ImportAlias] { 87 | file.Imports = append(file.Imports, Import{ 88 | Alias: scope.ImportAlias, 89 | ImportPath: scope.ImportPath, 90 | }) 91 | } 92 | addedAliases[scope.ImportAlias] = true 93 | } 94 | 95 | for _, o := range data.Objects { 96 | if o.HasResolvers() { 97 | file.Objects = append(file.Objects, o) 98 | } 99 | for _, f := range o.Fields { 100 | if !f.IsResolver { 101 | continue 102 | } 103 | resolver := &Resolver{ 104 | Object: o, 105 | Field: f, 106 | Implementation: `panic("not implemented yet")`, 107 | } 108 | enhanceResolver(m.pluginConfig, resolver, models) 109 | if resolver.Model.BoilerModel != nil && resolver.Model.BoilerModel.Name != "" { 110 | file.Resolvers = append(file.Resolvers, resolver) 111 | } else if resolver.Field.GoFieldName != "Node" { 112 | // log.Debug().Str("resolver", resolver.Object.Name).Str("field", resolver.Field.GoFieldName).Msg( 113 | // "skipping resolver since no model found") 114 | } 115 | } 116 | } 117 | 118 | // Get directory and filename of the resolver output 119 | resolverDir := filepath.Dir(m.resolverConfig.Filename) 120 | resolverBasename := filepath.Base(m.resolverConfig.Filename) 121 | 122 | // Scan for user-defined functions in the resolver directory, ignoring the generated file 123 | userDefinedFunctions, err := customization.GetFunctionNamesFromDir(resolverDir, []string{resolverBasename}) 124 | if err != nil { 125 | log.Err(err).Msg("could not parse user defined resolver functions") 126 | } 127 | 128 | // Convert to map for faster lookup in template 129 | userDefinedResolvers := make(map[string]bool) 130 | for _, fn := range userDefinedFunctions { 131 | userDefinedResolvers[fn] = true 132 | } 133 | 134 | resolverBuild := &ResolverBuild{ 135 | File: &file, 136 | PackageName: m.resolverConfig.Package, 137 | ResolverType: m.resolverConfig.Type, 138 | HasRoot: false, 139 | Models: models, 140 | AuthorizationScopes: m.pluginConfig.AuthorizationScopes, 141 | UserDefinedResolvers: userDefinedResolvers, 142 | } 143 | 144 | templateName := "generated_resolver.gotpl" 145 | templateContent, err := getTemplateContent(templateName) 146 | if err != nil { 147 | log.Err(err).Msg("error when reading " + templateName) 148 | return err 149 | } 150 | 151 | return templates.WriteTemplateFile(m.resolverConfig.Filename, templates.Options{ 152 | Template: templateContent, 153 | PackageName: m.resolverConfig.Package, 154 | Data: resolverBuild, 155 | }) 156 | } 157 | 158 | func buildImportPath(rootImportPath, directory string) string { 159 | index := strings.Index(directory, rootImportPath) 160 | if index > 0 { 161 | return directory[index:] 162 | } 163 | return directory 164 | } 165 | 166 | type ResolverBuild struct { 167 | *File 168 | HasRoot bool 169 | PackageName string 170 | ResolverType string 171 | Models []*structs.Model 172 | AuthorizationScopes []*AuthorizationScope 173 | TryHook func(string) bool 174 | UserDefinedResolvers map[string]bool 175 | } 176 | 177 | // IsResolverOverridden checks if the resolver function is overridden by user 178 | func (rb *ResolverBuild) IsResolverOverridden(name string) bool { 179 | if rb.UserDefinedResolvers == nil { 180 | return false 181 | } 182 | return rb.UserDefinedResolvers[name] 183 | } 184 | 185 | type File struct { 186 | // These are separated because the type definition of the resolver object may live in a different file from the 187 | // resolver method implementations, for example when extending a type in a different graphql schema file 188 | Objects []*codegen.Object 189 | Resolvers []*Resolver 190 | Imports []Import 191 | RemainingSource string 192 | } 193 | 194 | type Resolver struct { 195 | Object *codegen.Object 196 | Field *codegen.Field 197 | 198 | Implementation string 199 | IsSingle bool 200 | IsList bool 201 | IsListForward bool 202 | IsListBackward bool 203 | IsCreate bool 204 | IsUpdate bool 205 | IsDelete bool 206 | IsBatchCreate bool 207 | IsBatchUpdate bool 208 | IsBatchDelete bool 209 | ResolveOrganizationID bool // TODO: something more pluggable 210 | ResolveUserOrganizationID bool // TODO: something more pluggable 211 | ResolveUserID bool // TODO: something more pluggable 212 | Model structs.Model 213 | InputModel structs.Model 214 | BoilerWhiteList string 215 | PublicErrorKey string 216 | PublicErrorMessage string 217 | SoftDeleteSuffix string 218 | } 219 | 220 | func (rb *ResolverBuild) getResolverType(ty string) string { 221 | for _, imp := range rb.Imports { 222 | if strings.Contains(ty, imp.ImportPath) { 223 | if imp.Alias != "" { 224 | ty = strings.Replace(ty, imp.ImportPath, imp.Alias, -1) 225 | } else { 226 | ty = strings.Replace(ty, imp.ImportPath, "", -1) 227 | } 228 | } 229 | } 230 | return ty 231 | } 232 | 233 | func (rb *ResolverBuild) ShortResolverDeclaration(r *Resolver) string { 234 | res := "(ctx context.Context" 235 | 236 | if !r.Field.Object.Root { 237 | res += fmt.Sprintf(", obj %s", rb.getResolverType(r.Field.Object.Reference().String())) 238 | } 239 | for _, arg := range r.Field.Args { 240 | res += fmt.Sprintf(", %s %s", arg.VarName, rb.getResolverType(arg.TypeReference.GO.String())) 241 | } 242 | 243 | result := rb.getResolverType(r.Field.TypeReference.GO.String()) 244 | if r.Field.Object.Stream { 245 | result = "<-chan " + result 246 | } 247 | 248 | res += fmt.Sprintf(") (%s, error)", result) 249 | return res 250 | } 251 | 252 | func enhanceResolver(resolverConfig ResolverPluginConfig, r *Resolver, models []*structs.Model) { //nolint:gocyclo 253 | nameOfResolver := r.Field.GoFieldName 254 | 255 | // get model names + model convert information 256 | modelName, inputModelName := getModelNames(nameOfResolver, false) 257 | // modelPluralName, _ := getModelNames(nameOfResolver, true) 258 | 259 | model := findModelOrEmpty(models, modelName) 260 | inputModel := findModelOrEmpty(models, inputModelName) 261 | 262 | // save for later inside file 263 | r.Model = model 264 | r.InputModel = inputModel 265 | 266 | switch r.Object.Name { 267 | case "Mutation": 268 | r.IsCreate = containsPrefixAndPartAfterThatIsSingle(nameOfResolver, "Create") 269 | r.IsUpdate = containsPrefixAndPartAfterThatIsSingle(nameOfResolver, "Update") 270 | r.IsDelete = containsPrefixAndPartAfterThatIsSingle(nameOfResolver, "Delete") 271 | r.IsBatchCreate = containsPrefixAndPartAfterThatIsPlural(nameOfResolver, "Create") 272 | r.IsBatchUpdate = containsPrefixAndPartAfterThatIsPlural(nameOfResolver, "Update") 273 | r.IsBatchDelete = containsPrefixAndPartAfterThatIsPlural(nameOfResolver, "Delete") 274 | if resolverConfig.EnableSoftDeletes == true && model.HasDeletedAt { 275 | r.SoftDeleteSuffix = ", false" 276 | } 277 | case "Query": 278 | isPlural := cache.IsPlural(nameOfResolver) 279 | if isPlural { 280 | r.IsList = isPlural 281 | r.IsListBackward = strings.Contains(r.Field.GoFieldName, "first int") && 282 | strings.Contains(r.Field.GoFieldName, "after *string") 283 | r.IsListBackward = strings.Contains(r.Field.GoFieldName, "last int") && 284 | strings.Contains(r.Field.GoFieldName, "before *string") 285 | } 286 | 287 | r.IsSingle = !r.IsList 288 | case "Subscription": 289 | // TODO: generate helpers for subscription 290 | default: 291 | log.Warn().Str("unknown", r.Object.Name).Msg( 292 | "only Query and Mutation are handled we don't recognize the following") 293 | } 294 | 295 | lmName := strcase.ToLowerCamel(model.Name) 296 | lmpName := strcase.ToLowerCamel(model.PluralName) 297 | r.PublicErrorKey = "public" 298 | 299 | if (r.IsCreate || r.IsDelete || r.IsUpdate) && strings.HasSuffix(lmName, "Batch") { 300 | r.PublicErrorKey += "One" 301 | } 302 | r.PublicErrorKey += model.Name 303 | 304 | switch { 305 | case r.IsSingle: 306 | r.PublicErrorKey += "Single" 307 | r.PublicErrorMessage = "could not get " + lmName 308 | case r.IsList: 309 | r.PublicErrorKey += "List" 310 | r.PublicErrorMessage = "could not list " + lmpName 311 | case r.IsCreate: 312 | r.PublicErrorKey += "Create" 313 | r.PublicErrorMessage = "could not create " + lmName 314 | case r.IsUpdate: 315 | r.PublicErrorKey += "Update" 316 | r.PublicErrorMessage = "could not update " + lmName 317 | case r.IsDelete: 318 | r.PublicErrorKey += "Delete" 319 | r.PublicErrorMessage = "could not delete " + lmName 320 | case r.IsBatchCreate: 321 | r.PublicErrorKey += "BatchCreate" 322 | r.PublicErrorMessage = "could not create " + lmpName 323 | case r.IsBatchUpdate: 324 | r.PublicErrorKey += "BatchUpdate" 325 | r.PublicErrorMessage = "could not update " + lmpName 326 | case r.IsBatchDelete: 327 | r.PublicErrorKey += "BatchDelete" 328 | r.PublicErrorMessage = "could not delete " + lmpName 329 | } 330 | 331 | r.PublicErrorKey += "Error" 332 | } 333 | 334 | func findModelOrEmpty(models []*structs.Model, modelName string) structs.Model { 335 | if modelName == "" { 336 | return structs.Model{} 337 | } 338 | for _, m := range models { 339 | if strings.ToLower(m.Name) == strings.ToLower(modelName) { 340 | return *m 341 | } 342 | } 343 | return structs.Model{} 344 | } 345 | 346 | var InputTypes = []string{"Create", "Update", "Delete"} //nolint:gochecknoglobals 347 | 348 | func getModelNames(v string, plural bool) (modelName, inputModelName string) { 349 | var prefix string 350 | var isInputType bool 351 | for _, inputType := range InputTypes { 352 | if strings.HasPrefix(v, inputType) { 353 | isInputType = true 354 | v = strings.TrimPrefix(v, inputType) 355 | prefix = inputType 356 | } 357 | } 358 | var s string 359 | if plural { 360 | s = cache.Plural(v) 361 | } else { 362 | s = cache.Singular(v) 363 | } 364 | 365 | if isInputType { 366 | return s, s + prefix + "Input" 367 | } 368 | 369 | return s, "" 370 | } 371 | 372 | func containsPrefixAndPartAfterThatIsSingle(v string, prefix string) bool { 373 | partAfterThat := strings.TrimPrefix(v, prefix) 374 | return strings.HasPrefix(v, prefix) && cache.IsSingular(partAfterThat) 375 | } 376 | 377 | func containsPrefixAndPartAfterThatIsPlural(v string, prefix string) bool { 378 | partAfterThat := strings.TrimPrefix(v, prefix) 379 | return strings.HasPrefix(v, prefix) && cache.IsPlural(partAfterThat) 380 | } 381 | -------------------------------------------------------------------------------- /template_files/generated_resolver.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "sync" 11 | "errors" 12 | "bytes" 13 | "strings" 14 | 15 | "github.com/ericlagergren/decimal" 16 | "github.com/aarondl/sqlboiler/v4/boil" 17 | "github.com/aarondl/sqlboiler/v4/queries" 18 | "github.com/aarondl/sqlboiler/v4/queries/qm" 19 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 20 | "github.com/aarondl/sqlboiler/v4/types" 21 | "github.com/aarondl/null/v8" 22 | 23 | "github.com/web-ridge/utils-go/boilergql/v3" 24 | 25 | "database/sql" 26 | "github.com/vektah/gqlparser/v2" 27 | "github.com/vektah/gqlparser/v2/ast" 28 | "github.com/99designs/gqlgen/graphql" 29 | "github.com/99designs/gqlgen/graphql/introspection" 30 | "github.com/rs/zerolog/log" 31 | {{ range $import := $.Imports }} 32 | {{ $import.Alias }} "{{ $import.ImportPath }}" 33 | {{ end }} 34 | ) 35 | 36 | 37 | {{ if .HasRoot }} 38 | type {{.ResolverType}} struct { 39 | db *sql.DB 40 | } 41 | 42 | func New(db *sql.DB) *Resolver { 43 | return &Resolver{ 44 | db: db, 45 | } 46 | } 47 | {{ end }} 48 | 49 | const inputKey = "input" 50 | 51 | {{ range $resolver := .Resolvers -}} 52 | 53 | {{- if .IsBatchCreate -}} 54 | // 55 | {{- end -}} 56 | const {{ $resolver.PublicErrorKey }} = "{{ $resolver.PublicErrorMessage }}" 57 | 58 | {{ if $.IsResolverOverridden $resolver.Field.GoFieldName -}} 59 | // {{ $resolver.Field.GoFieldName }} is overridden by user-defined resolver 60 | {{ else -}} 61 | func (r *{{lcFirst $resolver.Object.Name}}{{ucFirst $.ResolverType}}) {{$resolver.Field.GoFieldName}}{{ $.ShortResolverDeclaration $resolver }} { 62 | 63 | 64 | 65 | {{- if .IsSingle }} 66 | m, err := Fetch{{ .Model.Name }}(ctx, r.db, id, "") 67 | if err != nil { 68 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 69 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 70 | } 71 | return {{ .Model.Name }}ToGraphQL(ctx, r.db, m), nil 72 | 73 | {{- end -}} 74 | 75 | {{- if .IsList }} 76 | mods := Get{{ .Model.Name }}NodePreloadMods(ctx) 77 | {{ range $scope := $.AuthorizationScopes -}} 78 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "listWhere") }} 79 | mods = append(mods, dm.{{ $resolver.Model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx))) 80 | {{- end }} 81 | {{- end }} 82 | 83 | mods = append(mods, {{.Model.Name}}FilterToMods(filter)...) 84 | {{- if .IsListBackward }} 85 | connection, err := {{.Model.Name}}Connection(ctx, r.db, mods, boilergql.NewBackwardPagination(last, before), ordering) 86 | {{- else }} 87 | connection, err := {{.Model.Name}}Connection(ctx, r.db, mods, boilergql.NewForwardPagination(first, after), ordering) 88 | {{- end }} 89 | if err != nil { 90 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 91 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 92 | } 93 | return connection, nil 94 | {{- end -}} 95 | 96 | {{- if .IsCreate }} 97 | {{- /* ID type conversion: find ID field type in BoilerModel.Fields */ -}} 98 | {{- $idExpr := "m.ID" -}} 99 | {{- if .Model.BoilerModel -}} 100 | {{- range $field := .Model.BoilerModel.Fields -}} 101 | {{- if eq $field.Name "ID" -}} 102 | {{- if and (ne $field.Type "uint") (ne $field.Type "string") -}} 103 | {{- $idExpr = "uint(m.ID)" -}} 104 | {{- end -}} 105 | {{- end -}} 106 | {{- end -}} 107 | {{- end -}} 108 | 109 | m := {{ .InputModel.Name }}ToBoiler(ctx, r.db, &input) 110 | 111 | {{ if gt (len $.AuthorizationScopes) 0 -}} 112 | // Validate foreign keys belong to user's scope 113 | if err := Validate{{ .InputModel.Name }}ForeignKeys(ctx, r.db, &input); err != nil { 114 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 115 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 116 | } 117 | {{- end }} 118 | 119 | {{ $model := .Model -}} 120 | {{ range $field := .InputModel.Fields -}} 121 | {{ if and $field.IsObject $field.BoilerField.IsRelation -}} 122 | if input.{{ $field.Name }} != nil { 123 | {{ $field.JSONName }} := {{ $field.BoilerField.Relationship.Name }}CreateInputToBoiler(ctx, r.db, input.{{ $field.Name }}) 124 | {{ range $scope := $.AuthorizationScopes -}} 125 | {{- if (call $scope.AddHook $field.BoilerField.Relationship $resolver "createRelationInput") }} 126 | {{ $field.JSONName }}.{{ $scope.BoilerColumnName }} = {{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx) 127 | {{- end }} 128 | {{- end }} 129 | 130 | // TODO: create the nested relations of {{ $field.Name }}Input if they exist 131 | if err := {{ $field.JSONName }}.Insert(ctx, r.db, boil.Infer()); err != nil { 132 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 133 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 134 | } 135 | m.{{ $field.Name }}ID = {{ $field.JSONName }}.ID 136 | } 137 | 138 | {{ end -}} 139 | {{ end -}} 140 | 141 | {{ range $scope := $.AuthorizationScopes -}} 142 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "createInput") }} 143 | m.{{$scope.BoilerColumnName}} = {{$scope.ImportAlias}}.{{$scope.ScopeResolverName}}(ctx) 144 | {{- end }} 145 | {{- end }} 146 | 147 | if err := m.Insert(ctx, r.db, boil.Infer()); err != nil { 148 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 149 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 150 | } 151 | 152 | // resolve requested fields after creating 153 | pM, err := Fetch{{ .Model.Name }}(ctx, r.db, {{ .Model.Name }}IDToGraphQL({{ $idExpr }}), {{ .Model.Name }}PayloadPreloadLevels.{{ .Model.JSONName }}) 154 | if err != nil { 155 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 156 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 157 | } 158 | return &fm.{{ .Model.Name }}Payload{ 159 | {{ .Model.JSONName }}: {{ .Model.Name }}ToGraphQL(ctx, r.db, pM), 160 | }, nil 161 | 162 | {{- end -}} 163 | 164 | {{- if .IsUpdate }} 165 | m := {{ .InputModel.Name }}ToModelM(ctx, r.db, boilergql.GetInputFromContext(ctx, inputKey), input) 166 | 167 | {{ if gt (len $.AuthorizationScopes) 0 -}} 168 | // Validate foreign keys belong to user's scope 169 | if err := Validate{{ .InputModel.Name }}ForeignKeys(ctx, r.db, &input); err != nil { 170 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 171 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 172 | } 173 | {{- end }} 174 | 175 | {{ $resolver := . -}} 176 | {{ $model := .Model -}} 177 | {{ range $field := .InputModel.Fields -}} 178 | {{ if and $field.IsObject $field.BoilerField.IsRelation -}} 179 | if input.{{ $field.Name }} != nil && input.{{ $field.Name }}ID != nil { 180 | dbID := {{ $field.BoilerField.Relationship.Name }}ID(*input.{{ $field.Name }}ID) 181 | nestedM := {{ $field.BoilerField.Relationship.Name }}UpdateInputToModelM( 182 | ctx, 183 | r.db, 184 | boilergql.GetInputFromContext(ctx, "input.{{ $field.JSONName }}"), 185 | *input.{{ $field.Name }}, 186 | ) 187 | if _, err := dm.{{ $field.BoilerField.Relationship.PluralName }}( 188 | dm.{{ $field.BoilerField.Relationship.Name }}Where.ID.EQ(dbID), 189 | {{ range $scope := $.AuthorizationScopes -}} 190 | {{- if (call $scope.AddHook $field.BoilerField.Relationship $resolver "updateRelationWhere") }} 191 | dm.{{ $field.BoilerField.Relationship.Name }}Where.{{ $scope.BoilerColumnName }}.EQ( 192 | {{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx), 193 | ), 194 | {{- end }} 195 | {{- end }} 196 | ).UpdateAll(ctx, r.db, nestedM); err != nil { 197 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 198 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 199 | } 200 | } 201 | 202 | {{ end -}} 203 | {{ end -}} 204 | 205 | dbID := {{ .Model.Name }}ID(id) 206 | if _, err := dm.{{ .Model.PluralName }}( 207 | dm.{{ .Model.Name }}Where.ID.EQ(dbID), 208 | {{ range $scope := $.AuthorizationScopes -}} 209 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "updateWhere") }} 210 | dm.{{ $resolver.Model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx)), 211 | {{- end }} 212 | {{- end }} 213 | ).UpdateAll(ctx, r.db, m); err != nil { 214 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 215 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 216 | } 217 | 218 | // resolve requested fields after updating 219 | pM, err := Fetch{{ .Model.Name }}(ctx, r.db, id, {{ .Model.Name }}PayloadPreloadLevels.{{ .Model.JSONName }}) 220 | if err != nil { 221 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 222 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 223 | } 224 | return &fm.{{ .Model.Name }}Payload{ 225 | {{ .Model.JSONName }}: {{ .Model.Name }}ToGraphQL(ctx, r.db, pM), 226 | }, nil 227 | 228 | {{- end -}} 229 | 230 | {{- if .IsDelete }} 231 | dbID := {{ .Model.Name }}ID(id) 232 | mods := []qm.QueryMod{ 233 | dm.{{ .Model.Name }}Where.ID.EQ(dbID), 234 | {{ range $scope := $.AuthorizationScopes -}} 235 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "deleteWhere") }} 236 | dm.{{ $resolver.Model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ( 237 | {{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx), 238 | ), 239 | {{- end }} 240 | {{- end }} 241 | } 242 | if _, err := dm.{{ .Model.PluralName }}(mods...).DeleteAll(ctx, r.db{{$resolver.SoftDeleteSuffix}}); err != nil { 243 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 244 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 245 | } 246 | 247 | return &fm.{{ .Model.Name }}DeletePayload{ 248 | ID: id, 249 | }, nil 250 | 251 | {{- end -}} 252 | 253 | {{- if .IsBatchCreate }} 254 | // TODO: Implement batch create 255 | return nil, nil 256 | 257 | {{- end -}} 258 | 259 | {{- if .IsBatchUpdate }} 260 | var mods []qm.QueryMod 261 | {{ range $scope := $.AuthorizationScopes -}} 262 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "batchUpdateWhere") }} 263 | mods = append(mods, dm.{{ $resolver.Model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx))) 264 | {{- end }} 265 | {{- end }} 266 | mods = append(mods, {{.Model.Name}}FilterToMods(filter)...) 267 | 268 | m := {{ .InputModel.Name }}ToModelM(ctx, r.db, boilergql.GetInputFromContext(ctx, inputKey), input) 269 | if _, err := dm.{{ .Model.PluralName }}(mods...).UpdateAll(ctx, r.db, m); err != nil { 270 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 271 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 272 | } 273 | 274 | return &fm.{{ .Model.PluralName }}UpdatePayload{ 275 | Ok: true, 276 | }, nil 277 | {{- end -}} 278 | 279 | {{- if .IsBatchDelete }} 280 | var mods []qm.QueryMod 281 | {{ range $scope := $.AuthorizationScopes -}} 282 | {{- if (call $scope.AddHook $resolver.Model.BoilerModel $resolver "batchDeleteWhere") }} 283 | mods = append(mods, dm.{{ $resolver.Model.Name }}Where.{{ $scope.BoilerColumnName }}.EQ({{ $scope.ImportAlias }}.{{ $scope.ScopeResolverName }}(ctx))) 284 | {{- end }} 285 | {{- end }} 286 | mods = append(mods, {{.Model.Name}}FilterToMods(filter)...) 287 | mods = append(mods, qm.Select(dm.{{ .Model.Name }}Columns.ID)) 288 | mods = append(mods, qm.From(dm.{{- .Model.TableNameResolverName }}.{{ .Model.BoilerModel.TableName }})) 289 | 290 | {{- if .Model.HasPrimaryStringID }} 291 | var IDsToRemove []boilergql.RemovedStringID 292 | {{- else }} 293 | var IDsToRemove []boilergql.RemovedID 294 | {{- end }} 295 | if err := dm.{{ .Model.PluralName }}(mods...).Bind(ctx, r.db, &IDsToRemove); err != nil { 296 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 297 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 298 | } 299 | 300 | boilerIDs := boilergql.RemovedIDsToBoiler{{.Model.PrimaryKeyType|go}}(IDsToRemove) 301 | if _, err := dm.{{ .Model.PluralName }}(dm.{{ .Model.Name }}Where.ID.IN(boilerIDs)).DeleteAll(ctx, r.db{{$resolver.SoftDeleteSuffix}}); err != nil { 302 | log.Error().Err(err).Msg({{ $resolver.PublicErrorKey }}) 303 | return nil, errors.New({{ $resolver.PublicErrorKey }}) 304 | } 305 | 306 | return &fm.{{ .Model.PluralName }}DeletePayload{ 307 | Ids: boilergql.{{.Model.PrimaryKeyType|go}}IDsToGraphQL(boilerIDs, dm.{{- .Model.TableNameResolverName }}.{{ .Model.BoilerModel.TableName }}), 308 | }, nil 309 | {{- end }} 310 | } 311 | {{ end -}} 312 | 313 | {{ end }} 314 | 315 | func (r *queryResolver) Node(ctx context.Context, globalGraphID string) (fm.Node, error) { 316 | splitID := strings.SplitN(globalGraphID, "-", 1) 317 | if len(splitID) != 2 { 318 | return nil, errors.New("could not parse id") 319 | } 320 | 321 | model := splitID[0] 322 | switch model { 323 | {{ range $model := .Models -}} 324 | {{ if .IsNormal -}} 325 | case "{{$model.Name}}": 326 | return r.{{$model.JSONName}}(ctx, globalGraphID) 327 | {{ end -}} 328 | {{ end -}} 329 | 330 | default: 331 | return nil, errors.New("could not find corresponding model for id") 332 | } 333 | } 334 | 335 | 336 | {{ range $object := .Objects -}} 337 | func (r *{{$.ResolverType}}) {{$object.Name}}() gm.{{ $object.Name }}Resolver { return &{{lcFirst $object.Name}}{{ucFirst $.ResolverType}}{r} } 338 | {{ end }} 339 | 340 | {{ range $object := .Objects -}} 341 | type {{lcFirst $object.Name}}{{ucFirst $.ResolverType}} struct { *{{$.ResolverType}} } 342 | {{ end }} 343 | 344 | 345 | {{ if (ne .RemainingSource "") }} 346 | // !!! WARNING !!! 347 | // The code below was going to be deleted when updating resolvers. It has been copied here so you have 348 | // one last chance to move it out of harms way if you want. There are two reasons this happens: 349 | // - When renaming or deleting a resolver the old code will be put in here. You can safely delete 350 | // it when you're done. 351 | // - You have helper methods in this file. Move them out to keep these resolver files clean. 352 | {{ .RemainingSource }} 353 | {{ end }} 354 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # gqlgen-sqlboiler 2 | 3 | We want developers to be able to build software faster using modern tools like GraphQL, Golang, React Native without depending on commercial providers like Firebase or AWS Amplify. 4 | 5 | Our plugins generate type-safe code between gqlgen and sqlboiler models with support for unique id's across your whole database. We can automatically generate the implementation of queries and mutations like create, update, delete based on your graphql scheme and your sqlboiler models. 6 | 7 | Tight coupling between your database and graphql scheme is required otherwise generation will be skipped. The advantage of this program is the most when you have a database already designed. You can write extra GrapQL resolvers, and override the generated functions so you can iterate fast. 8 | 9 | ## Why gqlgen and sqlboiler 10 | 11 | They go back to a schema first approach which we like. The generated code with these tools are the most efficient and fast in the Golang system (and probably outside of it too). 12 | * [sqlboiler](https://github.com/aarondl/sqlboiler#benchmarks) 13 | * [gqlgen](https://github.com/appleboy/golang-graphql-benchmark#summary) 14 | 15 | It's really amazing how fast a generated api with these techniques is! 16 | 17 | ## Usage 18 | 19 | ### Step 1 20 | Create folder convert/convert.go with the following content: 21 | See [example of `convert.go`](https://github.com/web-ridge/gqlgen-sqlboiler#convert.go) 22 | 23 | ### Step 2 24 | run `go mod tidy` in `convert/` 25 | 26 | ### Step 3 27 | Make sure you have [followed the prerequisites](https://github.com/web-ridge/gqlgen-sqlboiler#prerequisites) 28 | 29 | ### Step 4 30 | ```sh 31 | (cd convert && go run convert.go) 32 | ``` 33 | 34 | 35 | 36 | 37 | 38 | ## Features 39 | 40 | - [x] schema.graphql based on sqlboiler structs 41 | - [x] converts between sqlboiler and gqlgen 42 | - [x] connections / edges / filtering / ordering / sorting 43 | - [x] three-way-merge schema re-generate 44 | - [x] converts between input models and sqlboiler 45 | - [x] understands the difference between empty and null in update input 46 | - [x] sqlboiler preloads from graphql context 47 | - [x] foreign keys and relations 48 | - [x] resolvers based on queries/mutations in schema 49 | - [x] one-to-one relationships inside input types. 50 | - [x] batch update/delete generation in resolvers. 51 | - [x] enum support (only in graphql schema right now). 52 | - [x] public errors in resolvers + logging via zerolog. 53 | - [x] [overriding convert functions](https://github.com/web-ridge/gqlgen-sqlboiler#overriding-converts) 54 | - [x] [custom scope resolvers](https://github.com/web-ridge/gqlgen-sqlboiler-examples/blob/main/social-network/convert_plugin.go#L66) e.g userId, organizationId 55 | - [x] Support gqlgen multiple .graphql files 56 | - [x] Batch create helpers for sqlboiler and integration batch create inputs 57 | - [x] Support overriding resolvers 58 | ### Relay 59 | - [x] [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) 60 | - [x] [Global Object Identification](https://graphql.org/learn/global-object-identification/) 61 | ### Roadmap 62 | - [ ] Adding automatic database migrations and integration with [web-ridge/dbifier](https://github.com/web-ridge/dbifier) 63 | - [ ] Crud / configurable crud modes of adding/removing relationships from one-to-many and many-to-many on edges or for a model all at once. 64 | - [ ] Support more relationships inside input types 65 | 66 | 67 | ## Examples 68 | Checkout our examples to see the generated schema.grapql, converts and resolvers. 69 | [web-ridge/gqlgen-sqlboiler-examples](https://github.com/web-ridge/gqlgen-sqlboiler-examples) 70 | 71 | ### Output example 72 | ```go 73 | func PostToGraphQL(m *models.Post) *graphql_models.Post { 74 | if m == nil { 75 | return nil 76 | } 77 | r := &graphql_models.Post{ 78 | ID: PostIDToGraphQL(m.ID), 79 | Content: m.Content, 80 | } 81 | if boilergql.UintIsFilled(m.UserID) { 82 | if m.R != nil && m.R.User != nil { 83 | r.User = UserToGraphQL(m.R.User) 84 | } else { 85 | r.User = UserWithUintID(m.UserID) 86 | } 87 | } 88 | if m.R != nil && m.R.Comments != nil { 89 | r.Comments = CommentsToGraphQL(m.R.Comments) 90 | } 91 | if m.R != nil && m.R.Images != nil { 92 | r.Images = ImagesToGraphQL(m.R.Images) 93 | } 94 | if m.R != nil && m.R.Likes != nil { 95 | r.Likes = LikesToGraphQL(m.R.Likes) 96 | } 97 | return r 98 | } 99 | ``` 100 | 101 | 102 | ## Prerequisites 103 | 104 | ### sqlboiler.yml 105 | 106 | ```yaml 107 | mysql: 108 | dbname: dbname 109 | host: localhost 110 | port: 8889 111 | user: root 112 | pass: root 113 | sslmode: "false" 114 | blacklist: 115 | - notifications 116 | - jobs 117 | - password_resets 118 | - migrations 119 | mysqldump: 120 | column-statistics: 0 121 | ``` 122 | 123 | ### gqlgen.yml 124 | 125 | ```yaml 126 | schema: 127 | - *.graphql 128 | exec: 129 | filename: models/fm/generated.go 130 | package: fm 131 | model: 132 | filename: models/fm/generated_models.go 133 | package: fm 134 | models: 135 | ConnectionBackwardPagination: 136 | model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionBackwardPagination 137 | ConnectionForwardPagination: 138 | model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionForwardPagination 139 | ConnectionPagination: 140 | model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionPagination 141 | SortDirection: 142 | model: github.com/web-ridge/utils-go/boilergql/v3.SortDirection 143 | ``` 144 | 145 | ### resolver/resolver.go 146 | ```go 147 | 148 | package resolvers 149 | 150 | import ( 151 | "database/sql" 152 | ) 153 | 154 | type Resolver struct { 155 | db *sql.DB 156 | // you can add more here 157 | } 158 | 159 | func NewResolver(db *sql.DB) *Resolver { 160 | return &Resolver{ 161 | db: db, 162 | // you can add more here 163 | } 164 | } 165 | 166 | ``` 167 | 168 | ### convert.go 169 | Put something like the code below in file convert/convert.go 170 | 171 | ```go 172 | package main 173 | 174 | import ( 175 | "github.com/99designs/gqlgen/codegen/config" 176 | "github.com/rs/zerolog/log" 177 | gbgen "github.com/web-ridge/gqlgen-sqlboiler/v3" 178 | "github.com/web-ridge/gqlgen-sqlboiler/v3/cache" 179 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 180 | "os" 181 | "os/exec" 182 | "strings" 183 | ) 184 | 185 | func main() { 186 | // change working directory to parent directory where all configs are located 187 | newDir, _ := os.Getwd() 188 | os.Chdir(strings.TrimSuffix(newDir, "/convert")) 189 | 190 | enableSoftDeletes := true 191 | boilerArgs := []string{"mysql", "--no-back-referencing", "--wipe", "-d"} 192 | if enableSoftDeletes { 193 | boilerArgs = append(boilerArgs, "--add-soft-deletes") 194 | } 195 | cmd := exec.Command("sqlboiler", boilerArgs...) 196 | 197 | err := cmd.Run() 198 | if err != nil { 199 | log.Fatal().Err(err).Str("command", cmd.String()).Msg("error generating dm models running sql-boiler") 200 | } 201 | 202 | output := structs.Config{ 203 | Directory: "helpers", // supports root or sub directories 204 | PackageName: "helpers", 205 | } 206 | backend := structs.Config{ 207 | Directory: "models/dm", 208 | PackageName: "dm", 209 | } 210 | frontend := structs.Config{ 211 | Directory: "models/fm", 212 | PackageName: "fm", 213 | } 214 | 215 | boilerCache := cache.InitializeBoilerCache(backend) 216 | 217 | generateSchema := true 218 | generatedSchema := !generateSchema 219 | if generateSchema { 220 | if err := gbgen.SchemaWrite( 221 | gbgen.SchemaConfig{ 222 | BoilerCache: boilerCache, 223 | Directives: []string{"isAuthenticated"}, 224 | SkipInputFields: []string{"createdAt", "updatedAt", "deletedAt"}, 225 | GenerateMutations: true, 226 | GenerateBatchCreate: false, 227 | GenerateBatchDelete: false, 228 | GenerateBatchUpdate: false, 229 | HookShouldAddModel: func(model gbgen.SchemaModel) bool { 230 | if model.Name == "Config" { 231 | return false 232 | } 233 | return true 234 | }, 235 | HookChangeFields: func(model *gbgen.SchemaModel, fields []*gbgen.SchemaField, parenType gbgen.ParentType) []*gbgen.SchemaField { 236 | //profile: UserPayload! @isAuthenticated 237 | 238 | return fields 239 | }, 240 | HookChangeField: func(model *gbgen.SchemaModel, field *gbgen.SchemaField) { 241 | //"userId", "userOrganizationId", 242 | if field.Name == "userId" && model.Name != "UserUserOrganization" { 243 | field.SkipInput = true 244 | } 245 | if field.Name == "userOrganizationId" && model.Name != "UserUserOrganization" { 246 | field.SkipInput = true 247 | } 248 | }, 249 | }, 250 | "../frontend/schema.graphql", 251 | gbgen.SchemaGenerateConfig{ 252 | MergeSchema: false, 253 | }, 254 | ); err != nil { 255 | log.Fatal().Err(err).Msg("error generating schema") 256 | } 257 | generatedSchema = true 258 | } 259 | if generatedSchema { 260 | 261 | cfg, err := config.LoadConfigFromDefaultLocations() 262 | if err != nil { 263 | log.Fatal().Err(err).Msg("error loading config") 264 | } 265 | 266 | data, err := gbgen.NewModelPlugin().GenerateCode(cfg) 267 | if err != nil { 268 | log.Fatal().Err(err).Msg("error generating graphql models using gqlgen") 269 | } 270 | 271 | modelCache := cache.InitializeModelCache(cfg, boilerCache, output, backend, frontend) 272 | 273 | if err := gbgen.NewConvertPlugin( 274 | modelCache, 275 | gbgen.ConvertPluginConfig{ 276 | DatabaseDriver: gbgen.MySQL, 277 | //Searchable: { 278 | // Company: { 279 | // Column: dm.CompanyColumns.Name 280 | // }, 281 | //}, 282 | }, 283 | ).GenerateCode(); err != nil { 284 | log.Fatal().Err(err).Msg("error while generating convert/filters") 285 | } 286 | 287 | if err := gbgen.NewResolverPlugin( 288 | config.ResolverConfig{ 289 | Filename: "resolvers/all_generated_resolvers.go", 290 | Package: "resolvers", 291 | Type: "Resolver", 292 | }, 293 | output, 294 | boilerCache, 295 | modelCache, 296 | gbgen.ResolverPluginConfig{ 297 | 298 | EnableSoftDeletes: enableSoftDeletes, 299 | // Authorization scopes can be used to override e.g. userId, organizationId, tenantId 300 | // This will be resolved used the provided ScopeResolverName if the result of the AddTrigger=true 301 | // You would need this if you don't want to require these fields in your schema but you want to add them 302 | // to the db model. 303 | // If you do have these fields in your schema but want them authorized you could use a gqlgen directive 304 | AuthorizationScopes: []*gbgen.AuthorizationScope{}, 305 | // { 306 | // ImportPath: "github.com/my-repo/app/backend/auth", 307 | // ImportAlias: "auth", 308 | // ScopeResolverName: "UserIDFromContext", // function which is called with the context of the resolver 309 | // BoilerColumnName: "UserID", 310 | // AddHook: func(model *gbgen.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool { 311 | // // fmt.Println(model.Name) 312 | // // fmt.Println(templateKey) 313 | // // templateKey contains a unique where the resolver tries to add something 314 | // // e.g. 315 | // // most of the time you can ignore this 316 | 317 | // // we want the delete call to work for every object we don't want to take in account te user-id here 318 | // if resolver.IsDelete { 319 | // return false 320 | // } 321 | 322 | // var addResolver bool 323 | // for _, field := range model.Fields { 324 | // if field.Name == "UserID" { 325 | // addResolver = true 326 | // } 327 | // } 328 | // return addResolver 329 | // }, 330 | // }, 331 | // { 332 | // ImportPath: "github.com/my-repo/app/backend/auth", 333 | // ImportAlias: "auth", 334 | // ScopeResolverName: "UserOrganizationIDFromContext", // function which is called with the context of the resolver 335 | // BoilerColumnName: "UserOrganizationID", 336 | 337 | // AddHook: func(model *gbgen.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool { 338 | // // fmt.Println(model.Name) 339 | // // fmt.Println(templateKey) 340 | // // templateKey contains a unique where the resolver tries to add something 341 | // // e.g. 342 | // // most of the time you can ignore this 343 | // var addResolver bool 344 | // for _, field := range model.Fields { 345 | // if field.Name == "UserOrganizationID" { 346 | // addResolver = true 347 | // } 348 | // } 349 | // return addResolver 350 | // }, 351 | // }, 352 | // }, 353 | }, 354 | ).GenerateCode(data); err != nil { 355 | log.Fatal().Err(err).Msg("error while generating resolvers") 356 | } 357 | 358 | } 359 | } 360 | ``` 361 | 362 | ## Foreign Key Validation 363 | 364 | When using authorization scopes, you can enable FK validation to ensure users can only set foreign keys to resources within their scope. This prevents users from associating records with resources they don't have access to. 365 | 366 | Add `"validateForeignKey"` handling to your `AddHook` function and pass auth scopes to the ConvertPlugin: 367 | 368 | ```go 369 | authScopes := []*gbgen.AuthorizationScope{ 370 | { 371 | ImportPath: "github.com/my-repo/app/backend/auth", 372 | ImportAlias: "auth", 373 | ScopeResolverName: "OrganizationIDFromContext", 374 | BoilerColumnName: "OrganizationID", 375 | AddHook: func(model *structs.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool { 376 | // Enable FK validation for models with OrganizationID 377 | if templateKey == "validateForeignKey" { 378 | for _, field := range model.Fields { 379 | if field.Name == "OrganizationID" { 380 | return true 381 | } 382 | } 383 | return false 384 | } 385 | // ... existing authorization logic for other templateKeys 386 | for _, field := range model.Fields { 387 | if field.Name == "OrganizationID" { 388 | return true 389 | } 390 | } 391 | return false 392 | }, 393 | }, 394 | } 395 | 396 | // Pass auth scopes to convert plugin for FK validation 397 | if err := gbgen.NewConvertPlugin( 398 | modelCache, 399 | gbgen.ConvertPluginConfig{DatabaseDriver: gbgen.MySQL}, 400 | ).GenerateCode(authScopes); err != nil { 401 | log.Fatal().Err(err).Msg("error while generating convert/filters") 402 | } 403 | 404 | // Pass same auth scopes to resolver plugin 405 | if err := gbgen.NewResolverPlugin( 406 | config.ResolverConfig{...}, 407 | output, boilerCache, modelCache, 408 | gbgen.ResolverPluginConfig{ 409 | AuthorizationScopes: authScopes, 410 | }, 411 | ).GenerateCode(data); err != nil { 412 | log.Fatal().Err(err).Msg("error while generating resolvers") 413 | } 414 | ``` 415 | 416 | This generates validation functions like: 417 | 418 | ```go 419 | func ValidatePostCreateInputForeignKeys(ctx context.Context, db boil.ContextExecutor, m *fm.PostCreateInput) error { 420 | if m.AuthorID != nil { 421 | exists, err := dm.Users( 422 | dm.UserWhere.ID.EQ(UserID(*m.AuthorID)), 423 | dm.UserWhere.OrganizationID.EQ(auth.OrganizationIDFromContext(ctx)), 424 | ).Exists(ctx, db) 425 | if err != nil { 426 | return fmt.Errorf("authorId: %w", err) 427 | } 428 | if !exists { 429 | return fmt.Errorf("authorId: referenced User not found or access denied") 430 | } 431 | } 432 | return nil 433 | } 434 | ``` 435 | 436 | ## Overriding converts 437 | Put a file in your helpers/ directory e.g. convert_override_user.go 438 | ```golang 439 | package helpers 440 | 441 | import ( 442 | "github.com/../app/backend/graphql_models" 443 | "github.com/../app/backend/models" 444 | ) 445 | 446 | // use same name as in one of the generated files to override 447 | func UserCreateInputToBoiler( 448 | m *graphql_models.UserCreateInput, 449 | ) *models.User { 450 | if m == nil { 451 | return nil 452 | } 453 | 454 | originalConvert := originalUserCreateInputToBoiler(m) 455 | // e.g. bcrypt password 456 | return originalConvert 457 | } 458 | ``` 459 | 460 | If you re-generate the original convert will get changed to originalUserCreateInputToBoiler which you can still use in your overridden convert. 461 | 462 | ## Reusable CRUD Helpers 463 | 464 | The generator creates reusable CRUD helper functions in `generated_crud.go` that can be called from custom resolvers: 465 | 466 | ```go 467 | // Fetch with preloads and authorization 468 | func FetchActivity(ctx context.Context, db boil.ContextExecutor, id string, preloadLevel string) (*dm.Activity, error) 469 | 470 | // Create with FK validation and authorization 471 | func CreateActivity(ctx context.Context, db boil.ContextExecutor, input fm.ActivityCreateInput, preloadLevel string) (*dm.Activity, error) 472 | 473 | // Update with FK validation and authorization 474 | func UpdateActivity(ctx context.Context, db boil.ContextExecutor, id string, input fm.ActivityUpdateInput, preloadLevel string) (*dm.Activity, error) 475 | 476 | // Delete with authorization (hard delete) 477 | func DeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) error 478 | 479 | // Soft delete (only generated if model has deleted_at) 480 | func SoftDeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) error 481 | ``` 482 | 483 | Use these in custom resolvers: 484 | 485 | ```go 486 | func (r *mutationResolver) CreateActivityWithNotification(ctx context.Context, input fm.ActivityCreateInput) (*fm.ActivityPayload, error) { 487 | // Use the CRUD helper 488 | m, err := CreateActivity(ctx, r.db, input, ActivityPayloadPreloadLevels.Activity) 489 | if err != nil { 490 | return nil, err 491 | } 492 | 493 | // Custom logic 494 | notifyUsers(ctx, m) 495 | 496 | return &fm.ActivityPayload{ 497 | Activity: ActivityToGraphQL(ctx, r.db, m), 498 | }, nil 499 | } 500 | ``` 501 | 502 | ## Help us 503 | 504 | We're the most happy with your time investments and/or pull request to improve this plugin. Feedback is also highly appreciated. 505 | -------------------------------------------------------------------------------- /cache/sqlboiler_parse.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "go/ast" 6 | "go/parser" 7 | "go/token" 8 | "io/ioutil" 9 | "path/filepath" 10 | "regexp" 11 | "sort" 12 | "strings" 13 | "unicode" 14 | 15 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 16 | 17 | "github.com/iancoleman/strcase" 18 | "github.com/rs/zerolog/log" 19 | ) 20 | 21 | // parseModelsAndFieldsFromBoiler since these are like User.ID, User.Organization and we want them grouped by 22 | // modelName and their belonging fields. 23 | func GetBoilerModels(dir string) ([]*structs.BoilerModel, []*structs.BoilerEnum) { //nolint:gocognit,gocyclo 24 | boilerTypeMap, _, boilerTypeOrder := parseBoilerFile(dir) 25 | boilerTypes := getSortedBoilerTypes(boilerTypeMap, boilerTypeOrder) 26 | tableNames := parseTableNames(dir) 27 | viewNames := parseViews(dir) 28 | allTableNames := append(tableNames, viewNames...) 29 | enums := parseEnums(dir, allTableNames) 30 | 31 | // sortedModelNames is needed to get the right order back of the structs since we want the same order every time 32 | // this program has ran. 33 | var modelNames []string 34 | 35 | // fieldsPerModelName is needed to group the fields per model, so we can get all fields per modelName later on 36 | fieldsPerModelName := map[string][]*structs.BoilerField{} 37 | relationsPerModelName := map[string][]*structs.BoilerField{} 38 | 39 | // Anonymous function because this is used 2 times it prevents duplicated code 40 | // It's automatically init an empty field array if it does not exist yet 41 | addFieldToMap := func(m map[string][]*structs.BoilerField, modelName string, field *structs.BoilerField) { 42 | modelNames = AppendIfMissing(modelNames, modelName) 43 | _, ok := m[modelName] 44 | if !ok { 45 | m[modelName] = []*structs.BoilerField{} 46 | } 47 | m[modelName] = append(m[modelName], field) 48 | } 49 | 50 | // Let's parse boilerTypes to structs and fields 51 | for _, boiler := range boilerTypes { 52 | // split on . input is like model.Field e.g. -> User.ID 53 | splitted := strings.Split(boiler.Name, ".") 54 | // result in e.g. User 55 | modelName := splitted[0] 56 | 57 | // result in e.g. ID 58 | boilerFieldName := splitted[1] 59 | somethingFcm := strings.Contains(strings.ToLower(modelName), "fcm") 60 | 61 | // handle names with lowercase e.g. userR, userL or other sqlboiler extra's 62 | if IsFirstCharacterLowerCase(modelName) { 63 | // It's the relations of the model 64 | // let's add them so we can use them later 65 | if strings.HasSuffix(modelName, "R") { 66 | modelNameBefore := strings.TrimSuffix(modelName, "R") 67 | modelName = strings.ToUpper(string(modelNameBefore[0])) + modelNameBefore[1:] 68 | isArray := strings.HasSuffix(boiler.Type, "Slice") 69 | boilerType := strings.TrimSuffix(boiler.Type, "Slice") 70 | 71 | if somethingFcm { 72 | fmt.Println("boilerType", boilerType) 73 | fmt.Println("boilerFieldName", boilerFieldName) 74 | } 75 | 76 | relationField := &structs.BoilerField{ 77 | Name: boilerFieldName, 78 | RelationshipName: strings.TrimSuffix(boilerFieldName, "ID"), 79 | PluralName: Plural(boilerFieldName), 80 | Type: boilerType, 81 | IsRelation: true, 82 | IsRequired: false, 83 | IsArray: isArray, 84 | InTable: false, 85 | InTableNotID: false, 86 | } 87 | addFieldToMap(relationsPerModelName, modelName, relationField) 88 | } 89 | 90 | // ignore the default handling since this field is already handled 91 | continue 92 | } 93 | 94 | // Ignore these since these are sqlboiler helper structs for preloading relationships 95 | if boilerFieldName == "L" || boilerFieldName == "R" { 96 | continue 97 | } 98 | isID := boilerFieldName == "ID" 99 | isRelation := strings.HasSuffix(boilerFieldName, "ID") && !isID 100 | 101 | addFieldToMap(fieldsPerModelName, modelName, &structs.BoilerField{ 102 | Name: boilerFieldName, 103 | PluralName: Plural(boilerFieldName), 104 | Type: boiler.Type, 105 | IsRelation: isRelation, 106 | IsRequired: isRequired(boiler.Type), 107 | RelationshipName: strings.TrimSuffix(boilerFieldName, "ID"), 108 | IsForeignKey: isRelation, 109 | InTable: true, 110 | InTableNotID: !isID, 111 | }) 112 | } 113 | sort.Strings(modelNames) 114 | 115 | // Let's generate the structs in the same order as the sqlboiler structs were parsed 116 | models := make([]*structs.BoilerModel, len(modelNames)) 117 | for i, modelName := range modelNames { 118 | fields := fieldsPerModelName[modelName] 119 | tableName := findTableName(tableNames, modelName) 120 | 121 | var hasPrimaryStringID bool 122 | IDField := findBoilerField(fields, "ID") 123 | if IDField != nil && IDField.Type == "string" { 124 | hasPrimaryStringID = true 125 | } 126 | 127 | var hasDeletedAt bool 128 | deletedAtField := findBoilerField(fields, "DeletedAt") 129 | if deletedAtField != nil { 130 | hasDeletedAt = true 131 | } 132 | 133 | models[i] = &structs.BoilerModel{ 134 | Name: modelName, 135 | TableName: tableName, 136 | PluralName: Plural(modelName), 137 | Fields: fields, 138 | Enums: filterEnumsByModelName(enums, modelName), 139 | HasPrimaryStringID: hasPrimaryStringID, 140 | HasDeletedAt: hasDeletedAt, 141 | IsView: SliceContains(viewNames, modelName), 142 | } 143 | } 144 | 145 | // let's fill relationship structs 146 | // We need to this after because we have pointers to relationships 147 | for _, model := range models { 148 | relationFields := relationsPerModelName[model.Name] 149 | for _, relationField := range relationFields { 150 | relationship := FindBoilerModel(models, relationField.Type) 151 | 152 | // try to find foreign key inside model 153 | foreignKey := findBoilerField(model.Fields, relationField.Name+"ID") 154 | if foreignKey != nil { 155 | foreignKey.Relationship = relationship 156 | } else { 157 | // fmt.Println("could not find foreignkey", foreignKey, model.Name, relationField.Name) 158 | // this is not a foreign key but a normal relationship 159 | relationField.Relationship = relationship 160 | model.Fields = append(model.Fields, relationField) 161 | } 162 | } 163 | } 164 | for _, model := range models { 165 | for _, field := range model.Fields { 166 | enumForField := getEnumByModelNameAndFieldName(enums, model.Name, field.Name) 167 | // fmt.Println("enumForField", field.Name, enumForField) 168 | if enumForField != nil { 169 | field.IsEnum = true 170 | field.IsRelation = false 171 | field.Enum = *enumForField 172 | } 173 | 174 | if field.IsRelation && field.Relationship == nil { 175 | //log.Debug().Str("model", model.Name).Str("field", field.Name).Msg( 176 | // "We could not find the relationship in the generated " + 177 | // "boiler structs this could result in unexpected behavior, we marked this field as " + 178 | // "non-relational \n") 179 | field.IsRelation = false 180 | } 181 | 182 | } 183 | } 184 | 185 | return models, enums 186 | } 187 | 188 | func getEnumByModelNameAndFieldName(enums []*structs.BoilerEnum, modelName string, fieldName string) *structs.BoilerEnum { 189 | for _, e := range enums { 190 | // fmt.Println(" ", e.ModelName, modelName) 191 | // fmt.Println(" ", e.ModelFieldKey, fieldName) 192 | if e.ModelName == modelName && e.ModelFieldKey == fieldName { 193 | return e 194 | } 195 | } 196 | return nil 197 | } 198 | 199 | func filterEnumsByModelName(enums []*structs.BoilerEnum, modelName string) []*structs.BoilerEnum { 200 | var a []*structs.BoilerEnum 201 | for _, e := range enums { 202 | if e.ModelName == modelName { 203 | a = append(a, e) 204 | } 205 | } 206 | return a 207 | } 208 | 209 | func findBoilerField(fields []*structs.BoilerField, fieldName string) *structs.BoilerField { 210 | for _, m := range fields { 211 | if m.Name == fieldName { 212 | return m 213 | } 214 | } 215 | return nil 216 | } 217 | 218 | func findTableName(tableNames []string, modelName string) string { 219 | for _, tableName := range tableNames { 220 | if modelName == tableName { 221 | return tableName 222 | } 223 | } 224 | 225 | // if database name is plural 226 | for _, tableName := range tableNames { 227 | if Plural(modelName) == tableName { 228 | return tableName 229 | } 230 | } 231 | return modelName 232 | } 233 | 234 | func isRequired(boilerType string) bool { 235 | if strings.HasPrefix(boilerType, "null.") || 236 | strings.HasPrefix(boilerType, "types.Null") || 237 | strings.HasPrefix(boilerType, "*") { 238 | return false 239 | } 240 | return true 241 | } 242 | 243 | // getSortedBoilerTypes orders the sqlboiler struct in an ordered slice of BoilerType 244 | func getSortedBoilerTypes(boilerTypeMap map[string]string, boilerTypeOrder map[string]int) ( 245 | sortedBoilerTypes []*structs.BoilerType) { 246 | boilerTypeKeys := make([]string, 0, len(boilerTypeMap)) 247 | for k := range boilerTypeMap { 248 | boilerTypeKeys = append(boilerTypeKeys, k) 249 | } 250 | 251 | // order same way as sqlboiler fields with one exception 252 | // let createdAt, updatedAt and deletedAt as last 253 | sort.Slice(boilerTypeKeys, func(i, b int) bool { 254 | aKey := boilerTypeKeys[i] 255 | bKey := boilerTypeKeys[b] 256 | 257 | aOrder := boilerTypeOrder[aKey] 258 | bOrder := boilerTypeOrder[bKey] 259 | 260 | higherOrders := []string{"createdat", "updatedat", "deletedat"} 261 | for i, higherOrder := range higherOrders { 262 | if strings.HasSuffix(strings.ToLower(aKey), higherOrder) { 263 | aOrder += 100 + 100*i 264 | } 265 | if strings.HasSuffix(strings.ToLower(bKey), higherOrder) { 266 | bOrder += 100 + 100*i 267 | } 268 | } 269 | 270 | return aOrder < bOrder 271 | }) 272 | 273 | for _, modelAndField := range boilerTypeKeys { 274 | // fmt.Println(modelAndField) 275 | sortedBoilerTypes = append(sortedBoilerTypes, &structs.BoilerType{ 276 | Name: modelAndField, 277 | Type: boilerTypeMap[modelAndField], 278 | }) 279 | } 280 | return //nolint:nakedret 281 | } 282 | 283 | var tableNameRegex = regexp.MustCompile(`\s*(.*[^ ])\s*string`) //nolint:gochecknoglobals 284 | 285 | func parseTableNames(dir string) []string { 286 | dir, err := filepath.Abs(dir) 287 | errMessage := "could not open boiler table names file, this could not lead to problems if you're " + 288 | "using plural table names" 289 | if err != nil { 290 | log.Warn().Err(err).Msg(errMessage) 291 | return nil 292 | } 293 | content, err := ioutil.ReadFile(filepath.Join(dir, "boil_table_names.go")) 294 | if err != nil { 295 | log.Warn().Err(err).Msg(errMessage) 296 | return nil 297 | } 298 | tableNamesMatches := tableNameRegex.FindAllStringSubmatch(string(content), -1) 299 | tableNames := make([]string, len(tableNamesMatches)) 300 | for i, tableNameMatch := range tableNamesMatches { 301 | tableNames[i] = tableNameMatch[1] 302 | } 303 | return tableNames 304 | } 305 | 306 | func parseViews(dir string) []string { 307 | dir, err := filepath.Abs(dir) 308 | errMessage := "could not open boiler table names file, this could not lead to problems if you're " + 309 | "using plural table names" 310 | if err != nil { 311 | log.Warn().Err(err).Msg(errMessage) 312 | return nil 313 | } 314 | content, err := ioutil.ReadFile(filepath.Join(dir, "boil_view_names.go")) 315 | if err != nil { 316 | log.Warn().Err(err).Msg(errMessage) 317 | return nil 318 | } 319 | viewNamesMatches := tableNameRegex.FindAllStringSubmatch(string(content), -1) 320 | viewNames := make([]string, len(viewNamesMatches)) 321 | for i, tableNameMatch := range viewNamesMatches { 322 | viewNames[i] = tableNameMatch[1] 323 | } 324 | return viewNames 325 | } 326 | 327 | var ( 328 | enumRegex = regexp.MustCompile(`// Enum values for (.*)\nconst\s\(\n(:?(.|\n)*?)\n\)`) //nolint:gochecknoglobals 329 | enumValuesRegex = regexp.MustCompile(`\s(\w+)\s*string\s*=\s*"(\w+)"`) //nolint:gochecknoglobals 330 | ) 331 | 332 | func parseEnums(dir string, allTableNames []string) []*structs.BoilerEnum { 333 | dir, err := filepath.Abs(dir) 334 | errMessage := "could not open enum names file, this could not lead to problems if you're " + 335 | "using enums in your db" 336 | if err != nil { 337 | log.Warn().Err(err).Msg(errMessage) 338 | return nil 339 | } 340 | content, err := ioutil.ReadFile(filepath.Join(dir, "boil_types.go")) 341 | if err != nil { 342 | log.Warn().Err(err).Msg(errMessage) 343 | return nil 344 | } 345 | matches := enumRegex.FindAllStringSubmatch(string(content), -1) 346 | a := make([]*structs.BoilerEnum, len(matches)) 347 | for i, match := range matches { 348 | // 1: messageLetterStatus 349 | // 2: status 350 | // 3: contents 351 | modelName, fieldKey := stripLastWord(match[1], allTableNames) 352 | name := strcase.ToCamel(match[1]) 353 | 354 | a[i] = &structs.BoilerEnum{ 355 | Name: name, 356 | ModelName: strcase.ToCamel(modelName), 357 | ModelFieldKey: strcase.ToCamel(fieldKey), 358 | Values: parseEnumValues(match[2]), 359 | } 360 | } 361 | return a 362 | } 363 | 364 | func stripLastWord(v string, allTableNames []string) (string, string) { 365 | // longest tables first 366 | sort.Slice(allTableNames, func(i, j int) bool { 367 | return len(allTableNames[i]) > len(allTableNames[j]) 368 | }) 369 | 370 | for _, tableName := range allTableNames { 371 | if strings.HasPrefix(v, tableName) { 372 | return tableName, strings.TrimPrefix(v, tableName) 373 | } 374 | } 375 | log.Warn().Str("enumName", v).Msg("could not find model by enum") 376 | return "", "" 377 | } 378 | 379 | func isUpperRune(s rune) bool { 380 | if !unicode.IsUpper(s) && unicode.IsLetter(s) { 381 | return false 382 | } 383 | return true 384 | } 385 | 386 | func parseEnumValues(content string) []*structs.BoilerEnumValue { 387 | matches := enumValuesRegex.FindAllStringSubmatch(content, -1) 388 | a := make([]*structs.BoilerEnumValue, len(matches)) 389 | for i, match := range matches { 390 | // 1: message_letter 391 | // 2: status 392 | // 2: status 393 | // 3: contents 394 | a[i] = &structs.BoilerEnumValue{ 395 | Name: match[1], 396 | } 397 | } 398 | return a 399 | } 400 | 401 | // will return StructName.key 402 | // e.g. 403 | // Address.ID: null.Integer 404 | // Address.Longitude: null.String 405 | // Address.Latitude : null.Decimal 406 | // needed to generate the right convert code 407 | func parseBoilerFile(dir string) (map[string]string, map[string]string, map[string]int) { //nolint:gocognit,gocyclo 408 | fieldsMap := make(map[string]string) 409 | structsMap := make(map[string]string) 410 | fieldsOrder := make(map[string]int) 411 | 412 | dir, err := filepath.Abs(dir) 413 | if err != nil { 414 | log.Err(err).Msg("parseBoilerFile filepath.Abs error") 415 | return fieldsMap, structsMap, fieldsOrder 416 | } 417 | files, err := ioutil.ReadDir(dir) 418 | if err != nil { 419 | log.Err(err).Msg("parseBoilerFile ioutil.ReadDir error") 420 | return fieldsMap, structsMap, fieldsOrder 421 | } 422 | 423 | fset := token.NewFileSet() 424 | for _, file := range files { 425 | // only pick .go files and ignore test files 426 | if !strings.HasSuffix(strings.ToLower(file.Name()), ".go") || 427 | strings.HasSuffix(strings.ToLower(file.Name()), "_test.go") { 428 | continue 429 | } 430 | 431 | filename := filepath.Join(dir, file.Name()) 432 | if src, err := parser.ParseFile(fset, filename, nil, parser.ParseComments); err == nil { //nolint:nestif 433 | var i int 434 | for _, decl := range src.Decls { 435 | // TODO: make cleaner 436 | typeDecl, ok := decl.(*ast.GenDecl) 437 | if !ok { 438 | continue 439 | } 440 | 441 | for _, spec := range typeDecl.Specs { 442 | safeTypeSpec, ok := spec.(*ast.TypeSpec) 443 | if !ok { 444 | continue 445 | } 446 | 447 | structsMap[safeTypeSpec.Name.String()] = safeTypeSpec.Name.String() 448 | 449 | safeStructDecl, ok := safeTypeSpec.Type.(*ast.StructType) 450 | if !ok { 451 | continue 452 | } 453 | 454 | for _, field := range safeStructDecl.Fields.List { 455 | switch xv := field.Type.(type) { 456 | case *ast.StarExpr: 457 | 458 | if len(field.Names) > 0 { 459 | name := field.Names[0].Name 460 | 461 | if si, ok := xv.X.(*ast.Ident); ok { 462 | k := safeTypeSpec.Name.String() + "." + name 463 | //https://stackoverflow.com/questions/28246970/how-to-parse-a-method-declaration 464 | fieldsMap[k] = si.Name 465 | fieldsOrder[k] = i 466 | } 467 | } // else { 468 | // fmt.Println("len(field.Names) == 0", field) 469 | // } 470 | case *ast.ArrayType: 471 | 472 | name := field.Names[0].Name 473 | 474 | if !IsFirstCharacterLowerCase(name) { 475 | //nolint:errcheck //TODO: handle errors 476 | t, _ := field.Type.(*ast.ArrayType) 477 | 478 | k := safeTypeSpec.Name.String() + "." + name 479 | 480 | fieldsMap[k] = t.Elt.(*ast.Ident).Name + "Slice" 481 | fieldsOrder[k] = i 482 | } 483 | 484 | case *ast.SelectorExpr: 485 | //nolint:errcheck //TODO: handle errors 486 | t, _ := field.Type.(*ast.SelectorExpr) 487 | name := field.Names[0].Name 488 | 489 | k := safeTypeSpec.Name.String() + "." + name 490 | 491 | fieldsMap[k] = t.X.(*ast.Ident).Name + "." + t.Sel.Name 492 | fieldsOrder[k] = i 493 | 494 | case *ast.Ident: 495 | //nolint:errcheck //TODO: handle errors 496 | t, _ := field.Type.(*ast.Ident) // The type as a string 497 | typeName := t.Name 498 | name := field.Names[0].Name // name as a string 499 | 500 | k := safeTypeSpec.Name.String() + "." + name 501 | fieldsMap[k] = typeName 502 | fieldsOrder[k] = i 503 | 504 | default: 505 | fmt.Println("ignoring....", field.Names, field) 506 | } 507 | i++ 508 | } 509 | } 510 | } 511 | } 512 | } 513 | 514 | //// To store the keys in slice in sorted order 515 | //var keys []string 516 | //for k := range fieldsMap { 517 | // keys = append(keys, k) 518 | //} 519 | //sort.Strings(keys) 520 | // 521 | //fmt.Println(" ") 522 | //fmt.Println(" ") 523 | //fmt.Println(" ") 524 | //fmt.Println("START OF MAP DUMP") 525 | //fmt.Println("START OF MAP DUMP") 526 | //fmt.Println("START OF MAP DUMP") 527 | //fmt.Println(" ") 528 | //fmt.Println(" ") 529 | //for _, key := range keys { 530 | // fmt.Println(key, ":", fieldsMap[key]) 531 | //} 532 | //fmt.Println(" ") 533 | //fmt.Println(" ") 534 | //fmt.Println("END OF MAP DUMP") 535 | //fmt.Println("END OF MAP DUMP") 536 | //fmt.Println("END OF MAP DUMP") 537 | //fmt.Println(" ") 538 | //fmt.Println(" ") 539 | //fmt.Println(" ") 540 | 541 | return fieldsMap, structsMap, fieldsOrder 542 | } 543 | -------------------------------------------------------------------------------- /template_files/generated_filter.gotpl: -------------------------------------------------------------------------------- 1 | // Code generated by github.com/web-ridge/gqlgen-sqlboiler, DO NOT EDIT. 2 | package {{.PackageName}} 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "io" 8 | "strconv" 9 | "time" 10 | "reflect" 11 | "unsafe" 12 | "sync" 13 | "errors" 14 | "bytes" 15 | "strings" 16 | "github.com/web-ridge/utils-go/boilergql/v3" 17 | "github.com/vektah/gqlparser/v2" 18 | "github.com/vektah/gqlparser/v2/ast" 19 | "github.com/99designs/gqlgen/graphql" 20 | "github.com/99designs/gqlgen/graphql/introspection" 21 | "github.com/aarondl/sqlboiler/v4/drivers" 22 | "github.com/ericlagergren/decimal" 23 | "github.com/aarondl/sqlboiler/v4/boil" 24 | "github.com/aarondl/sqlboiler/v4/queries" 25 | "github.com/aarondl/sqlboiler/v4/queries/qm" 26 | "github.com/aarondl/sqlboiler/v4/queries/qmhelper" 27 | "github.com/aarondl/sqlboiler/v4/types" 28 | "github.com/aarondl/null/v8" 29 | "database/sql" 30 | {{ range $import := .Imports }} 31 | {{ $import.Alias }} "{{ $import.ImportPath }}" 32 | {{ end }} 33 | ) 34 | 35 | 36 | 37 | // const regexSign = `'` 38 | const percentSign = `%` 39 | const parentTableStatement = "%v.%v = %v.%v" 40 | func startsWithValue(v string) string { return v + percentSign } 41 | func endsWithValue(v string) string { return percentSign + v } 42 | func containsValue(v string) string { return percentSign + v + percentSign } 43 | 44 | const emptyString = "''" 45 | const isZero = "0" 46 | const isLike = " LIKE ?" 47 | const in = " IN ?" 48 | const notIn = " NOT IN ?" 49 | 50 | func isNullOr(column string, v string) qm.QueryMod { 51 | return qm.Where("("+column+" IS NULL OR "+column+" = "+v+")") 52 | } 53 | 54 | func isNotNullOr(column string, v string) qm.QueryMod { 55 | return qm.Where("("+column+" IS NOT NULL AND "+column+" != "+v+")") 56 | } 57 | 58 | func appendSubQuery(queryMods []qm.QueryMod, q *queries.Query) []qm.QueryMod { 59 | // TODO: integrate with subquery in sqlboiler if it will be released in the future 60 | {{- if eq $.PluginConfig.DatabaseDriver "postgres" }} 61 | // https://github.com/web-ridge/gqlgen-sqlboiler/issues/25 we need this for postgres 62 | member := reflect.ValueOf(q).Elem().FieldByName("dialect") 63 | dialectPtr := (**drivers.Dialect)(unsafe.Pointer(member.UnsafeAddr())) 64 | dialect := **dialectPtr 65 | dialect.UseIndexPlaceholders = false 66 | *dialectPtr = &dialect 67 | {{- end }} 68 | 69 | qs, args := queries.BuildQuery(q) 70 | qsClean := strings.TrimSuffix(qs, ";") 71 | return append(queryMods, qm.Where(fmt.Sprintf("EXISTS(%v)", qsClean), args...)) 72 | } 73 | 74 | 75 | func BooleanFilterToMods(m *{{ $.Frontend.PackageName }}.BooleanFilter, column string) []qm.QueryMod { 76 | if m == nil { 77 | return nil 78 | } 79 | var queryMods []qm.QueryMod 80 | if m.IsNull != nil { 81 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 82 | } 83 | if m.NotNull != nil { 84 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 85 | } 86 | if m.EqualTo != nil { 87 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 88 | } 89 | if m.NotEqualTo != nil { 90 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 91 | } 92 | return queryMods 93 | } 94 | 95 | func IDFilterToMods(m *{{ $.Frontend.PackageName }}.IDFilter, column string) []qm.QueryMod { 96 | if m == nil { 97 | return nil 98 | } 99 | var queryMods []qm.QueryMod 100 | if m.IsNull != nil { 101 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 102 | } 103 | if m.NotNull != nil { 104 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 105 | } 106 | if m.EqualTo != nil { 107 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, boilergql.IDToBoiler(*m.EqualTo))) 108 | } 109 | if m.NotEqualTo != nil { 110 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, boilergql.IDToBoiler(*m.NotEqualTo))) 111 | } 112 | if len(m.In) > 0 { 113 | queryMods = append(queryMods, qm.WhereIn(column + in, boilergql.IDsToBoilerInterfaces(m.In)...)) 114 | } 115 | if len(m.NotIn) > 0 { 116 | queryMods = append(queryMods, qm.WhereIn(column + notIn, boilergql.IDsToBoilerInterfaces(m.NotIn)...)) 117 | } 118 | return queryMods 119 | } 120 | 121 | func StringFilterToMods(m *{{ $.Frontend.PackageName }}.StringFilter, column string) []qm.QueryMod { 122 | if m == nil { 123 | return nil 124 | } 125 | 126 | var queryMods []qm.QueryMod 127 | if m.IsNullOrEmpty != nil { 128 | queryMods = append(queryMods, isNullOr(column, emptyString)) 129 | } 130 | if m.IsEmpty != nil { 131 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, emptyString)) 132 | } 133 | if m.IsNull != nil { 134 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 135 | } 136 | if m.NotNullOrEmpty != nil { 137 | queryMods = append(queryMods, isNotNullOr(column, emptyString)) 138 | } 139 | if m.NotEmpty != nil { 140 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, emptyString)) 141 | } 142 | if m.NotNull != nil { 143 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 144 | } 145 | if m.EqualTo != nil { 146 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 147 | } 148 | if m.NotEqualTo != nil { 149 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 150 | } 151 | 152 | lowerColumn := "LOWER("+column+")" 153 | if m.StartWith != nil { 154 | queryMods = append(queryMods, qm.Where(lowerColumn+isLike, startsWithValue(strings.ToLower(*m.StartWith)))) 155 | } 156 | if m.EndWith != nil { 157 | queryMods = append(queryMods, qm.Where(lowerColumn+isLike, endsWithValue(strings.ToLower(*m.EndWith)))) 158 | } 159 | if m.Contain != nil { 160 | queryMods = append(queryMods, qm.Where(lowerColumn+isLike, containsValue(strings.ToLower(*m.Contain)))) 161 | } 162 | 163 | if m.StartWithStrict != nil { 164 | queryMods = append(queryMods, qm.Where(column+isLike, startsWithValue(*m.StartWithStrict))) 165 | } 166 | if m.EndWithStrict != nil { 167 | queryMods = append(queryMods, qm.Where(column+isLike, endsWithValue(*m.EndWithStrict))) 168 | } 169 | if m.ContainStrict != nil { 170 | queryMods = append(queryMods, qm.Where(column+isLike, containsValue(*m.ContainStrict))) 171 | } 172 | 173 | if len(m.In) > 0 { 174 | queryMods = append(queryMods, qm.WhereIn(column+in, boilergql.StringsToInterfaces(m.In)...)) 175 | } 176 | if len(m.NotIn) > 0 { 177 | queryMods = append(queryMods, qm.WhereIn(column+notIn, boilergql.StringsToInterfaces(m.NotIn)...)) 178 | } 179 | 180 | return queryMods 181 | } 182 | 183 | 184 | func FloatFilterToMods(m *{{ $.Frontend.PackageName }}.FloatFilter, column string) []qm.QueryMod { 185 | if m == nil { 186 | return nil 187 | } 188 | var queryMods []qm.QueryMod 189 | if m.IsNullOrZero != nil { 190 | queryMods = append(queryMods, isNullOr(column, isZero)) 191 | } 192 | if m.IsNull != nil { 193 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 194 | } 195 | if m.NotNullOrZero != nil { 196 | queryMods = append(queryMods, isNotNullOr(column, isZero)) 197 | } 198 | if m.NotNull != nil { 199 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 200 | } 201 | if m.EqualTo != nil { 202 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 203 | } 204 | if m.NotEqualTo != nil { 205 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 206 | } 207 | if m.LessThan != nil { 208 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LT, *m.LessThan)) 209 | } 210 | if m.MoreThan != nil { 211 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GT, *m.MoreThan)) 212 | } 213 | if m.LessThanOrEqualTo != nil { 214 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LTE, *m.LessThanOrEqualTo)) 215 | } 216 | if m.MoreThanOrEqualTo != nil { 217 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GTE, *m.MoreThanOrEqualTo)) 218 | } 219 | if len(m.In) > 0 { 220 | queryMods = append(queryMods, qm.WhereIn(column + in, boilergql.FloatsToInterfaces(m.In)...)) 221 | } 222 | if len(m.NotIn) > 0 { 223 | queryMods = append(queryMods, qm.WhereIn(column + notIn, boilergql.FloatsToInterfaces(m.NotIn)...)) 224 | } 225 | return queryMods 226 | } 227 | 228 | func IntFilterToMods(m *{{ $.Frontend.PackageName }}.IntFilter, column string) []qm.QueryMod { 229 | if m == nil { 230 | return nil 231 | } 232 | var queryMods []qm.QueryMod 233 | if m.IsNullOrZero != nil { 234 | queryMods = append(queryMods, isNullOr(column, isZero)) 235 | } 236 | if m.IsNull != nil { 237 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 238 | } 239 | if m.NotNullOrZero != nil { 240 | queryMods = append(queryMods, isNotNullOr(column, isZero)) 241 | } 242 | if m.NotNull != nil { 243 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 244 | } 245 | if m.EqualTo != nil { 246 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 247 | } 248 | if m.NotEqualTo != nil { 249 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 250 | } 251 | if m.LessThan != nil { 252 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LT, *m.LessThan)) 253 | } 254 | if m.MoreThan != nil { 255 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GT, *m.MoreThan)) 256 | } 257 | if m.LessThanOrEqualTo != nil { 258 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LTE, *m.LessThanOrEqualTo)) 259 | } 260 | if m.MoreThanOrEqualTo != nil { 261 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GTE, *m.MoreThanOrEqualTo)) 262 | } 263 | if len(m.In) > 0 { 264 | queryMods = append(queryMods, qm.WhereIn(column + in, boilergql.IntsToInterfaces(m.In)...)) 265 | } 266 | if len(m.NotIn) > 0 { 267 | queryMods = append(queryMods, qm.WhereIn(column + notIn, boilergql.IntsToInterfaces(m.NotIn)...)) 268 | } 269 | return queryMods 270 | } 271 | 272 | 273 | func TimeUnixFilterToMods(m *{{ $.Frontend.PackageName }}.TimeUnixFilter, c string) []qm.QueryMod { 274 | if m == nil { 275 | return nil 276 | } 277 | 278 | {{- if eq $.PluginConfig.DatabaseDriver "postgres" }} 279 | column := "extract(epoch from (" + c + ")" 280 | {{- else if eq $.PluginConfig.DatabaseDriver "mysql" }} 281 | column := "UNIX_TIMESTAMP(" + c + ")" 282 | {{- end }} 283 | 284 | var queryMods []qm.QueryMod 285 | if m.IsNullOrZero != nil { 286 | queryMods = append(queryMods, isNullOr(column, isZero)) 287 | } 288 | if m.IsNull != nil { 289 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 290 | } 291 | if m.NotNullOrZero != nil { 292 | queryMods = append(queryMods, isNotNullOr(column, isZero)) 293 | } 294 | if m.NotNull != nil { 295 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 296 | } 297 | if m.EqualTo != nil { 298 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 299 | } 300 | if m.NotEqualTo != nil { 301 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 302 | } 303 | if m.LessThan != nil { 304 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LT, *m.LessThan)) 305 | } 306 | if m.MoreThan != nil { 307 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GT, *m.MoreThan)) 308 | } 309 | if m.LessThanOrEqualTo != nil { 310 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.LTE, *m.LessThanOrEqualTo)) 311 | } 312 | if m.MoreThanOrEqualTo != nil { 313 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.GTE, *m.MoreThanOrEqualTo)) 314 | } 315 | return queryMods 316 | } 317 | 318 | {{ range $enum := .Enums }} 319 | {{ if $enum.HasFilter }} 320 | func {{ .Name }}FilterToMods(m *{{ $.Frontend.PackageName }}.{{ .Name }}Filter, column string) []qm.QueryMod { 321 | if m == nil { 322 | return nil 323 | } 324 | 325 | var queryMods []qm.QueryMod 326 | if m.IsNull != nil { 327 | queryMods = append(queryMods, qmhelper.WhereIsNull(column)) 328 | } 329 | if m.NotNull != nil { 330 | queryMods = append(queryMods, qmhelper.WhereIsNotNull(column)) 331 | } 332 | if m.EqualTo != nil { 333 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.EQ, *m.EqualTo)) 334 | } 335 | if m.NotEqualTo != nil { 336 | queryMods = append(queryMods, qmhelper.Where(column, qmhelper.NEQ, *m.NotEqualTo)) 337 | } 338 | if len(m.In) > 0 { 339 | queryMods = append(queryMods, qm.WhereIn(column+in, {{ .PluralName }}ToInterfaceArray(m.In)...)) 340 | } 341 | if len(m.NotIn) > 0 { 342 | queryMods = append(queryMods, qm.WhereIn(column+notIn, {{ .PluralName }}ToInterfaceArray(m.NotIn)...)) 343 | } 344 | 345 | return queryMods 346 | } 347 | {{ end }} 348 | {{- end }} 349 | 350 | {{ range $model := .Models }} 351 | 352 | {{- if and .IsFilter .HasBoilerModel -}} 353 | func {{ .Name }}ToMods(m *{{ $.Frontend.PackageName }}.{{ .Name }}) []qm.QueryMod { 354 | if m == nil { 355 | return nil 356 | } 357 | if m.Search != nil || m.Where != nil { 358 | 359 | searchMods := {{ .BoilerModel.Name }}SearchToMods(m.Search) 360 | filterMods := {{ .BoilerModel.Name }}WhereToMods(m.Where, true, "", "") 361 | if len(searchMods) > 0 && len(filterMods) > 0 { 362 | return []qm.QueryMod{ 363 | qm.Expr(searchMods...), 364 | qm.Expr(filterMods...), 365 | } 366 | } else if len(searchMods) > 0 { 367 | return []qm.QueryMod{ 368 | qm.Expr(searchMods...), 369 | } 370 | } else if len(filterMods) > 0 { 371 | return []qm.QueryMod{ 372 | qm.Expr(filterMods...), 373 | } 374 | } 375 | } 376 | return nil 377 | } 378 | func {{ .BoilerModel.Name }}SearchToMods(search *string) []qm.QueryMod { 379 | // TODO: implement your own custom search here 380 | return nil 381 | } 382 | {{ end }} 383 | {{- if and .IsWhere .HasBoilerModel -}} 384 | func {{ .Name }}SubqueryToMods(m *{{ $.Frontend.PackageName }}.{{ .Name }}, foreignColumn string, parentTable string) []qm.QueryMod { 385 | if m == nil { 386 | return nil 387 | } 388 | var queryMods []qm.QueryMod 389 | 390 | // if foreign key exist so we can filter on ID in the root table instead of subquery 391 | hasForeignKeyInRoot := foreignColumn != "" 392 | if hasForeignKeyInRoot { 393 | queryMods = append(queryMods, IDFilterToMods(m.ID, foreignColumn)...) 394 | } 395 | 396 | subQueryMods := {{ .Name }}ToMods(m, !hasForeignKeyInRoot, parentTable, foreignColumn) 397 | if len(subQueryMods) > 0 { 398 | subQuery := {{ $.Backend.PackageName }}.{{.BoilerModel.PluralName}}(append(subQueryMods, qm.Select("1"))...) 399 | queryMods = appendSubQuery(queryMods, subQuery.Query) 400 | } 401 | return queryMods 402 | } 403 | 404 | func {{ .Name }}ToMods(m *{{ $.Frontend.PackageName }}.{{ .Name }}, withPrimaryID bool, parentTable string, parentForeignKey string) []qm.QueryMod { 405 | if m == nil { 406 | return nil 407 | } 408 | var queryMods []qm.QueryMod 409 | 410 | {{ $model := . }} 411 | {{ range $field := .Fields }} 412 | {{- if and $field.IsRelation $field.BoilerField.IsRelation }} 413 | {{- if $field.IsPlural }} 414 | queryMods = append(queryMods, {{ $field.TypeWithoutPointer|go }}SubqueryToMods(m.{{ $field.Name }}, "", {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }})...) 415 | {{- else if $field.BoilerField.IsForeignKey }} 416 | queryMods = append(queryMods, {{ $field.TypeWithoutPointer|go }}SubqueryToMods(m.{{ $field.Name }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $field.BoilerField.Name }}, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }})...) 417 | {{- else }} 418 | queryMods = append(queryMods, {{ $field.TypeWithoutPointer|go }}SubqueryToMods(m.{{ $field.Name }}, "", {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }})...) 419 | {{- end }} 420 | {{- else if $field.IsOr }} 421 | if m.Or != nil { 422 | queryMods = append(queryMods, qm.Or2(qm.Expr({{ $field.TypeWithoutPointer|go }}ToMods(m.Or, true, "", "")...))) 423 | } 424 | {{- else if $field.IsAnd }} 425 | if m.And != nil { 426 | queryMods = append(queryMods, qm.Expr({{ $field.TypeWithoutPointer|go }}ToMods(m.And, true, "", "")...)) 427 | } 428 | {{- else if $field.IsWithDeleted }} 429 | if m.WithDeleted != nil && *m.WithDeleted == true { 430 | queryMods = append(queryMods, qm.WithDeleted()) 431 | } 432 | {{- else }} 433 | {{- if $field.IsPrimaryID }} 434 | if withPrimaryID { 435 | queryMods = append(queryMods, {{ $field.TypeWithoutPointer|go }}ToMods(m.{{ $field.Name }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $field.BoilerField.Name }})...) 436 | } 437 | {{- else }} 438 | queryMods = append(queryMods, {{ $field.TypeWithoutPointer|go }}ToMods(m.{{ $field.Name }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $field.BoilerField.Name }})...) 439 | {{- end }} 440 | {{- end -}} 441 | {{ end }} 442 | 443 | if len(queryMods) > 0 && parentTable != "" { 444 | if parentForeignKey == "" { 445 | {{ range $field := .Fields }} 446 | {{- if and $field.IsRelation $field.BoilerField.IsRelation -}} 447 | {{- if not $field.IsPlural -}} 448 | {{- if $field.BoilerField.IsForeignKey }} 449 | if parentTable == {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $field.Relationship.BoilerModel.TableName }} { 450 | queryMods = append(queryMods, qm.Where(fmt.Sprintf(parentTableStatement, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $field.BoilerField.Name }}, parentTable, {{ $.Backend.PackageName }}.{{ $field.Relationship.BoilerModel.TableName }}Columns.ID))) 451 | } 452 | {{- end -}} 453 | {{- else }} 454 | if parentTable == {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $field.Relationship.BoilerModel.TableName }} { 455 | queryMods = append(queryMods, qm.Where(fmt.Sprintf(parentTableStatement, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.ID, parentTable, {{ $.Backend.PackageName }}.{{ $field.Relationship.BoilerModel.TableName }}Columns.ID))) 456 | } 457 | {{- end -}} 458 | {{- end -}} 459 | {{ end }} 460 | } else { 461 | {{ range $field := .Fields }} 462 | {{- if and $field.IsRelation $field.BoilerField.IsRelation -}} 463 | {{- if not $field.IsPlural -}} 464 | {{- if $field.BoilerField.IsForeignKey }} 465 | if parentTable == {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $field.Relationship.BoilerModel.TableName }} { 466 | queryMods = append(queryMods, qm.Where(fmt.Sprintf(parentTableStatement, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.{{ $field.BoilerField.Name }}, parentTable, parentForeignKey))) 467 | } 468 | {{- end -}} 469 | {{- else }} 470 | if parentTable == {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{ $field.Relationship.BoilerModel.TableName }} { 471 | queryMods = append(queryMods, qm.Where(fmt.Sprintf(parentTableStatement, {{ $.Backend.PackageName }}.{{- $model.TableNameResolverName }}.{{- $model.BoilerModel.TableName }}, {{ $.Backend.PackageName }}.{{ $model.BoilerModel.Name }}Columns.ID, parentTable, parentForeignKey))) 472 | } 473 | {{- end -}} 474 | {{- end -}} 475 | {{ end }} 476 | } 477 | } 478 | 479 | 480 | 481 | return queryMods 482 | } 483 | {{ end }} 484 | 485 | 486 | 487 | {{- end }} 488 | -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "fmt" 5 | "go/types" 6 | "sort" 7 | "strings" 8 | "unicode" 9 | 10 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 11 | 12 | "github.com/aarondl/strmangle" 13 | "github.com/iancoleman/strcase" 14 | 15 | "github.com/99designs/gqlgen/codegen/config" 16 | gqlgenTemplates "github.com/99designs/gqlgen/codegen/templates" 17 | "github.com/rs/zerolog/log" 18 | "github.com/vektah/gqlparser/v2/ast" 19 | ) 20 | 21 | type BoilerCache struct { 22 | BoilerModels []*structs.BoilerModel 23 | BoilerEnums []*structs.BoilerEnum 24 | } 25 | 26 | func InitializeBoilerCache(backend structs.Config) *BoilerCache { 27 | log.Debug().Msg("[boiler-cache] building cache") 28 | boilerModels, boilerEnums := GetBoilerModels(backend.Directory) 29 | log.Debug().Msg("[boiler-cache] built cache!") 30 | return &BoilerCache{ 31 | BoilerModels: boilerModels, 32 | BoilerEnums: boilerEnums, 33 | } 34 | } 35 | 36 | type ModelCache struct { 37 | Models []*structs.Model 38 | Interfaces []*structs.Interface 39 | Enums []*structs.Enum 40 | Backend structs.Config 41 | Frontend structs.Config 42 | Output structs.Config 43 | Scalars []string 44 | } 45 | 46 | func copyConfig(cfg config.Config) *config.Config { 47 | return &cfg 48 | } 49 | 50 | func InitializeModelCache(config *config.Config, boilerCache *BoilerCache, output structs.Config, backend structs.Config, frontend structs.Config) *ModelCache { 51 | //config := copyConfig(*originalConfig) 52 | //config.ReloadAllPackages() 53 | //if err := config.Init(); err != nil { 54 | // log.Err(err).Msg("failed to init config") 55 | //} 56 | //config := *originalConfig 57 | 58 | log.Debug().Msg("[model-cache] get structs") 59 | baseModels := getModelsFromSchema(config.Schema, boilerCache.BoilerModels) 60 | 61 | log.Debug().Msg("[model-cache] get extra's from schema") 62 | interfaces, enums, scalars := getExtrasFromSchema(config.Schema, boilerCache.BoilerEnums, baseModels) 63 | 64 | log.Debug().Msg("[model-cache] enhance structs with information") 65 | models := EnhanceModelsWithInformation(backend, enums, config, boilerCache.BoilerModels, baseModels, []string{frontend.PackageName, backend.PackageName, "boilergql"}) 66 | log.Debug().Msg("[model-cache] built cache!") 67 | 68 | return &ModelCache{ 69 | Models: models, 70 | Output: output, 71 | Backend: backend, 72 | Frontend: frontend, 73 | Interfaces: interfaces, 74 | Enums: enumsWithout(enums, []string{"SortDirection", "Sort"}), 75 | Scalars: scalars, 76 | } 77 | } 78 | 79 | func EnhanceModelsWithInformation( 80 | backend structs.Config, 81 | enums []*structs.Enum, 82 | cfg *config.Config, 83 | boilerModels []*structs.BoilerModel, 84 | models []*structs.Model, 85 | ignoreTypePrefixes []string) []*structs.Model { 86 | // always sort enums the same way to prevent merge conflicts in generated code 87 | sort.Slice(enums, func(i, j int) bool { 88 | return enums[i].Name < enums[j].Name 89 | }) 90 | 91 | // Now we have all model's let enhance them with fields 92 | enhanceModelsWithFields(enums, cfg.Schema, cfg, models, ignoreTypePrefixes) 93 | 94 | // Add preload maps 95 | enhanceModelsWithPreloadArray(backend, models) 96 | 97 | // Sort in same order 98 | sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name }) 99 | for _, m := range models { 100 | cfg.Models.Add(m.Name, cfg.Model.ImportPath()+"."+gqlgenTemplates.ToGo(m.Name)) 101 | } 102 | return models 103 | } 104 | 105 | //nolint:gocognit,gocyclo 106 | func enhanceModelsWithFields(enums []*structs.Enum, schema *ast.Schema, cfg *config.Config, 107 | models []*structs.Model, ignoreTypePrefixes []string) { 108 | binder := cfg.NewBinder() 109 | 110 | // Generate the basic of the fields 111 | for _, m := range models { 112 | if !m.HasBoilerModel { 113 | continue 114 | } 115 | // Let's convert the pure ast fields to something usable for our templates 116 | for _, field := range m.PureFields { 117 | fieldDef := schema.Types[field.Type.Name()] 118 | 119 | // This calls some qglgen boilerType which gets the gqlgen type 120 | typ, err := getAstFieldType(binder, schema, cfg, field) 121 | if err != nil { 122 | log.Err(err).Msg("could not get field type from graphql schema") 123 | } 124 | jsonName := getGraphqlFieldName(cfg, m.Name, field) 125 | name := gqlgenTemplates.ToGo(jsonName) 126 | 127 | // just some (old) Relay clutter which is not needed anymore + we won't do anything with it 128 | // in our database converts. 129 | if strings.EqualFold(name, "clientMutationId") { 130 | continue 131 | } 132 | 133 | // override type struct with qqlgen code 134 | typ = binder.CopyModifiersFromAst(field.Type, typ) 135 | if isStruct(typ) && (fieldDef.Kind == ast.Object || fieldDef.Kind == ast.InputObject) { 136 | typ = types.NewPointer(typ) 137 | } 138 | 139 | // generate some booleans because these checks will be used a lot 140 | isObject := fieldDef.Kind == ast.Object || fieldDef.Kind == ast.InputObject 141 | 142 | shortType := getShortType(typ.String(), ignoreTypePrefixes) 143 | 144 | isPrimaryID := strings.EqualFold(name, "id") 145 | 146 | // get sqlboiler information of the field 147 | boilerField := findBoilerFieldOrForeignKey(m.BoilerModel, name, isObject) 148 | isString := strings.Contains(strings.ToLower(boilerField.Type), "string") 149 | isNumberID := strings.HasSuffix(name, "ID") && !isString 150 | isPrimaryNumberID := isPrimaryID && !isString 151 | 152 | isPrimaryStringID := isPrimaryID && isString 153 | 154 | // enable simpler code in resolvers 155 | if isPrimaryStringID { 156 | m.HasPrimaryStringID = isPrimaryStringID 157 | } 158 | if isPrimaryNumberID || isPrimaryStringID { 159 | m.PrimaryKeyType = boilerField.Type 160 | } 161 | 162 | isEdges := strings.HasSuffix(m.Name, "Connection") && name == "Edges" 163 | isPageInfo := strings.HasSuffix(m.Name, "Connection") && name == "PageInfo" 164 | isSort := strings.HasSuffix(m.Name, "Ordering") && name == "Sort" 165 | isSortDirection := strings.HasSuffix(m.Name, "Ordering") && name == "Direction" 166 | isCursor := strings.HasSuffix(m.Name, "Edge") && name == "Cursor" 167 | isNode := strings.HasSuffix(m.Name, "Edge") && name == "Node" 168 | 169 | // log some warnings when fields could not be converted 170 | if boilerField.Type == "" { 171 | skipWarningInFilter := 172 | strings.EqualFold(name, "and") || 173 | strings.EqualFold(name, "or") || 174 | strings.EqualFold(name, "search") || 175 | strings.EqualFold(name, "where") || 176 | strings.EqualFold(name, "withDeleted") 177 | 178 | switch { 179 | case m.IsPayload: 180 | case IsPlural(name): 181 | case ((m.IsFilter || m.IsWhere) && skipWarningInFilter) || 182 | isEdges || 183 | isSort || 184 | isSortDirection || 185 | isPageInfo || 186 | isCursor || 187 | isNode: 188 | // ignore 189 | default: 190 | log.Warn().Str("field", m.Name+"."+name).Msg("no database mapping") 191 | } 192 | } 193 | 194 | if boilerField.Name == "" { 195 | if m.IsPayload || m.IsFilter || m.IsWhere || m.IsOrdering || m.IsEdge || isPageInfo || isEdges { 196 | } else { 197 | log.Warn().Str("field", m.Name+"."+name).Msg("no database mapping") 198 | continue 199 | } 200 | } 201 | 202 | enum := findEnum(enums, shortType) 203 | field := &structs.Field{ 204 | Name: name, 205 | JSONName: jsonName, 206 | Type: shortType, 207 | TypeWithoutPointer: strings.Replace(strings.TrimPrefix(shortType, "*"), ".", "Dot", -1), 208 | BoilerField: boilerField, 209 | IsNumberID: isNumberID, 210 | IsPrimaryID: isPrimaryID, 211 | IsPrimaryNumberID: isPrimaryNumberID, 212 | IsPrimaryStringID: isPrimaryStringID, 213 | IsRelation: boilerField.IsRelation, 214 | IsRelationAndNotForeignKey: boilerField.IsRelation && 215 | !strings.HasSuffix(strings.ToLower(name), "id"), 216 | IsObject: isObject, 217 | IsOr: strings.EqualFold(name, "or"), 218 | IsAnd: strings.EqualFold(name, "and"), 219 | IsWithDeleted: strings.EqualFold(name, "withDeleted"), 220 | IsPlural: IsPlural(name), 221 | PluralName: Plural(name), 222 | OriginalType: typ, 223 | Description: field.Description, 224 | Enum: enum, 225 | } 226 | field.ConvertConfig = getConvertConfig(enums, m, field) 227 | m.Fields = append(m.Fields, field) 228 | } 229 | } 230 | 231 | for _, m := range models { 232 | for _, f := range m.Fields { 233 | if f.BoilerField.Relationship != nil { 234 | f.Relationship = findModel(models, f.BoilerField.Relationship.Name) 235 | } 236 | } 237 | } 238 | } 239 | 240 | func enumsWithout(enums []*structs.Enum, skip []string) []*structs.Enum { 241 | // lol: cleanup xD 242 | var a []*structs.Enum 243 | for _, e := range enums { 244 | var skipped bool 245 | for _, skip := range skip { 246 | if strings.HasSuffix(e.Name, skip) { 247 | skipped = true 248 | } 249 | } 250 | if !skipped { 251 | a = append(a, e) 252 | } 253 | } 254 | return a 255 | } 256 | 257 | // getAstFieldType check's if user has defined a 258 | func getAstFieldType(binder *config.Binder, schema *ast.Schema, cfg *config.Config, field *ast.FieldDefinition) ( 259 | types.Type, error) { 260 | var typ types.Type 261 | var err error 262 | 263 | fieldDef := schema.Types[field.Type.Name()] 264 | if cfg.Models.UserDefined(field.Type.Name()) { 265 | typ, err = binder.FindTypeFromName(cfg.Models[field.Type.Name()].Model[0]) 266 | if err != nil { 267 | return typ, err 268 | } 269 | } else { 270 | switch fieldDef.Kind { 271 | case ast.Scalar: 272 | // no user defined model, referencing a default scalar 273 | typ = types.NewNamed( 274 | types.NewTypeName(0, cfg.Model.Pkg(), "string", nil), 275 | nil, 276 | nil, 277 | ) 278 | 279 | case ast.Interface, ast.Union: 280 | // no user defined model, referencing a generated interface type 281 | typ = types.NewNamed( 282 | types.NewTypeName(0, cfg.Model.Pkg(), gqlgenTemplates.ToGo(field.Type.Name()), nil), 283 | types.NewInterfaceType([]*types.Func{}, []types.Type{}), 284 | nil, 285 | ) 286 | 287 | case ast.Enum: 288 | // no user defined model, must reference a generated enum 289 | typ = types.NewNamed( 290 | types.NewTypeName(0, cfg.Model.Pkg(), gqlgenTemplates.ToGo(field.Type.Name()), nil), 291 | nil, 292 | nil, 293 | ) 294 | 295 | case ast.Object, ast.InputObject: 296 | // no user defined model, must reference a generated struct 297 | typ = types.NewNamed( 298 | types.NewTypeName(0, cfg.Model.Pkg(), gqlgenTemplates.ToGo(field.Type.Name()), nil), 299 | types.NewStruct(nil, nil), 300 | nil, 301 | ) 302 | 303 | default: 304 | panic(fmt.Errorf("unknown ast type %s", fieldDef.Kind)) 305 | } 306 | } 307 | 308 | return typ, err 309 | } 310 | 311 | func getGraphqlFieldName(cfg *config.Config, modelName string, field *ast.FieldDefinition) string { 312 | name := field.Name 313 | if nameOveride := cfg.Models[modelName].Fields[field.Name].FieldName; nameOveride != "" { 314 | // TODO: map overrides to sqlboiler the other way around? 315 | name = nameOveride 316 | } 317 | return name 318 | } 319 | 320 | // TaskBlockedBies -> TaskBlockedBy 321 | // People -> Person 322 | func Singular(s string) string { 323 | singular := strmangle.Singular(strcase.ToSnake(s)) 324 | 325 | singularTitle := strmangle.TitleCase(singular) 326 | if IsFirstCharacterLowerCase(s) { 327 | a := []rune(singularTitle) 328 | a[0] = unicode.ToLower(a[0]) 329 | return string(a) 330 | } 331 | return singularTitle 332 | } 333 | 334 | // TaskBlockedBy -> TaskBlockedBies 335 | // Person -> Persons 336 | // Person -> People 337 | func Plural(s string) string { 338 | plural := strmangle.Plural(strcase.ToSnake(s)) 339 | 340 | pluralTitle := strmangle.TitleCase(plural) 341 | if IsFirstCharacterLowerCase(s) { 342 | a := []rune(pluralTitle) 343 | a[0] = unicode.ToLower(a[0]) 344 | return string(a) 345 | } 346 | return pluralTitle 347 | } 348 | 349 | func IsFirstCharacterLowerCase(s string) bool { 350 | if len(s) > 0 && s[0] == strings.ToLower(s)[0] { 351 | return true 352 | } 353 | return false 354 | } 355 | 356 | func IsPlural(s string) bool { 357 | return s == Plural(s) 358 | } 359 | 360 | func IsSingular(s string) bool { 361 | return s == Singular(s) 362 | } 363 | 364 | func getShortType(longType string, ignoreTypePrefixes []string) string { 365 | // longType e.g = gitlab.com/decicify/app/backend/graphql_models.FlowWhere 366 | splittedBySlash := strings.Split(longType, "/") 367 | // gitlab.com, decicify, app, backend, graphql_models.FlowWhere 368 | 369 | lastPart := splittedBySlash[len(splittedBySlash)-1] 370 | isPointer := strings.HasPrefix(longType, "*") 371 | isStructInPackage := strings.Count(lastPart, ".") > 0 372 | 373 | if isStructInPackage { 374 | // if packages are deeper they don't have pointers but *time.Time will since it's not deep 375 | returnType := strings.TrimPrefix(lastPart, "*") 376 | for _, ignoreType := range ignoreTypePrefixes { 377 | fullIgnoreType := ignoreType + "." 378 | returnType = strings.TrimPrefix(returnType, fullIgnoreType) 379 | } 380 | 381 | if isPointer { 382 | return "*" + returnType 383 | } 384 | return returnType 385 | } 386 | 387 | return longType 388 | } 389 | 390 | func findModel(models []*structs.Model, search string) *structs.Model { 391 | for _, m := range models { 392 | if m.Name == search { 393 | return m 394 | } 395 | } 396 | return nil 397 | } 398 | 399 | //func findField(fields []*Field, search string) *Field { 400 | // for _, f := range fields { 401 | // if f.Name == search { 402 | // return f 403 | // } 404 | // } 405 | // return nil 406 | //} 407 | 408 | func findBoilerFieldOrForeignKey(boilerModel *structs.BoilerModel, golangGraphQLName string, isObject bool) structs.BoilerField { 409 | if boilerModel == nil { 410 | return structs.BoilerField{} 411 | } 412 | 413 | // get database friendly struct for this model 414 | for _, field := range boilerModel.Fields { 415 | if isObject { 416 | // If it a relation check to see if a foreign key is available 417 | if strings.EqualFold(field.Name, golangGraphQLName+"ID") { 418 | return *field 419 | } 420 | } 421 | if strings.EqualFold(field.Name, golangGraphQLName) { 422 | return *field 423 | } 424 | } 425 | return structs.BoilerField{} 426 | } 427 | 428 | func getExtrasFromSchema(schema *ast.Schema, boilerEnums []*structs.BoilerEnum, models []*structs.Model) (interfaces []*structs.Interface, enums []*structs.Enum, scalars []string) { 429 | for _, schemaType := range schema.Types { 430 | switch schemaType.Kind { 431 | case ast.Interface, ast.Union: 432 | interfaces = append(interfaces, &structs.Interface{ 433 | Description: schemaType.Description, 434 | Name: schemaType.Name, 435 | }) 436 | case ast.Enum: 437 | boilerEnum := findBoilerEnum(boilerEnums, schemaType.Name) 438 | it := &structs.Enum{ 439 | Name: schemaType.Name, 440 | PluralName: Plural(schemaType.Name), 441 | Description: schemaType.Description, 442 | HasBoilerEnum: boilerEnum != nil, 443 | BoilerEnum: boilerEnum, 444 | HasFilter: findModel(models, schemaType.Name+"Filter") != nil, 445 | } 446 | for _, v := range schemaType.EnumValues { 447 | it.Values = append(it.Values, &structs.EnumValue{ 448 | Name: v.Name, 449 | NameLower: strcase.ToLowerCamel(strings.ToLower(v.Name)), 450 | Description: v.Description, 451 | BoilerEnumValue: findBoilerEnumValue(boilerEnum, v.Name), 452 | }) 453 | } 454 | if strings.HasPrefix(it.Name, "_") { 455 | continue 456 | } 457 | enums = append(enums, it) 458 | case ast.Scalar: 459 | scalars = append(scalars, schemaType.Name) 460 | } 461 | } 462 | return 463 | } 464 | 465 | func getModelsFromSchema(schema *ast.Schema, boilerModels []*structs.BoilerModel) (models []*structs.Model) { //nolint:gocognit,gocyclo 466 | for _, schemaType := range schema.Types { 467 | // skip boiler plate from ggqlgen, we only want the structs 468 | if strings.HasPrefix(schemaType.Name, "_") { 469 | continue 470 | } 471 | 472 | // if cfg.Models.UserDefined(schemaType.Name) { 473 | // fmt.Println("continue") 474 | // continue 475 | // } 476 | 477 | switch schemaType.Kind { 478 | case ast.Object, ast.InputObject: 479 | { 480 | if schemaType == schema.Query || 481 | schemaType == schema.Mutation || 482 | schemaType == schema.Subscription { 483 | continue 484 | } 485 | modelName := schemaType.Name 486 | 487 | // fmt.Println("GRAPHQL MODEL ::::", m.Name) 488 | if strings.HasPrefix(modelName, "_") { 489 | continue 490 | } 491 | 492 | // We will try to find a corresponding boiler struct 493 | boilerModel := FindBoilerModel(boilerModels, getBaseModelFromName(modelName)) 494 | 495 | isInput := doesEndWith(modelName, "Input") 496 | isCreateInput := doesEndWith(modelName, "CreateInput") 497 | isUpdateInput := doesEndWith(modelName, "UpdateInput") 498 | isFilter := doesEndWith(modelName, "Filter") 499 | isWhere := doesEndWith(modelName, "Where") 500 | isPayload := doesEndWith(modelName, "Payload") 501 | isEdge := doesEndWith(modelName, "Edge") 502 | isConnection := doesEndWith(modelName, "Connection") 503 | isPageInfo := modelName == "PageInfo" 504 | isOrdering := doesEndWith(modelName, "Ordering") 505 | 506 | var isPagination bool 507 | paginationTriggers := []string{ 508 | "ConnectionBackwardPagination", 509 | "ConnectionPagination", 510 | "ConnectionForwardPagination", 511 | } 512 | for _, p := range paginationTriggers { 513 | if modelName == p { 514 | isPagination = true 515 | } 516 | } 517 | 518 | // if no boiler model is found 519 | 520 | hasEmptyBoilerModel := boilerModel == nil || boilerModel.Name == "" 521 | // TODO: make this cleaner and support custom structs 522 | if !isFilter { 523 | if hasEmptyBoilerModel { 524 | if isInput || isWhere || isPayload || isPageInfo || isPagination { 525 | // silent continue 526 | continue 527 | } 528 | log.Debug().Str("model", modelName).Msg("skipped because no database model found") 529 | continue 530 | } 531 | } 532 | 533 | isNormalInput := isInput && !isCreateInput && !isUpdateInput 534 | isNormal := !isInput && !isWhere && !isFilter && !isPayload && !isEdge && !isConnection && !isOrdering 535 | 536 | hasBoilerModel := !hasEmptyBoilerModel 537 | hasDeletedAt := hasBoilerModel && boilerModel.HasDeletedAt 538 | tableNameResolverName := "TableNames" 539 | if hasBoilerModel && boilerModel.IsView { 540 | tableNameResolverName = "ViewNames" 541 | } 542 | m := &structs.Model{ 543 | Name: modelName, 544 | JSONName: strcase.ToCamel(modelName), 545 | Description: schemaType.Description, 546 | PluralName: Plural(modelName), 547 | BoilerModel: boilerModel, 548 | HasBoilerModel: hasBoilerModel, 549 | IsInput: isInput, 550 | IsFilter: isFilter, 551 | IsWhere: isWhere, 552 | IsUpdateInput: isUpdateInput, 553 | IsCreateInput: isCreateInput, 554 | IsNormalInput: isNormalInput, 555 | IsConnection: isConnection, 556 | IsEdge: isEdge, 557 | IsPayload: isPayload, 558 | IsOrdering: isOrdering, 559 | IsNormal: isNormal, 560 | IsPreloadable: isNormal, 561 | HasDeletedAt: hasDeletedAt, 562 | TableNameResolverName: tableNameResolverName, 563 | } 564 | 565 | for _, implementor := range schema.GetImplements(schemaType) { 566 | m.Implements = append(m.Implements, implementor.Name) 567 | } 568 | 569 | m.PureFields = append(m.PureFields, schemaType.Fields...) 570 | models = append(models, m) 571 | } 572 | } 573 | } 574 | return //nolint:nakedret 575 | } 576 | 577 | func doesEndWith(s string, suffix string) bool { 578 | return strings.HasSuffix(s, suffix) && s != suffix 579 | } 580 | 581 | func getPreloadMapForModel(backend structs.Config, model *structs.Model) map[string]structs.ColumnSetting { 582 | preloadMap := map[string]structs.ColumnSetting{} 583 | for _, field := range model.Fields { 584 | // only relations are preloadable 585 | if !field.IsObject || !field.BoilerField.IsRelation { 586 | continue 587 | } 588 | // var key string 589 | // if field.IsPlural { 590 | key := field.JSONName 591 | // } else { 592 | // key = field.PluralName 593 | // } 594 | name := fmt.Sprintf("%v.%vRels.%v", backend.PackageName, model.Name, foreignKeyToRel(field.BoilerField.Name)) 595 | setting := structs.ColumnSetting{ 596 | Name: name, 597 | IDAvailable: !field.IsPlural, 598 | RelationshipModelName: field.BoilerField.Relationship.TableName, 599 | } 600 | 601 | preloadMap[key] = setting 602 | } 603 | return preloadMap 604 | } 605 | 606 | func enhanceModelsWithPreloadArray(backend structs.Config, models []*structs.Model) { 607 | // first adding basic first level relations 608 | for _, model := range models { 609 | if !model.IsPreloadable { 610 | continue 611 | } 612 | 613 | modelPreloadMap := getPreloadMapForModel(backend, model) 614 | 615 | sortedPreloadKeys := make([]string, 0, len(modelPreloadMap)) 616 | for k := range modelPreloadMap { 617 | sortedPreloadKeys = append(sortedPreloadKeys, k) 618 | } 619 | sort.Strings(sortedPreloadKeys) 620 | 621 | model.PreloadArray = make([]structs.Preload, len(sortedPreloadKeys)) 622 | for i, k := range sortedPreloadKeys { 623 | columnSetting := modelPreloadMap[k] 624 | model.PreloadArray[i] = structs.Preload{ 625 | Key: k, 626 | ColumnSetting: columnSetting, 627 | } 628 | } 629 | } 630 | } 631 | 632 | // The relationship is defined in the normal model but not in the input, where etc structs 633 | // So just find the normal model and get the relationship type :) 634 | func getBaseModelFromName(v string) string { 635 | v = safeTrim(v, "CreateInput") 636 | v = safeTrim(v, "UpdateInput") 637 | v = safeTrim(v, "Input") 638 | v = safeTrim(v, "Payload") 639 | v = safeTrim(v, "Where") 640 | v = safeTrim(v, "Filter") 641 | v = safeTrim(v, "Ordering") 642 | v = safeTrim(v, "Edge") 643 | v = safeTrim(v, "Connection") 644 | 645 | return v 646 | } 647 | 648 | func safeTrim(v string, trimSuffix string) string { 649 | // let user still choose Payload as model names 650 | // not recommended but could be done theoretically :-) 651 | if v != trimSuffix { 652 | v = strings.TrimSuffix(v, trimSuffix) 653 | } 654 | return v 655 | } 656 | 657 | func foreignKeyToRel(v string) string { 658 | return strings.TrimSuffix(v, "ID") 659 | } 660 | 661 | func isStruct(t types.Type) bool { 662 | _, is := t.Underlying().(*types.Struct) 663 | return is 664 | } 665 | 666 | func findBoilerEnum(enums []*structs.BoilerEnum, graphType string) *structs.BoilerEnum { 667 | for _, enum := range enums { 668 | if enum.Name == graphType { 669 | return enum 670 | } 671 | } 672 | return nil 673 | } 674 | 675 | func findBoilerEnumValue(enum *structs.BoilerEnum, name string) *structs.BoilerEnumValue { 676 | if enum != nil { 677 | for _, v := range enum.Values { 678 | boilerName := strings.TrimPrefix(v.Name, enum.Name) 679 | frontendName := strings.Replace(name, "_", "", -1) 680 | if strings.EqualFold(boilerName, frontendName) { 681 | return v 682 | } 683 | } 684 | log.Error().Str(enum.Name, name).Msg("could not find sqlboiler enum value") 685 | } 686 | 687 | return nil 688 | } 689 | 690 | func findEnum(enums []*structs.Enum, graphType string) *structs.Enum { 691 | for _, enum := range enums { 692 | if enum.Name == graphType { 693 | return enum 694 | } 695 | } 696 | return nil 697 | } 698 | 699 | func getConvertConfig(enums []*structs.Enum, model *structs.Model, field *structs.Field) (cc structs.ConvertConfig) { //nolint:gocognit,gocyclo 700 | graphType := field.Type 701 | boilType := field.BoilerField.Type 702 | 703 | enum := findEnum(enums, field.TypeWithoutPointer) 704 | if enum != nil { //nolint:nestif 705 | cc.IsCustom = true 706 | cc.ToBoiler = strings.TrimPrefix( 707 | getToBoiler( 708 | getBoilerTypeAsText(boilType), 709 | getGraphTypeAsText(graphType), 710 | ), boilerPackage) 711 | 712 | cc.ToGraphQL = strings.TrimPrefix( 713 | getToGraphQL( 714 | getBoilerTypeAsText(boilType), 715 | getGraphTypeAsText(graphType), 716 | ), boilerPackage) 717 | } else if graphType != boilType { 718 | cc.IsCustom = true 719 | if field.IsPrimaryID || field.IsNumberID && field.BoilerField.IsRelation { 720 | // TODO: more dynamic and universal 721 | cc.ToGraphQL = "VALUE" 722 | cc.ToBoiler = "VALUE" 723 | 724 | // first unpointer json type if is pointer 725 | if strings.HasPrefix(graphType, "*") { 726 | cc.ToBoiler = boilerPackage + "PointerStringToString(VALUE)" 727 | } 728 | 729 | goToUint := getBoilerTypeAsText(boilType) + "ToUint" 730 | if goToUint == "IntToUint" { 731 | cc.ToGraphQL = "uint(VALUE)" 732 | } else if goToUint != "UintToUint" { 733 | cc.ToGraphQL = boilerPackage + goToUint + "(VALUE)" 734 | } 735 | 736 | if field.IsPrimaryID { 737 | cc.ToGraphQL = model.Name + "IDToGraphQL(" + cc.ToGraphQL + ")" 738 | } else if field.IsNumberID { 739 | cc.ToGraphQL = field.BoilerField.Relationship.Name + "IDToGraphQL(" + cc.ToGraphQL + ")" 740 | } 741 | 742 | isInt := strings.HasPrefix(strings.ToLower(boilType), "int") && !strings.HasPrefix(strings.ToLower(boilType), "uint") 743 | 744 | if strings.HasPrefix(boilType, "null") { 745 | cc.ToBoiler = fmt.Sprintf("boilergql.IDToNullBoiler(%v)", cc.ToBoiler) 746 | if isInt { 747 | cc.ToBoiler = fmt.Sprintf("boilergql.NullUintToNullInt(%v)", cc.ToBoiler) 748 | } 749 | } else { 750 | cc.ToBoiler = fmt.Sprintf("boilergql.IDToBoiler(%v)", cc.ToBoiler) 751 | if isInt { 752 | cc.ToBoiler = fmt.Sprintf("int(%v)", cc.ToBoiler) 753 | } 754 | } 755 | 756 | cc.ToGraphQL = strings.Replace(cc.ToGraphQL, "VALUE", "m."+field.BoilerField.Name, -1) 757 | cc.ToBoiler = strings.Replace(cc.ToBoiler, "VALUE", "m."+field.Name, -1) 758 | } else { 759 | // Make these go-friendly for the helper/plugin_convert.go package 760 | cc.ToBoiler = getToBoiler(getBoilerTypeAsText(boilType), getGraphTypeAsText(graphType)) 761 | cc.ToGraphQL = getToGraphQL(getBoilerTypeAsText(boilType), getGraphTypeAsText(graphType)) 762 | } 763 | } 764 | 765 | // fmt.Println("boilType for", field.Name, ":", boilType) 766 | 767 | // JSON let the user convert how it looks in a custom file 768 | if strings.Contains(boilType, "JSON") { 769 | cc.ToBoiler = strings.TrimPrefix(cc.ToBoiler, boilerPackage) 770 | cc.ToGraphQL = strings.TrimPrefix(cc.ToGraphQL, boilerPackage) 771 | } 772 | 773 | cc.GraphTypeAsText = getGraphTypeAsText(graphType) 774 | cc.BoilerTypeAsText = getBoilerTypeAsText(boilType) 775 | 776 | return //nolint:nakedret 777 | } 778 | 779 | const boilerPackage = "boilergql." 780 | 781 | func getToBoiler(boilType, graphType string) string { 782 | return boilerPackage + getGraphTypeAsText(graphType) + "To" + getBoilerTypeAsText(boilType) 783 | } 784 | 785 | func getToGraphQL(boilType, graphType string) string { 786 | return boilerPackage + getBoilerTypeAsText(boilType) + "To" + getGraphTypeAsText(graphType) 787 | } 788 | 789 | func getBoilerTypeAsText(boilType string) string { 790 | // backward compatible missed Dot 791 | if strings.HasPrefix(boilType, "types.") { 792 | boilType = strings.TrimPrefix(boilType, "types.") 793 | boilType = strcase.ToCamel(boilType) 794 | boilType = "Types" + boilType 795 | } 796 | 797 | // if strings.HasPrefix(boilType, "null.") { 798 | // boilType = strings.TrimPrefix(boilType, "null.") 799 | // boilType = strcase.ToCamel(boilType) 800 | // boilType = "NullDot" + boilType 801 | // } 802 | boilType = strings.Replace(boilType, ".", "Dot", -1) 803 | 804 | return strcase.ToCamel(boilType) 805 | } 806 | 807 | func getGraphTypeAsText(graphType string) string { 808 | if strings.HasPrefix(graphType, "*") { 809 | graphType = strings.TrimPrefix(graphType, "*") 810 | graphType = strcase.ToCamel(graphType) 811 | graphType = "Pointer" + graphType 812 | } 813 | return strcase.ToCamel(graphType) 814 | } 815 | 816 | func FindBoilerModel(models []*structs.BoilerModel, modelName string) *structs.BoilerModel { 817 | for _, m := range models { 818 | if strings.ToLower(m.Name) == strings.ToLower(modelName) { 819 | return m 820 | } 821 | } 822 | return nil 823 | } 824 | -------------------------------------------------------------------------------- /sqlboiler_graphql_schema.go: -------------------------------------------------------------------------------- 1 | // TODO: needs big refactor 2 | 3 | package gbgen 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "strings" 11 | 12 | "github.com/web-ridge/gqlgen-sqlboiler/v3/structs" 13 | 14 | "github.com/rs/zerolog/log" 15 | "github.com/web-ridge/gqlgen-sqlboiler/v3/cache" 16 | 17 | "github.com/iancoleman/strcase" 18 | ) 19 | 20 | const ( 21 | indent = " " 22 | lineBreak = "\n" 23 | ) 24 | 25 | type SchemaConfig struct { 26 | BoilerCache *cache.BoilerCache 27 | Directives []string 28 | SkipInputFields []string 29 | GenerateBatchCreate bool 30 | GenerateMutations bool 31 | GenerateBatchDelete bool 32 | GenerateBatchUpdate bool 33 | HookShouldAddModel func(model SchemaModel) bool 34 | HookShouldAddField func(model SchemaModel, field SchemaField) bool 35 | HookChangeField func(model *SchemaModel, field *SchemaField) 36 | HookChangeFields func(model *SchemaModel, fields []*SchemaField, parenType ParentType) []*SchemaField 37 | HookChangeModel func(model *SchemaModel) 38 | } 39 | 40 | type SchemaGenerateConfig struct { 41 | MergeSchema bool 42 | } 43 | 44 | type SchemaModel struct { 45 | Name string 46 | IsView bool 47 | Fields []*SchemaField 48 | } 49 | 50 | type SchemaField struct { 51 | Name string 52 | Type string // String, ID, Integer 53 | InputWhereType string 54 | InputCreateType string 55 | InputUpdateType string 56 | InputBatchUpdateType string 57 | InputBatchCreateType string 58 | BoilerField *structs.BoilerField 59 | SkipInput bool 60 | SkipWhere bool 61 | SkipCreate bool 62 | SkipUpdate bool 63 | SkipBatchUpdate bool 64 | SkipBatchCreate bool 65 | InputDirectives []string 66 | Directives []string 67 | } 68 | 69 | func NewSchemaField(name string, typ string, boilerField *structs.BoilerField) *SchemaField { 70 | return &SchemaField{ 71 | Name: name, 72 | Type: typ, 73 | InputWhereType: typ, 74 | InputCreateType: typ, 75 | InputUpdateType: typ, 76 | InputBatchUpdateType: typ, 77 | InputBatchCreateType: typ, 78 | BoilerField: boilerField, 79 | } 80 | } 81 | 82 | func (s *SchemaField) SetInputTypeForAllInputs(v string) { 83 | s.InputWhereType = v 84 | s.InputCreateType = v 85 | s.InputUpdateType = v 86 | s.InputBatchUpdateType = v 87 | s.InputBatchCreateType = v 88 | } 89 | 90 | func (s *SchemaField) SetSkipForAllInputs(v bool) { 91 | s.SkipInput = v 92 | s.SkipWhere = v 93 | s.SkipCreate = v 94 | s.SkipUpdate = v 95 | s.SkipBatchUpdate = v 96 | s.SkipBatchCreate = v 97 | } 98 | 99 | type ParentType string 100 | 101 | const ( 102 | ParentTypeNormal ParentType = "Normal" 103 | ParentTypeWhere ParentType = "Where" 104 | ParentTypeCreate ParentType = "Create" 105 | ParentTypeUpdate ParentType = "Update" 106 | ParentTypeBatchUpdate ParentType = "BatchUpdate" 107 | ParentTypeBatchCreate ParentType = "BatchCreate" 108 | ) 109 | 110 | func SchemaWrite(config SchemaConfig, outputFile string, generateOptions SchemaGenerateConfig) error { 111 | // Generate schema based on config 112 | schema := SchemaGet(config) 113 | 114 | // TODO: Write schema to the configured location 115 | if fileExists(outputFile) && generateOptions.MergeSchema { 116 | if err := mergeContentInFile(schema, outputFile); err != nil { 117 | log.Err(err).Msg("Could not write schema to disk") 118 | return err 119 | } 120 | } else { 121 | log.Debug().Int("bytes", len(schema)).Str("file", outputFile).Msg("write GraphQL schema to disk") 122 | if err := writeContentToFile(schema, outputFile); err != nil { 123 | log.Err(err).Msg("Could not write schema to disk") 124 | return err 125 | } 126 | log.Debug().Msg("formatting GraphQL schema") 127 | 128 | err := formatFile(outputFile) 129 | log.Debug().Msg("formatted GraphQL schema") 130 | return err 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func getDirectivesAsString(va []string) string { 137 | a := make([]string, len(va)) 138 | for i, v := range va { 139 | a[i] = "@" + v 140 | } 141 | return strings.Join(a, " ") 142 | } 143 | 144 | //nolint:gocognit,gocyclo 145 | func SchemaGet( 146 | config SchemaConfig, 147 | ) string { 148 | w := &SimpleWriter{} 149 | 150 | // Parse structs and their fields based on the sqlboiler model directory 151 | 152 | models := executeHooksOnModels(boilerModelsToModels(config.BoilerCache.BoilerModels), config) 153 | 154 | fullDirectives := make([]string, len(config.Directives)) 155 | for i, defaultDirective := range config.Directives { 156 | fullDirectives[i] = "@" + defaultDirective 157 | w.l(fmt.Sprintf("directive @%v on FIELD_DEFINITION", defaultDirective)) 158 | } 159 | w.br() 160 | 161 | joinedDirectives := strings.Join(fullDirectives, " ") 162 | 163 | w.l(`schema {`) 164 | w.tl(`query: Query`) 165 | if config.GenerateMutations { 166 | w.tl(`mutation: Mutation`) 167 | } 168 | w.l(`}`) 169 | 170 | w.br() 171 | 172 | w.l(`interface Node {`) 173 | w.tl(`id: ID!`) 174 | w.l(`}`) 175 | 176 | w.br() 177 | 178 | w.l(`type PageInfo {`) 179 | w.tl(`hasNextPage: Boolean!`) 180 | w.tl(`hasPreviousPage: Boolean!`) 181 | w.tl(`startCursor: String`) 182 | w.tl(`endCursor: String`) 183 | w.l(`}`) 184 | 185 | w.br() 186 | 187 | // Add helpers for filtering lists 188 | w.l(queryHelperStructs) 189 | 190 | for _, enum := range config.BoilerCache.BoilerEnums { 191 | 192 | // enum UserRoleFilter { ADMIN, USER } 193 | w.l(fmt.Sprintf(enumFilterHelper, enum.Name)) 194 | 195 | // enum UserRole { ADMIN, USER } 196 | w.l("enum " + enum.Name + " {") 197 | for _, v := range enum.Values { 198 | w.tl(strcase.ToScreamingSnake(strings.TrimPrefix(v.Name, enum.Name))) 199 | } 200 | w.l("}") 201 | 202 | w.br() 203 | } 204 | 205 | // Generate sorting helpers 206 | w.l("enum SortDirection { ASC, DESC }") 207 | w.br() 208 | 209 | for _, model := range models { 210 | // enum UserSort { FIRST_NAME, LAST_NAME } 211 | w.l("enum " + model.Name + "Sort {") 212 | for _, v := range fieldAsEnumStrings(model.Fields) { 213 | w.tl(v) 214 | } 215 | w.l("}") 216 | 217 | w.br() 218 | 219 | // input UserOrdering { 220 | // sort: UserSort! 221 | // direction: SortDirection! = ASC 222 | // } 223 | w.l("input " + model.Name + "Ordering {") 224 | w.tl("sort: " + model.Name + "Sort!") 225 | w.tl("direction: SortDirection! = ASC") 226 | w.l("}") 227 | 228 | w.br() 229 | 230 | // Create basic structs e.g. 231 | // type User { 232 | // firstName: String! 233 | // lastName: String 234 | // isProgrammer: Boolean! 235 | // organization: Organization! 236 | // } 237 | 238 | w.l("type " + model.Name + " implements Node {") 239 | 240 | for _, field := range enhanceFields(config, model, model.Fields, ParentTypeNormal) { 241 | directives := getDirectivesAsString(field.Directives) 242 | // e.g we have foreign key from user to organization 243 | // organizationID is clutter in your scheme 244 | // you only want Organization and OrganizationID should be skipped 245 | if field.BoilerField.IsRelation { 246 | w.tl( 247 | getRelationName(field) + ": " + 248 | getFinalFullTypeWithRelation(field, ParentTypeNormal) + directives, 249 | ) 250 | } else { 251 | fullType := getFinalFullType(field, ParentTypeNormal) 252 | w.tl(field.Name + ": " + fullType + directives) 253 | } 254 | } 255 | w.l("}") 256 | 257 | w.br() 258 | 259 | //type UserEdge { 260 | // cursor: String! 261 | // node: User 262 | //} 263 | 264 | w.l("type " + model.Name + "Edge {") 265 | 266 | w.tl(`cursor: String!`) 267 | w.tl(`node: ` + model.Name) 268 | w.l("}") 269 | 270 | w.br() 271 | 272 | //type UserConnection { 273 | // edges: [UserEdge] 274 | // pageInfo: PageInfo! 275 | //} 276 | 277 | w.l("type " + model.Name + "Connection {") 278 | w.tl(`edges: [` + model.Name + `Edge]`) 279 | w.tl(`pageInfo: PageInfo!`) 280 | w.l("}") 281 | 282 | w.br() 283 | 284 | // generate filter structs per model 285 | 286 | // Ignore some specified input fields 287 | // Generate a type safe grapql filter 288 | 289 | // Generate the base filter 290 | // type UserFilter { 291 | // search: String 292 | // where: UserWhere 293 | // } 294 | w.l("input " + model.Name + "Filter {") 295 | w.tl("search: String") 296 | w.tl("where: " + model.Name + "Where") 297 | w.l("}") 298 | 299 | w.br() 300 | 301 | // Generate a where struct 302 | // type UserWhere { 303 | // id: IDFilter 304 | // title: StringFilter 305 | // organization: OrganizationWhere 306 | // or: FlowBlockWhere 307 | // and: FlowBlockWhere 308 | // } 309 | w.l("input " + model.Name + "Where {") 310 | 311 | for _, field := range enhanceFields(config, model, model.Fields, ParentTypeWhere) { 312 | if field.SkipInput || field.SkipWhere { 313 | continue 314 | } 315 | directives := getDirectivesAsString(field.InputDirectives) 316 | if field.BoilerField.IsRelation { 317 | 318 | // Support filtering in relationships (at least schema wise) 319 | relationName := getRelationName(field) 320 | w.tl(relationName + ": " + field.BoilerField.Relationship.Name + "Where" + directives) 321 | } else { 322 | w.tl(field.Name + ": " + getFilterType(field) + "Filter" + directives) 323 | } 324 | } 325 | w.tl("withDeleted: Boolean") 326 | w.tl("or: " + model.Name + "Where") 327 | w.tl("and: " + model.Name + "Where") 328 | w.l("}") 329 | 330 | w.br() 331 | } 332 | 333 | w.l("type Query {") 334 | w.tl("node(id: ID!): Node" + joinedDirectives) 335 | 336 | for _, model := range models { 337 | // single structs 338 | w.tl(strcase.ToLowerCamel(model.Name) + "(id: ID!): " + model.Name + "!" + joinedDirectives) 339 | 340 | // lists 341 | modelPluralName := cache.Plural(model.Name) 342 | 343 | arguments := []string{ 344 | "first: Int!", 345 | "after: String", 346 | "ordering: [" + model.Name + "Ordering!]", 347 | "filter: " + model.Name + "Filter", 348 | } 349 | w.tl( 350 | strcase.ToLowerCamel(modelPluralName) + "(" + strings.Join(arguments, ", ") + "): " + 351 | model.Name + "Connection!" + joinedDirectives) 352 | } 353 | w.l("}") 354 | 355 | w.br() 356 | 357 | // Generate input and payloads for mutations 358 | if config.GenerateMutations { //nolint:nestif 359 | for _, model := range models { 360 | if model.IsView { 361 | continue 362 | } 363 | 364 | filteredFields := fieldsWithout(model.Fields, config.SkipInputFields) 365 | 366 | modelPluralName := cache.Plural(model.Name) 367 | // input UserCreateInput { 368 | // firstName: String! 369 | // lastName: String 370 | // organizationId: ID! 371 | // } 372 | w.l("input " + model.Name + "CreateInput {") 373 | 374 | for _, field := range enhanceFields(config, model, filteredFields, ParentTypeCreate) { 375 | if field.SkipInput || field.SkipCreate { 376 | continue 377 | } 378 | // id is not required in create and will be specified in update resolver 379 | if field.Name == "id" { 380 | continue 381 | } 382 | // not possible yet in input 383 | // TODO: make this possible for one-to-one structs? 384 | // only for foreign keys inside model itself 385 | if field.BoilerField.IsRelation && field.BoilerField.IsArray || 386 | field.BoilerField.IsRelation && !strings.HasSuffix(field.BoilerField.Name, "ID") { 387 | continue 388 | } 389 | directives := getDirectivesAsString(field.InputDirectives) 390 | fullType := getFinalFullType(field, ParentTypeCreate) 391 | w.tl(field.Name + ": " + fullType + directives) 392 | } 393 | w.l("}") 394 | 395 | w.br() 396 | 397 | // input UserUpdateInput { 398 | // firstName: String! 399 | // lastName: String 400 | // organizationId: ID! 401 | // } 402 | w.l("input " + model.Name + "UpdateInput {") 403 | 404 | for _, field := range enhanceFields(config, model, filteredFields, ParentTypeUpdate) { 405 | if field.SkipInput || field.SkipUpdate { 406 | continue 407 | } 408 | // id is not required in create and will be specified in update resolver 409 | if field.Name == "id" { 410 | continue 411 | } 412 | // not possible yet in input 413 | // TODO: make this possible for one-to-one structs? 414 | // only for foreign keys inside model itself 415 | if field.BoilerField.IsRelation && field.BoilerField.IsArray || 416 | field.BoilerField.IsRelation && !strings.HasSuffix(field.BoilerField.Name, "ID") { 417 | continue 418 | } 419 | directives := getDirectivesAsString(field.InputDirectives) 420 | w.tl(field.Name + ": " + getFinalFullType(field, ParentTypeUpdate) + directives) 421 | } 422 | w.l("}") 423 | 424 | w.br() 425 | 426 | if config.GenerateBatchCreate { 427 | w.l("input " + modelPluralName + "CreateInput {") 428 | 429 | w.tl(strcase.ToLowerCamel(modelPluralName) + ": [" + model.Name + "CreateInput!]!") 430 | w.l("}") 431 | 432 | w.br() 433 | } 434 | 435 | // if batchUpdate { 436 | // w.l("input " + modelPluralName + "UpdateInput {") 437 | // w.tl(strcase.ToLowerCamel(modelPluralName) + ": [" + model.Name + "UpdateInput!]!") 438 | // w.l("}") 439 | // w.br() 440 | // } 441 | 442 | // type UserPayload { 443 | // user: User! 444 | // } 445 | w.l("type " + model.Name + "Payload {") 446 | w.tl(strcase.ToLowerCamel(model.Name) + ": " + model.Name + "!") 447 | w.l("}") 448 | 449 | w.br() 450 | 451 | // TODO batch, delete input and payloads 452 | 453 | // type UserDeletePayload { 454 | // id: ID! 455 | // } 456 | w.l("type " + model.Name + "DeletePayload {") 457 | w.tl("id: ID!") 458 | w.l("}") 459 | 460 | w.br() 461 | 462 | // type UsersPayload { 463 | // users: [User!]! 464 | // } 465 | if config.GenerateBatchCreate { 466 | w.l("type " + modelPluralName + "Payload {") 467 | w.tl(strcase.ToLowerCamel(modelPluralName) + ": [" + model.Name + "!]!") 468 | w.l("}") 469 | 470 | w.br() 471 | } 472 | 473 | // type UsersDeletePayload { 474 | // ids: [ID!]! 475 | // } 476 | if config.GenerateBatchDelete { 477 | w.l("type " + modelPluralName + "DeletePayload {") 478 | w.tl("ids: [ID!]!") 479 | w.l("}") 480 | 481 | w.br() 482 | } 483 | // type UsersUpdatePayload { 484 | // ok: Boolean! 485 | // } 486 | if config.GenerateBatchUpdate { 487 | w.l("type " + modelPluralName + "UpdatePayload {") 488 | w.tl("ok: Boolean!") 489 | w.l("}") 490 | 491 | w.br() 492 | } 493 | } 494 | 495 | // Generate mutation queries 496 | w.l("type Mutation {") 497 | 498 | for _, model := range models { 499 | if model.IsView { 500 | continue 501 | } 502 | modelPluralName := cache.Plural(model.Name) 503 | 504 | // create single 505 | // e.g createUser(input: UserInput!): UserPayload! 506 | w.tl("create" + model.Name + "(input: " + model.Name + "CreateInput!): " + 507 | model.Name + "Payload!" + joinedDirectives) 508 | 509 | // create multiple 510 | // e.g createUsers(input: [UsersInput!]!): UsersPayload! 511 | if config.GenerateBatchCreate { 512 | w.tl("create" + modelPluralName + "(input: " + modelPluralName + "CreateInput!): " + 513 | modelPluralName + "Payload!" + joinedDirectives) 514 | } 515 | 516 | // update single 517 | // e.g updateUser(id: ID!, input: UserInput!): UserPayload! 518 | w.tl("update" + model.Name + "(id: ID!, input: " + model.Name + "UpdateInput!): " + 519 | model.Name + "Payload!" + joinedDirectives) 520 | 521 | // update multiple (batch update) 522 | // e.g updateUsers(filter: UserFilter, input: UsersInput!): UsersPayload! 523 | if config.GenerateBatchUpdate { 524 | w.tl("update" + modelPluralName + "(filter: " + model.Name + "Filter, input: " + 525 | model.Name + "UpdateInput!): " + modelPluralName + "UpdatePayload!" + joinedDirectives) 526 | } 527 | 528 | // delete single 529 | // e.g deleteUser(id: ID!): UserPayload! 530 | w.tl("delete" + model.Name + "(id: ID!): " + model.Name + "DeletePayload!" + joinedDirectives) 531 | 532 | // delete multiple 533 | // e.g deleteUsers(filter: UserFilter, input: [UsersInput!]!): UsersPayload! 534 | if config.GenerateBatchDelete { 535 | w.tl("delete" + modelPluralName + "(filter: " + model.Name + "Filter): " + 536 | modelPluralName + "DeletePayload!" + joinedDirectives) 537 | } 538 | } 539 | w.l("}") 540 | 541 | w.br() 542 | } 543 | 544 | return w.s.String() 545 | } 546 | 547 | func getFilterType(field *SchemaField) string { 548 | boilerType := field.BoilerField.Type 549 | if boilerType == "null.Time" || boilerType == "time.Time" { 550 | return "TimeUnix" 551 | } 552 | return field.Type 553 | } 554 | 555 | func enhanceFields(config SchemaConfig, model *SchemaModel, fields []*SchemaField, parentType ParentType) []*SchemaField { 556 | if config.HookChangeFields != nil { 557 | return config.HookChangeFields(model, fields, parentType) 558 | } 559 | return fields 560 | } 561 | 562 | func fieldAsEnumStrings(fields []*SchemaField) []string { 563 | var enums []string 564 | for _, field := range fields { 565 | if field.BoilerField != nil && (!field.BoilerField.IsRelation && !field.BoilerField.IsForeignKey) { 566 | enums = append(enums, strcase.ToScreamingSnake(field.Name)) 567 | } 568 | } 569 | return enums 570 | } 571 | 572 | func getFullType(fieldType string, isArray bool, isRequired bool) string { 573 | gType := fieldType 574 | 575 | if isArray { 576 | // To use a list type, surround the type in square brackets, so [Int] is a list of integers. 577 | gType = "[" + gType + "!]" 578 | } 579 | if isRequired { 580 | // Use an exclamation point to indicate a type cannot be nullable, 581 | // so String! is a non-nullable string. 582 | gType += "!" 583 | } 584 | return gType 585 | } 586 | 587 | func boilerModelsToModels(boilerModels []*structs.BoilerModel) []*SchemaModel { 588 | a := make([]*SchemaModel, len(boilerModels)) 589 | for i, boilerModel := range boilerModels { 590 | a[i] = &SchemaModel{ 591 | Name: boilerModel.Name, 592 | Fields: boilerFieldsToFields(boilerModel.Fields), 593 | IsView: boilerModel.IsView, 594 | } 595 | } 596 | return a 597 | } 598 | 599 | // executeHooksOnModels removes structs and fields which the user hooked in into + it can change values 600 | func executeHooksOnModels(models []*SchemaModel, config SchemaConfig) []*SchemaModel { 601 | var a []*SchemaModel 602 | for _, m := range models { 603 | if config.HookShouldAddModel != nil && !config.HookShouldAddModel(*m) { 604 | continue 605 | } 606 | var af []*SchemaField 607 | for _, f := range m.Fields { 608 | if config.HookShouldAddField != nil && !config.HookShouldAddField(*m, *f) { 609 | continue 610 | } 611 | if config.HookChangeField != nil { 612 | config.HookChangeField(m, f) 613 | } 614 | af = append(af, f) 615 | } 616 | m.Fields = af 617 | if config.HookChangeModel != nil { 618 | config.HookChangeModel(m) 619 | } 620 | 621 | a = append(a, m) 622 | 623 | } 624 | return a 625 | } 626 | 627 | func boilerFieldsToFields(boilerFields []*structs.BoilerField) []*SchemaField { 628 | fields := make([]*SchemaField, len(boilerFields)) 629 | for i, boilerField := range boilerFields { 630 | fields[i] = boilerFieldToField(boilerField) 631 | } 632 | return fields 633 | } 634 | 635 | func getRelationName(schemaField *SchemaField) string { 636 | return strcase.ToLowerCamel(schemaField.BoilerField.RelationshipName) 637 | } 638 | 639 | func getAlwaysOptional(parentType ParentType) bool { 640 | return parentType == ParentTypeUpdate || parentType == ParentTypeWhere || parentType == ParentTypeBatchUpdate 641 | } 642 | 643 | func getFinalFullTypeWithRelation(schemaField *SchemaField, parentType ParentType) string { 644 | boilerField := schemaField.BoilerField 645 | alwaysOptional := getAlwaysOptional(parentType) 646 | 647 | if boilerField.Relationship != nil { 648 | relationType := boilerField.Relationship.Name 649 | if alwaysOptional { 650 | return getFullType( 651 | relationType, 652 | boilerField.IsArray, 653 | false, 654 | ) 655 | } 656 | return getFullType( 657 | relationType, 658 | boilerField.IsArray, 659 | boilerField.IsRequired, 660 | ) 661 | } 662 | return getFinalFullType(schemaField, parentType) 663 | } 664 | 665 | func getFinalFullType(schemaField *SchemaField, parentType ParentType) string { 666 | alwaysOptional := getAlwaysOptional(parentType) 667 | boilerField := schemaField.BoilerField 668 | isRequired := boilerField.IsRequired 669 | if alwaysOptional { 670 | isRequired = false 671 | } 672 | 673 | return getFullType(getFieldType(schemaField, parentType), boilerField.IsArray, isRequired) 674 | } 675 | 676 | func getFieldType(schemaField *SchemaField, parentType ParentType) string { 677 | switch parentType { 678 | case ParentTypeNormal: 679 | return schemaField.Type 680 | case ParentTypeWhere: 681 | return schemaField.InputWhereType 682 | case ParentTypeCreate: 683 | return schemaField.InputCreateType 684 | case ParentTypeUpdate: 685 | return schemaField.InputUpdateType 686 | case ParentTypeBatchUpdate: 687 | return schemaField.InputBatchUpdateType 688 | case ParentTypeBatchCreate: 689 | return schemaField.InputBatchCreateType 690 | default: 691 | return "" 692 | } 693 | } 694 | 695 | func boilerFieldToField(boilerField *structs.BoilerField) *SchemaField { 696 | t := toGraphQLType(boilerField) 697 | return NewSchemaField(toGraphQLName(boilerField.Name), t, boilerField) 698 | } 699 | 700 | func toGraphQLName(fieldName string) string { 701 | graphqlName := fieldName 702 | 703 | // Golang ID to Id the right way 704 | // Primary key 705 | if graphqlName == "ID" { 706 | graphqlName = "id" 707 | } 708 | 709 | if graphqlName == "URL" { 710 | graphqlName = "url" 711 | } 712 | 713 | // e.g. OrganizationID, TODO: more robust solution? 714 | graphqlName = strings.Replace(graphqlName, "ID", "Id", -1) 715 | graphqlName = strings.Replace(graphqlName, "URL", "Url", -1) 716 | 717 | return strcase.ToLowerCamel(graphqlName) 718 | } 719 | 720 | func toGraphQLType(boilerField *structs.BoilerField) string { 721 | lowerBoilerType := strings.ToLower(boilerField.Type) 722 | 723 | if boilerField.IsEnum { 724 | return boilerField.Enum.Name 725 | } 726 | 727 | if strings.HasSuffix(boilerField.Name, "ID") { 728 | return "ID" 729 | } 730 | if strings.Contains(lowerBoilerType, "string") { 731 | return "String" 732 | } 733 | if strings.Contains(lowerBoilerType, "int") { 734 | return "Int" 735 | } 736 | if strings.Contains(lowerBoilerType, "byte") { 737 | return "String" 738 | } 739 | if strings.Contains(lowerBoilerType, "decimal") || strings.Contains(lowerBoilerType, "float") { 740 | return "Float" 741 | } 742 | if strings.Contains(lowerBoilerType, "bool") { 743 | return "Boolean" 744 | } 745 | 746 | // TODO: make this a scalar or something configurable? 747 | // I like to use unix here 748 | // make sure TimeUnixFilter keeps working 749 | if strings.Contains(lowerBoilerType, "time") { 750 | return "Int" 751 | } 752 | 753 | // e.g. null.JSON let user define how it looks with their own struct 754 | return strcase.ToCamel(boilerField.Name) 755 | } 756 | 757 | func fieldsWithout(fields []*SchemaField, skipFieldNames []string) []*SchemaField { 758 | var filteredFields []*SchemaField 759 | for _, field := range fields { 760 | if !cache.SliceContains(skipFieldNames, field.Name) { 761 | filteredFields = append(filteredFields, field) 762 | } 763 | } 764 | return filteredFields 765 | } 766 | 767 | func mergeContentInFile(content, outputFile string) error { 768 | baseFile := filenameWithoutExtension(outputFile) + 769 | "-empty" + 770 | getFilenameExtension(outputFile) 771 | 772 | newOutputFile := filenameWithoutExtension(outputFile) + 773 | "-new" + 774 | getFilenameExtension(outputFile) 775 | 776 | // remove previous files if exist 777 | _ = os.Remove(baseFile) 778 | _ = os.Remove(newOutputFile) 779 | 780 | if err := writeContentToFile(content, newOutputFile); err != nil { 781 | return fmt.Errorf("could not write schema to disk: %v", err) 782 | } 783 | //if err := formatFile(outputFile); err != nil { 784 | // return fmt.Errorf("could not format with prettier %v", err) 785 | //} 786 | //if err := formatFile(newOutputFile); err != nil { 787 | // return fmt.Errorf("could not format with prettier%v", err) 788 | //} 789 | 790 | // Three way merging done based on this answer 791 | // https://stackoverflow.com/a/9123563/2508481 792 | 793 | // Empty file as base per the stackoverflow answer 794 | name := "touch" 795 | args := []string{baseFile} 796 | out, err := exec.Command(name, args...).Output() 797 | if err != nil { 798 | log.Err(err).Str("name", name).Str("args", strings.Join(args, " ")).Msg("merging failed") 799 | return fmt.Errorf("merging failed %v: %v", err, out) 800 | } 801 | 802 | // Let's do the merge 803 | name = "git" 804 | args = []string{"merge-file", outputFile, baseFile, newOutputFile} 805 | out, err = exec.Command(name, args...).Output() 806 | if err != nil { 807 | log.Err(err).Str("name", name).Str("args", strings.Join(args, " ")).Msg("executing command failed") 808 | 809 | // remove base file 810 | _ = os.Remove(baseFile) 811 | return fmt.Errorf("merging failed or had conflicts %v: %v", err, out) 812 | } 813 | log.Info().Msg("merging done without conflicts") 814 | 815 | // remove files 816 | _ = os.Remove(baseFile) 817 | _ = os.Remove(newOutputFile) 818 | return nil 819 | } 820 | 821 | func getFilenameExtension(fn string) string { 822 | return path.Ext(fn) 823 | } 824 | 825 | func filenameWithoutExtension(fn string) string { 826 | return strings.TrimSuffix(fn, path.Ext(fn)) 827 | } 828 | 829 | func formatFile(filename string) error { 830 | name := "prettier" 831 | args := []string{filename, "--write"} 832 | 833 | cmd := exec.Command(name, args...) 834 | cmd.Stdout = os.Stdout 835 | cmd.Stderr = os.Stderr 836 | err := cmd.Run() 837 | if err != nil { 838 | return fmt.Errorf("executing command: '%v %v' failed with: %v", name, strings.Join(args, " "), err) 839 | } 840 | 841 | // fmt.Println(fmt.Sprintf("Formatting of %v done", filename)) 842 | return nil 843 | } 844 | 845 | func writeContentToFile(content string, filename string) error { 846 | file, err := os.Create(filename) 847 | if err != nil { 848 | return fmt.Errorf("could not write %v to disk: %v", filename, err) 849 | } 850 | 851 | // Close file if this functions returns early or at the end 852 | defer func() { 853 | closeErr := file.Close() 854 | if closeErr != nil { 855 | log.Err(closeErr).Msg("error while closing file") 856 | } 857 | }() 858 | 859 | if _, err := file.WriteString(content); err != nil { 860 | return fmt.Errorf("could not write content to file %v: %v", filename, err) 861 | } 862 | 863 | return nil 864 | } 865 | 866 | type SimpleWriter struct { 867 | s strings.Builder 868 | } 869 | 870 | func (sw *SimpleWriter) l(v string) { 871 | sw.s.WriteString(v + lineBreak) 872 | } 873 | 874 | func (sw *SimpleWriter) br() { 875 | sw.s.WriteString(lineBreak) 876 | } 877 | 878 | func (sw *SimpleWriter) tl(v string) { 879 | sw.s.WriteString(indent + v + lineBreak) 880 | } 881 | 882 | const enumFilterHelper = ` 883 | input %[1]vFilter { 884 | isNull: Boolean 885 | notNull: Boolean 886 | 887 | equalTo: %[1]v 888 | notEqualTo: %[1]v 889 | 890 | in: [%[1]v!] 891 | notIn: [%[1]v!] 892 | } 893 | ` 894 | 895 | // TODO: only generate these if they are set 896 | const queryHelperStructs = ` 897 | input IDFilter { 898 | isNull: Boolean 899 | notNull: Boolean 900 | equalTo: ID 901 | notEqualTo: ID 902 | in: [ID!] 903 | notIn: [ID!] 904 | } 905 | 906 | input StringFilter { 907 | isNullOrEmpty: Boolean 908 | isEmpty: Boolean 909 | isNull: Boolean 910 | notNullOrEmpty: Boolean 911 | notEmpty: Boolean 912 | notNull: Boolean 913 | equalTo: String 914 | notEqualTo: String 915 | 916 | in: [String!] 917 | notIn: [String!] 918 | 919 | startWith: String 920 | notStartWith: String 921 | 922 | endWith: String 923 | notEndWith: String 924 | 925 | contain: String 926 | notContain: String 927 | 928 | startWithStrict: String # Camel sensitive 929 | notStartWithStrict: String # Camel sensitive 930 | 931 | endWithStrict: String # Camel sensitive 932 | notEndWithStrict: String # Camel sensitive 933 | 934 | containStrict: String # Camel sensitive 935 | notContainStrict: String # Camel sensitive 936 | } 937 | 938 | input IntFilter { 939 | isNullOrZero: Boolean 940 | isNull: Boolean 941 | notNullOrZero: Boolean 942 | notNull: Boolean 943 | equalTo: Int 944 | notEqualTo: Int 945 | lessThan: Int 946 | lessThanOrEqualTo: Int 947 | moreThan: Int 948 | moreThanOrEqualTo: Int 949 | in: [Int!] 950 | notIn: [Int!] 951 | } 952 | 953 | input TimeUnixFilter { 954 | isNullOrZero: Boolean 955 | isNull: Boolean 956 | notNullOrZero: Boolean 957 | notNull: Boolean 958 | equalTo: Int 959 | notEqualTo: Int 960 | lessThan: Int 961 | lessThanOrEqualTo: Int 962 | moreThan: Int 963 | moreThanOrEqualTo: Int 964 | } 965 | 966 | input FloatFilter { 967 | isNullOrZero: Boolean 968 | isNull: Boolean 969 | notNullOrZero: Boolean 970 | notNull: Boolean 971 | equalTo: Float 972 | notEqualTo: Float 973 | lessThan: Float 974 | lessThanOrEqualTo: Float 975 | moreThan: Float 976 | moreThanOrEqualTo: Float 977 | in: [Float!] 978 | notIn: [Float!] 979 | } 980 | 981 | input BooleanFilter { 982 | isNull: Boolean 983 | notNull: Boolean 984 | equalTo: Boolean 985 | notEqualTo: Boolean 986 | } 987 | ` 988 | --------------------------------------------------------------------------------