├── .earthignore ├── .github ├── renovate.json5 └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Earthfile ├── LICENSE ├── README.md ├── cmd └── protoc-gen-fieldmask │ └── main.go ├── go.mod ├── go.sum ├── protoc ├── generator.go ├── helper.go ├── interface_generator.go ├── plugin.go ├── struct_generator.go └── vars_generator.go ├── protos └── cases │ ├── from_other_file.proto │ ├── pkg_a.proto │ ├── pkg_b.proto │ ├── recursive.proto │ ├── thirdpartyimport │ ├── a.proto │ └── b.proto │ └── types.proto └── test ├── cases_test.go └── gen └── cases ├── a └── package.go ├── b └── package.go ├── package.go └── thirdpartyimport └── package.go /.earthignore: -------------------------------------------------------------------------------- 1 | cmd/tester/* -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | constraints: { 3 | go: '1.21', 4 | }, 5 | extends: [ 6 | 'config:recommended', 7 | 'default:pinDigestsDisabled', 8 | ], 9 | configMigration: true, 10 | enabledManagers: [ 11 | 'custom.regex', 12 | 'github-actions', 13 | 'gomod', 14 | ], 15 | postUpdateOptions: [ 16 | 'gomodTidy', 17 | ], 18 | customManagers: [ 19 | { 20 | customType: 'regex', 21 | fileMatch: [ 22 | '.github/renovate.json5$', 23 | 'Earthfile$', 24 | ], 25 | matchStrings: [ 26 | 'GO_VERSION=(?.*?)\\n', 27 | 'constraints: {(\\s*\\n\\s*)"go":\\s*"(?.*?)"', 28 | 'ARG go_version=(?.*?)\\n', 29 | ], 30 | depNameTemplate: 'go', 31 | datasourceTemplate: 'golang-version', 32 | versioningTemplate: 'npm', 33 | }, 34 | { 35 | customType: 'regex', 36 | fileMatch: [ 37 | 'Earthfile$', 38 | ], 39 | matchStrings: [ 40 | 'ARG ALPINE_VERSION=(?.*?)\\n', 41 | ], 42 | depNameTemplate: 'alpine', 43 | datasourceTemplate: 'docker', 44 | }, 45 | { 46 | customType: 'regex', 47 | fileMatch: [ 48 | 'Earthfile$', 49 | ], 50 | matchStrings: [ 51 | 'ARG LINTER_VERSION=(?.*?)\\n', 52 | ], 53 | depNameTemplate: 'golangci/golangci-lint', 54 | datasourceTemplate: 'github-releases', 55 | }, 56 | { 57 | customType: 'regex', 58 | fileMatch: [ 59 | 'Earthfile$', 60 | ], 61 | matchStrings: [ 62 | 'ARG DOCKER_PROTOC_VERSION=(?.*?)\\n', 63 | ], 64 | depNameTemplate: 'namely/protoc-all', 65 | datasourceTemplate: 'docker', 66 | versioningTemplate: 'regex:^(?\\d+)\\.(?\\d+)_(?\\d+)$', 67 | }, 68 | { 69 | customType: 'regex', 70 | fileMatch: [ 71 | '.github/workflows/ci.yml$', 72 | '.github/workflows/release.yml$', 73 | ], 74 | matchStrings: [ 75 | 'version: (?.*?)\\n', 76 | ], 77 | depNameTemplate: 'earthly/earthly', 78 | datasourceTemplate: 'github-releases', 79 | }, 80 | ], 81 | packageRules: [ 82 | { 83 | matchDatasources: [ 84 | 'go', 85 | ], 86 | groupName: 'go modulesUpgrades', 87 | }, 88 | ], 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | # Controls when the workflow will run 4 | on: 5 | # Triggers the workflow on push or pull request events but only for the main branch 6 | push: 7 | branches: [ main ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | submodules: true 18 | 19 | - uses: nelonoel/branch-name@v1.0.1 20 | 21 | - uses: earthly/actions/setup-earthly@main 22 | with: 23 | version: v0.8.15 24 | 25 | - name: Earthly Version 26 | run: earthly --version 27 | 28 | - name: Build 29 | env: 30 | COMMIT_HASH: ${{ github.sha }} 31 | FORCE_COLOR: 1 32 | run: earthly -P --ci +all 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v[0-9]+\.[0-9]+\.[0-9]+' 7 | 8 | jobs: 9 | release-build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | submodules: true 15 | 16 | - uses: nelonoel/branch-name@v1.0.1 17 | 18 | - uses: earthly/actions/setup-earthly@v1 19 | with: 20 | version: v0.8.15 21 | 22 | - name: Earthly Version 23 | run: earthly --version 24 | 25 | - name: Create Version 26 | id: version 27 | if: success() 28 | run: | 29 | VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,') 30 | # Strip "v" prefix from tag name 31 | [[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//') 32 | # set to output var 33 | echo ::set-output name=VERSION::${VERSION} 34 | 35 | - name: Build 36 | if: success() 37 | env: 38 | FORCE_COLOR: 1 39 | VERSION: ${{ steps.version.outputs.VERSION }} 40 | run: earthly -P --ci --output +all --VERSION=$VERSION 41 | 42 | - name: Upload Release Assets 43 | if: success() 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | run: gh release upload $BRANCH_NAME ./bin/protoc-gen-fieldmask*.zip 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | gen/ 3 | .idea/ 4 | vendor/ 5 | -------------------------------------------------------------------------------- /Earthfile: -------------------------------------------------------------------------------- 1 | VERSION 0.7 2 | 3 | ARG ALPINE_VERSION=3.21 4 | ARG GO_VERSION=1.23 5 | ARG LINTER_VERSION=v1.64.5 6 | FROM golang:$GO_VERSION-alpine$ALPINE_VERSION 7 | WORKDIR /app 8 | 9 | stage: 10 | COPY --dir go.mod go.sum ./ 11 | RUN go mod download -x 12 | COPY --dir protoc cmd . 13 | SAVE ARTIFACT /app 14 | 15 | vendor: 16 | FROM +stage 17 | RUN go mod vendor 18 | 19 | lint: 20 | # Installs golangci-lint to ./bin 21 | RUN wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s $LINTER_VERSION 22 | COPY +stage/app . 23 | RUN ./bin/golangci-lint run --skip-dirs=vendor --skip-dirs=./gen/ --timeout=10m --tests=true -E revive \ 24 | -E gosec -E unconvert -E goconst -E gocyclo -E goimports 25 | 26 | build: 27 | FROM +vendor 28 | # compile app binary, save as artifact 29 | ARG VERSION="dev" 30 | ARG GOOS 31 | ARG GOARCH 32 | RUN go build -ldflags="-s -w -X 'main.version=${VERSION}'" -mod=vendor -o bin/protoc-gen-fieldmask ./cmd/protoc-gen-fieldmask/... 33 | SAVE ARTIFACT ./bin/protoc-gen-fieldmask /protoc-gen-fieldmask 34 | 35 | zip: 36 | RUN apk add zip 37 | WORKDIR /artifacts 38 | ARG VERSION 39 | ARG ZIP_FILE_NAME 40 | ARG EXT 41 | COPY (+build/protoc-gen-fieldmask) protoc-gen-fieldmask${EXT} 42 | RUN zip -m protoc-gen-fieldmask-${VERSION}-${ZIP_FILE_NAME}.zip protoc-gen-fieldmask${EXT} 43 | SAVE ARTIFACT /artifacts 44 | 45 | build-all: 46 | WORKDIR /artifacts 47 | COPY (+zip/artifacts/*.zip --GOOS=darwin --GOARCH=amd64 --ZIP_FILE_NAME=osx-x86_64) . 48 | COPY (+zip/artifacts/*.zip --GOOS=linux --GOARCH=386 --ZIP_FILE_NAME=linux-x86_32) . 49 | COPY (+zip/artifacts/*.zip --GOOS=linux --GOARCH=amd64 --ZIP_FILE_NAME=linux-x86_64) . 50 | COPY (+zip/artifacts/*.zip --GOOS=windows --GOARCH=386 --ZIP_FILE_NAME=win32 --EXT=.exe) . 51 | COPY (+zip/artifacts/*.zip --GOOS=windows --GOARCH=amd64 --ZIP_FILE_NAME=win64 --EXT=.exe) . 52 | SAVE ARTIFACT /artifacts AS LOCAL bin 53 | 54 | test-gen: 55 | ARG DOCKER_PROTOC_VERSION=1.51_2 56 | FROM namely/protoc-all:$DOCKER_PROTOC_VERSION 57 | RUN mkdir /plugins 58 | COPY +build/protoc-gen-fieldmask /usr/local/bin/. 59 | COPY --dir protos . 60 | RUN entrypoint.sh -i protos -d protos/cases -l go -o gen 61 | RUN entrypoint.sh -i protos -d protos/cases/thirdpartyimport -l go -o gen 62 | RUN protoc -I/opt/include -Iprotos --fieldmask_out=gen protos/cases/*.proto protos/cases/thirdpartyimport/*.proto 63 | SAVE ARTIFACT gen /gen AS LOCAL test/gen 64 | 65 | test: 66 | FROM +vendor 67 | RUN apk add build-base 68 | COPY --dir test . 69 | COPY --dir +test-gen/gen test/. 70 | RUN go mod vendor 71 | RUN go test github.com/idodod/protoc-gen-fieldmask/test 72 | 73 | all: 74 | BUILD +lint 75 | BUILD +test 76 | BUILD +build-all 77 | 78 | clean: 79 | LOCALLY 80 | RUN rm -rf test/gen vendor 81 | 82 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ido David 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # protoc-gen-fieldmask 2 | 3 | [![CI](https://github.com/idodod/protoc-gen-fieldmask/actions/workflows/ci.yml/badge.svg)](https://github.com/idodod/protoc-gen-fieldmask/actions/workflows/ci.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/idodod/protoc-gen-fieldmask)](https://goreportcard.com/report/github.com/idodod/protoc-gen-fieldmask) 5 | ![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/idodod/protoc-gen-fieldmask) 6 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/idodod/protoc-gen-fieldmask) 7 | ![GitHub](https://img.shields.io/github/license/idodod/protoc-gen-fieldmask) 8 | 9 | A protoc plugin that generates fieldmask paths as static type properties for proto messages, which elimantes the usage of error-prone strings. 10 | 11 | For example, given the following proto messages: 12 | 13 | ```proto 14 | 15 | syntax = "proto3"; 16 | 17 | package example; 18 | 19 | option go_package = "example/;example"; 20 | 21 | import "google/type/date.proto"; 22 | 23 | message Foo { 24 | string baz = 1; 25 | int32 xyz = 2; 26 | Bar my_bar = 3; 27 | google.type.Date some_date = 4; 28 | } 29 | 30 | message Bar { 31 | string some_field = 1; 32 | bool another_field = 2; 33 | } 34 | ``` 35 | 36 | fieldmasks paths can be used as follows: 37 | 38 | ```golang 39 | foo := &example.Foo{} 40 | 41 | // Prints "baz" 42 | fmt.Println(foo.FieldMaskPaths().Baz()) 43 | 44 | // Prints "xyz" 45 | fmt.Println(foo.FieldMaskPaths().Xyz()) 46 | 47 | // Prints "my_bar" 48 | fmt.Println(foo.FieldMaskPaths().MyBar().String()) 49 | 50 | // Since baz is a nested message, we can print a nested path - "my_bar.some_field" 51 | fmt.Println(foo.FieldMaskPaths().MyBar().SomeField()) 52 | 53 | // Thirdparty messages work the same way: 54 | // Prints "some_date" 55 | fmt.Println(foo.FieldMaskPaths().SomeDate().String()) 56 | 57 | // Prints "some_date.year" 58 | fmt.Println(foo.FieldMaskPaths().SomeDate().Year()) 59 | ``` 60 | 61 | ## Usage 62 | 63 | ### Installation 64 | 65 | The plugin can be downloaded from the [release page](https://github.com/idodod/protoc-gen-fieldmask/releases/latest), and should be ideally installed somewhere available in your `$PATH`. 66 | 67 | ### Executing the plugin 68 | 69 | ```sh 70 | protoc --fieldmask_out=gen protos/example.proto 71 | 72 | # If the plugin is not in your $PATH: 73 | protoc --fieldmask_out=out_dir protos/example.proto --plugin=protoc-gen-fieldmask=/path/to/protoc-gen-fieldmask 74 | ``` 75 | 76 | ### Parameters 77 | 78 | The following parameters can be set by passing `--fieldmask_opt` to the command: 79 | 80 | * `maxdepth`: This option is relevant for a recursive message case.\ 81 | Specify the max depth for which the paths will be pregenerated. If the path depth gets over the max value, it will be generated at runtime. 82 | default value is `7`. 83 | 84 | ## Features 85 | 86 | * Currently the only supported language is `go`. 87 | * All paths are pregenerated (except for recursive messages past `maxdepth`). 88 | * Support all type of fields including repeated fields, maps, oneofs, third parties, nested messages and recursive messages. 89 | -------------------------------------------------------------------------------- /cmd/protoc-gen-fieldmask/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/idodod/protoc-gen-fieldmask/protoc" 12 | "google.golang.org/protobuf/compiler/protogen" 13 | ) 14 | 15 | const ( 16 | defaultMaxDepth = 7 17 | defaultLang = "go" 18 | ) 19 | 20 | var version = "dev" 21 | 22 | func main() { 23 | app := filepath.Base(os.Args[0]) 24 | showVersion := flag.Bool("version", false, "print the version and exit") 25 | flag.Parse() 26 | if *showVersion { 27 | fmt.Printf("%s %v\n", app, version) 28 | return 29 | } 30 | 31 | var flags flag.FlagSet 32 | maxDepth := flags.Uint("maxdepth", defaultMaxDepth, "") 33 | lang := flags.String("lang", defaultLang, "") 34 | protogen.Options{ 35 | ParamFunc: flags.Set, 36 | }.Run(func(plugin *protogen.Plugin) error { 37 | if strings.ToLower(*lang) != defaultLang { 38 | return errors.New("go is the only supported language at the moment") 39 | } 40 | if *maxDepth <= 0 { 41 | return errors.New("maxdepth must be bigger than 0") 42 | } 43 | return protoc.Generate(plugin, *maxDepth) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/idodod/protoc-gen-fieldmask 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/iancoleman/strcase v0.3.0 7 | github.com/stretchr/testify v1.9.0 8 | google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6 9 | google.golang.org/protobuf v1.34.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/google/go-cmp v0.6.0 // indirect 15 | github.com/pmezard/go-difflib v1.0.0 // indirect 16 | gopkg.in/yaml.v3 v3.0.1 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 4 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= 6 | github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 11 | google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6 h1:MTmrc2F5TZKDKXigcZetYkH04YwqtOPEQJwh4PPOgfk= 12 | google.golang.org/genproto v0.0.0-20240429193739-8cf5692501f6/go.mod h1:2ROWwqCIx97Y7CSyp11xB8fori0wzvD6+gbacaf5c8I= 13 | google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= 14 | google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 16 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 17 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 18 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 19 | -------------------------------------------------------------------------------- /protoc/generator.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "google.golang.org/protobuf/compiler/protogen" 5 | ) 6 | 7 | type generator interface { 8 | Generate(file *protogen.GeneratedFile) 9 | } 10 | -------------------------------------------------------------------------------- /protoc/helper.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "github.com/iancoleman/strcase" 5 | "google.golang.org/protobuf/compiler/protogen" 6 | ) 7 | 8 | func getInterfaceName(message *protogen.Message) string { 9 | return strcase.ToLowerCamel(string(message.Desc.Parent().FullName())) + message.GoIdent.GoName + "Int" + structSuffix 10 | } 11 | 12 | func getStructName(message *protogen.Message) string { 13 | return strcase.ToLowerCamel(string(message.Desc.Parent().FullName())) + message.GoIdent.GoName + structSuffix 14 | } 15 | 16 | func getStructNewFunction(message *protogen.Message) string { 17 | return "new" + strcase.ToCamel(getStructName(message)) 18 | } 19 | 20 | func getFilePath(f *protogen.File) string { 21 | return f.GeneratedFilenamePrefix + generatedExtension 22 | } 23 | 24 | func getFileHeaderComment(protoFile string) string { 25 | return "// Code generated by protoc-gen-fieldmask. DO NOT EDIT.\n" + 26 | "// source: " + protoFile 27 | } 28 | -------------------------------------------------------------------------------- /protoc/interface_generator.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "google.golang.org/protobuf/compiler/protogen" 5 | ) 6 | 7 | type interfaceGenerator struct { 8 | name string 9 | strFields []*protogen.Field 10 | msgFields []*protogen.Field 11 | } 12 | 13 | func newInterfaceGenerator(message *protogen.Message) *interfaceGenerator { 14 | return &interfaceGenerator{ 15 | name: getInterfaceName(message), 16 | } 17 | } 18 | 19 | // AddStringFields adds fields for which the fieldmask path is a simple string 20 | func (x *interfaceGenerator) AddStringFields(fields ...*protogen.Field) { 21 | x.strFields = append(x.strFields, fields...) 22 | } 23 | 24 | // AddMessageFields adds fields for which the fieldmask path is a nested message with additional nested paths 25 | func (x *interfaceGenerator) AddMessageFields(fields ...*protogen.Field) { 26 | x.msgFields = append(x.msgFields, fields...) 27 | } 28 | 29 | // Generate generates an interface with all fieldmask paths functions for the given type. 30 | func (x *interfaceGenerator) Generate(g *protogen.GeneratedFile) { 31 | g.P("type ", x.name, " interface {") 32 | for _, field := range x.strFields { 33 | g.P(field.GoName, "() string") 34 | } 35 | for _, field := range x.msgFields { 36 | g.P(field.GoName, "() *", getStructName(field.Message)) 37 | } 38 | g.P("}") 39 | g.P() 40 | } 41 | -------------------------------------------------------------------------------- /protoc/plugin.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "google.golang.org/protobuf/compiler/protogen" 5 | "google.golang.org/protobuf/reflect/protoreflect" 6 | ) 7 | 8 | const ( 9 | generatedExtension = ".pb.fieldmask.go" 10 | structSuffix = "FieldMaskPaths" 11 | ) 12 | 13 | // Generate will iterate over all given proto files and will generate fieldmask paths functions for each message 14 | func Generate(plugin *protogen.Plugin, maxDepth uint) error { 15 | seen := make(map[string]map[string]struct{}) 16 | for _, f := range plugin.Files { 17 | if !f.Generate { 18 | continue 19 | } 20 | m, exists := seen[f.GoImportPath.String()] 21 | if !exists { 22 | m = make(map[string]struct{}) 23 | seen[f.GoImportPath.String()] = m 24 | } 25 | generateFile(f, plugin, m, maxDepth) 26 | } 27 | return nil 28 | } 29 | 30 | func generateFile(f *protogen.File, plugin *protogen.Plugin, seen map[string]struct{}, maxDepth uint) { 31 | 32 | if len(f.Messages) > 0 { 33 | g := plugin.NewGeneratedFile(getFilePath(f), f.GoImportPath) 34 | g.P(getFileHeaderComment(f.Desc.Path())) 35 | g.P("package " + f.GoPackageName) 36 | g.P("") 37 | 38 | varsGenerator := newVarsGenerator(maxDepth) 39 | generators := []generator{varsGenerator} 40 | 41 | packageName := string(f.GoImportPath) 42 | for _, message := range f.Messages { 43 | generators = append(generators, generateFieldMaskPaths(g, packageName, message, "", seen, varsGenerator, maxDepth)...) 44 | } 45 | 46 | for _, generator := range generators { 47 | generator.Generate(g) 48 | } 49 | } 50 | } 51 | 52 | // generateFieldMaskPaths generates a FieldMaskPath struct for each proto message which will contain the fieldmask paths 53 | func generateFieldMaskPaths(g *protogen.GeneratedFile, generatedFileImportPath string, message *protogen.Message, currFieldPath string, seen map[string]struct{}, varsGenerator *varsGenerator, maxDepth uint) []generator { 54 | messageName := string(message.Desc.FullName()) 55 | if _, exists := seen[messageName]; exists { 56 | return nil 57 | } 58 | seen[messageName] = struct{}{} 59 | 60 | msgStructGenerator := newStructGenerator(message, maxDepth) 61 | msgInterfaceGenerator := newInterfaceGenerator(message) 62 | 63 | var generators []generator 64 | // only generate the fieldmask function if the message belongs to the current file's package 65 | if string(message.GoIdent.GoImportPath) == generatedFileImportPath { 66 | varsGenerator.AddMessage(message) 67 | generators = append(generators, msgInterfaceGenerator) 68 | } 69 | generators = append(generators, msgStructGenerator) 70 | 71 | for _, field := range message.Fields { 72 | if (field.Desc.Kind() != protoreflect.MessageKind && field.Desc.Kind() != protoreflect.GroupKind) || field.Desc.IsList() || field.Desc.IsMap() { 73 | msgInterfaceGenerator.AddStringFields(field) 74 | msgStructGenerator.AddStringFields(field) 75 | } else { 76 | msgInterfaceGenerator.AddMessageFields(field) 77 | msgStructGenerator.AddMessageFields(field) 78 | nextFieldPath := string(field.Desc.Name()) 79 | if currFieldPath != "" { 80 | nextFieldPath = currFieldPath + "." + nextFieldPath 81 | } 82 | generators = append(generators, generateFieldMaskPaths(g, generatedFileImportPath, field.Message, nextFieldPath, seen, varsGenerator, maxDepth)...) 83 | } 84 | } 85 | g.P() 86 | return generators 87 | } 88 | -------------------------------------------------------------------------------- /protoc/struct_generator.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "go/token" 5 | 6 | "github.com/iancoleman/strcase" 7 | "google.golang.org/protobuf/compiler/protogen" 8 | ) 9 | 10 | type structGenerator struct { 11 | name string 12 | strFields []*protogen.Field 13 | msgFields []*protogen.Field 14 | maxDepth uint 15 | } 16 | 17 | func newStructGenerator(message *protogen.Message, maxDepth uint) *structGenerator { 18 | return &structGenerator{ 19 | name: strcase.ToLowerCamel(string(message.Desc.Parent().FullName())) + message.GoIdent.GoName + structSuffix, 20 | maxDepth: maxDepth, 21 | } 22 | } 23 | 24 | // AddStringFields adds fields for which the fieldmask path is a simple string 25 | func (x *structGenerator) AddStringFields(fields ...*protogen.Field) { 26 | x.strFields = append(x.strFields, fields...) 27 | } 28 | 29 | // AddMessageFields adds fields for which the fieldmask path is a nested message with additional nested paths 30 | func (x *structGenerator) AddMessageFields(fields ...*protogen.Field) { 31 | x.msgFields = append(x.msgFields, fields...) 32 | } 33 | 34 | // safeFieldName generates a valid go identifier for the field name 35 | // 36 | // Additionally the and underscore is appended if the field name is 'fieldPath' or 'prefix' 37 | func safeFieldName(field *protogen.Field) string { 38 | out := strcase.ToLowerCamel(field.GoName) 39 | if token.IsKeyword(out) || out == "fieldPath" || out == "prefix" { 40 | return out + "_" 41 | } 42 | return out 43 | } 44 | 45 | // Generate generates a struct with all fieldmask paths functions for the given type. 46 | func (x *structGenerator) Generate(g *protogen.GeneratedFile) { 47 | // generate struct with all fields 48 | g.P("type ", x.name, " struct {") 49 | g.P("fieldPath string") 50 | g.P("prefix string") 51 | for _, field := range x.strFields { 52 | g.P(safeFieldName(field), " string") 53 | } 54 | for _, field := range x.msgFields { 55 | g.P(safeFieldName(field), " *", getStructName(field.Message)) 56 | } 57 | g.P("}") 58 | g.P() 59 | 60 | // generate ctor 61 | g.P("func ", "new"+strcase.ToCamel(x.name), "(fieldPath string, maxDepth int) *", x.name, " { ") 62 | g.P("if maxDepth <= 0 {") 63 | g.P("return nil") 64 | g.P("}") 65 | g.P("prefix := \"\"") 66 | g.P("if fieldPath != \"\" {") 67 | g.P("prefix = fieldPath + \".\"") 68 | g.P("}") 69 | g.P("return &", x.name, "{") 70 | g.P("fieldPath: fieldPath,") 71 | g.P("prefix: prefix,") 72 | for _, field := range x.strFields { 73 | g.P(safeFieldName(field), ": prefix + \"", field.Desc.Name(), "\",") 74 | } 75 | for _, field := range x.msgFields { 76 | fieldStructNewFunction := getStructNewFunction(field.Message) 77 | g.P(safeFieldName(field), ": ", fieldStructNewFunction, "(prefix + \"", field.Desc.Name(), "\", maxDepth - 1),") 78 | } 79 | g.P("}") 80 | g.P("}") 81 | g.P() 82 | 83 | // generate receiver methods 84 | g.P("func (x *", x.name, ") String() string { return x.fieldPath }") 85 | for _, field := range x.strFields { 86 | g.P("func (x *", x.name, ") ", field.GoName, "() string { return x.", safeFieldName(field), "}") 87 | } 88 | for _, field := range x.msgFields { 89 | varName := safeFieldName(field) 90 | fieldStructNewFunction := getStructNewFunction(field.Message) 91 | g.P("func (x *", x.name, ") ", field.GoName, "() *", getStructName(field.Message), " {") 92 | g.P("if x.", varName, "!= nil {") 93 | g.P("return x.", varName) 94 | g.P("}") 95 | g.P("return ", fieldStructNewFunction, "(x.prefix + \"", field.Desc.Name(), "\",", x.maxDepth, ")") 96 | g.P("}") 97 | } 98 | g.P() 99 | } 100 | -------------------------------------------------------------------------------- /protoc/vars_generator.go: -------------------------------------------------------------------------------- 1 | package protoc 2 | 3 | import ( 4 | "github.com/iancoleman/strcase" 5 | "google.golang.org/protobuf/compiler/protogen" 6 | ) 7 | 8 | type varsGenerator struct { 9 | messages []*protogen.Message 10 | maxDepth uint 11 | } 12 | 13 | func newVarsGenerator(maxDepth uint) *varsGenerator { 14 | return &varsGenerator{ 15 | maxDepth: maxDepth, 16 | } 17 | } 18 | 19 | // AddMessage adds proto message definitions for which to create an instance of generated fieldmaskpath type 20 | func (x *varsGenerator) AddMessage(messages ...*protogen.Message) { 21 | x.messages = append(x.messages, messages...) 22 | } 23 | 24 | // Generate generates the var definitions for all added messages 25 | func (x *varsGenerator) Generate(g *protogen.GeneratedFile) { 26 | for _, message := range x.messages { 27 | messageStructName := getStructName(message) 28 | structNewFunction := getStructNewFunction(message) 29 | localVarName := "local" + strcase.ToCamel(messageStructName) 30 | g.P("var ", localVarName, " = ", structNewFunction, "(\"\",", x.maxDepth, ")") 31 | } 32 | g.P() 33 | for _, message := range x.messages { 34 | messageInterfaceName := getInterfaceName(message) 35 | messageStructName := getStructName(message) 36 | localVarName := "local" + strcase.ToCamel(messageStructName) 37 | g.P("func (x *", message.GoIdent.GoName, ") ", structSuffix, "() ", messageInterfaceName, " {") 38 | g.P("return ", localVarName) 39 | g.P("}") 40 | } 41 | g.P() 42 | } 43 | -------------------------------------------------------------------------------- /protos/cases/from_other_file.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package cases; 4 | 5 | option go_package = "cases/;cases"; 6 | 7 | import "google/type/date.proto"; 8 | 9 | message YetAnotherTestNestedExternalMessage { 10 | string foo = 1; 11 | int32 bar = 2; 12 | google.type.Date baz = 3; 13 | } -------------------------------------------------------------------------------- /protos/cases/pkg_a.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package casesa; 4 | 5 | option go_package = "cases/a;a"; 6 | 7 | message Foo { 8 | string bar = 1; 9 | int32 baz = 2; 10 | } -------------------------------------------------------------------------------- /protos/cases/pkg_b.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package casesb; 4 | 5 | option go_package = "cases/b;b"; 6 | 7 | message Foo { 8 | string bar = 1; 9 | int32 baz = 2; 10 | } -------------------------------------------------------------------------------- /protos/cases/recursive.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package example; 4 | 5 | option go_package = "cases/;cases"; 6 | 7 | message Node { 8 | string name = 1; 9 | Node next = 2; 10 | } 11 | -------------------------------------------------------------------------------- /protos/cases/thirdpartyimport/a.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package thirdpartyimport; 4 | 5 | option go_package = "cases/thirdpartyimport;thirdpartyimport"; 6 | 7 | import "google/type/date.proto"; 8 | 9 | message FooA { 10 | string bar = 1; 11 | int32 baz = 2; 12 | google.type.Date some_date = 3; 13 | } -------------------------------------------------------------------------------- /protos/cases/thirdpartyimport/b.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package thirdpartyimport; 4 | 5 | option go_package = "cases/thirdpartyimport;thirdpartyimport"; 6 | 7 | import "google/type/date.proto"; 8 | 9 | message FooB { 10 | string bar = 1; 11 | int32 baz = 2; 12 | google.type.Date some_other_date = 3; 13 | } -------------------------------------------------------------------------------- /protos/cases/types.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package cases; 4 | 5 | option go_package = "cases/;cases"; 6 | 7 | import "cases/from_other_file.proto"; 8 | import "google/protobuf/any.proto"; 9 | import "google/type/date.proto"; 10 | 11 | enum MyEnum { 12 | UNDEFINED = 0; 13 | ONE = 1; 14 | TWO = 2; 15 | THREE = 3; 16 | } 17 | 18 | message TestNestedExternalMessage { 19 | string foo = 1; 20 | int32 bar = 2; 21 | } 22 | 23 | message Foo { 24 | double my_double_field = 1; 25 | float my_float_field = 2; 26 | int32 my_int_32_field = 3; 27 | int64 my_int_64_field = 4; 28 | uint32 my_uint_32_field = 5; 29 | uint64 my_uint_64_field = 6; 30 | sint32 my_sint_32_field = 7; 31 | sint64 my_sint_64_field = 8; 32 | fixed32 my_fixed_32_field = 9; 33 | fixed64 my_fixed_64_field = 10; 34 | sfixed32 my_sfixed_32_field = 11; 35 | sfixed64 my_sfixed_64_field = 12; 36 | bool my_bool_field = 13; 37 | string my_string_field = 14; 38 | bytes my_bytes_field = 15; 39 | MyEnum my_enum_field = 16; 40 | google.protobuf.Any my_any_field = 17; 41 | oneof my_oneof_field { 42 | string option_1 = 18; 43 | google.protobuf.Any option_2 = 19; 44 | } 45 | map my_map_field = 20; 46 | repeated string my_string_list_field = 21; 47 | repeated google.protobuf.Any my_any_list_field = 22; 48 | google.type.Date my_date_field = 23; 49 | TestNestedExternalMessage my_nested_ext_msg = 24; 50 | 51 | message TestNestedInternalMessage { 52 | string foo = 1; 53 | int32 bar = 2; 54 | } 55 | 56 | TestNestedInternalMessage my_nested_int_msg = 25; 57 | YetAnotherTestNestedExternalMessage my_yet_another_test_nested_external_msg = 26; 58 | } 59 | 60 | message TestReservedGoFieldNames { 61 | // These fields are reserved Go field names and should be escaped. 62 | // copied from https://golang.org/ref/spec#Keywords 63 | 64 | string break = 2; 65 | string case = 3; 66 | string chan = 4; 67 | string const = 5; 68 | string continue = 6; 69 | 70 | string default = 7; 71 | string defer = 8; 72 | string else = 9; 73 | string fallthrough = 10; 74 | string for = 11; 75 | 76 | string func = 12; 77 | string go = 13; 78 | string goto = 14; 79 | string if = 15; 80 | string import = 16; 81 | 82 | string interface = 17; 83 | string map = 18; 84 | string package = 19; 85 | string range = 20; 86 | string return = 21; 87 | 88 | string select = 22; 89 | string struct = 23; 90 | string switch = 24; 91 | string type = 25; 92 | string var = 26; 93 | } 94 | 95 | message EmptyMessage {} 96 | 97 | message TestMessageWithEmptyMessage { 98 | EmptyMessage empty = 1; 99 | } 100 | -------------------------------------------------------------------------------- /test/cases_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/iancoleman/strcase" 9 | "github.com/idodod/protoc-gen-fieldmask/test/gen/cases" 10 | "github.com/idodod/protoc-gen-fieldmask/test/gen/cases/a" 11 | "github.com/idodod/protoc-gen-fieldmask/test/gen/cases/b" 12 | "github.com/idodod/protoc-gen-fieldmask/test/gen/cases/thirdpartyimport" 13 | "github.com/stretchr/testify/suite" 14 | "google.golang.org/protobuf/proto" 15 | "google.golang.org/protobuf/types/known/fieldmaskpb" 16 | ) 17 | 18 | type TestSuite struct { 19 | suite.Suite 20 | maxDepth int 21 | } 22 | 23 | func TestTestSuite(t *testing.T) { 24 | suite.Run(t, new(TestSuite)) 25 | } 26 | 27 | func (s *TestSuite) SetupTest() { 28 | s.maxDepth = 100 29 | } 30 | 31 | func (s *TestSuite) TestTypes() { 32 | testCases := map[string]struct { 33 | msg proto.Message 34 | }{ 35 | "all field types get a fieldmask path": {msg: &cases.Foo{}}, 36 | "an external nested message also gets fieldmasks when it's a parent message": {msg: &cases.TestNestedExternalMessage{}}, 37 | "an internal nested message also gets fieldmasks when it's a parent message": {msg: &cases.Foo_TestNestedInternalMessage{}}, 38 | "an external nested message from another file also gets fieldmasks when it's a parent message": {msg: &cases.YetAnotherTestNestedExternalMessage{}}, 39 | "a message with reserved go field names works": {msg: &cases.TestReservedGoFieldNames{}}, 40 | "a message with field that is an empty message works": {msg: &cases.TestMessageWithEmptyMessage{}}, 41 | "messages with the same name and a different package get fieldmasks (a.Foo, 1/2)": {msg: &a.Foo{}}, 42 | "messages with the same name and a different package get fieldmasks (b.Foo, 2/2)": {msg: &b.Foo{}}, 43 | "messages from different proto files, in the same package can get fieldmask for 3rd-parties (1/2)": {msg: &thirdpartyimport.FooA{}}, 44 | "messages from different proto files, in the same package can get fieldmask for 3rd-parties (2/2)": {msg: &thirdpartyimport.FooB{}}, 45 | "recursive message works": {msg: &cases.Node{}}, 46 | } 47 | 48 | for name, testCase := range testCases { 49 | s.Run(name, func() { 50 | msgTypeCountMap := make(map[string]int) 51 | msgPtr := testCase.msg 52 | msgPtrType := reflect.TypeOf(msgPtr) 53 | fmPaths, res := msgPtrType.MethodByName("FieldMaskPaths") 54 | s.Run("FieldMaskPaths exists", func() { 55 | s.Require().True(res) 56 | }) 57 | fmVal := fmPaths.Func.Call([]reflect.Value{reflect.ValueOf(msgPtr)})[0] 58 | ref := fmVal.Type() 59 | s.Run("FieldMaskPaths does not have a String method", func() { 60 | _, res := ref.MethodByName("String") 61 | s.Assert().False(res) 62 | }) 63 | 64 | s.Run("All fields have valid paths", func() { 65 | paths := s.collectAndAssertPaths("", msgPtrType, fmVal, msgTypeCountMap) 66 | s.Assert().NotZero(len(paths), "number of paths cannot be zero") 67 | fm, err := fieldmaskpb.New(msgPtr, paths...) 68 | s.Require().NoError(err) 69 | s.T().Log(fm) 70 | }) 71 | }) 72 | } 73 | } 74 | 75 | func (s *TestSuite) collectAndAssertPaths(parent string, protoMessagePtrType reflect.Type, fieldMaskValue reflect.Value, typesMap map[string]int) []string { 76 | el := protoMessagePtrType.Elem() 77 | name := el.PkgPath() + "." + el.Name() 78 | if c, exists := typesMap[name]; exists && c > s.maxDepth { 79 | return nil 80 | } 81 | typesMap[name]++ 82 | 83 | var paths []string 84 | for i := 0; i < protoMessagePtrType.NumMethod(); i++ { 85 | method := protoMessagePtrType.Method(i) 86 | if !method.IsExported() || !strings.HasPrefix(method.Name, "Get") { 87 | continue 88 | } 89 | funcType := method.Func.Type() 90 | if funcType.NumOut() != 1 { 91 | continue 92 | } 93 | 94 | out := funcType.Out(0) 95 | if out.Kind() == reflect.Interface { 96 | // skipping oneof's 97 | continue 98 | } 99 | fieldMaskMethodName := strings.TrimPrefix(method.Name, "Get") 100 | m := fieldMaskValue.MethodByName(fieldMaskMethodName) 101 | s.Assert().False(m.IsZero()) 102 | s.Assert().Zero(m.Type().NumIn()) 103 | s.Assert().Equal(1, m.Type().NumOut()) 104 | outType := m.Type().Out(0) 105 | expected := strcase.ToSnake(fieldMaskMethodName) 106 | if parent != "" { 107 | expected = parent + "." + expected 108 | } 109 | fv := m.Call(nil)[0] 110 | var res string 111 | if outType.Kind() == reflect.String { 112 | res = fv.String() 113 | s.Assert().Equal(expected, res) 114 | } else if outType.Kind() == reflect.Ptr { 115 | strMethod, exists := outType.MethodByName("String") 116 | s.Assert().True(exists, "String method does not exists for %s", fieldMaskMethodName) 117 | res = strMethod.Func.Call([]reflect.Value{fv})[0].String() 118 | s.Assert().Equal(expected, res) 119 | paths = append(paths, s.collectAndAssertPaths(res, out, fv, typesMap)...) 120 | } 121 | paths = append(paths, res) 122 | } 123 | return paths 124 | } 125 | -------------------------------------------------------------------------------- /test/gen/cases/a/package.go: -------------------------------------------------------------------------------- 1 | package a 2 | 3 | -------------------------------------------------------------------------------- /test/gen/cases/b/package.go: -------------------------------------------------------------------------------- 1 | package b 2 | 3 | -------------------------------------------------------------------------------- /test/gen/cases/package.go: -------------------------------------------------------------------------------- 1 | package cases 2 | 3 | -------------------------------------------------------------------------------- /test/gen/cases/thirdpartyimport/package.go: -------------------------------------------------------------------------------- 1 | package thirdpartyimport 2 | 3 | --------------------------------------------------------------------------------