27 февраля в 09:13

Трансляция запросов в 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 раза.
Тем не менее, наш код точно стал стабильнее, чему мы очень рады.

Вот и всё. С удовольствием отвечу на ваши вопросы :)
Автор: @timramone
Mindbox
рейтинг 41,17
Похожие публикации

Комментарии (1)

  • 0
    всё конечно зависит от количества и сложности тестов, но тестировать базу без базы сомнительная идея, я в своём проекте прилепил файлик базы MSSQL и с ним тесты работают как с реальной базой, это конечно помедленнее, учитывая что каждый тест в конце всё дропает и новый тест создаёт базу с нуля с накатыванием миграций, но это всё каждый раз вовсе не обязательно делать.
    реальная база избавляет от некоторых ошибок которые не поймаешь работая со своей имплементацией базы, я поначалу тоже делал так, но чтото не ловилось, к сожалению уже не помню что и я всё переделал, и главное с файликом всё работает точно также как с сервером базы

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

Самое читаемое Разработка