EF Core · docs/ef-core/README.md

Entity Framework Core

Queries, mappings, migrations, scaffolding, and PostgreSQL 19 graph.

65 min read14,113 wordsSource synchronized
View source on GitHub
ON THIS PAGE

Entity Framework Core

BlueTusk.EntityFrameworkCore is the EF Core provider over the BlueTusk ADO.NET driver. The current implementation supports provider registration, relational queries, change tracking and PostgreSQL CRUD, explicit transactions and savepoints, store-generated values, optimistic concurrency, and PostgreSQL-native type mappings.

Microsoft’s provider-facing relational test package is consumed by a dedicated test assembly. The exact adopted suites, commands, and completed 1.0 coverage gate are recorded in EF Core relational specification tests.

Configure a context

services.AddSingleton(_ =>
    new BlueTuskDataSourceBuilder(connectionString).Build());
services.AddDbContext<AppDbContext>((serviceProvider, options) =>
    options.UseBlueTusk(serviceProvider.GetRequiredService<BlueTuskDataSource>()));

The long-lived data source is the recommended application entry point: EF-created logical connections share its physical pool, configured codecs, and runtime type catalogue, while the dependency-injection container owns the data source lifetime. UseBlueTusk also accepts a connection string or an existing BlueTuskConnection for compatibility and dedicated-lifetime scenarios; directly constructed connections are unpooled.

Internally, EF reaches Data only through a small assembly-private provider contract. That contract covers logical/data-source creation, ownership, type-registry snapshots, capability probing, dedicated administration connections, pool/catalogue lifecycle and diagnostics. It adds no public API and prevents query, graph and database-lifecycle services from casting or constructing concrete provider types. See ADR 0017.

Configure runtime user-defined types before registering the data source:

var builder = new BlueTuskDataSourceBuilder(connectionString);
builder.MapEnum<OrderStatus>("app.order_status");
builder.MapComposite<Address>("app.address");
var dataSource = builder.Build();

services.AddDbContext<AppDbContext>(options =>
    options.UseBlueTusk(dataSource));

Optional extensions keep their ADO.NET and EF registrations separate. For example, citext uses BlueTusk.Extensions.Citext for the data-source codec and BlueTusk.Extensions.Citext.EntityFrameworkCore for EF scalar/array mappings and migration helpers:

var dataSource = new BlueTuskDataSourceBuilder(connectionString)
    .UseCitext()
    .Build();

services.AddDbContext<AppDbContext>(options =>
    options.UseBlueTusk(dataSource, provider => provider.UseCitext()));

Pgvector integration follows the same split. The EF package maps dense, half-precision, and sparse vectors, preserves dimension-qualified store types, and translates the index-compatible vector and bit distances while the data source owns the extension wire codecs:

var dataSource = new BlueTuskDataSourceBuilder(connectionString)
    .UsePgVector()
    .Build();

services.AddDbContext<AppDbContext>(options =>
    options.UseBlueTusk(dataSource, provider => provider.UsePgVector()));

var nearest = await context.Items
    .OrderBy(item => EF.Functions.L2Distance(item.Embedding, probe))
    .Take(10)
    .ToListAsync();

PostGIS follows the same data-source/provider split. The transport package owns lossless EWKB codecs; BlueTusk.Extensions.PostGIS.EntityFrameworkCore maps the NetTopologySuite hierarchy, geometry/geography typmods, and arrays, and adds typed spatial translations:

var dataSource = new BlueTuskDataSourceBuilder(connectionString)
    .UsePostGis()
    .Build();

services.AddDbContext<AppDbContext>(options =>
    options.UseBlueTusk(dataSource, provider => provider.UsePostGis()));

var nearby = await context.Places
    .Where(place => EF.Functions.IsWithinDistance(place.Location, probe, 500))
    .OrderBy(place => place.Location.Distance(probe))
    .ToListAsync();

Configure exact spatial intent with store types such as geometry(Point,4326), geography(Point,4326), and geometry(Polygon,3857)[]. Geography accepts only operations implemented by PostGIS for geography; geometry-only calls fail with a focused diagnostic.

TimescaleDB also keeps data-source and EF registration separate. The optional EF package adds schema-qualified interval and integer time_bucket translations plus typed first, last, and histogram group aggregates:

var dataSource = new BlueTuskDataSourceBuilder(connectionString)
    .UseTimescaleDb()
    .Build();

services.AddDbContext<AppDbContext>(options =>
    options.UseBlueTusk(dataSource, provider => provider.UseTimescaleDb()));

var hourly = context.Metrics
    .GroupBy(metric => EF.Functions.TimeBucket(width, metric.RecordedAt))
    .Select(group => new
    {
        group.Key,
        First = EF.Functions.TimescaleFirst(
            group.Select(metric => ValueTuple.Create(metric.Value, metric.RecordedAt))),
    });

Aggregate input ordering, distinctness, and filters remain composable through LINQ. The package also owns extension and hypertable migration helpers; the feature-only ADO.NET package owns retention, Hypercore columnstore, and continuous-aggregate policy operations.

PostgreSQL type mappings

In addition to the standard .NET relational types, the 0.3 query work maps BlueTusk’s wire-native PostgreSQL scalar values. This includes inet/cidr, macaddr/macaddr8, all built-in geometric values, bit/varbit, arbitrary-precision numeric, money, pg_lsn, tid, timetz, native intervals, jsonpath, tsvector/tsquery, object identifiers, transaction values, and system-catalogue values. PostgreSQL 19 oid8 and regdatabase use BlueTuskObjectIdentifier64 and BlueTuskRegDatabase, including arrays. string can be explicitly mapped to json, jsonb, or xml. EF structural JSON configured with ToJson() defaults to jsonb, reads through EF’s UTF-8 JSON stream contract, and writes parameters with the exact built-in JSON/JSONB OID. Nested scalar and structural member projections use PostgreSQL’s native ->, ->>, #>, and #>> traversal with typed casts. LINQ over primitive JSON collections expands through jsonb_array_elements_text/json_array_elements_text; LINQ over structural JSON collections uses typed jsonb_to_recordset/json_to_recordset inside PostgreSQL’s ROWS FROM (...) WITH ORDINALITY form so predicates, ordering, and custom JSON property names remain composable.

Ambiguous types default to the general-purpose mapping: BlueTuskNetworkAddress uses inet, BlueTuskBitString uses bit varying, and BlueTuskTransactionSnapshot uses pg_snapshot. Select the alternative with normal EF configuration:

modelBuilder.Entity<NetworkRule>(entity =>
{
    entity.Property(rule => rule.Network).HasColumnType("cidr");
    entity.Property(rule => rule.Mask).HasColumnType("bit(128)");
    entity.Property(rule => rule.Document).HasColumnType("jsonb");
});

Provider mappings carry the exact PostgreSQL OID into every parameter, including null parameters, so store-type intent is preserved on the wire.

CLR arrays and supported mutable generic collections such as List<T> map to PostgreSQL arrays. One- and multidimensional CLR arrays preserve shape, while one-dimensional collections convert to native wire arrays and materialize back to their declared collection shape. Both forms preserve null reference-type elements and exact store intent such as cidr[], bit(128)[], jsonb[], and schema-qualified user-defined arrays. Structural snapshots detect in-place element changes and list additions. byte[] remains the scalar bytea mapping; use byte[][] for bytea[].

One- through four-dimensional arrays support native projected element access and inclusive slicing. PostgreSQL subscripts and bounds are one-based. Array2D constructs a rectangular two-dimensional array from one to four equal-length row arrays, while preserving each row and bound as an ordinary EF parameter:

int[] firstRow = [5, 6];
int[] secondRow = [7, 8];

var result = await context.Values
    .Select(value => new
    {
        Element = EF.Functions.ArrayElement(value.Matrix, 2, 1),
        FirstColumn = EF.Functions.ArraySlice(value.Matrix, 1, 2, 1, 1),
        Constructed = EF.Functions.Array2D(firstRow, secondRow),
    })
    .SingleAsync(cancellationToken);

These operations emit ARRAY[...], [subscript], and [lower:upper] directly, compose in filters/projections and compiled queries, and preserve the CLR array rank on materialization. PostgreSQL returns SQL NULL for an out-of-range element, so non-nullable value-type projections must use valid subscripts.

The six built-in range families map to BlueTuskRange<T>, and the corresponding multiranges map to BlueTuskMultirange<T>:

Subtype Range Multirange
int int4range int4multirange
long int8range int8multirange
BlueTuskNumeric numrange nummultirange
DateTime tsrange tsmultirange
DateTimeOffset tstzrange tstzmultirange
DateOnly daterange datemultirange

Range and multirange arrays are supported as well.

Schema-qualified store types map runtime-registered enums and composites, catalogue-discovered domains, and lossless BlueTuskRecord values. Their arrays are supported with the same exact runtime type identity. Configure primitive-collection element store types explicitly so EF can build the element mapping used by query parameters and change tracking:

modelBuilder.Entity<Order>(entity =>
{
    entity.Property(order => order.Status)
        .HasColumnType("app.order_status");
    entity.PrimitiveCollection(order => order.StatusHistory)
        .HasColumnType("app.order_status[]")
        .ElementType(element => element.HasStoreType("app.order_status"));
    entity.Property(order => order.ShippingAddress)
        .HasColumnType("app.address");
});

Runtime enum and domain properties participate in ordinary typed predicates; captured enum/domain values retain the schema-qualified column mapping and use the data source’s runtime codec catalogue. Typed composites additionally support direct CLR member access in filters, ordering, and projections. BlueTusk maps a member to the same snake-case field name used by the composite codec, or to its explicit BlueTuskName override, and emits PostgreSQL’s native parenthesized field access:

var addresses = await context.Orders
    .Where(order => order.Status == status
        && order.ShippingAddress.Street == street)
    .Select(order => new
    {
        order.Id,
        order.ShippingAddress.HouseNumber,
        order.ShippingAddress.Street,
    })
    .ToListAsync(cancellationToken);

Nested composite members are resolved from the data source’s catalogue rather than inferred from CLR types. Register every typed composite in the graph and load the runtime catalogue before the first nested query (applications that open the data source earlier may already have loaded it):

var dataSource = new BlueTuskDataSourceBuilder(connectionString)
    .MapComposite<Address>("app.address")
    .MapComposite<Location>("app.location")
    .Build();

await dataSource.ReloadTypesAsync(cancellationToken);

var points = await context.Orders
    .Select(order => new
    {
        order.ShippingAddress.Location.Latitude,
        order.ShippingAddress.Location.Longitude,
    })
    .ToListAsync(cancellationToken);

Lossless BlueTuskRecord properties use RecordField<T> because their field shape is discovered at runtime rather than represented by CLR members:

var records = await context.Orders
    .Select(order => new
    {
        Street = EF.Functions.RecordField<string>(
            order.RecordAddress,
            "street"),
        Note = EF.Functions.RecordField<string?>(
            order.RecordAddress,
            "note"),
    })
    .ToListAsync(cancellationToken);

RecordField<T> can also return a registered nested composite (or another BlueTuskRecord) and compose with another member/record-field access. The catalogue supplies the exact nested store type at every level.

The record field name must be constant query metadata, is validated as a PostgreSQL identifier, and is always centrally delimited; it is never treated as an SQL fragment. T must match the field’s mapped CLR type, including nullability. Typed and nested-composite fields, dynamic record fields, enum/domain parameters, projections, predicates, and compiled queries execute across PostgreSQL 15–19. Attempting nested access before the runtime catalogue is loaded produces a focused translation error rather than guessing a mapping.

PostgreSQL operator predicates

The first PostgreSQL-specific query slice exposes typed, translation-only extensions on EF.Functions. Captured values remain normal EF parameters and retain the store type inferred from the mapped column. No operator API accepts an SQL fragment or concatenates application values into generated SQL.

var requiredTags = new[] { 2, 3 };
var activeWindow = new BlueTuskRange<int>(100, 200);
var jsonFilter = """{"kind":"provider"}""";
var candidateIds = new[] { 7, 11, 42 };
var cursorId = 7;
var cursorName = "BlueTusk";

var documents = await context.Documents
    .Where(document =>
        EF.Functions.ILike(document.Name, "blue%")
        && EF.Functions.ArrayContains(document.Tags, requiredTags)
        && EF.Functions.RangeContains(document.ValidIds, activeWindow)
        && EF.Functions.JsonContains(document.Metadata, jsonFilter)
        && EF.Functions.EqualAny(document.Id, candidateIds)
        && EF.Functions.RowGreaterThan(
            ValueTuple.Create(document.Id, document.Name),
            ValueTuple.Create(cursorId, cursorName)))
    .ToListAsync(cancellationToken);

The V1 contract covers:

  • text ILIKE, case-sensitive ~/!~, and case-insensitive ~*/!~*;
  • array containment (@>, <@), overlap (&&), append/prepend, and concatenation;
  • range and multirange containment, overlap, strict left/right, non-extension, and adjacency across every range/range, range/multirange, multirange/range, and multirange/multirange form;
  • typed range and multirange union, intersection, and difference;
  • JSONB containment and key tests, JSONPath @?/@@, concatenation, key/index/path deletion, and JSONB/text extraction;
  • inet/cidr inclusive/strict containment, overlap, bitwise operations, address arithmetic, and address distance;
  • tsvector @@ tsquery matching, vector/query composition, phrase and negation, plus tsquery containment;
  • variable-bit concatenation, bitwise operations, negation, and shifts;
  • geometric equality/ordering, relative position, overlap, containment, intersection, perpendicular/parallel/horizontal/vertical tests, distance, intersection/closest-point values, point arithmetic, and path/box/circle translation/scaling;
  • typed =, <>, <, <=, >, and >= comparisons with PostgreSQL ANY(array) and ALL(array), plus LIKE/ILIKE quantifiers; and
  • two-or-more-element row comparisons using ValueTuple.Create(...) and all six PostgreSQL B-tree comparison operators.

The quantified methods are named after their SQL shape: EqualAny, NotEqualAll, LessThanAny, and the corresponding comparison/quantifier combinations. Array arguments retain one PostgreSQL array parameter instead of being interpolated or expanded into SQL literals. Row methods are RowEqual, RowNotEqual, RowLessThan, RowLessThanOrEqual, RowGreaterThan, and RowGreaterThanOrEqual. Both row constructors must have the same arity; BlueTusk rejects a mismatch during translation with a focused diagnostic.

These methods deliberately throw if evaluated as ordinary CLR methods. A query must translate completely, and SQL null behavior follows the underlying PostgreSQL operator rather than pretending to be an in-memory implementation. Operator behavior is defined by PostgreSQL’s row and array comparison, pattern, array, range/multirange, JSON, network, and text-search, bit-string, and geometric documentation. SQL-generation tests cover every exposed operator family, and live acceptance executes typed parameters against PostgreSQL 15–19.

All scalar-producing operators carry their PostgreSQL result mapping through later composition and materialisation. JSONB-returning extraction stays jsonb, while the ->> and #>> methods return text. PostgreSQL treats a point as a complex number for point multiplication and division; PointMultiply and PointDivide intentionally preserve that server behavior rather than performing coordinate-wise arithmetic.

PostgreSQL scalar functions

Scalar EF.Functions translations compose inside filters and projections, and can be nested with the operator predicates above. For example, full-text query construction and ranking remain entirely server-side:

var search = "PostgreSQL provider";

var matches = await context.Documents
    .Where(document => EF.Functions.FullTextMatches(
        EF.Functions.ToTextSearchVector(document.Body),
        EF.Functions.PlainToTextSearchQuery(search)))
    .Select(document => new
    {
        document.Id,
        Rank = EF.Functions.TextSearchRank(
            EF.Functions.ToTextSearchVector(document.Body),
            EF.Functions.PlainToTextSearchQuery(search)),
        MetadataType = EF.Functions.JsonTypeOf(document.Metadata),
        ValidFrom = EF.Functions.RangeLower(document.ValidIds),
    })
    .OrderByDescending(result => result.Rank)
    .ToListAsync(cancellationToken);

The initial scalar surface includes array length/lower/upper/cardinality; range and multirange bounds, inclusivity, infinity, and empty checks; JSONB type, array length, and first JSONPath result; regular-expression replace and count; network host/family/mask/network/broadcast; and full-text vector/query construction, lexeme/node counts, and rank. PostgreSQL’s default text-search configuration applies to the current no-configuration overloads.

JSONB methods additionally expose pretty printing, object/array null stripping, path-based set/lax-set/insert, and parameterized JSONPath variables for exists, match, first-result, and array-result functions. JSON documents, replacements, and variable objects retain jsonb mappings; silent and creation/insertion flags use native PostgreSQL Boolean literals or parameters. The strip_in_arrays overload requires PostgreSQL 18, while the one-argument form works across PostgreSQL 15–19.

Full-text overloads accept typed BlueTuskRegConfig values for explicit search configuration. Text and JSONB vector construction, internal-character weights, lexeme-selective weighting, stripping, query-tree inspection, typed rewrites, normalization and custom rank weights, cover-density rank, and text/JSONB headlines remain composable with @@. JSONB headline results keep their JSONB mapping rather than silently becoming text.

The extended array surface includes dimensions/rank, first/all positions, remove/replace/trim, string conversion, and string-to-array parsing. ArrayShuffle/ArraySample require PostgreSQL 16 and ArrayReverse requires PostgreSQL 18; the common methods execute unchanged on PostgreSQL 15–19. String translations cover character codes, bit/octet lengths, case formatting, left/right extraction, padding/trimming, MD5, identifier parsing and quoting, literal quoting, repetition, reversal, splitting, prefix tests, and character translation. Bytea values support encode/decode, byte/bit access and mutation, trimming, length/hash operations, and PostgreSQL 18+ reversal with typed binary results.

Numeric translations include cube roots, angle conversion, integral numeric division, factorial, integer/bigint/numeric GCD and LCM, numeric scale inspection/trimming, and scalar/threshold-array width_bucket. FormatValue, ParseDate, ParseNumber, ParseTimestamp, and UnixTimestamp map to the typed PostgreSQL to_char, to_date, to_number, and to_timestamp families. Format strings and all application values remain parameters.

The date/time surface includes date_part, date_trunc, date_bin, and two-argument age; date, time, timestamp, timestamp-with-time-zone, and interval constructors; and all three interval-justification functions. Timestamp-with-time-zone construction and truncation expose an explicit time- zone argument so results do not silently depend on the session setting. date_bin accepts a TimeSpan stride, which cannot represent months and therefore matches PostgreSQL’s stride restriction. Calendar-sensitive interval results use BlueTuskInterval, preserving independent months, days, and microseconds instead of flattening them into a TimeSpan:

var buckets = context.Events.Select(item => new
{
    Bin = EF.Functions.DateBin(
        TimeSpan.FromMinutes(15),
        item.RecordedAt,
        origin),
    Day = EF.Functions.DateTrunc(
        "day",
        item.RecordedAtWithTimeZone,
        "Europe/London"),
});

The geometric surface covers PostgreSQL’s complete documented function table: area, center, diagonal, diameter, height, open/closed path tests, length, point count, path open/close conversion, radius, slope, and width. Overloads retain the exact BlueTuskBox, BlueTuskPath, BlueTuskCircle, BlueTuskLineSegment, BlueTuskPolygon, and BlueTuskPoint mappings. Path area is nullable because PostgreSQL returns NULL for an open path. Generated SQL and live typed-parameter/result tests run across PostgreSQL 15–19. Other The function definitions follow PostgreSQL’s date/time, JSON, full-text search, and geometric documentation.

PostgreSQL aggregate functions

The initial aggregate surface translates grouping enumerables without losing EF’s aggregate metadata:

var summaries = await context.Events
    .GroupBy(item => item.Category)
    .Select(group => new
    {
        group.Key,
        Values = EF.Functions.ArrayAggregate(
            group.OrderBy(item => item.Position).Select(item => item.Value)),
        Labels = EF.Functions.StringAggregate(
            group.OrderBy(item => item.Position).Select(item => item.Label),
            ", "),
        AllValid = EF.Functions.BooleanAnd(group.Select(item => item.IsValid)),
        Covered = EF.Functions.RangeAggregate(
            group.Where(item => item.IsIncluded).Select(item => item.ValidRange)),
    })
    .ToListAsync(cancellationToken);

ArrayAggregate, text/bytea StringAggregate, BooleanAnd, BooleanOr, RangeAggregate, and RangeIntersectAggregate map to PostgreSQL array_agg, string_agg, bool_and, bool_or, range_agg, and range_intersect_agg; both range aggregates accept range and multirange inputs. Ordering stays inside the aggregate call, Distinct() becomes aggregate DISTINCT, and a grouping Where(...) becomes native FILTER (WHERE ...). Delimiters and filter values remain normal parameters. The APIs return nullable results because PostgreSQL returns NULL when an aggregate has no selected input rows.

JsonAggregate, JsonbAggregate, and XmlAggregate retain json, jsonb, and xml result mappings. SmallInt, Integer, BigInt, and BitString And/Or/Xor methods expose PostgreSQL’s width-preserving bitwise aggregates. StandardDeviationPopulation, StandardDeviationSample, VariancePopulation, and VarianceSample have double and decimal overloads so floating-point and PostgreSQL numeric calculations materialize without changing result families. These aggregates keep the same in-call ordering, DISTINCT, and FILTER support as the initial surface:

var summaries = context.Events
    .GroupBy(item => item.Category)
    .Select(group => new
    {
        Payloads = EF.Functions.JsonbAggregate(
            group.OrderBy(item => item.Position).Select(item => item.Payload)),
        PopulationVariance = EF.Functions.VariancePopulation(
            group.Where(item => item.IsIncluded).Select(item => item.Measurement)),
    });

JSON object aggregates consume a translated two-value tuple. Use ValueTuple.Create because C# expression trees do not support tuple literals:

var advanced = context.Events
    .GroupBy(item => item.Category)
    .Select(group => new
    {
        PayloadByLabel = EF.Functions.JsonbObjectAggregate(
            group.OrderBy(item => item.Position)
                .Select(item => ValueTuple.Create(item.Label, item.Payload))),
        Correlation = EF.Functions.Correlation(
            group.Select(item => ValueTuple.Create(item.Measurement, item.Reference))),
        Median = EF.Functions.PercentileContinuous(
            group.Select(item => item.Measurement),
            0.5),
        MostCommon = EF.Functions.Mode(group.Select(item => item.Label)),
    });

JsonObjectAggregate and JsonbObjectAggregate retain json and jsonb results and render both tuple values as native aggregate arguments. PostgreSQL 16+ strict, unique, and unique-strict JSON/JSONB variants are exposed with the same pair shape; JsonAggregateStrict, JsonbAggregateStrict, and AnyValue cover the other aggregate additions introduced in that release. These methods remain translation-compatible with all targets, but executing them on PostgreSQL 15 produces PostgreSQL’s normal undefined-function error. The same pair shape supports Correlation, population/sample covariance, and the full PostgreSQL linear-regression family: averages, count, intercept, R-squared, slope, sums of squares, and sum products. Pair order follows PostgreSQL’s (Y, X) convention.

Mode, PercentileContinuous, and PercentileDiscrete emit native ordered-set syntax with the input selector inside WITHIN GROUP (ORDER BY ...); filters remain native FILTER clauses and percentile fractions remain parameters. Scalar and array-valued fraction overloads preserve scalar and array result mappings. HypotheticalRank, HypotheticalDenseRank, HypotheticalPercentRank, and HypotheticalCumulativeDistribution use the same machinery, placing the hypothetical value in the direct-argument list and the grouped selector in the ordered set.

Ordered-set Distinct() input is rejected with a focused diagnostic because PostgreSQL does not accept that combination. Mode and discrete percentiles are generic over mapped ordered types; continuous percentiles cover both double precision and interval scalar/array results. Generated SQL and typed live tests cover the version-independent families across PostgreSQL 15–19 and the PostgreSQL 16 additions across PostgreSQL 16–19. Standard LINQ supplies avg, count, min, max, and sum; BlueTusk’s APIs cover the remaining documented built-in aggregate families without client-side emulation.

Array expansion and lateral queries

Mapped PostgreSQL array properties can be queried as ordinary primitive collections. BlueTusk translates collection filters, projections, Any, and correlated SelectMany through unnest(...) WITH ORDINALITY. Correlated inner and outer collection selectors use PostgreSQL JOIN LATERAL and LEFT JOIN LATERAL; no SQL Server APPLY syntax leaks into generated SQL:

var minimum = 10;

var expanded = await context.Documents
    .SelectMany(
        document => document.Scores.Where(score => score >= minimum),
        (document, score) => new { document.Id, Score = score })
    .OrderBy(result => result.Id)
    .ThenBy(result => result.Score)
    .ToListAsync(cancellationToken);

The array value and filter inputs keep their relational type mappings and normal parameterization. Explicit output-column names survive EF alias uniquification, ordinality preserves PostgreSQL array order, and nullable array elements materialize without being collapsed. For an outer expansion over a non-nullable value-type array, project the element to its nullable form before DefaultIfEmpty() so the absent row remains distinguishable from the CLR default value. This V1 contract covers mapped array columns only.

Series are also available as typed, composable query roots. Use Database.GenerateSeries for a standalone series; int, long, and decimal map to PostgreSQL integer, bigint, and numeric, while DateTime and DateTimeOffset map to timestamp and timestamp with time zone. Numeric steps default to one; temporal roots require a TimeSpan interval:

var values = await context.Database
    .GenerateSeries(2, 10, 2)
    .Where(value => value >= minimum)
    .OrderBy(value => value)
    .ToListAsync(cancellationToken);

Use EF.Functions.GenerateSeries inside a translated query when a bound must refer to an outer row. BlueTusk represents the function as a query root before EF navigation expansion and emits a parameterized PostgreSQL lateral join:

var expanded = await context.Documents
    .SelectMany(
        document => EF.Functions.GenerateSeries(1, document.PageCount),
        (document, page) => new { document.Id, Page = page })
    .ToListAsync(cancellationToken);

The numeric two-argument and explicit-step forms and the explicit-step temporal forms participate in compiled queries. Database.GenerateSeries rejects a zero step before execution; PostgreSQL retains its native empty-series and direction semantics. The translation-only EF.Functions form must not be called outside an EF query. The PostgreSQL 16+ timezone-name overload is not exposed yet so the same API executes across the PostgreSQL 15–19 support matrix.

GenerateSubscripts expands the valid indexes of a mapped PostgreSQL array. Its dimension is an ordinary typed argument, and the overload with reverse requests descending index order from PostgreSQL:

var positions = await context.Documents
    .SelectMany(
        document => EF.Functions.GenerateSubscripts(
            document.Scores,
            dimension: 1,
            reverse: true),
        (document, position) => new { document.Id, Position = position })
    .ToListAsync(cancellationToken);

Regex and delimiter expansion use the same typed query-root machinery. RegexMatches returns one string[] per match (and supports PostgreSQL flags), RegexSplitToTable returns text segments, and StringToTable supports an optional null marker whose rows materialize as nullable strings. Inputs remain parameters and correlated calls become lateral joins:

var captures = context.Documents.SelectMany(
    document => EF.Functions.RegexMatches(document.Title, "([A-Z]+)", "g"),
    (document, match) => new { document.Id, Match = match });

var fields = context.Documents.SelectMany(
    document => EF.Functions.StringToTable(document.Csv, ",", "NULL"),
    (document, field) => new { document.Id, Field = field });

BlueTusk also exposes four single-column JSONB roots: JsonArrayElements, JsonArrayElementsText, JsonObjectKeys, and JsonPathQuery. JSON-valued results remain mapped as jsonb; text elements materialize as nullable strings so a JSON null is not replaced with an empty value. JSON and JSONPath parameters receive exact store-type mappings, while mapped properties should be configured as jsonb:

modelBuilder.Entity<Document>()
    .Property(document => document.Payload)
    .HasColumnType("jsonb");

var elements = await context.Documents
    .SelectMany(
        document => EF.Functions.JsonArrayElementsText(document.Payload),
        (document, element) => new { document.Id, Element = element })
    .ToListAsync(cancellationToken);

JsonEach and JsonEachText expand JSON objects to typed KeyValuePair<string, string> and KeyValuePair<string, string?> rows. The JSONB form preserves each value as JSON text with a jsonb result mapping; the text form uses nullable values so JSON null materializes as null:

var properties = await context.Documents
    .SelectMany(
        document => EF.Functions.JsonEachText(document.Payload),
        (document, property) => new
        {
            document.Id,
            property.Key,
            property.Value,
        })
    .ToListAsync(cancellationToken);

These roots emit WITH ORDINALITY internally so duplicate values and JSON-null elements have stable row identity and source order. Correlated roots use lateral joins, and captured JSON/JSONPath values remain parameters. The four-argument JsonPathQuery overload accepts a JSONB variables object and PostgreSQL’s silent flag with exact jsonb and Boolean mappings.

JsonToRecordset<T> expands a JSONB array of objects into an application row shape. T must be registered as a flat keyless entity. Its configured column names become the JSON field names and its relational store types become the PostgreSQL column-definition list required by jsonb_to_recordset:

modelBuilder.Entity<PayloadRow>(row =>
{
    row.HasNoKey();
    row.Property(item => item.Id)
        .HasColumnName("id")
        .HasColumnType("integer");
    row.Property(item => item.Label)
        .HasColumnName("label")
        .HasColumnType("text");
});

var payloadRows = await context.Documents
    .SelectMany(
        document => EF.Functions.JsonToRecordset<PayloadRow>(document.Payload),
        (document, row) => new { document.Id, row.Label })
    .ToListAsync(cancellationToken);

BlueTusk quotes every model-derived output name and emits each mapped store type; the JSON input remains a jsonb parameter or mapped column. PostgreSQL converts JSON fields according to those declared types and returns NULL for missing fields. Configure nullable CLR properties wherever the payload may contain JSON null or omit a field. Recordset rows are keyless and untracked, and callers should use an explicit OrderBy when result order matters. Inheritance, navigations, and complex properties are rejected with a focused diagnostic so the generated record contract remains flat and explicit. Correlated and compiled queries are covered across PostgreSQL 15–19; no query-time column name, store type, or SQL fragment is accepted.

The convenience multi-argument unnest API pairs an integer[] with a nullable text[] and returns KeyValuePair<int?, string?> rows. Both outputs are nullable because PostgreSQL pads the shorter input with NULL:

var pairs = await context.Documents
    .SelectMany(
        document => EF.Functions.Unnest(document.Scores, document.Labels),
        (document, pair) => new
        {
            document.Id,
            Score = pair.Key,
            Label = pair.Value,
        })
    .ToListAsync(cancellationToken);

Column-correlated inputs use a lateral join; captured arrays retain their exact array mappings and work in compiled queries. Generic overloads accept two, three, or four arrays and return BlueTuskUnnestPair, BlueTuskUnnestTriple, or BlueTuskUnnestQuadruple rows:

long?[] numbers = [10, null];
Guid?[] identifiers = [orderId];
bool?[] flags = [true, false, null];

var rows = await context.Documents
    .SelectMany(
        _ => EF.Functions.Unnest(numbers, identifiers, flags),
        (_, row) => new { row.First, row.Second, row.Third })
    .ToListAsync(cancellationToken);

Use nullable element types for value-type arrays passed to the generic overloads, because every output can be NULL when another input is longer. Reference-type elements follow their normal nullable annotations. The provider preserves each array’s own PostgreSQL mapping, emits WITH ORDINALITY for deterministic source order, and rejects an unmapped element family with a focused translation error. Two- through four-array translation, null padding, typed materialisation, and compiled execution are live-tested across PostgreSQL 15–19.

Application-defined table functions use EF Core’s model metadata instead of a runtime string-based SQL API. Define a context method with FromExpression, map its row as a keyless entity, and register the method’s PostgreSQL name and schema with HasDbFunction:

public IQueryable<SearchResult> SearchDocuments(int minimumRank)
    => FromExpression(() => SearchDocuments(minimumRank));

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<SearchResult>().HasNoKey();
    modelBuilder
        .HasDbFunction(typeof(AppDbContext).GetMethod(
            nameof(SearchDocuments), [typeof(int)])!)
        .HasName("search_documents")
        .HasSchema("application");
}

The function name and schema are fixed model metadata and are identifier-quoted; function arguments remain normal EF parameters. The returned keyless row can be filtered, ordered, projected, and used in compiled queries. A call whose argument refers to an outer row becomes a PostgreSQL JOIN LATERAL, so model- registered functions compose in correlated SelectMany queries as well. SQL- generation and live PostgreSQL 15–19 tests cover schema qualification, parameterization, typed materialization, correlation, and compiled execution. BlueTusk does not expose an API that accepts an application-provided function name or SQL fragment at query time.

PostgreSQL query constructs

DistinctOn emits PostgreSQL’s native DISTINCT ON (...). Order the query with the distinct key first, add any tie-breakers, apply the final server projection, and then apply DistinctOn before materialization. BlueTusk validates the leftmost ORDER BY expression so an invalid query fails during translation:

var latestPerTenant = await context.Events
    .OrderBy(item => item.TenantId)
    .ThenByDescending(item => item.RecordedAt)
    .Select(item => new { item.Id, item.TenantId, item.RecordedAt })
    .DistinctOn(item => item.TenantId)
    .ToListAsync(cancellationToken);

Mapped table roots support typed TableSampleSystem and TableSampleBernoulli operations. Percentages are validated in the inclusive 0–100 range and remain parameters; the second overload adds PostgreSQL’s REPEATABLE seed. Sampling is rejected for a composed join because PostgreSQL attaches TABLESAMPLE to one concrete table source:

var sample = await context.Events
    .TableSampleBernoulli(percentage: 5, repeatable: 42)
    .Where(item => item.IsActive)
    .ToListAsync(cancellationToken);

Translated LINQ queries can be exposed through a named PostgreSQL CTE with AsCte, AsMaterializedCte, or AsNotMaterializedCte. The latter two emit PostgreSQL’s explicit CTE planning controls; the default form leaves the choice to the server. The name is identifier-delimited, must fit PostgreSQL’s 63-byte identifier limit, and is never interpreted as SQL. Values and predicates inside the CTE remain normal EF parameters:

var ranked = await context.Events
    .Where(item => item.Score >= minimumScore)
    .OrderBy(item => item.Id)
    .Select(item => new { item.Id, item.Score })
    .AsMaterializedCte("ranked_events")
    .ToListAsync(cancellationToken);

The operation wraps the complete translated query and remains compatible with compiled queries. For an ordered CTE, every ordering expression must be present in the projection; BlueTusk then reapplies the order by output position outside the CTE so enumeration order is retained. Applying more than one CTE wrapper to the same query fails with a focused diagnostic. Default, materialized, and non-materialized SQL generation plus compiled execution are covered across PostgreSQL 15–19.

Self-referencing mapped tables also expose typed recursive traversal through RecursiveDescendants. Apply it directly to a DbSet, identify the non-nullable key and nullable parent key with ValueTuple.Create, and pass one or more root keys. BlueTusk emits a recursive CTE whose seed uses = ANY (...) with an array parameter, then joins mapped child and parent columns without accepting a table, column, or SQL string:

var branch = await context.Categories
    .RecursiveDescendants(
        category => ValueTuple.Create(category.Id, category.ParentId),
        rootCategoryIds)
    .Where(category => category.IsVisible)
    .OrderBy(category => category.Id)
    .ToListAsync(cancellationToken);

The default BlueTuskRecursiveUnionBehavior.Distinct emits UNION, so a cycle that revisits an identical mapped row terminates instead of expanding forever. Use All only for a hierarchy known to be acyclic when retaining duplicate paths is intentional. Filters, projections, joins, and ordering compose after the recursive root. To keep the recursive table definition exact and avoid bypassing model filters during traversal, the root entity must use one table, must not participate in inheritance, and must not define a global query filter. The key pair must name direct mapped properties with the same PostgreSQL store type. Multi-root parameterization, compiled queries, both union modes, and cycle termination execute across PostgreSQL 15–19.

PostgreSQL data-modification queries can materialize the rows they changed with DeleteReturning and UpdateReturning. These are deferred query operations: enumerating the result executes the modification, so enumerate exactly once. They do not use SaveChanges or synchronize tracked instances, and BlueTusk forces their returned entity shape to be no-tracking.

var updated = await context.Documents
    .Where(document => document.Category == category)
    .UpdateReturning(setters => setters
        .SetProperty(document => document.Score, document => document.Score + increment)
        .SetProperty(document => document.Status, document => "reviewed"))
    .Select(document => new { document.Id, document.Score, document.Status })
    .ToListAsync(cancellationToken);

var deleted = await context.Documents
    .Where(document => document.ExpiresAt < cutoff)
    .DeleteReturning()
    .Select(document => new { document.Id, document.ExpiresAt })
    .ToListAsync(cancellationToken);

The source must resolve to one mapped table. Predicates and a returned projection are supported; ordering, paging, distinct, grouping, table sampling, row locking, joins, and CTE composition are rejected with a focused diagnostic. Setter values remain normal translated expressions and parameters. For compiled updates, use the single-property overload and put AsNoTracking in the compiled expression explicitly:

var incrementScore = EF.CompileQuery(
    (AppDbContext database, int id, int increment) => database.Documents
        .AsNoTracking()
        .Where(document => document.Id == id)
        .UpdateReturning(
            document => document.Score,
            document => document.Score + increment)
        .Select(document => new { document.Id, document.Score }));

Compiled deletes likewise require an explicit AsNoTracking. Multi-setter updates use the builder overload outside compiled-query expressions. SQL generation, async materialization, multi-setter updates, compiled single-setter updates/deletes, and no-tracking behavior execute across PostgreSQL 15–19.

Single-row inserts and upserts use the same returned-query model. Supply a mapped entity object initializer, select one or more conflict properties, and choose either DO NOTHING or the mapped properties that PostgreSQL should copy from its EXCLUDED row:

var inserted = await context.Documents
    .InsertOnConflictDoNothingReturning(
        () => new Document { Id = id, Status = status, Score = score },
        document => document.Id)
    .SingleOrDefaultAsync(cancellationToken);

var upserted = await context.Documents
    .InsertOnConflictUpdateReturning(
        () => new Document { Id = id, Status = status, Score = score },
        document => document.Id,
        document => new { document.Status, document.Score })
    .Select(document => new { document.Id, document.Status, document.Score })
    .SingleAsync(cancellationToken);

DO NOTHING returns no row when a conflict is ignored. The update overload emits DO UPDATE SET ... = EXCLUDED... in selector order and returns the inserted or updated row. Object-initializer values use their mapped PostgreSQL types and remain parameters; conflict and update selectors accept one direct property or an anonymous/tuple selection of distinct direct properties. The target must be a DbSet for one non-inherited table without a global query filter. This initial surface intentionally excludes multi-row inserts, constraint-name and partial-index inference, and arbitrary conflict-update expressions.

As with the other modification-returning APIs, execution is deferred, enumeration must happen once, and returned entities are no-tracking. Compiled queries must put AsNoTracking before the insert operation. Parameterized DO NOTHING, EXCLUDED updates, returned projections, compiled upserts, and no-tracking materialization execute across PostgreSQL 15–19.

For PostgreSQL’s native MERGE, BlueTusk exposes immediate synchronous and asynchronous operations on DbContext. The main overload updates selected source properties when the match succeeds and inserts every initialized property when it does not:

var affected = await context.ExecuteMergeAsync(
    () => new Document { Id = id, Status = status, Score = score },
    document => document.Id,
    document => new { document.Status, document.Score },
    cancellationToken);

ExecuteMergeDeleteAsync selects WHEN MATCHED THEN DELETE, while ExecuteMergeDoNothingAsync selects WHEN MATCHED THEN DO NOTHING; both still insert the initialized source row when it does not match. Genuine synchronous counterparts are available for all three forms. Every operation returns PostgreSQL’s affected-row count and executes through EF’s relational command pipeline, so the current transaction, command interceptors, diagnostics, timeouts, and cancellation behavior are retained.

The source is currently one mapped row. Values must be a non-empty entity object initializer, and match/update selectors must name distinct direct properties that were initialized in that row. Table and column identifiers are derived from EF metadata and centrally quoted; values use each column’s exact relational type mapping and remain parameters. Entities using inheritance, conditional WHEN clauses, arbitrary update expressions, and multi-row source queries are intentionally outside this initial typed surface. As with EF bulk updates, the change tracker is not synchronized.

PostgreSQL 15 and 16 do not permit RETURNING on MERGE, so BlueTusk’s cross-version API deliberately reports the affected count rather than rows. Use a separate query when the resulting row is needed, or use the returned-row insert/update/delete APIs above. Update, insert, delete, and do-nothing paths execute across PostgreSQL 15–19.

Row-locking extensions cover ForUpdate, ForNoKeyUpdate, ForShare, and ForKeyShare. Each accepts Wait, NoWait, or SkipLocked behavior. Apply the locking operation after the final server projection and enumerate it inside an explicit transaction so the locks have a useful lifetime:

await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken);

var claimedIds = await context.Jobs
    .Where(job => job.State == JobState.Pending)
    .OrderBy(job => job.Id)
    .Take(20)
    .Select(job => job.Id)
    .ForUpdate(BlueTuskRowLockingBehavior.SkipLocked)
    .ToListAsync(cancellationToken);

Typed window methods project ranking/distribution functions (row_number, rank, dense_rank, percent_rank, and cume_dist), ntile, lag/lead, and first_value/last_value/nth_value. Every method accepts an order value; the overloads with one additional value use it as PARTITION BY. WindowDescending(value) marks descending window order without turning an identifier or expression into a string:

var ranked = await context.Events
    .OrderBy(item => item.Id)
    .Select(item => new
    {
        item.Id,
        Row = EF.Functions.WindowRowNumber(
            item.TenantId,
            EF.Functions.WindowDescending(item.RecordedAt)),
        Previous = EF.Functions.WindowLag(
            item.RecordedAt,
            1,
            DateTime.UnixEpoch,
            item.TenantId,
            item.RecordedAt),
    })
    .ToListAsync(cancellationToken);

Pass a nullable value to WindowNthValue (and to other value functions when needed) because PostgreSQL can return NULL before the requested row enters the current window frame. Window methods are translation-only and throw if invoked as ordinary CLR functions. SQL generation, typed materialization, compiled queries, repeatable sampling, and concurrent SKIP LOCKED behavior execute against PostgreSQL 15–19.

PostgreSQL’s tableoid, xmin, cmin, xmax, cmax, and ctid system columns are available through explicit shadow-property mappings. Opting in is deliberate: it adds the system values to normal entity materialization, but the migration differ always excludes them from CREATE TABLE and column lifecycle DDL because PostgreSQL owns them:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    var document = modelBuilder.Entity<Document>();
    document.UseSystemColumns();
    document.UseXminConcurrencyToken();
}

var physicalRows = await context.Documents
    .Select(document => new
    {
        document.Id,
        TableOid = EF.Property<uint>(document, BlueTuskSystemColumns.TableOid),
        Version = EF.Property<BlueTuskTransactionId>(
            document,
            BlueTuskSystemColumns.Xmin),
        Tuple = EF.Property<BlueTuskTupleId>(document, BlueTuskSystemColumns.Ctid),
    })
    .ToListAsync(cancellationToken);

tableoid maps to uint; xmin/xmax map to BlueTuskTransactionId; cmin/cmax map to BlueTuskCommandId; and ctid maps to BlueTuskTupleId. UseSystemColumn enables one selected column when the full set is unnecessary. UseXminConcurrencyToken configures xmin as a store-generated concurrency token, so EF includes the original transaction ID in updates and reports a normal DbUpdateConcurrencyException for a stale tracked entity. Querying, migration exclusion, native materialization, generated-value refresh, and stale-update detection are live- tested across PostgreSQL 15–19.

Migrations

Database.GenerateCreateScript() and IRelationalDatabaseCreator.CreateTables() generate PostgreSQL DDL for ordinary relational models. The supported create-schema surface includes tables, primary and foreign keys, indexes, defaults, length and precision facets, and GENERATED BY DEFAULT AS IDENTITY integer keys. This path is covered both by SQL-shape tests and by executing the generated commands against PostgreSQL.

Database.EnsureCreated() and EnsureDeleted() also manage the physical PostgreSQL database through an unpooled maintenance connection. Before a drop, BlueTusk closes the EF connection and drains the configured data-source pool; PostgreSQL’s DROP DATABASE ... WITH (FORCE) removes other sessions. After a create, the target connection reloads its type catalogue before EF creates the model tables. This keeps runtime UDT metadata correct when a database is deleted and recreated through the same data source or connection.

The maintenance connection uses postgres by default, or template1 when postgres is itself the target. Configure another existing database when the deployment role cannot connect to that default:

options.UseBlueTusk(
    dataSource,
    provider => provider.UseAdminDatabase("maintenance"));

The target and maintenance database must differ. Database names are centrally quoted, credentials and transport settings are retained, pooling is disabled, and multi-host maintenance connections require a read-write server. The full synchronous and asynchronous lifecycle executes across PostgreSQL 15–19.

Runtime migrations support the PostgreSQL __EFMigrationsHistory repository, transaction-scoped migration locking, up/down application, and idempotent scripts. MigrationsHistoryTable(name, schema) is supported with PostgreSQL identifier delimiting throughout existence checks, locking, creation, conditional guards, inserts, and deletes. The initial DDL surface covers tables, columns, keys and constraints, indexes, sequences, defaults, comments, schema moves, and alter/rename/drop operations. Acceptance tests apply an idempotent script twice, re-enter Database.MigrateAsync(), move back to an earlier migration, finally revert to the empty database, and round-trip a custom history schema/table whose identifiers require quoting.

Identity columns, generated columns, and comments

Integer primary keys configured as ValueGenerated.OnAdd continue to use GENERATED BY DEFAULT AS IDENTITY. Use the provider API when the generation mode is part of the model contract:

modelBuilder.Entity<Order>()
    .Property(order => order.Id)
    .UseIdentityColumn(BlueTuskIdentityGeneration.Always);

Always rejects normal explicit values unless SQL uses PostgreSQL’s OVERRIDING SYSTEM VALUE; ByDefault accepts them. Migrations can add, remove, or switch an identity mode in place. Database-first scaffolding preserves the catalogue’s exact ALWAYS or BY DEFAULT mode and regenerates the provider fluent call when an explicit mode is present.

EF’s HasComputedColumnSql(expression, stored: true) creates a PostgreSQL stored generated column on every supported server. Passing stored: false creates a virtual generated column and is guarded at execution time because it requires PostgreSQL 18 or later. Changing a stored expression in place uses ALTER COLUMN ... SET EXPRESSION and requires PostgreSQL 17 or later. PostgreSQL cannot safely convert an ordinary column to generated, switch stored and virtual modes, or combine a generated expression with a type/collation change in one in-place operation; BlueTusk reports those cases so the migration can stage an explicit data-preserving replacement. Reverse engineering retains the server-normalized expression and the stored/virtual mode.

Table comments configured through ToTable("orders", table => table.HasComment(...)) and column comments configured with Property(...).HasComment(...) are emitted after table creation, altered with COMMENT ON, cleared with IS NULL, and retained by database-first scaffolding. Identity, generated-column, and comment lifecycle tests execute across PostgreSQL 15–19, including the version guards.

PostgreSQL table CHECK constraints

EF’s standard table CHECK metadata generates PostgreSQL constraints. BlueTusk’s builder extensions retain the PostgreSQL-specific validation and inheritance options:

modelBuilder.Entity<Measurement>().ToTable(
    "measurements",
    table =>
    {
        table.HasCheckConstraint("measurements_bounded", "\"value\" < 100")
            .IsNoInherit();
        table.HasCheckConstraint("measurements_positive", "\"value\" > 0")
            .IsNotValid();
        table.HasCheckConstraint("measurements_legacy_limit", "\"value\" < 50")
            .IsNotEnforced(); // PostgreSQL 18+
    });

Validated constraints are emitted inline during CREATE TABLE. PostgreSQL does not accept NOT VALID in that inline form, so an initially unvalidated constraint is added immediately afterward with ALTER TABLE. It still rejects new or changed rows that violate the expression; it only defers scanning rows that already exist. Changing an otherwise identical model constraint from NOT VALID to validated emits ALTER TABLE ... VALIDATE CONSTRAINT without a drop. Changing in the other direction or changing NO INHERIT requires a destructive drop/add pair because PostgreSQL has no in-place inverse operation. PostgreSQL 18 added NOT ENFORCED; BlueTusk capability-guards that form and also uses a destructive drop/add when enforceability changes because PostgreSQL does not allow a table CHECK constraint’s enforcement state to be altered in place. A NOT ENFORCED constraint is unvalidated and cannot be validated until it has been replaced with an enforced constraint.

Manual migrations can use AddCheckConstraint for the PostgreSQL options and ValidateCheckConstraint for a staged rollout. CHECK SQL is trusted model-time SQL and must never be populated from request data or other untrusted input. Database-first discovery reads the canonical expression, validation state, inheritance flag, and PostgreSQL 18+ enforcement state from pg_constraint, excludes extension-owned constraints plus inherited and partition clones, and regenerates the same EF and BlueTusk fluent metadata. The create, enforce, failed/successful validation, reverse-engineering, and scaffolding paths execute across PostgreSQL 15–19.

Advanced PostgreSQL indexes

Advanced index metadata composes with EF’s standard IsUnique, IsDescending, HasFilter, and HasDatabaseName configuration:

modelBuilder.Entity<Document>()
    .HasIndex(document => new { document.Title, document.CreatedAt })
    .HasDatabaseName("ix_documents_title_created")
    .IsUnique()
    .IsDescending(false, true)
    .HasFilter("\"title\" IS NOT NULL")
    .UseIndexMethod("btree")
    .UseOperatorClass("text_pattern_ops", null)
    .UseCollation("C", null)
    .HasNullSortOrder(
        BlueTuskIndexNullSortOrder.NullsFirst,
        BlueTuskIndexNullSortOrder.NullsLast)
    .IncludeProperties(document => new
    {
        document.SearchVector,
        document.Summary,
    })
    .HasFillFactor(80)
    .HasNullsDistinct(false)
    .IsConcurrent();

UseIndexMethod accepts built-in B-tree, hash, GiST, SP-GiST, GIN, and BRIN methods as well as extension-provided access methods. Operator classes and collations are configured per leading key and may be schema-qualified. Included properties are resolved through EF’s table/column mapping, while storage-parameter names and values are restricted to safe PostgreSQL tokens. NULLS NOT DISTINCT requires PostgreSQL 15 or newer, which is BlueTusk’s current minimum supported server.

Trusted expression indexes can replace selected mapped keys with HasIndexExpressions; an empty entry retains the mapped column. These expressions become migration DDL verbatim and must be fixed application model metadata, never request data or user input. Partial indexes continue to use EF’s HasFilter API.

Database-first expression indexes cannot be represented safely as ordinary EF indexes because EF requires every key to be a distinct mapped property. BlueTusk therefore retains pure and mixed expression indexes as provider-owned table metadata without inventing placeholder properties:

modelBuilder.Entity<Document>().HasExpressionIndex(
    "documents_search",
    index => index
        .HasKeySql(
            "(lower(\"title\")) COLLATE \"C\" text_pattern_ops",
            "\"created_at\" DESC NULLS LAST")
        .UseMethod("btree")
        .IncludeColumns("active")
        .IsUnique()
        .HasNullsDistinct(false)
        .HasStorageParameter("fillfactor", "80")
        .HasFilter("\"active\""));

HasKeySql values and partial predicates are trusted model-time SQL. Other identities are quoted, and storage settings are validated. Provider-owned indexes participate in create, rename, destructive replacement, and drop diffing; concurrent create/drop commands suppress migration transactions. Database-first discovery uses pg_get_indexdef plus the index catalogues to retain every ordered key expression, collation, operator class and parameters, sort/null ordering, included column, null-distinctness setting, storage parameter, predicate, and non-default tablespace. The resulting definition is replay-tested across PostgreSQL 15–19. CONCURRENTLY is a creation procedure, not stored index state, so a reverse-engineered index does not infer it.

Concurrent create and drop commands are emitted with CONCURRENTLY and marked as transaction-suppressed EF migration commands. PostgreSQL does not allow those commands inside a transaction. Idempotent generation therefore fails fast with a descriptive NotSupportedException when any generated command is transaction-suppressed; this prevents deployment tooling from receiving a script whose conditional DO block PostgreSQL cannot execute. Generate a normal migration script or use transactional, non-concurrent DDL for that migration. The same rule applies to other transaction-suppressed PostgreSQL operations, including concurrent partition detach, subscription lifecycle commands that manage slots, and cluster-wide tablespace lifecycle commands.

PostgreSQL exclusion constraints

Exclusion constraints use provider-owned entity metadata because EF has no relational constraint abstraction for PostgreSQL’s EXCLUDE form. Column elements use typed property selectors and resolve through EF’s table mapping:

modelBuilder.Entity<Reservation>()
    .HasExclusionConstraint(
        "reservations_no_overlap",
        constraint => constraint
            .UseIndexMethod("gist")
            .HasProperty(reservation => reservation.During, "&&")
            .IncludeProperties(nameof(Reservation.Note))
            .HasStorageParameter("fillfactor", "80")
            .HasFilter("active")
            .IsDeferrable());

Each element can instead be a fixed trusted SQL expression and can configure a schema-qualified operator, collation, operator class and operator-class parameters, descending order, and explicit null ordering. Constraints also support included mapped columns, validated index storage parameters, an index tablespace, a trusted partial predicate, and immediate or initially deferred deferrability. Operator tokens and storage settings are validated separately from identifier-quoted names. Expression and predicate SQL are deliberate model-time escape hatches and must never contain request data or other untrusted input.

