entdomain

package module
v1.4.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 25, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

README

entdomain

CI CodeRabbit Pull Request Reviews Security OpenSSF Scorecard Go Reference Go Report Card License

An ent extension that generates a pure Go domain layer from your ent schema — with zero ORM dependency in the domain package.

Overview

When using ent in a clean architecture project, the generated types (ent.User, ent.UserCreate, etc.) carry DB-layer concerns and cannot be used directly as domain entities. entdomain solves this by generating:

  • internal/domain/{entity}.go — Pure Go structs with no ent imports
  • ent/domain.goToDomain() and ApplyDomain() mapping methods on ent types

The domain package stays in sync with your ent schema automatically — no manual drift.

Installation

go get github.com/danhtran94/entdomain

Setup

Register the extension in your ent/entc.go:

//go:build ignore

package main

import (
    "log"

    "entgo.io/ent/entc"
    "entgo.io/ent/entc/gen"
    "github.com/danhtran94/entdomain"
)

func main() {
    ex, err := entdomain.NewExtension(
        entdomain.WithPackagePath("internal/domain"), // output dir (relative to module root)
        entdomain.WithPackageName("domain"),           // generated package name

        // Disable bulk generation for specific entities:
        entdomain.WithNoBulk("Post", "Order"),

        // Or disable bulk generation for all entities:
        // entdomain.WithNoBulk(),
    )
    if err != nil {
        log.Fatalf("creating entdomain extension: %v", err)
    }
    if err := entc.Generate("./schema",
        &gen.Config{
            // Optional: enable ent upsert support — entdomain auto-detects this
            // and generates ApplyDomain on *EntityUpsertOne / *EntityUpsertBulk.
            Features: []gen.Feature{gen.FeatureUpsert},
        },
        entc.Extensions(ex),
    ); err != nil {
        log.Fatalf("running ent codegen: %v", err)
    }
}
Custom Layout

WithPackagePath and WithProtoDir are resolved relative to the module root (the directory containing go.mod), not relative to the ent directory. This means you can place ent anywhere in your project tree:

myproject/          ← go.mod here (module root)
├── repo/
│   ├── schema/
│   └── ent/        ← ent output
└── internal/
    └── domain/     ← WithPackagePath("internal/domain") resolves here ✓
entdomain.WithPackagePath("internal/domain"),  // relative to go.mod, not to ent dir

One caveat: when the schema directory is outside the ent directory, ent derives the generated package name from the schema's parent directory rather than from Target. Set gen.Config.Package explicitly to get the correct import path:

if err := entc.Generate("../schema",
    &gen.Config{
        Target:  ".",
        Package: "github.com/myorg/myproject/repo/ent", // required when schema is outside ent dir
    },
    entc.Extensions(ex),
); err != nil { ... }

See examples/custom/ for a working example of this layout.

Schema Annotations

Opt in per entity and per edge. Entities without entdomain.Entity() are skipped entirely.

Entity
func (User) Annotations() []schema.Annotation {
    return []schema.Annotation{
        entdomain.Entity(), // basic — scalar fields only

        // with virtual fields:
        entdomain.Entity(
            entdomain.VirtualField("full_name", entdomain.String),
            entdomain.VirtualField("is_premium", entdomain.Bool),
            entdomain.VirtualField("metadata", entdomain.GoType("map[string]any")),
        ),
    }
}
Edges
func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("posts", Post.Type).
            Annotations(
                entdomain.Edge(entdomain.IDs()),              // → PostIDs []int
                // entdomain.Edge(entdomain.Nest()),          // → Posts []Post
                // entdomain.Edge(entdomain.IDs(), entdomain.Nest()), // → both
            ),

        edge.To("profile", Profile.Type).Unique().
            Annotations(
                entdomain.Edge(entdomain.IDs()),              // → ProfileID int
            ),
    }
}
Annotation Domain field ToDomain() ApplyDomain (create/update)
IDs() PostIDs []int from Edges.Posts AddPostIDs (if len > 0) / replace by default
Nest() Posts []Post from Edges.Posts skipped
IDs(), Nest() both from Edges.Posts AddPostIDs only (if len > 0)
Virtual Fields

Virtual fields appear in the domain struct but have no corresponding ent schema field. They are set to their zero value by ToDomain() — the caller (or a Transformer) is responsible for hydrating them.

entdomain.VirtualField("full_name", entdomain.String)          // → string
entdomain.VirtualField("is_premium", entdomain.Bool)           // → bool
entdomain.VirtualField("count",     entdomain.Int)             // → int
entdomain.VirtualField("ratio",     entdomain.Float64)         // → float64
entdomain.VirtualField("amount",   entdomain.GoType("Money"))                                               // → Money
entdomain.VirtualField("tags",     entdomain.GoType("[]string"))                                            // → []string
entdomain.VirtualField("metadata", entdomain.GoType("map[string]any"))                                      // → map[string]any
entdomain.VirtualField("price",    entdomain.GoType("Decimal", "github.com/shopspring/decimal"))             // → decimal.Decimal
entdomain.VirtualField("price2",   entdomain.GoType("*Decimal", "github.com/shopspring/decimal"))            // → *decimal.Decimal
entdomain.VirtualField("ext_id",   entdomain.GoType("UUID", "github.com/google/uuid"))                      // → uuid.UUID
entdomain.VirtualField("opt_ref",  entdomain.GoType("*Money"))                                              // → *Money

Generated Output

Domain struct (internal/domain/user.go)

No ent imports. Optional fields become pointers. Enum types are re-declared with the entity name as prefix to avoid cross-entity collisions.

package domain

import "time"

type UserStatus string

const (
    UserStatusActive   UserStatus = "active"
    UserStatusInactive UserStatus = "inactive"
)

type User struct {
    ID          int
    Name        string
    Bio         *string        // optional → pointer
    Status      UserStatus
    CreatedAt   time.Time
    PostIDs     []int          // IDs edge
    Posts       PostList       // Nest edge (plural)
    PinnedPost  *Post          // Nest edge (singular) → pointer
    FullName    string         // virtual field
    IsPremium   bool           // virtual field
    Metadata    map[string]any // virtual field
}

// UserList is generated unless WithNoBulk is set for the entity.
type UserList []*User
Mapping methods (ent/domain.go)
// Read: ent → domain
func (e *User) ToDomain() *domain.User

// Create: domain → ent builder
func (c *UserCreate) ApplyDomain(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) (*UserCreate, error)

// Update by ID: domain → ent builder
func (u *UserUpdateOne) ApplyDomain(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) (*UserUpdateOne, error)

// Update by WHERE condition: domain → ent builder, chain .Where(...) after
func (u *UserUpdate) ApplyDomain(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) (*UserUpdate, error)

// Upsert (generated only when gen.FeatureUpsert is enabled in gen.Config)
func (u *UserUpsertOne) ApplyDomain(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) (*UserUpsertOne, error)
func (u *UserUpsertBulk) ApplyDomain(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) (*UserUpsertBulk, error) // absent when NoBulk

