using System.Data; using System.Diagnostics; using Dapper; using Microsoft.Data.SqlClient; namespace CareFix.Core; /// /// Every operation CareFix performs on a hospital database. Used by the server (direct channel) /// and by the on-prem agent, so both enforce exactly the same limits. /// Reads run as the read-only login; writes only through carefix.usp_CF_UpdateRow as the write login. /// public sealed class SqlOps(string readConnectionString, string writeConnectionString, SqlLimits limits) { public static string BuildConnectionString(string server, int port, string database, string user, string password, bool encrypt, bool trustServerCert, string appName) => new SqlConnectionStringBuilder { DataSource = $"{server},{port}", InitialCatalog = database, UserID = user, Password = password, Encrypt = encrypt ? SqlConnectionEncryptOption.Mandatory : SqlConnectionEncryptOption.Optional, TrustServerCertificate = trustServerCert, ApplicationName = appName, ConnectTimeout = 15, }.ConnectionString; private static async Task OpenAsync(string cs, CancellationToken ct) { var conn = new SqlConnection(cs); try { await conn.OpenAsync(ct); } catch (SqlException ex) { await conn.DisposeAsync(); throw new OpsException(502, $"Could not connect to the hospital database: {ex.Message}"); } return conn; } private async Task PrepareReadSessionAsync(SqlConnection conn, CancellationToken ct) { await using var pre = new SqlCommand($"SET LOCK_TIMEOUT {limits.LockTimeoutMs}; SET TRANSACTION ISOLATION LEVEL READ COMMITTED;", conn); await pre.ExecuteNonQueryAsync(ct); } public async Task RunSelectAsync(string sql, CancellationToken ct) { await using var conn = await OpenAsync(readConnectionString, ct); await PrepareReadSessionAsync(conn, ct); var sw = Stopwatch.StartNew(); await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = limits.SelectTimeoutSeconds }; var rows = new List(); var truncated = false; List cols = []; try { await using var reader = await cmd.ExecuteReaderAsync(ct); cols = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToList(); while (await reader.ReadAsync(ct)) { if (rows.Count >= limits.MaxRowsPerSelect) { truncated = true; cmd.Cancel(); break; } var vals = new object?[reader.FieldCount]; for (var i = 0; i < vals.Length; i++) vals[i] = Normalize(reader.IsDBNull(i) ? null : reader.GetValue(i)); rows.Add(vals); } } catch (SqlException ex) when (!truncated) { throw new OpsException(400, ex.Number switch { -2 => $"The query timed out after {limits.SelectTimeoutSeconds} seconds. Filter on indexed identifiers (bill no, UHID, IPD no) or a narrow date range.", 1222 => "The rows are locked by the hospital's live work right now. Try again in a minute.", _ => "SQL error: " + ex.Message, }); } catch (SqlException) when (truncated) { // Expected: the command was cancelled on purpose after the row cap. } return new QueryResult(cols, rows, truncated, (int)sw.ElapsedMilliseconds); } public async Task AnyRowsAsync(string sql, string pkValue, CancellationToken ct) { await using var conn = await OpenAsync(readConnectionString, ct); await PrepareReadSessionAsync(conn, ct); await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = limits.SelectTimeoutSeconds }; cmd.Parameters.Add(new SqlParameter("@PkValue", SqlDbType.NVarChar, 200) { Value = pkValue }); try { await using var reader = await cmd.ExecuteReaderAsync(ct); return await reader.ReadAsync(ct); } catch (SqlException ex) { throw new OpsException(400, "Lock check failed: " + ex.Message); } } private sealed record GetValueRow(int RowCount, string? Value, bool IsNull); public async Task GetValueAsync(string table, string pkColumn, string pkValue, string column, CancellationToken ct) { await using var conn = await OpenAsync(readConnectionString, ct); try { var r = await conn.QuerySingleAsync(new CommandDefinition("carefix.usp_CF_GetValue", new { TableName = table, PkColumn = pkColumn, PkValue = pkValue, ColumnName = column }, commandType: CommandType.StoredProcedure, commandTimeout: limits.SelectTimeoutSeconds, cancellationToken: ct)); return new ValueRead(r.RowCount, r.IsNull ? null : r.Value); } catch (SqlException ex) when (ex.Number is >= 51000 and < 52000) { throw new OpsException(400, ex.Message); } catch (SqlException ex) when (ex.Number == 2812) { throw new OpsException(400, "CareFix procedures are not installed on this hospital database. Run sql/02_hospital_setup.sql."); } } public async Task?> GetRowAsync(string table, string pkColumn, string pkValue, CancellationToken ct) { await using var conn = await OpenAsync(readConnectionString, ct); await PrepareReadSessionAsync(conn, ct); var sql = $"SELECT TOP (2) * FROM dbo.{Q(table)} WHERE {Q(pkColumn)} = @pk"; var rows = (await conn.QueryAsync(new CommandDefinition(sql, new { pk = pkValue }, commandTimeout: limits.SelectTimeoutSeconds, cancellationToken: ct))).ToList(); if (rows.Count != 1) return null; var dict = (IDictionary)rows[0]; return dict.ToDictionary(kv => kv.Key, kv => Normalize(kv.Value is DBNull ? null : kv.Value)); } public async Task ApplyStepsAsync(IReadOnlyList steps, string ticketRef, CancellationToken ct) { await using var conn = await OpenAsync(writeConnectionString, ct); await using var tx = (SqlTransaction)await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct); try { var total = 0; foreach (var st in steps) { var rc = await conn.ExecuteScalarAsync(new CommandDefinition("carefix.usp_CF_UpdateRow", new { TableName = st.Table, PkColumn = st.PkColumn, PkValue = st.PkValue, ColumnName = st.Column, ExpectedOld = st.ExpectedOld, ExpectedOldIsNull = st.ExpectedOld is null, NewValue = st.NewValue, NewValueIsNull = st.NewValue is null, TicketRef = ticketRef, }, tx, 30, CommandType.StoredProcedure, cancellationToken: ct)); if (rc != 1) throw new OpsException(409, $"{st.Table}.{st.Column} for {st.PkColumn}={st.PkValue} affected {rc} rows instead of 1."); total += rc; } await tx.CommitAsync(ct); return total; } catch (Exception ex) { try { await tx.RollbackAsync(CancellationToken.None); } catch { /* already rolled back by XACT_ABORT */ } if (ex is SqlException sql) throw new OpsException(409, (sql.Number is >= 51000 and < 52000 ? sql.Message : "SQL error: " + sql.Message) + " Nothing was changed."); if (ex is OpsException ops) throw new OpsException(409, ops.Message + " Nothing was changed."); throw; } } public async Task> ReadSchemaAsync(CancellationToken ct) { const string sql = """ SELECT c.TABLE_SCHEMA AS SchemaName, c.TABLE_NAME AS TableName, c.COLUMN_NAME AS ColumnName, c.DATA_TYPE AS DataType, CAST(CASE WHEN c.IS_NULLABLE = 'YES' THEN 1 ELSE 0 END AS BIT) AS IsNullable, CAST(CASE WHEN k.COLUMN_NAME IS NULL THEN 0 ELSE 1 END AS BIT) AS IsPk FROM INFORMATION_SCHEMA.COLUMNS c JOIN INFORMATION_SCHEMA.TABLES t ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME LEFT JOIN (SELECT ku.TABLE_SCHEMA, ku.TABLE_NAME, ku.COLUMN_NAME FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE ku ON ku.CONSTRAINT_NAME = tc.CONSTRAINT_NAME AND ku.TABLE_SCHEMA = tc.TABLE_SCHEMA WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY') k ON k.TABLE_SCHEMA = c.TABLE_SCHEMA AND k.TABLE_NAME = c.TABLE_NAME AND k.COLUMN_NAME = c.COLUMN_NAME WHERE c.TABLE_SCHEMA = 'dbo' AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') """; await using var conn = await OpenAsync(readConnectionString, ct); return (await conn.QueryAsync(new CommandDefinition(sql, commandTimeout: 120, cancellationToken: ct))).ToList(); } public async Task> TestAsync(CancellationToken ct) { string? readDb, version; bool procsReadable, procsWritable; await using (var ro = await OpenAsync(readConnectionString, ct)) { readDb = await ro.ExecuteScalarAsync("SELECT DB_NAME()"); version = await ro.ExecuteScalarAsync("SELECT CAST(SERVERPROPERTY('ProductVersion') AS NVARCHAR(50))"); procsReadable = await ro.ExecuteScalarAsync("SELECT OBJECT_ID('carefix.usp_CF_GetValue')") is not null; } await using (var rw = await OpenAsync(writeConnectionString, ct)) { procsWritable = await rw.ExecuteScalarAsync("SELECT OBJECT_ID('carefix.usp_CF_UpdateRow')") is not null; } return new Dictionary { ["database"] = readDb, ["sqlServerVersion"] = version, ["readLoginOk"] = true, ["writeLoginOk"] = true, ["getValueProcedure"] = procsReadable, ["updateProcedure"] = procsWritable, ["ready"] = procsReadable && procsWritable, }; } public static string Q(string identifier) => "[" + identifier.Replace("]", "]]") + "]"; private static object? Normalize(object? v) => v switch { null => null, DateTime dt => dt.ToString("yyyy-MM-ddTHH:mm:ss.fff"), DateTimeOffset dto => dto.ToString("yyyy-MM-ddTHH:mm:ss.fffzzz"), TimeSpan ts => ts.ToString(), byte[] b => $"[binary {b.Length} bytes]", Guid g => g.ToString(), _ => v, }; }