Migration diffing adds constraints after their tables and drops them before dependent relational changes. Equal definitions can be renamed without an index rebuild; all other definition changes produce an explicit destructive drop/add pair. Drops use RESTRICT. PostgreSQL does not support exclusion constraints on partitioned roots, so BlueTusk reports a model diagnostic and requires constraints to be configured on concrete leaf partitions.

Database-first discovery joins pg_constraint to the backing index and retains the access method, ordered canonical index expressions, exact operators, included columns, storage settings, tablespace, partial predicate, and deferrability. Canonical expressions that cannot safely be mapped back to one EF property are retained as trusted preformatted model metadata. The complete enforcement, discovery, generated-C#, rename, and drop lifecycle is exercised against PostgreSQL 15–19.

PostgreSQL table and view triggers

Entity relations can own typed PostgreSQL triggers. Update-column selectors are resolved through EF’s column mapping, while function, relation, transition-table, trigger, and extension names are identifier-quoted independently:

modelBuilder.Entity<Document>()
    .HasTrigger(
        "normalize_note",
        trigger => trigger
            .UseTiming(BlueTuskTriggerTiming.Before)
            .OnInsert()
            .OnUpdate(document => document.Note)
            .ForEachRow()
            .When("NEW.note IS NOT NULL")
            .ExecuteFunction(
                "normalize_document_note",
                "application",
                "fixed argument")
            .HasEnabledMode(BlueTuskTriggerEnabledMode.Always));

The metadata covers BEFORE, AFTER, and INSTEAD OF; INSERT, column-specific UPDATE, DELETE, and TRUNCATE combinations; row or statement orientation; OLD and NEW transition tables; schema-qualified trigger functions; fixed string arguments; and a trusted WHEN expression. Constraint triggers can identify a referenced table and configure immediate or deferred execution. Origin, disabled, replica-only, and always-enabled firing modes are migrated through ALTER TABLE, and a trigger can declare DEPENDS ON EXTENSION.

BlueTusk validates PostgreSQL’s incompatible combinations before SQL generation: TRUNCATE must be statement-level, INSTEAD OF must be a non-constraint row trigger without WHEN, constraint triggers must be AFTER ROW, and transition tables require one compatible non-constraint AFTER event without UPDATE OF. Function arguments are always emitted as string literals. When is a deliberate trusted model-time SQL boundary and must not contain request data.

Trigger creation follows its function and target table or view; removal precedes dependent routine and relational changes. Equal bodies can be renamed and can change firing mode without recreation. Other body changes use an explicit destructive drop/create pair, drops use RESTRICT, and PostgreSQL’s unsupported OR REPLACE form for constraint triggers is rejected.

Database-first discovery excludes internal partition clones and extension-owned triggers, retains the stable non-pretty pg_get_triggerdef reconstruction, firing mode, and automatic extension dependency, and generates provider fluent metadata. Canonical catalogue DDL preserves expressions and combinations that cannot safely be reverse-mapped to typed property selectors.

PostgreSQL event triggers

Database-wide event triggers are modeled separately from relation triggers and refer to a provider-owned or pre-existing no-argument function returning event_trigger:

modelBuilder.HasFunction(
    "capture_ddl",
    "event_trigger",
    "BEGIN INSERT INTO application.ddl_log(tag) VALUES (TG_TAG); END",
    function => function.UseLanguage("plpgsql"),
    schema: "application");

modelBuilder.HasEventTrigger(
    "capture_table_creation",
    BlueTuskEventTriggerEvent.DdlCommandEnd,
    "capture_ddl",
    trigger => trigger
        .HasTags("CREATE TABLE")
        .HasEnabledMode(BlueTuskEventTriggerEnabledMode.Origin),
    functionSchema: "application");

The typed events are ddl_command_start, ddl_command_end, sql_drop, table_rewrite, and PostgreSQL 17’s login. DDL events can filter on one or more exact command tags. Origin/local, disabled, replica-only, and always firing modes use ALTER EVENT TRIGGER. A name-only change uses PostgreSQL’s rename; an event, function, or tag change is a destructive RESTRICT drop/create.

Event triggers are superuser-managed and can block all DDL or, for login, make a database inaccessible. BlueTusk therefore removes provider-owned event triggers before other migration DDL and creates them only after the rest of the migration has completed. Login creation has an execution-time PostgreSQL 17 guard and rejects command-tag filters. Review event-trigger functions as security- and availability-sensitive deployment code; owners and privileges remain explicit operations.

Database-first discovery reads pg_event_trigger, preserving the exact event, schema-qualified function, command tags, and firing mode while excluding extension-owned definitions. Because event-trigger names are database-global, a schema filter selects them through their function schema. Generated contexts retain the definitions for later migration diffs.

PostgreSQL rewrite rules

Tables and views can own PostgreSQL rewrite rules with typed events, replacement behavior, and firing modes. The rule action and optional condition are fixed, trusted model-time SQL; names and the target relation are quoted centrally:

modelBuilder.Entity<Document>()
    .HasRule(
        "audit_insert",
        BlueTuskRuleEvent.Insert,
        "INSERT INTO application.document_audit(document_id, note) " +
        "VALUES (NEW.id, NEW.note)",
        conditionSql: "NEW.note IS NOT NULL",
        enabledMode: BlueTuskRuleEnabledMode.Always);

Rules default to DO ALSO; set instead: true for DO INSTEAD. ActionSql accepts one command, NOTHING, or PostgreSQL’s parenthesized command-list form. Neither ActionSql nor conditionSql may contain request data or other untrusted input. Origin, disabled, replica-only, and always-enabled behavior is migrated through ALTER TABLE.

Body changes use CREATE OR REPLACE RULE, while name-only and firing-mode-only changes use ALTER RULE and ALTER TABLE without recreation. Drops are marked destructive, retain PostgreSQL’s default RESTRICT, and run before dependent relation or routine changes; creation runs after the relation graph exists. PostgreSQL permits SELECT rules only as unconditional INSTEAD rules named _RETURN, which BlueTusk validates. Ordinary and materialised view metadata already owns PostgreSQL’s generated _RETURN rule, so database-first discovery excludes it to avoid duplicating a view as provider rule metadata.

Reverse engineering retains stable, non-pretty pg_get_ruledef DDL and the catalogued firing mode, excludes extension-owned rules, and regenerates fluent model metadata.

Logical-replication publications

Publications are database-level model objects with typed table and schema membership, per-table column lists and row filters, published DML operations, and partition-root behavior:

modelBuilder.HasPublication(
    "document_changes",
    publication => publication
        .ForTable(
            "documents",
            "application",
            table => table
                .HasColumns("id", "tenant_id", "note")
                .HasRowFilter("tenant_id > 0"))
        .Publishes(
            BlueTuskPublicationOperations.Insert |
            BlueTuskPublicationOperations.Update)
        .PublishViaPartitionRoot());

Explicit table membership emits ONLY by default, preventing a later direct inheritance child from silently entering the publication. Call IncludeDescendants on that table when inherited descendants are intentional. PostgreSQL does not retain the original ONLY token in publication catalogues; database-first models therefore reconstruct the exact current table set with ONLY, not an unknowable future-inheritance intent. ForTablesInSchema and ForAllTables include future eligible tables and require the PostgreSQL privileges documented for those broad forms. Publications accept only persistent base and partitioned tables, not views, materialised views, foreign tables, temporary tables, or unlogged tables.

Column-list identifiers are validated and quoted centrally. HasRowFilter is a deliberate trusted model-time SQL boundary and must never receive request data. PostgreSQL additionally requires the relevant replica-identity columns for published UPDATE/DELETE column lists and filters. Schema membership cannot be combined with a table column list.

PublishGeneratedColumns maps to PostgreSQL 18’s stored-generated-column option. PostgreSQL 19 adds ForAllSequences and ExceptTable for all-table publications. BlueTusk wraps those newer forms in execution-time version guards, so an older target receives a clear unsupported-feature error instead of a parser-dependent failure. All-table and all-sequence mode transitions require a destructive drop/create because PostgreSQL cannot unset those modes in place; ordinary membership, row-filter, column-list, DML, partition-root, generated- column, and exclusion changes use ALTER PUBLICATION. Names use ALTER PUBLICATION ... RENAME, and drops retain default RESTRICT behavior.

Database-first discovery reads pg_publication, pg_publication_rel, and pg_publication_namespace directly, reconstructs column names and stable pg_get_expr row filters, handles PostgreSQL 15–19 catalogue differences, and excludes extension-owned publications. Publication owners and privileges remain deployment policy rather than model state. Creating or changing a publication does not start logical replication and does not refresh existing subscriptions; run the corresponding subscriber refresh as an explicit operational step when membership changes.

Logical-replication subscriptions

Subscriptions are database-level model objects and are ordered after their publication metadata. A disconnected definition is safe for repeatable schema deployment because PostgreSQL does not contact the publisher, create a remote slot, copy data, or enable its worker:

modelBuilder.HasSubscription(
    "application_subscription",
    subscription => subscription
        .UseConnectionString("host=publisher dbname=app user=replicator")
        .FromPublication("document_changes")
        .WithoutSlot()
        .UsesStreaming(BlueTuskSubscriptionStreamingMode.Off));

Call ConnectOnCreate only when migration execution is intentionally allowed to contact the publisher. It can select slot creation, initial copy, and enabled state, and defaults the slot name to the subscription name. BlueTusk suppresses the migration transaction when PostgreSQL must create the remote slot. Drops with an associated slot, publication/sequence refreshes, failover changes, and disabling prepared two-phase subscription state are likewise kept outside the ambient migration transaction where PostgreSQL requires it. Publication-list model changes use refresh = false; data-copy side effects remain an explicit RefreshSubscription operation. Manual operations also cover PostgreSQL 19 REFRESH SEQUENCES and SKIP (lsn = ...) recovery handling.

The typed options include slot name, enabled/binary/streaming modes, synchronous_commit, two-phase application, disable-on-error, password policy, run-as-owner, origin filtering, failover, and PostgreSQL 19 dead-tuple retention, maximum retention duration, and WAL-receiver timeout. Parallel streaming, password policy, run-as-owner, and origin filtering require PostgreSQL 16; failover requires PostgreSQL 17; foreign-server sources, retention controls, receiver timeout, and sequence refresh require PostgreSQL 19. Generated SQL performs execution-time version checks before emitting those forms. A failover-enabled create also requires an explicit slot name.

Subscription connection information is a deliberate security boundary. Database-first discovery never selects pg_subscription.subconninfo, because it can contain plaintext credentials; such connections scaffold as redacted and cannot generate a CREATE or target connection change until a developer supplies the source in a manually reviewed migration. Password-bearing keyword and URI connection strings are rejected from EF model annotations, snapshots, and generated migration C#. A manually authored migration can obtain a secret at deployment time and construct a typed operation without persisting it in source. PostgreSQL 19 foreign-server sources are catalogued by object identity and can round-trip without redaction; their user mappings remain separate deployment policy.

Foreign data

Foreign-data wrappers and servers are database-level model objects. User mappings are keyed by server plus a local role, or by PUBLIC. A keyless EF entity can map a foreign table and retain both table-level and store-column wrapper options:

modelBuilder.HasForeignDataWrapper(
    "application_fdw",
    wrapper => wrapper.HasOption("debug", "false"));

modelBuilder.HasForeignServer(
    "application_remote",
    "application_fdw",
    server => server
        .HasType("service")
        .HasVersion("1")
        .HasOption("endpoint", "primary"));

modelBuilder.HasPublicUserMapping(
    "application_remote",
    mapping => mapping.HasOption("user", "reader"));

modelBuilder.Entity<RemoteDocument>(entity =>
{
    entity.HasNoKey();
    entity.ToTable("remote_documents", "application");
    entity.HasForeignTable(
        "application_remote",
        table => table
            .HasOption("table_name", "documents")
            .HasColumnOption("document_id", "column_name", "id"));
});

Migration diffs create wrappers before servers, mappings, and foreign tables, and reverse that dependency order for removal. Wrapper, server, mapping, table, and column option changes use PostgreSQL’s ADD, SET, and DROP option actions. Wrapper and server names can be changed without rebuilding dependent objects. PostgreSQL cannot change a server’s wrapper/type or a foreign table’s server in place, so those changes report an explicit replacement diagnostic. Foreign tables must be keyless: PostgreSQL accepts NOT NULL and CHECK as local planner assertions but does not enforce primary, unique, or foreign-key constraints on a foreign table. Option values and any check expressions are trusted deployment-time metadata and must not contain request data.

Wrapper connection functions are available on PostgreSQL 19 and generate an execution-time version guard on older servers. Wrapper handler, validator, and connection function names are schema-qualified and quoted by component. Object ownership, wrapper/server USAGE, and role grants remain deployment policy. Drops use PostgreSQL’s default RESTRICT behavior so unmanaged dependents do not disappear silently.

User-mapping options are a credential boundary. BlueTusk never selects their catalogue values during database-first discovery; every discovered mapping is stored with redacted options. Password-, secret-, token-, credential-, and API key-like option names are rejected from EF model annotations, snapshots, and generated migration C#. A manually authored migration can construct a typed mapping operation from a deployment secret, but generated C# deliberately refuses to serialize it. Redacted mappings cannot generate create or alter SQL until explicit values are supplied.

Database-first discovery reads the PostgreSQL foreign-data catalogues directly, including table and column options, and regenerates keyless foreign-table fluent metadata. Extension-owned wrappers are excluded because their lifecycle belongs to the extension; user-created servers that reference such wrappers are still retained. PostgreSQL 15–19 acceptance covers create, option alteration, rename, drop ordering, exact catalogue discovery, generated C#, and foreign-table scaffolding. PostgreSQL 19 additionally executes and discovers a wrapper connection function.