// Every ApplyDomain* method has a panicking X-variant that mirrors ent's
// SaveX / FirstX / OnlyX convention. Use in tests and scripts for fluent
// chaining; prefer the error-returning form on request paths.
func (c *UserCreate) ApplyDomainX(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) *UserCreate
func (u *UserUpdateOne) ApplyDomainX(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) *UserUpdateOne
func (u *UserUpdate) ApplyDomainX(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) *UserUpdate
func (u *UserUpsertOne) ApplyDomainX(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) *UserUpsertOne
func (u *UserUpsertBulk) ApplyDomainX(ctx context.Context, d *domain.User, opts ...entdomain.ApplyOption) *UserUpsertBulk
Bulk methods (ent/domain.go, unless WithNoBulk is set)
// Slice mapper
func (es Users) ToDomain() domain.UserList

// Create bulk
func (c *UserClient) CreateBulkDomain(ctx context.Context, ds domain.UserList, opts ...entdomain.ApplyOption) (*UserCreateBulk, error)
func (c *UserClient) CreateBulkDomainX(ctx context.Context, ds domain.UserList, opts ...entdomain.ApplyOption) *UserCreateBulk

// Update bulk by ID — mirrors UserCreateBulk API
func (c *UserClient) UpdateBulkDomain(ctx context.Context, ds domain.UserList, opts ...entdomain.ApplyOption) (*UserUpdateOneBulk, error)
func (c *UserClient) UpdateBulkDomainX(ctx context.Context, ds domain.UserList, opts ...entdomain.ApplyOption) *UserUpdateOneBulk

func (b *UserUpdateOneBulk) Save(ctx context.Context) (domain.UserList, error)
func (b *UserUpdateOneBulk) SaveX(ctx context.Context) domain.UserList
func (b *UserUpdateOneBulk) Exec(ctx context.Context) error
func (b *UserUpdateOneBulk) ExecX(ctx context.Context)
Fluent chaining via X variants (opt-in panic)

Every error-returning ApplyDomain* / CreateBulkDomain / UpdateBulkDomain has a panicking sibling with the X suffix. The X variant restores fluent chaining by converting the error return into a panic — exactly matching ent's SaveX / FirstX / OnlyX convention:

// Error-returning, production-safe — recommended on request paths
builder, err := client.User.Create().ApplyDomain(ctx, d)
if err != nil { return err }
created, err := builder.Save(ctx)

// Panicking, fluent — idiomatic in tests and scripts
created := client.User.Create().ApplyDomainX(ctx, d).SaveX(ctx)

Guidance: transformer errors are typically I/O (KMS timeouts, signer unavailable, network partition) — recoverable, transient, routinely observed in production. The default (non-X) form returns an error so these failures are handled, not crashed. Reach for ApplyDomainX only where panic is acceptable: tests, one-off scripts, migrations with a supervisor, contexts where you know transformers are pure.

Typed field constants
const (
    UserDomainFieldName    UserDomainField = "name"
    UserDomainFieldStatus  UserDomainField = "status"
    UserDomainFieldPostIDs UserDomainField = "post_ids"
    // ...
)

Upsert Support

When ent's gen.FeatureUpsert is enabled in gen.Config.Features, entdomain automatically detects it and generates ApplyDomain on the *EntityUpsertOne and *EntityUpsertBulk builders — no additional annotation is required.

// Single upsert — conflict on "email", apply domain fields on conflict:
client.User.Create().
    ApplyDomain(d).
    OnConflict(sql.ConflictColumns("email")).
    ApplyDomain(d).   // ← on *UserUpsertOne
    Exec(ctx)

// Bulk upsert — uniform conflict resolution across all rows:
client.User.CreateBulkDomain(ds).
    OnConflict(sql.ConflictColumns("email")).
    ApplyDomain(d).   // ← on *UserUpsertBulk (absent when NoBulk is set)
    Exec(ctx)
Field Handling in Upsert

*EntityUpsert has SetX / ClearX but no SetNillableX. The upsert methods handle each field category as follows:

Field type Upsert behaviour
Non-nillable scalar / enum uu.SetX(val) — same as UpdateOne
Nillable scalar if d.X != nil { uu.SetX(*d.X) } — nil means leave unchanged
Nillable enum if d.X != nil { uu.SetX(EntType(*d.X)) }
Immutable field skipped — same as UpdateOne
Edge IDs skipped — ent upsert does not support edge mutations
Virtual fields skipped — no corresponding *EntityUpsert setter exists

*EntityUpsertBulk.ApplyDomain is suppressed for entities that have WithNoBulk set — consistent with the existing bulk generation policy.

FIQL Filtering

entdomain generates a typed FIQL filter entry point per entity. FIQL expressions are URI-safe without percent-encoding — ideal for GET query parameters.

Schema Annotation

Fields opt in explicitly. No field is filterable unless annotated — sensitive fields are never accidentally exposed.

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").
            Annotations(entdomain.Field(
                entdomain.FIQL(entdomain.EQ, entdomain.NEQ, entdomain.Contains),
            )),

        field.Int("score").
            Annotations(entdomain.Field(
                entdomain.FIQL(entdomain.EQ, entdomain.GT, entdomain.LT, entdomain.GTE, entdomain.LTE),
            )),

        field.Enum("status").Values("active", "inactive").
            Annotations(entdomain.Field(
                entdomain.FIQL(entdomain.EQ, entdomain.NEQ),
            )),

        field.Time("created_at").
            Annotations(entdomain.Field(
                entdomain.FIQL(entdomain.GTE, entdomain.LTE),
            )),

        field.String("password_hash"), // no FIQL → never filterable
    }
}
Operator Constants
Constant FIQL syntax Valid for
EQ == all types (string, int, float, bool, time, enum, uuid)
NEQ != all types except bool
GT =gt= int, float, time
LT =lt= int, float, time
GTE =ge= int, float, time
LTE =le= int, float, time
Contains =like= string
HasPrefix =prefix= string
In =in= string, int, float, enum, uuid (value syntax: field=in=(a,b,c))
NotIn =out= string, int, float, enum, uuid (value syntax: field=out=(a,b,c))
IsNull =is=null every field — but only when annotated AND Optional() / Nillable()
NotNull =is=notnull every field — but only when annotated AND Optional() / Nillable()

UUID values are parsed via uuid.Parse from github.com/google/uuid — accepts canonical 36-char hyphenated form, braced ({...}), urn:uuid:..., and 32-char hex without hyphens.

Logical: ; = AND, , = OR, ( ) = grouping. AND binds tighter than OR (standard FIQL precedence).

Filtering by nullness

The =is= operator translates to SQL IS NULL / IS NOT NULL. Required because chained NEQ (bio!=foo;bio!=bar) doesn't substitute — SQL three-valued logic excludes NULL rows from any NEQ comparison.

The field must be Optional() or Nillable() (ent only generates XxxIsNil / XxxNotNil predicate methods for those). Annotating a non-optional field with IsNull / NotNull is a hard codegen error naming the field.

field.String("bio").Optional().
    Annotations(entdomain.Field(entdomain.FIQL(
        entdomain.IsNull,
        entdomain.NotNull,
    ))),
GET /users?filter=bio=is=null         → WHERE bio IS NULL
GET /users?filter=bio=is=notnull      → WHERE bio IS NOT NULL
GET /users?filter=name==john,bio=is=null  → WHERE name = ? OR bio IS NULL

Each direction gates independently — annotating only IsNull allows bio=is=null and rejects bio=is=notnull with the standard "operator not allowed" error.

Generated Code (ent/fiql.go)
// Code generated by entdomain. DO NOT EDIT.

