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
{
///
/// Подмена 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) — для ассертов снаружи.
///
internal sealed class SqlHelperTest : ISqlHelper
{
private readonly RequestParams _requestParams;
private readonly DataTable[] _resultTables;
private readonly object? _scalarResult;
private readonly List _requests = new();
private bool _consumed;
public SqlHelperTest(RequestParams requestParams, DataTable[] resultTables, object? scalarResult)
{
_requestParams = requestParams ?? throw new ArgumentNullException(nameof(requestParams));
_resultTables = resultTables ?? Array.Empty();
_scalarResult = scalarResult;
}
#region Журнал запросов (ассерты снаружи)
/// Снапшоты всех запросов к транспорту, в порядке вызовов.
internal IReadOnlyList Requests => _requests;
/// Падает, если открытая заготовка не израсходована: репозиторий не вызвал транспорт.
internal void VerifyAll()
{
if (HasResponse && !_consumed)
throw new SqlHelperTestException(
$"Заготовка \"{_requestParams.CommandText}\" не израсходована: репозиторий не вызвал транспорт (ожидался один вызов — конвенция 1:1).");
}
/// Ответ описан: хотя бы одна таблица курсоров или скалярный результат.
private bool HasResponse => _resultTables.Length > 0 || _scalarResult != null;
#endregion
#region ISqlHelper
public void ExecuteNonQuery(RequestParams requestParams) => TakeCall(requestParams);
/// Возвращает заготовленное значение; жёсткий каст — как в проде.
public object ExecuteScalar(RequestParams requestParams) => TakeCall(requestParams).ScalarResult!;
public TResult ExecuteScalar(RequestParams requestParams) =>
(TResult)TakeCall(requestParams).ScalarResult!;
public IEnumerable ExecuteSelectMany(RequestParamsSelect requestParams) =>
ExecuteSelectMany(SelectType.Default, requestParams);
public IEnumerableWithPage ExecuteSelectManyWithPage(RequestParamsSelect requestParams) =>
(IEnumerableWithPage)ExecuteSelectMany(SelectType.Page, requestParams);
public IEnumerableWithOffer ExecuteSelectManyWithOffer(RequestParamsSelect requestParams) =>
(IEnumerableWithOffer)ExecuteSelectMany(SelectType.Offer, requestParams);
#endregion
private IEnumerable ExecuteSelectMany(SelectType extraSelectType, RequestParamsSelect 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 selectResult = extraSelectType.CreateList();
// Как ProcessForSimple: по курсору (DataTable) на конвертер, строки подаются построчно,
// хвост Page/Offer читается ProcessExtraSelect после последнего конвертера.
Npgsql.INpgsqlNameTranslator translator = new NpgsqlSnakeCaseNameTranslator();
int cursorIndex = 0;
foreach (Action> 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();
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 };
}
/// compare(repArgs, expArgs): сверяет фактические аргументы вызова с заготовкой.
private void AssertExpected(RequestParams requestParams)
{
List 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 actual = new(StringComparer.Ordinal);
foreach (KeyValuePair parameter in requestParams.Parameters ?? Enumerable.Empty>())
actual[parameter.Key] = parameter.Value; // дубликаты имён: остаётся последнее значение
Dictionary declared = new(StringComparer.Ordinal);
foreach (KeyValuePair parameter in _requestParams.Parameters ?? Enumerable.Empty>())
declared[parameter.Key] = parameter.Value;
// Значения/наличие заявленных параметров (ParamExpect.Any — только наличие).
foreach (KeyValuePair 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 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));
}
/// Точное сравнение: null и DBNull взаимозаменяемы, перечисления (массивы) — поэлементно.
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);
/// Копия запроса с материализованными параметрами (отложенные перечисления не протухают).
private static RequestParams Snapshot(RequestParams r) => new(
r.ConnectionString, r.CommandText, r.IsStoredProcedure, r.WithStrictSyntax,
r.Parameters?.ToList() ?? new List>(),
r.CustomTypes?.ToList() ?? new List());
private static string Describe(RequestParams r)
{
StringBuilder sb = new(r.CommandText);
sb.Append('(');
bool first = true;
foreach (KeyValuePair parameter in r.Parameters ?? Enumerable.Empty>())
{
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