aster.Web Data / Docs Aster UI ↗

Aster Web Data / Connect your data

PostgreSQL

How Aster Web Data uses PostgreSQL, and how PostgreSQL's types, collations and results shape what clients receive.

If your data is in PostgreSQL 14 or later, Aster Web Data serves it from the tables you already have. A filter, a sort, a page, a count and an $expand each run in PostgreSQL, usually as one statement, with every comparison made under the column's own collation. The indexes your DBA has built serve those statements, and the answers are the ones psql would give. Where PostgreSQL's answer differs from OData's, PostgreSQL's stands; Departures from OData lists those places.

Three choices on this page change what your users see: how the connection verifies TLS, which collation each text column uses, and which indexes you add for the navigations and sorts your screens use.

Connecting

Declare the database once under databases:, and name it from each data source stored on it:

databases:
  main:
    kind: postgres
    url_env: MAIN_DB_URL        # postgres://user:password@host:5432/database
    tls: verify-full
    ca_file: ca.pem             # optional: otherwise the Mozilla roots

The URL comes from an environment variable (url_env) or a file (url_file), or you give its parts: host, port, user, password_env or password_file, and database. It is read again on every attempt to connect, so a rotated password takes effect without a restart. Secrets explains the rules, and the configuration reference lists every key with its default, including the pool and the retry schedule.

These keys behave in a way particular to PostgreSQL:

Two databases: entries are two connection pools, even on one server, and a request that reads from both is combined in the service, as Reads across databases describes.

The service checks the server version when it opens a connection and refuses a server older than 14, naming the version it found. PostgreSQL 14 is the oldest version the PostgreSQL project supports. The tests pass on PostgreSQL 14, 15, 16, 17 and 18.

Start-up check

When the service opens the database, it checks each data source's table against its configuration. The table and each stored column must exist, each column must hold the field's values exactly (the accepted types are listed under Types), and the column's precision, scale, collation and default must match what the configuration declares. A mismatch lists every problem and says what to declare, for example declare precision: 3 on Order.created.

A failed check makes only that database's sets unavailable, and the check runs again on the retry schedule, so fixing the table brings them back without a restart. A database goes down describes what clients see meanwhile and how to make the service stop instead. To run the check on its own, use the check command.

Types

When setup: recreate creates a table, each field gets the column type below. An existing table must use one of the column types the start-up check accepts, listed after the table.

OData type Column created by recreate Notes
Edm.String VARCHAR(n) up to a length of 10,485,760, otherwise TEXT With the declared collation, if any.
Edm.Boolean BOOLEAN
Edm.Byte, Edm.SByte, Edm.Int16, Edm.Int32 INT
Edm.Int64 BIGINT
Edm.Duration BIGINT Signed nanoseconds.
Edm.Single, Edm.Double DOUBLE PRECISION Holds INF, -INF and NaN.
Edm.Decimal(p, s) NUMERIC(p, s), or NUMERIC without facets A value too large for a 28-digit decimal fails when it is read, and is never rounded.
Edm.Date DATE
Edm.TimeOfDay TIME, or TIME(p) with a declared precision Microseconds unless declared.
Edm.DateTimeOffset TIMESTAMPTZ, or TIMESTAMPTZ(p) Read in UTC. A value with finer precision than the column fails visibly and is not rounded.
Edm.Guid UUID
Edm.Binary BYTEA
128-bit unsigned integer NUMERIC(39,0) Compared, sorted and summed in the database. The sum is range-checked.

A 28-digit decimal here is one whose digits fit a 96-bit mantissa: 28 digits always fit, and 29 only up to 79,228,162,514,264,337,593,543,950,335. A non-zero digit past the 28th decimal place also fails.

The start-up check accepts these column types, and refuses any other:

Every session runs in UTC with ISO date style. Values are bound as their PostgreSQL types, and results are read in binary where that is exact. Inside the JSON of a nested $expand, a decimal or a double is written as text, so it reads back exactly.

Collation and case

A comparison, a sort and like use the column's own collation. name eq 'x' is name = $1 and $orderby=name is ORDER BY name, with no cast and no COLLATE, so an ordinary index on the column serves them. $metadata publishes each text field's collation as AsterData.Collation: the field's own, else the database's collation: key, else the column's. The start-up check refuses a column whose collation differs from the one declared.

