Tuplex.Template (tuplex v0.1.0)

Copy Markdown View Source

Internal

Published because the matching rules below are the exact semantics of every template in the library, and there is nowhere better to state them. It is not part of the public API and not covered by semantic versioning. Use Tuplex.

Validation, shard-key extraction, and match-spec compilation for tuples and templates.

This module is pure. It performs no side effects, owns no state, and never touches :ets — it only decides what a template means. Tuplex.Store turns those decisions into table operations, and owns the storage form; nothing here knows how a tuple is stored.

Tuples and templates

A tuple is what you write with Tuplex.out/1. A template is what you match with (in, rd, inp, rdp, rd_all, watch). Both are Erlang tuples whose first element is an atom tag:

{:job, 1, "payload"}      # a tuple
{:job, :_, :_}            # a template matching any 3-arity :job tuple

Matching is exact

Matching uses strict equality, which is the correct Linda semantics: the float 1.0 does not match the stored integer 1. Only :_ is a wildcard, and it matches at any depth:

matches?({:point, :_, 2}, {:point, 1, 2})           #=> true
matches?({:point, {:x, :_}}, {:point, {:x, 9}})     #=> true
matches?({:n, 1.0}, {:n, 1})                        #=> false

Arity and tag are part of the match, so an arity-3 template never matches an arity-2 tuple and a :job template never matches a :task tuple.

Maps

Maps are ordinary Elixir terms and templates carry them fine, but they are never placed in the ETS head pattern. ETS matches a map in a head partially — a %{a: 1} pattern matches a stored %{a: 1, b: 2} — which would be a second, contradictory notion of "match" living alongside the exact one above.

So compile/1 hoists any map-bearing subterm out of the head and re-attaches it as an =:= guard:

compile({:job, :_, %{region: :north}})
#=> {{:job, :_, :"$1"}, [{:"=:=", :"$1", {:const, %{region: :north}}}]}

=:= is exactly the equality the rest of the module promises: the map must be equal, not a subset, and 1.0 still does not match 1. The hoist takes the largest wildcard-free subterm containing the map, so one guard usually covers a whole nested structure, and wildcards elsewhere in the template keep working normally.

The one thing this cannot express is a wildcard inside a map%{a: :_}. That would need the partial semantics back, so it is rejected. Match the enclosing position with :_ and filter the results yourself.

What a template may not contain

  • A wildcard tag. {:_, :_} is rejected: the tag selects the shard, so a wildcard tag would mean fanning every read across every shard. Use Tuplex.tags/0 and fold over it if you genuinely want that — then the cost is visible at the call site.

  • :"$1"-style atoms in head positions. ETS reads any atom beginning with $ as a match variable, so {:x, :"$1"} would quietly behave as a second wildcard. They are fine inside a hoisted subterm, where they end up in a {:const, _} guard and are never interpreted — {:cfg, %{name: :"$1"}} is accepted.

  • Wildcards inside maps, as above.

None of these apply to tuples passed to out/1. Stored tuples are data: they are never interpreted as patterns, so they may contain :_, :"$1", and maps freely. Only the tag rule is shared, because storage still has to route them.

Summary

Types

Why a tuple or template was rejected.

An ETS guard pinning a hoisted subterm to an exact value.

An ETS head pattern, shaped like the template it came from.

The waiter-index key: the tuple's tag paired with its arity.

t()

A tuple written into the space with Tuplex.out/1.

A pattern matched against stored tuples. :_ is the wildcard.

Functions

Compiles a template into an ETS head pattern and its guards.

Same as compile/1 but returns the pair or raises ArgumentError.

Returns the waiter-index key for a tuple or template: its tag paired with its arity.

Returns whether tuple matches template, without consulting ETS.

Validates a template, returning it unchanged.

Same as validate/1 but returns the template or raises ArgumentError.

Validates a tuple destined for the space.

Same as validate_tuple/1 but returns the tuple or raises ArgumentError.

Types

error()

@type error() ::
  :not_a_tuple
  | :empty_tuple
  | :wildcard_tag
  | {:non_atom_tag, term()}
  | {:reserved_atom, atom()}
  | {:wildcard_in_map, map()}

Why a tuple or template was rejected.

guard()

@type guard() :: {:"=:=", atom(), {:const, term()}}

An ETS guard pinning a hoisted subterm to an exact value.

head()

@type head() :: tuple()

An ETS head pattern, shaped like the template it came from.

key()

@type key() :: {atom(), non_neg_integer()}

