Как известно, между объектно-ориентированной и реляционной моделью существует концептуальный разрыв, преодолеть который не в состоянии даже ORM. В основном этот разрыв влияет на то, что при использовании реляционной базы данных мы вынуждены работать над множествами, а не над конкретными объектами. Но есть и другой фактор: поведение NULL в бд отличается от поведения NULL в объектно-ориентированных языках. Это может стать проблемой, когда вы используете один и тот же запрос в двух ситуациях: 1) при запросе к бд 2) при юнит-тестировании, когда вместо таблицы из бд используется массив в оперативной памяти. Более того, это может стать проблемой, если вы обращаетесь только к бд, но мыслите о NULL в терминах ООП, а не реляционной бд!

image

Пример 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

image

И вот план исполнения, когда (door != null? door.DOOR_ID: (int?)null) equals doorHandle.DOOR_ID

image

Как видите, план исполнения совершенно другой, и его 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)


  1. VladVR
    02.11.2015 01:39

    С приходом C#6 вот такое выражение DOOR_ID = door != null? door.DOOR_ID: (int?) null можно писать как door?.DOOR_ID
    А вот такое doorHandle.Color != “RED” || doorHandle == null можно писать как doorHandle?.Color != “RED”
    И я не совсем понял, почему условия тут идут в этом порядке, почему левую часть не переставить с правой?


    1. FiresShadow
      02.11.2015 05:54

      почему левую часть не переставить с правой?
      Можно и переставить.

      Это ещё одна демонстрация концептуального разрыва. Для запроса к бд такая запись корректна, а в юнит-тесте выбросится исключение, если doorHandle == null.


    1. impwx
      02.11.2015 10:28
      +2

      В LINQ-запросах нельзя использовать оператор безопасной навигации. Для него не добавили особый тип Expression'а, поэтому ваш вариант не будет работать.


      1. FractalizeR
        02.11.2015 10:53

        Можно немного покодить, получить похожий, но немного более verbose вариант: www.thomaslevesque.com/2010/02/21/automating-null-checks-with-linq-expressions


        1. impwx
          02.11.2015 11:14

          Если мы говорим про EF и Navigation Properties, то там можно обойтись и без него: обычный оператор навигации при трансляции в SQL превращается в безопасный.

          from c in context.Comments
          select new
          {
              Text = c.Text,
              UserName = c.User.Name // если c.User = null, UserName = null, ошибки не будет
          }
          


          1. mird
            02.11.2015 11:16

            Проблема в том, что тут абстракция течет. Нам нужно знать, что это IQueryable поверх EF, а не поверх какого-то другого провайдера. А если вы в тестах подкладываете реализацию поверх InMemory коллекций — получаете внезапно null ref


            1. impwx
              02.11.2015 11:24

              Тестировать работу с базой, собственноручно подсовывая вместо нее InMemory-коллекции, имхо, слишком ненадежно. Не отлавливается целая куча глупых багов, допускаемых по невнимательности — использование нетранслируемых методов типа First вместо FirstOrDefault, или забытый ToList() перед циклом. Хочется надеяться, что с этим как-то поможет поддержка тестирования в EF7.


  1. mird
    02.11.2015 11:07

    Скажите, а ваш подход к реализации чем-то отличается от описанного мной тут?


    1. FiresShadow
      02.11.2015 11:43

      Подход похожий: модификация дерева выражений перед его выполнением.
      Кстати, у вас для предотвращения NulLReferenceException возвращается не null, а default(TResult) (в методе With). Из-за этого в будущем могут быть проблемы, если вы решите обрабатывать ситуацию сравнения null == null.


      1. mird
        02.11.2015 12:00

        А если null возвращать — будут проблемы со всякими не null типами


        1. FiresShadow
          02.11.2015 12:18

          Да, но их можно решить. Можно менять дерево выражений внутри как угодно. Нельзя наружу вернуть null, если возвращаемое значение присваивается not nullable полю, но это и не нужно: при такой попытке ORM кидает исключение; следовательно, должен кинуть исключение и unit-тест.