Declarative table partitioning

Partition trees are part of the EF model rather than a collection of unrelated tables. RANGE, LIST, and HASH roots, default partitions, and recursive subpartitions use the same metadata in create scripts, migration diffs, snapshots, and reverse-engineered models:

modelBuilder.Entity<Event>()
    .HasRangePartitioning(item => item.OccurredOn)
    .HasRangePartition(
        "events_2025",
        BlueTuskPartitionValue.Literal(new DateOnly(2025, 1, 1)),
        BlueTuskPartitionValue.Literal(new DateOnly(2026, 1, 1)))
    .HasRangePartition(
        "events_2026",
        BlueTuskPartitionValue.Literal(new DateOnly(2026, 1, 1)),
        BlueTuskPartitionValue.Literal(new DateOnly(2027, 1, 1)))
    .HasDefaultPartition("events_default")
    .HasSubpartitioning(
        "events_2026",
        BlueTuskPartitionStrategy.Hash,
        [BlueTuskPartitionKeyDefinition.Column(nameof(Event.TenantId))],
        child => child
            .HasHashPartition("events_2026_0", modulus: 2, remainder: 0)
            .HasHashPartition("events_2026_1", modulus: 2, remainder: 1));

The property-expression helpers resolve EF property names to their mapped column names. Explicit keys may be columns or fixed trusted SQL expressions, with optional schema-qualified collation and operator-class identifiers. LIST partitioning accepts one key, matching PostgreSQL’s restriction; RANGE and HASH may use multiple keys. Typed bound values cover strings, Booleans, integral and decimal numbers, dates, timestamps with time zone, UUIDs, NULL, MINVALUE, and MAXVALUE. BlueTuskPartitionValue.FromSql and BlueTuskPartitionBound.FromSql are deliberate escape hatches for fixed model metadata and must never receive request data or other user input.

Migration diffing creates new partition trees and emits add, drop, rename, and schema-move operations for children. Changing a bound replaces that partition with a destructive drop/create pair. PostgreSQL cannot convert an existing table to a different partition strategy or key in place, so BlueTusk reports a diagnostic requiring an explicit data-preserving replacement migration instead of silently rebuilding the table.

Existing compatible tables can be attached and partitions can be detached from manual migrations:

migrationBuilder.AttachPartition(
    "events",
    "events_2027",
    BlueTuskPartitionBound.Range(
        BlueTuskPartitionValue.Literal(new DateOnly(2027, 1, 1)),
        BlueTuskPartitionValue.Literal(new DateOnly(2028, 1, 1))),
    parentSchema: "application",
    partitionSchema: "application");

migrationBuilder.DetachPartition(
    "events",
    "events_2027",
    BlueTuskPartitionDetachMode.Concurrently,
    parentSchema: "application",
    partitionSchema: "application");

Concurrent detach is emitted as a transaction-suppressed migration command. PostgreSQL does not permit it when the partitioned table has a default partition, and interrupted concurrent detaches may require the Finalize mode. Attaching a new partition may also require validating or constraining existing rows in its table and the current default partition; plan those data steps in the migration before the attach operation.

Row-level security

Row-level security enablement, owner enforcement, and policies are retained as table-owned EF metadata:

modelBuilder.Entity<Document>()
    .UseRowLevelSecurity(enabled: true, forced: true)
    .HasPolicy(
        "tenant_select",
        BlueTuskRowSecurityPolicyCommand.Select,
        usingSql: "tenant_id = current_setting('application.tenant_id')::integer",
        roles: [BlueTuskRowSecurityRoleDefinition.Named("application_user")])
    .HasPolicy(
        "tenant_insert",
        BlueTuskRowSecurityPolicyCommand.Insert,
        withCheckSql: "tenant_id = current_setting('application.tenant_id')::integer",
        roles: [BlueTuskRowSecurityRoleDefinition.Named("application_user")]);

Policies support PostgreSQL’s permissive and restrictive behavior, ALL, SELECT, INSERT, UPDATE, and DELETE command scopes, named roles, and the PUBLIC, CURRENT_ROLE, CURRENT_USER, and SESSION_USER targets. BlueTusk rejects USING on INSERT policies and WITH CHECK on SELECT or DELETE policies because PostgreSQL does not accept those combinations. Policy expressions are emitted verbatim: they must be fixed application-model SQL and must never contain request data or other user input.

Migration diffs create, alter, drop, and rename policies. Role and predicate changes use PostgreSQL’s in-place ALTER POLICY. A change PostgreSQL cannot alter—such as command scope, permissive versus restrictive behavior, or removing an existing expression—is represented as an explicit drop/create replacement. Removing the model metadata drops its policies and emits DISABLE ROW LEVEL SECURITY and NO FORCE ROW LEVEL SECURITY when needed. Table renames preserve the attached policies without trying to recreate them. Generated migration C# and snapshots retain the same typed definition.

Enabling RLS without an applicable permissive policy produces PostgreSQL’s default-deny behavior. Superusers and roles with BYPASSRLS still bypass policies; table owners normally bypass them unless forced: true is configured. RLS supplements normal GRANT privileges rather than replacing them, so roles also need schema and table privileges. Application migrations should create or manage those roles and grants separately from the policy metadata.

Direct table inheritance

PostgreSQL table inheritance is modelled separately from EF’s CLR inheritance mapping and from declarative partitioning. A child may name one or more direct parents; parent order is retained because PostgreSQL records it in pg_inherits.inhseqno and uses it when arranging inherited columns:

modelBuilder.Entity<BaseEvent>()
    .ToTable("base_events", "application");
modelBuilder.Entity<AuditRecord>()
    .ToTable("audit_records", "application");

modelBuilder.Entity<EventMessage>()
    .ToTable("event_messages", "application")
    .InheritsFromTable<BaseEvent>()
    .InheritsFromTable<AuditRecord>();

Configure each parent entity and its final table mapping before using the typed helper. InheritsFromTable("base_events", "application") is available when the parent is external to the EF model. The child must contain compatible columns, nullability, and inheritable check constraints for every parent; PostgreSQL validates those structural rules when the migration is applied. Primary keys, unique constraints, and foreign keys are not inherited.

Migration diffing emits NO INHERIT before a relationship or dependent table is removed and INHERIT after both tables exist. Parent and child table/schema renames preserve an unchanged relationship without detaching it. Reordering multiple parents performs an explicit detach/reattach so the catalogue order is deterministic. Manual migrations can use AddTableInheritance and RemoveTableInheritance; removal leaves both tables and their columns in place. Normal PostgreSQL queries against a parent include descendant rows, while ONLY parent_table restricts a query to the parent’s own rows.

PostgreSQL collations

Provider-owned collation schema objects participate in migration diffing, snapshots, generated migration C#, dependency ordering, and database-first scaffolding:

modelBuilder.HasCollation(
    "case_insensitive",
    collation => collation
        .UseProvider(BlueTuskCollationProvider.Icu)
        .UseLocale("und-u-ks-level2")
        .IsDeterministic(false),
    schema: "application");

The builder supports one provider locale or separate libc LC_COLLATE and LC_CTYPE values. Nondeterministic comparison and custom rules require ICU. ICU HasRules is capability-guarded for PostgreSQL 16 and later; the Builtin provider is guarded for PostgreSQL 17 and later. Accepted locales and their exact behavior depend on the server’s operating-system or ICU build, so applications should test the comparisons and ordering they rely on.

Collations are created after their schema and before provider-owned types, routines, tables, indexes, and views. Automatic creation omits IF NOT EXISTS so an unmanaged collision cannot be mistaken for the configured definition. An otherwise identical name or schema change uses ALTER COLLATION, preserving PostgreSQL dependency identities. Provider, locale, determinism, rules, and recorded-version changes cannot be altered safely in place; BlueTusk rejects them and requires an explicit rebuild of every dependent object followed by a drop/create migration. Automatic drops are destructive and use RESTRICT.

Manual migrations can copy an existing collation or control collision/drop semantics:

migrationBuilder.CreateCollationFrom(
    "application_default",
    "C",
    schema: "application",
    sourceSchema: "pg_catalog");

migrationBuilder.DropCollation(
    "application_default",
    schema: "application");

Provider version drift needs special care. Rebuild every affected index and other stored object first, then use RefreshCollationVersion; PostgreSQL’s refresh only updates the catalogue version and does not verify or rebuild dependants. HasVersion is the low-level creation option primarily used when preserving state through upgrade or scaffolding, not an automatic upgrade mechanism.

Reverse engineering reads pg_collation through a version-adaptive projection, retaining provider, locale categories, determinism, ICU rules, and the recorded version while excluding system and extension-owned collations. PostgreSQL does not retain whether a definition was originally copied with FROM, so database-first scaffolding emits its explicit discovered properties.

PostgreSQL tablespaces

Cluster-wide tablespaces participate in model snapshots, dependency-ordered migration diffs, generated migration C#, and full-database reverse engineering:

modelBuilder.HasTablespace(
    "archive_space",
    "/srv/postgresql/archive_space",
    tablespace => tablespace
        .OwnedBy("application_owner")
        .HasSequentialPageCost(1.25)
        .HasRandomPageCost(1.75)
        .HasEffectiveIoConcurrency(4)
        .HasComment("Archive storage"));

The location is a path on the PostgreSQL server, not the application host. It must already exist, be empty, use an absolute path, and be owned by the server’s operating-system account. PostgreSQL only permits a superuser to create a tablespace. Loss of that directory can make the whole cluster unavailable, so the path must be durable and covered by the cluster’s backup and recovery plan.

BlueTusk emits create before database-local objects and drop after them. Both commands are transaction-suppressed because PostgreSQL rejects them inside a transaction block. A drop has no cascade mode and PostgreSQL accepts it only when the tablespace is empty across every database in the cluster. Automatic creation has no collision-suppression clause because PostgreSQL does not offer one; automatic removal is marked destructive.

Name, owner, supported planner/I/O options, and shared comments can change in place. Removed options use ALTER TABLESPACE ... RESET, and an identity-preserving name change uses PostgreSQL’s rename operation. PostgreSQL cannot alter a tablespace’s filesystem location. The model differ rejects that change and requires an explicit operational migration that first moves every dependent object, drops the empty old tablespace, prepares the new directory, and creates the replacement.

Full-database discovery reads custom spaces directly from pg_tablespace, retaining pg_tablespace_location, owner, options, and shobj_description while excluding pg_default, pg_global, and reserved pg_* names. Schema- or table-filtered scaffolding omits these unrelated cluster-global objects. Review generated definitions before deploying them to another cluster because server filesystem paths and roles are environment-specific.

PostgreSQL extensions

Provider-owned extension installations participate in migration diffing, snapshots, generated migration C#, dependency ordering, and database-first scaffolding:

modelBuilder.HasExtension(
    "hstore",
    extension => extension
        .UseSchema("application_types")
        .HasVersion("1.8"));

modelBuilder.HasExtension("postgis");
modelBuilder.HasExtension(
    "postgis_topology",
    extension => extension
        .DependsOnExtension("postgis")
        .InstallDependencies());

An extension is created after its target schema but before provider-owned types, routines, tables, indexes, and views, so those objects may consume its types, functions, operators, or access methods. Declared provider-owned extension dependencies are installed first and removed last. PostgreSQL extension names are database-global rather than schema-qualified; UseSchema selects the installation schema and a later change emits ALTER EXTENSION SET SCHEMA. PostgreSQL accepts that move only for a relocatable extension.

HasVersion pins initial installation and emits ALTER EXTENSION ... UPDATE TO when changed. PostgreSQL must provide a valid update path; BlueTusk does not promise downgrades. Removing the version requests PostgreSQL’s next available update without pinning a target. A schema move and version update are emitted as separate statements, update first. If dependent model metadata contains textual schema-qualified extension object names, stage the extension move and those metadata changes in explicit migrations.

Automatic creates deliberately omit IF NOT EXISTS, because that clause does not prove that an existing installation has the requested schema, version, or configuration. Automatic drops are destructive and explicitly use RESTRICT; dependent provider-owned objects are removed first, while unmanaged dependants make PostgreSQL reject the drop. Manual migrations can use CreateExtension, AlterExtension, and DropExtension, including explicit IF NOT EXISTS or CASCADE options when the application owns that risk. InstallDependencies adds CASCADE only during creation so PostgreSQL may recursively install missing required extensions.

Reverse engineering reads the installed schema, exact version, and recorded extension-to-extension dependencies from pg_extension and pg_depend. Whether the original install used CASCADE is not stored by PostgreSQL, so scaffolding does not regenerate InstallDependencies. Owners, grants, and extension membership changes are also outside this metadata and require explicit migrations. Extension installation executes server-side package scripts with the installing role’s privileges; install only reviewed extension packages and follow each extension’s trusted/superuser and search_path guidance.

PostgreSQL enum, domain, composite, range, and multirange types

Provider-owned enum, domain, standalone composite, range, and paired multirange schema objects use typed model metadata and participate in migration diffing, snapshots, generated migration C#, and database-first scaffolding:

modelBuilder.HasEnum(
    "mood",
    ["sad", "ok", "happy"],
    schema: "application");

modelBuilder.HasDomain(
    "positive_integer",
    "integer",
    domain => domain
        .HasDefaultSql("1")
        .IsRequired()
        .HasCheckConstraint(
            "value_positive",
            "VALUE > 0",
            isValidated: false),
    schema: "application");

