Как известно, между объектно-ориентированной и реляционной моделью существует концептуальный разрыв, преодолеть который не в состоянии даже ORM. В основном этот разрыв влияет на то, что при использовании реляционной базы данных мы вынуждены работать над множествами, а не над конкретными объектами. Но есть и другой фактор: поведение NULL в бд отличается от поведения NULL в объектно-ориентированных языках. Это может стать проблемой, когда вы используете один и тот же запрос в двух ситуациях: 1) при запросе к бд 2) при юнит-тестировании, когда вместо таблицы из бд используется массив в оперативной памяти. Более того, это может стать проблемой, если вы обращаетесь только к бд, но мыслите о NULL в терминах ООП, а не реляционной бд!
Есть три таблицы, связанные по внешнему ключу: машина, дверь, дверная ручка. Все внешние ключи not nullable, т.е. у каждой двери и у ручки должно быть указано, на чём именно они крепятся (конкретная машина или дверь).
Создадим в бд одну машину, остальные таблицы остаются пустыми. Затем просто сделаем left join между машиной и дверями, используя ORM:
Как вы думаете, что вернёт этот запрос?
Правильно, ORM кинет в вас исключением и пошлёт лесом. Почему? Бд вернёт строку
CAR_ID=1, DOOR_ID=NULL, а ORM не сможет её обработать, потому что и в бд, и в маппинге указано, что door.DOOR_ID не может быть NULL. NULL же появился исключительно из-за left join. Может быть, виновата “кривая” ORM? Нет, поведение ORM вполне корректно: заменить null на 0 или вернуть пустую строку означает обмануть пользователя. Изменение маппинга тоже не выход: в коде будет сказано, что поле можно не заполнять, а бизнес-логика будет требовать обратного. Решением является изменение запроса, чтобы ORM смогла понять, что поле может иметь значение null:
Изменить запрос вы можете вручную, или же можно написать код, который будет изменять все такие запросы автоматически в рантайме (но об этом чуть позже).
Есть запрос с двумя left join-ами.
Этот запрос будет прекрасно обрабатываться, когда вы обращаетесь к бд. Но стоит использовать его в юнит-тесте, и вы получите NullReferenceException при попытке доступа к door.DOOR_ID во втором джойне, если какая-либо из машин не нуждается в дверях по причине открытого верха. Что ж, время изменять запрос:
Однако тут есть одно «но». Изменяя linq-запрос, вы можете на выходе получить sql-запрос с гораздо более медленным планом исполнения. Давайте посмотрим такой случай на примере.
Вот sql-запрос и план его исполнения, когда джойн происходит так: door.DOOR_ID equals doorHandle.DOOR_ID
И вот план исполнения, когда (door != null? door.DOOR_ID: (int?)null) equals doorHandle.DOOR_ID
Как видите, план исполнения совершенно другой, и его Cost в полтора раза больше.
Для решения этой проблемы можно использовать #if DEBUG и прогонять тесты в дебаге, но, поверьте, читаемость и надёжность кода от этого нисколько не увеличатся. Гораздо лучше бороться с проблемой на корню – сделать так, чтобы при написании юнит-тестов вам вообще не требовалось беспокоиться об этой особенности left join-ов. С этой целью мною была написана библиотека, выложенная на https://github.com/FiresShadow/LinqTestable.
Для того чтобы использовать библиотеку, нужно скачать и подключить проект, и изменить MockObjectSet в вашем проекте, а именно заменить вот этот кусок:
на:
После этого вышеописанная проблема в юнит-тестах пропадёт сама собой.
Кстати, почитать как писать unit-тесты для Entity Framework можно здесь.
Библиотека немного сыровата и решает лишь одну проблему: NullReferenceException при двух left join-ах. Решение одной лишь этой проблемы не устраняет концептуального разрыва, есть множество других проблем, например: сравнение null с null на равенство даёт разные результаты в реляционной и объектно-ориентированной моделях. Но и эта проблема тоже решаема.
Пример 1
Есть три таблицы, связанные по внешнему ключу: машина, дверь, дверная ручка. Все внешние ключи not nullable, т.е. у каждой двери и у ручки должно быть указано, на чём именно они крепятся (конкретная машина или дверь).
Исходный код создания таблиц
(В качестве бд использовался Oracle, ORM – EntityFramework, язык – C#.)
create table CAR
(
CAR_ID NUMBER(10) not null
);
alter table CAR
add constraint CAR_PK primary key (CAR_ID);
create table DOOR
(
DOOR_ID NUMBER(10) not null,
CAR_ID NUMBER(10) not null
);
alter table DOOR
add constraint DOOR_PK primary key (DOOR_ID);
alter table DOOR
add constraint DOOR_CAR_FK foreign key (CAR_ID)
references CAR (CAR_ID);
create index DOOR_CAR_ID_I on DOOR (CAR_ID)
tablespace INDX_S;
create table DOOR_HANDLE
(
DOOR_HANDLE_ID NUMBER(10) not null,
DOOR_ID NUMBER(10) not null,
COLOR NVARCHAR2(15) null
);
alter table DOOR_HANDLE
add constraint DOOR_HANDLE_PK primary key (DOOR_HANDLE_ID);
alter table DOOR_HANDLE
add constraint DOOR_HANDLE_DOOR_FK foreign key (DOOR_ID)
references DOOR (DOOR_ID);
create index DOOR_HANDLE_DOOR_ID_I on DOOR_HANDLE (DOOR_ID)
tablespace INDX_S;
Создадим в бд одну машину, остальные таблицы остаются пустыми. Затем просто сделаем left join между машиной и дверями, используя ORM:
var cars =
(from car in dataModel.CAR
join door in dataModel.DOOR on car.CAR_ID equals door.CAR_ID
into joinedDoor from door in joinedDoor.DefaultIfEmpty() //left join
select new { car.CAR_ID, door.DOOR_ID }).ToList();
Как вы думаете, что вернёт этот запрос?
Правильно, ORM кинет в вас исключением и пошлёт лесом. Почему? Бд вернёт строку
CAR_ID=1, DOOR_ID=NULL, а ORM не сможет её обработать, потому что и в бд, и в маппинге указано, что door.DOOR_ID не может быть NULL. NULL же появился исключительно из-за left join. Может быть, виновата “кривая” ORM? Нет, поведение ORM вполне корректно: заменить null на 0 или вернуть пустую строку означает обмануть пользователя. Изменение маппинга тоже не выход: в коде будет сказано, что поле можно не заполнять, а бизнес-логика будет требовать обратного. Решением является изменение запроса, чтобы ORM смогла понять, что поле может иметь значение null:
var cars =
(from car in dataModel.CAR
join door in dataModel.DOOR on car.CAR_ID equals door.CAR_ID
into joinedDoor from door in joinedDoor.DefaultIfEmpty()
select new { car.CAR_ID, DOOR_ID = door != null ? door.DOOR_ID : (int?) null }).ToList();
Изменить запрос вы можете вручную, или же можно написать код, который будет изменять все такие запросы автоматически в рантайме (но об этом чуть позже).
Пример 2
Есть запрос с двумя left join-ами.
var carsWithoutRedHandle =
(from car in dataModel.CAR
join door in dataModel.DOOR on car.CAR_ID equals door.CAR_ID
into joinedDoor from door in joinedDoor.DefaultIfEmpty()
join doorHandle in dataModel.DOOR_HANDLE on door.DOOR_ID equals doorHandle.DOOR_ID
into joinedDoorHandle from doorHandle in joinedDoorHandle.DefaultIfEmpty()
where doorHandle.Color != “RED” || doorHandle == null
select car).ToList();
Этот запрос будет прекрасно обрабатываться, когда вы обращаетесь к бд. Но стоит использовать его в юнит-тесте, и вы получите NullReferenceException при попытке доступа к door.DOOR_ID во втором джойне, если какая-либо из машин не нуждается в дверях по причине открытого верха. Что ж, время изменять запрос:
var carsWithoutRedHandle =
(from car in dataModel.CAR
join door in dataModel.DOOR on car.CAR_ID equals door.CAR_ID
into joinedDoor from door in joinedDoor.DefaultIfEmpty()
join doorHandle in dataModel.DOOR_HANDLE on (door != null ? door.DOOR_ID : (int?)null) equals doorHandle.DOOR_ID
into joinedDoorHandle from doorHandle in joinedDoorHandle.DefaultIfEmpty()
where doorHandle.Color != “RED” || doorHandle == null
select car).ToList();
Однако тут есть одно «но». Изменяя linq-запрос, вы можете на выходе получить sql-запрос с гораздо более медленным планом исполнения. Давайте посмотрим такой случай на примере.
using System.Linq;
using System.Linq.Expressions;
using LinqKit;
IEnumerable<CAR> GetCars(IDataModel dataModel, Expression<Func<DOOR, bool>> doorSpecification = null, Expression<Func<DOOR_HANDLE, bool>> doorHandleSpecification = null)
{
if (doorSpecification == null)
doorSpecification = door => true;
if (doorHandleSpecification == null)
doorHandleSpecification = handle => true;
var cars =
(from car in dataModel.CAR.AsExpandable()
join door in dataModel.DOOR on car.CAR_ID equals door.CAR_ID
into joinedDoor from door in joinedDoor.DefaultIfEmpty()
join doorHandle in dataModel.DOOR_HANDLE on /*(door != null ? door.DOOR_ID : (int?)null)*/door.DOOR_ID equals doorHandle.DOOR_ID
into joinedDoorHandle from doorHandle in joinedDoorHandle.DefaultIfEmpty()
where doorSpecification.Invoke(door) && doorHandleSpecification.Invoke(doorHandle)
select car);
return cars;
}
var carsWithRedHandle = GetCars(dataModel, doorHandleSpecification: doorHandle => doorHandle.COLOR == "RED").ToList();
Вот sql-запрос и план его исполнения, когда джойн происходит так: door.DOOR_ID equals doorHandle.DOOR_ID
И вот план исполнения, когда (door != null? door.DOOR_ID: (int?)null) equals doorHandle.DOOR_ID
Как видите, план исполнения совершенно другой, и его Cost в полтора раза больше.
Для решения этой проблемы можно использовать #if DEBUG и прогонять тесты в дебаге, но, поверьте, читаемость и надёжность кода от этого нисколько не увеличатся. Гораздо лучше бороться с проблемой на корню – сделать так, чтобы при написании юнит-тестов вам вообще не требовалось беспокоиться об этой особенности left join-ов. С этой целью мною была написана библиотека, выложенная на https://github.com/FiresShadow/LinqTestable.
Для того чтобы использовать библиотеку, нужно скачать и подключить проект, и изменить MockObjectSet в вашем проекте, а именно заменить вот этот кусок:
public System.Linq.Expressions.Expression Expression
{
get { return _collection.AsQueryable<T>().Expression; }
}
public IQueryProvider Provider
{
get { return _collection.AsQueryable<T>().Provider; }
}
на:
public System.Linq.Expressions.Expression Expression
{
get { return _collection.AsQueryable<T>().ToTestable().Expression; }
}
public IQueryProvider Provider
{
get { return _collection.AsQueryable<T>().ToTestable().Provider; }
}
После этого вышеописанная проблема в юнит-тестах пропадёт сама собой.
Кстати, почитать как писать unit-тесты для Entity Framework можно здесь.
Библиотека немного сыровата и решает лишь одну проблему: NullReferenceException при двух left join-ах. Решение одной лишь этой проблемы не устраняет концептуального разрыва, есть множество других проблем, например: сравнение null с null на равенство даёт разные результаты в реляционной и объектно-ориентированной моделях. Но и эта проблема тоже решаема.
Комментарии (11)
mird
02.11.2015 11:07Скажите, а ваш подход к реализации чем-то отличается от описанного мной тут?
FiresShadow
02.11.2015 11:43Подход похожий: модификация дерева выражений перед его выполнением.
Кстати, у вас для предотвращения NulLReferenceException возвращается не null, а default(TResult) (в методе With). Из-за этого в будущем могут быть проблемы, если вы решите обрабатывать ситуацию сравнения null == null.mird
02.11.2015 12:00А если null возвращать — будут проблемы со всякими не null типами
FiresShadow
02.11.2015 12:18Да, но их можно решить. Можно менять дерево выражений внутри как угодно. Нельзя наружу вернуть null, если возвращаемое значение присваивается not nullable полю, но это и не нужно: при такой попытке ORM кидает исключение; следовательно, должен кинуть исключение и unit-тест.
VladVR
С приходом C#6 вот такое выражение DOOR_ID = door != null? door.DOOR_ID: (int?) null можно писать как door?.DOOR_ID
А вот такое doorHandle.Color != “RED” || doorHandle == null можно писать как doorHandle?.Color != “RED”
И я не совсем понял, почему условия тут идут в этом порядке, почему левую часть не переставить с правой?
FiresShadow
Это ещё одна демонстрация концептуального разрыва. Для запроса к бд такая запись корректна, а в юнит-тесте выбросится исключение, если doorHandle == null.
impwx
В LINQ-запросах нельзя использовать оператор безопасной навигации. Для него не добавили особый тип Expression'а, поэтому ваш вариант не будет работать.
FractalizeR
Можно немного покодить, получить похожий, но немного более verbose вариант: www.thomaslevesque.com/2010/02/21/automating-null-checks-with-linq-expressions
impwx
Если мы говорим про EF и Navigation Properties, то там можно обойтись и без него: обычный оператор навигации при трансляции в SQL превращается в безопасный.
mird
Проблема в том, что тут абстракция течет. Нам нужно знать, что это IQueryable поверх EF, а не поверх какого-то другого провайдера. А если вы в тестах подкладываете реализацию поверх InMemory коллекций — получаете внезапно null ref
impwx
Тестировать работу с базой, собственноручно подсовывая вместо нее InMemory-коллекции, имхо, слишком ненадежно. Не отлавливается целая куча глупых багов, допускаемых по невнимательности — использование нетранслируемых методов типа
First
вместоFirstOrDefault
, или забытыйToList()
перед циклом. Хочется надеяться, что с этим как-то поможет поддержка тестирования в EF7.