var UserFIQLFields = entdomain.FIQLFields[predicate.User]{
    "name":       entdomain.FIQLString[predicate.User]{EQ: user.NameEQ, NEQ: user.NameNEQ, Contains: user.NameContains},
    "score":      entdomain.FIQLInt[predicate.User]{EQ: user.ScoreEQ, GT: user.ScoreGT, LT: user.ScoreLT, GTE: user.ScoreGTE, LTE: user.ScoreLTE, In: user.ScoreIn, NotIn: user.ScoreNotIn},
    "bio":        entdomain.FIQLString[predicate.User]{IsNil: user.BioIsNil, NotNil: user.BioNotNil},
    "status":     entdomain.FIQLEnum[predicate.User]{
        EQ:  map[string]predicate.User{"active": user.StatusEQ(user.StatusActive), "inactive": user.StatusEQ(user.StatusInactive)},
        NEQ: map[string]predicate.User{"active": user.StatusNEQ(user.StatusActive), "inactive": user.StatusNEQ(user.StatusInactive)},
    },
    "created_at": entdomain.FIQLTime[predicate.User]{GTE: user.CreatedAtGTE, LTE: user.CreatedAtLTE},
    "external_id": entdomain.FIQLUUID[predicate.User]{EQ: user.ExternalIDEQ, NEQ: user.ExternalIDNEQ},
}

func UserFIQL(expr string) (predicate.User, error) {
    return entdomain.ParseFIQL(expr, UserFIQLFields)
}
HTTP Handler Usage
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    q := h.client.User.Query()
    if expr := r.URL.Query().Get("filter"); expr != "" {
        pred, err := ent.UserFIQL(expr)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadRequest)
            return
        }
        q = q.Where(pred)
    }
    users, err := q.All(r.Context())
    // ...
}

GET request — no percent-encoding required:

GET /users?filter=name==john;score=gt=25,status==active
GET /users?filter=(status==active,status==inactive);created_at=ge=2024-01-01T00:00:00Z
Error Handling
unknown field "email" — annotate with entdomain.FIQL(...) to enable
operator "=gt=" not allowed on field "name" (String) — allowed: ==, !=, =like=
unknown enum value "pending" for field "status" — valid values: active, inactive
invalid integer value "abc" for field "score": strconv.Atoi: ...
invalid time value "not-a-time" for field "created_at": ...
Skipped fields summary

When the proto generator excludes a field (custom UUID GoType, field.Bytes, untyped JSON, entdomain.SkipProto() annotation, etc.), the reason is recorded in a sibling proto/entpb/.entdomain.skipped.json artifact rather than vanishing silently. Inspect after make gen if a field you expected in the proto output is missing — the file lists {message, field, reason} triples sorted deterministically.

Extending the generator

Two extension points have a single source of truth:

  • Adding a new ent field type to the FIQL pipeline → edit kinds.go:resolveFieldKind. A (FieldKind, reason) return propagates to the FIQL template (via fieldFIQLKindFn) and to the proto generator's ExcludedReason. Add the matching FIQL{Kind} runtime type to fiql.go if the kind needs a new struct.
  • Adding a new FIQL operator → add the constant to fiql.go's FIQLOp block, register it in the opByName map, and add an {{ if isOp "<Name>" $op }} branch to template/fiql.tmpl. TestOpRegistryCovered (fiql_internal_test.go) fails if you forget the registry entry.
Known Limitations
  • UUID fields with custom GoType(...) — only the canonical github.com/google/uuid.UUID type is wired. The codegen explicitly checks f.Type.RType and skips any other GoType, so a custom UUID field is omitted from the FIQL registry rather than producing a signature-mismatched generated file. The proto generator inherits the same gate; skipped fields surface in proto/entpb/.entdomain.skipped.json.
  • =in= / =out= constraints — bool and time fields don't support set membership (=in=(true,false) is meaningless; time uses range operators). Maximum 100 values per list. Values containing , or ) cannot be used in lists — chain == / != for those.
  • =is=null / =is=notnull constraints — only valid on fields declared Optional() or Nillable() (ent emits the underlying XxxIsNil / XxxNotNil predicates only there). Annotating a required field with IsNull / NotNull is a codegen error naming the field. The value vocabulary is strict — =is=nil, =is=empty, =is=present all return unknown =is= value. There is no implicit "" → null conversion: bio== is rejected as empty-value; if you need "empty string OR null" use bio==,bio=is=null. SQL three-valued logic still applies — bio!=foo excludes NULL bio rows.
  • Time values — must be RFC3339 format (2006-01-02T15:04:05Z07:00).
  • Nesting depth — maximum 50 levels; deeper expressions return "maximum nesting depth exceeded".
  • Edge fields — cross-entity filtering (e.g. owner.name==john) is out of scope.
  • JSON/virtual fields — no DB column mapping; cannot be used as FIQL fields.

Apply Options

Control which fields ApplyDomain writes to the ent builder:

entdomain.OmitZeroVal()                      // skip fields with zero values
entdomain.OmitNil()                          // skip nil pointer fields
entdomain.OmitFields("bio", "score")         // skip specific fields
entdomain.OnlyFields("name", "status")       // allowlist specific fields
entdomain.AppendEdge("post_ids")             // append edge IDs instead of replacing

OmitZeroVal on create can silently skip intentional zero values — use with care.

Example
// Update only name and status, append new posts rather than replace
u.UpdateOneID(id).
    ApplyDomain(d,
        entdomain.OnlyFields(ent.UserDomainFieldName, ent.UserDomainFieldStatus),
        entdomain.AppendEdge(ent.UserDomainFieldPostIDs),
    ).
    Save(ctx)

Transformer (Virtual Fields)

For virtual fields that require custom logic, wire a per-entity transformer at startup. Only set the functions you need — unset ones are skipped by ToDomain() and ApplyDomain().

// generated in ent/domain.go
type UserDomainTransformer struct {
    // Pure synchronous projection, invoked by ToDomain. No ctx, no error.
    GetFullName func(e *User) string

    // Invoked during ApplyDomain. Receives ctx + the full domain struct
    // (sibling-field access for key derivation / AAD binding) and may return
    // an error. May perform I/O (KMS-backed field encryption, signing, etc.).
    SetFullNameOnCreate func(ctx context.Context, c *UserCreate, d *domain.User, val string) error
    SetFullNameOnUpdate func(ctx context.Context, u *UserUpdateOne, d *domain.User, val string) error
    // one Get + two Set functions per virtual field
}

var UserTransformer *UserDomainTransformer // nil by default

Wire at app startup (infrastructure handles captured in closure):

func registerTransformers(kms KMS) {
    ent.UserTransformer = &ent.UserDomainTransformer{
        // Pure projection — no ctx, no error.
        GetFullName: func(u *ent.User) string {
            return u.FirstName + " " + u.LastName
        },
        // Stateful transform with sibling field access + ctx-aware I/O.
        SetSecretTokenOnCreate: func(ctx context.Context, c *ent.UserCreate, d *domain.User, val string) error {
            subkey, err := kms.DeriveKey(ctx, d.TenantID) // sibling field used to scope encryption
            if err != nil {
                return fmt.Errorf("derive key: %w", err)
            }
            c.SetSecretToken(encrypt(subkey, val))
            return nil
        },
        // other functions left nil — skipped automatically
    }
}