modelBuilder.HasComposite(
    "address",
    composite => composite
        .HasAttribute("street", "text")
        .HasAttribute("postal_code", "application.positive_integer"),
    schema: "application");

modelBuilder.HasRange(
    "measurement_range",
    "float8",
    range => range
        .UseSubtypeOperatorClass("float8_ops", "pg_catalog")
        .HasSubtypeDifferenceFunction("float8mi", "pg_catalog")
        .HasMultirangeType("measurement_multirange"),
    schema: "application",
    subtypeSchema: "pg_catalog");

Type names, enum labels, constraint names, and attribute names are quoted as identifiers or literals. Store types, collations, DefaultSql, and domain check expressions are trusted model-time SQL; never populate them from request data or other untrusted input. Schema-qualified provider-owned store types are used to order dependent creates and reverse-order drops. Drops use PostgreSQL’s default RESTRICT behavior and are marked destructive rather than silently adding CASCADE.

The supported in-place alteration surface follows PostgreSQL’s DDL limits:

  • enum labels may be added at a specific position or renamed; removal and reordering require an explicit data-preserving replacement migration;
  • domain defaults, nullability, and named check constraints may be added, dropped, renamed, replaced, or validated, while base type and collation changes require explicit replacement;
  • composite attributes may be renamed, dropped, have their type/collation altered, or be appended; reordering existing attributes or inserting before them requires explicit replacement.

Enum ADD VALUE commands are transaction-suppressed so a new label can be used by following migration commands. Automatic type/schema renames require an otherwise unchanged definition; split a rename from a simultaneous body change into separate migrations. Manual migrations can use the typed Create*Type, Alter*Type, Drop*Type, and RenameUserDefinedType helpers when a replacement or staged rollout is needed.

Custom ranges retain structured, schema-qualified references to their subtype, B-tree operator class, optional collation, optional canonical function, optional subtype-difference function, and PostgreSQL-created multirange type. When HasMultirangeType is omitted, BlueTusk uses PostgreSQL’s naming rule: the first range substring becomes multirange, or _multirange is appended. Creates are ordered before domains, composites, routines, and tables that use either the range or multirange name. Drops are destructive, explicitly use RESTRICT, and rely on PostgreSQL to remove the paired multirange.

PostgreSQL treats the multirange as a separate type for ALTER TYPE; it does not follow a range rename or schema move. BlueTusk therefore moves and renames the multirange first and the range second. Changes to subtype, operator class, collation, canonical function, or subtype-difference function cannot be made in place and produce replacement guidance.

HasCanonicalFunction references a function that already exists when the range is created. A canonical function whose argument or result is the new range requires PostgreSQL’s shell-type workflow: create the shell type, create the function, and then complete the range definition. BlueTusk does not synthesize that cycle from provider-owned routine metadata; use a staged manual migration for it. Function SQL is trusted deployment input, while every type, operator-class, collation, and function name in the range API is quoted as an identifier.

PostgreSQL functions and procedures

Provider-owned routines are separate from EF’s HasDbFunction query mapping. The routine schema API models PostgreSQL overload identity and generates actual CREATE FUNCTION/CREATE PROCEDURE migrations:

modelBuilder.HasFunction(
    "calculate_total",
    "numeric",
    "SELECT amount * (1 + tax_rate)",
    function => function
        .HasParameter("numeric", "amount")
        .HasParameter("numeric", "tax_rate", defaultSql: "0.2")
        .HasVolatility(BlueTuskFunctionVolatility.Immutable)
        .IsStrict()
        .HasParallelSafety(BlueTuskFunctionParallelSafety.Safe)
        .HasCost(1),
    schema: "application");

modelBuilder.HasProcedure(
    "record_call",
    "BEGIN INSERT INTO application.call_log(message) VALUES (message); END",
    procedure => procedure
        .UseLanguage("plpgsql")
        .HasParameter("text", "message"),
    schema: "application");

The typed builders cover ordered IN/OUT/INOUT/VARIADIC parameters, defaults, scalar or SETOF function results, implementation language, volatility, strict/null-input behavior, invoker/definer security, leakproof and parallel classifications, planner cost/rows, and routine-local configuration. Store types, parameter defaults, configuration values, and bodies are trusted model-time SQL. Bodies are safely dollar-quoted, but their contents are not sanitized; never derive them from request data.

Overloads are keyed by kind, schema, name, and PostgreSQL input argument types. Initial migrations use CREATE so an unmanaged collision fails. Body, default, language, and compatible attribute changes use CREATE OR REPLACE, preserving the routine’s ownership and privileges. PostgreSQL cannot replace a routine while changing its kind, input signature, parameter name/mode/output shape, function return type, or WINDOW status; BlueTusk diagnoses same-signature changes and treats a different signature as destructive create/drop. Use the signature-qualified RenameRoutine helper for dependency-preserving name or schema changes.

User-defined types are created before routines and dropped after them. Quoted string bodies are created before relational objects so tables may reference a function in defaults or generated expressions. SQL-standard bodies discovered through prosqlbody retain PostgreSQL’s tracked dependencies and are instead created after, and dropped before, relational objects.

SECURITY DEFINER routines require a carefully restricted search_path; use HasConfiguration("search_path", "application, pg_temp") only with reviewed, trusted SQL. Routine execute grants are not managed by this metadata and should be applied with explicit GRANT/REVOKE migrations.

PostgreSQL operators, index semantics, casts, and aggregates

Provider-owned executable schema objects can be kept in the EF model, migration snapshots, generated migration C#, and database-first scaffolding. The APIs are separate from query translation: defining an operator or aggregate creates the PostgreSQL object but does not automatically add a new LINQ translation.

modelBuilder.HasOperator(
    "===",
    op => op
        .HasLeftType("integer")
        .HasRightType("integer")
        .UsesFunction("int4eq", "pg_catalog")
        .HasCommutator("===", "application")
        .SupportsHashJoin()
        .SupportsMergeJoin(),
    schema: "application");

modelBuilder.HasOperatorFamily(
    "integer_family",
    "btree",
    schema: "application");

modelBuilder.HasOperatorClass(
    "integer_ops",
    "integer",
    "btree",
    opClass => opClass
        .IsInFamily("integer_family", "application")
        .HasOperator(1, "<", "integer", "integer", "pg_catalog")
        .HasOperator(2, "<=", "integer", "integer", "pg_catalog")
        .HasOperator(3, "===", "integer", "integer", "application")
        .HasOperator(4, ">=", "integer", "integer", "pg_catalog")
        .HasOperator(5, ">", "integer", "integer", "pg_catalog")
        .HasFunction(
            1,
            "btint4cmp",
            "integer",
            "integer",
            ["integer", "integer"],
            "pg_catalog"),
    schema: "application");

modelBuilder.HasCast(
    "application.mood",
    "text",
    cast => cast.UsesInputOutput().IsAssignment());

modelBuilder.HasAggregate(
    "product",
    "integer",
    aggregate => aggregate
        .UsesState("int4mul", "integer", "pg_catalog")
        .HasInitialCondition("1")
        .IsParallelSafe(BlueTuskAggregateParallelSafety.Safe),
    schema: "application");

Operator-class and family builders retain access methods, exact strategy and support numbers, operand types, search versus ordering purpose, sort families, support-function overloads, default status, and optional storage types. Family metadata represents only loose members added directly to the family; members owned by an operator class remain with that class. Family changes add and drop only changed members.

Casts support function implementations with an explicit overload signature, binary coercion, and input/output conversion in explicit, assignment, or implicit contexts. Cast identity is database-global, even when its types or function are schema-qualified. Aggregate builders cover ordinary, ordered-set, and hypothetical-set signatures; transition/final/combination and serialisation functions; moving state; state-space hints; initial conditions; sort operators; final-state modification; and parallel safety. Ordered and hypothetical signatures include their ORDER BY portion in identityArgumentsSql.

Creates are ordered after provider-owned routines and before relational consumers. Drops reverse that dependency order. Aggregate-compatible changes use CREATE OR REPLACE AGGREGATE. PostgreSQL has no equivalent replacement for operators, operator classes, or casts, so a same-identity change is destructive and uses a RESTRICT drop followed by create. It succeeds only after unmanaged dependent indexes and schema objects have been handled explicitly. Automatic drops never add CASCADE; ownership, privileges, comments, and grants remain explicit migration concerns.

Names are centrally quoted, and operator symbols are validated against PostgreSQL’s operator grammar. Store types and aggregate identity signatures are trusted model-time SQL fragments: never derive them from request data. Function bodies belong in the routine schema APIs and are created first.

Reverse engineering reads the executable-object catalogues directly, excludes system and extension-owned definitions, retains exact referenced object names, and uses pg_depend ownership edges to distinguish class-owned members from loose family members. Function-based casts keep their overload argument types; aggregates retain the server’s canonical identity arguments and all supported state attributes. Schema selection applies to schema-owned definitions and to casts whose source type, target type, or implementation function is selected.

PostgreSQL views and materialised views

Provider-owned views are schema objects rather than EF query mappings. Ordinary and materialised definitions retain their trusted defining query, explicit output names, and view-on-view dependencies in migrations, snapshots, generated migration C#, and database-first scaffolding:

modelBuilder.HasView(
    "active_orders",
    "SELECT id, tenant_id, total FROM application.orders WHERE total >= 0",
    view => view
        .HasColumns("id", "tenant_id", "total")
        .IsSecurityBarrier()
        .IsSecurityInvoker()
        .HasCheckOption(BlueTuskViewCheckOption.Cascaded),
    schema: "application");

modelBuilder.HasMaterializedView(
    "order_totals",
    "SELECT tenant_id, sum(total)::numeric AS total " +
        "FROM application.orders GROUP BY tenant_id",
    view => view
        .HasColumns("tenant_id", "total")
        .UseAccessMethod("heap")
        .HasStorageParameter("fillfactor", "80")
        .IsPopulated(),
    schema: "application");

modelBuilder.HasView(
    "large_order_totals",
    "SELECT tenant_id, total FROM application.order_totals WHERE total >= 1000",
    view => view
        .HasColumns("tenant_id", "total")
        .DependsOnView("order_totals", "application"),
    schema: "application");

QuerySql and storage-parameter values are trusted model-time SQL and must not contain request data or other untrusted input. Names are quoted centrally. Ordinary builders also support PostgreSQL’s recursive form; recursive views require explicit output names and cannot use CHECK OPTION.

Ordinary query and option changes use CREATE OR REPLACE VIEW. BlueTusk rejects an explicit output-list change that renames, removes, or reorders existing columns; PostgreSQL also validates that existing output types remain unchanged and permits only new columns appended at the end. Replacements explicitly reset removed security_barrier, security_invoker, and check_option settings. Name and schema-only changes use ALTER VIEW/ALTER MATERIALIZED VIEW, while drops retain PostgreSQL’s default RESTRICT behavior rather than silently adding CASCADE.

PostgreSQL cannot replace a materialised view’s defining query in place. A query or output-list change is therefore marked destructive and emits a dependency- ordered drop/create. Provider-owned views that declare a transitive dependency on the replaced materialised view are dropped first and reconstructed after it. Declare model-authored view dependencies with DependsOnView; reverse engineering derives the same edges from PostgreSQL’s catalogues. Access method, tablespace, storage-parameter, and populated/unpopulated changes use supported ALTER MATERIALIZED VIEW and REFRESH MATERIALIZED VIEW forms without replacing the definition.

Manual refreshes use typed migration operations:

migrationBuilder.RefreshMaterializedView(
    "order_totals",
    schema: "application",
    concurrently: true);

PostgreSQL requires a populated materialised view and at least one all-row, column-only unique index for CONCURRENTLY; it rejects CONCURRENTLY WITH NO DATA and allows only one refresh of a materialised view at a time. Create the required index separately with a normal migration index operation. A no-data refresh is marked destructive because it discards the stored contents and leaves the relation unscannable.

The schema metadata deliberately does not manage owners, privileges, or application-specific grants. Apply those through explicit migrations. Defining queries are dependency-tracked by PostgreSQL, and security_invoker changes whose privileges and row-level-security policies apply to underlying relations; review both the query and grants as security-sensitive schema.

PostgreSQL 19 property graphs have typed model metadata, migration diffing and operation scaffolding, central identifier quoting, live CREATE/ALTER/DROP PROPERTY GRAPH coverage, and an execution-time SQL/PGQ capability guard. The optional citext, pgvector, PostGIS, and TimescaleDB EF packages provide their own extension lifecycle migration helpers. The complete schema surface defined by product-spec sections 19 and 21 is implemented and acceptance-tested across PostgreSQL 15–19. Ownership, privileges, and application-specific grants remain deliberately explicit migrations rather than generated model metadata.

PostgreSQL 19 property-graph queries

PropertyGraph creates a typed SQL/PGQ query from graph metadata configured in the EF model. The V1 contract translates linear directed paths to GRAPH_TABLE, keeps captured predicate values parameterized, and returns a composable IQueryable. It supports outer relational filters, joins, grouping, ordering, pagination, DTO projections, and tracked entity materialization:

var friends = await context.PropertyGraph("social", "application")
    .Match(pattern => pattern
        .Vertex<Person>("source", person => person.Id == personId)
        .Outgoing<Friendship>("edge")
        .Vertex<Person>("target"))
    .Select<FriendResult>(projection => projection
        .Property<Person, int>(
            "target", person => person.Id, result => result.PersonId)
        .Property<Person, string>(
            "target", person => person.Name, result => result.Name))
    .OrderBy(result => result.Name)
    .ToListAsync(cancellationToken);

The exact supported expression subset and raw-SQL-only remainder are documented in the SQL/PGQ guide.