The waiter-index key: the tuple's tag paired with its arity.

t()

@type t() :: tuple()

A tuple written into the space with Tuplex.out/1.

template()

@type template() :: tuple()

A pattern matched against stored tuples. :_ is the wildcard.

Functions

compile(template)

@spec compile(term()) :: {:ok, {head(), [guard()]}} | {:error, error()}

Compiles a template into an ETS head pattern and its guards.

The head is shaped exactly like the template, so tag and arity are matched structurally and cheaply. Map-bearing subterms are replaced by :"$1", :"$2", … and pinned by =:= guards; a template with no maps compiles to itself and no guards at all.

The caller composes the final match spec, because the record layout belongs to Tuplex.Store, not here. Store nests this head under its own key position, so the variables numbered here can never collide with anything Store introduces.

Examples

iex> Tuplex.Template.compile({:job, :_, 2})
{:ok, {{:job, :_, 2}, []}}

iex> Tuplex.Template.compile({:job, :_, %{region: :north}})
{:ok, {{:job, :_, :"$1"}, [{:"=:=", :"$1", {:const, %{region: :north}}}]}}

iex> Tuplex.Template.compile({:job, %{a: :_}})
{:error, {:wildcard_in_map, %{a: :_}}}

compile!(template)

@spec compile!(term()) :: {head(), [guard()]}

Same as compile/1 but returns the pair or raises ArgumentError.

key(tuple)

@spec key(t() | template()) :: key()

Returns the waiter-index key for a tuple or template: its tag paired with its arity.

A tuple and every template that can match it share this key. The shard relies on that: a newly written tuple only has to be offered to waiters filed under its own key, and if the two ever disagreed a legitimately waiting in would never wake.

Examples

iex> Tuplex.Template.key({:job, 1, "payload"})
{:job, 3}

iex> Tuplex.Template.key({:job, :_, :_})
{:job, 3}

iex> Tuplex.Template.key({:ping})
{:ping, 1}

matches?(template, tuple)

@spec matches?(template(), t()) :: boolean()

Returns whether tuple matches template, without consulting ETS.

This is the same relation the compiled match spec expresses, implemented in plain Elixir so a shard can test a freshly written tuple against waiting templates without a table round-trip.

Examples

iex> Tuplex.Template.matches?({:job, :_, 2}, {:job, 1, 2})
true

iex> Tuplex.Template.matches?({:job, :_}, {:job, 1, 2})
false

iex> Tuplex.Template.matches?({:n, 1.0}, {:n, 1})
false

iex> Tuplex.Template.matches?({:cfg, %{a: 1}}, {:cfg, %{a: 1, b: 2}})
false

validate(template)

@spec validate(term()) :: {:ok, template()} | {:error, error()}

Validates a template, returning it unchanged.

Equivalent to compile/1 with the compiled form discarded — a template is valid exactly when it compiles.

Examples

iex> Tuplex.Template.validate({:job, :_, 2})
{:ok, {:job, :_, 2}}

iex> Tuplex.Template.validate({:job, %{region: :north}})
{:ok, {:job, %{region: :north}}}

iex> Tuplex.Template.validate({:_, 1})
{:error, :wildcard_tag}

iex> Tuplex.Template.validate({:job, :"$1"})
{:error, {:reserved_atom, :"$1"}}

iex> Tuplex.Template.validate([:job])
{:error, :not_a_tuple}

validate!(template)

@spec validate!(term()) :: template()

Same as validate/1 but returns the template or raises ArgumentError.

validate_tuple(tuple)

@spec validate_tuple(term()) :: {:ok, t()} | {:error, error()}

Validates a tuple destined for the space.

Only the tag is constrained — it must be a concrete atom, because it selects the shard. Everything else is data and is stored verbatim, including terms that would be illegal in a template.

Examples

iex> Tuplex.Template.validate_tuple({:job, 1, "payload"})
{:ok, {:job, 1, "payload"}}

iex> Tuplex.Template.validate_tuple({:cfg, %{retries: 3}, :"$1"})
{:ok, {:cfg, %{retries: 3}, :"$1"}}

iex> Tuplex.Template.validate_tuple({})
{:error, :empty_tuple}

iex> Tuplex.Template.validate_tuple({"job", 1})
{:error, {:non_atom_tag, "job"}}

validate_tuple!(tuple)

@spec validate_tuple!(term()) :: t()

Same as validate_tuple/1 but returns the tuple or raises ArgumentError.