Local Extension Methods
An extension adds a typed receiver method to the common compiler and supplies a local evaluator separately. Declarations are immutable and shared by the engine; implementations belong to a backend. Extensions cannot replace built-in methods or run arbitrary query-supplied Go code.
Declare one signature
Create a declaration before constructing the engine:
declaration, err := hff.NewMethodDeclaration(hff.MethodConfig{
ID: "service.string.hasprefix",
Name: "hasprefix",
Receiver: schema.StringType,
Parameters: []hff.Type{schema.StringType},
Result: schema.BoolType,
BaseCost: 1,
})
if err != nil {
return err
}
ID is the stable identity for this signature and its capability. Name is the
ASCII receiver-method name written in HFF. Receiver, Parameters, and
Result use immutable hff.Type descriptors. Parameters are required;
Optional adds trailing optional parameter types and Variadic declares a
repeated trailing type. A zero Variadic means there is no variadic parameter.
Optional types specify accepted argument types; they do not provide default
values. Optional arguments are positional and cannot be skipped to supply an
argument for the variadic tail.
Every signature has its own ID. Different IDs may share a Name when their
receiver and parameter types do not overlap. engine.New rejects duplicate IDs,
built-in name/ID collisions, and overlapping signatures, then retains the
declarations immutably. NewMethodDeclaration copies the supplied type slices.
MethodDeclaration provides Valid(), ID(), Name(), Receiver(),
Parameters(), Optional(), Variadic(), Parameter(i), Result(),
MinArgs(), MaxArgs(), NullPolicy(), BaseCost(), and Equal(other).
Parameters() and Optional() return copies; MaxArgs() returns -1 for a
variadic signature. Engine.Methods() also returns a copied slice of immutable
declarations.
BaseCost must be at least one. NullPolicy defaults to
hff.PropagateNull. With hff.InvokeNull, the evaluator may be called when the
receiver or an argument is null only if that position's declared type admits
null. Internal no-value always propagates and is never exposed as hff.Value.
Grant the capability and compile
Declaring a method does not enable it. Select its ID explicitly through
capabilities.Method:
features := capabilities.Standard().Allow(capabilities.Method(declaration.ID()))
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.String("name"))),
engine.WithCapabilities(features),
engine.WithMethods(declaration),
)
if err != nil {
return err
}
program, err := e.Compile(ctx, `name:hasprefix("Al")`)
if err != nil {
return err
}
The compiler checks method name, arity, receiver/argument compatibility, and
capability permission. For a call that can select multiple declared signatures,
every statically applicable signature must be enabled. A type guard can narrow
a dynamic receiver before this check. Engine.Methods() exposes the configured
declarations for service configuration and discovery.
Register the local evaluator
The local backend requires an implementation for every used extension
signature. Unused declarations need no evaluator. Build fails with a
source-located hff.BackendError when a used declaration is missing or the
implementation does not match its declaration.
filter, err := local.Build(ctx, program, local.WithMethods(local.Implement(
declaration,
func(call *local.Call, receiver hff.Value, args []hff.Value) (hff.Value, error) {
name, _ := receiver.AsString()
prefix, _ := args[0].AsString()
if err := call.ChargeSteps(len(name)); err != nil {
return hff.Null(), err
}
return hff.Bool(strings.HasPrefix(name, prefix)), nil
},
)))
if err != nil {
return err
}
The evaluator is a pure, concurrent-safe Go function. Its receiver and
arguments have already been checked against the selected declaration. It may
return hff.Null(), nil for an ordinary data-dependent failure. A non-null
result must be a valid HFF value matching the declared result type; invalid
values, non-finite floats, invalid UTF-8, or a wrong result type produce a
backend error.
HFF charges the fixed base cost before calling the evaluator. Additional
data-dependent work must be metered with Call.ChargeSteps; all variable-size
results and generated values must be charged with Call.ChargeGeneratedValues,
including results that reuse immutable input storage. Return a charging error
immediately. Charging
errors are latched and still abort the match if the evaluator ignores them.
Call.Context() provides the operation context; check it during long-running
work because the backend cannot preempt arbitrary evaluator code. Context
cancellation and limit exhaustion abort evaluation with their original HFF
error codes; ordinary evaluator errors and recovered panics become
hff.BackendError. HFF physically validates the returned value, charges
validation traversal/string work, applies MaxInputDepth, and checks the
declared result type. A Call is active only during its invocation and must
not be retained for later work.
Nulls and no-value
With the default PropagateNull, a null receiver or argument returns null
without invoking the evaluator. If null leaves no viable signature, later
arguments are not evaluated. InvokeNull opts into receiving null, subject to
nullable declared types. No-value from a failed guard or another absent
subexpression always propagates without invoking an extension; it cannot be
constructed or returned as a public value. Top-level null and no-value both
produce a false filter result after HFF's ordinary boolean conversion.
SQL extensions need a separate translator registration for the selected dialect; a local evaluator does not provide SQL support by itself. PostgreSQL translators can target the currently supported scalar expression profile. See the PostgreSQL backend guide for registration, parameter helpers, and backend guarantees.
Run the example
go run ./examples/extensions
Expected output:
Alice: true
Bob: false