Timestamps, Durations, and Time Zones
The local backend supports timestamp parsing and comparison, fixed-duration arithmetic, component extraction, and formatting. The signatures and edge cases are defined in the method reference.
Parse, compare, and format timestamps
Timestamp strings use RFC 3339 with a numeric offset or Z. HFF keeps
nanosecond precision, accepts years 0000 through 9999, and rejects leap seconds,
invalid dates, seconds above 59, and fractions longer than nine digits. Invalid
or unrepresentable values yield null.
created:after("2026-10-01T00:00:00Z":timestamp)
created:hours("+09:00")=18
created:add("30m":duration):string("UTC")="2026-10-11T10:00:00Z"
The component methods year(), month(), dayofmonth(), dayofyear(),
hours(), minutes(), seconds(), and milliseconds() accept an optional
time-zone argument and use UTC when it is omitted. They return integer
components; month, day-of-month, and day-of-year are one-based. The
timestamp:string(tz) method requires an explicit zone and formats RFC 3339,
omitting trailing fractional zeros. If a historical zone offset includes
seconds or is outside the RFC 3339 range, formatting yields null rather than
changing the represented instant.
Store PostgreSQL timestamps
Bind a schema-known Timestamp to timestamptz with BindTimestamp. PostgreSQL
stores timestamps at microsecond resolution, so use the optional
WithNanosecondRemainder smallint column when HFF nanosecond precision is
needed. The base timestamp is floored to a whole microsecond; the remainder
stores the following 0–999 nanoseconds. This representation does not round the
HFF instant during query translation. Flooring also applies before the Unix
epoch, so the remainder is always nonnegative.
The split uses exact integer arithmetic, including for instants immediately
before a second boundary, so conversion does not round the query value.
BindTimestamp also works as a child binding inside BindStruct and as a
BindRelation item binding. A flattened struct supports direct predicates on
the Timestamp field. A direct relation binding supports size() through a
correlated row count, even when its item is a Timestamp. Direct has(index)
also supports statically known numeric or null/no-value indexes, including
nullable numeric types; negative indexes are checked from the end without
materializing items or requiring a unique order. Relation items also support
direct Timestamp-field access in any() and all() callbacks.
Guarded or computed relation receivers and operations that need full item or
list values, such as indexing or materialization, may still fail at Build.
These bindings do not make a containing struct, relation item, list, or object
with a Timestamp materializable.
If the remainder column is omitted, the caller guarantees all stored values are microsecond-exact. If it is bound, the caller guarantees both columns are simultaneously NULL or non-NULL and the remainder is in the range 0–999. A pair of SQL NULL values maps to HFF null, and nullable schema fields accept it. Add constraints matching those storage requirements:
CREATE TABLE events (
id bigint PRIMARY KEY,
created_at timestamptz,
created_ns_remainder smallint,
CHECK ((created_at IS NULL) = (created_ns_remainder IS NULL)),
CHECK (created_ns_remainder IS NULL OR created_ns_remainder BETWEEN 0 AND 999)
);
INSERT INTO events VALUES
(101, '2026-10-01 00:00:00+00', 123),
(102, NULL, NULL);
The first row represents 2026-10-01T00:00:00.000000123Z. HFF year 0000 maps
to PostgreSQL 1 BC so the complete HFF year range remains representable. A
timestamptz stores an instant, not its original offset; PostgreSQL normalizes
it to UTC and displays it using the session time zone.
Declare the HFF field and bind both columns together:
schema.Struct(schema.Nullable(schema.Timestamp("created_at")))
hffsql.BindTimestamp(
hff.Path{"created_at"},
hffsql.Column("events", "created_at"),
hffsql.WithNanosecondRemainder(hffsql.Column("events", "created_ns_remainder")),
)
For example, created_at:after("2026-10-01T00:00:00Z":timestamp) matches
row 101 by its retained nanoseconds. Compile and build the predicate, then pass
Query.Where() and Query.Args() to the service's database call.
For native timestamp bindings, PostgreSQL currently supports direct timestamp
comparisons, before() and after(), add(duration), in(), exists(), and
type(). The component methods year(), month(), dayofmonth(),
dayofyear(), hours(), minutes(), seconds(), and milliseconds() use
UTC and take no zone argument. string() and json() also format in UTC.
The string:timestamp conversion is accepted only for a literal RFC 3339
string; parsing data-dependent strings and zone arguments are unsupported by
this backend. Build rejects operations outside this subset. JSON/JSONB
Timestamp values use a separate schema-known canonical-string representation;
see the PostgreSQL JSON guide.
For eligible scalar filter predicates, direct comparisons between a native
BindTimestamp and a Timestamp literal or another native BindTimestamp, as
well as before() and after(), compare the stored timestamptz and optional
nanosecond remainder directly. Guarded, structured, and value-context
expressions retain their existing lowering; JSON/JSONB Timestamp values use a
separate representation and are not covered by this physical-column path.
The native Timestamp predicate record
describes the exact predicate shape and its limits.
The timestamp binding example builds this kind of predicate and prints whether the predicate was built and how many parameters it produced; it does not print the SQL fragment or connect to a database. From the repository root, run:
go run ./examples/sql-timestamps
For example, a flattened profile can expose a nested Timestamp to a predicate without constructing the complete profile value:
CREATE TABLE records (
id bigint PRIMARY KEY,
profile_present boolean NOT NULL,
profile_null boolean NOT NULL,
created_at timestamptz,
created_ns_remainder smallint,
CHECK (NOT profile_null OR profile_present),
CHECK ((created_at IS NULL) = (created_ns_remainder IS NULL)),
CHECK (created_ns_remainder IS NULL OR created_ns_remainder BETWEEN 0 AND 999)
);
package main
import (
"context"
"fmt"
"log"
"github.com/ornew/hff"
hffsql "github.com/ornew/hff/backends/sql"
"github.com/ornew/hff/capabilities"
"github.com/ornew/hff/engine"
"github.com/ornew/hff/schema"
)
func main() {
ctx := context.Background()
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.Nullable(schema.Field(
"profile", schema.Struct(schema.Timestamp("created")),
)))),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
log.Fatal(err)
}
profile := hffsql.BindStruct(hff.Path{"profile"},
hffsql.AsPredicate(hffsql.ColumnValue(hffsql.Column("records", "profile_present"), schema.BoolType)),
hffsql.AsPredicate(hffsql.ColumnValue(hffsql.Column("records", "profile_null"), schema.BoolType)),
hffsql.BindTimestamp(hff.Path{"created"}, hffsql.Column("records", "created_at"),
hffsql.WithNanosecondRemainder(hffsql.Column("records", "created_ns_remainder"))),
)
program, err := e.Compile(ctx,
`profile.created:after("2026-10-01T00:00:00Z":timestamp)`,
)
if err != nil {
log.Fatal(err)
}
query, err := hffsql.Build(ctx, program,
hffsql.WithDialect(hffsql.PostgreSQL),
hffsql.WithBindings(profile),
)
if err != nil {
log.Fatal(err)
}
fmt.Println("WHERE", query.Where(), "Args", query.Args())
}
For a relation item, use the direct Timestamp field in an any() or all()
callback. For example,
events:any(event=>event.created:after("2026-10-01T00:00:00Z":timestamp))
filters related rows. A direct events:size query counts correlated rows
without constructing Timestamp items. For example, events:has(-1) checks
whether the relation contains an item at its final negative index. Direct
has(index) supports statically known numeric or null/no-value indexes,
including nullable numeric types. Genuinely dynamic Any or mixed-union
indexes use the JSON fallback and may be rejected when the relation has
Timestamp items. Indexing or operations on guarded or computed relation
receivers may still fail to build when their SQL lowering requires complete
item or list values.
Store schema-known timestamps in JSON or JSONB
For a JSON-backed value, store a schema-known Timestamp as a canonical UTC
RFC 3339 string with at most nine fractional digits and no trailing fractional
zeros. The schema determines the HFF type; the same JSON string under Any
remains a String. The caller guarantees that stored documents match the schema
and use the canonical representation. Build does not inspect or validate
stored documents.
CREATE TABLE json_events (
id bigint PRIMARY KEY,
payload jsonb NOT NULL
);
INSERT INTO json_events VALUES
(1, '{"created":"2026-10-01T00:00:00.000000123Z"}'::jsonb),
(2, '{"created":"2026-10-01T00:00:00.000000122Z"}'::jsonb);
Declare the nested field as Timestamp, then bind the document column. This
standalone example builds and reports a predicate; a service can execute it by
passing query.Where() and query.Args() to its database driver.
package main
import (
"context"
"fmt"
"github.com/ornew/hff"
hffsql "github.com/ornew/hff/backends/sql"
"github.com/ornew/hff/capabilities"
"github.com/ornew/hff/engine"
"github.com/ornew/hff/schema"
)
func main() {
ctx := context.Background()
payloadType := schema.Struct(schema.Nullable(schema.Timestamp("created")))
e, err := engine.New(
engine.WithSchema(schema.Struct(schema.Field("payload", payloadType))),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
panic(err)
}
program, err := e.Compile(ctx,
`payload.created:after("2026-10-01T00:00:00.000000122Z":timestamp)`,
)
if err != nil {
panic(err)
}
query, err := hffsql.Build(ctx, program,
hffsql.WithDialect(hffsql.PostgreSQL),
hffsql.WithBindings(hffsql.BindJSON(
hff.Path{"payload"}, hffsql.Column("json_events", "payload"), hffsql.JSONB,
)),
)
if err != nil {
panic(err)
}
fmt.Println("Predicate built:", query.Where() != "")
fmt.Println("Parameter count:", len(query.Args()))
}
Executing the predicate selects row 1 because its Timestamp is one nanosecond
after the comparison value. The translator parses canonical UTC fields into
exact PostgreSQL numeric total epoch nanoseconds. This representation keeps
nanosecond precision and supports HFF years 0000 through 9999 without using
the microsecond-precision timestamptz column representation. Schema-known
JSON/JSONB support includes comparisons, before(), after(),
add(duration), in(), exists(), type(), UTC component methods, and
zero-argument string()/json(). Schema-known lists of Timestamps also
support ascending sort() by exact instant, contains(), and unique().
Timestamp values can be retained in supported list/struct construction and
JSON serialization:
events:contains("2026-10-01T00:00:00.000000123Z":timestamp)
events:unique
events:json="[\"2026-10-01T00:00:00.000000123Z\"]"
[payload.created]:json="[\"2026-10-01T00:00:00.000000123Z\"]"
payload:json="{\"created\":\"2026-10-01T00:00:00.000000123Z\"}"
Schema-known disjoint unions Timestamp|Bool|Null and
Timestamp|Float|Null are supported for type(), explicit Timestamp guards,
comparisons, and membership. Typed lists of these unions support contains(),
unique(), and callback cases where the result is representable. JSON strings
select Timestamp, booleans select Bool, numbers select Float in the second
union, and JSON null remains null. For example, if value and events have a
Timestamp|Bool|Null schema:
value:type="timestamp"
value:in("2026-01-01T00:00:00.000000123Z":timestamp,true)
events:contains(true)
events:unique
events:map(x=>x:type):contains("timestamp")
With a Timestamp|Float|Null schema, the numeric branch is Float:
value:[float]:exists
value:in("2026-01-01T00:00:00.000000123Z":timestamp,1.5)
events:contains(1.5)
The schema-known Timestamp|Int|Float|Null union is supported when the active
numeric type can be recovered. Native JSON preserves the Int-versus-Float
spelling; JSONB requires WithTypeMetadata for operations that observe that
distinction. A version-1 sidecar can preserve Float text such as -0.0 when
string() or json() must reproduce it. Without metadata, Build rejects
operations on a mixed-union value bound to raw JSONB when they need to
distinguish Int from Float. A separate Float-only JSONB value can still be
compared with a mixed-union value in native JSON because the Float schema fixes
its type. For example, use
value:in("2026-01-01T00:00:00.000000123Z":timestamp,1.5) for a scalar union
and events:contains(1.5) or events:unique for a separately
bound list of the same union type. The JSON binding guide
shows the sidecar shape and binding; the runnable JSON union example builds
scalar and list predicates for Timestamp and Duration unions.
The same support applies to Duration|Int|Float|Null. For either union, JSONB
string()/json() output involving Float requires sidecar text when needed to
preserve signed zero. Other collection transformations may still fail to build
when they require an unsupported materialized value. Time-zone arguments,
data-dependent Timestamp parsing, and ambiguous string-backed unions such as
String|Timestamp are rejected by Build.
The PostgreSQL JSON guide
describes the full binding contract and operation limits.
These schema-known temporal/numeric list unions also support sort() when the
runtime list is homogeneous by JSON category: all temporal strings sort by
exact instant or duration, and all numbers sort numerically across Int and
Float. Equal numeric values preserve their order and representation. A list
mixing temporal strings and numbers, or containing null or another
non-orderable value, produces ordinary null. For mixed Int/Float JSONB lists,
provide the numeric metadata needed to recover the stored subtype; raw JSONB
without it is rejected when that distinction is required. This is a list
sorting operation, not general callback support for temporal/numeric unions.
For example, observed_items:sort:json="[9007199254740992.0,9007199254740993]"
checks the numerically sorted Float/Int list. The runnable JSON-union
example
builds this sort predicate separately from its other predicates; it does not
execute a database query.
The same schema-known lists support a selected set of callbacks: any() and
all() with type guards, map() to type(), string(), or the identity
value, identity flatmap(), and foldl()/foldr() with an Int guard. Callback
results must have a supported representation; a failed Int guard produces
no-value and propagates through a fold. For example:
observed_items:any(item=>item:[timestamp])
observed_items:map(item=>item:type):contains("timestamp")
observed_items:foldl(0,(acc,item)=>acc+(item:[int]))
For a schema-known List<Timestamp>, PostgreSQL JSON and JSONB also support a
bounded computed callback subset: mapping add(duration) over each item,
then serializing or searching the mapped list, and flatmapping each computed
Timestamp into a singleton list. With observed_items containing
["0000-01-01T00:00:00Z"], adding one nanosecond preserves its full
precision in the serialized JSON:
observed_items:map(item=>item:add("1ns":duration)):json="[\"0000-01-01T00:00:00.000000001Z\"]"
observed_items:map(item=>item:add("1ns":duration)):contains("0000-01-01T00:00:00.000000001Z":timestamp)
observed_items:flatmap(item=>[item:add("1ns":duration)]):json="[\"0000-01-01T00:00:00.000000001Z\"]"
Adding one nanosecond to the maximum Timestamp produces null. This support is specific to these representable callback results; other collection transformations can still fail to build when they require a Timestamp representation the backend cannot lower.
For JSONB, provide numeric metadata when an operation needs Int/Float identity
or representation details such as signed zero. Without it, Build rejects the
operation when JSONB has erased a required distinction. The runnable example
builds an any() guard and a map() projection alongside the separate sort
predicate; it does not execute the SQL.
Parse and measure durations
Duration strings combine signed decimal components with h, m, s, ms,
us, or ns suffixes. For example, "1h30m", "-23.4s", ".5h", and
"1h34us" are valid. Components are summed exactly before checking that the
result is an integral number of nanoseconds in the signed int64 range. Invalid,
sub-nanosecond, or overflowing values yield null. Days and weeks are not
calendar-free units and are not accepted.
Duration unit methods return total units: nanoseconds() returns int64, while
hours, minutes, seconds, milliseconds, and microseconds return float64. Adding
a duration to a timestamp uses fixed elapsed time, not calendar arithmetic; an
out-of-range result yields null.
Store native PostgreSQL durations
Bind a schema-known Duration column to a PostgreSQL bigint containing signed
int64 nanoseconds. The binding does not round or reinterpret the value; SQL
NULL is HFF null. A nullable schema field accepts SQL NULL, while a
non-nullable field requires a non-null column value.
CREATE TABLE job_runs (
id bigint PRIMARY KEY,
elapsed_ns bigint
);
INSERT INTO job_runs VALUES
(1, 45000000000),
(2, 90000000000),
(3, NULL);
Declare the nullable field as a Duration and bind it to the nanosecond column:
package main
import (
"context"
"fmt"
"log"
"github.com/ornew/hff"
hffsql "github.com/ornew/hff/backends/sql"
"github.com/ornew/hff/capabilities"
"github.com/ornew/hff/engine"
"github.com/ornew/hff/schema"
)
func main() {
ctx := context.Background()
e, err := engine.New(
engine.WithSchema(schema.Struct(
schema.Nullable(schema.Duration("elapsed")),
)),
engine.WithCapabilities(capabilities.Standard()),
)
if err != nil {
log.Fatal(err)
}
program, err := e.Compile(ctx, `elapsed:seconds>=60`)
if err != nil {
log.Fatal(err)
}
query, err := hffsql.Build(ctx, program,
hffsql.WithDialect(hffsql.PostgreSQL),
hffsql.WithBindings(hffsql.Bind(
hff.Path{"elapsed"},
hffsql.Column("job_runs", "elapsed_ns"),
)),
)
if err != nil {
log.Fatal(err)
}
fmt.Println("Predicate built:", query.Where() != "")
fmt.Println("Parameter count:", len(query.Args()))
}
Executing the predicate against the sample table selects row 2; the runnable
example demonstrates construction and does not execute the query. Pass
query.Where() and query.Args() to the application's PostgreSQL driver.
Native Duration columns support Duration unit methods, comparisons, truthiness,
membership, and canonical string()/json() output. A Duration column can
also be used as a typed relation ordering key, for example with
hffsql.WithUniqueOrder(hffsql.Ascending(hffsql.Column("job_runs", "elapsed_ns"), schema.DurationType)).
The same scalar mapping works for a direct flattened BindStruct field, but
materializing a whole struct containing Duration remains unsupported.
Native composite Duration storage is also unavailable. The
runnable stored-duration example
shows the complete binding setup and can be run with
go run ./examples/sql-stored-durations from the repository root.
In a PostgreSQL predicate, a closed literal conversion such as
elapsed>"1h":duration can be resolved to a nanoseconds parameter during
Build; invalid conversions still behave as null. This does not change the
compiled HFF plan or the meaning of Duration parsing. Conversion folding also
applies in other supported literal value positions.
Filter a relation of Duration values
A direct scalar relation item can also bind a bigint nanosecond column. Make
the item nullable when the child column may be SQL NULL:
CREATE TABLE jobs (
id bigint PRIMARY KEY
);
CREATE TABLE job_durations (
id bigint PRIMARY KEY,
job_id bigint NOT NULL REFERENCES jobs(id),
elapsed_ns bigint
);
INSERT INTO jobs VALUES (41), (42);
INSERT INTO job_durations VALUES
(1, 41, 45000000000),
(2, 41, 90000000000),
(3, 41, NULL);
Declare the list item as nullable Duration and bind the child column relative
to each relation row. The following is a fragment inside a function returning
error; it assumes the HFF, SQL backend, and schema imports. Use
recordSchema when constructing the engine, then attach durationRelation
with BindRelation during PostgreSQL Build.
durationItems := schema.NullableType(schema.DurationType)
recordSchema := schema.Struct(schema.List("durations", durationItems))
durationRelation, err := hffsql.NewRelation(
hffsql.WithRelationSource(hffsql.Column("job_durations"), "duration"),
hffsql.WithCorrelation(hffsql.Equal(
hffsql.ColumnValue(hffsql.Column("duration", "job_id"), schema.IntType),
hffsql.ColumnValue(hffsql.Column("jobs", "id"), schema.IntType),
)),
hffsql.WithRelationItem(durationItems,
hffsql.Bind(hff.Path{}, hffsql.Column("duration", "elapsed_ns")),
),
)
if err != nil {
return err
}
Either callback predicate can be compiled and attached with
BindRelation(hff.Path{"durations"}, durationRelation):
durations:any(d=>d:seconds>=60)
durations:all(d=>d:exists)
The relation also supports size() and has() without an item ordering:
durations:size=3
durations:has(-1)
size() counts the matching child rows, including a row whose bound Duration
is SQL NULL. has() checks whether a statically known numeric index exists;
nullable numeric, null, and no-value indexes are also supported. A dynamic
Any or mixed-union index may require the JSON fallback, which can fail to
build if it would need to materialize the unsupported Duration list.
The first predicate matches job 41 because one item is 90 seconds. A SQL-null
item remains HFF null; its seconds() comparison is null and does not make
any() true. The second predicate is false for job 41 because of its null item,
and true for job 42's empty relation. Plain any() and all() do not require a
unique item order. They do not imply that the complete Duration list can be
materialized or used by every order-sensitive operation; those shapes may
still be rejected at Build.
Store schema-known durations in JSON
PostgreSQL BindJSON can decode schema-known Duration fields from canonical
duration strings in JSON or JSONB. The schema, not the string contents, supplies
the HFF type: an Any value containing the same text remains a String. The
JSON binding guide
shows the table, schema, binding, and query. This does not add Duration tags to
the JSONB numeric metadata format.
The same guide documents schema-known Duration unions, including
Duration|Int|Float|Null. Native JSON spelling distinguishes numeric types;
JSONB uses WithTypeMetadata for mixed Int/Float unions. Use [float], not
type="float", for a Float-specific guard because HFF type() reports both
Int and Float as number. The runnable example also binds lists of these
unions and builds contains() and unique() predicates.
The JSON union example shows mixed Timestamp and Duration unions with JSONB metadata, including list predicates. It builds a parameterized predicate without opening a database connection.
PostgreSQL computed durations
The PostgreSQL backend can parse a text column into a computed Duration and
use its unit methods in a predicate. For example,
elapsed:duration:seconds>=60 accepts a text value of at least one minute.
The SQL duration example
shows the schema, binding, and parameterized predicate construction. It builds
the query without connecting to a database. Run it from the repository root
with go run ./examples/sql-durations; it prints that the predicate was built
and reports one bound parameter. This conversion produces a computed Duration;
it is separate from native scalar bigint bindings and schema-known JSON/JSONB
Duration storage described above. The backend does not enforce the local
parser's MaxSteps
budget, so database timeouts and input-size controls remain the caller's
responsibility.
Supply versioned time-zone data
UTC and fixed offsets such as +09:00 work without a profile. Named IANA or
Joda-Time zones come from application-supplied TZif data, so matching never
loads zone files or reads environment settings. The application chooses and
reports the profile version:
profile := &local.TimeZoneProfile{
Version: "tzdata-2026a",
Zones: map[string][]byte{"America/New_York": newYorkTZif},
Aliases: map[string]string{"US/Eastern": "America/New_York"},
}
filter, err := local.Build(ctx, program, local.WithTimeZones(profile))
if err != nil {
return err
}
fmt.Println(filter.TimeZoneVersion())
Supply TZif bytes from an application-managed, versioned data source. Build
copies and parses the profile into the filter, so later changes to caller-owned
maps or byte slices do not alter its behavior. Aliases point directly to a zone
in Zones or to UTC; alias chains and attempts to override UTC or fixed
offset names are rejected. A nil, invalid, or unversioned profile is a
configuration error. A name not available in the profile yields null when used
in a query. TimeZoneVersion() returns an empty string if the filter uses only
built-in UTC and fixed-offset zones.
Run the example
go run ./examples/temporal
Expected output:
created:after("2026-10-01T00:00:00Z":timestamp) -> true
created:hours("+09:00")=18 elapsed:duration:minutes>=60 -> true
created:add("30m":duration):string("UTC")="2026-10-11T10:00:00Z" -> true
The parser charges timestamp and zone-name input length to the local per-match
MaxSteps budget. Duration parsing reserves a conservative cost based on the
input length before allocating exact decimal intermediates. See the
local backend configuration for local runtime limits.