InfluxElixir.Client.Local is an in-memory InfluxDB client that parses real
line protocol, stores data in ETS, and responds with the same shapes as the
HTTP client. It enables fast, isolated tests with async: true and no
external dependencies.
Choosing a Profile
LocalClient enforces an InfluxDB version profile matching your production backend. This ensures your tests fail if you use operations your real InfluxDB doesn't support.
| Profile | Operations |
|---|---|
:v3_core | write, SQL queries, InfluxQL, database CRUD |
:v3_enterprise | everything in v3_core + token management |
:v2 | write, Flux queries, bucket CRUD |
Setup
1. Add the dependency
# mix.exs
defp deps do
[
{:influx_elixir, "~> 0.1"}
]
end2. Configure LocalClient for tests
# config/test.exs
config :influx_elixir, :client, InfluxElixir.Client.Local3. Write your test setup
defmodule MyApp.InfluxTest do
use ExUnit.Case, async: true
alias InfluxElixir.Client.Local
setup do
# Match your production InfluxDB version
{:ok, conn} = Local.start(
databases: ["myapp_test"],
profile: :v3_core
)
on_exit(fn -> Local.stop(conn) end)
{:ok, conn: conn}
end
test "writes and queries data", %{conn: conn} do
{:ok, :written} = Local.write(
conn,
"sensors,location=lab temp=22.5",
database: "myapp_test"
)
{:ok, [row]} = Local.query_sql(
conn,
"SELECT * FROM sensors WHERE location = 'lab' LIMIT 1",
database: "myapp_test"
)
assert row["temp"] == 22.5
assert row["location"] == "lab"
end
end4. Use the shared case template (optional)
If you have many test modules that need InfluxDB, create a shared setup:
# test/support/my_influx_case.ex
defmodule MyApp.InfluxCase do
use ExUnit.CaseTemplate
using do
quote do
alias InfluxElixir.Client.Local
end
end
setup do
{:ok, conn} = InfluxElixir.Client.Local.start(
databases: ["test_db"],
profile: :v3_core
)
on_exit(fn -> InfluxElixir.Client.Local.stop(conn) end)
{:ok, conn: conn}
end
endThen use it in tests:
defmodule MyApp.SensorTest do
use MyApp.InfluxCase, async: true
test "stores sensor readings", %{conn: conn} do
{:ok, :written} = Local.write(conn, "sensors temp=22.5", database: "test_db")
# ...
end
endConnection-Level Default Database
Both Client.HTTP and Client.Local honour the :database config key
as a connection-level default. When the caller doesn't pass database:
in opts, this value is used:
{:ok, conn} = Local.start(database: "myapp_test")
# No explicit `database:` opt — uses "myapp_test"
{:ok, :written} = Local.write(conn, "sensors temp=22.5")
{:ok, rows} = Local.query_sql(conn, "SELECT * FROM sensors"):databases (list) is also accepted by both implementations:
Client.Local pre-creates each entry; Client.HTTP uses the first
entry as the default when :database is not set. This makes a config
like database: "primary", databases: ["primary", "backup"] a true
drop-in across implementations.
Profile Enforcement
If you pick the wrong profile, operations fail the same way they would against the real backend:
# Your production InfluxDB is v3 Core — no Flux support
{:ok, conn} = Local.start(profile: :v3_core)
# This returns {:error, :unsupported_operation}
Local.query_flux(conn, "from(bucket: \"test\") |> range(start: -1h)")This catches profile mismatches in tests, before they reach production.
Checking Support at Runtime
Use supports?/2 if you need to conditionally execute operations:
if Local.supports?(conn, :query_flux) do
Local.query_flux(conn, flux_query)
else
# fall back or skip
endRunning Contract Tests
The library includes a shared contract test template at
InfluxElixir.ClientContract. You can use it to verify that your own
adapters or wrappers conform to the InfluxDB client contract:
defmodule MyApp.ContractTest do
use ExUnit.Case, async: true
use InfluxElixir.ClientContract,
client: InfluxElixir.Client.Local,
profile: :v3_core
alias InfluxElixir.Client.Local
setup do
{:ok, conn} = Local.start(databases: ["contract_db"], profile: :v3_core)
on_exit(fn -> Local.stop(conn) end)
{:ok, conn: conn, database: "contract_db", query_delay: 0}
end
endThe contract tests verify health, write, query, admin, and round-trip operations. They run the same assertions against every backend — if both LocalClient and real InfluxDB pass, LocalClient is proven faithful.
Named Connections via the Facade
LocalClient works seamlessly with the facade's named connection system.
When Client.Local is the configured client, ConnectionSupervisor
calls Local.init_connection/1 to create an ETS-backed connection and
registers it under the given name. All facade functions then work
transparently:
# config/test.exs
config :influx_elixir, :client, InfluxElixir.Client.Local
config :influx_elixir, :connections,
test_db: [
databases: ["myapp_test"],
profile: :v3_core
]defmodule MyApp.FacadeTest do
use ExUnit.Case
test "write and query via named connection" do
{:ok, :written} = InfluxElixir.write(
:test_db,
"sensors temp=22.5",
database: "myapp_test"
)
{:ok, [row]} = InfluxElixir.query_sql(
:test_db,
"SELECT * FROM sensors LIMIT 1",
database: "myapp_test"
)
assert row["temp"] == 22.5
end
endThis lets you test your application code that uses InfluxElixir.write/3
and InfluxElixir.query_sql/3 without any code changes — just swap the
client in config.
Aggregate Queries
LocalClient supports DATE_BIN time-bucketed aggregate queries — the same
pattern used in InfluxDB v3 SQL:
test "hourly average temperature", %{conn: conn} do
# Write some data points
lines = """
sensors,location=lab temp=20.0 1000000000000
sensors,location=lab temp=22.0 2000000000000
sensors,location=lab temp=24.0 5000000000000
"""
{:ok, :written} = Local.write(conn, lines, database: "test_db")
sql = """
SELECT
DATE_BIN(INTERVAL '1 hour', time) AS time,
AVG(temp) AS avg_temp
FROM "sensors"
WHERE location = 'lab'
GROUP BY DATE_BIN(INTERVAL '1 hour', time)
ORDER BY time ASC
"""
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db")
assert [%{"time" => _, "avg_temp" => _} | _] = rows
endSupported aggregate functions: AVG, SUM, COUNT, MIN, MAX (one argument).
Ordered aggregates use the InfluxDB v3 SQL (DataFusion) spelling:
first_value(field ORDER BY col [ASC|DESC]) and
last_value(field ORDER BY col [ASC|DESC]) — for OHLCV candles and
"latest value per group" queries:
sql = """
SELECT symbol, last_value(price ORDER BY time) AS price
FROM prices
WHERE time >= $start
GROUP BY symbol, provider
"""The ORDER BY inside the call is required. Without it the real engine
returns an arbitrary row from each group, which the double cannot
reproduce, so it rejects the query instead of certifying a
non-deterministic result. InfluxQL-style FIRST(field, time) /
LAST(field, time) are rejected too: InfluxDB v3 SQL has no such
functions (the real engine fails planning with Invalid function 'last'),
and a double that accepted them would pass tests for a query that 400s in
production.
Supported interval units: seconds, minutes, hours, days.
Anything outside the supported subset is rejected with
{:error, %{status: 400, body: "Client.Local: ..."}}. The Client.Local:
prefix tells you the double, not InfluxDB, refused the query.
GROUP BY DATE_BIN is optional. When omitted, aggregate queries return a
single scalar row over all matching points:
sql = """
SELECT AVG(net_value) AS average_balance
FROM account_balances
WHERE account_id = 'abc'
"""
{:ok, [%{"average_balance" => avg}]} = Local.query_sql(conn, sql, database: "test_db")COUNT over zero matching rows returns 0; other aggregates return nil,
matching real InfluxDB SQL semantics.
GROUP BY Tag/Field Columns
GROUP BY on bare tag/field columns is supported alongside DATE_BIN
time bucketing. The grouping columns can also appear in the SELECT
list (with optional AS alias):
sql = """
SELECT ticker, AVG(value) AS average_balance, holding_type
FROM account_holdings
WHERE account_id = 'abc'
GROUP BY ticker, holding_type
"""
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db")
# => one row per unique (ticker, holding_type) pairDecimal Params
Decimal values pass through params: as bare numeric literals — no
quoting, so WHERE amount >= $min performs a numeric comparison even
when $min is a %Decimal{}:
params = %{"$min" => Decimal.new("1000.00")}
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db", params: params)Do not pass pre-stringified numbers ("1000.00") as params. A string
param becomes a string literal, and InfluxDB v3 compares a numeric column
against a string literal by casting the column to text — so
amount >= '1000.00' is a lexical comparison in which 500.0 matches.
Client.Local reproduces that so the mistake fails in tests rather than
in production.
WHERE Literal Typing
Quoted literals are always strings, exactly as in InfluxDB v3. A zero-padded identifier keeps its leading zero and matches a string tag:
sql = "SELECT * FROM accounts WHERE repcode IN ($rc)"
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db", params: %{rc: "08338636"})Bare literals are typed (42 integer, 1.5 float, true boolean). A bare
numeric literal never matches a string tag (WHERE repcode = 08338636
returns no rows), again matching the real engine.
Multi-Column Projection
Specific columns can be projected by name (with optional AS alias):
sql = """
SELECT net_value, total_balance, time
FROM account_balances
WHERE account_id = 'abc'
ORDER BY time DESC
LIMIT 1
"""
{:ok, [row]} = Local.query_sql(conn, sql, database: "test_db")
# => row has keys "net_value", "total_balance", "time" onlyBoth fields and tags are selectable. Aliasing renames the output key:
SELECT net_value AS nv FROM x produces rows keyed by "nv".
IN / NOT IN Clauses
WHERE col IN (...) and WHERE col NOT IN (...) are supported on tags
and fields. Combine with binary operators via AND:
sql = """
SELECT * FROM holdings
WHERE ticker IN ('AAPL', 'MSFT') AND shares > 5
"""
{:ok, rows} = Local.query_sql(conn, sql, database: "test_db")IN () (empty list) matches no rows; NOT IN () matches all rows.
Time Filters with Bare Dates
WHERE time accepts three formats: integer nanoseconds, full ISO-8601
datetimes, and bare ISO dates (interpreted as midnight UTC):
# All three are equivalent if your data is at exactly the day boundary:
"WHERE time >= '2026-03-31'"
"WHERE time >= '2026-03-31T00:00:00Z'"
"WHERE time >= 1774915200000000000"Unparseable date strings filter out all rows (fail-closed) instead of silently producing wrong results via Elixir term ordering.
Key Differences from Real InfluxDB
- No WAL flush delay: Writes are immediately queryable (set
query_delay: 0) - In-memory only: Data is lost when
stop/1is called - Simplified SQL parser: Supports
SELECT *, multi-column projection (with optionalAS alias),WHEREwith binary ops +IN/NOT IN,ORDER BY time,LIMIT,DATE_BIN+ aggregate functions (AVG,SUM,COUNT,MIN,MAX,first,last) with optionalGROUP BY DATE_BIN - No authentication: All operations succeed regardless of token
- ETS-based: Each
start/1creates an isolated ETS table