PostgreSQL Relation Bindings
BindRelation maps one HFF list field to rows from one caller-selected
PostgreSQL table or view. A relation has four parts: the HFF list path, a row
source and alias, a correlation predicate that connects each row to the outer
record, and a binding for one row as one HFF list item. HFF does not
inspect foreign keys, choose tables, join tables, or execute the resulting
query. The service supplies the surrounding SELECT, connection, and
execution context.
Start with the database shape
Suppose an order has zero or more items. The parent query selects from
orders; each related row comes from order_items, joined logically by
order_items.order_id = orders.id:
CREATE TABLE orders (
id bigint PRIMARY KEY,
status text NOT NULL
);
CREATE TABLE order_items (
id bigint PRIMARY KEY,
order_id bigint NOT NULL REFERENCES orders(id),
price bigint NOT NULL,
active boolean NOT NULL
);
ALTER TABLE orders
ADD COLUMN tenant_id bigint NOT NULL DEFAULT 17;
INSERT INTO orders (id, status) VALUES (42, 'open');
INSERT INTO order_items (id, order_id, price, active) VALUES
(7, 42, 125, true),
(8, 42, 35, false);
The logical HFF schema describes the record seen by the query. For this
example it has one field, items, whose list elements have price and
active fields:
itemType := schema.Struct(
schema.Int("price"),
schema.Bool("active"),
)
recordType := schema.Struct(schema.List("items", itemType))
If the local backend received the row data, its HFF record would look like this:
{"items":[{"price":125,"active":true},{"price":35,"active":false}]}
The PostgreSQL backend reconstructs that logical list from correlated rows;
there does not need to be an items column in orders. The root relation
binding uses hff.Path{"items"}. The item binding uses an empty path because
its paths are relative to one order_items row.
The table may contain columns such as orders.status that the HFF schema does
not expose; only fields the filter reads need to be represented in its schema
and bindings.
Bind the row source and compile a filter
This complete configuration maps the child row columns into the declared
HFF item struct, then attaches that relation at items:
item := schema.Struct(
schema.Int("price"),
schema.Bool("active"),
)
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.List("items", item))),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
return err
}
program, err := e.Compile(ctx,
`items:any(item=>item.price>100 item.active)`,
)
if err != nil {
return err
}
parentOrder := hffsql.Equal(
hffsql.ColumnValue(hffsql.Column("item", "order_id"), schema.IntType),
hffsql.ColumnValue(hffsql.Column("orders", "id"), schema.IntType),
)
itemBinding := hffsql.BindStruct(
hff.Path{}, // the current order_items row is the HFF list item
hffsql.Boolean(true),
hffsql.Boolean(false),
hffsql.Bind(hff.Path{"price"}, hffsql.Column("item", "price")),
hffsql.Bind(hff.Path{"active"}, hffsql.Column("item", "active")),
)
relation, err := hffsql.NewRelation(
hffsql.WithRelationSource(hffsql.Column("order_items"), "item"),
hffsql.WithCorrelation(parentOrder),
hffsql.WithRelationItem(item, itemBinding),
)
if err != nil {
return err
}
query, err := hffsql.Build(ctx, program,
hffsql.WithDialect(hffsql.PostgreSQL),
hffsql.WithBindings(hffsql.BindRelation(
hff.Path{"items"}, relation,
)),
)
if err != nil {
return err
}
The relation's parentOrder predicate compares two declared integer columns
through the typed Equal constructor. The child field bindings are relative
to item:
hff.Path{"price"} means the HFF item field, while
Column("item", "price") means the SQL column item.price. A path such as
hff.Path{"items", "price"} would instead address a nested HFF field from
the outer record and is not the item-binding path.
Build correlation predicates safely
WithCorrelation and WithRelationState accept SQLPredicate. Prefer the
typed constructors over raw SQL:
ColumnValue(column, type)declares the HFF scalar type of a physical SQL column.Literal(value)adds a typed scalar value as a bound parameter.Equal,IsNull, andIsNotNullcompare or test scalar expressions.AsPredicateuses a Boolean scalar;Booleancreates a fixed true/false predicate.And,Or, andNotcombine predicates.
The relation example uses Equal to compare item.order_id and orders.id.
The caller declares both as schema.IntType; the backend checks the types and
quotes the identifiers. To add a tenant condition while keeping the tenant
value out of SQL text, compose typed predicates:
tenantMatch := hffsql.Equal(
hffsql.ColumnValue(hffsql.Column("orders", "tenant_id"), schema.IntType),
hffsql.Literal(hff.Int(17)),
)
correlation := hffsql.And(parentOrder, tenantMatch)
Here parentOrder is the Equal predicate defined for the preceding
relation. The value 17 is emitted in query.Args(). Use IsNull and
IsNotNull for null tests: SQL equality with null is unknown, not an identity
test.
TrustedSQL remains an escape hatch for a PostgreSQL Boolean expression the
typed constructors cannot express. It accepts fixed, developer-authored SQL
text; HFF does not parse it, convert it into an AST, validate its Boolean
semantics, prove its determinism or purity, or sanitize it. Its optional
ColumnRef arguments support alias bookkeeping only; they do not interpolate
values or validate that the text refers to those identifiers. The service must
write and review the expression, keep untrusted query/record data out of it,
and ensure it has the intended meaning. Any expression used for state or
correlation must be deterministic and side-effect-free; HFF cannot prove that
contract. Build does not execute it. PostgreSQL evaluates the predicate only
when the caller runs the complete statement.
WithRelationState(present, null) uses the same predicate constructors to
represent a missing or null HFF list. Without it, the relation is present and
non-null. No matching rows produce an empty list; that is different from a
missing field or a null list.
Put the predicate in the caller's statement
Build returns a predicate fragment and ordered arguments, not a complete
query. The caller chooses the parent row and supplies the outer alias used by
the correlation:
rows, err := db.QueryContext(ctx,
`SELECT "orders"."id" FROM "orders" WHERE `+query.Where(),
query.Args()...,
)
The parameter values must be passed separately; do not interpolate
query.Args() into the SQL text. The caller owns database I/O, transaction
boundaries, timeouts, cancellation during execution, and result scanning. The
SQL backend does not open a connection or run the statement.
Which shapes fit a relation?
- One-to-many: one
BindRelationmaps each parent to zero or more items. This is the natural shape fororders.items. - Nested one-to-many: an item's
BindStructcan contain a childBindRelationat a relative field path. Give the nested relation its own row source, alias, correlation to the current item alias, item type, and item binding. Each relation is explicit. - Many-to-many: the API does not infer or generate a join through a junction table. Expose the desired child rows through a PostgreSQL view and use that view as the single row source, or model the junction rows as one relation and a further collection as a nested relation when that is the HFF schema you want.
- One-to-one: use a native composite or
BindStructfor one object. A relation is always an HFF list; it does not turn zero-or-one rows into a nullable or optional struct.
For other physical representations, choose deliberately: Bind
maps scalar columns, BindJSON maps a schema-known JSON document,
BindComposite maps a native composite, and
BindStruct maps fields spread across columns.
These choices determine whether the physical data can represent missing,
null, empty, and ordered values.
Order and null rules
Relations are present and non-null by default. Zero matching child rows mean
an empty list, so any() is false and all() is true. Use
WithRelationState(present, null) only when the storage model also needs to
represent a missing or null list. For a struct item, BindStruct's present
expression answers whether the item itself exists; its null expression
answers whether that present item is SQL null. Child columns that are SQL null
are explicit null fields. An all-null set of child columns does not imply a
missing item.
WithUniqueOrder supplies a typed, unique ordering tuple for operations that
can observe list order, such as indexing or returning the list. It is not
required for a plain any() or all() predicate. If a relation is transformed
or guarded, the builder checks whether the consumer still observes order and
rejects a mapping that cannot preserve it.
Duration columns can be used as typed order components with
schema.DurationType; their signed nanosecond values define the ordering.
For a plain relation path, any() and all() can use correlated
EXISTS/NOT EXISTS. Guards, computed lists, and alias collisions may use a
general list-materialization path instead. Neither path guarantees physical
row or callback evaluation order, local HFF runtime budgets, or an index plan.
A visible correlation merely gives PostgreSQL a predicate it may be able to
use with an appropriate index and plan.
count() returns the number of related rows, and count(predicate) counts
the callback results that convert to true; these count results do not expose
row order. filter(predicate) returns the original matching items in relation
order and therefore requires WithUniqueOrder. Its callback uses ordinary
boolean conversion, with guarded no-value counting as false. PostgreSQL Build
rejects relation filters whose result order cannot be represented.
distinct() and find(predicate) preserve first-occurrence order, so a
relation using either method must supply WithUniqueOrder. distinct() keeps
the original first item from each HFF-equal group; find() returns the first
original item whose callback result converts to true. Guarded no-value from the
predicate is false. The builder rejects these operations when it cannot
represent a unique relation order.
avg() over a relation also does not observe row order. PostgreSQL supports a
numeric callback projection when each projected value has a recoverable Int or
Float subtype; the relation does not need WithUniqueOrder unless a nested
operation observes order. Both a no-argument average over statically numeric
relation items and a numeric callback projection are supported. For example,
an unordered relation with native Int item values can use items:avg, and a
flattened item relation can use items:avg(item=>item.price).
For an eligible relation binding and statically numeric projection, the
translator can compute a correlated AVG(numeric) directly from related rows
instead of materializing the relation as JSON. Null and guarded no-value
contributions are skipped. Dynamic or Any projections and guarded or
computed relation receivers retain general lowering. Float inputs use the
PostgreSQL Float Profile's round-trip-safe text conversion before numeric
aggregation and a checked Float conversion afterward; numeric accumulation
can differ from sequential Float addition. This Float path requires
extra_float_digits > 0; HFF neither sets nor inspects the session setting.
See the relation-average SQL-shape
record for its
eligible cases and reproduction details.
min() and max() also support numeric relation callback projections. They
skip null and guarded no-value results, return null for empty or all-skipped
relations or a runtime nonnumeric result, and preserve the selected original
value and subtype. Equal values select the first item. An Int-only or
null-only projection can be evaluated without WithUniqueOrder, since ties
have the same observable numeric value and subtype. Float or mixed Int/Float
projections require WithUniqueOrder so signed-zero and subtype ties have a
defined first item. In the earlier order_items example,
items:min(item=>item.price) is valid without relation ordering because
price is Int.
When the callback is composed of direct native scalar fields, literals, and
Boolean AND/OR/NOT, eligible scalar predicates can be lowered directly
inside the correlated subquery. For the earlier filter
items:any(item=>item.price>100 item.active), this removes four inner
CROSS JOIN LATERAL projections from the generated predicate; two outer
generic projections remain. Nullable or optional struct item state and
noneligible callback expressions retain general lowering. This describes SQL
shape, not runtime speed.
For a plain bound relation, the constant Int filters items:has(0) and
items:has(-1) only need to determine whether the relation is nonempty. They
use the same correlated existence check and do not require WithUniqueOrder.
The lowering retains the configured parent and relation presence/null state,
so an empty relation is a non-match and a null relation remains null before
the filter is applied. A relation containing a null-valued item is still
nonempty. Other indexes, including items:has(-2) and invalid Float indexes,
retain the general cardinality/index lowering.
Nest a one-to-many relation
To expose adjustments under each order item, extend the item schema and make
the second relation correlate to the current item row:
CREATE TABLE item_adjustments (
id bigint PRIMARY KEY,
order_item_id bigint NOT NULL REFERENCES order_items(id),
amount bigint NOT NULL
);
adjustmentRelation, err := hffsql.NewRelation(
hffsql.WithRelationSource(hffsql.Column("item_adjustments"), "adjustment"),
hffsql.WithCorrelation(hffsql.Equal(
hffsql.ColumnValue(hffsql.Column("adjustment", "order_item_id"), schema.IntType),
hffsql.ColumnValue(hffsql.Column("item", "id"), schema.IntType),
)),
hffsql.WithRelationItem(schema.IntType,
hffsql.Bind(hff.Path{}, hffsql.Column("adjustment", "amount")),
),
hffsql.WithUniqueOrder(
hffsql.Ascending(hffsql.Column("adjustment", "id"), schema.IntType),
),
)
if err != nil {
return err
}
itemWithAdjustments := schema.Struct(
schema.Int("price"),
schema.Bool("active"),
schema.List("adjustments", schema.IntType),
)
itemBinding := hffsql.BindStruct(
hff.Path{}, hffsql.Boolean(true), hffsql.Boolean(false),
hffsql.Bind(hff.Path{"price"}, hffsql.Column("item", "price")),
hffsql.Bind(hff.Path{"active"}, hffsql.Column("item", "active")),
hffsql.BindRelation(hff.Path{"adjustments"}, adjustmentRelation),
)
outerRelation, err := hffsql.NewRelation(
hffsql.WithRelationSource(hffsql.Column("order_items"), "item"),
hffsql.WithCorrelation(hffsql.Equal(
hffsql.ColumnValue(hffsql.Column("item", "order_id"), schema.IntType),
hffsql.ColumnValue(hffsql.Column("orders", "id"), schema.IntType),
)),
hffsql.WithRelationItem(itemWithAdjustments, itemBinding),
)
The outer item binding gives the nested adjustments field a path relative to
one order_items row. The nested correlation then uses item.id as its
parent key. The HFF shape is
{"items":[{"price":125,"active":true,"adjustments":[5,10]}]}. Bind
outerRelation at hff.Path{"items"} as in the preceding example. No
foreign-key discovery or automatic nested join occurs.
Expose a many-to-many result through a view
For example, a view can turn order-item tags into one row source with the columns needed by the HFF item binding:
CREATE TABLE tags (id bigint PRIMARY KEY, label text NOT NULL);
CREATE TABLE order_item_tags (
order_item_id bigint NOT NULL REFERENCES order_items(id),
tag_id bigint NOT NULL REFERENCES tags(id),
PRIMARY KEY (order_item_id, tag_id)
);
CREATE VIEW order_tags AS
SELECT DISTINCT oi.order_id, t.id, t.label
FROM order_items AS oi
JOIN order_item_tags AS link ON link.order_item_id = oi.id
JOIN tags AS t ON t.id = link.tag_id;
BindRelation can use Column("order_tags") as its single source, correlate
order_tags.order_id to the parent order, and bind id and label from its
row alias. Declare id as a unique order when a consumer observes the list
order. If the desired HFF shape includes the junction as an intermediate
collection, model each edge with a relation and place a nested relation on the
edge item. The API does not accept a join tree as WithRelationSource.
Runnable example and checks
The SQL relations example builds the schema, relation, and filter without a database connection:
go run ./examples/sql-relations
It prints the generated WHERE fragment and the bound argument 100. Run
the database parity suite with a dedicated PostgreSQL container:
HFF_POSTGRES_CONTAINER=<container-name-or-id> go test -race -tags=integration ./backends/sql
See the PostgreSQL overview for the supported profile and execution boundary, and translation optimizations for SQL shape choices and separate Build/database measurement commands.