aster.Web Data / Docs Aster UI ↗

Aster Web Data / Connect your data

DuckDB

How Aster Web Data uses a DuckDB file, and how DuckDB's types, isolation and results shape what clients receive.

DuckDB suits a service that publishes an analytical file, such as a reporting extract your team already queries with DuckDB. The service opens the file inside its own process, so there is no database server to run, no network connection and no TLS. Filters, sorts, pages, counts and grouping run in DuckDB. Decimal division and 128-bit arithmetic run in the service, so the digits of those results do not depend on DuckDB. Where DuckDB's answer differs from OData's, DuckDB's stands; Departures from OData lists those places.

Two things decide whether DuckDB fits. A file can be open in one service instance at a time. And DuckDB has one isolation level, repeatable read, so an atomic group that checks a rule, which needs serializable isolation, is refused.

Connecting

Name the file from an environment variable or a file that holds its path:

databases:
  main:
    kind: duckdb
    url_file: database.path     # a file holding the path, or ':memory:'; url_env also works

:memory: gives a database held in memory, and each :memory: entry is a separate database. The parts form is refused, because DuckDB takes a path. The configuration reference lists every key with its default. These behave in a way particular to DuckDB:

A second open of a file that another service instance or process holds is refused. The service then treats the file as unavailable and retries the open; A database goes down describes what clients see meanwhile.

DuckDB 1.5.6 is bundled with the service, with its JSON extension. Extension downloads and autoloading are disabled, and DuckDB runs with two threads.

Start-up check

DuckDB is the database whose column types are checked most strictly. When the file opens, the check refuses a column that does not store the field exactly:

Other fields are accepted over any column. The configuration is checked before the file opens: a decimal needs a precision from 1 to 38 and a scale that does not exceed it, a temporal precision above six is refused, and a link set that needs ON DELETE CASCADE is refused, because DuckDB does not support that constraint. The check compares each column's facets and default with the configuration and says what to declare.

A failed check makes only that file's sets unavailable. To run the check on its own, use the check command.

Types

OData type Column created by recreate Notes
Edm.Boolean BOOLEAN
Edm.Byte, Edm.SByte, Edm.Int16, Edm.Int32, Edm.Int64, Edm.Duration BIGINT Checked when read. A duration is signed nanoseconds.
128-bit unsigned integer UHUGEINT The full unsigned range, up to 2^128 - 1.
Edm.Single, Edm.Double DOUBLE IEEE values.
Edm.Decimal(p, s) DECIMAL(p, s), with p at most 38 Binds are exact and never go through a double. A decimal field must declare its facets, because no one scale holds every decimal the service computes.
Edm.String, opaque JSON VARCHAR UTF-8, with no length limit. A NUL is allowed.
Edm.Date DATE
Edm.TimeOfDay TIME Microseconds. A finer value is refused.
Edm.DateTimeOffset TIMESTAMPTZ A UTC instant, with microseconds. A finer value is refused.
Edm.Guid UUID
Edm.Binary BLOB
Collections of text, integers or booleans Native lists Bound as typed JSON. Lists of other element types are refused.

$metadata publishes what the configuration declares: a decimal's precision and scale, times and timestamps at their declared precision (6 by default), and a collation only where one is declared.

Collation and case

DuckDB exposes no collations to the service, and the service maps only binary. Text comparison, sorting and like are in code point order and case-sensitive, and every text column publishes AsterData.Collation as binary. tolower and toupper are DuckDB's lower and upper, which map Unicode letters.

Where each operation runs

DuckDB uses the same SQL forms as PostgreSQL, adjusted where DuckDB differs, and each operator that runs there is DuckDB's own. This request reads a customer with their two largest invoices and their invoice count:

2 statements on DuckDB

DuckDB, statement 1 of 2

/* aster read customers */ SELECT c."id", c."name", c."email", c."region" FROM "main"."customers" AS c WHERE c."id" = ?
-- binds: 3

DuckDB, statement 2 of 2

/* aster read invoices */ SELECT q."id", q."customerId", q."issued", q."amount", q."paid", q.c6 FROM (SELECT i."id", i."customerId", i."issued", i."amount", i."paid", COUNT(*) OVER (PARTITION BY i."customerId") AS c6, i."customerId" AS "w", i."amount" AS c8, i."id" AS c9 FROM "main"."invoices" AS i WHERE i."customerId" IN (?)) AS q QUALIFY ROW_NUMBER() OVER (PARTITION BY q."w" ORDER BY q.c8 DESC, q.c9) <= ? ORDER BY q."amount" DESC, q."id"
-- binds: 3, 2

An $expand is read one level per statement, by the parents' keys. The per-parent page uses ROW_NUMBER() and the count a window, and the key lists are padded to a power of two by repeating a key, so the statement shapes stay few.

These run in DuckDB:

These run in the service:

A filter on a decimal division or a 128-bit field reads the table's rows into the service, and those rows count against limits.residual_rows, so on a large table the request can be refused. Add a bounded condition, such as a key or a paged sort, as Limits describes.

Errors

Case Response
Integer overflow +, -, *, unary - and MIN div -1 raise DuckDB's Out of Range Error: 400 EvaluationFailed.
Integer division by zero div and mod of integers by zero give null.
Double division by zero / and % by zero give INF, -INF or NaN, which is also OData's divby result.
Decimals A product or sum past 38 digits, or a common type that cannot hold both operands, raises an error: 400 EvaluationFailed. A value too large for a 28-digit decimal is refused when read.
Constraint violation 409 Conflict.
Transaction conflict 409 Conflict. It is not retried.
Statement interrupted by the timeout 503 Timeout.
Isolation level other than repeatable read 501 NotImplemented.
Any other DuckDB error 500 InternalError. The client gets a generic message, and DuckDB's text goes to the log.

Nulls and ordering

Each sort names its null placement, and the service writes NULLS FIRST or NULLS LAST as the sort asks: OData puts nulls first in ascending order and last in descending order. Text is ordered by code point. NaN equals itself and sorts after every other number.

A next page is a keyset query with the same null placement and directions as the sort. $count=true is exact and agrees with the page, as A page with its total shows.

Departures from OData

Where OData 4.01 defines a result and DuckDB gives another, DuckDB's result stands. These are the places:

round takes ties away from zero, as OData's does.

Writes and transactions

Indexes and sort order

The service creates no indexes on a DuckDB file. Each $expand level is read by the parents' keys in an IN (...) list, and every sort ends with the entity key so pages never overlap. What runs on your database shows the SQL for common requests.

Scaffold

The scaffold command reads DuckDB's catalog for the tables, columns and keys, and writes a starting data-source configuration for the types the start-up check accepts, leaving other columns as comments with the reason. Run it with the file's path: scaffold --kind duckdb --url-file PATH, or --url-env VAR. It opens the file as the server does, so another process that holds the file stops it, and it needs no privilege beyond opening the file. Connect your own database walks through it.