diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml new file mode 100644 index 0000000..0c9c60c --- /dev/null +++ b/.buildkite/pipeline.yaml @@ -0,0 +1,69 @@ +env: + APP_NAME: ${BUILDKITE_PIPELINE_SLUG} + IMAGE_REPO: ghcr.io/theopenlane/${APP_NAME} + SONAR_HOST: "https://sonarcloud.io" +steps: + - group: ":test_tube: tests" + key: "tests" + steps: + - label: ":golangci-lint: lint :lint-roller:" + cancel_on_build_failing: true + key: "lint" + plugins: + - docker#v5.11.0: + image: "registry.hub.docker.com/golangci/golangci-lint:latest-alpine" + command: ["golangci-lint", "run", "-v"] + always-pull: true + environment: + - "GOTOOLCHAIN=auto" + - label: ":golang: go test" + key: "go_test" + plugins: + - docker#v5.11.0: + image: golang:1.23.0 + command: ["go", "test", "-coverprofile=coverage.out", "./..."] + artifact_paths: ["coverage.out"] + - group: ":closed_lock_with_key: Security Checks" + depends_on: "tests" + key: "security" + steps: + - label: ":closed_lock_with_key: gosec" + key: "gosec" + plugins: + - docker#v5.11.0: + image: "registry.hub.docker.com/securego/gosec:2.20.0" + command: ["-no-fail", "-exclude-generated", "-fmt sonarqube", "-out", "results.txt", "./..."] + environment: + - "GOTOOLCHAIN=auto" + artifact_paths: ["results.txt"] + - label: ":github: upload PR reports" + key: "scan-upload-pr" + if: build.pull_request.id != null + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: "results.txt" + - artifacts#v1.9.4: + download: "coverage.out" + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:5" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" + - "SONAR_SCANNER_OPTS=-Dsonar.pullrequest.branch=$BUILDKITE_BRANCH -Dsonar.pullrequest.base=$BUILDKITE_PULL_REQUEST_BASE_BRANCH -Dsonar.pullrequest.key=$BUILDKITE_PULL_REQUEST" + - label: ":github: upload reports" + key: "scan-upload" + if: build.branch == "main" + depends_on: ["gosec", "go_test"] + plugins: + - artifacts#v1.9.4: + download: results.txt + - artifacts#v1.9.4: + download: coverage.out + step: "go_test" + - docker#v5.11.0: + image: "sonarsource/sonar-scanner-cli:5" + environment: + - "SONAR_TOKEN" + - "SONAR_HOST_URL=$SONAR_HOST" diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f906a12 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @theopenlane/blacksmiths \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..48b8eca --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,24 @@ +# Contributing + +Given external users will not have write to the branches in this repository, you'll need to follow the forking process to open a PR - [here](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork) is a guide from github on how to do so. + +Please also read our main [contributing guide](https://github.com/theopenlane/.github/blob/main/CONTRIBUTING.md) in addition to this one; the main guide mostly says that we'd like for you to open an issue first but it's not hard-required, and that we accept all forms of proposed changes given the state of this code base (in it's infancy, still!) + +## Pre-requisites to a PR + +This repository contains a number of code generating functions / utilities which take schema modifications and scaffold out resolvers, graphql API schemas, openAPI specifications, among other things. To ensure you've generated all the necessary dependencies run `task pr`; this will run the entirety of the commands required to safely generate a PR. If for some reason one of the commands fails / encounters an error, you will need to debug the individual steps. It should be decently easy to follow the `Taskfile` in the root of this repository. + +### Pre-Commit Hooks + +We have several `pre-commit` hooks that should be run before pushing a commit. Make sure this is installed: + +```bash +brew install pre-commit +pre-commit install +``` + +You can optionally run against all files: + +```bash +pre-commit run --all-files +``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..263ebe5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,16 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug]" +labels: bug +assignees: '' + +--- + +**Describe the bug or issue you're encountering** + + +**What are the relevant steps to reproduce, including the version(s) of the relevant software?** + + +**What is the expected behavior?** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..897f8f2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature Request]" +labels: enhancement +assignees: matoszz + +--- + +**Describe how the feature might make your life easier or solve a problem** + +**Describe the solution you'd like to see with any relevant context** + +**Describe any alternatives you've considered or if there are short-tern vs. long-term options** diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..6e813dc --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,9 @@ +# Add 'bug' label to any PR where the head branch name starts with `bug` or has a `bug` section in the name +bug: + - head-branch: ["^bug", "bug"] +# Add 'enhancement' label to any PR where the head branch name starts with `enhancement` or has a `enhancement` section in the name +enhancement: + - head-branch: ["^enhancement", "enhancement", "^feature", "feature", "^enhance", "enhance", "^feat", "feat"] +# Add 'breaking-change' label to any PR where the head branch name starts with `breaking-change` or has a `breaking-change` section in the name +breaking-change: + - head-branch: ["^breaking-change", "breaking-change"] diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..37df9bc --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +changelog: + exclude: + labels: + - ignore-for-release + authors: [] + categories: + - title: Breaking Changes 🛠 + labels: + - Semver-Major + - breaking-change + - title: New Features 🎉 + labels: + - Semver-Minor + - enhancement + - feature + - title: Bug Fixes 🐛 + labels: + - bug + - title: 👒 Dependencies + labels: + - dependencies + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/labeler.yaml b/.github/workflows/labeler.yaml new file mode 100644 index 0000000..fc43cb1 --- /dev/null +++ b/.github/workflows/labeler.yaml @@ -0,0 +1,13 @@ +name: "Pull Request Labeler" +on: + - pull_request_target +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..9381e0d --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,127 @@ +name: Release +on: + workflow_dispatch: + release: + types: [created] +permissions: + contents: write +jobs: + ldflags_args: + runs-on: ubuntu-latest + outputs: + commit-date: ${{ steps.ldflags.outputs.commit-date }} + commit: ${{ steps.ldflags.outputs.commit }} + version: ${{ steps.ldflags.outputs.version }} + tree-state: ${{ steps.ldflags.outputs.tree-state }} + steps: + - id: checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: ldflags + run: | + echo "commit=$GITHUB_SHA" >> $GITHUB_OUTPUT + echo "commit-date=$(git log --date=iso8601-strict -1 --pretty=%ct)" >> $GITHUB_OUTPUT + echo "version=$(git describe --tags --always --dirty | cut -c2-)" >> $GITHUB_OUTPUT + echo "tree-state=$(if git diff --quiet; then echo "clean"; else echo "dirty"; fi)" >> $GITHUB_OUTPUT + release: + name: Build and release + needs: + - ldflags_args + outputs: + hashes: ${{ steps.hash.outputs.hashes }} + permissions: + contents: write # To add assets to a release. + id-token: write # To do keyless signing with cosign + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Install Syft + uses: anchore/sbom-action/download-syft@ab9d16d4b419c9d1a02df5213fa0ebe965ca5a57 # v0.17.1 + - name: Install Cosign + uses: sigstore/cosign-installer@v3.6.0 + - name: Run GoReleaser + id: run-goreleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + VERSION: ${{ needs.ldflags_args.outputs.version }} + COMMIT: ${{ needs.ldflags_args.outputs.commit }} + COMMIT_DATE: ${{ needs.ldflags_args.outputs.commit-date }} + TREE_STATE: ${{ needs.ldflags_args.outputs.tree-state }} + - name: Generate subject + id: hash + env: + ARTIFACTS: "${{ steps.run-goreleaser.outputs.artifacts }}" + run: | + set -euo pipefail + hashes=$(echo $ARTIFACTS | jq --raw-output '.[] | {name, "digest": (.extra.Digest // .extra.Checksum)} | select(.digest) | {digest} + {name} | join(" ") | sub("^sha256:";"")' | base64) + if test "$hashes" = ""; then # goreleaser < v1.13.0 + checksum_file=$(echo "$ARTIFACTS" | jq -r '.[] | select (.type=="Checksum") | .path') + hashes=$(cat $checksum_file | base64) + fi + echo "hashes=$hashes" >> $GITHUB_OUTPUT + provenance: + name: Generate provenance (SLSA3) + needs: + - release + permissions: + actions: read # To read the workflow path. + id-token: write # To sign the provenance. + contents: write # To add assets to a release. + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0 + with: + base64-subjects: "${{ needs.release.outputs.hashes }}" + upload-assets: true # upload to a new release + verification: + name: Verify provenance of assets (SLSA3) + needs: + - release + - provenance + runs-on: ubuntu-latest + permissions: read-all + steps: + - name: Install the SLSA verifier + uses: slsa-framework/slsa-verifier/actions/installer@v2.6.0 + - name: Download assets + env: + GH_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + CHECKSUMS: "${{ needs.release.outputs.hashes }}" + ATT_FILE_NAME: "${{ needs.provenance.outputs.provenance-name }}" + run: | + set -euo pipefail + checksums=$(echo "$CHECKSUMS" | base64 -d) + while read -r line; do + fn=$(echo $line | cut -d ' ' -f2) + echo "Downloading $fn" + gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$fn" + done <<<"$checksums" + gh -R "$GITHUB_REPOSITORY" release download "$GITHUB_REF_NAME" -p "$ATT_FILE_NAME" + - name: Verify assets + env: + CHECKSUMS: "${{ needs.release.outputs.hashes }}" + PROVENANCE: "${{ needs.provenance.outputs.provenance-name }}" + run: |- + set -euo pipefail + checksums=$(echo "$CHECKSUMS" | base64 -d) + while read -r line; do + fn=$(echo $line | cut -d ' ' -f2) + echo "Verifying SLSA provenance for $fn" + slsa-verifier verify-artifact --provenance-path "$PROVENANCE" \ + --source-uri "github.com/$GITHUB_REPOSITORY" \ + --source-tag "$GITHUB_REF_NAME" \ + "$fn" + done <<<"$checksums" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb83d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Go workspace file +go.work + +# Packages +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar + +# Logs +*.log + +# Editor files +.vscode + +# OS Generated Files +.DS_Store* +.AppleDouble +.LSOverride +ehthumbs.db +Icon? +Thumbs.db + diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..62c3c21 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,41 @@ +run: + timeout: 10m + allow-serial-runners: true +linters-settings: + goimports: + local-prefixes: github.com/theopenlane/gqlgen-plugins + gofumpt: + extra-rules: true + gosec: + exclude-generated: true + revive: + ignore-generated-header: true +linters: + enable: + - bodyclose + - errcheck + - gocritic + - gocyclo + - err113 + - gofmt + - goimports + - mnd + - gosimple + - govet + - gosec + - ineffassign + - misspell + - noctx + - revive + - staticcheck + - stylecheck + - typecheck + - unused + - whitespace + - wsl +issues: + fix: true + exclude-use-default: true + exclude-dirs: + - totp/testing/* + exclude-files: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b7a016f --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +default_stages: [pre-commit] +fail_fast: true +default_language_version: + golang: system + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + exclude: jsonschema/api-docs.md + - id: detect-private-key + exclude: 'pkg/tokens/testdata/.*' + - repo: https://github.com/google/yamlfmt + rev: v0.13.0 + hooks: + - id: yamlfmt + - repo: https://github.com/crate-ci/typos + rev: v1.24.1 + hooks: + - id: typos diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..e69de29 diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 0000000..ef3bcd6 --- /dev/null +++ b/.typos.toml @@ -0,0 +1,20 @@ +[files] +extend-exclude = ["go.mod","go.sum"] +ignore-hidden = true +ignore-files = true +ignore-dot = true +ignore-vcs = true +ignore-global = true +ignore-parent = true + +[default] +binary = false +check-filename = true +check-file = true +unicode = true +ignore-hex = true +identifier-leading-digits = false +locale = "en" +extend-ignore-identifiers-re = [] +extend-ignore-words-re = ["(?i)requestor","(?i)encrypter","(?i)seeked"] +extend-ignore-re = ["#\\s*spellchecker:off\\s*\\n.*\\n\\s*#\\s*spellchecker:on"] \ No newline at end of file diff --git a/.yamlfmt b/.yamlfmt new file mode 100644 index 0000000..f6cfc8b --- /dev/null +++ b/.yamlfmt @@ -0,0 +1,4 @@ +exclude: + - config/ +formatter: + retain_line_breaks: true \ No newline at end of file diff --git a/LICENSE b/LICENSE index 261eeb9..31018d8 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2024 The Open Lane, Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9f4529 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# gqlgen-plugins + +[gqlgen](https://gqlgen.com/reference/plugins/) provides a way to hook into the gqlgen code generation lifecycle. This repo contains two hooks: +- bulkgen +- resovlergen + +## ResolverGen + +This hook will override the default generated resolver functions with the templates for CRUD operations. + +## BulkGen + +Creates resolvers to do bulk operations for a schema for both bulk input or a csv file upload input. + +## Usage + +Add the plugins to the `generate.go` `main` function to be included in the setup: + +```go +func main() { + cfg, err := config.LoadConfigFromDefaultLocations() + if err != nil { + fmt.Fprintln(os.Stderr, "failed to load config", err.Error()) + os.Exit(2) + } + + if err := api.Generate(cfg, + api.ReplacePlugin(resolvergen.New()), // replace the resolvergen plugin + api.AddPlugin(bulkgen.New()), // add the bulkgen plugin + ); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(3) + } +} +``` + diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..8fc5c91 --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,44 @@ +version: '3' + +tasks: + default: + silent: true + cmds: + - task --list + + go:lint: + desc: runs golangci-lint, the most annoying opinionated linter ever + cmds: + - golangci-lint run --config=.golangci.yaml --verbose + + go:fmt: + desc: format all go code + cmds: + - go fmt ./... + + go:test: + desc: runs and outputs results of created go tests + cmds: + - go test -v ./... + + go:tidy: + desc: runs go mod tidy on the backend + aliases: [tidy] + cmds: + - go mod tidy + + go:all: + aliases: [go] + desc: runs all go test and lint related tasks + cmds: + - task: go:tidy + - task: go:fmt + - task: go:lint + - task: go:test + + precommit-full: + desc: Lint the project against all files + cmds: + - pre-commit install && pre-commit install-hooks + - pre-commit autoupdate + - pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/bulkgen/bulk.gotpl b/bulkgen/bulk.gotpl new file mode 100644 index 0000000..67aa178 --- /dev/null +++ b/bulkgen/bulk.gotpl @@ -0,0 +1,30 @@ +{{ reserveImport "context" }} +{{ reserveImport "errors" }} + +{{reserveImport "github.com/theopenlane/core/internal/ent/generated" }} +{{reserveImport "github.com/theopenlane/core/internal/ent/generated/privacy"}} + +{{ $root := . }} + +{{ range $object := .Objects }} + +// bulkCreate{{ $object.Name }} uses the CreateBulk function to create multiple {{ $object.Name }} entities +func (r *mutationResolver) bulkCreate{{ $object.Name }} (ctx context.Context, input []*generated.Create{{ $object.Name }}Input) (*{{ $object.Name }}BulkCreatePayload, error) { + c := withTransactionalMutation(ctx) + builders := make([]*generated.{{ $object.Name }}Create, len(input)) + for i, data := range input { + builders[i] = c.{{ $object.Name }}.Create().SetInput(*data) + } + + res, err := c.{{ $object.Name }}.CreateBulk(builders...).Save(ctx) + if err != nil { + return nil, parseRequestError(err, action{action: ActionCreate, object: "{{ $object.Name | toLower }}"}, r.logger) + } + + // return response + return &{{ $object.Name }}BulkCreatePayload{ + {{ $object.PluralName }}: res, + }, nil +} + +{{ end }} \ No newline at end of file diff --git a/bulkgen/bulkresolvers.go b/bulkgen/bulkresolvers.go new file mode 100644 index 0000000..8402f56 --- /dev/null +++ b/bulkgen/bulkresolvers.go @@ -0,0 +1,87 @@ +package bulkgen + +import ( + _ "embed" + "strings" + "text/template" + + "github.com/99designs/gqlgen/codegen" + "github.com/99designs/gqlgen/codegen/templates" + "github.com/99designs/gqlgen/plugin" + "github.com/gertd/go-pluralize" +) + +//go:embed bulk.gotpl +var bulkTemplate string + +// New returns a new plugin +func New() plugin.Plugin { + return &Plugin{} +} + +// Plugin is a gqlgen plugin to generate bulk resolver functions used for mutations +type Plugin struct{} + +// Name returns the name of the plugin +func (m *Plugin) Name() string { + return "bulkgen" +} + +// BulkResolverBuild is a struct to hold the objects for the bulk resolver +type BulkResolverBuild struct { + // Objects is a list of objects to generate bulk resolvers for + Objects []Object +} + +// Object is a struct to hold the object name for the bulk resolver +type Object struct { + // Name of the object + Name string + // PluralName of the object + PluralName string +} + +// GenerateCode generates the bulk resolver code +func (m *Plugin) GenerateCode(data *codegen.Data) error { + if !data.Config.Resolver.IsDefined() { + return nil + } + + return m.generateSingleFile(*data) +} + +// generateSingleFile generates the bulk resolver code, this is all done in a single file and +// used by the resolvergen plugin for each bulk resolver +func (m *Plugin) generateSingleFile(data codegen.Data) error { + inputData := BulkResolverBuild{ + Objects: []Object{}, + } + + for _, f := range data.Schema.Mutation.Fields { + lowerName := strings.ToLower(f.Name) + + // if the field is a bulk create mutation, add it to the list of objects + // we skip csv bulk mutations because they will reuse the same functions + if strings.Contains(lowerName, "bulk") && !strings.Contains(lowerName, "csv") { + objectName := strings.Replace(f.Name, "createBulk", "", 1) + + inputData.Objects = append(inputData.Objects, Object{ + Name: objectName, + PluralName: pluralize.NewClient().Plural(objectName), + }) + } + } + + // render the bulk resolver template + return templates.Render(templates.Options{ + PackageName: data.Config.Resolver.Package, // use the resolver package + Filename: data.Config.Resolver.Dir() + "/bulk.go", // write to the resolver directory + FileNotice: `// THIS CODE IS REGENERATED BY github.com/theopenlane/core/pkg/gqlplugin. DO NOT EDIT.`, + Data: inputData, + Funcs: template.FuncMap{ + "toLower": strings.ToLower, + }, + Packages: data.Config.Packages, + Template: bulkTemplate, + }) +} diff --git a/bulkgen/doc.go b/bulkgen/doc.go new file mode 100644 index 0000000..838c78f --- /dev/null +++ b/bulkgen/doc.go @@ -0,0 +1,2 @@ +// Package bulkgen provides a gqlgen plugin to generate bulk resolver functions used for mutations +package bulkgen diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d54ab15 --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/theopenlane/gqlgen-plugins + +go 1.23.0 + +require ( + github.com/99designs/gqlgen v0.17.49 + github.com/gertd/go-pluralize v0.2.1 + github.com/stoewer/go-strcase v1.3.0 + github.com/stretchr/testify v1.9.0 + github.com/vektah/gqlparser/v2 v2.5.16 +) + +require ( + github.com/agnivade/levenshtein v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sosodev/duration v1.3.1 // indirect + golang.org/x/mod v0.18.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/tools v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0347386 --- /dev/null +++ b/go.sum @@ -0,0 +1,50 @@ +github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= +github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0= +github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= +github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= +github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/gertd/go-pluralize v0.2.1 h1:M3uASbVjMnTsPb0PNqg+E/24Vwigyo/tvyMTtAlLgiA= +github.com/gertd/go-pluralize v0.2.1/go.mod h1:rbYaKDbsXxmRfr8uygAEKhOWsjyrrqrkHVpZvoOp8zk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= +github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..fdfbb4f --- /dev/null +++ b/renovate.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "postUpdateOptions": [ + "gomodTidy" + ], + "labels": [ + "dependencies" + ] +} \ No newline at end of file diff --git a/resolvergen/crud.go b/resolvergen/crud.go new file mode 100644 index 0000000..ed2f18b --- /dev/null +++ b/resolvergen/crud.go @@ -0,0 +1,154 @@ +package resolvergen + +import ( + "embed" + + "bytes" + "html/template" + "strings" + + "github.com/99designs/gqlgen/codegen" + gqltemplates "github.com/99designs/gqlgen/codegen/templates" + + "github.com/stoewer/go-strcase" + gqlast "github.com/vektah/gqlparser/v2/ast" +) + +//go:embed templates/**.gotpl +var templates embed.FS + +// crudResolver is a struct to hold the field for the CRUD resolver +type crudResolver struct { + Field *codegen.Field + AppendFields []string +} + +// renderTemplate renders the template with the given name +func renderTemplate(templateName string, input *crudResolver) string { + t, err := template.New(templateName).Funcs(template.FuncMap{ + "getEntityName": getEntityName, + "toLower": strings.ToLower, + "toLowerCamel": strcase.LowerCamelCase, + "hasArgument": hasArgument, + "hasOwnerField": hasOwnerField, + "reserveImport": gqltemplates.CurrentImports.Reserve, + }).ParseFS(templates, "templates/"+templateName) + if err != nil { + panic(err) + } + + var code bytes.Buffer + + if err = t.Execute(&code, input); err != nil { + panic(err) + } + + return strings.Trim(code.String(), "\t \n") +} + +// renderCreate renders the create template +func renderCreate(field *codegen.Field) string { + return renderTemplate("create.gotpl", &crudResolver{ + Field: field, + }) +} + +// renderUpdate renders the update template +func renderUpdate(field *codegen.Field) string { + appendFields := getAppendFields(field) + + cr := &crudResolver{ + Field: field, + AppendFields: appendFields, + } + + return renderTemplate("update.gotpl", cr) +} + +// renderDelete renders the delete template +func renderDelete(field *codegen.Field) string { + return renderTemplate("delete.gotpl", &crudResolver{ + Field: field, + }) +} + +// renderBulkUpload renders the bulk upload template +func renderBulkUpload(field *codegen.Field) string { + return renderTemplate("upload.gotpl", &crudResolver{ + Field: field, + }) +} + +// renderBulk renders the bulk template +func renderBulk(field *codegen.Field) string { + return renderTemplate("bulk.gotpl", &crudResolver{ + Field: field, + }) +} + +// renderQuery renders the query template +func renderQuery(field *codegen.Field) string { + return renderTemplate("get.gotpl", &crudResolver{ + Field: field, + }) +} + +// renderList renders the list template +func renderList(field *codegen.Field) string { + return renderTemplate("list.gotpl", &crudResolver{ + Field: field, + }) +} + +// crudTypes is a list of CRUD operations that are included in the resolver name +var stripStrings = []string{"Create", "Update", "Delete", "Bulk", "CSV", "Connection", "Payload"} + +// getEntityName returns the entity name by stripping the CRUD operation from the resolver name +func getEntityName(name string) string { + for _, s := range stripStrings { + if strings.Contains(name, s) { + name = strings.ReplaceAll(name, s, "") + } + } + + return name +} + +// hasArgument checks if the argument is present in the list of arguments +func hasArgument(arg string, args gqlast.ArgumentDefinitionList) bool { + for _, a := range args { + if a.Name == arg { + return true + } + } + + return false +} + +// hasOwnerField checks if the field has an owner field in the input arguments +func hasOwnerField(field *codegen.Field) bool { + for _, arg := range field.Args { + if arg.TypeReference.Definition.Kind == gqlast.InputObject { + if arg.TypeReference.Definition.Fields.ForName("ownerID") != nil { + return true + } + } + } + + return false +} + +// getAppendFields returns the list of fields that are appendable in the update mutation +func getAppendFields(field *codegen.Field) (appendFields []string) { + for _, arg := range field.Args { + if arg.TypeReference.Definition.Kind == gqlast.InputObject { + for _, f := range arg.TypeReference.Definition.Fields { + if strings.Contains(f.Name, "append") { + appendFields = append(appendFields, strcase.UpperCamelCase(f.Name)) + } + } + } + } + + return +} diff --git a/resolvergen/crud_test.go b/resolvergen/crud_test.go new file mode 100644 index 0000000..e214a86 --- /dev/null +++ b/resolvergen/crud_test.go @@ -0,0 +1,100 @@ +package resolvergen + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vektah/gqlparser/v2/ast" +) + +func TestHasArgument(t *testing.T) { + args := ast.ArgumentDefinitionList{ + {Name: "where"}, + {Name: "here"}, + } + + testCases := []struct { + name string + argName string + expected bool + }{ + { + name: "arg found", + argName: "where", + expected: true, + }, + { + name: "arg not found", + argName: "nowhere", + expected: false, + }, + { + name: "empty arg", + argName: "", + expected: false, + }, + } + + for _, tc := range testCases { + t.Run("Get "+tc.name, func(t *testing.T) { + res := hasArgument(tc.argName, args) + assert.Equal(t, tc.expected, res) + }) + } +} + +func TestGetEntityName(t *testing.T) { + testCases := []struct { + name string + input string + expected string + }{ + { + name: "strip Create", + input: "CreateUser", + expected: "User", + }, + { + name: "strip Update", + input: "UpdatePost", + expected: "Post", + }, + { + name: "strip Delete", + input: "DeleteComment", + expected: "Comment", + }, + { + name: "strip Bulk", + input: "BulkUpdateProduct", + expected: "Product", + }, + { + name: "strip CSV + Bulk", + input: "BulkCSVOrder", + expected: "Order", + }, + { + name: "strip Connection", + input: "UserConnection", + expected: "User", + }, + { + name: "strip Payload", + input: "PayloadUser", + expected: "User", + }, + { + name: "no strip", + input: "User", + expected: "User", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + res := getEntityName(tc.input) + assert.Equal(t, tc.expected, res) + }) + } +} diff --git a/resolvergen/doc.go b/resolvergen/doc.go new file mode 100644 index 0000000..309553d --- /dev/null +++ b/resolvergen/doc.go @@ -0,0 +1,2 @@ +// Package resolvergen provides the a resolver template for the gqlgen resolver plugin to override the default resolver functions +package resolvergen diff --git a/resolvergen/resolver.go b/resolvergen/resolver.go new file mode 100644 index 0000000..31a2db6 --- /dev/null +++ b/resolvergen/resolver.go @@ -0,0 +1,113 @@ +package resolvergen + +import ( + "fmt" + "strings" + + "github.com/99designs/gqlgen/codegen" + "github.com/99designs/gqlgen/plugin" + "github.com/99designs/gqlgen/plugin/resolvergen" +) + +var ( + _ plugin.ResolverImplementer = (*ResolverPlugin)(nil) +) + +const ( + defaultImplementation = "panic(fmt.Errorf(\"not implemented: %v - %v\"))" +) + +// ResolverPlugin is a gqlgen plugin to generate resolver functions +type ResolverPlugin struct { + *resolvergen.Plugin +} + +// Name returns the name of the plugin +// This name must match the upstream resolvergen to replace during code generation +func (r ResolverPlugin) Name() string { + return "resolvergen" +} + +// New returns a new resolver plugin +func New() *ResolverPlugin { + return &ResolverPlugin{} +} + +// Implement gqlgen api.ResolverImplementer +func (r ResolverPlugin) Implement(s string, f *codegen.Field) (val string) { + // if the field has a custom resolver, use it + // panic is not a custom resolver so attempt to implement the field + if s != "" && !strings.Contains(s, "panic") { + return s + } + + switch { + case isMutation(f): + return mutationImplementer(f) + case isQuery(f): + return queryImplementer(f) + default: + return fmt.Sprintf(defaultImplementation, f.GoFieldName, f.Name) + } +} + +// GenerateCode implements api.CodeGenerator +func (r ResolverPlugin) GenerateCode(data *codegen.Data) error { + // use the default resolver plugin to generate the code + return r.Plugin.GenerateCode(data) +} + +// isMutation returns true if the field is a mutation +func isMutation(f *codegen.Field) bool { + return f.Object.Definition.Name == "Mutation" +} + +// isQuery returns true if the field is a query +func isQuery(f *codegen.Field) bool { + return f.Object.Definition.Name == "Query" +} + +// mutationImplementer returns the implementation for the mutation +func mutationImplementer(f *codegen.Field) string { + switch crudType(f) { + case "BulkCSV": + return renderBulkUpload(f) + case "Bulk": + return renderBulk(f) + case "Create": + return renderCreate(f) + case "Update": + return renderUpdate(f) + case "Delete": + return renderDelete(f) + default: + return fmt.Sprintf(defaultImplementation, f.GoFieldName, f.Name) + } +} + +// queryImplementer returns the implementation for the query +func queryImplementer(f *codegen.Field) string { + if strings.Contains(f.TypeReference.Definition.Name, "Connection") { + return renderList(f) + } + + return renderQuery(f) +} + +// crudType returns the type of CRUD operation +func crudType(f *codegen.Field) string { + switch { + case strings.Contains(f.GoFieldName, "CSV"): + return "BulkCSV" + case strings.Contains(f.GoFieldName, "Bulk"): + return "Bulk" + case strings.Contains(f.GoFieldName, "Create"): + return "Create" + case strings.Contains(f.GoFieldName, "Update"): + return "Update" + case strings.Contains(f.GoFieldName, "Delete"): + return "Delete" + default: + return "unknown" + } +} diff --git a/resolvergen/templates/bulk.gotpl b/resolvergen/templates/bulk.gotpl new file mode 100644 index 0000000..aa8749e --- /dev/null +++ b/resolvergen/templates/bulk.gotpl @@ -0,0 +1,3 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} + +return r.bulkCreate{{ $entity }}(ctx, input) \ No newline at end of file diff --git a/resolvergen/templates/create.gotpl b/resolvergen/templates/create.gotpl new file mode 100644 index 0000000..1a75435 --- /dev/null +++ b/resolvergen/templates/create.gotpl @@ -0,0 +1,22 @@ +{{ reserveImport "github.com/theopenlane/utils/rout" }} + +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} +{{ $isOrgOwned := .Field | hasOwnerField -}} + +{{- if $isOrgOwned }} +// set the organization in the auth context if its not done for us +if err := setOrganizationInAuthContext(ctx, input.OwnerID); err != nil { + r.logger.Errorw("failed to set organization in auth context", "error", err) + + return nil, rout.NewMissingRequiredFieldError("owner_id") +} +{{- end }} + +res, err := withTransactionalMutation(ctx).{{ $entity }}.Create().SetInput(input).Save(ctx) +if err != nil { + return nil, parseRequestError(err, action{action: ActionCreate, object: "{{ $entity | toLower }}"}, r.logger) +} + +return &{{ $entity }}CreatePayload{ + {{ $entity }}: res, +}, nil diff --git a/resolvergen/templates/delete.gotpl b/resolvergen/templates/delete.gotpl new file mode 100644 index 0000000..b81deb3 --- /dev/null +++ b/resolvergen/templates/delete.gotpl @@ -0,0 +1,13 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} + +if err := withTransactionalMutation(ctx).{{ $entity }}.DeleteOneID(id).Exec(ctx); err != nil { + return nil, parseRequestError(err, action{action: ActionDelete, object: "{{ $entity | toLower }}"}, r.logger) +} + +if err := generated.{{ $entity }}EdgeCleanup(ctx, id); err != nil { + return nil, newCascadeDeleteError(err) +} + +return &{{ $entity }}DeletePayload{ + DeletedID: id, +}, nil \ No newline at end of file diff --git a/resolvergen/templates/get.gotpl b/resolvergen/templates/get.gotpl new file mode 100644 index 0000000..3162942 --- /dev/null +++ b/resolvergen/templates/get.gotpl @@ -0,0 +1,8 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} + +res, err := withTransactionalMutation(ctx).{{ $entity }}.Get(ctx, id) +if err != nil { + return nil, parseRequestError(err, action{action: ActionGet, object: "{{ $entity | toLower }}"}, r.logger) +} + +return res, nil \ No newline at end of file diff --git a/resolvergen/templates/list.gotpl b/resolvergen/templates/list.gotpl new file mode 100644 index 0000000..63b8ad2 --- /dev/null +++ b/resolvergen/templates/list.gotpl @@ -0,0 +1,29 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} +{{ $hasAfter := hasArgument "after" .Field.FieldDefinition.Arguments }} +{{ $hasFirst := hasArgument "first" .Field.FieldDefinition.Arguments }} +{{ $hasBefore := hasArgument "before" .Field.FieldDefinition.Arguments }} +{{ $hasLast := hasArgument "last" .Field.FieldDefinition.Arguments }} +{{ $hasOrderBy := hasArgument "orderBy" .Field.FieldDefinition.Arguments }} +{{ $hasWhere := hasArgument "where" .Field.FieldDefinition.Arguments }} + +return withTransactionalMutation(ctx).{{ $entity }}.Query().Paginate( + ctx, + {{- if $hasAfter }} + after, + {{- end -}} + {{- if $hasFirst }} + first, + {{- end -}} + {{- if $hasBefore }} + before, + {{- end -}} + {{- if $hasLast }} + last, + {{- end -}} + {{- if $hasOrderBy }} + generated.With{{ $entity }}Order(orderBy), + {{- end -}} + {{- if $hasWhere }} + generated.With{{ $entity }}Filter(where.Filter), + {{- end -}} +) \ No newline at end of file diff --git a/resolvergen/templates/update.gotpl b/resolvergen/templates/update.gotpl new file mode 100644 index 0000000..3d9be4b --- /dev/null +++ b/resolvergen/templates/update.gotpl @@ -0,0 +1,28 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} +{{ $isOrgOwned := .Field | hasOwnerField -}} + +res, err := withTransactionalMutation(ctx).{{ $entity }}.Get(ctx, id) +if err != nil { + return nil, parseRequestError(err, action{action: ActionUpdate, object: "{{ $entity | toLower }}"}, r.logger) +} + +{{- if $isOrgOwned }} +// set the organization in the auth context if its not done for us +if err := setOrganizationInAuthContext(ctx, &res.OwnerID); err != nil { + r.logger.Errorw("failed to set organization in auth context", "error", err) + + return nil, ErrPermissionDenied +} +{{- end }} + +// setup update request +req := res.Update().SetInput(input){{- range $appendField := .AppendFields }}.{{ $appendField }}(input.{{ $appendField }}){{- end }} + +res, err = req.Save(ctx) +if err != nil { + return nil, parseRequestError(err, action{action: ActionUpdate, object: "{{ $entity | toLower }}"}, r.logger) +} + +return &{{ $entity }}UpdatePayload{ + {{ $entity }}: res, + }, nil \ No newline at end of file diff --git a/resolvergen/templates/upload.gotpl b/resolvergen/templates/upload.gotpl new file mode 100644 index 0000000..e5c8506 --- /dev/null +++ b/resolvergen/templates/upload.gotpl @@ -0,0 +1,10 @@ +{{ $entity := .Field.TypeReference.Definition.Name | getEntityName -}} + +data, err := unmarshalBulkData[generated.Create{{ $entity }}Input](input) +if err != nil { + r.logger.Errorw("failed to unmarshal bulk data", "error", err) + + return nil, err +} + +return r.bulkCreate{{ $entity }}(ctx, data) \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..b26a601 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,16 @@ +sonar.projectKey=theopenlane_gqlgen-plugins +sonar.organization=theopenlane + +sonar.projectName=utils +sonar.projectVersion=1.0 + +sonar.sources=. + +sonar.exclusions=**/*_test.go,**/vendor/** +sonar.tests=. +sonar.test.inclusions=**/*_test.go +sonar.test.exclusions=**/vendor/** + +sonar.sourceEncoding=UTF-8 +sonar.go.coverage.reportPaths=coverage.out +sonar.externalIssuesReportPaths=results.txt \ No newline at end of file