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().Select(DisplayValue)) + "]", _ => value.ToString() ?? "", }; } /// Нарушение дисциплины тестовой заготовки SqlHelperTest (слот/ответ/аргументы/курсоры). public sealed class SqlHelperTestException : Exception { public SqlHelperTestException(string message) : base(message) { } } }