Трансляция запросов в SQL с использованием LinqToSql в тестах

    Мы уже несколько лет делаем наш продукт автоматизации маркетинга, и пилить фичи с высокой скоростью нам помогает CI, а точнее — большое количество автоматических тестов.

    В продукте примерно 700 000 строк кода со всеми кастомизациями, и на это всё мы имеем около 7 000 тестов, и их количество постоянно растёт. За счет них мы не боимся совершать большие рефакторинги, затрагивающие многие части системы. Но, к сожалению, тесты не панацея. Каких-то тестов может не быть, какие-то тесты могут оказаться слишком дорогими, а какие-то ситуации не воспроизводятся в тестовой среде.

    Практически каждая транзакция в нашей системе связана с работой с MS SQL с использованием LinqToSql. Да, технология старенькая, но мигрировать с неё нам довольно сложно, и по бизнесу она нас вполне устраивает. Более того, как я уже писал раньше, у нас даже есть свой форк LinqToSql, где мы чуть-чуть чиним его баги и добавляем кое-какой функциональности.

    Для того, чтобы делать запросы к БД, используя LinqToSql, нужно использовать интерфейс IQueryable. В момент получения Enumerator’а или выполнения Execute у QueryProvider’а построенное дерево выражений с помощью Extension-методов к IQueryable транслируется в SQL, который и выполняется на SQL Server.

    Так как наша бизнес-логика сильно завязана на сущностях в базе данных, наши тесты много работают с базой данных. Однако в 95% тестов мы не используем реальную базу, так как это очень дорого по времени, а довольствуемся InMemoryDatabase. Она является частью нашей тестовой инфраструктуры, о которой можно написать отдельную статью, и на самом деле представляет из себя просто Dictionary<Type, List> для каждого существующего типа сущности. В тестах наш UnitOfWork прозрачно работает с такой базой, давая доступ к EnumerableQueryable, который просто получить из любого IEnumerable, вызвав у него AsQueryable().

    Покажу пример теста для понимания происходящего:

    [TestMethod]
    public void ФильтрПоСегментуНеВозвращаетТехУКогоНеБылоСегментов()
    {
    	var customer = new CustomerTestDataBuilder(TestDatabase).Build();
    
    	using (var modelContext = CreateModelContext())
    	{
    		var filter = new SegmentFilter<Customer>(null, modelContext)
    		{
    			Segmentation = Controller.PeriodicalSegmentation,
    			Segment = FilterValueWithPresence<Segment>.Concrete(Controller.PeriodicalSegment1)
    		};
    		var result = modelContext.Repositories.Get<CustomerRepository>().GetFiltered(filter).ToList();
    		Assert.IsFalse(result.Contains(customer));
    	}
    }

    В тесте мы создаем modelContext — наш UnitOfWork, обёртка над DataContext со всякими плюшками, и потом пользуемся им, чтобы добраться до репозитория и пофильтровать какие-то сегменты. Разумеется, репозиторий ни о каких тестах не знает, просто ModelContext работает с InMemoryDatabase. Метод GetFiltered(filter) формирует некий IQueryable, а потом мы его материализуем.

    С таким подходом есть проблема: мы никак не тестируем, что тот IQueryable, который мы получили из GetFiltered, транслируется в SQL. В итоге можем получить баг на продакшене примерно такого содержания:
    [NotSupportedException: Method 'Boolean DoesCurrentUserHaveSmsPermissionOnProject(Int32)' has no supported translation to SQL.]
    at System.Data.Linq.SqlClient.PostBindDotNetConverter.Visitor.VisitMethodCall(SqlMethodCall mc)
    at System.Data.Linq.SqlClient.SqlVisitor.Visit(SqlNode node)
    at System.Data.Linq.SqlClient.SqlVisitor.VisitExpression(SqlExpression exp)
    at System.Data.Linq.SqlClient.SqlVisitor.VisitSelectCore(SqlSelect select)
    at System.Data.Linq.SqlClient.PostBindDotNetConverter.Visitor.VisitSelect(SqlSelect select)


    Как сделать так, чтобы такие баги не попадали на продакшен? Можно писать тесты с реальной базой, и у нас такие есть. Они несильно отличаются от тех, что работают с InMemoryDatabase, тестовый класс просто имеет другого родителя. Вот пример:
    [TestMethod]
    public void ЗакрытиеАктивнойСессииИСозданиеНовойВОднойТранзакции()
    {
    	Controller.CurrentDateTimeUtc = new DateTime(2016, 11, 1, 0, 0, 0, DateTimeKind.Utc);
    	var sessionStartDateTime = Controller.CurrentDateTimeUtc.Value.AddHours(-1);
    
    	using (var modelContext = CreateModelContext())
    	{
    		var customer = new CustomerTestDataBuilder(modelContext).Build();
    
    		var activeSession = new CustomerSessionTestDataBuilder(modelContext)
    			.WithLastCustomer(customer)
    			.Active()
    			.WithStartDateTimeUtc(sessionStartDateTime)
    			.Build();
    
    		modelContext.SubmitTestData();
    
    		var newSession = new CustomerSession
    		{
    			PointOfContact = activeSession.PointOfContact,
    			DeviceGuid = activeSession.DeviceGuid,
    			IpAddress = activeSession.IpAddress,
    			ScreenResolution = activeSession.IpAddress,
    			IsAuthenticated = false
    		};
    		newSession.SetStartDateTimeUtc(modelContext, Controller.CurrentDateTimeUtc.Value, customer);
    		newSession.SetUserAgent(activeSession.UserAgent.UserAgentString, modelContext);
    		newSession.SetLastCustomer(modelContext, customer, copyWebSiteVisitActions: false);
    
    		modelContext.Repositories.Get<CustomerSessionRepository>().Add(newSession);
    
    		modelContext.SubmitChanges();
    
    		Assert.IsNull(activeSession.IsActiveOrNull);
    		Assert.IsNotNull(newSession.IsActiveOrNull);
    	}
    }

    В этом тесте всё происходит в реальной базе с последующим откатом Snapshot транзакции, и подобных ошибок пролезть не может. Но, разумеется, таких тестов у нас не очень много, всего около сотни. Число ни в какое сравнение не идёт с 7 000. И они стоят по времени заметно дороже, чем обычные.

    Решение напрашивалось само: написать свою реализацию IQueryable и соответственно IQueryProvider, декорирующих EnumberableQueryble и System.Data.Linq.DataQuery. Такая реализация должна, при попытке получить результат запроса с помощью получение энумератора, или же с помощью вызова методов, приводящих к немедленному выполнению запроса, таких как Any, Count, Single, и т.д., сначала проверять, можно ли транслировать такой запрос в SQL, и если можно, просто выполнять его над обычными коллекциями.

    Теперь я расскажу, как именно это реализовано, и начну с теста, что такая трансляция вообще работает:
    [TestMethod]
    public void ПолучениеВсехСущностейИзТаблицы()
    {
    	var testEntity1 = new SomeTestEntityTestDataBuilder(TestDatabase).Build();
    	var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).Build();
    
    	using (var modelContext = CreateModelContext())
    	{
    		var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items.Select(e => e.Id);
    
    		var sqlQuery = query.ToString();
    		var expectedQuery =
    			$"SELECT [t0].[{nameof(SomeTestEntity.Id)}]\r\n" +
    			$"FROM [{SomeTestEntity.TableName}] AS [t0]";
    		Assert.AreEqual(expectedQuery, sqlQuery);
    
    		var entities = query.ToList();
    		Assert.AreEqual(2, entities.Count);
    		Assert.IsTrue(entities.Contains(testEntity1.Id));
    		Assert.IsTrue(entities.Contains(testEntity2.Id));
    	}
    }

    Этот и ещё несколько тестов были написаны для проверки того, что трансляция в SQL действительно происходит и работает корректно. Вот ещё несколько примеров:
    Парочка тестов под спойлером
    [TestMethod]
    public void ЗапросСИспользованиемДвухСущностейСИспользованиемEntityRefиInheritanceMapping()
    {
    	SomeAbstractTestEntity testEntity1 = new SomeTestEntityChildTestDataBuilder(TestDatabase).WithId(1).Build();
    	var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(2).Build();
    
    	var anotherTestEntity1 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithLinkedEntity(testEntity1).Build();
    	var anotherTestEntity2 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(3).Build();
    
    	using (var modelContext = CreateModelContext())
    	{
    		var query = modelContext.Repositories.Get<AnotherTestEntityRepository>()
    			.Items
    			.Where(a => a.SomeTestEntity == testEntity1)
    			.Select(a => a.Id);
    
    		var entities = query.ToList();
    		Assert.AreEqual(1, entities.Count);
    		Assert.IsTrue(entities.Contains(anotherTestEntity1.Id));
    
    		var sqlQuery = query.ToString();
    		var expectedQuery =
    			$"SELECT [t0].[{nameof(AnotherTestEntity.Id)}]\r\n" +
    			$"FROM [{AnotherTestEntity.TableName}] AS [t0]\r\n" +
    			$"WHERE [t0].[{nameof(AnotherTestEntity.SomeTestEntityId)}] = @p0";
    		Assert.AreEqual(expectedQuery, sqlQuery);
    	}
    }


    [TestMethod]
    public void ЗапросНеТранслируетсяВSQL()
    {
    	using (var modelContext = CreateModelContext())
    	{
    		var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items.Where(e => e.ToString() == "asdf");
    
    		AssertException.Throws<InvalidOperationException>(
    			() => query.ToList(),
    			"ToStringOnlySupportedForPrimitiveTypes");
    	}
    }


    Как вы видите в последнем примере, при попытке проэнумерироваться по IQueryable, который не имеет трансляции в SQL, в тесте возникает exception.

    Теперь перейдём непосредственно к реализации. Нас интересуют запросы, которые происходят внутри модели, то есть фактически нам интересны любые обращения к репозиториям. Репозиторий для каждой сущности обладает некоторым набором бизнес методов и даёт доступ к IQueryable через свойство Items, который является просто DataTable. Посмотрим на пример использования свойства Items.

    Базовый класс для всех репозиториев:
    public abstract class Repository<TEntity> : Repository
    {
    	private ITable<TEntity> table;
    
    	public IQueryable<TEntity> Items
    	{
    		get { return table; }
    	}
    }

    Пример использования Items внутри репозитория
    public class CustomerRepository : ChangeRestrictedRepository<Customer, int, CustomerInitialState>
    {
    	public List<Customer> GetCustomersByEmail(string email)
    	{
    		if (String.IsNullOrEmpty(email))
    			throw new ArgumentException("Email не указан.", nameof(email));
    
    		return Items.Where(user => user.Email == email).ToList();
    	}
    }


    Пример использования вне репозитория:
    FmcgPurchase = Add(ReverseSingleLinkedItemFilter<CustomerAction, FmcgPurchase>.GetFactory(
    	"fmcgpurchase",
    	modelContext => customerAction => modelContext
    		.Repositories
    		.Get<FmcgPurchaseRepository>()
    		.Items
    		.Where(fmcgPurchase => fmcgPurchase.CustomerAction == customerAction),
    	canLinkedItemBeAbsent: true));

    Выходит, нужно добиться, чтобы Repository.Items возвращал наш хитрый IQueryable. Ну и написать наш хитрый IQueryable :)

    Как уже было видно выше, Repository.Items фактически возвращает ITable, а сам table инициализируется при создании UnitOfWork:
    public override void SetRepositoryRegistry(RepositoryRegistry repositories)
    {
    	table = repositories.DatabaseContext.GetTable<TEntity>();
    }

    Метод DatabaseContext.GetTable() абстрактный. У DatabaseContext есть 2 наследника: LinqDatabaseContext и InMemoryDatabaseContext. В LinqDatabaseContext, который используется при работе с реальной базой, всё просто: GetTable возвращает System.Data.Linq.Table. В InMemoryDatabase код написан такой:
    protected internal override ITable<T> GetTable<T>()
    {
    	if (!tables.ContainsKey(typeof(T)))
    		tables.Add(
    			typeof(T), 
    			new StubTableImpl<T>(
    				this, 
    				(InMemoryTable<T>)database.GetTable<T>(), 
    				linqToSqlTranslateHelperContext));
    	return (ITable<T>)tables[typeof(T)];
    }

    Тут немного магии с кэшом и пока не очень понятный linqToSqlTranslateHelperContext, но уже видно, что требуемый нам IQueryable, который нам нужно подменять — это StubTableImpl, а так же используется вызов database.GetTable().
    Начнём с database.GetTable(). Тут смысл в том, что StubTable создаётся, когда мы обращаемся при уже созданном UnitOfWork к каким-то репозиториям. Но в тесте может существовать множество UnitOfWork, и все они должны работать с одной базой. Database — и есть эта база, а StubTable — это просто способ получения доступа к этой базе.

    Теперь посмотрим внимательнее на класс StubTableImpl:
    public class StubTableImpl<T> : ITable<T>, IStubTable
    	where T : class
    {
    	internal StubTableImpl(
    		InMemoryDatabaseContext databaseContext, 
    		InMemoryTable<T> inMemoryTable,
    		DataContext linqToSqlTranslateHelperContext)
    	{
    		InnerTable = inMemoryTable;
    
    		innerQueryable = new StubTableQueryable<T>(
    			databaseContext,
    			linqToSqlTranslateHelperContext.GetTable<T>());
    	}
    
    	public Type ElementType
    	{
    		get { return innerQueryable.ElementType; }
    	}
    
    	public Expression Expression
    	{
    		get { return innerQueryable.Expression; }
    	}
    
    	public IQueryProvider Provider
    	{
    		get { return innerQueryable.Provider; }
    	}
    
    	Type IStubTable.EntityType
    	{
    		get { return typeof(T); }
    	}
    
    	public override string ToString()
    	{
    		return innerQueryable.Select(e => e).ToString();
    	}
    
    	IEnumerable IStubTable.Items
    	{
    		get { return InnerTable; }
    	}
    }

    StubTableImpl реализует IQueryable и IQueryProvider, делегируя всю реализацию StubTableQueryable innerQueryable. Сам StubTableQueryable выглядит так:
    internal class StubTableQueryable<TEntity> : IOrderedQueryable<TEntity>
    {
    	private readonly InMemoryDatabaseContext inMemoryContext;
    	private readonly IQueryable<TEntity> dataContextQueryable;
    	private readonly StubTableQueryProvider stubTableQueryProvider;
    
    	public StubTableQueryable(
    		InMemoryDatabaseContext inMemoryContext,
    		IQueryable<TEntity> dataContextQueryable)
    	{
    		this.inMemoryContext = inMemoryContext;
    		this.dataContextQueryable = dataContextQueryable;
    
    		stubTableQueryProvider = new StubTableQueryProvider(inMemoryContext, dataContextQueryable);
    	}
    
    	public IEnumerator<TEntity> GetEnumerator()
    	{
    		inMemoryContext.CheckConvertionToSql(Expression);
    
    		IEnumerable<TEntity> enumerable =
    			new EnumerableQuery<TEntity>(inMemoryContext.ConvertDataContextExpressionToInMemory(Expression));
    
    		return enumerable.GetEnumerator();
    	}
    
    	IEnumerator IEnumerable.GetEnumerator()
    	{
    		return GetEnumerator();
    	}
    
    	public Expression Expression { get { return dataContextQueryable.Expression; } }
    	public Type ElementType { get { return dataContextQueryable.ElementType; } }
    	public IQueryProvider Provider { get { return stubTableQueryProvider; } }
    
    	public override string ToString()
    	{
    		return inMemoryContext.GetQueryText(Expression);
    	}
    }

    Приведу сразу код StubTableQueryProvider, потому что они очень связаны между собой (теперь даже кажется, что возможно было бы разумным, чтобы это был один класс):
    internal class StubTableQueryProvider : IQueryProvider
    {
    	private static readonly IQueryProvider enumerableQueryProvider = Array.Empty<object>().AsQueryable().Provider;
    	private readonly InMemoryDatabaseContext inMemoryContext;
    
    	private readonly IQueryable dataContextQueryable;
    
    	public StubTableQueryProvider(
    		InMemoryDatabaseContext inMemoryContext,
    		IQueryable dataContextQueryable)
    	{
    		this.inMemoryContext = inMemoryContext;
    		this.dataContextQueryable = dataContextQueryable;
    	}
    
    	public IQueryable<TElement> CreateQuery<TElement>(Expression expression)
    	{
    		return new StubTableQueryable<TElement>(
    			inMemoryContext,
    			dataContextQueryable.Provider.CreateQuery<TElement>(expression));
    	}
    
    	public object Execute(Expression expression)
    	{
    		inMemoryContext.CheckConvertionToSql(expression);
    
    		return enumerableQueryProvider.Execute(inMemoryContext.ConvertDataContextExpressionToInMemory(expression));
    	}
    
    	public TResult Execute<TResult>(Expression expression)
    	{
    		inMemoryContext.CheckConvertionToSql(expression);
    
    		return enumerableQueryProvider.Execute<TResult>(inMemoryContext.ConvertDataContextExpressionToInMemory(expression));
    	}
    }

    Тут необходимо пояснить, как вообще работает построение деревьев выражений с использованием методов расширения на IQueryable в System.Linq.
    Сами эти методы определены в статическом классе Queryable. Вот кусочек этого класса для понимания происходящего:
    public static class Queryable 
    {
    	public static IQueryable<TSource> Where<TSource>(this IQueryable<TSource> source, Expression<Func<TSource, bool>> predicate)
    	{
    		return source.Provider.CreateQuery<TSource>(
    			Expression.Call(
    				null,
    				GetMethodInfo(Queryable.Where, source, predicate),
    				new Expression[] { source.Expression, Expression.Quote(predicate) }
    			));
    	}
    
    	public static int Count<TSource>(this IQueryable<TSource> source)
    	{
    		return source.Provider.Execute<int>(
    			Expression.Call(
    				null,
    				GetMethodInfo(Queryable.Count, source),
    				new Expression[] { source.Expression }
    			));
    	}
    }

    Я привел тут примеры реализации двух методов: Where и Count. Мой выбор пал на них, потому что они показывают разные способы взаимодействия интерфейсов IQueryable и IQueryProvider.
    Посмотрим сначала на реализацию метода Where. Этот метод принимает IQueryable и условие фильтрации, и возвращает IQueryable. При этом вы можете легко заметить, что этот метод ничего не фильтрует. Всё, что он делает — это создаёт дерево выражений: вытаскивает дерево выражений из входящего IQueryable, добавляет к нему вызов метода Where, то есть себя же, с параметром условия фильтрации. После этого полученное новое дерево выражений передаётся в IQueryProvider.CreateQuery, который нужен как раз для того, чтобы оборачивать Expression в IQueryable.

    Попробуем разобрать на примере. Допустим, у нас такой код:
    Customers.Where(c => c.Sex == Sex.Male)
    При этом Customers — Table. Тогда в метод Where будет передан IQueryable, в котором будет Expression — Table. После этого Where добавит себя же в конец этого Expression’а с условием, которое мы передали. Получится Table.Where(c => c.Sex == Sex.Male). Далее этот Expression оборачивается обратно в IQueryable и возвращается из метода. Тут не происходит никаких обращений к БД, просто вызов чистой функции.

    Теперь посмотрим на метод Count. Он вычисляет количество элементов в запрашиваемой коллекции сразу при обращении к нему. Это происходит посредством вызова метода IQueryProvider.Execute. Этот метод принимает Expression, на основании которого он должен построить запрос, и возвращает результат этого запроса — количество. Построение Expression’а тут аналогично методу Where: берется исходный IQueryable, из него получается Expression и достраивается Count’ом. Таким образом IQueryProvider.Execute должен обойти этот Expression, понять, что от него требуется и сделать соответствующий запрос к бд.

    Теперь, вооружившись новыми знаниями, вернёмся к StubTableQueryable и StubTableQueryProvider. Сейчас мы примерно понимаем, чего мы от них хотим: при вызове методов StubTableQueryable.GetEnumerator и StubTableQueryProvider.Execute мы должны взять наш Expression или IQueryable, попытаться транслировать его в SQL, используя какой-нибудь DataContext, а затем получить данные просто из памяти. Для этого в StubTableQueryProvider.Execute и StubTableQueryable.GetEnumerator написан похожий код, который сначала вызывает CheckConvertionToSql, а затем с помощью ConvertDataContextExpressionToInMemory преобразует исходный Expression и либо выполняет его с помощь EnumerableQueryble, либо вызывает Enumerator у EnumerableQueryble с преобразованным Expression’ом.

    Начнём с того, как выполняется проверка, что запрос действительно транслируется в SQL. Метод CheckConversionToSql пытается получить текст запроса по его Expression’у, для чего использует DataContext.GetCommand. Небольшая проблема заключается в том, что GetCommand принимает IQueryable, а мы имеем Expression, но это не беда, на самом деле ему нужен только Expression :)

    В итоге код, проверяющий, что запрос транслируется в SQL, выглядит так:
    public string GetQueryText(Expression expression)
    {
    	return queryExpressionToQueryText.GetOrAdd(
    		expression.ToString(),
    		expressionText =>
    		{
    			var fakeQueryable = new FakeQueryable(expression);
    			var result = linqToSqlTranslateHelperContext.GetCommand(fakeQueryable);
    
    			return result.CommandText;
    		});
    }

    Класс FakeQueryable нужен просто как адаптер, вот его реализация:
    public class FakeQueryable : IQueryable
    {
    	public FakeQueryable(Expression expression)
    	{
    		Expression = expression;
    	}
    
    	public IEnumerator GetEnumerator()
    	{
    		throw new NotSupportedException();
    	}
    
    	public Expression Expression { get; }
    
    	public Type ElementType { get { throw new NotSupportedException(); } }
    
    	public IQueryProvider Provider { get { throw new NotSupportedException(); }  }
    }

    Правильнее было бы всё-таки поправить Mindbox.Data.Linq так, чтобы существовала перегрузка GetCommand(Expression), но пока этого сделано не было.

    Используемый выше linqToSqlTranslateHelperContext — это инстанс DataContext’а, который используется не только для вызова на нём GetCommand, но и для получения из него Table, связанных с базой данных. Изначальный запрос строится относительно этих Table. Если мы попробуем реально выполнить такой запрос, мы получим исключение о том, что соединение для этого DataContext’а отсутствует, ведь Connection не нужен для того, чтобы транслировать запросы, но нужен, чтобы их выполнять.
    Однако получать данные из этого Expression’а нам всё-таки нужно. Для этого его приходится немного преобразовывать, для чего и используется ConvertDataContextExpressionToInMemory.

    Обычно, чтобы что-то делать с Expression’ами, нужно наследоваться от ExpressionVisitor, где для каждого типа выражения есть метод, который можно переопределить, и писать там свою логику. Для замены LinqToSql-таблиц на таблицы InMemoryDatabase в Expression’ах мы так и поступили. Вот этот Visitor:
    public class ConstantObjectReplaceExpressionVisitor<T> : ExpressionVisitor
    	where T : class
    {
    	private readonly Dictionary<T, T> replacementDictionary;
    
    	public ConstantObjectReplaceExpressionVisitor(Dictionary<T, T> replacementDictionary)
    	{
    		this.replacementDictionary = replacementDictionary;
    	}
    
    	protected override Expression VisitConstant(ConstantExpression node)
    	{
    		var value = node.Value as T;
    		if (value == null)
    			return base.VisitConstant(node);
    
    		if (!replacementDictionary.ContainsKey(value))
    			return base.VisitConstant(node);
    
    		return Expression.Constant(replacementDictionary[value]);
    	}
    
    	public Expression ReplaceConstants(Expression sourceExpression)
    	{
    		return Visit(sourceExpression);
    	}
    }

    Смысл этого Visitor’а — заменить одну константу на другую. Что на что заменять передаётся в конструкторе. Вся логика написана в VisitConstant и довольно прямолинейна.
    Посмотрим на создание экземпляра этого Visitor’а:
    private ConstantObjectReplaceExpressionVisitor<IQueryable> CreateTableReplaceVisitor(DataContext dataContext)
    {
    	var dataContextTableToInMemoryTableMap = new Dictionary<IQueryable, IQueryable>();
    
    	var entityTypes = ModelApplicationHostController.Instance
    		.ModelConfiguration
    		.DatabaseModel
    		.GetRepositoriesByEntity()
    		.Keys;
    
    	foreach (var entityType in entityTypes)
    	{
    		var dataContextTable = dataContextGetTableFunc(dataContext, entityType);
    
    		if (dataContextTable == null)
    			throw new InvalidOperationException($"Для типа {entityType} не удалось получить таблицу из DataContext'а");
    
    		var inMemoryContextTable = GetInMemoryTable(database, entityType);
    
    		if (inMemoryContextTable == null)
    			throw new InvalidOperationException($"Для типа {entityType} не удалось получить InMemory таблицу");
    
    		dataContextTableToInMemoryTableMap.Add(dataContextTable, inMemoryContextTable);
    	}
    
    	return new ConstantObjectReplaceExpressionVisitor<IQueryable>(dataContextTableToInMemoryTableMap);
    }

    Тут мы проходимся по всем типам сущностей, которые зарегистрированы, и для каждого типа получаем Table из DataContext’а — это будет Key в конечном Dictionary, а так же InMemoryTable — это будет Value. В итоге, получившийся Visitor будет подменять все ContantExpression, Value которых присутствуют в ключах переданного в него словаря и соответствуют Table какой-то из наших сущностей, в InMemoryTable.

    Может показаться, что с таким проходом будут проблемы с деревьями выражений, где мы используем не константное значение Table, а выражение, значением которого является Table. На этот случай написан вот такой тест:
    [TestMethod]
    public void ЗапросСИспользованиемДвухСущностей()
    {
    	var testEntity1 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(1).Build();
    	var testEntity2 = new SomeTestEntityTestDataBuilder(TestDatabase).WithId(2).Build();
    
    	var anotherTestEntity1 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(testEntity1.Id).Build();
    	var anotherTestEntity2 = new AnotherTestEntityTestDataBuilder(TestDatabase).WithId(3).Build();
    
    	using (var modelContext = CreateModelContext())
    	{
    		var query = modelContext.Repositories.Get<SomeTestEntityRepository>().Items
    			.SelectMany(e => modelContext.Repositories.Get<AnotherTestEntityRepository>()
    				.Items
    				.Where(a => a.Id == e.Id))
    			.Select(a => a.Id);
    
    		var entities = query.ToList();
    		Assert.AreEqual(1, entities.Count);
    		Assert.IsTrue(entities.Contains(anotherTestEntity1.Id));
    
    		var sqlQuery = query.ToString();
    		var expectedQuery =
    			$"SELECT [t1].[{nameof(AnotherTestEntity.Id)}]\r\n" +
    			$"FROM [{SomeTestEntity.TableName}] AS [t0], [{AnotherTestEntity.TableName}] AS [t1]\r\n" +
    			$"WHERE [t1].[{nameof(AnotherTestEntity.Id)}] = [t0].[{nameof(SomeTestEntity.Id)}]";
    		Assert.AreEqual(expectedQuery, sqlQuery);
    	}
    }

    Тут modelContext.Repositories.Get().Items является частью дерева выражений, и не будет заменен нашим Visitor'ом. Почему же тогда такой тест проходит? Каким образом запрос верно транслируется и каким образом происходит энумерация?
    Трансляция запроса в такой ситуации не должна вызывать удивления, ведь LinqToSql во время трансляции запроса обходит дерево выражений, выполняя в нём выражения, которые являются фактическими константами. Все вызовы методов C# будут вызваны до настоящей трансляции, если они не используются в контексте, требующем выполнения на SQL сервере. Именно поэтому в запросе можно написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber()), но нельзя написать modelContext.Repositories.Get().Items.Where(a => a.TestNumber == GetSomeTestNumber(a)). Потому что в первом случае результат GetSomeTestNumber() будет вычислен на этапе трансляции и подставлен в запрос, а во втором GetSomeTestNumber принимает аргументом сущность, по которой идёт запрос, то есть зависит от сущность, а значит должен быть тоже транслирован. В тесте modelContext.Repositories.Get().Items будет выполнен на этапе трансляции, а Items любого репозитория возвращает StubTableImpl, Expression которого — это Table. Для особо любознательных даю ссылку на код, который делает то, что я описал выше.
    Что же касается непосредственного выполнения запроса, то тут всё ещё проще. После замены первого и единственного Table в Expression'е исходного запроса он начинает выполняться как обычный Enumerable. И SelectMany просто выполняет свою часть выражения как делегат. В рамках этого выполнения мы попробуем транслировать вложенный запрос в SQL, что у нас, разумеется получится, заменим в нём Table на InMemoryTable и выполним точно так же.

    Какие проблемы есть в этом решении? Основная проблема заключается в том, что всё ещё существуют ошибки в маппинге, которые не будут обнаружены таким образом. То, что запрос из IQueryable транслируется в SQL, ещё не значит, что фаза материализации, когда LinqToSql читает поток с данными и создаёт из них объекты, пройдёт успешно. Например, на этой фазе могут возникать ошибки, связанные с попытками записи Null-значний в свойства сущностей, которые не могут содержать Null. Нужно сказать, что мы попробовали тестировать и фазу материализации, но это пришлось откатить, так как нас не устроила производительность тестов в такой ситуации: она ухудшилась почти в 2 раза.
    Тем не менее, наш код точно стал стабильнее, чему мы очень рады.

    Вот и всё. С удовольствием отвечу на ваши вопросы :)
    Mindbox 42,80
    Компания
    Поделиться публикацией
    Похожие публикации
    Комментарии 1
    • 0
      всё конечно зависит от количества и сложности тестов, но тестировать базу без базы сомнительная идея, я в своём проекте прилепил файлик базы MSSQL и с ним тесты работают как с реальной базой, это конечно помедленнее, учитывая что каждый тест в конце всё дропает и новый тест создаёт базу с нуля с накатыванием миграций, но это всё каждый раз вовсе не обязательно делать.
      реальная база избавляет от некоторых ошибок которые не поймаешь работая со своей имплементацией базы, я поначалу тоже делал так, но чтото не ловилось, к сожалению уже не помню что и я всё переделал, и главное с файликом всё работает точно также как с сервером базы

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое