Как известно, между объектно-ориентированной и реляционной моделями существует различие.
концептуальный разрыв , который не способен преодолеть даже ORM. Этот разрыв в основном влияет на то, что при использовании реляционной базы данных мы вынуждены работать с множествами, а не с конкретными объектами.
Но есть еще один фактор: поведение NULL в базе данных отличается от поведения NULL в объектно-ориентированных языках.
Это может стать проблемой, если вы используете один и тот же запрос в двух ситуациях: 1) при запросе к базе данных 2) во время модульного тестирования, когда вместо таблицы из базы данных используется массив в памяти.
Более того, это может стать проблемой, если вы обращаетесь только к базе данных, но думаете о NULL в терминах ООП, а не о реляционной базе данных!
Пример 1
Есть три таблицы, связанные внешним ключом: машина, дверь, дверная ручка.Все внешние ключи не являются обнуляемыми, т.е.
на каждой двери и ручке должно быть указано, к чему именно они прикреплены (конкретному автомобилю или двери).
Исходный код для создания таблиц (В качестве базы данных использовался 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;
Затем мы просто создадим левое соединение между автомобилем и дверями, используя 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 появился исключительно из-за левого соединения.
Может быть, виновата «кривая» ORM? Нет, поведение ORM вполне корректное: замена null на 0 или возврат пустой строки означает обман пользователя.
Изменить маппинг тоже не вариант: код скажет, что поле можно оставить пустым, но бизнес-логика потребует обратного.
Решение состоит в том, чтобы изменить запрос, чтобы 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()
select new { car.CAR_ID, DOOR_ID = door != null ? door.DOOR_ID : (int?) null }).
ToList();
Вы можете изменить запрос вручную или написать код, который будет автоматически изменять все такие запросы во время выполнения (но об этом позже).
Пример 2
Есть запрос с двумя левыми соединениями.
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 равен DoorHandle.DOOR_ID.
А вот план выполнения, когда (door != null? Door.DOOR_ID: (int?)null) равно DoorHandle.DOOR_ID
Как видите, план выполнения совершенно другой, а Стоимость его в полтора раза больше.
Для решения этой проблемы можно использовать #if DEBUG и запускать тесты в отладке, но поверьте, читаемость и надежность кода от этого совершенно не повысятся.
Гораздо лучший способ решить проблему в корне — убедиться, что при написании модульных тестов вам вообще не нужно беспокоиться об этой особенности левых соединений.
Для этой цели я написал библиотеку, размещенную на 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; }
}
После этого описанная выше проблема в юнит-тестах исчезнет сама собой.
Кстати, вы можете прочитать, как писать модульные тесты для Entity Framework. Здесь .
Библиотека немного сыроватая и решает только одну проблему: исключение NullReferenceException с двумя левыми соединениями.
Решение этой проблемы само по себе не устраняет концептуальный разрыв, существует множество других проблем, например: Сравнение нуля с нулем на предмет равенства дает разные результаты в реляционных и объектно-ориентированных моделях.
Но и эту проблему можно решить.
Теги: #тестирование приложений #оптимизация запросов #философия программирования #.
NET #sql #tdd
-
Материалы Positive Hack Days 2011
19 Oct, 24 -
«Бегун» Начинает Принимать Любые Сайты
19 Oct, 24