feat(historian): SQL Server backend skeleton - schema DDL, locked bootstrapper, options, connection/mapper/tag-catalog helpers, status consts, env-gated fixture

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-09 12:52:15 -04:00
parent ff7c6c531a
commit 17962d4233
19 changed files with 1187 additions and 0 deletions
@@ -0,0 +1,54 @@
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <summary>
/// Options for the SQL Server historian backend (the <c>ServerHistorian:SqlServer</c> config block).
/// </summary>
public sealed partial class SqlServerHistorianOptions
{
/// <summary>Required. ADO.NET connection string for the historian database (never the ConfigDb).</summary>
public string ConnectionString { get; set; } = "";
/// <summary>SQL schema holding the historian objects. Spliced into DDL/DML, so it is regex-validated.</summary>
public string Schema { get; set; } = "hist";
/// <summary>Per-command timeout. Must be positive.</summary>
public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// <c>true</c> creates the schema and applies missing scripts on first use; <c>false</c> only verifies
/// that schema version 1 exists and fails every historian call with a clear error otherwise.
/// </summary>
public bool AutoCreateSchema { get; set; } = true;
[GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]{0,127}$")]
private static partial Regex IdentifierRegex();
/// <summary>True when <paramref name="schema"/> is a safe SQL identifier.</summary>
/// <param name="schema">Candidate schema name.</param>
/// <returns>Whether it matches <c>^[A-Za-z_][A-Za-z0-9_]{0,127}$</c>.</returns>
public static bool IsValidSchemaName(string? schema) => schema is not null && IdentifierRegex().IsMatch(schema);
/// <summary>Validates the options without touching SQL.</summary>
/// <returns>One message per problem; empty when valid.</returns>
public IReadOnlyList<string> Validate()
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(ConnectionString))
errors.Add("ServerHistorian:SqlServer:ConnectionString is required.");
if (!IsValidSchemaName(Schema))
errors.Add("ServerHistorian:SqlServer:Schema must be a SQL identifier matching ^[A-Za-z_][A-Za-z0-9_]{0,127}$.");
if (CommandTimeout <= TimeSpan.Zero)
errors.Add("ServerHistorian:SqlServer:CommandTimeout must be greater than zero.");
return errors;
}
}
/// <summary>Thrown when <c>AutoCreateSchema=false</c> and the historian schema is absent or behind.</summary>
public sealed class HistorianSchemaMissingException : Exception
{
/// <summary>Creates the exception.</summary>
/// <param name="message">Message naming the missing version.</param>
public HistorianSchemaMissingException(string message) : base(message) { }
}