The InfluxQL SELECT subset InfluxElixir.Client.Local answers, shaped
the way InfluxDB 3 answers it (verified against the engine; see
docs/design/2026-09-23_local-influxql.md).
InfluxQL is not SQL with other keywords. Every row carries
"iox::measurement" and "time"; rows come back in time order; a row with
no selected field value is dropped; an unknown column or measurement is an
empty result, not an error; aggregates are named after the function
(mean, count, ...) and put time at the lower bound the WHERE
gives time (the epoch when it gives none), except a lone selector
(MAX, MIN, FIRST, LAST), which returns its point's time and tags;
LIMIT and OFFSET apply per GROUP BY series.
This module is pure. parse/1 turns the statement into a query map;
run/4 shapes rows the caller has already filtered with the statement's
WHERE clause (Client.Local runs it through its SQL engine) and put in
time order.
WHERE follows InfluxQL, not SQL (where_plan/2): a missing tag is the
empty string (host != 'a' keeps points without host, host = ''
finds them), =~ /re/ and !~ /re/ are unanchored matches on tags and
false on fields, </> on a tag is false, durations (now() - 30m)
are intervals, double-quoted identifiers are exact, and NOT is the
engine's parse error. Compared with time, a bare integer or a duration
is an offset from the epoch in nanoseconds (time >= 2, time > 0s,
time > 1s - 999999999ns), != and <> are the engine's planning
error, and a float is refused. The engine pulls time comparisons out
of the whole WHERE as if they were joined by AND, so one inside an
OR is refused by name. SHOW TAG VALUES [FROM m] WITH KEY = | != | =~ | !~ | IN (...) [WHERE ...] lists values as the engine does, over the
last 24 hours unless the WHERE bounds time (parse_show_tag_values/1).
A reserved word (reserved?/1) is no bare identifier: the engine's parse
errors for it, and for a select list, FROM, WHERE, GROUP BY,
ORDER BY, LIMIT or a statement after a ; that stops short, are
positioned in the text as sent and read with the statement's own offset. A
field is compared with a literal by the types of the two (b = 1 is false
for every row, u > -1 wraps the -1, an integer past the signed range
compares as unsigned), LIMIT and OFFSET beyond the signed 64-bit range
are the planning error and beyond the unsigned a parse error.
Refused by name, rather than answered wrongly: GROUP BY time(...) (the
engine fills every empty bucket), fill(), INTO, SLIMIT/SOFFSET,
subqueries, GROUP BY *, functions other than
MEAN SUM COUNT MIN MAX FIRST LAST, F(*) other than COUNT(*), plain
columns beside anything but a single selector, arithmetic in the select
list, several measurements in FROM, sub-second durations in now() - ..., LIMIT / OFFSET on SHOW TAG VALUES, an unsigned field
compared with a string, a string field or a tag compared with an integer
past the signed range, a field compared with a constant the double cannot
fold, a bare non-boolean field inside AND / OR, DISTINCT, and a
statement after a ; that the double does not read. A keyword inside a
quoted string, quoted identifier or regular expression is not one:
WHERE k = 'into' is answered.
Summary
Types
A bound on time: nanoseconds since the epoch, or an offset from the
query's now().
The type of a field, as the engine plans it.
A select item: every column, a column, or a function of a column.
Which tag keys a SHOW TAG VALUES lists.
A parsed SELECT.
A WHERE as the caller's SQL, with the lower bounds its time comparisons give.
Functions
The engine's planning error for a LIMIT or OFFSET beyond the signed
64-bit range (LIMIT first), or :ok. It is raised only for a measurement
that exists.
Whether a tag key is one a key_filter/0 lists.
Whether a WHERE names time: then it, not the default window, bounds the rows.
Parses an InfluxQL SELECT. Returns {:error, message} for syntax the
engine rejects and for the constructs listed in the moduledoc.
Parses SHOW TAG VALUES [FROM m] WITH KEY = k | != k | =~ /re/ | !~ /re/ | IN (k, ...) [WHERE ...], or nil when the statement is not one.
LIMIT and OFFSET are refused by name: the engine applies them per
measurement in an order the double does not reproduce.
Whether a bare word is one InfluxQL reserves (any case).
Shapes rows — the measurement's points already filtered by the
statement's WHERE, as SQL row maps with "time", in time order —
into the engine's answer. tags names the measurement's tag columns;
everything else but time is a field.
Rewrites an InfluxQL WHERE into the caller's SQL, given the
measurement's tag columns, with the column names the WHERE mentions and
the lower bounds it puts on time:
time >= x and time = x give x, time > x gives x + 1, upper
bounds give none. An aggregate over a lower bound is stamped with the
greatest of them (run/4). types maps each field to its type: a
comparison of a field with a literal follows the engine's rules for the
two types (see the section on typed comparisons). deferred is the engine's
error for a bare field as the whole condition, which it raises after it has
checked LIMIT and OFFSET. {:error, message} for what the double
refuses by name; {:error, {:engine, body}} for what the engine itself
answers with a 400.
Types
A bound on time: nanoseconds since the epoch, or an offset from the
query's now().
@type field_type() :: :integer | :unsigned | :float | :string | :boolean
The type of a field, as the engine plans it.
@type item() :: :star | {:column, binary(), binary()} | {:aggregate, binary(), binary() | :star, binary() | nil}
A select item: every column, a column, or a function of a column.
@type key_filter() :: {:eq, binary()} | {:ne, binary()} | {:in, [binary()]} | {:regex, Regex.t(), boolean()}
Which tag keys a SHOW TAG VALUES lists.
@type query() :: %{ items: [item()], measurement: binary(), where: binary() | nil, group_by: [binary()], descending: boolean(), limit: non_neg_integer() | nil, offset: non_neg_integer() }
A parsed SELECT.
@type where_plan() :: %{ sql: binary(), lowers: [bound()], idents: MapSet.t(binary()), deferred: binary() | nil }
A WHERE as the caller's SQL, with the lower bounds its time comparisons give.
Functions
The engine's planning error for a LIMIT or OFFSET beyond the signed
64-bit range (LIMIT first), or :ok. It is raised only for a measurement
that exists.
@spec key_listed?(binary(), key_filter()) :: boolean()
Whether a tag key is one a key_filter/0 lists.
Whether a WHERE names time: then it, not the default window, bounds the rows.
Parses an InfluxQL SELECT. Returns {:error, message} for syntax the
engine rejects and for the constructs listed in the moduledoc.
@spec parse_show_tag_values(binary()) :: nil | {:ok, %{measurement: binary() | nil, keys: key_filter(), where: binary() | nil}} | {:error, binary()}
Parses SHOW TAG VALUES [FROM m] WITH KEY = k | != k | =~ /re/ | !~ /re/ | IN (k, ...) [WHERE ...], or nil when the statement is not one.
LIMIT and OFFSET are refused by name: the engine applies them per
measurement in an order the double does not reproduce.
Whether a bare word is one InfluxQL reserves (any case).
Shapes rows — the measurement's points already filtered by the
statement's WHERE, as SQL row maps with "time", in time order —
into the engine's answer. tags names the measurement's tag columns;
everything else but time is a field.
Options:
:lower- the lower bound (nanoseconds) theWHEREgivestime; an aggregate row is stamped with it, and with the epoch without one (verified:WHERE time >= 2answersmeanat 2 ns,WHERE time < 3at the epoch; withGROUP BYevery series carries it):fields- the measurement's field names, whenLIMITorOFFSETneed them (they are read from the rows otherwise)
@spec where_plan(binary(), MapSet.t(binary()), %{required(binary()) => field_type()}) :: {:ok, where_plan()} | {:error, binary() | {:engine, binary()}}
Rewrites an InfluxQL WHERE into the caller's SQL, given the
measurement's tag columns, with the column names the WHERE mentions and
the lower bounds it puts on time:
time >= x and time = x give x, time > x gives x + 1, upper
bounds give none. An aggregate over a lower bound is stamped with the
greatest of them (run/4). types maps each field to its type: a
comparison of a field with a literal follows the engine's rules for the
two types (see the section on typed comparisons). deferred is the engine's
error for a bare field as the whole condition, which it raises after it has
checked LIMIT and OFFSET. {:error, message} for what the double
refuses by name; {:error, {:engine, body}} for what the engine itself
answers with a 400.