See ADR-005 for the design rationale (why ctx+error, why sibling access, why this shape aligns with ent's hook/privacy conventions).

Advanced: ent hook integration via entdomain.WithDomain (opt-in)

Most domain-to-ent encoding belongs in transformers above. For the narrower case where work must happen at ent-hook time — outbox event emission, cross-cutting audit logging, tenant-isolation checks that depend on virtual-field values — entdomain exposes a small escape hatch:

// entdomain package
func WithDomain[T any](ctx context.Context, d T) context.Context
func DomainFrom[T any](ctx context.Context) (T, bool)

Usage at the repository boundary:

func (r *UserRepo) Create(ctx context.Context, d *domain.User) (*domain.User, error) {
    ctx = entdomain.WithDomain(ctx, d) // explicit opt-in at call site

    builder, err := r.client.User.Create().ApplyDomain(ctx, d)
    if err != nil { return nil, err }

    created, err := builder.Save(ctx) // hooks now see d via DomainFrom[*domain.User](ctx)
    if err != nil { return nil, err }

    return created.ToDomain(), nil
}

Registered ent hook can then retrieve the domain struct, including virtual fields:

client.User.Use(func(next ent.Mutator) ent.Mutator {
    return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
        d, ok := entdomain.DomainFrom[*domain.User](ctx)
        if !ok {
            // Not an entdomain-originated mutation — skip domain-aware logic.
            return next.Mutate(ctx, m)
        }
        // d.TenantID, d.VirtualField, etc. available here.
        return next.Mutate(ctx, m)
    })
})

When to use WithDomain vs a transformer:

Use case Pick
Field-level encryption, signing, tokenization Transformer
Virtual field that must flow through to an ent column Transformer
Audit log / outbox event / cross-cutting concern needing domain context at Save time Hook + WithDomain
Tenant-isolation check reading virtual-field values Hook + WithDomain

Caveats:

  • WithDomain uses context.Value, which Go's guidance reserves for request-scoped data. Static analyzers may flag call sites. That is expected — the smell lives at the opt-in site, not inside the library. Transformers remain the first-class path.
  • Hook authors must handle the absent case via the ok return. Bare .Save(ctx) calls that skipped WithDomain will see ok == false.
  • Distinct T parameters produce distinct context keys, so WithDomain(ctx, user) and WithDomain(ctx, post) coexist cleanly.
  • The library does not auto-stash inside ApplyDomain. Callers opt in explicitly so the ctx chain stays visible.

Repository Adapter Example

// This layer owns ent — domain package has zero knowledge of it.

func (r *UserRepo) GetByID(ctx context.Context, id int) (*domain.User, error) {
    u, err := r.client.User.Query().
        Where(user.ID(id)).
        WithPosts().
        Only(ctx)
    if err != nil {
        return nil, err
    }
    return u.ToDomain(), nil
}

func (r *UserRepo) Create(ctx context.Context, d *domain.User) (*domain.User, error) {
    builder, err := r.client.User.Create().ApplyDomain(ctx, d)
    if err != nil {
        return nil, err
    }
    created, err := builder.Save(ctx)
    if err != nil {
        return nil, err
    }
    return created.ToDomain(), nil
}

func (r *UserRepo) Update(ctx context.Context, d *domain.User) (*domain.User, error) {
    builder, err := r.client.User.UpdateOneID(d.ID).ApplyDomain(ctx, d,
        entdomain.OnlyFields(ent.UserDomainFieldName, ent.UserDomainFieldStatus),
    )
    if err != nil {
        return nil, err
    }
    updated, err := builder.Save(ctx)
    if err != nil {
        return nil, err
    }
    return updated.ToDomain(), nil
}

func (r *UserRepo) CreateBulk(ctx context.Context, ds domain.UserList) (domain.UserList, error) {
    bulk, err := r.client.User.CreateBulkDomain(ctx, ds)
    if err != nil {
        return nil, err
    }
    saved, err := bulk.Save(ctx)
    if err != nil {
        return nil, err
    }
    result := make(domain.UserList, len(saved))
    for i, u := range saved {
        result[i] = u.ToDomain()
    }
    return result, nil
}

func (r *UserRepo) UpdateBulk(ctx context.Context, ds domain.UserList) (domain.UserList, error) {
    bulk, err := r.client.User.UpdateBulkDomain(ctx, ds)
    if err != nil {
        return nil, err
    }
    return bulk.Save(ctx)
}

func (r *UserRepo) DeactivateAll(ctx context.Context) error {
    builder, err := r.client.User.Update().ApplyDomain(ctx,
        &domain.User{Status: domain.UserStatusInactive},
        entdomain.OnlyFields(ent.UserDomainFieldStatus),
    )
    if err != nil {
        return err
    }
    return builder.Where(user.StatusEQ(user.StatusActive)).Exec(ctx)
}

// Upsert — requires gen.FeatureUpsert in gen.Config.Features
func (r *UserRepo) Upsert(ctx context.Context, d *domain.User) error {
    createBuilder, err := r.client.User.Create().ApplyDomain(ctx, d)
    if err != nil {
        return err
    }
    upsertBuilder, err := createBuilder.
        OnConflict(sql.ConflictColumns("username")).
        ApplyDomain(ctx, d)
    if err != nil {
        return err
    }
    return upsertBuilder.Exec(ctx)
}

func (r *UserRepo) UpsertBulk(ctx context.Context, ds domain.UserList) error {
    bulk, err := r.client.User.CreateBulkDomain(ctx, ds)
    if err != nil {
        return err
    }
    upsertBulk, err := bulk.
        OnConflict(sql.ConflictColumns("username")).
        ApplyDomain(ctx, ds[0]) // same conflict resolution for all rows
    if err != nil {
        return err
    }
    return upsertBulk.Exec(ctx)
}

Proto Generation

entdomain can generate .proto message files and domain↔proto mappers alongside the existing domain layer. This is opt-in and keeps the domain package itself proto-free.

Enable in entc.go
ex, err := entdomain.NewExtension(
    entdomain.WithPackagePath("internal/domain"),
    entdomain.WithPackageName("domain"),

    entdomain.WithProto(
        entdomain.WithProtoDir("proto"),              // output dir, relative to module root
        entdomain.WithProtoPackageName("entpb"),      // proto package name
        entdomain.WithProtoGoPackage("github.com/myorg/myrepo/proto/entpb;entpb"),
    ),
)
Generated Output
proto/entpb/
  .entdomain.lock.json     ← stable field number registry — commit this file
  ent_messages.proto       ← all entity messages in one file

internal/domain/
  pbmap/                   ← domain ↔ proto mappers (package pbmap)
    user_proto_gen.go
    post_proto_gen.go
    proto_helpers_gen.go   ← shared helpers (ToInt64Slice, MapToProtoStruct, etc.)
Mapper Usage
import "github.com/myorg/myrepo/internal/domain/pbmap"

// domain → proto
p := pbmap.UserToProto(user)
ps := pbmap.UserListToProto(users)

// proto → domain
d := pbmap.UserFromProto(req.User)
ds := pbmap.UserListFromProto(req.Users)
Field Opt-out (SkipProto)

Any ent field or edge can be excluded from proto output:

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("name"),
        field.String("password_hash").
            Annotations(entdomain.Field(entdomain.SkipProto())),
    }
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        // Break a mutual Nest cycle on one side:
        edge.From("owner", User.Type).Ref("posts").Unique().
            Annotations(
                entdomain.Edge(entdomain.IDs(), entdomain.Nest()),
                entdomain.Field(entdomain.SkipProto()),
            ),
    }
}
Custom Proto Type

ProtoType() works with both VirtualField() and Field(), making it usable in two contexts:

Virtual fields

Fields with a GoType that has no well-known mapping are excluded by default. Supply an explicit type to include them:

entdomain.VirtualField("amount",
    entdomain.GoType("Decimal", "github.com/shopspring/decimal"),
    entdomain.ProtoType("google.type.Money", "google/type/money.proto"),
)
Ent JSON fields (Field(ProtoType(...)))

Typed JSON fields are excluded by default (except map[string]any which auto-maps to google.protobuf.Struct). Use Field(ProtoType(...)) to opt any JSON field into proto:

// Typed map → Struct (would otherwise be excluded)
field.JSON("labels", map[string]string{}).
    Annotations(entdomain.Field(
        entdomain.ProtoType("google.protobuf.Struct", "google/protobuf/struct.proto"),
    ))

// Slice → repeated scalar (IsRepeated is auto-inferred from the [] prefix)
field.JSON("tag_names", []string{}).
    Annotations(entdomain.Field(entdomain.ProtoType("string")))

// Custom struct → hand-defined proto message
// WithConversion provides the Go conversion expressions (%s = source expression).
// The actual converter functions must be hand-written in the pbmap package.
field.JSON("metadata", domain.UserMetadata{}).
    Annotations(entdomain.Field(
        entdomain.ProtoType("UserMetadata", "entpb/user_metadata.proto").
            WithConversion("UserMetadataToProto(%s)", "UserMetadataFromProto(%s)"),
    ))
Proto Helper Functions

The generated proto_helpers_gen.go file in the pbmap package includes shared conversion helpers used across all entity mappers:

func ToInt64Slice(ids []int) []int64
func FromInt64Slice(ids []int64) []int
func ToInt64Ptr(v *int) *int64
func FromInt64Ptr(v *int64) *int
func MapToProtoStruct(m map[string]any) *structpb.Struct  // for map[string]any fields
func ProtoStructToMap(s *structpb.Struct) map[string]any  // inverse of MapToProtoStruct
// ... and more

MapToProtoStruct and ProtoStructToMap are used automatically for field.JSON("x", map[string]any{}) fields. When using Field(ProtoType(...)).WithConversion(...) on other JSON fields, the hand-written conversion functions in the pbmap package are called instead.

Built-in Auto-Mappings
entdomain / ent type Proto type Import
entdomain.String string
entdomain.Bool bool
entdomain.Int int64
entdomain.Float64 double
GoType("Time", "time") google.protobuf.Timestamp google/protobuf/timestamp.proto
GoType("Duration", "time") google.protobuf.Duration google/protobuf/duration.proto
GoType("UUID", "github.com/google/uuid") string
field.Time(...) google.protobuf.Timestamp google/protobuf/timestamp.proto
field.UUID(...) string
field.Enum(...) top-level enum
field.JSON("x", map[string]any{}) google.protobuf.Struct google/protobuf/struct.proto
field.JSON("x", []T{}) + Field(ProtoType("T")) repeated T depends on T
field.JSON("x", OtherType{}) excluded (use Field(ProtoType(...)) to opt in)
Nest Edges in Proto

Nest edges are included in the proto output by default, generating embedded messages:

message User {
  repeated int64 post_ids = 8;   // IDs edge
  repeated Post  posts    = 9;   // Nest edge
}

Proto3 supports circular message types at the wire level (e.g. User nests Tag and Tag nests User). Use SkipProto() on one side to break the cycle in the generated proto if desired.

Field Number Stability

Proto field numbers are tracked in .entdomain.lock.json. Commit this file — it ensures wire compatibility across schema changes. Removed fields are permanently reserved and never reused.


Design Notes

  • Nested edge mutations (creating/updating child entities) are intentionally not generated — manage them in the repository layer
  • Virtual fields are always zero in ToDomain() unless a Transformer is wired; each transformer function field is nil-checked individually before calling
  • Immutable ent fields are excluded from UpdateOne.ApplyDomain, Update.ApplyDomain, and UpsertOne/Bulk.ApplyDomain
  • Nested edges (Nest()) are excluded from ApplyDomain entirely; only IDs() edges are written
  • Edge IDs are additionally excluded from upsert ApplyDomain — ent's *EntityUpsert type does not support edge mutations
  • Virtual field transformer hooks are excluded from upsert ApplyDomain — transformer setters are typed to *UserCreate / *UserUpdateOne only
  • Upsert nillable fields use an explicit d.X != nil guard with dereference (uu.SetX(*d.X)) instead of SetNillableX*EntityUpsert has no SetNillable* methods
  • (*UserUpdate).ApplyDomain does not call virtual field transformer hooks — transformer setters are typed to *UserUpdateOne
  • Optional() fields without .Nillable() are stored as base types in ent (string, int, …) but mapped to pointers in the domain struct by taking their address — &e.Bio. The zero value is never nil in this case; use .Nillable() in the schema if you need nil-distinguishable optionals
  • WithNoBulk is configured at extension level, not per schema — keeps schema annotations focused on domain shape, not generation policy; it also suppresses *EntityUpsertBulk.ApplyDomain
  • Upsert generation is auto-detected from gen.Config.Features — no entdomain annotation or config option is needed

See docs/ADR-001-entdomain-extension.md for the full design rationale.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	// String is a FieldType for string.
	String = FieldType{TypeName: "string"}
	// Bool is a FieldType for bool.
	Bool = FieldType{TypeName: "bool"}
	// Int is a FieldType for int.
	Int = FieldType{TypeName: "int"}
	// Float64 is a FieldType for float64.
	Float64 = FieldType{TypeName: "float64"}
)
View Source
var (
	// DomainTemplate generates mapper methods (ToDomain, ApplyDomain) in the ent/ dir.
	DomainTemplate = parseT("template/domain.tmpl")

	// FIQLTemplate generates FIQL field registries and entry-point functions in the ent/ dir.
	FIQLTemplate = parseT("template/fiql.tmpl")

	// TemplateFuncs contains the extra template functions used by entdomain.
	TemplateFuncs = template.FuncMap{
		"domainNodes":         domainNodesFn,
		"entityAnnotation":    entityAnnotationFn,
		"edgeAnnotation":      edgeAnnotationFn,
		"domainImportPath":    domainImportPathFn,
		"domainPkgName":       domainPkgNameFn,
		"virtualFieldType":    virtualFieldTypeFn,
		"isEnum":              func(f *gen.Field) bool { return f.Type.Type == field.TypeEnum },
		"isNillable":          func(f *gen.Field) bool { return f.Optional || f.Nillable },
		"isJSONField":         func(f *gen.Field) bool { return f.Type.Type == field.TypeJSON || f.Type.Type == field.TypeBytes },
		"hasEnumFields":       hasEnumFieldsFn,
		"hasUpsert":           hasUpsertFn,
		"hasFIQLNodes":        hasFIQLNodesFn,
		"hasFIQLFields":       hasFIQLFieldsFn,
		"fieldFIQLAnnotation": fieldFIQLAnnotationFn,
		"fieldFIQLKind":       fieldFIQLKindFn,
		"isOp":                isOpFn,
		"lower":               strings.ToLower,
		"singular":            func(s string) string { return gen.Funcs["singular"].(func(string) string)(s) },
		"pascal":              func(s string) string { return gen.Funcs["pascal"].(func(string) string)(s) },
		"snake":               func(s string) string { return gen.Funcs["snake"].(func(string) string)(s) },
	}
)

