Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlHistorianConnection.cs
T

131 lines
5.9 KiB
C#

using System.Data;
using Microsoft.Data.SqlClient;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer;
/// <summary>
/// Connection + command helpers shared by every SQL historian component.
/// <para>Usage contract for writers/readers: call <see cref="SqlServerSchemaBootstrapper.EnsureAsync"/>
/// once (it is cheap after the first success), then <see cref="OpenAsync"/>. Build SQL with <see cref="Q"/>
/// for every object name and bind every timestamp through <see cref="AddDateTime2"/> or a TVP built by
/// <see cref="SampleRowMapper"/> — never a <c>DataTable</c> (it infers 3.33 ms <c>datetime</c>).</para>
/// </summary>
internal sealed class SqlHistorianConnection
{
private static readonly int[] TransientNumbers =
[1205, -2, 40613, 40197, 40501, 49918, 4060, 233, 10053, 10054, 10060, 11001];
private readonly string _connectionString;
/// <summary>Creates the helper; options must already be valid.</summary>
/// <param name="options">Validated options.</param>
public SqlHistorianConnection(SqlServerHistorianOptions options)
{
ArgumentNullException.ThrowIfNull(options);
if (!SqlServerHistorianOptions.IsValidSchemaName(options.Schema))
throw new ArgumentException("Invalid schema name.", nameof(options));
_connectionString = options.ConnectionString;
Schema = options.Schema;
CommandTimeoutSeconds = Math.Max(1, (int)Math.Ceiling(options.CommandTimeout.TotalSeconds));
}
/// <summary>The validated schema name.</summary>
public string Schema { get; }
/// <summary>Command timeout applied by <see cref="CreateCommand"/>, in seconds.</summary>
public int CommandTimeoutSeconds { get; }
/// <summary>Schema-qualifies an object name: <c>[schema].[obj]</c>.</summary>
/// <param name="obj">Unquoted object name (a fixed literal from this project, never user input).</param>
/// <returns>The quoted two-part name.</returns>
public string Q(string obj) => $"[{Schema}].[{obj}]";
/// <summary>Opens a new connection (no bootstrap; see the class remarks).</summary>
/// <param name="ct">Cancellation.</param>
/// <returns>An open connection the caller disposes.</returns>
public async Task<SqlConnection> OpenAsync(CancellationToken ct)
{
var conn = new SqlConnection(_connectionString);
try
{
await conn.OpenAsync(ct).ConfigureAwait(false);
return conn;
}
catch
{
await conn.DisposeAsync().ConfigureAwait(false);
throw;
}
}
/// <summary>Creates a text command with the configured timeout.</summary>
/// <param name="conn">Open connection.</param>
/// <param name="sql">Command text.</param>
/// <param name="tx">Optional transaction.</param>
/// <returns>The command.</returns>
public SqlCommand CreateCommand(SqlConnection conn, string sql, SqlTransaction? tx = null)
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
cmd.CommandTimeout = CommandTimeoutSeconds;
cmd.Transaction = tx;
return cmd;
}
/// <summary>Adds a <c>datetime2(7)</c> scalar parameter (full 100 ns tick precision, UTC kind).</summary>
/// <param name="cmd">Target command.</param>
/// <param name="name">Parameter name including <c>@</c>.</param>
/// <param name="value">Timestamp; treated as UTC.</param>
/// <returns>The added parameter.</returns>
public static SqlParameter AddDateTime2(SqlCommand cmd, string name, DateTime value)
{
var p = new SqlParameter(name, SqlDbType.DateTime2)
{
Scale = 7,
Value = DateTime.SpecifyKind(value, DateTimeKind.Utc),
};
cmd.Parameters.Add(p);
return p;
}
/// <summary>Adds a table-valued parameter of one of this schema's table types.</summary>
/// <param name="cmd">Target command.</param>
/// <param name="name">Parameter name including <c>@</c>.</param>
/// <param name="typeName">Unqualified type name (<c>SampleRow</c> or <c>TimeRow</c>).</param>
/// <param name="rows">Non-empty <c>SqlDataRecord</c> sequence (SqlClient rejects an empty one — skip the call).</param>
/// <returns>The added parameter.</returns>
public SqlParameter AddStructured(SqlCommand cmd, string name, string typeName,
IEnumerable<Microsoft.Data.SqlClient.Server.SqlDataRecord> rows)
{
var p = new SqlParameter(name, SqlDbType.Structured) { TypeName = Q(typeName), Value = rows };
cmd.Parameters.Add(p);
return p;
}
/// <summary>Reads a <c>datetime2</c> column as a UTC <see cref="DateTime"/> (the driver returns Unspecified).</summary>
/// <param name="reader">Reader positioned on a row.</param>
/// <param name="ordinal">Column ordinal.</param>
/// <returns>UTC timestamp.</returns>
public static DateTime GetUtc(SqlDataReader reader, int ordinal) =>
DateTime.SpecifyKind(reader.GetDateTime(ordinal), DateTimeKind.Utc);
/// <summary>True for connection/timeout/deadlock/throttling errors worth retrying.</summary>
/// <param name="ex">Exception from a SQL call.</param>
/// <returns>Whether a retry may succeed.</returns>
public static bool IsTransient(Exception ex)
{
if (ex is TimeoutException) return true;
if (ex is not SqlException sql) return false;
if (sql.IsTransient) return true;
foreach (SqlError e in sql.Errors)
if (Array.IndexOf(TransientNumbers, e.Number) >= 0) return true;
return Array.IndexOf(TransientNumbers, sql.Number) >= 0;
}
/// <summary>True when <paramref name="ex"/> is a unique-key violation (2627/2601): "already present".</summary>
/// <param name="ex">Exception from a SQL call.</param>
/// <returns>Whether it is a duplicate-key error.</returns>
public static bool IsDuplicateKey(Exception ex) =>
ex is SqlException sql && sql.Errors.Cast<SqlError>().Any(e => e.Number is 2627 or 2601);
}