DHI.Services.PostgreSQL — Internal Developer Guide¶
This package provides a thin, opinionated PostgreSQL provider layer for DHI Domain Services. It gives you:
- A base repository that self-provisions tables, handles JSON (via converters), and plays nicely with Npgsql.
- A set of ready-made repositories (Accounts, Filters, Jobs, JsonDocuments, MapStyles, Notifications, PasswordHistory, Places, RefreshTokens, Scalars, Scenarios, UserGroups).
- Utilities to make DateTime handling safe with Npgsql 8+ and to simplify connection strings, parameters, and JSONB filtering.
If you’re building or using DHI services that persist to PostgreSQL, this is the foundation.
1) Install & Requirements¶
- Database: PostgreSQL 12+ (JSONB used in some repos)
- Client: Npgsql 8.x (see DateTime notes below)
- Packages:
DHI.Services.PostgreSQLplus the DHI domain packages you depend on (Accounts, Notifications, etc.)
2) Architecture in 90 seconds¶
-
BaseRepository(andBaseRepository<TEntity>) is the base class for all providers. It:- Parses the connection string (supports
Table=...override). - Ensures the table exists on first use.
- Wires up JSON serialization/deserialization with a set of converters that “do the right thing” for enums and arbitrary JSON -> .NET primitives.
- Exposes standard Count/Get/GetAll/GetIds/Contains for
TEntity(string ids).
- Parses the connection string (supports
-
Each concrete repo overrides:
CreateDataModel(connectionString)-> creates table(s)/index(es).GetEntity(IDataReader)-> maps DB rows -> domain entity.- Plus its own CRUD/search APIs.
-
Helpers (internal):
DateTimeHelper.NormalizeDateTime-> makes DateTimes Kind=Unspecified (needed with Npgsql 8 semantics).- Extension methods for parameters, table existence, schema.table parsing, JSONB filtering, etc.
3) Serialization & JSON converters¶
Built-ins (always added)¶
JsonStringEnumConverter— round-trips enums as strings.DictionaryTypeResolverConverter<string, object>(fromDHI.Services.Converters) — deserializes nested dictionaries cleanly.PostgreSQLConverter.ObjectToInferredTypeConverter— smartly infers JSON primitives on read (bool,int,long,double,decimal,DateTime,Guid,string) and falls back toJsonElementclone when unknown.
Optional / experimental¶
ObjectArrayToInferredTypeConverter— same idea but for arrays of unknown element type (not enabled by default).
You can pass additional converters into each repository constructor;
BaseRepositorywill append them to the serializer options.
Why this matters: many repos persist metadata/data as JSON (string or JSONB). These converters let you read them back as strongly typed dictionaries without writing custom plumbing.
4) DateTime & Npgsql 8 (read this)¶
Npgsql 8 tightened timestamp semantics. To avoid surprises:
DateTimeHelper.NormalizeDateTimeforces anyDateTimeparameter to Kind=Unspecified unless it already is (it keeps values but strips the Kind).AddParameter(..., handleDateTime: true)(default) runs that normalization for you.
Guideline
- Save timestamps as UTC in your application, but send them as Unspecified to PostgreSQL when the column type is
timestamp without time zone. On read, interpret values as UTC if that’s your domain convention.
5) Connection strings & “Table=” override¶
All repositories accept a standard PostgreSQL connection string. You may optionally append a Table=... segment to override the default table:
Host=localhost;Port=5432;Database=app;Username=svc;Password=***;SSL Mode=Disable;Table=security.accounts
BaseRepository pulls Table=... out and sets:
TableName=security.accounts(else the default provided by the repo)ConnectionString= connection string without theTable=...bit
This lets you reuse the same repository type against different schemas/tables.
6) Extension methods (what you’ll use)¶
-
NpgsqlConnection.TableExists(tableName)Checkspg_tablesusingschema.table(defaults topublicif schema omitted). -
string.SplitSchemaAndTable()"public.accounts" -> ("public", "accounts");"accounts" -> ("public", "accounts"). -
NpgsqlCommand.AddParameter(NpgsqlDbType, name, value, handleDateTime=true)Adds a unique parameter name (avoids clashes), normalizes DateTime if enabled. -
QueryCondition.Validate(...)andQueryCondition.ToJsonCondition(columnName)Builds JSONB filter snippets for=,<>,>,>=,<,<=,LIKE, and arrayANY. It also constructs nested JSON paths likedata->'level1'->'level2'->>'leaf'. -
HashSet<string>.ToSqlArray()Rendersarray['a','b','c']for SQL= ANY (...)patterns.
QueryCondition/QueryOperatorlive in the shared services. You typically pass conditions down to repos that support JSONB filtering (e.g., JsonDocuments).
7) BaseRepository API (generic)¶
public abstract class BaseRepository
{
protected readonly string ConnectionString;
protected readonly ILogger Logger;
protected readonly string TableName;
protected abstract void CreateDataModel(string connectionString);
}
public abstract class BaseRepository<TEntity> : BaseRepository,
IRepository<TEntity, string>,
IDiscreteRepository<TEntity, string>
where TEntity : IEntity<string>
{
public virtual int Count(ClaimsPrincipal user = null);
public virtual bool Contains(string id, ClaimsPrincipal user = null);
public virtual IEnumerable<TEntity> GetAll(ClaimsPrincipal user = null);
public virtual IEnumerable<string> GetIds(ClaimsPrincipal user = null);
public virtual Maybe<TEntity> Get(string id, ClaimsPrincipal user = null);
protected abstract TEntity GetEntity(IDataReader reader);
}
Lifecycle
- On construction,
AssureTableExists(...)runs:- If table missing -> calls your
CreateDataModel(connectionString). - Errors go to
Logger.LogErrorand are swallowed if a logger is provided (so your app can boot even if the DB is temporarily unavailable).
- If table missing -> calls your
Serialization
_serializerOptionsused when writing JSON to DB._deserializerOptionsused when reading JSON from DB (has the required converters pre-registered).
8) Using the built-in repositories¶
All snippets assume you’ve created a valid connection string & have access to a DI container or
ServiceLocator.
8.1 Accounts (AccountRepository)¶
- Table:
public.accounts(override withTable=). - Seeds a temporary demo admin if the table is empty (id
demo_admin_to_be_deleted, passwordwebapi). - Case-insensitive lookup by
idandemail(usesLOWER()in SQL). - Password verification:
- Legacy 20-byte hash (compatible with old repos) or PBKDF2 (salted; 10000 iterations).
ExtendDataModel()on startup ensures newer columns exist (enabled, lockout, timestamps).
Register
builder.Services.AddScoped<IAccountRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.AccountRepository(pg, logger, converters: null, loginAttemptPolicy: myPolicy));
Add/Update
var repo = provider.GetRequiredService<IAccountRepository>();
var acc = new Account("alice", "Alice") { Activated = true, Email = "alice@acme.io" };
acc.SetPassword("S3cret!");
repo.Add(acc);
acc.Company = "ACME"; repo.Update(acc);
Validate
bool ok = await repo.ValidatePassword("alice", "S3cret!");
8.2 Filters (FilterRepository)¶
What it stores
id,datatype,dataconnectionid,queryconditions (text as JSON),transport_connections (text[])- BTREE index on
datatype
Register
builder.Services.AddScoped<IFilterRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.FilterRepository(
builder.Configuration.GetConnectionString("Postgres-Filters")));
Add / Remove
var filters = app.Services.GetRequiredService<IFilterRepository>();
var f = new Filter(
dataType: "timeseries",
dataConnectionId: "csv",
queryConditions: new[]
{
new QueryCondition("stationId", QueryOperator.Any, new[] { "A001", "A002" }),
new QueryCondition("date", QueryOperator.GreaterThanOrEqual, new DateTime(2024, 1, 1))
})
{
Id = "ts-stations-2024"
};
filters.Add(f);
filters.Remove("ts-stations-2024");
Query
// get all filters for a type (optionally constrained to a data connection)
var list = await filters.GetListAsync("timeseries", dataConnectionId: "csv");
// how many transports are bound to a filter?
int cnt = await filters.TransportConnectionsCountAsync("ts-stations-2024");
// which filter IDs are bound to a given transport connection?
var ids = await filters.GetIdsAsync("ui-connection-1");
Bind/unbind “transport connections” (idempotent)
await filters.AddTransportConnectionAsync("ui-connection-1", "ts-stations-2024");
await filters.DeleteTransportConnectionAsync("ui-connection-1", "ts-stations-2024");
queryconditionsare serialized withFilter.SerializerOption. Values are deserialized using an inferred-type converter (ints, doubles, bools, DateTime, etc.).
8.3 Jobs (JobRepository)¶
Register with DI (for controllers like AutomationsController)
builder.Services.AddScoped<IJobRepository<Guid, string>>(sp =>
new DHI.Services.Provider.PostgreSQL.JobRepository(
configuration.GetConnectionString("Postgres-Jobs")
?? configuration["Postgres-Jobs:ConnectionString"],
logger: sp.GetService<ILogger<DHI.Services.Provider.PostgreSQL.JobRepository>>(),
converters: DHI.Services.Jobs.WebApi.SerializerOptionsDefault.Options.Converters // or null if you don’t use these
));
Register with ServiceLocator (for connection-routed endpoints)
var pgJobs = configuration.GetConnectionString("Postgres-Jobs")
?? configuration["Postgres-Jobs:ConnectionString"];
var postgresJobRepo = new DHI.Services.Provider.PostgreSQL.JobRepository(
pgJobs,
logger: null,
converters: DHI.Services.Jobs.WebApi.SerializerOptionsDefault.Options.Converters // or null
);
// however you build your task service
var workflowTaskService = new TaskService<Workflow, string>(
new DHI.Services.Jobs.Workflows.WorkflowRepository(
"[AppData]workflows.json".Resolve(),
DHI.Services.Jobs.WebApi.SerializerOptionsDefault.Options.Converters));
ServiceLocator.Register(
new JobService<Workflow, string>(postgresJobRepo, workflowTaskService, hostRepo: null),
"wf-jobs");
Create / Update / Delete
var jobs = app.Services.GetRequiredService<IJobRepository<Guid, string>>();
var job = new Job<Guid, string>(Guid.NewGuid(), taskId: "ingest-csv", accountId: "alice")
{
HostId = "host-1",
HostGroup = "etl",
Priority = 5,
Tag = "nightly",
Progress = 0,
Status = JobStatus.Created,
StatusMessage = "Queued",
Requested = DateTime.UtcNow,
Parameters = { ["path"] = "/data/in.csv", ["delimiter"] = "," },
Metadata = { ["tenant"] = "acme" }
};
jobs.Add(job);
// update fields
job.Status = JobStatus.Running;
job.Starting = DateTime.UtcNow;
jobs.Update(job);
// delete
jobs.Remove(job.Id);
Point updates (whitelisted fields)
// heartbeat is explicitly allowed
jobs.UpdateField(job.Id, "heartbeat", DateTime.UtcNow);
Queries
// fetch by id
var maybe = jobs.Get(job.Id);
// list everything (ordered in SQL by requested index effectiveness)
var all = jobs.GetAll();
// filtered query (mix columns + JSONB parameters)
var q = new Query<Job<Guid, string>>
{
new QueryCondition("status", QueryOperator.Any, new[] { JobStatus.Running, JobStatus.Created }),
new QueryCondition("requested", QueryOperator.GreaterThanOrEqual, DateTime.UtcNow.AddDays(-1)),
// JSONB in 'parameters'
new QueryCondition("path", QueryOperator.Like, "%.csv")
};
var matches = jobs.Get(q);
// most recent matching job
var last = jobs.GetLast(q);
// remove by query
jobs.Remove(new Query<Job<Guid, string>> { new QueryCondition("tag", QueryOperator.Equal, "nightly") });
8.4 JsonDocuments (secured)¶
Register with ServiceLocator
var jsonDocumentsPostgres = configuration["Postgres-JsonDocuments:ConnectionString"];
var pgJsonRepo = new DHI.Services.Provider.PostgreSQL.JsonDocumentRepositorySecured(
jsonDocumentsPostgres,
logger: null,
converters: null // optional
);
var jsonDocService = new JsonDocumentService(pgJsonRepo);
ServiceLocator.Register(jsonDocService, "json-documents");
Add (must include at least one read permission)
Retrieve the service from the ServiceLocator (not DI).
var docsSvc = Services.Get<JsonDocumentService>("json-documents");
ClaimsPrincipal user = HttpContext.User; // must not be null
var doc = new DHI.Services.JsonDocuments.JsonDocument(
id: "customers/acme/001",
name: "ACME Customer",
group: "customers/acme",
data: @"{ ""customer"": { ""id"": ""001"", ""name"": ""ACME"", ""active"": true } }")
{
DateTime = DateTime.UtcNow,
Added = DateTime.UtcNow,
Permissions =
{
new Permission { Operation = "read", Principals = new[] { "Editors", "alice" } },
new Permission { Operation = "update", Principals = new[] { "Editors" } },
new Permission { Operation = "delete", Principals = new[] { "Administrators" } }
},
Metadata = { ["region"] = "eu" }
};
docsSvc.Repository.Add(doc, user);
Query / project / update / delete (permission-checked)
var full = docsSvc.Repository.Get("customers/acme/001", user);
// projections from JSONB "data"
var projected = pgJsonRepo.Get("customers/acme/001",
new[] { "customer.name", "customer.active" }, user);
// lists
var all = pgJsonRepo.GetAll(user);
var inGroup = pgJsonRepo.GetByGroup("customers/acme", user);
// JSONB query
var q = new Query<DHI.Services.JsonDocuments.JsonDocument>
{
new QueryCondition("groupname", QueryOperator.Like, "customers/%"),
new QueryCondition("customer.active", QueryOperator.Equal, true),
new QueryCondition("datetime", QueryOperator.GreaterThanOrEqual, DateTime.UtcNow.AddDays(-30)),
};
var recentActive = pgJsonRepo.Get(q, user: user);
// update / delete
doc.Metadata["tier"] = "gold";
doc.Updated = DateTime.UtcNow;
pgJsonRepo.Update(doc, user); // needs 'update' permission
pgJsonRepo.Remove(doc.Id, user); // needs 'delete' permission
8.5 MapStyles (MapStyleRepository)¶
What it stores
id (varchar),name (varchar),style_code (TEXT)- Primary key on
id
Register
If controllers or services resolve IMapStyleRepository from DI:
builder.Services.AddScoped<IMapStyleRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.MapStyleRepository(
configuration.GetConnectionString("Postgres-MapStyles")
?? configuration["Postgres-MapStyles:ConnectionString"]));
If you wire a map service via ServiceLocator:
var pgMapStyles = configuration["Postgres-MapStyles:ConnectionString"];
var mapStyleRepo = new DHI.Services.Provider.PostgreSQL.MapStyleRepository(pgMapStyles);
var mapStyleService = new MapStyleService(mapStyleRepo);
ServiceLocator.Register(
new GroupedMapService(new GroupedMapSource(sqliteConn), mapStyleService),
"groupedmap-mclite");
Common ops
var repo = app.Services.GetRequiredService<IMapStyleRepository>();
repo.Add(new MapStyle("osm-dark", "OpenStreetMap Dark", styleCode: "{...json...}"));
bool exists = repo.Contains("osm-dark");
var one = repo.Get("osm-dark");
var all = repo.GetAll();
var ids = repo.GetIds();
repo.Update(new MapStyle("osm-dark", "OSM Dark (v2)", styleCode: "{...json v2...}"));
repo.Remove("osm-dark");
8.6 Notifications (NotificationRepository)¶
What it stores
id (uuid),datetime (timestamp without tz),notificationlevel (int),source,tag,machinename,text- BTREE index on
datetime DESC - Supports connection-string additions:
Table=public.MessageLog(override table)Utc=true|false(incomingDateTimeconverted to UTC before storing iftrue)
Register
builder.Services.AddScoped<INotificationRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.NotificationRepository(
configuration["Postgres-Security:LogConnectionString"]));
// e.g. "...;Table=public.messagelog;Utc=true"
Write
var notif = app.Services.GetRequiredService<INotificationRepository>();
notif.Add(new NotificationEntry(
level: NotificationLevel.Information,
text: "Service started",
source:"webapi",
tag: "bootstrap",
machineName: Environment.MachineName,
dateTime: DateTime.UtcNow));
Query
var q = new[]
{
new QueryCondition("datetime", QueryOperator.GreaterThanOrEqual, DateTime.UtcNow.AddHours(-1)),
new QueryCondition("notificationlevel",QueryOperator.GreaterThanOrEqual, (int)NotificationLevel.Warning),
new QueryCondition("source", QueryOperator.Equal, "webapi"),
};
IEnumerable<NotificationEntry> recent = notif.Get(q);
var lastWarning = notif.Last(q);
8.7 PasswordHistory (PasswordHistoryRepository)¶
What it stores
id (varchar),accountid (varchar),encryptedpassword (bytea),passwordexpirydate (timestamp without tz)- BTREE index on
accountid
Register
builder.Services.AddScoped<IPasswordHistoryRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.PasswordHistoryRepository(
configuration.GetConnectionString("Postgres-Security")
?? configuration["Postgres-Security:ConnectionString"],
logger: null,
converters: DHI.Services.Security.WebApi.SerializerOptionsDefault.Options.Converters // optional
));
Typical flows
var ph = app.Services.GetRequiredService<IPasswordHistoryRepository>();
// add when user changes password
ph.Add(new PasswordHistory(id: Guid.NewGuid().ToString())
{
AccountId = "alice",
EncryptedPassword = hashedBytes,
PasswordExpiryDate = DateTime.UtcNow.AddDays(90)
});
// read history
var all = await ph.GetByAccountId("alice");
var mostRecent = await ph.GetMostRecentByAccountId("alice");
var lastN = await ph.GetRecentByAccountId("alice", limit: 3);
// update / remove (rare)
mostRecent.PasswordExpiryDate = DateTime.UtcNow.AddDays(120);
ph.Update(mostRecent);
ph.Remove(mostRecent.Id);
8.8 Places (PlaceRepository) — ServiceLocator-first (matches the sample project)¶
Tables
public.places:id,name,groupname,featureid,attributekey,attributevaluepublic.indicators: FK ->places(id); carries indicator wiring (id uuid,type,connectionid,entityid,datasourcetype,timeinterval,starttime,endtime,aggregationtype,stylecode,placeid)- Delete cascades: removing a place removes its indicators.
Register with ServiceLocator (preferred for {connectionId}-routed Web APIs)
var pgPlaces = configuration.GetConnectionString("Postgres-Places")
?? configuration["Postgres-Places:ConnectionString"];
var placeRepo = new DHI.Services.Provider.PostgreSQL.PlaceRepository(pgPlaces);
var placeSvc = new PlaceService(placeRepo, timeSeriesServices, scalarServices, gisService);
ServiceLocator.Register(placeSvc, "pg-places");
This mirrors your sample app’s pattern (ServiceLocator), but swaps the JSON repository for the PostgreSQL one.
(Optional) DI registration — only if some controller directly resolves IPlaceRepository<string>:
builder.Services.AddScoped<IPlaceRepository<string>>(_ =>
new DHI.Services.Provider.PostgreSQL.PlaceRepository(
configuration.GetConnectionString("Postgres-Places")
?? configuration["Postgres-Places:ConnectionString"]));
Common ops (via ServiceLocator)
var placesSvc = Services.Get<PlaceService>("pg-places");
var places = placesSvc.Repository; // IPlaceRepository<string>
// add a place + indicator
var featureId = new FeatureId("fc:buildings", "building_id", "B-42");
var p = new Place(id: "hq", name: "Headquarters", featureId, group: "sites/eu");
p.Indicators["temperature"] = new Indicator(
dataSource: new DataSource(DataSourceType.TimeSeries, "csv", "sensor-7"),
styleCode: "heat-legend",
timeInterval: TimeInterval.CreateRelativeToNow(-24, 0),
aggregationType: AggregationType.FromDisplayName("Average"));
places.Add(p);
// reads
bool hasId = places.Contains("hq");
bool hasGrp = places.ContainsGroup("sites/eu"); // regex ^group(/|$)
var one = places.Get("hq");
var all = places.GetAll();
var inGroup = places.GetByGroup("sites/eu");
var ids = places.GetIds();
var byType = places.GetIndicatorsByType("temperature");
var forPlace = places.GetIndicatorsByPlace("hq");
var byGroup = places.GetIndicatorsByGroup("sites/eu");
// update / remove
p.Name = "HQ (Main)";
places.Update(p); // replaces row + indicators
places.Remove("hq"); // cascades to indicators
Table overrides: this repo creates both
placesandindicators. If you need per-env schemas, supply a connection string withTable=overrides, or separate connections.
8.9 RefreshTokens (RefreshTokenRepository)¶
What it stores
id (varchar),token (varchar),accountid (varchar),expiration (timestamp without tz),clientip (varchar)- BTREE index on
expiration DESC
Register (DI)
builder.Services.AddScoped<IRefreshTokenRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.RefreshTokenRepository(
configuration.GetConnectionString("Postgres-Security")
?? configuration["Postgres-Security:ConnectionString"]));
Common ops
var tokens = app.Services.GetRequiredService<IRefreshTokenRepository>();
// create
tokens.Add(new RefreshToken(
token: "r:abc123",
accountId: "alice",
expiration: DateTime.UtcNow.AddDays(7),
clientIp: "192.0.2.10") { Id = Guid.NewGuid().ToString("N") });
// lookup
var forAlice = tokens.GetByAccount("alice");
var byToken = tokens.GetByToken("r:abc123");
// update
var t = byToken.Value;
t.Expiration = DateTime.UtcNow.AddDays(14);
tokens.Update(t);
// delete
tokens.Remove(t.Id);
Notes
NpgsqlDbType.Timestampis used forexpiration; with the shared helpers, values are normalized to Unspecified on write.
8.10 Scalars (ScalarRepository, ScalarService)¶
Tables
public.scalarswith:id,groupname,name,valuetypename,description,locked,value (text),datetime (timestamp without tz),flag (int)- BTREE index on
datetime DESC - Values are stored as text; typed reads are inferred from
valuetypename.
Register (DI)
builder.Services.AddScoped<IGroupedScalarRepository<string, int>>(_ =>
new DHI.Services.Provider.PostgreSQL.ScalarRepository(
configuration.GetConnectionString("Postgres-Scalars")
?? configuration["Postgres-Scalars:ConnectionString"]));
// Optional service wrapper if your code consumes IScalarService<,>
builder.Services.AddScoped<IScalarService<string, int>, DHI.Services.Provider.PostgreSQL.ScalarService>();
ServiceLocator (if you route by {connectionId})
var pgScalars = configuration["Postgres-Scalars:ConnectionString"];
var scalarRepo = new DHI.Services.Provider.PostgreSQL.ScalarRepository(pgScalars);
ServiceLocator.Register(new DHI.Services.Scalars.GroupedScalarService<string,int>(scalarRepo, logger: null), "pg-scalars");
Common ops
var scalars = app.Services.GetRequiredService<IGroupedScalarRepository<string, int>>();
// add (with data)
var s = new DHI.Services.Provider.PostgreSQL.Scalar(
name: "maxRetries",
valueTypeName: "System.Int32",
group: "jobs",
data: new DHI.Services.Provider.PostgreSQL.ScalarData(5, DateTime.UtcNow));
scalars.Add(s);
// read variants
var one = scalars.Get("jobs/maxRetries");
var byGroup = scalars.GetByGroup("jobs");
var all = scalars.GetAll();
bool exists = scalars.Contains("jobs/maxRetries");
bool grpHit = scalars.ContainsGroup("jobs");
// update metadata (and/or data)
s.Description = "Max retry count for job runner";
scalars.Update(s);
// point-update only the data triplet
scalars.SetData("jobs/maxRetries", new ScalarData<int>(7, DateTime.UtcNow, flag: 1));
// lock / unlock
scalars.SetLocked("jobs/maxRetries", locked: true);
// remove
scalars.Remove("jobs/maxRetries");
Notes
- When you supply
ScalarData,valueis written as text anddatetimeas timestamp; reads usevaluetypenameto rehydrate the typed value. - Group queries use a regex pattern
^group(/|$).
8.11 Scenarios (ScenarioRepository)¶
What it stores
id (varchar),version (varchar Guid),lastjobid (varchar Guid),datetime (timestamp without tz),data (JSONB),deleted (timestamp without tz)- BTREE index on
datetime DESC - Schema evolutions included on startup:
datacoerced to JSONBdeletedcolumn added IF NOT EXISTS
Register
// Repository
builder.Services.AddScoped<DHI.Services.Provider.PostgreSQL.ScenarioRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.ScenarioRepository(
configuration.GetConnectionString("Postgres-Scenarios")
?? configuration["Postgres-Scenarios:ConnectionString"]));
ServiceLocator (typical service wiring)
var pgScenarios = configuration["Postgres-Scenarios:ConnectionString"];
var pgJobs = configuration["Postgres-Jobs:ConnectionString"];
var scenarioRepo = new DHI.Services.Provider.PostgreSQL.ScenarioRepository(pgScenarios);
var jobRepo = new DHI.Services.Provider.PostgreSQL.JobRepository(pgJobs);
// ScenarioService expects a repository and (optionally) a jobs repo for cross refs
var scenarioService = new DHI.Services.Jobs.Scenarios.ScenarioService(scenarioRepo, jobRepo);
ServiceLocator.Register(scenarioService, "pg-scenarios");
Common ops
// via repo
var repo = app.Services.GetRequiredService<DHI.Services.Provider.PostgreSQL.ScenarioRepository>();
// add
repo.Add(new Scenario("model/run-001")
{
LastJobId = Guid.NewGuid(),
DateTime = DateTime.UtcNow,
Data = @"{ ""state"": ""Queued"", ""inputs"": { ""mesh"": ""dfsu-1"" } }"
});
// get single
var s1 = repo.Get("model/run-001");
// time window
var recent = repo.Get(DateTime.UtcNow.AddDays(-7), DateTime.UtcNow);
// JSONB + column query
var q = new Query<Scenario>
{
new QueryCondition("datetime", QueryOperator.GreaterThanOrEqual, DateTime.UtcNow.AddDays(-30)),
new QueryCondition("inputs.mesh", QueryOperator.Equal, "dfsu-1"), // JSONB path inside 'data'
new QueryCondition("state", QueryOperator.Any, new[] { "Queued", "Running" }) // JSONB again
};
var matches = repo.Get(q);
// update (also bumps version)
var s = s1.Value;
s.Deleted = null;
s.Data = @"{ ""state"": ""Finished"", ""resultId"": ""abc"" }";
repo.Update(s);
// soft-delete
s.Deleted = DateTime.UtcNow;
repo.Update(s);
// remove hard
repo.Remove("model/run-001");
Query rules
id,version,lastjobid-> allowEqual,NotEqual,Likedatetime,deleted-> allow comparisons (>,>=,<,<=,=,!=)- Any other field name is treated as a JSONB path into
data.
8.12 UserGroups (UserGroupRepository)¶
What it stores
id (varchar pk),name (varchar),users (text[]),metadata (varchar(2048))- BTREE index on
id DESC - On first use, if table is empty, seeds:
Administrators(containsdemo_admin_to_be_deleted)Editors(containsdemo_admin_to_be_deleted)
Register (DI)
builder.Services.AddScoped<IUserGroupRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.UserGroupRepository(
configuration.GetConnectionString("Postgres-Security")
?? configuration["Postgres-Security:ConnectionString"]));
Common ops
var groups = app.Services.GetRequiredService<IUserGroupRepository>();
// add
groups.Add(new UserGroup(
id: "Analysts",
name: "Data Analysts",
users: new HashSet<string> { "alice", "bob" })
{
Metadata = { ["area"] = "eu" }
});
// read
var all = groups.GetAll();
var one = groups.Get("Analysts");
bool has = groups.Contains("Administrators");
// update
var g = one.Value;
g.Users.Add("charlie");
g.Metadata["notes"] = "Has prod read access";
groups.Update(g);
// delete
groups.Remove("Analysts");
Notes
usersis stored as a Postgrestext[]; metadata is serialized into a short JSON string (varchar(2048)).- The repo inherits the standard
Count/Get/GetAll/GetIds/Containsfrom the base.
9) JSONB filtering patterns¶
For repos that store JSONB (e.g., JsonDocuments), you can translate structured filter conditions into SQL:
// Condition: data.customer.age >= 18 AND data.customer.active = true
var age = new QueryCondition("customer.age", QueryOperator.GreaterThanOrEqual, 18);
var active = new QueryCondition("customer.active", QueryOperator.Equal, true);
var sqlWhere = $"{age.ToJsonCondition("data")} AND {active.ToJsonCondition("data")}";
// -> ((data->'customer'->>'age')::numeric >= 18) AND ((data->'customer'->>'active')::boolean = 'True'::boolean)
Notes:
- Strings use
LIKEwhere appropriate. DateTimeemits::timestamp without time zonewith ISO formatting.- Arrays use
ANYsemantics.
10) Writing your own repository¶
- Inherit from
BaseRepository<TEntity>(orBaseRepositoryif you don’t need generic helpers). - Implement
CreateDataModel(connectionString):
protected override void CreateDataModel(string connectionString)
{
using var c = new NpgsqlConnection(connectionString);
c.Open();
var create = $@"
CREATE TABLE IF NOT EXISTS {TableName}(
id character varying(255) PRIMARY KEY,
name character varying(255) NOT NULL,
added timestamp without time zone NOT NULL,
metadata JSONB
);";
using var cmd = new NpgsqlCommand(create, c);
cmd.ExecuteNonQuery();
var index = $"CREATE INDEX IF NOT EXISTS {TableName.Split('.')[1]}_added_idx ON {TableName} USING btree (added DESC);";
new NpgsqlCommand(index, c).ExecuteNonQuery();
}
- Implement
GetEntity(IDataReader reader):
protected override MyEntity GetEntity(IDataReader r)
{
var e = new MyEntity(r["id"].ToString(), r["name"].ToString())
{
Added = r["added"] is DBNull ? default : (DateTime)r["added"]
};
if (r["metadata"] is not DBNull)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, object>>(r["metadata"].ToString(), _deserializerOptions);
foreach (var kv in dict) e.Metadata[kv.Key] = kv.Value;
}
return e;
}
- Use
AddParameterfor all dynamic values:
var sql = $"INSERT INTO {TableName}(id,name,added,metadata) VALUES(@id,@name,@added,@metadata)";
using var cmd = new NpgsqlCommand(sql, conn);
cmd.AddParameter(NpgsqlDbType.Varchar, "@id", entity.Id);
cmd.AddParameter(NpgsqlDbType.Varchar, "@name", entity.Name);
cmd.AddParameter(NpgsqlDbType.Timestamp, "@added", entity.Added); // normalized
cmd.AddParameter(NpgsqlDbType.Jsonb, "@metadata", JsonSerializer.Serialize(entity.Metadata, _serializerOptions), handleDateTime: false);
cmd.ExecuteNonQuery();
- For table/schema overrides, honor
TableNameprovided by the base.
Best practices
- Use parameters for all values (avoid injection; we already do).
- Keep
CreateDataModelidempotent (IF NOT EXISTS). - Add useful indexes (your reads will thank you).
- If you evolve schema, expose an
ExtendDataModel()likeAccountRepositorydoes.
11) Operational notes¶
- Pooling: Npgsql pools by default; the repository opens/closes per call.
- Logging: If you pass an
ILogger, startup DDL errors are logged and swallowed (so the app can boot). Without a logger, errors bubble. - Case-insensitive IDs: Some repos use
LOWER()for identifiers. Be consistent in your UI/API expectations. - Large values: Prefer JSONB columns for structured metadata; keep string columns within declared
varcharsizes (many arevarchar(2048)). - Time zones: Store UTC in your app; map to
timestamp without time zoneas Unspecified per §4.
12) Quick cheat sheets¶
Common DDL themes¶
CREATE TABLE IF NOT EXISTS schema.table ...- Primary key on
id(oruuidfor Jobs/Notifications). -
Useful indexes:
-
..._index ON table USING btree (datetime DESC); ..._index ON table USING btree (requested DESC);..._index ON table USING btree (accountid);
Typical registration¶
// Accounts
builder.Services.AddScoped<IAccountRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.AccountRepository(
builder.Configuration.GetConnectionString("Postgres-Security"),
logger: provider.GetService<ILogger<AccountRepository>>()
));
ServiceLocator (for Web APIs that route by {connectionId})¶
var jsonDocumentsPostgres = configuration["Postgres-JsonDocuments:ConnectionString"];
var JsonDocumentService = new JsonDocumentService(new DHI.Services.Provider.PostgreSQL.JsonDocumentRepositorySecured(jsonDocumentsPostgres));
ServiceLocator.Register(JsonDocumentService, "json-documents");
13) What’s implemented where (reference)¶
| Repository | Key columns / JSON usage | Indexes |
|---|---|---|
| AccountRepository | id,name,encryptedpassword,activated, lockout fields, metadata(varchar) |
name DESC |
| FilterRepository | id,datatype,dataconnectionid,queryconditions(text),transport_connections(text[]) |
datatype DESC |
| JobRepository | id(uuid), status, requested/started/finished, parameters, metadata |
requested DESC |
| JsonDocumentRepositorySecured | id,name,groupname,datetime,data(JSONB),metadata(JSONB),permissions(JSONB) |
datetime DESC |
| MapStyleRepository | id,name,style_code(TEXT) |
— |
| NotificationRepository | id(uuid),datetime,notificationlevel,source,tag,machinename,text |
datetime DESC |
| PasswordHistoryRepository | id,accountid,encryptedpassword,passwordexpirydate |
accountid |
| PlaceRepository | id,name,groupname,featureid,attributekey,attributevalue |
— |
| RefreshTokenRepository | id,token,accountid,expiration,clientip |
expiration DESC |
| ScalarRepository | id,groupname,name,valuetypename,description,locked,value(text),datetime,flag |
datetime DESC |
| ScenarioRepository | id,version,lastjobid,datetime,data(text),deleted |
datetime DESC |
| UserGroupRepository | id,name,users(text[]),metadata(varchar) |
id DESC |
14) Example end-to-end (Accounts)¶
// Program.cs
var pg = builder.Configuration.GetConnectionString("Postgres-Security");
// Optional: override table
// pg += ";Table=security.accounts";
builder.Services.AddScoped<IAccountRepository>(_ =>
new DHI.Services.Provider.PostgreSQL.AccountRepository(pg, logger));
// Usage
var app = builder.Build();
var repo = app.Services.GetRequiredService<IAccountRepository>();
var bob = new Account("bob", "Bob Ross") { Activated = true, Email = "bob@trees.tv" };
bob.SetPassword("HappyLittleTrees!");
repo.Add(bob);
var maybe = repo.Get("bob");
if (maybe.HasValue) {
var ok = await repo.ValidatePassword("bob", "HappyLittleTrees!");
// ...
}