Functions

func DomainFrom

func DomainFrom[T any](ctx context.Context) (T, bool)

DomainFrom retrieves a domain struct of type T previously stashed via WithDomain. Returns the zero value of T and false when no value of that type is present. Always check ok — bare .Save(ctx) callers (who never called WithDomain) will see ok == false.

Example in an ent hook:

client.User.Use(func(next ent.Mutator) ent.Mutator {
    return hook.UserFunc(func(ctx context.Context, m *ent.UserMutation) (ent.Value, error) {
        d, ok := entdomain.DomainFrom[*domain.User](ctx)
        if !ok {
            // Not an entdomain-originated mutation; run default path.
            return next.Mutate(ctx, m)
        }
        // Use d (including virtual fields) here.
        return next.Mutate(ctx, m)
    })
})

func FIQL

func FIQL(ops ...FIQLOp) fieldOption

FIQL returns a fieldOption that enables FIQL filtering on a field with the given operators. Only the listed operators will be accepted at runtime; others return an error.

Example:

field.String("name").Annotations(entdomain.Field(
    entdomain.FIQL(entdomain.EQ, entdomain.NEQ, entdomain.Contains),
))

func ParseFIQL

func ParseFIQL[P Predicate](expr string, fields FIQLFields[P]) (P, error)

ParseFIQL parses a FIQL expression and returns an ent predicate using the provided field registry. Returns an error for unknown fields, disallowed operators, or malformed expressions.

FIQL syntax:

  • field==value equality
  • field!=value inequality
  • field=gt=value greater than (numeric/time fields)
  • field=lt=value less than
  • field=ge=value greater than or equal
  • field=le=value less than or equal
  • field=like=value contains substring (string fields)
  • field=prefix=value has prefix (string fields)
  • expr;expr AND (higher precedence)
  • expr,expr OR (lower precedence)
  • (expr) grouping

func SkipProto

func SkipProto() fieldOption

SkipProto returns a fieldOption that excludes the field from proto generation.

func VirtualField

func VirtualField(name string, ft FieldType, opts ...virtualFieldOption) entityOption

VirtualField returns an entityOption that adds a virtual field. Optional virtualFieldOptions (e.g., ProtoType) can be passed.

func WithDomain

func WithDomain[T any](ctx context.Context, d T) context.Context

WithDomain returns a derived context carrying the domain struct d keyed by its Go type. It is an escape-hatch helper for users who want ent mutation hooks (client.X.Use) to read the full domain struct — including virtual fields — during Save.

The primary path for domain-to-ent encoding remains the typed transformer slots generated in ent/domain.go (see ADR-005). Use WithDomain only when the work must happen at ent-hook time rather than ApplyDomain time, e.g. outbox event emission, cross-cutting audit logging, or tenant-isolation checks that depend on virtual-field values.

Typical use is at the repository boundary, immediately before Save:

ctx = entdomain.WithDomain(ctx, d)
builder, err := client.User.Create().ApplyDomain(ctx, d)
// ... check err ...
created, err := builder.Save(ctx) // hooks see d via DomainFrom[*domain.User](ctx)

Caveats:

  • This uses context.Value, which Go's standard guidance reserves for request-scoped data. Static analyzers may flag call sites. That is expected: the smell lives at the opt-in site, not inside the library.
  • Hook authors must handle the absent case via the ok return from DomainFrom. Do not assume presence — callers can bypass WithDomain.
  • Do not auto-wrap ApplyDomain to call WithDomain internally. The ctx chain must stay visible to the caller.

Types

type ApplyConfig

type ApplyConfig struct {
	// contains filtered or unexported fields
}

ApplyConfig holds the runtime configuration for domain apply operations.

func NewApplyConfig

func NewApplyConfig(opts ...ApplyOption) *ApplyConfig

NewApplyConfig creates a new ApplyConfig with the given options applied.

func (*ApplyConfig) IsAppendEdge

func (c *ApplyConfig) IsAppendEdge(field string) bool

IsAppendEdge reports whether the given edge field should use append semantics.

func (*ApplyConfig) ShouldApply

func (c *ApplyConfig) ShouldApply(field string, val any) bool

ShouldApply reports whether a non-pointer field should be applied. It checks onlyFields, omitFields, and OmitZeroVal (via reflect.Value.IsZero).

func (*ApplyConfig) ShouldApplyPtr

func (c *ApplyConfig) ShouldApplyPtr(field string, val any) bool

ShouldApplyPtr reports whether a pointer field should be applied. It checks onlyFields, omitFields, and OmitNil (val == nil).

type ApplyOption

type ApplyOption func(*ApplyConfig)

ApplyOption is a functional option for configuring ApplyConfig.

func AppendEdge

func AppendEdge(field string) ApplyOption

AppendEdge returns an ApplyOption that makes the given edge field use append instead of the default replace semantics.

func OmitFields

func OmitFields(fields ...string) ApplyOption

OmitFields returns an ApplyOption that skips specific fields by name.

func OmitNil

func OmitNil() ApplyOption

OmitNil returns an ApplyOption that skips pointer fields that are nil.

func OmitZeroVal

func OmitZeroVal() ApplyOption

OmitZeroVal returns an ApplyOption that skips fields with zero values.

func OnlyFields

func OnlyFields(fields ...string) ApplyOption

OnlyFields returns an ApplyOption that allowlists specific fields by name.

type EdgeAnnotation

type EdgeAnnotation struct {
	Mode EdgeMode
}

EdgeAnnotation is placed on individual edge Annotations() to configure domain inclusion.

func Edge

func Edge(opts ...EdgeOption) EdgeAnnotation

Edge builds an EdgeAnnotation with the given options.

func (*EdgeAnnotation) Decode

func (a *EdgeAnnotation) Decode(v interface{}) error

Decode unmarshals the annotation.

func (EdgeAnnotation) HasIDs

func (a EdgeAnnotation) HasIDs() bool

HasIDs reports whether the IDs mode is set.

func (EdgeAnnotation) HasNest

func (a EdgeAnnotation) HasNest() bool

HasNest reports whether the Nest mode is set.

func (EdgeAnnotation) Merge

Merge implements schema.Merger.

func (EdgeAnnotation) Name

func (EdgeAnnotation) Name() string

Name implements schema.Annotation.

type EdgeMode

type EdgeMode int

EdgeMode is a bitmask of edge inclusion modes.

const (
	// EdgeModeIDs includes the edge IDs in the domain struct.
	EdgeModeIDs EdgeMode = 1 << iota
	// EdgeModeNest includes the full nested structs in the domain struct.
	EdgeModeNest
)

type EdgeOption

type EdgeOption func(*EdgeAnnotation)

EdgeOption is a functional option for EdgeAnnotation.

func IDs

func IDs() EdgeOption

IDs returns an EdgeOption that includes edge IDs in the domain struct.

func Nest

func Nest() EdgeOption

Nest returns an EdgeOption that includes nested structs in the domain struct.

type EntityAnnotation

type EntityAnnotation struct {
	VirtualFields []VirtualFieldConfig
	NoBulk        bool
}