Where each operation runs

Each OData operator becomes the PostgreSQL operator with the same meaning, so PostgreSQL's comparison, arithmetic, rounding and errors give the answer. This request reads a customer with their two largest invoices and their invoice count:

1 statement on PostgreSQL

PostgreSQL, statement 1 of 1

/* aster read customers */ SELECT c."id", c."name", c."email", c."region", CAST(c.xmin AS text) AS c5, q2.j AS "invoices", (SELECT COUNT(*) FROM "public"."invoices" AS i2 WHERE i2."customerId" = c."id") AS c7 FROM "public"."customers" AS c LEFT JOIN LATERAL (SELECT COALESCE(json_agg(json_build_array(q."issued", CAST(q."amount" AS text), q."id", q.c4) ORDER BY q."amount" DESC, q."id"), '[]'::json) AS j FROM (SELECT i."issued", i."amount", i."id", CAST(i.xmin AS text) AS c4 FROM "public"."invoices" AS i WHERE i."customerId" = c."id" ORDER BY i."amount" DESC, i."id" LIMIT $1) AS q) AS q2 ON TRUE WHERE c."id" = $2
-- binds: 2, 3

The invoices come from a LEFT JOIN LATERAL that builds them as JSON, in the same statement as the customer. PostgreSQL reads an $expand this way where the navigation's foreign key leads an index and the table has at most 48 columns; otherwise each level is read by a second statement that takes the parents' keys as one array, = ANY($1).

These run in PostgreSQL:

These run in the service, within the limits on rows it may hold: mod of doubles (PostgreSQL has no float8 remainder), 128-bit arithmetic, date, time and duration arithmetic, regular expressions, hour, minute and second, casts from string, lambda expressions over collections, and full-text search.

Errors

Case Response
Integer overflow +, -, *, unary - and / on bigint raise 22003, including MIN div -1: 400 EvaluationFailed. MIN mod -1 is 0.
Division by zero div, mod and divby by zero raise 22012 on every numeric type, doubles included: 400 EvaluationFailed.
Double overflow A float8 result past the finite range raises 22003: 400 EvaluationFailed.
Decimals numeric is unbounded, so its arithmetic does not overflow. A result too large for a 28-digit decimal is refused when read, never rounded. A quotient keeps at least 16 significant digits, so 1.5 div 2 is 0.75000000000000000000.
Evaluation order PostgreSQL chooses it. false and 1 div 0 eq 1 may fail or succeed, as the SQL false AND 1 / 0 = 1 would.
Unique, foreign key, not-null or check violation 409 Conflict.
Serialisation failure or deadlock 409 TransactionConflict, after the atomic group's retries.
Data exception (22xxx), cardinality error (21xxx), or two columns of different collations compared 400 EvaluationFailed.
Statement timeout 503 Timeout.
Connection lost 503 ConnectionLost.
Any other database error 500 InternalError. The client gets a generic message, and the database's detail goes to the log.

Nulls and ordering

OData puts nulls first in ascending order and last in descending order, and the service writes that as NULLS FIRST or NULLS LAST. A column the table declares NOT NULL sorts with no NULLS clause, so a plain index can serve it.

NaN is PostgreSQL's: NaN = NaN is true, and NaN sorts after every other number.

A next page is a keyset query from the last row's values for every sort key, including the entity key, so a row inserted or deleted before the cursor moves no other row across a page boundary. Offset paging is used only where the order is an expression, or with $apply or /$all. $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 PostgreSQL gives another, PostgreSQL's result stands. These are the places:

Writes and transactions

A request waits for a free connection from the pool; Limits and A database goes down give the timeouts and the 503 answers.

Indexes and sort order

Indexes gives the indexes that serve a sorted page and a navigation, including NULLS FIRST for a nullable sort column. Two more rules are particular to PostgreSQL:

Scaffold

The scaffold command reads PostgreSQL's catalog and writes a starting data-source configuration for the columns it can map exactly, leaving the others as comments with the reason. It needs CONNECT, USAGE on each schema and SELECT on the tables, and runs no DDL and no write. Connect your own database walks through it, and Model your data covers what to add once it has run.