Database-first scaffolding

The runtime provider exposes EF Core’s conventional design-time service entry point and loads the companion design assembly through that stable boundary. The design service registers both BlueTusk’s runtime provider services and its tooling services, so migration snapshot compilation, database-model reads, and generated-code workflows use the same type mappings and annotations as runtime EF operations.

The design-time provider integrates with EF Core reverse engineering. It discovers ordinary and foreign tables and views, columns and PostgreSQL store types, defaults and generated values, primary and unique keys, foreign keys, table CHECK and exclusion constraints, table/view and database-wide event triggers, rewrite rules, logical-replication publications and subscriptions, foreign-data wrappers, servers and redacted user mappings, cluster-wide custom tablespaces, operators, operator families and classes, casts, aggregates, column and expression indexes, comments, standalone sequences, provider-owned collations, installed extensions, declarative partition trees, direct table-inheritance parents, row-level security policies, provider-owned enums, domains, standalone composite, range, and multirange types, functions, procedures, and PostgreSQL 19 property graphs. Table CHECK constraints retain their canonical expression, validation state, NO INHERIT mode, and PostgreSQL 18+ enforcement state. Column-based indexes retain their access method, operator classes, collations, sort/null ordering, included columns, null-distinctness, storage parameters, and predicate; standalone and mixed expression indexes additionally retain their canonical key SQL, operator-class parameters, and tablespace through provider-owned metadata. Generated contexts use the corresponding BlueTusk fluent index APIs. Exclusion constraints retain their access method, ordered canonical elements and exact operators, included columns, storage settings, tablespace, predicate, and deferrability without duplicating their backing indexes. Foreign-data discovery retains wrapper functions/options, server identity/type/version/options, and foreign-table/column options, excludes extension-owned wrappers, and never reads user-mapping option values. Relation-trigger discovery retains canonical PostgreSQL DDL, firing mode, and extension dependency while excluding internal clones and extension-owned objects; event-trigger discovery retains its global identity, event, function, tags, and firing mode. Rule discovery retains canonical PostgreSQL DDL and firing mode while excluding extension-owned rules and view _RETURN machinery. Publication discovery retains explicit tables, columns, filters, schemas, DML options, partition-root behavior, and version-specific generated-column/all-sequence/exclusion state while excluding extension-owned objects. Subscription discovery retains publications, slots, enabled and application options, cross-version streaming state, and version-specific origin/failover/retention settings while deliberately redacting direct connection information; PostgreSQL 19 foreign-server sources retain their server identity. Tablespace discovery retains server location, owner, supported options, and shared comments while excluding built-ins and activating only for unfiltered full-database scaffolding. Collation discovery retains the provider, locale categories, determinism, ICU rules, and recorded version while excluding system and extension-owned objects. Installed-extension discovery retains the exact version, installation schema, and extension dependency edges while excluding extensions installed into system schemas. Partition discovery retains PostgreSQL’s exact catalogue key and bound expressions, including empty partitioned tables and recursive subpartitions. Child partitions are represented inside the root’s fluent metadata instead of being scaffolded as unrelated EF entities. Direct inheritance discovery retains ordered multiple parents while excluding declarative-partition catalogue edges. RLS discovery retains enable/force flags, permissive/restrictive behavior, command scopes, roles, and catalogue-rendered USING/WITH CHECK expressions. User-defined-type discovery retains enum order, domain base/default/nullability/collation/check state, ordered composite attributes, and range subtype/operator-class/collation/function/multirange identities while excluding table row types, system schemas, and extension-owned types. Routine discovery retains overload identity, arguments/defaults, results, window status, tracked-body dependency phase, and the server’s canonical pg_get_functiondef DDL; aggregates remain in the separate schema-program metadata while normal routines exclude them. View discovery retains the stable, non-pretty pg_get_viewdef query, ordered output names, security/check options, materialisation kind, access method, storage parameters, tablespace, population state, and view-on-view dependency edges while excluding system and extension-owned relations. Graph metadata includes vertex and edge tables, keys, labels, properties, and source/destination column mappings. Sequence metadata is read directly from PostgreSQL’s catalogues, avoiding the relation-opening behavior of pg_sequences when another session is concurrently changing schema. Schema and table filters are supported, and caller-owned open connections remain open.

dotnet ef dbcontext scaffold \
  "Host=localhost;Database=app;Username=app;Password=..." \
  BlueTusk.EntityFrameworkCore \
  --context AppDbContext \
  --output-dir Models \
  --schema public

The packaged BlueTusk tool provides the product-specific command shape and includes views, routines, and property graphs without requiring opt-in flags:

export BLUETUSK_CONNECTION_STRING="Host=localhost;Database=app;Username=app;Password=..."
bluetusk scaffold \
  --output Models \
  --context AppDbContext \
  --namespace App.Models \
  --schema public

Install it with dotnet tool install --global BlueTusk.Tool. The CLI does not write its connection string into generated C# unless --include-connection-string is explicitly supplied. Repeat --schema or --table for selection, and use --force only when existing generated files should be overwritten. See the tool reference for all options.

Generated contexts use the BlueTusk provider; dotnet ef and the CLI’s explicit connection-string mode also generate UseBlueTusk in OnConfiguring. Reverse-engineered table CHECK and exclusion constraints, standalone and mixed expression indexes, relation and event triggers, rewrite rules, publications, credential-redacted subscriptions and user mappings, foreign-data wrappers, servers and foreign tables, tablespaces, operators, operator families and classes, casts, aggregates, collations, installed extensions, graphs, partition trees, table-inheritance relationships, RLS policies, enums, domains, standalone composites, ranges and paired multiranges, functions, procedures, ordinary views, and materialised views are retained through provider model annotations and participate in later migration diffs. This completes the database-first discovery surface defined by the product specification. Owners, privileges, and application-specific grants are intentionally not inferred into generated models; use explicit migrations for those deployment security decisions.

Validation

The full native EF provider project runs 301 cases on each supported server. PostgreSQL 15 passes 299 cases with the filesystem-dependent tablespace case and the PostgreSQL 16 aggregate-capability case skipped; PostgreSQL 16–19 each pass 300 cases with only the tablespace case skipped when no server-owned test directory is configured. The matrix covers query translation and execution, database lifecycle, migrations, catalogue discovery, generated code, and product-specific schema objects.

The PostgreSQL 15–19 table-CHECK gate verifies inline and deferred creation, NO INHERIT, enforcement of unvalidated constraints for new rows, PostgreSQL 18+ NOT ENFORCED behavior and earlier-version guards, failed/successful VALIDATE CONSTRAINT, exact catalogue round-tripping, and generated fluent C#.

The PostgreSQL 15–19 expression-index gate verifies pure/mixed key execution, unique and partial behavior, canonical key/operator/collation/sort/null replay, included columns, storage parameters, lifecycle diffing, catalogue round-tripping, and generated fluent C#.

The PostgreSQL 15–19 tablespace gate prepares a real server-owned filesystem directory and verifies transaction-suppressed create/drop, owner/options/comment alteration and reset, rename, physical table placement, direct catalogue round-tripping, fluent scaffolding, immutable-location rejection, and empty cluster-wide removal.

The PostgreSQL 15–19 event-trigger gate verifies filtered DDL execution, routine and migration-boundary ordering, enable/disable and rename behavior, catalogue discovery, generated migration/fluent C#, default-RESTRICT removal, and PostgreSQL 17+ disabled-login-trigger creation.

The PostgreSQL 15–19 schema-program gate verifies executable operator, operator-family, operator-class, cast, and aggregate lifecycles; precise loose family-member changes; destructive replacements; exact catalogue ownership discovery; generated migration and fluent C#; and dependency-safe removal.

The PostgreSQL 15–19 view gate verifies security/check enforcement, dependency ordering, normal and concurrent materialised refresh, constrained replacement, auxiliary alteration, rename, canonical catalogue discovery, and generated fluent C#.

The PostgreSQL 15–19 extension gate verifies installation, dependency and schema ordering, exact version/schema catalogue round-tripping, generated fluent C#, relocation, and default-RESTRICT removal.

The PostgreSQL 15–19 collation gate verifies ICU comparison behavior, collation-first ordering, safe rename/schema moves, exact cross-version catalogue discovery, generated fluent C#, default-RESTRICT removal, PostgreSQL 16+ ICU rules, and PostgreSQL 17+ built-in-provider guards.

The PostgreSQL 15–19 custom-range gate verifies executable range and multirange values, dependency-ordered creation, pair-aware rename/schema moves, default-RESTRICT removal, exact pg_range discovery, and generated fluent C#.

The PostgreSQL 15–19 exclusion-constraint gate verifies live overlap rejection, partial-predicate behavior, included columns and storage settings, exact pg_constraint/index discovery, generated fluent C#, constraint rename, and default-RESTRICT removal.

The PostgreSQL 15–19 trigger gate verifies function execution with literal arguments, canonical catalogue DDL, always/disabled firing modes, generated fluent C#, rename without replacement, dependency-safe ordering, and default-RESTRICT removal.

The PostgreSQL 15–19 rewrite-rule gate verifies live rewritten execution, canonical catalogue DDL, always/disabled firing modes, generated fluent C#, rename without replacement, dependency-safe ordering, _RETURN exclusion, and default-RESTRICT removal.

The PostgreSQL 15–19 publication gate verifies filtered/column-limited table and schema membership, option alteration, rename, exact cross-version catalogue round-tripping, generated fluent C#, relation-safe ordering, and default- RESTRICT removal. PostgreSQL 18–19 also execute generated-column publishing; PostgreSQL 19 executes all-sequence publications plus all-table exclusion create and alteration.

The PostgreSQL 15–19 subscription gate verifies disconnected creation, option alteration, rename/drop ordering, exact cross-version catalogue round-tripping, credential-redacted database-first C#, and non-transactional operation marking. PostgreSQL 16–19 additionally execute parallel-streaming, password-policy, run-as-owner, and origin changes; PostgreSQL 17–19 round-trip failover state; PostgreSQL 19 verifies retention/receiver-timeout fields and foreign-server source identity.

The PostgreSQL 15–19 foreign-data gate verifies wrapper/server/mapping/foreign- table creation, table and column option changes, dependency-safe rename and removal, exact catalogue round-tripping, credential-redacted mapping metadata, and generated keyless fluent C#. PostgreSQL 19 also executes and discovers a wrapper connection function.

The provider gate runs against PostgreSQL and covers service lifetimes, core and wire-native scalar mappings, generated values and concurrency, CRUD and transactions, common LINQ and compiled queries, raw SQL composition and parameters, tracking modes and identity resolution, split-query includes and relationship fix-up, bulk update/delete, schema creation, migrations and idempotent scripts, advanced index creation/deletion, declarative partition lifecycles, direct table inheritance, row-level security enforcement, rewrite-rule lifecycles, catalogue round-tripping, and database-first C# generation. Advanced index acceptance runs on PostgreSQL 15–19 and verifies expression/partial keys, access methods, operator classes, collations, sort/null ordering, included columns, null-distinctness, storage parameters, and transaction-suppressed concurrent operations. Partition acceptance on the same server matrix verifies RANGE/LIST/HASH DDL, recursive row routing, default partitions, typed bounds, destructive-change diagnostics, exact catalogue discovery, generated fluent C#, and attach/detach operations. Table-inheritance acceptance verifies ordered multiple parents, inherited versus ONLY scans, add/remove lifecycle SQL, rename-aware diffs, pg_inherits discovery, and generated fluent C# across PostgreSQL 15–19. RLS acceptance verifies non-owner tenant filtering, successful and rejected WITH CHECK inserts, active enable/force state, policy lifecycle SQL, catalogue discovery, and generated fluent C# on PostgreSQL 15–19. User-defined-type acceptance on the same matrix verifies dependency-ordered enum/domain/composite creation, runtime enforcement, transaction-suppressed enum additions, supported alterations and renames, destructive diagnostics, exact catalogue discovery, generated fluent C#, enum/domain predicates, and catalogue-resolved nested typed-composite/lossless-record field access. Routine acceptance across PostgreSQL 15–19 verifies overloaded functions, default arguments, optimizer/null/parallel attributes, PL/pgSQL procedures, UDT and relational dependency phases, signature-qualified lifecycle operations, canonical catalogue discovery, and generated fluent C#. The native type gate round-trips network, geometric, bit-string, LSN, arbitrary-numeric, temporal, full-text, JSON/JSONB/XML, JSON-path, one- and multidimensional array, range, multirange, enum, domain, typed composite, and lossless record values through EF. The PostgreSQL-specific query gate executes parameterized operator predicates, the documented scalar-function surface, typed built-in aggregate families, multidimensional array construction/subscripts/slices, lateral array expansion, typed series and JSONB roots, generic two- through four-array expansion, array-subscript generation, regex/delimiter table roots, model-registered user-defined table functions, and PostgreSQL-native data-modification constructs across PostgreSQL 15–19. Aggregate ordering, DISTINCT, and FILTER, plus single/multi-array unnest filtering, ordinality, nullable elements, null padding, inner/outer lateral composition, standalone/correlated/compiled generate_series, generate_subscripts, JSONB element/key/path/pair/recordset expansion (including JSONPath variables and silent mode), regex captures/splitting, nullable delimiter splitting, schema-qualified typed table-function materialization, named/recursive CTEs, RETURNING, ON CONFLICT, and MERGE are covered in generated SQL and live execution. A focused 57-case native query/type matrix passes completely on PostgreSQL 16–19; PostgreSQL 15 passes 56 cases and reports only the intentional capability skip for PostgreSQL 16 strict/unique JSON aggregates and any_value.