EntityAnnotation is placed on schema Annotations() to opt an entity into domain generation.

func Entity

func Entity(opts ...entityOption) EntityAnnotation

Entity builds an EntityAnnotation with the given options.

func (*EntityAnnotation) Decode

func (a *EntityAnnotation) Decode(v interface{}) error

Decode unmarshals the annotation.

func (EntityAnnotation) Merge

Merge implements schema.Merger.

func (EntityAnnotation) Name

func (EntityAnnotation) Name() string

Name implements schema.Annotation.

type Extension

type Extension struct {
	entc.DefaultExtension
	// contains filtered or unexported fields
}

Extension implements entc.Extension for generating a pure Go domain layer.

func NewExtension

func NewExtension(opts ...ExtensionOption) (*Extension, error)

NewExtension creates a new Extension with the given options.

func (*Extension) Hooks

func (e *Extension) Hooks() []gen.Hook

Hooks returns the gen.Hook implementations for this extension.

func (*Extension) Templates

func (e *Extension) Templates() []*gen.Template

Templates returns the gen.Template implementations for this extension.

type ExtensionOption

type ExtensionOption func(*Extension) error

ExtensionOption is a functional option for configuring Extension.

func WithNoBulk

func WithNoBulk(entityNames ...string) ExtensionOption

WithNoBulk disables bulk generation (XxxList type, ToDomain slice method, CreateBulkDomain, UpdateBulkDomain, XxxUpdateOneBulk). Called with no arguments, it disables bulk for all entities. Called with entity names, it disables bulk only for those entities.

func WithPackageName

func WithPackageName(name string) ExtensionOption

WithPackageName sets the Go package name for the generated domain package. Defaults to "domain" if not specified.

func WithPackagePath

func WithPackagePath(path string) ExtensionOption

WithPackagePath sets the output path for the generated domain package, relative to the module root (the parent of the ent directory). Example: "internal/domain"

func WithProto

func WithProto(opts ...ProtoOption) ExtensionOption

WithProto enables proto file generation with the given options. When enabled, the extension generates .proto message files and domain ↔ proto mapper files.

type FIQLBool

type FIQLBool[P Predicate] struct {
	EQ     func(bool) P
	IsNil  func() P
	NotNil func() P
}

FIQLBool handles FIQL filtering for boolean fields. Values must be "true" or "false".

type FIQLEnum

type FIQLEnum[P Predicate] struct {
	EQ     map[string]P
	NEQ    map[string]P
	IsNil  func() P
	NotNil func() P
}

FIQLEnum handles FIQL filtering for enum fields. Predicates are pre-built at generation time; lookups are O(1) map access.

type FIQLField

type FIQLField[P Predicate] interface {
	// contains filtered or unexported methods
}

FIQLField is implemented by all typed FIQL field helpers.

type FIQLFields

type FIQLFields[P Predicate] map[string]FIQLField[P]

FIQLFields maps field names to their FIQL field descriptors.

type FIQLFloat

type FIQLFloat[P Predicate] struct {
	EQ     func(float64) P
	NEQ    func(float64) P
	GT     func(float64) P
	LT     func(float64) P
	GTE    func(float64) P
	LTE    func(float64) P
	In     func(...float64) P
	NotIn  func(...float64) P
	IsNil  func() P
	NotNil func() P
}

FIQLFloat handles FIQL filtering for float fields.

type FIQLInt

type FIQLInt[P Predicate] struct {
	EQ     func(int) P
	NEQ    func(int) P
	GT     func(int) P
	LT     func(int) P
	GTE    func(int) P
	LTE    func(int) P
	In     func(...int) P
	NotIn  func(...int) P
	IsNil  func() P
	NotNil func() P
}

FIQLInt handles FIQL filtering for integer fields.

type FIQLOp

type FIQLOp string

FIQLOp is a FIQL comparison operator.

const (
	// EQ matches field == value (all types).
	EQ FIQLOp = "=="
	// NEQ matches field != value (all types).
	NEQ FIQLOp = "!="
	// GT matches field > value (int, float, time).
	GT FIQLOp = "=gt="
	// LT matches field < value (int, float, time).
	LT FIQLOp = "=lt="
	// GTE matches field >= value (int, float, time).
	GTE FIQLOp = "=ge="
	// LTE matches field <= value (int, float, time).
	LTE FIQLOp = "=le="
	// Contains matches field LIKE '%value%' (string).
	Contains FIQLOp = "=like="
	// HasPrefix matches field LIKE 'value%' (string).
	HasPrefix FIQLOp = "=prefix="
	// In matches field IN (v1, v2, ...) — value syntax: =in=(a,b,c).
	// Supported on string, int, float, enum, uuid fields.
	In FIQLOp = "=in="
	// NotIn matches field NOT IN (v1, v2, ...) — value syntax: =out=(a,b,c).
	// Supported on string, int, float, enum, uuid fields.
	NotIn FIQLOp = "=out="
	// Is is the parser-internal wire form ("=is=") that gets normalized into
	// IsNull or NotNull based on its value during parseComparison. Never
	// reaches apply — the normalization is the contract. Kept as a constant
	// so readOp can recognise the wire form symbolically.
	Is FIQLOp = "=is="
	// IsNull matches field IS NULL — value syntax: field=is=null.
	// Synthetic op produced by parser normalization (never appears on the wire
	// literally). Supported on Optional() / Nillable() fields only — codegen
	// errors otherwise.
	IsNull FIQLOp = "=is=null"
	// NotNull matches field IS NOT NULL — value syntax: field=is=notnull.
	// Synthetic op produced by parser normalization. Same optionality
	// requirement as IsNull.
	NotNull FIQLOp = "=is=notnull"
)

type FIQLString

type FIQLString[P Predicate] struct {
	EQ        func(string) P
	NEQ       func(string) P
	Contains  func(string) P
	HasPrefix func(string) P
	In        func(...string) P
	NotIn     func(...string) P
	IsNil     func() P
	NotNil    func() P
}

FIQLString handles FIQL filtering for string fields.

type FIQLTime

type FIQLTime[P Predicate] struct {
	EQ     func(time.Time) P
	NEQ    func(time.Time) P
	GT     func(time.Time) P
	LT     func(time.Time) P
	GTE    func(time.Time) P
	LTE    func(time.Time) P
	IsNil  func() P
	NotNil func() P
}

FIQLTime handles FIQL filtering for time.Time fields. Values are parsed as RFC3339 (e.g. "2024-01-15T10:30:00Z").

type FIQLUUID

type FIQLUUID[P Predicate] struct {
	EQ     func(uuid.UUID) P
	NEQ    func(uuid.UUID) P
	In     func(...uuid.UUID) P
	NotIn  func(...uuid.UUID) P
	IsNil  func() P
	NotNil func() P
}

FIQLUUID handles FIQL filtering for UUID fields (github.com/google/uuid). Values are parsed via uuid.Parse, which accepts canonical 36-char hyphenated form, braced ({...}), urn:uuid:..., and 32-char hex without hyphens. Only == and != are supported — UUIDs are opaque identifiers, so ordering and substring operators are intentionally rejected.

type FieldAnnotation

type FieldAnnotation struct {
	SkipProto bool
	ProtoType *ProtoTypeConfig
	FIQLOps   []FIQLOp
}

FieldAnnotation is placed on individual field Annotations() to configure domain field behaviour.

func Field

