260 lines
15 KiB
C#
260 lines
15 KiB
C#
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) { }
|
||
}
|
||
}
|
||
|