A protoc/buf plugin that generates gRPC service definitions (.proto)
from messages annotated with protobuf-orm
options.
You annotate an entity once; this plugin emits its Add / AddMany / Get /
Patch / Apply / Erase service and the request/response messages those RPCs need. The output is
itself a .proto file, so it feeds back into your normal protoc pipeline (e.g.
protoc-gen-go-grpc, or protoc-gen-orm-go /
protoc-gen-orm-ent for the implementation).
Given an ORM entity:
message User {
bytes id = 1 [(orm.field) = {type: TYPE_UUID, key: true}];
Tenant tenant = 2;
string alias = 4 [(orm.field) = {default: ""}];
string name = 5 [(orm.field) = {default: ""}];
map<string, string> labels = 7;
google.protobuf.Timestamp date_updated = 14 [(orm.field) = {version: {}}];
google.protobuf.Timestamp date_created = 15 [(orm.field) = {immutable: true, default: ""}];
option (orm.message) = {
rpc: {crud: true}
indexes: [{name: "alias", unique: true, refs: [{name: "alias", number: 4}, {name: "tenant", number: 2}]}]
};
}it produces user_svc.g.proto:
service UserService {
rpc Add(UserAddRequest) returns (User);
rpc Get(UserGetRequest) returns (User);
rpc Patch(UserPatchRequest) returns (User);
rpc Apply(UserApplyRequest) returns (User);
rpc Erase(UserRef) returns (google.protobuf.Empty);
}
message UserAddRequest { ... } // creatable fields (no key, no version)
message UserRefByAlias { ... } // one message per unique index
message UserRef { oneof key { ... } } // key OR any unique index
message UserSelect { ... } // field mask (bool per prop)
message UserGetRequest { UserRef ref = 1; UserSelect select = 2; }
message UserPatchRequest { ... } // nullable fields get a *_null flag; version gets *_force
message UserApplyRequest { UserRef ref = 1; patch.Patch patch = 2; }Highlights of the mapping:
UserRefis aoneofover the primary key and every unique index (each unique index also gets its ownUserRefBy<Index>message).UserAddRequestcontains only the props you can supply at creation time (the key andversionfields are excluded).UserSelectis a field mask used byGet.UserPatchRequestadds a<field>_nullboolean for nullable fields and a<field>_forceboolean for the optimistic-lockingversionfield.UserApplyRequestcarries apatch.Patchdocument instead of a field per prop, for the updates a fixed request shape cannot express — edit one map key, splice a list, or test-then-set. See Apply.UserAddManyRequestis there when the entity asks for AddMany. See AddMany.
add: {many: {}} beside an entity's Add asks for AddMany, which adds many rows
in one call; crud alone does not:
option (orm.message) = {rpc: {crud: true, add: {many: {max: 200}}}};rpc AddMany(UserAddManyRequest) returns (UserAddManyResponse);
message UserAddManyRequest { repeated UserAddRequest items = 1; }
message UserAddManyResponse { repeated User items = 1; }An item is the entity's own UserAddRequest, so whatever reads an Add's request
reads an item the same way. The rows come back in the order of the items, and
the call is all of them or none. max is the most items one call may carry,
1000 when unset; the comment on the RPC says which.
Patch spends one request field per prop, so it can only say "set this prop to
this value". Apply takes a
patch.Patch document instead —
the operations travel inside it, so the request only has to name the entity:
message UserApplyRequest {
UserRef ref = 1;
patch.Patch patch = 2;
}The document addresses fields of User, not of the request. That buys the edits
UserPatchRequest has no shape for: removing a single map key rather than
replacing labels wholesale, inserting into a list at an index, or asserting a
current value before writing.
There is no <field>_force counterpart either. A document states optimistic
locking as a test entry on the version field, and omitting that entry is the
same statement _force makes. Apply is atomic in the same way the format is: if
any entry fails — a test that does not hold, a field that has moved — nothing
is written.
Apply is emitted wherever Patch is, since protobuf-orm has no apply option
of its own for (orm.message).rpc to carry.
Because the generated .proto now imports patch/patch.proto, add the module to
your buf.yaml:
deps:
- buf.build/orm/orm
- buf.build/patch/patchThe patch schema is not frozen. It is changed in place, so pin it with
buf.lockand re-read its format.md when you move.
Add the plugin to your buf.gen.yaml:
version: v2
plugins:
- local: [go, run, github.com/protobuf-orm/protoc-gen-orm-service]
out: ./protoOptions (opt):
| Option | Default | Meaning |
|---|---|---|
namer |
{{ .Name }}_svc.g.proto |
Go text/template for the output filename per file. |
main.go flag parsing; wires protogen → Handler
handler.go parses files into a protobuf-orm graph.Graph, runs app
app/
app.go per-file driver: one ast.File per source, one service per entity
work.go builds the AST (messages, refs, selects, requests) from the graph
type.go proto type-string mapping for a field (incl. map<>)
x-*.go one file per RPC (add/get/patch/apply/erase/ref/select)
internal/ast/ a small proto-syntax AST + printer used to emit the .proto text
internal/apptest/ fixtures (generated, gitignored) + a test that drives the plugin
The generator never writes .proto text directly: it builds an
internal/ast tree (File → Service / Message / Enum …)
and prints it through ast.Printer, which handles indentation, package-relative
type names, and import ordering.
This repo uses a go.work that points at the sibling protobuf-orm checkout, so
local changes to the library are picked up immediately.
buf generate # regenerate apptest fixtures under proto/ and internal/apptest/
go test ./... # internal/ast, plus internal/apptest end-to-end
go build ./...internal/apptest is generated and gitignored, so buf generate has to have run
at least once before go test ./... will build. Its test runs the plugin over
those fixtures by reading their descriptors out of the protobuf registry, which
means it needs no protoc or buf of its own — and it asserts on the same text
that lands in proto/apptest/*_svc.g.proto.