VEU.WathBody/3-app/1-lib/Kit.Helpers/SqlHelperAbstract/Test/SqlHelperTest.cs

260 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Npgsql.NameTranslation;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
namespace Kit.Helpers.SqlHelperAbstract
{
/// <summary>
/// Подмена ISqlHelper без БД — тестовое окружение репозиториев (ассерты снаружи + ожидания внутри фейка).
/// Внутренний тип: единственный вход в тестовое API — IConnectionStringTest / ConnectionStringTest
/// (снаружи сборки недостижим, в том числе через new — прод-код не может подменить себе транспорт по ошибке).
/// Конвенция 1:1: одна заготовка = один вызов транспорта = один тестируемый метод репозитория.
/// - заготовка собирается снаружи: expArgs (PrepareExecute(...).AddParameter(...).BuildParams();
/// беззначная заявка — маркер ParamExpect.Any) + таблицы DataTable (по одной на курсор; колонки
/// типизированы, значения — в CLR-типах Npgsql) либо скалярный результат (Scalar);
/// ответ обязан быть описан, иначе вызов транспорта падает;
/// - вызов транспорта сверяет CommandText, IsStoredProcedure и строгий набор имён параметров
/// в обе стороны (SqlHelperTestException при расхождении); второй вызов транспорта — ошибка;
/// - выборка исполняется по-настоящему: DataTableReader + DataRecordSpecified +
/// NpgsqlSnakeCaseNameTranslator (по курсору на конвертер, строки построчно в общий список,
/// хвост Page/Offer читается ProcessExtraSelect после последнего конвертера);
/// - ноль вызовов ловит VerifyAll(); журнал запросов (Requests) — для ассертов снаружи.
/// </summary>
internal sealed class SqlHelperTest : ISqlHelper
{
private readonly RequestParams _requestParams;
private readonly DataTable[] _resultTables;
private readonly object? _scalarResult;
private readonly List<RequestParams> _requests = new();
private bool _consumed;
public SqlHelperTest(RequestParams requestParams, DataTable[] resultTables, object? scalarResult)
{
_requestParams = requestParams ?? throw new ArgumentNullException(nameof(requestParams));
_resultTables = resultTables ?? Array.Empty<DataTable>();
_scalarResult = scalarResult;
}
#region Журнал запросов (ассерты снаружи)
/// <summary>Снапшоты всех запросов к транспорту, в порядке вызовов.</summary>
internal IReadOnlyList<RequestParams> Requests => _requests;
/// <summary>Падает, если открытая заготовка не израсходована: репозиторий не вызвал транспорт.</summary>
internal void VerifyAll()
{
if (HasResponse && !_consumed)
throw new SqlHelperTestException(
$"Заготовка \"{_requestParams.CommandText}\" не израсходована: репозиторий не вызвал транспорт (ожидался один вызов — конвенция 1:1).");
}
/// <summary>Ответ описан: хотя бы одна таблица курсоров или скалярный результат.</summary>
private bool HasResponse => _resultTables.Length > 0 || _scalarResult != null;
#endregion
#region ISqlHelper
public void ExecuteNonQuery(RequestParams requestParams) => TakeCall(requestParams);
/// <summary>Возвращает заготовленное значение; жёсткий каст — как в проде.</summary>
public object ExecuteScalar(RequestParams requestParams) => TakeCall(requestParams).ScalarResult!;
public TResult ExecuteScalar<TResult>(RequestParams requestParams) =>
(TResult)TakeCall(requestParams).ScalarResult!;
public IEnumerable<TEntity> ExecuteSelectMany<TEntity>(RequestParamsSelect<TEntity> requestParams) =>
ExecuteSelectMany(SelectType.Default, requestParams);
public IEnumerableWithPage<TEntity> ExecuteSelectManyWithPage<TEntity>(RequestParamsSelect<TEntity> requestParams) =>
(IEnumerableWithPage<TEntity>)ExecuteSelectMany(SelectType.Page, requestParams);
public IEnumerableWithOffer<TEntity> ExecuteSelectManyWithOffer<TEntity>(RequestParamsSelect<TEntity> requestParams) =>
(IEnumerableWithOffer<TEntity>)ExecuteSelectMany(SelectType.Offer, requestParams);
#endregion
private IEnumerable<TEntity> ExecuteSelectMany<TEntity>(SelectType extraSelectType, RequestParamsSelect<TEntity> requestParams)
{
if (requestParams.Converters == null || !requestParams.Converters.Any())
throw new ArgumentNullException("converters", "Список конвертеров пуст.");
DataTable[] resultSets = TakeCall(requestParams).ResultTables;
int convertersCount = requestParams.Converters.Count();
int requiredCursors = convertersCount + (extraSelectType == SelectType.Default ? 0 : 1);
if (resultSets.Length < requiredCursors)
throw new SqlHelperTestException(
$"Количество конвертеров больше количества возвращаемых курсоров: конвертеров {convertersCount}" +
(extraSelectType == SelectType.Default ? "" : $" (+ хвост {extraSelectType})") +
$", а заготовок курсоров {resultSets.Length} ({Describe(requestParams)}).");
IList<TEntity> selectResult = extraSelectType.CreateList<TEntity>();
// Как ProcessForSimple: по курсору (DataTable) на конвертер, строки подаются построчно,
// хвост Page/Offer читается ProcessExtraSelect после последнего конвертера.
Npgsql.INpgsqlNameTranslator translator = new NpgsqlSnakeCaseNameTranslator();
int cursorIndex = 0;
foreach (Action<IDataRecordSpecified, IList<TEntity>> converter in requestParams.Converters)
{
using DataTableReader reader = resultSets[cursorIndex++].CreateDataReader();
IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, translator);
while (reader.Read())
converter(recordSpecified, selectResult);
}
if (extraSelectType != SelectType.Default)
{
using DataTableReader reader = resultSets[cursorIndex].CreateDataReader();
IDataRecordSpecified recordSpecified = new DataRecordSpecified(reader, translator);
recordSpecified.ProcessExtraSelect(selectResult, extraSelectType);
}
return selectResult;
}
private sealed class CallSetup
{
public DataTable[] ResultTables = Array.Empty<DataTable>();
public object? ScalarResult;
}
private CallSetup TakeCall(RequestParams requestParams)
{
if (!HasResponse)
throw new SqlHelperTestException(
$"Транспорт вызван без заготовленного ответа ({Describe(requestParams)}). " +
"Опишите ответ: таблицы DataTable (курсоры выборки) или скалярный результат — ConnectionStringTest.Scalar(...).");
if (_consumed)
throw new SqlHelperTestException(
$"Повторный вызов транспорта ({Describe(requestParams)}): по конвенции 1:1 одна заготовка обслуживает ровно один вызов.");
AssertExpected(requestParams);
_consumed = true;
_requests.Add(Snapshot(requestParams));
return new CallSetup { ResultTables = _resultTables, ScalarResult = _scalarResult };
}
/// <summary>compare(repArgs, expArgs): сверяет фактические аргументы вызова с заготовкой.</summary>
private void AssertExpected(RequestParams requestParams)
{
List<string> errors = new();
if (!string.Equals(_requestParams.CommandText, requestParams.CommandText, StringComparison.Ordinal))
errors.Add($"CommandText: ожидалось \"{_requestParams.CommandText}\", фактически \"{requestParams.CommandText}\"");
if (_requestParams.IsStoredProcedure != requestParams.IsStoredProcedure)
errors.Add($"IsStoredProcedure: ожидалось {_requestParams.IsStoredProcedure}, фактически {requestParams.IsStoredProcedure}");
Dictionary<string, object?> actual = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, object> parameter in requestParams.Parameters ?? Enumerable.Empty<KeyValuePair<string, object>>())
actual[parameter.Key] = parameter.Value; // дубликаты имён: остаётся последнее значение
Dictionary<string, object?> declared = new(StringComparer.Ordinal);
foreach (KeyValuePair<string, object> parameter in _requestParams.Parameters ?? Enumerable.Empty<KeyValuePair<string, object>>())
declared[parameter.Key] = parameter.Value;
// Значения/наличие заявленных параметров (ParamExpect.Any — только наличие).
foreach (KeyValuePair<string, object?> expectation in declared)
{
if (!actual.TryGetValue(expectation.Key, out object? value))
errors.Add($"{expectation.Key}: параметр не передан, ожидалось {DescribeExpectation(expectation.Value)}");
else if (!ReferenceEquals(expectation.Value, ParamExpect.Any) && !ValueEquals(expectation.Value, value))
errors.Add($"{expectation.Key}: ожидалось {DisplayValue(expectation.Value)}, фактически {DisplayValue(value)}");
}
// Строгий набор имён в обе стороны: незаявленные фактические параметры — ошибка.
foreach (KeyValuePair<string, object?> parameter in actual)
if (!declared.ContainsKey(parameter.Key))
errors.Add($"{parameter.Key}: незаявленный параметр ({DisplayValue(parameter.Value)}); заявлены: {string.Join(", ", declared.Keys)}");
if (errors.Count > 0)
throw new SqlHelperTestException(
$"Вызов — аргументы не совпали с ожидаемыми ({Describe(requestParams)}):\n - "
+ string.Join("\n - ", errors));
}
/// <summary>Точное сравнение: null и DBNull взаимозаменяемы, перечисления (массивы) — поэлементно.</summary>
internal static bool ValueEquals(object? expected, object? actual)
{
bool expectedEmpty = expected is null or DBNull;
bool actualEmpty = actual is null or DBNull;
if (expectedEmpty || actualEmpty)
return expectedEmpty && actualEmpty;
if (expected is string expectedString && actual is string actualString)
return string.Equals(expectedString, actualString, StringComparison.Ordinal);
if (expected is IEnumerable expectedItems && actual is IEnumerable actualItems)
{
IEnumerator expectedEnumerator = expectedItems.GetEnumerator();
IEnumerator actualEnumerator = actualItems.GetEnumerator();
try
{
while (expectedEnumerator.MoveNext())
{
if (!actualEnumerator.MoveNext())
return false;
if (!ValueEquals(expectedEnumerator.Current, actualEnumerator.Current))
return false;
}
return !actualEnumerator.MoveNext();
}
finally
{
(expectedEnumerator as IDisposable)?.Dispose();
(actualEnumerator as IDisposable)?.Dispose();
}
}
return expected!.Equals(actual);
}
private static string DescribeExpectation(object? value) =>
ReferenceEquals(value, ParamExpect.Any) ? "наличие (значение не проверяется)" : DisplayValue(value);
/// <summary>Копия запроса с материализованными параметрами (отложенные перечисления не протухают).</summary>
private static RequestParams Snapshot(RequestParams r) => new(
r.ConnectionString, r.CommandText, r.IsStoredProcedure, r.WithStrictSyntax,
r.Parameters?.ToList() ?? new List<KeyValuePair<string, object>>(),
r.CustomTypes?.ToList() ?? new List<CustomType>());
private static string Describe(RequestParams r)
{
StringBuilder sb = new(r.CommandText);
sb.Append('(');
bool first = true;
foreach (KeyValuePair<string, object> parameter in r.Parameters ?? Enumerable.Empty<KeyValuePair<string, object>>())
{
sb.Append(first ? "" : ", ").Append(parameter.Key).Append('=').Append(DisplayValue(parameter.Value));
first = false;
}
sb.Append(')');
return sb.ToString();
}
private static string DisplayValue(object? value) => value switch
{
null => "null",
DBNull => "DBNull",
string s => $"\"{s}\"",
IEnumerable items when value is not string => "[" + string.Join(", ", items.Cast<object?>().Select(DisplayValue)) + "]",
_ => value.ToString() ?? "",
};
}
/// <summary>Нарушение дисциплины тестовой заготовки SqlHelperTest (слот/ответ/аргументы/курсоры).</summary>
public sealed class SqlHelperTestException : Exception
{
public SqlHelperTestException(string message) : base(message) { }
}
}