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.
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.
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.
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).
It's really amazing how fast a generated api with these techniques is!
Create folder convert/convert.go with the following content:
See example of convert.go
run go mod tidy in convert/
Make sure you have followed the prerequisites
(cd convert && go run convert.go)- schema.graphql based on sqlboiler structs
- converts between sqlboiler and gqlgen
- connections / edges / filtering / ordering / sorting
- three-way-merge schema re-generate
- converts between input models and sqlboiler
- understands the difference between empty and null in update input
- sqlboiler preloads from graphql context
- foreign keys and relations
- resolvers based on queries/mutations in schema
- one-to-one relationships inside input types.
- batch update/delete generation in resolvers.
- enum support (only in graphql schema right now).
- public errors in resolvers + logging via zerolog.
- overriding convert functions
- custom scope resolvers e.g userId, organizationId
- Support gqlgen multiple .graphql files
- Batch create helpers for sqlboiler and integration batch create inputs
- Support overriding resolvers
- Adding automatic database migrations and integration with web-ridge/dbifier
- 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.
- Support more relationships inside input types
Checkout our examples to see the generated schema.grapql, converts and resolvers.
web-ridge/gqlgen-sqlboiler-examples
func PostToGraphQL(m *models.Post) *graphql_models.Post {
if m == nil {
return nil
}
r := &graphql_models.Post{
ID: PostIDToGraphQL(m.ID),
Content: m.Content,
}
if boilergql.UintIsFilled(m.UserID) {
if m.R != nil && m.R.User != nil {
r.User = UserToGraphQL(m.R.User)
} else {
r.User = UserWithUintID(m.UserID)
}
}
if m.R != nil && m.R.Comments != nil {
r.Comments = CommentsToGraphQL(m.R.Comments)
}
if m.R != nil && m.R.Images != nil {
r.Images = ImagesToGraphQL(m.R.Images)
}
if m.R != nil && m.R.Likes != nil {
r.Likes = LikesToGraphQL(m.R.Likes)
}
return r
}mysql:
dbname: dbname
host: localhost
port: 8889
user: root
pass: root
sslmode: "false"
blacklist:
- notifications
- jobs
- password_resets
- migrations
mysqldump:
column-statistics: 0schema:
- *.graphql
exec:
filename: models/fm/generated.go
package: fm
model:
filename: models/fm/generated_models.go
package: fm
models:
ConnectionBackwardPagination:
model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionBackwardPagination
ConnectionForwardPagination:
model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionForwardPagination
ConnectionPagination:
model: github.com/web-ridge/utils-go/boilergql/v3.ConnectionPagination
SortDirection:
model: github.com/web-ridge/utils-go/boilergql/v3.SortDirectionpackage resolvers
import (
"database/sql"
)
type Resolver struct {
db *sql.DB
// you can add more here
}
func NewResolver(db *sql.DB) *Resolver {
return &Resolver{
db: db,
// you can add more here
}
}Put something like the code below in file convert/convert.go
package main
import (
"github.com/99designs/gqlgen/codegen/config"
"github.com/rs/zerolog/log"
gbgen "github.com/web-ridge/gqlgen-sqlboiler/v3"
"github.com/web-ridge/gqlgen-sqlboiler/v3/cache"
"github.com/web-ridge/gqlgen-sqlboiler/v3/structs"
"os"
"os/exec"
"strings"
)
func main() {
// change working directory to parent directory where all configs are located
newDir, _ := os.Getwd()
os.Chdir(strings.TrimSuffix(newDir, "/convert"))
enableSoftDeletes := true
boilerArgs := []string{"mysql", "--no-back-referencing", "--wipe", "-d"}
if enableSoftDeletes {
boilerArgs = append(boilerArgs, "--add-soft-deletes")
}
cmd := exec.Command("sqlboiler", boilerArgs...)
err := cmd.Run()
if err != nil {
log.Fatal().Err(err).Str("command", cmd.String()).Msg("error generating dm models running sql-boiler")
}
output := structs.Config{
Directory: "helpers", // supports root or sub directories
PackageName: "helpers",
}
backend := structs.Config{
Directory: "models/dm",
PackageName: "dm",
}
frontend := structs.Config{
Directory: "models/fm",
PackageName: "fm",
}
boilerCache := cache.InitializeBoilerCache(backend)
generateSchema := true
generatedSchema := !generateSchema
if generateSchema {
if err := gbgen.SchemaWrite(
gbgen.SchemaConfig{
BoilerCache: boilerCache,
Directives: []string{"isAuthenticated"},
SkipInputFields: []string{"createdAt", "updatedAt", "deletedAt"},
GenerateMutations: true,
GenerateBatchCreate: false,
GenerateBatchDelete: false,
GenerateBatchUpdate: false,
HookShouldAddModel: func(model gbgen.SchemaModel) bool {
if model.Name == "Config" {
return false
}
return true
},
HookChangeFields: func(model *gbgen.SchemaModel, fields []*gbgen.SchemaField, parenType gbgen.ParentType) []*gbgen.SchemaField {
//profile: UserPayload! @isAuthenticated
return fields
},
HookChangeField: func(model *gbgen.SchemaModel, field *gbgen.SchemaField) {
//"userId", "userOrganizationId",
if field.Name == "userId" && model.Name != "UserUserOrganization" {
field.SkipInput = true
}
if field.Name == "userOrganizationId" && model.Name != "UserUserOrganization" {
field.SkipInput = true
}
},
},
"../frontend/schema.graphql",
gbgen.SchemaGenerateConfig{
MergeSchema: false,
},
); err != nil {
log.Fatal().Err(err).Msg("error generating schema")
}
generatedSchema = true
}
if generatedSchema {
cfg, err := config.LoadConfigFromDefaultLocations()
if err != nil {
log.Fatal().Err(err).Msg("error loading config")
}
data, err := gbgen.NewModelPlugin().GenerateCode(cfg)
if err != nil {
log.Fatal().Err(err).Msg("error generating graphql models using gqlgen")
}
modelCache := cache.InitializeModelCache(cfg, boilerCache, output, backend, frontend)
if err := gbgen.NewConvertPlugin(
modelCache,
gbgen.ConvertPluginConfig{
DatabaseDriver: gbgen.MySQL,
//Searchable: {
// Company: {
// Column: dm.CompanyColumns.Name
// },
//},
},
).GenerateCode(); err != nil {
log.Fatal().Err(err).Msg("error while generating convert/filters")
}
if err := gbgen.NewResolverPlugin(
config.ResolverConfig{
Filename: "resolvers/all_generated_resolvers.go",
Package: "resolvers",
Type: "Resolver",
},
output,
boilerCache,
modelCache,
gbgen.ResolverPluginConfig{
EnableSoftDeletes: enableSoftDeletes,
// Authorization scopes can be used to override e.g. userId, organizationId, tenantId
// This will be resolved used the provided ScopeResolverName if the result of the AddTrigger=true
// You would need this if you don't want to require these fields in your schema but you want to add them
// to the db model.
// If you do have these fields in your schema but want them authorized you could use a gqlgen directive
AuthorizationScopes: []*gbgen.AuthorizationScope{},
// {
// ImportPath: "github.com/my-repo/app/backend/auth",
// ImportAlias: "auth",
// ScopeResolverName: "UserIDFromContext", // function which is called with the context of the resolver
// BoilerColumnName: "UserID",
// AddHook: func(model *gbgen.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool {
// // fmt.Println(model.Name)
// // fmt.Println(templateKey)
// // templateKey contains a unique where the resolver tries to add something
// // e.g.
// // most of the time you can ignore this
// // we want the delete call to work for every object we don't want to take in account te user-id here
// if resolver.IsDelete {
// return false
// }
// var addResolver bool
// for _, field := range model.Fields {
// if field.Name == "UserID" {
// addResolver = true
// }
// }
// return addResolver
// },
// },
// {
// ImportPath: "github.com/my-repo/app/backend/auth",
// ImportAlias: "auth",
// ScopeResolverName: "UserOrganizationIDFromContext", // function which is called with the context of the resolver
// BoilerColumnName: "UserOrganizationID",
// AddHook: func(model *gbgen.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool {
// // fmt.Println(model.Name)
// // fmt.Println(templateKey)
// // templateKey contains a unique where the resolver tries to add something
// // e.g.
// // most of the time you can ignore this
// var addResolver bool
// for _, field := range model.Fields {
// if field.Name == "UserOrganizationID" {
// addResolver = true
// }
// }
// return addResolver
// },
// },
// },
},
).GenerateCode(data); err != nil {
log.Fatal().Err(err).Msg("error while generating resolvers")
}
}
}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.
Add "validateForeignKey" handling to your AddHook function and pass auth scopes to the ConvertPlugin:
authScopes := []*gbgen.AuthorizationScope{
{
ImportPath: "github.com/my-repo/app/backend/auth",
ImportAlias: "auth",
ScopeResolverName: "OrganizationIDFromContext",
BoilerColumnName: "OrganizationID",
AddHook: func(model *structs.BoilerModel, resolver *gbgen.Resolver, templateKey string) bool {
// Enable FK validation for models with OrganizationID
if templateKey == "validateForeignKey" {
for _, field := range model.Fields {
if field.Name == "OrganizationID" {
return true
}
}
return false
}
// ... existing authorization logic for other templateKeys
for _, field := range model.Fields {
if field.Name == "OrganizationID" {
return true
}
}
return false
},
},
}
// Pass auth scopes to convert plugin for FK validation
if err := gbgen.NewConvertPlugin(
modelCache,
gbgen.ConvertPluginConfig{DatabaseDriver: gbgen.MySQL},
).GenerateCode(authScopes); err != nil {
log.Fatal().Err(err).Msg("error while generating convert/filters")
}
// Pass same auth scopes to resolver plugin
if err := gbgen.NewResolverPlugin(
config.ResolverConfig{...},
output, boilerCache, modelCache,
gbgen.ResolverPluginConfig{
AuthorizationScopes: authScopes,
},
).GenerateCode(data); err != nil {
log.Fatal().Err(err).Msg("error while generating resolvers")
}This generates validation functions like:
func ValidatePostCreateInputForeignKeys(ctx context.Context, db boil.ContextExecutor, m *fm.PostCreateInput) error {
if m.AuthorID != nil {
exists, err := dm.Users(
dm.UserWhere.ID.EQ(UserID(*m.AuthorID)),
dm.UserWhere.OrganizationID.EQ(auth.OrganizationIDFromContext(ctx)),
).Exists(ctx, db)
if err != nil {
return fmt.Errorf("authorId: %w", err)
}
if !exists {
return fmt.Errorf("authorId: referenced User not found or access denied")
}
}
return nil
}Put a file in your helpers/ directory e.g. convert_override_user.go
package helpers
import (
"github.com/../app/backend/graphql_models"
"github.com/../app/backend/models"
)
// use same name as in one of the generated files to override
func UserCreateInputToBoiler(
m *graphql_models.UserCreateInput,
) *models.User {
if m == nil {
return nil
}
originalConvert := originalUserCreateInputToBoiler(m)
// e.g. bcrypt password
return originalConvert
}If you re-generate the original convert will get changed to originalUserCreateInputToBoiler which you can still use in your overridden convert.
The generator creates reusable CRUD helper functions in generated_crud.go that can be called from custom resolvers:
// Fetch with preloads and authorization
func FetchActivity(ctx context.Context, db boil.ContextExecutor, id string, preloadLevel string) (*dm.Activity, error)
// Create with FK validation and authorization
func CreateActivity(ctx context.Context, db boil.ContextExecutor, input fm.ActivityCreateInput, preloadLevel string) (*dm.Activity, error)
// Update with FK validation and authorization
func UpdateActivity(ctx context.Context, db boil.ContextExecutor, id string, input fm.ActivityUpdateInput, preloadLevel string) (*dm.Activity, error)
// Delete with authorization (hard delete)
func DeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) error
// Soft delete (only generated if model has deleted_at)
func SoftDeleteActivity(ctx context.Context, db boil.ContextExecutor, id string) errorUse these in custom resolvers:
func (r *mutationResolver) CreateActivityWithNotification(ctx context.Context, input fm.ActivityCreateInput) (*fm.ActivityPayload, error) {
// Use the CRUD helper
m, err := CreateActivity(ctx, r.db, input, ActivityPayloadPreloadLevels.Activity)
if err != nil {
return nil, err
}
// Custom logic
notifyUsers(ctx, m)
return &fm.ActivityPayload{
Activity: ActivityToGraphQL(ctx, r.db, m),
}, nil
}We're the most happy with your time investments and/or pull request to improve this plugin. Feedback is also highly appreciated.