Pull to refresh

Библиотека, помогающая преодолеть концептуальный разрыв между ООП и БД во время тестирования при использовании ORM, — LinqTestable

Reading time5 min
Views11K
Как известно, между объектно-ориентированной и реляционной моделью существует концептуальный разрыв, преодолеть который не в состоянии даже 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 на равенство даёт разные результаты в реляционной и объектно-ориентированной моделях. Но и эта проблема тоже решаема.
Tags:
Hubs:
Total votes 16: ↑13 and ↓3+10
Comments11

Articles