PostgreSQL provider
Use
nextorm.postgresfor PostgreSQL; it renders@nameparameters,limit/offsetpaging,coalesce,extractdate parts andstddev/varianceaggregates, supportsINTERSECT ALL/EXCEPT ALL, and requires double-quoted aliases.
Prerequisites: Provider overview · Quickstart
Overview
PostgresDataContext (src/nextorm.postgres/PostgresDataContext.cs) wraps Npgsql. It creates an
NpgsqlConnection and passes null parameter values as DBNull (Npgsql rejects a null parameter value).
PostgresDialect (src/nextorm.postgres/PostgresDialect.cs) is the dialect:
- parameter placeholder
@name; - string concatenation with
||; MakeCoalescerenderscoalesce(a, b);- boolean literals are
true/false; - identifiers use double quotes (
Escapereturns"name"), andMakeColumnReferencequotes a name too so that a quoted alias survives when it is referenced from an outer query; - derived tables and table-valued functions must be aliased (
RequireSubqueryAliasistrue, and its consequence is that an alias is always emitted); INTERSECT ALL/EXCEPT ALLare supported (SupportsIntersectExceptAllistrue);- arrays are supported (
SupportsArraysistrue): array parameters with theany/allquantifiers and the array functions, with two per-name exceptions (SupportsArrayFunction):array_reverseis refused on every PostgreSQL version, andarray_sortrenders only from an explicit 18+; - JSON/JSONB is supported (
SupportsPostgresJsonSqlistrue, PostgreSQL only; nativejsonbstorage isSupportsJson): thejson_agg/jsonb_aggaggregates, the construction/access functions and the->/->>/@>/?operators, withJsonDocument/JsonElement/JsonNodeparameters bound asjsonb; greatest/leastand the aggregateFILTER (WHERE ...)clause are enabled (SupportsGreatestLeastistrueandAggregateFilterStyleisAnsiFilterwhen the configured server version is unset or 9.4+; on a version below 9.4 the dialect rejects filtered aggregates withNotSupportedException);date_truncis enabled (SupportsDateTruncistrue);- date arithmetic is enabled (
SupportsDateArithmeticistrue):SqlFunctions.Sql.date_add/end_of_monthand theDateTime.Add*methods render PostgreSQL interval arithmetic (x + (n * interval '1 day'),date_trunc('month', x) + interval '1 month - 1 day'); - a
TimeSpancolumn maps a PostgreSQLintervalonly for zero-month values: a returned interval carrying a non-zero month component (either sign) is rejected withInvalidCastExceptionnaming calendar months, zero-month days and microseconds convert exactly with checked arithmetic (an unrepresentable value fails withOverflowExceptioninstead of wrapping),time/timetzcolumns use the driver'sTimeSpanaccessor, and a mappedTimeOnlyprojection is supported for atimecolumn (µs exact, with the day domain enforced); - the
string_agg/array_aggaggregates are enabled (SupportsStringArrayAggregatesistrue); - full-text search is enabled (
SupportsFullTextistrue):SqlFunctions.Sql.containsrendersto_tsvector(col) @@ plainto_tsquery(search)andfreetextwebsearch_to_tsquery(search); ranking is available through the nativeSqlFunctions.Postgres.ts_rank/ts_rank_cd/ts_headline(gated bySupportsTextSearchFunctions); - the extended scalar function library is enabled (
SupportsExtendedScalarFunctionsistrue): additional math (asin,cbrt,mod, ...), string (split_part,lpad,initcap, ...), POSIX regular expression (regexp_replace,regexp_like, ...), date/time (make_interval,justify_days,justify_hours,to_char,to_date, ...),num_nulls/num_nonnullsand the type helperpg_typeof; - the session/information functions are enabled (
SessionInfoFunctionsistrue):SqlFunctions.Sql.current_user/session_user/current_schemarender the key words andcurrent_database/versionrendercurrent_database()/version(); - the UUID generators are enabled (
UuidGeneratorsistrue):SqlFunctions.Sql.gen_random_uuid()rendersgen_random_uuid()(PostgreSQL 13+), anduuidv7()rendersuuidv7()only when the configured server version is an explicit 18 or later; below 18 and when the version is unset the call is refused at preparation withNotSupportedExceptionand no v4 UUID is substituted; - the boolean, bitwise, statistical and ordered-set aggregates are enabled
(
SupportsBooleanAggregates,SupportsBitAggregates,SupportsStatisticalAggregatesandSupportsOrderedAggregatesaretrue):bool_and/bool_or/every,bit_and/bit_or/bit_xor,corr/covar_*/regr_*andpercentile_cont/percentile_disc/modewithWITHIN GROUP; - the ordered-set percentile surface adds the
mediancomposition (SqlFunctions.Postgres.median(() => x)→percentile_cont(0.5) within group (order by x), an aggregate: one row per group) under the separateSupportsOrderedPercentilesflag. The continuous form interpolates, sopercentile_contandmedianrequire a floating-point sort expression and refuse an integer/numeric one (The percentile_cont function on PostgreSQL requires a floating-point sort expression.);percentile_disckeeps the sort expression's type. A fraction is adoublein[0, 1], not a percentage, and is validated before rendering; the genericSqlFunctions.Postgres.percentilehas no native spelling and throwsNotSupportedException; - aggregate names are remapped:
stdev→stddev,stdevp→stddev_pop,var→variance,varp→var_pop; Math.Logmaps toln(...)(PostgreSQL'slog()is base 10);SqlFunctions.Postgres.log(base, x)renderslog((base)::numeric, (x)::numeric): the only two-argument overload islog(numeric, numeric), so both operands are cast, and a null operand yields SQLNULL;Math.Round(x, digits)casts adouble/floatfirst argument tonumeric(round((x)::numeric, digits)): PostgreSQL has noround(double precision, integer);SqlFunctions.Postgres.setseed(seed)renderssetseed(seed)(gated bySupportsRandomSeed); PostgreSQL'ssetseedreturnsvoid, so a projected value is alwaysnull;DateTime.Nowrendersnow(),DateTime.UtcNowrendersnow() at time zone 'utc';- date parts render as
extract(part from value);SqlFunctions.Sql.extract(part, value)coversquarter/week(ISO)/dow/isodowandSqlFunctions.Sql.date_part("epoch", value)renderscast(extract(epoch from value) as double precision); - paging is
limit n/limit n offset m; an offset-only query emitsoffset malone.
The variability aggregates follow the shared cardinality contract: Stdev/Var are null for zero or
one contributing value, Stdevp/Varp are null for zero and 0.0 for one. The result is a real
Nullable<T>, so a sample over an empty or single-row set materializes as null.
Registering the provider
Two overloads are available on DataContextBuilder
(src/nextorm.postgres/DI/PostgresDataContextOptionsBuilderExtensions.cs):
using NextORM.Core;
using NextORM.Postgres;
var byString = new DataContextBuilder().UsePostgres("Host=localhost;Database=app;Username=app;Password=secret");
using var connection = new Npgsql.NpgsqlConnection("Host=localhost;Database=app;...");
var byConnection = new DataContextBuilder().UsePostgres(connection);
using var ctx = byString.CreateDataContext(); // IDataContext
Directly:
using NextORM.Core;
using NextORM.Postgres;
using IDataContext ctx = new PostgresDataContext("Host=localhost;Database=app;...", new DataContextBuilder());
Server version
The dialect can be pinned to the server version, which gates version-dependent syntax. Configuration is
explicit — nextorm never probes the live server. Pass the version with the context constructor's
Version parameter; leaving it unset keeps the historical behaviour and assumes PostgreSQL 9.4 or later,
so the ANSI aggregate FILTER (WHERE ...) clause stays enabled. A version below 9.4 disables it: a
filtered aggregate rejects with NotSupportedException.
The per-name array and UUID functions are a deliberate exception to that permissive unset default.
PostgreSQL provides no array_reverse in any current release and added array_sort and uuidv7 only
in 18, so an unset version refuses all three at preparation instead of assuming a version.
An explicit 18+ enables array_sort/uuidv7; array_reverse stays refused on every version. No other
unset-version gate changes, and no fallback (NULL, an unchanged array, a v4 UUID) is substituted.
// Unset: filtered aggregates are enabled (assumed 9.4+), while array_reverse, array_sort and uuidv7 are refused.
using var current = new PostgresDataContext("Host=localhost;Database=app;...", new DataContextBuilder());
// Pin 9.3: the aggregate `FILTER (WHERE ...)` clause is rejected.
using var legacy = new PostgresDataContext("Host=localhost;Database=app;...", new DataContextBuilder(), new Version(9, 3));
// Pin 17: array_sort() and uuidv7() are refused at preparation too; array_reverse is always refused.
using var pg17 = new PostgresDataContext("Host=localhost;Database=app;...", new DataContextBuilder(), new Version(17, 0));
// Pin 18: array_sort() and uuidv7() render natively; array_reverse is still refused.
using var pg18 = new PostgresDataContext("Host=localhost;Database=app;...", new DataContextBuilder(), new Version(18, 0));
The version is immutable and fixed per concrete context type for the process lifetime. Two contexts
of the same concrete type that request different versions throw InvalidOperationException; to run
against several versions in one process, declare a distinct context subclass per version:
public sealed class Postgres93DataContext : PostgresDataContext
{
public Postgres93DataContext(string connectionString, DataContextBuilder builder)
: base(connectionString, builder, new Version(9, 3)) { }
}
Paging
ctx.From<ISimpleEntity>().Page(5, 10).Select(x => x.Id); // limit 5 offset 10
ctx.From<ISimpleEntity>().Offset(10).Select(x => x.Id); // offset 10
select id
from simple_entity limit 5 offset 10
select id
from simple_entity offset 10
OFFSET may appear on its own, but LIMIT must come first when both are present. Because PostgreSQL
does not require an injected sort, no ORDER BY is added.
Coalesce, date parts and aggregates
var query = ctx.From<IComplexEntity>()
.Select(x => new
{
Fallback = x.String ?? "",
Year = x.Datetime!.Value.Year,
});
select coalesce(somestring, '') as "Fallback", extract(year from dt) as "Year"
from complex_entity
var stdev = ctx.From<IComplexEntity>().Select(x => SqlFunctions.Sql.stdev((double)x.Id)); // stddev(...)
var varp = ctx.From<IComplexEntity>().Select(x => SqlFunctions.Sql.varp((double)x.Id)); // var_pop(...)
count and count_big both render count(*), because PostgreSQL's count already returns a 64-bit
integer.
Arrays
PostgreSQL is the only supported provider with native arrays, and the array surface lives on
Postgres (Sql stays cross-provider). An array operand is passed as a single parameter, so
column = any(@array) works with a runtime parameter or a captured array, and the SQL does not depend
on the number of elements:
var ids = new long[] { 1, 2, 3 };
ctx.From<IComplexEntity>().Where(e => SqlFunctions.Postgres.any(e.Id, ids)); // (id = any(@p0))
ctx.From<IComplexEntity>().Where(e => e.Id == SqlFunctions.Postgres.any(ids)); // id = any(@p0)
ctx.From<IComplexEntity>().Where(e => e.Id == SqlFunctions.Postgres.any(SqlFunctions.Parameter<long[]>(0))); // id = any(@norm_p0)
select id
from complex_entity where (id = any(@p0))
The array functions (cardinality, array_length, array_position, ...) and the @>/&& operators
are documented in Scalar functions. Two names are
refused at preparation even on PostgreSQL: array_reverse (no release provides it) and array_sort
below an explicit 18+. Other providers reject the array surface with NotSupportedException.
Range types
PostgreSQL is the only supported provider with native range types (int4range, int8range,
numrange, tsrange, tstzrange, daterange). Use the provider-agnostic
Range<T> for a column or parameter; the provider binds it through Npgsql
and reads it back preserving unbounded sides and the empty range. The PostgreSQL-only range surface
(overlaps, range_contains/range_contained_by, range_union, lower/upper/isempty, the
constructors and empty_range<T>()) lives on
Postgres and is gated by
SupportsRanges:
var window = new Range<int>(15, 25); // [15,25)
ctx.From<IReservation>()
.Where(e => SqlFunctions.Postgres.overlaps(e.During, window))
.Select(e => e.Id)
.ToList(); // (during && @p0)
Multirange types (int4multirange…datemultirange) map to Range<T>[] and use the same operator names
overloaded for multiranges, plus multirange, range_merge and the range_agg/range_intersect_agg
aggregates. See PostgreSQL-specific SQL for the
full surface.
The in-memory provider evaluates the whole range and multirange surface with the same semantics; every
other provider rejects it with NotSupportedException.
See PostgreSQL-specific SQL for the full table.
JSON and JSONB
PostgreSQL is the only supported provider with json/jsonb. A [JsonColumn] property maps a CLR object
to a native jsonb column on read and write (Auto storage; Native forces jsonb, Text forces text),
so the object round-trips through insert, update and RETURNING. Passing a JsonDocument, JsonElement
or JsonNode parameter binds it as jsonb, so the access operators and functions work directly:
using System.Text.Json;
var document = JsonDocument.Parse("""{"name":"Alice","tags":["a","b"]}""");
using var ctx = new PostgresDataContext(connectionString, new DataContextBuilder());
ctx.From<IComplexEntity>()
.Where(e => SqlFunctions.Postgres.json_get_text(SqlFunctions.Parameter<JsonDocument>(0), "name") == "Alice")
.Select(e => e.Id)
.ToList(document);
ctx.From<IComplexEntity>()
.Select(e => SqlFunctions.Postgres.jsonb_agg(e.String)); // jsonb_agg(somestring)
Provider parameter binding: a plain JSON string parameter is bound as text. The six
json_get/json_get_text/json_get_path/json_get_path_text accessors (string-key and integer-index
overloads) are translator-local — they apply contextual SQL typing to a raw client document value
(literal, captured value or explicit parameter) and parse it as jsonb in the generated SQL, and they type
the key/index/path operand as text/integer/text[]. A native json/jsonb column or a server-side JSON
expression is emitted uncast, so its operand and result types are unchanged. Use
SqlFunctions.Postgres.json_cast(value) to parse a plain string for any other JSON function. A
bare JsonNode/JsonNode? property (declared exactly JsonNode, not JsonObject/JsonArray) reads a
native json/jsonb column: the column text is read and parsed for object, array and scalar roots, in both
scalar (Select(x => x.Data)) and composite projections. A SQL NULL and a JSON literal null both
materialize as CLR null. Declared JsonObject/JsonArray properties and a bare
JsonDocument/JsonElement scalar projection are outside this support (map them with [JsonColumn], or
project a JsonDocument/JsonElement in a named shape). [JsonColumn] mapping and JSON parameter binding
are unchanged, and nextorm generates no column DDL. The full
surface (json_agg, jsonb_build_object, ->, ->>, #>, @>, ?, ?|, ?&, ...) is documented
in Scalar functions. Other providers
reject it with NotSupportedException.
SqlFunctions.Sql.base64_encode and base64_decode use the core encode(value, 'base64') /
decode(value, 'base64') pair (the encode result has its trailing newline removed explicitly).
SqlFunctions.Sql.crypt_gen_random calls gen_random_bytes from the pgcrypto extension: the
extension must be installed on the server (CREATE EXTENSION pgcrypto). nextorm neither installs it
nor detects its absence — a missing extension surfaces as the provider's native error.
Additional function surface
PostgreSQL also opts into greatest/least, date_trunc, the string_agg/array_agg aggregates, the
aggregate FILTER (WHERE ...) clause, the portable iif (rendered case when ... then ... else ... end),
the percent_rank/cume_dist/nth_value window functions and the built-in set-returning table
functions generate_series, unnest, regexp_matches, regexp_split_to_table,
jsonb_array_elements(_text), jsonb_each(_text), jsonb_object_keys, jsonb_path_query,
ts_stat and the record functions jsonb_to_record/jsonb_to_recordset
(SqlFunctions.Postgres.jsonb_to_record<TRow>(json), whose
result schema is rendered as the alias column-definition list from the caller's TRow under
SupportsResultSchema(TableFunctionSchema)):
ctx.From<IComplexEntity>()
.GroupBy(e => new { e.Int })
.Select(e => new
{
e.Int,
Names = SqlFunctions.Sql.string_agg(e.String, ","),
Big = SqlFunctions.Sql.count(() => e.Id > 10L)
}); // string_agg(somestring, ',') ... count(*) filter (where (id > 10))
These are documented in
Scalar functions. SQLite also
accepts the FILTER clause; the other functions are PostgreSQL-only.
*ALL set operations and null ordering
PostgreSQL is the only supported relational provider that implements INTERSECT ALL and EXCEPT ALL,
so IntersectAll/ExceptAll render their SQL directly.
var q = a.Select(x => x.Id).IntersectAll(b.Select(x => x.Id)); // ... intersect all ...
PostgreSQL treats NULL as the largest value, so ORDER BY … DESC puts the NULL group first. The
shared test suite avoids depending on this; the provider test pins it explicitly.
var r = ctx.From<IComplexEntity>()
.OrderByDescending(it => it.Int)
.Select(it => new { it.Id })
.ToList();
// r[0].Id == 1 (the row whose Int is NULL)
Explicit NULL placement
PostgreSQL has native NULLS FIRST/NULLS LAST, so NullsPosition
First/Last render the modifier directly and ISqlDialect.SupportsNullsOrdering
is true:
var r = ctx.From<IComplexEntity>()
.OrderBy(it => it.Int, NullsPosition.Last)
.Select(it => new { it.Id })
.ToList();
// order by nullableint NULLS LAST
Placement is independent of direction: First/Last keep their group even when the key is descending
(order by nullableint desc NULLS FIRST). NullsPosition.Default emits no clause and keeps the PostgreSQL
default, which is direction-aware and opposite to SQLite/SQL Server/MySQL/MariaDB: ascending puts nulls
last and descending puts them first (null sorts as the largest value). Because the modifier is
native, the key is emitted once, DISTINCT/DISTINCT ON need no special handling, and a parameterized key
binds once. See Sorting and paging.
Large objects (LOB streaming)
A single byte[] or string column can be read as a Stream/TextReader without loading the value into managed memory. Npgsql implements GetStream/GetTextReader for bytea and text, so PostgreSQL is one of the three providers that support the LOB terminals in this release (alongside SQL Server and SQLite):
await using var stream = ctx.From<BinaryEntity>()
.Where(x => x.Id == 1)
.Select(x => x.Payload)
.ToStream(); // opens the reader with CommandBehavior.SequentialAccess
using var reader = ctx.From<Document>()
.Where(x => x.Id == 1)
.Select(x => x.Body)
.ToTextReader();
The returned stream owns the reader and the per-call command until it is disposed, and it does not close the context; the projection must be exactly one byte[]/string column (otherwise InvalidOperationException). MySQL/MariaDB, ClickHouse and the in-memory provider reject the terminals with NotSupportedException. See Streaming large objects.
PostgreSQL also supports the multi-column ToDataReader/ToDataReaderAsync terminal: it hands the same sequential-access command over as a caller-owned DbDataReader, so the caller can read every column and row (or several LOB columns in order) without materialising the result. SQLite supports it too, but via a buffered, locator-free reader (no rowid, no SequentialAccess, so no chunked LOB); MySQL/MariaDB, ClickHouse and the in-memory provider have no sequential-access support and reject it.
Aliases
Derived tables and table-valued functions must be aliased with double quotes:
select t1.value, t2.somestring as "String"
from all_rows() as "t1"
join complex_entity as "t2" on t1.id = t2.id
Bulk insert
The native path is binary COPY ... FROM STDIN (FORMAT BINARY) through NpgsqlBinaryImporter on the
context's own connection; Auto selects it for a compatible request, Native refuses by name instead of
falling back, and Portable writes INSERT ... VALUES (the only path for Returning*).
- Rows stream into one importer; a finite
MaxBatchSizestarts one importer per bounded chunk,Completed on success and disposed on error or cancellation. KeepIdentitykeeps the explicit identity column in theCOPYcolumn list; the portable statement addsOVERRIDING SYSTEM VALUE.IgnoreDuplicatesis portable-only (ON CONFLICT DO NOTHING).TableLock,FireTriggersandCheckConstraints=falseare refused by name;KeepNulls=falseneeds a default-equivalent shape (every written column nullable with a NULL/absent SQL default).DatabaseNameaddresses only the connection's current database — naming it is a no-op, another database is refused by name;ServerNameis refused (no rerouting).Timeoutis one whole-invocation budget feeding the importer timeout and the linked token; an expired budget is aTimeoutException, and a caller cancellation always wins.
See Bulk insert.
Provider differences
| Aspect | PostgreSQL |
|---|---|
| Parameter placeholder | @name |
| Paging | limit n / limit n offset m / offset m |
| Injected sort when paging | none |
| Concat | || |
| Coalesce | coalesce |
| Boolean literal | true / false |
| Identifier quoting | double quotes (as "t1") |
| Derived table / TVF alias | required |
*ALL |
supported |
| Arrays | supported (any(@array), cardinality, ...) |
| JSON/JSONB | supported (json_agg, ->, ...; native json/jsonb columns via [JsonColumn]; JsonDocument/JsonElement/JsonNode params bind as jsonb) |
LOB streaming (ToStream/ToTextReader, ToDataReader) |
supported (SequentialAccess; single byte[]/string column, or a multi-column caller-owned reader) |
greatest / least / date_trunc |
supported (greatest/least ignore NULL arguments) |
| Conditional function | iif(cond, a, b) → case when cond then a else b end |
| Window functions | percent_rank(), cume_dist(), nth_value(expr, n) supported |
date_add / end_of_month / DateTime.Add* |
interval arithmetic (x + (n * interval '1 day')) |
string_agg / array_agg / aggregate filter |
supported (aggregate filter requires 9.4+; unset assumes ≥9.4) |
| Median / percentiles | SqlFunctions.Postgres.median(() => x) → percentile_cont(0.5) within group (order by x) (aggregate, one row per group; floating-point sort only); percentile_cont/percentile_disc ordered-set; generic percentile throws (SupportsOrderedPercentiles) |
| Server version | configured explicitly via the context Version |
| Session/info functions | current_user, session_user, current_schema, current_database(), version() |
| Table functions | generate_series, unnest, regexp_matches, regexp_split_to_table, jsonb_array_elements(_text), jsonb_each(_text), jsonb_object_keys, jsonb_path_query, ts_stat |
| Recursive CTE | with recursive (no max-recursion option) |
stdev / stdevp |
stddev / stddev_pop |
var / varp |
variance / var_pop |
DateTime.Now / UtcNow |
now() / now() at time zone 'utc' |
ORDER BY null placement (default) |
nulls sort as the largest value, so DESC puts them first and ASC last |
Explicit NULL placement (NullsPosition) |
native NULLS FIRST/NULLS LAST (SupportsNullsOrdering) |
See also
Source: tests/nextorm.postgres.tests/Dialect/PostgresDialectTests.cs:21,27,41,49,
tests/nextorm.postgres.tests/SqlGeneration/SqlGenerationTests.cs:117,130,139,151,160,172,199,211,223,248,788,976,
tests/nextorm.integration.tests/Postgres/PostgresSpecificTests.cs:15,24,
src/nextorm.postgres/PostgresDialect.cs, src/nextorm.postgres/PostgresDataContext.cs,
src/nextorm.postgres/DI/PostgresDataContextOptionsBuilderExtensions.cs.