A protoc/buf plugin that generates a working
ent-backed gRPC server from
protobuf-orm entities.
Where protoc-gen-orm-service emits the service contract and protoc-gen-orm-go emits the Go glue, this plugin emits the implementation: the ent schema for your tables, the proto↔ent conversion code, and gRPC service servers that run CRUD against an ent client.
The handler runs four sub-generators in order:
| App | Output | Contents |
|---|---|---|
| Schema | schema/<name>.go |
ent schema types — Fields(), Edges(), Indexes(), Annotations(). |
| Ent | ent/<name>.go, ent/orm.g.go |
proto ↔ ent conversion helpers for each entity, and what the ent client will not say about itself. |
| Server | server/.../<name>.g.go |
<Entity>ServiceServer{ Db *ent.Client } implementing Add/Get/Patch/Apply/Erase. |
| Store | store.g.go |
server/client registration wiring (like protoc-gen-orm-go's store). |
The generated ent schema (schema/*.go) is then consumed by ent's own code
generator (ent generate) to produce the runtime ent package the servers use.
ent/orm.g.go is the one output that is about the package rather than about an
entity, and it is there because it has nowhere else to be: ent keeps the driver
on an unexported field, so only a file inside that package can read it.
Client.Dialect() |
the SQL this client speaks — the servers write raw SQL for Apply and have to know which. |
Client.Driver() |
what the client runs through, which is what a shared transaction is begun on. |
Client.WithDriver() |
the same client on another driver, hooks and interceptors intact. |
Client.InTx() |
whether the client is already bound to a transaction of ent's own. |
Without these a caller would have to repeat, to every server it builds, what it already said when it opened the connection — and a caller that repeats itself can disagree with itself.
The generated servers import
protoc-gen-orm-ent/runtime, a module of its own so that the
plugin does not depend on ent and a consumer of the plugin does not build it:
runtime/entpatch |
renders a compiled patch document as ent statements. |
runtime/enttx |
lets a transaction stand in for a driver, so a whole server stack shares one. |
runtime/entpage |
the half of a list that is not about the domain: a keyset cursor, and a size a request cannot blow past. |
entpage is the one of those a generated server does not import. Nothing
generates a list — what a list filters by is the app's, and there is no general
answer to it — but the paging is the same for every entity and is the half
people get wrong: offsets that skip a row when something is inserted ahead of
the page, an order with ties and no tiebreaker, a limit a request can set to a
million. So the filtering is written out and the paging is borrowed.
It is one module rather than one per package because a generated server imports all of it: split up, a consumer would carry a pin per package and would have to keep those pins agreeing with each other and with the plugin that wrote the imports.
runtime/entpatchwasprotoc-gen-orm-ent/entpatch. The path appears only in generated code, so moving costs one regeneration — which upgrading the plugin asks for anyway.
Example of the generated ent schema for a User entity:
func (User) Fields() []ent.Field {
return []ent.Field{
field.UUID("id", uuid.UUID{}).Unique().Immutable(),
field.String("alias").Optional(),
field.String("name").Optional(),
field.JSON("labels", map[string]string{}).Optional(),
...
}
}An app usually has something to say about writes and something to say about reads: keep an audit trail, invalidate a cache, publish an event; show a caller only their own rows, hide what was soft-deleted. Both look like work for a layer stacked in front of these servers, and both are worse there.
A write cannot be watched from in front. Add, Get, Patch, Apply and
Erase are five RPCs and four code paths: Patch turns its request into a
patch document and joins Apply's path inside this server, below anything
that can be stacked on top. A layer in front sees two shapes, has to convert one
of them itself, once per entity and once more for every entity added afterwards
— and still cannot tell a document that wrote something from one that only
asserted.
A read can be narrowed from in front, and that is the problem. It means
overriding Get, Patch, Apply and Erase, once per entity, forever. It is
the same override written out again and again, and the copies drift: a rule
fixed in one of them stays wrong in the next.
And a key is made up out of nothing, which is the third place with the same shape: something has to decide, the decision is the same for every entity, and an app that wants a say has nowhere to have it.
So all three are hooks on the generated server.
Recorder |
told about every write | inside the transaction that makes it, before it is committed |
Scope |
asked before every read | and its predicate goes into the query |
Minter |
asked for the key of every Add |
for an entity keyed by a uuid, and only for those |
// A Scope with something to say about one entity and nothing about the others.
// Embedding Unscoped is what keeps that from being a method per entity -- and
// what keeps an entity added to the schema later from being a compile error.
type wall struct{ bare.Unscoped }
func (wall) HolderScope(ctx context.Context) (predicate.Holder, error) { ... }
s, err := bare.NewServer(db,
bare.WithRecorder(trail), // given twice it adds, rather than replacing
bare.WithScope(wall{}),
bare.WithMinter(mint),
)Minter is one method rather than one per entity, and the difference says
which is which. A predicate is typed per entity and no single signature can
carry them all; a key is the same type for every entity keyed by a uuid, and
the only thing that varies is which entity is being asked about -- so that
arrives as its full name, "app.Holder".
bare.MinterFunc(func(ctx context.Context, entity string, given uuid.UUID, ok bool) (uuid.UUID, error) {
if ok {
return given, nil // ...or refuse it; an error here is the caller's answer
}
return mintFor(entity), nil
})It is asked after the bytes a request carried were read, so it never sees
something that is not already a key -- "that is not sixteen bytes" is refused
where it always was. A nil Minter keeps what a request named and makes up a
v4 for a request that named nothing, which is what these servers did before
there was anywhere to say otherwise.
No hook teaches this generator what it is for. It emits them and calls them; whether the predicate is about tenancy, ownership or a soft delete, whether the recorder writes a trail or drops a cache, and what makes a key worth having, is the app's to say.
Three things follow from where they are:
- A write and what is recorded about it are one write.
AddandEraseopen a transaction they would not otherwise need, and only while a recorder is configured.Erasealso reads the row before deleting it, since a request may have named it by an alias and an alias is not what a trail is read back with. - Narrowing is not refusing. A row out of scope is a row the query does not
match, so a
Getof it isNotFoundand anApplyof it says no row was matched. That is usually the right answer: that something exists is itself something a caller who may not see it should not be told. - Neither applies to
Add. It builds a row rather than finding one. Whether a caller may create something, or point an edge at something they cannot see, is not a predicate, and it belongs in front.
A Change carries two names, and they answer different questions. Method is
what the caller asked for — the RPC gRPC dispatched, which is the whole
request's and not this leg of it, so an RPC written by hand that ends in an
Apply is on the trail under its own name. By is the RPC of this server
that made the write, which says which entity and which kind of write without a
field for either. A trail takes Method; anything that acts on the row — a
cache to drop, an event to publish — takes By.
The server a recorder is handed carries neither hook: it does not record, so a trail cannot audit itself into a loop, and it is not narrowed, so it can read what it has just been told about.
<Entity>Narrow is where the scope is asked, and it is a function of the
package rather than only a method, because the read most likely to forget is the
one nothing generates. A List is not CRUD, so it is written by hand, and what
it has at hand is a client and a scope — given only a method it reaches for the
hook directly and misses whatever narrowing means besides the hook.
An entity that declares an
erased field
is not deleted. Erase stamps the column instead, and everything else follows
from one line in <Entity>Narrow:
google.protobuf.Timestamp date_erased = 13 [(orm.field) = {erased: {}}];// A row that was erased is not a row a read answers with.
ps = append(ps, note.DateErasedIsNil())That predicate is unconditional and deliberately not something the scope was asked for. A scope says what this caller may see; this says what there is to see at all, and an app that could leave it out would be an app that could leave it out by accident.
Get, Patch, Apply |
NotFound, because the row is not matched — no second rule |
Erase of an erased row |
succeeds and stamps nothing, which is what erasing what is not there has always done |
Add |
has no field for it: a row is added alive |
| a unique index | covers only the rows still there, so an erased row gives up its name |
Two of those are worth a second look.
The version moves with an erase, if the entity has one. Nothing can compare-and-swap against the row afterwards — it is out of reach — but a row brought back by hand would otherwise return holding a version that was current before it left, and a client that had read it then would find its test still passing across a gap it never saw.
MySQL is refused. A unique index that covers only the live rows is a partial
index; MySQL has none, and entsql.IndexWhere is simply not written out for it,
so the schema would come up with a plain unique index and an alias freed by an
erasure would stay taken with nothing anywhere to say so. NewServer refuses
that dialect for a schema in which anything erases softly, beside the refusal
that is already there for the dialects Apply writes no SQL for.
Both spellings of unique free their value. A field marked unique is
ordinarily a constraint over every row of the table, which would hold the value
of an erased row for ever while a declared index of the same entity gave it up.
One of the two behaving differently is worse than either, so for a soft-erasing
entity the field one is promoted: .Unique() comes off the field and a partial
index goes on beside the declared ones. Nothing above notices -- which props are
keys, and so what shape their Ref has, is graph's to say and it reads the
field's own unique. Only the SQL moves.
There is no way to read an erased row through these servers, and no option to ask for one. An app that needs to restore something, or to look at what was erased, writes that by hand against the client — which is what it does for every other RPC nothing generates.
apps/schema/app/x-fields.go maps each prop's ORM Type to an ent field
builder:
| ORM type | ent builder |
|---|---|
BOOL |
field.Bool |
INT32/SINT32/SFIXED32, ENUM |
field.Int32 |
UINT32/FIXED32 |
field.Uint32 |
INT64/SINT64/SFIXED64 |
field.Int64 |
UINT64/FIXED64 |
field.Uint64 |
FLOAT / DOUBLE |
field.Float32 / field.Float |
STRING / BYTES |
field.String / field.Bytes |
UUID / TIME |
field.UUID / field.Time |
JSON, map<>, repeated |
field.JSON |
Constraints map to ent modifiers: key → .Unique().Immutable(), unique →
.Unique(), nullable (non-key, non-JSON) → .Nillable(), immutable →
.Immutable(), optional (non-key) → .Optional().
This plugin is the last stage of a multi-plugin buf.gen.yaml (it relies on the
proto, gRPC, service, and Go-helper outputs existing):
version: v2
plugins:
- local: [go, tool, google.golang.org/protobuf/cmd/protoc-gen-go] # messages
- local: [go, tool, google.golang.org/grpc/cmd/protoc-gen-go-grpc] # gRPC stubs
- local: [go, run, github.com/protobuf-orm/protoc-gen-orm-service] # service .proto
out: ./proto
- local: [go, run, github.com/protobuf-orm/protoc-gen-orm-go] # Go helpers
- local: [go, run, "."] # this plugin
opt:
- ent.namer=ent/{{ .Name }}.goAfter buf generate, run ent generate over the produced schema/ package to
materialize the ent runtime. It needs the sql/modifier feature: Apply writes
a patch document as one statement, through column expressions ent has no builder
for.
ent generate ./schema --target ./ent --feature sql/modifierOptions:
| Option | Default | Meaning |
|---|---|---|
ent.namer |
ent/{{ .Name }}.go |
template for the proto↔ent helper filename. |
ent.client |
orm.g.go |
filename, beside those, for what is about the package rather than an entity. |
main.go / handler.go flag parsing; parses files into a graph.Graph; runs the 4 apps
apps/schema/app/ ent schema (x-fields.go, x-edges.go, x-annotations.go, type.go)
apps/ent/app/ proto ↔ ent conversion (x-proto.go), client accessors (x-client.go)
apps/server/app/ gRPC servers backed by ent (x-add/get/patch/apply/erase/select/pick.go)
apps/store/app/ registration wiring
internal/work/ file/name/import bookkeeping (Name.Go(), Name.Ent())
internal/ent/ PascalCase helper (vendored from ent)
internal/strs/ protobuf name-casing helpers (vendored from protobuf-go)
runtime/ SEPARATE MODULE — what the generated servers import (see below)
internal/apptest/ SEPARATE MODULE — integration fixtures + tests (see below)
The plugin itself depends only on protobuf-orm, google.golang.org/protobuf,
and go-openapi/inflect — so a consumer that just runs the generator pulls a
tiny dependency set (the plugin binary compiles ~4 modules).
runtime/ is its own Go module for the same reason from the other side: it
needs ent because it renders ent statements and wraps ent drivers, and a
consumer imports it from the generated servers rather than from the generator.
Keeping it apart is what lets the plugin stay free of ent.
The heavy test-only dependencies — ariga.io/atlas, mattn/go-sqlite3 (cgo),
google.golang.org/grpc, testify, … — are used only by the generated
integration suite. To keep them out of consumers' builds, internal/apptest/ is
a third module (…/internal/apptest); those deps live in its go.mod, not
the plugin's. It replaces the runtime with the working tree, so a change to
either the generator or the runtime is exercised by the same go test.
Because they are nested modules, go build ./... / go test ./... from the
repo root operate on the plugin only. Work on the other two from inside
their directories.
A (git-ignored) go.work ties the plugin module, runtime, the
internal/apptest module, and the sibling protobuf-orm / protoc-gen-orm-go
/ protoc-gen-orm-service checkouts together so local changes flow through.
# the plugin
go build ./... # plugin packages only (the other two are separate modules)
go vet ./...
# what the generated servers import
cd runtime && go test ./...
# regenerate + exercise the integration suite
buf generate # full plugin pipeline over proto/ (writes into internal/apptest)
./gen-ent.sh # run `ent generate` for the schema
cd internal/apptest && go test ./... # server integration tests against sqlite
internal/apptestonly compiles once the codegen pipeline (buf generate + ent generate) has produced the proto and ent runtime; ago mod tidythere is meaningful only after that.