func Field(opts ...fieldOption) FieldAnnotation

Field builds a FieldAnnotation with the given options.

func (*FieldAnnotation) Decode

func (a *FieldAnnotation) Decode(v interface{}) error

Decode unmarshals the annotation.

func (FieldAnnotation) Merge

Merge implements schema.Merger.

func (FieldAnnotation) Name

func (FieldAnnotation) Name() string

Name implements schema.Annotation.

type FieldKind

type FieldKind int

FieldKind is the canonical broad classification of an ent field for the FIQL pipeline. Each generator (FIQL template, proto, domain) dispatches on this kind for the cases that diverge between ent types but converge into one behaviour from FIQL's perspective. Narrow type granularity (e.g. int32 vs int64) stays in each generator's sub-switch; the kind exists to centralise the outer ent-type-to-FIQL-kind mapping and the UUID/GoType gate.

const (
	// KindUnsupported indicates the field has no FIQL representation. The
	// accompanying reason string from resolveFieldKind explains why.
	KindUnsupported FieldKind = iota
	KindString
	KindInt
	KindFloat
	KindBool
	KindTime
	KindEnum
	KindUUID
)

func (FieldKind) String

func (k FieldKind) String() string

String returns the FIQL template kind tag (e.g. "String", "UUID") used by the FIQL template's else-branch to instantiate the matching FIQL{kind} type. KindUnsupported returns "" — preserved as the existing skip sentinel.

type FieldType

type FieldType struct {
	// PkgPath is the import path; empty string means local/stdlib (no import added).
	PkgPath string
	// TypeName is the Go type name as-is; prefix with "*" for pointer types.
	TypeName string
}

FieldType describes the Go type of a virtual field.

func GoType

func GoType(typeName string, pkgPath ...string) FieldType

GoType constructs a FieldType for an arbitrary Go type. typeName is the Go type name (prefix with "*" for pointer). pkgPath is the optional full import path (omit for same-package or stdlib).

type Predicate

type Predicate interface {
	~func(*sql.Selector)
}

Predicate is a constraint for ent predicate types. All ent predicate types have the underlying type func(*sql.Selector), enabling generic AND/OR combination.

type ProtoConfig

type ProtoConfig struct {
	// contains filtered or unexported fields
}

ProtoConfig holds configuration for proto file generation.

type ProtoEntityLock

type ProtoEntityLock struct {
	Fields   map[string]int `json:"fields"`   // snake_name → field number
	Reserved []int          `json:"reserved"` // permanently retired numbers
}

ProtoEntityLock records the field numbers assigned to an entity's fields, plus any field numbers that have been permanently retired (reserved).

type ProtoFieldSpec

type ProtoFieldSpec struct {
	// ProtoType is the proto field type string (e.g., "string", "int64", "google.protobuf.Timestamp").
	ProtoType string
	// ImportPath is the proto import path needed (e.g., "google/protobuf/timestamp.proto"), or "".
	ImportPath string
	// IsOptional means the proto field should use the proto3 "optional" keyword.
	IsOptional bool
	// IsRepeated means the proto field is "repeated".
	IsRepeated bool
	// IsEnum means the field is a proto enum.
	IsEnum bool
	// IsExcluded means this field should be omitted from proto output.
	IsExcluded bool
	// ExcludedReason explains why IsExcluded was set — populated by the resolvers
	// at every exclusion site so the proto generator can surface a per-message
	// summary of skipped fields. Empty when IsExcluded is false.
	ExcludedReason string
	// ToProtoExpr is a Go fmt format string (with %s for the source expression) that converts
	// the domain value to the proto value. Empty string means direct assignment.
	ToProtoExpr string
	// FromProtoExpr is a Go fmt format string (with %s for the source expression) that converts
	// the proto value to the domain value. Empty string means direct assignment.
	FromProtoExpr string
	// EnumTypeName is the proto enum type name (e.g., "UserStatus"). Only set when IsEnum=true.
	EnumTypeName string
	// NestEntityName is set for Nest edge specs and holds the referenced entity type name
	// (e.g., "Post"). The mapper uses it to build mode-appropriate converter calls
	// (e.g., PostToProto / PostFromProto in subpackage mode).
	NestEntityName string
}

ProtoFieldSpec describes how a single domain field maps to a proto field.

type ProtoLockFile

type ProtoLockFile struct {
	Version  int                        `json:"version"`
	Entities map[string]ProtoEntityLock `json:"entities"`
}

ProtoLockFile is the in-memory representation of the .entdomain.lock.json file. It records the field number assignment for each proto entity to ensure stability.

type ProtoOption

type ProtoOption func(*ProtoConfig)

ProtoOption is a functional option for ProtoConfig.

func WithProtoDir

func WithProtoDir(dir string) ProtoOption

WithProtoDir sets the directory for generated .proto files, relative to the module root.

func WithProtoFullPackageName

func WithProtoFullPackageName(name string) ProtoOption

WithProtoFullPackageName overrides the proto `package` declaration written into the generated .proto file. By default the declaration uses pkgName (the directory suffix), e.g. "entpb". Use this when the proto package name differs from the directory name, e.g. WithProtoPackageName("v1") + WithProtoFullPackageName("entminimal.v1").

func WithProtoGoPackage

func WithProtoGoPackage(goPackage string) ProtoOption

WithProtoGoPackage sets the go_package proto option value.

func WithProtoHelpersInDomain

func WithProtoHelpersInDomain() ProtoOption

WithProtoHelpersInDomain places the generated proto helper functions (toInt64Slice, etc.) in the domain package instead of the proto package (the default).

func WithProtoPackageName

func WithProtoPackageName(name string) ProtoOption

WithProtoPackageName sets the proto package name.

type ProtoTypeConfig

type ProtoTypeConfig struct {
	TypeName      string // e.g. "google.protobuf.Timestamp"
	ImportPath    string // e.g. "google/protobuf/timestamp.proto"
	ToProtoExpr   string // optional Go fmt string, e.g. "UserMetadataToProto(%s)"
	FromProtoExpr string // optional Go fmt string, e.g. "UserMetadataFromProto(%s)"
}

ProtoTypeConfig specifies an explicit proto type for a field or virtual field.

type ProtoTypeOpt

type ProtoTypeOpt struct {
	TypeName      string
	ImportPath    string
	ToProtoExpr   string
	FromProtoExpr string
}

ProtoTypeOpt is the value returned by ProtoType(). It implements both virtualFieldOption (for use with VirtualField()) and fieldOption (for use with Field()), so the same call works in both contexts.

func ProtoType

func ProtoType(typeName string, importPath ...string) ProtoTypeOpt

ProtoType sets an explicit proto type. Usable with both VirtualField() and Field(). importPath is optional; if omitted, no import is added.

func (ProtoTypeOpt) WithConversion

func (p ProtoTypeOpt) WithConversion(toExpr, fromExpr string) ProtoTypeOpt

WithConversion returns a copy of ProtoTypeOpt with explicit Go conversion expressions. toExpr and fromExpr are Go fmt strings where %s is the source expression (e.g. "UserMetadataToProto(%s)" / "UserMetadataFromProto(%s)").

type VirtualFieldConfig

type VirtualFieldConfig struct {
	Name      string
	FieldType FieldType
	ProtoType *ProtoTypeConfig
}

VirtualFieldConfig represents a single virtual field entry on EntityAnnotation.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL