23 марта 2015 в 14:58

Интересные моменты работы LINQ to SQL. Опять

С моего предыдущего поста прошёл месяц, по-моему самое время продолжить. В этот раз поговорим об Inheritance Mapping’е, ну а особо интересующихся в конце статьи ждёт сюрприз.

Итак, начнём.

Проблемы с дискриминатором


Разумеется, мы храним в нашей базе данных полиморфные сущности. Например, есть сущность CustomerOperation, которая отражает некоторую операцию, которую можно совершать над потребителем. Операции совершаются в основном через сервисы, поэтому есть наследник CustomerServiceOperation, а так же у нас есть механизм WebTracking’а, для которого есть WebTrackingOperation. Но довольно слов, лучше покажу код:

[Table(Name = "directcrm.CustomerOperations")]
[InheritanceMapping(Code = "", Type = typeof(CustomerOperation), IsDefault = true)]
[InheritanceMapping(Code = "Service", Type = typeof(CustomerServiceOperation))]
[InheritanceMapping(Code = "WebTracking", Type = typeof(WebTrackingOperation))]
public class CustomerOperation : CampaignItemBase, ICampaignItem
{
	// тут что-то происходит

	[Column(Storage = "discriminator", CanBeNull = false, IsDiscriminator = true)]
	public string Discriminator
	{
		get
		{
			return discriminator;
		}
		private set
		{
			if (discriminator != value)
			{
				SendPropertyChanging();
				discriminator = value;
				SendPropertyChanged();
			}
		}
	}
	
	// тут происходит что-то другое
}

Попробуем получить какую-нибудь WebTracking-операцию. А вот и код:
modelContext.Repositories.Get<CustomerOperationRepository>()
				.FixedItems.OfType<WebTrackingOperation>()
				.FirstOrDefault();


Всё хорошо. Но что будет происходить, если мы вдруг забыли зарегистрировать тип WebTrackingOperation в Inheritance Mapping атрибутах? Если мы забыли это сделать, то такой запрос будет транслирован в

SELECT TOP (1) NULL AS [EMPTY]
FROM [directcrm].[CustomerOperations] AS [t0]
WHERE 0 = 1

Умный LINQ to SQL! Но исключение было бы лучшим выбором, на мой скромный взгляд. Хорошо, а если мы забыли зарегистрировать тип, но прошлись по таблице этих операций и некоторым тип поменяли прямо в базе скриптом (например, раньше все операции были CustomerService, а теперь появились WebTracking, и некоторые старые нужно обновить). Попробуем вычитать операцию с незарегистрированным дискриминатором из базы:

var test = modelContext.Repositories.Get<CustomerOperationRepository>().GetBySystemName("Webtrackingtest");

image

Ожидаемо вычитывается сущность базового типа. Что если попробовать сохранить такое чудо?

Вот код:

var test = mc.Repositories.Get<CustomerOperationRepository>().GetBySystemName("Webtrackingtest");
			
test.NormalizeAndSetName("SomeOperationName");
			
mc.SubmitChanges();

Ничего страшного не произошло. Имя поменялось, остальное осталось прежним:

image

Отлично, давайте теперь попробуем записать именно WebTrackingOperation в базу (при рефакторинге, который я описал выше, код, создающий такие сущности, обязательно бы появился). Попытка эта провалится с забавным NullReference, пруф:

image

Пока не обращайте внимания, что ошибка падает в загадочной Mindbox.Data.Linq.dll, она падает точно так же и в классическом LINQ to SQL. Из ошибки, как вы может заметить, вообще ни разу не видно, где искать проблему, так что баг не очень приятный. Будьте внимательнее и не забывайте указывать все типы полиморфных сущностей в атрибутах Inheritance Mapping’а.

Ну и напоследок о забавном свойстве дискриминатора. Попробуем создать новую сущность базового класса CustomerOperation, присвоить дискриминатор самостоятельно и сохранить:

var newOp = new CustomerOperation();

newOp.NormalizeAndSetName("TestForHabr");
newOp.NormalizeAndSetSystemName("TestForHabr");
newOp.NormalizeAndSetDescription("TestForHabr");
newOp.Discriminator = "WebTracking";
newOp.Campaign = test.Campaign;
mc.Repositories.Get<CustomerOperationRepository>().Add(newOp);

mc.SubmitChanges();

При этом будет сгенерирован insert со значением поля Discriminator = WebTracking, однако после вставки LINQ to SQL перевыставит дискриминатор сам — то есть вызовет его setter с пустой строкой (потому что это значение по-умолчанию для базового типа было указано в Inheritance Mapping атрибуте):

image

Если это поведение вас не устраивает, то есть простой workaround: в setter’е дискриминатора игнорировать выставление пустой строки.

Непрошеная энумерация


У LINQ to SQL есть один (нет, ну разумеется ни разу не один, но сейчас речь о конкретном) очень неприятный момент. Почти всегда, если запрос на linq был построен таким образом, что его не удаётся смаппить на sql, linq при вызове энумератора кидает исключение. Текст таких исключений всем известен, это может быть что-то типа (вытащил парочку из багтрекера): “Member access 'System.DateTime DateTimeUtc' of 'Itc.DirectCrm.Model.CustomerAction' not legal on type 'System.Linq.IQueryable`1[Itc.DirectCrm.Model.CustomerAction]” или “Method 'Boolean Evaluate[CustomerLotteryTicket,Boolean](System.Linq.Expressions.Expression`1[System.Func`2[Itc.DirectCrm.Promo.CustomerLotteryTicket,System.Boolean]], Itc.DirectCrm.Promo.CustomerLotteryTicket)' has no supported translation to SQL”. Ключевое слово тут почти. Иногда LINQ to SQL может посчитать, что вместо того, чтобы кинуть подобное исключение, лучше вычитать побольше сущностей в память, и в памяти уже произвести какие-то преобразования. Это очень печально по нескольким причинам: это не документировано никак (на сколько мне известно), из-за этого иногда падают OutOfMemory (так как вычитанные сущности уже никогда не покинут контекст, хотя код будет выглядеть так, как будто ты вычитываешь анонимные объекты, которые будут быстро собираться GC), а так же из-за багов с Inheritance Mapping. Собственно, давайте посмотрим на такой баг.

Есть сущность “Шаблон действия”, она отражает различные виды действий людей в системе. Есть простые шаблоны: человек совершил авторизацию, зашёл на какой-то конкретный раздел сайта, выиграл приз. Так же есть всякого рода рассылки, которые у нас реализованы через другие типы шаблонов действий — то есть через inheritance mapping. Кусочек кода, чтобы всё было хорошо:

[Table(Name = "directcrm.ActionTemplates")]
[InheritanceMapping(Code = "", Type = typeof(ActionTemplate), IsDefault = true)]
[InheritanceMapping(Code = "Hierarchical", Type = typeof(HierarchicalActionTemplate))]
[InheritanceMapping(Code = "CustomerToCustomer", Type = typeof(CustomerToCustomerActionTemplate))]
[InheritanceMapping(Code = "EmailMailing", Type = typeof(EmailMailingActionTemplate))]
[InheritanceMapping(Code = "SmsMailing", Type = typeof(SmsMailingActionTemplate))]
[InheritanceMapping(Code = "BannerCampaign", Type = typeof(BannerCampaignActionTemplate))]
public class ActionTemplate : INotifyPropertyChanging, INotifyPropertyChanged, IValidatable, IEntityWithSystemName
{
	// тут что-то есть
}

Шаблон действия с id = 20 является EmailMailingActionTemplate — Email-рассылкой (я проверил), давайте вычитаем его:

var actualTemplate = modelContext.Repositories.Get<ActionTemplateRepository>().GetById(20);

image

Прекрасно. А теперь попробуем до запроса этого шаблона действия выполнить забагованный запрос:

var test = modelContext.Repositories.Get<ActionTemplateRepository>()
					.Items
					.Where(at => at.Id == 20)
					.Select(at => at.EffectiveEndDateTimeUtc)
					.FirstOrDefault();


Id я специально ограничил, чтобы показать, что проблема именно в этом запросе. EffectiveEndDateTimeUtc не является колонкой, это просто свойство в классе, перед вызовом Select не был вызван AsEnumerable, так что по идее linq должен был бы кинуть одно из тех самых исключений, примеры которых я приводил выше. Но нет. Запрос транслируется в следующий sql (просто запрос сущности):

SELECT TOP (1) [t0].[Id], [t0].[CategoryId], [t0].[Name], [t0].[Type], [t0].[Discriminator], [t0].[RowVersion], [t0].[SystemName], [t0].[CreationCondition], [t0].[UsageDescription], [t0].[StartDateTimeUtcOverride], [t0].[EndDateTimeUtcOverride], [t0].[DateCreatedUtc], [t0].[DateChangedUtc], [t0].[CreatedById], [t0].[LastChangedById], [t0].[CampaignId]
FROM [directcrm].[ActionTemplates] AS [t0]
WHERE [t0].[Id] = @p0

После такого запроса предыдущий (вытаскивающий конкретный шаблон по Id) возвращает шаблон действия базового типа:
image
Автоматическая энумерация, как показывает такой результат, просто не обращает внимания на Inheritance Mapping, всегда создавая сущности базового класса. Бойтесь автоматической энумерации!

Комментарий от IharBury:
Боюсь, это не совсем отражает, то что на самом деле происходит. LINQ не трактует такой код, как AsEnumerable. Он выполняет маппинг для того, что мапится, а остальное выполняет в памяти. Например, если ты сделаешь не просто Select свойства сущности, а Select свойства у связанной сущности, то будет создана в памяти только связанная сущность.
Хорошим советом было бы не делать у сущностей свойств, которые не мапятся на SQL — делать их методами, чтобы сразу была видна проблема.


FixedItems


Вы могли заметить, что в части, где я рассказывал про проблемы с дискриминатором, у меня был представлен следующий код:

modelContext.Repositories.Get<CustomerOperationRepository>()
				.FixedItems.OfType<WebTrackingOperation>()
				.FirstOrDefault();


Как можно догадаться, весь доступ к СУБД из кода осуществляется через репозитории. У каждого репозитория есть методы-хелперы для получения сущностей по каким-то признакам (у репозитория потребителей метод GetByName и т.п.), но, так как не практично на каждый чих создавать свой метод, то у каждого нашего репозитория есть свойство Items — это просто IQueryable нужных сущностей. Но что же такое FixedItems на CustomerOperationRepository? Самое забавное — свойство FixedItems в коде выглядит так:

public IQueryable<TCampaignItem> FixedItems
{
	get
	{
		return base.Items.Select(item => item);
	}
}


Это — один из костылей, которым приходится пользоваться при работе с LINQ to SQL. Для того, чтобы объяснить проблему, придётся немного описать ситуацию.

CustomerOperationRepository даёт доступ к сущностям CustomerOperation, но сама сущность CustomerOperation является элементом кампании (нашего большого агрегата, к которому привязывается довольно много других сущностей). У этих сущностей похожая валидация, есть множество общих свойств, так что они наследуются от одного класса и репозитории их тоже наследуются. Так же все элементы кампании наследуются от интерфейса ICampaignItem, а базовый класс репозитория принимает тип сущности в качестве первого параметра generic’а:

public abstract class CampaignItemRepositoryBase<TCampaignItem, TInitialState>
	: ChangeRestrictedExtensionSubsetRepository<TCampaignItem, int, Campaign>, 
		ICampaignItemRepository,
		ICampaignRelatedItemRepository<TCampaignItem> 
		where TCampaignItem : class, ICampaignItem, new()
		where TInitialState : CampaignItemInitialState

Проблема в том, что в методах этого класса обращение к полям TCampaignItem происходит с использованием интерфейса ICampaignItem, и действительно есть известная проблема, что LINQ to SQL не маппит свойства, определенные в интерфейсах. Поэтому запрос, например, всех операций, связанных с кампанией (Where(item => item.CampaignId == campaign.Id)), падает с InvalidOperationException: MappingOfInterfacesMemberIsNotSupported: ICampaignItem, CampaignId. При этом добавление в цепочку IQueryable, казалось бы, бесполезного Select(item => item) магическим образом решает все проблемы. Так же можно использовать метод object.Equals вместо оператора ==: Where(item => Equals(item.CampaignId, campaign.Id)), такой запрос транслируется и без использования FixedItems.

Null/Not null в разных наследниках


Короткий и понятный баг. LINQ to SQL не поддерживает для одного и того же поля различные настройки null и not null в наследниках. Например:

[Table(Name = "directcrm.CustomerOperations")]
[InheritanceMapping(Code = "", Type = typeof(CustomerOperation), IsDefault = true)]
[InheritanceMapping(Code = "Service", Type = typeof(CustomerServiceOperation))]
[InheritanceMapping(Code = "Custom", Type = typeof(CustomCustomerServiceOperation))]
[InheritanceMapping(Code = "PerformAction", Type = typeof(PerformActionCustomerServiceOperation))]
[InheritanceMapping(Code = "WebTracking", Type = typeof(WebTrackingOperation))]
[InheritanceMapping(Code = "IdentificationTracking", Type = typeof(IdentificationTrackingOperation))]
[InheritanceMapping(Code = "CustomerOperationByStaff", Type = typeof(CustomerOperationByStaff))]
public class CustomerOperation : CampaignItemBase, ICampaignItem


PerformActionCustomerServiceOperation требует, чтобы значение operationStepGroupId было всегда (этого требует доменная модель), но при этом то же самое поле operationStepGroupId в IdentificationTrackingOperation может отсутствовать. Базовый класс для этих двух сущностей — CustomerOperation, в котором никакого operationStepGroupId нет, это поле добавляется отдельно и в PerformActionCustomerServiceOperation, и в IdentificationTrackingOperation.

Вот как это происходит. Обратите внимание на значения CanBeNull и тип колонки в разных сущностях:

public class IdentificationTrackingOperation : CustomerOperation
{
	private int? operationStepGroupId;

	[Column(Storage = "operationStepGroupId", CanBeNull = true)]
	public int? OperationStepGroupId
	{
		get { return operationStepGroupId; }
		set
		{
			if (operationStepGroupId != value)
			{
				SendPropertyChanging();
				operationStepGroupId = value;
				SendPropertyChanged();
			}
		}
	}
}

public class PerformActionCustomerServiceOperation : CustomerServiceOperation, IPerformActionCustomerServiceOperation
{
	private int operationStepGroupId;
	private EntityRef<OperationStepGroup> operationStepGroup;

	[Column(Storage = "operationStepGroupId", CanBeNull = false)]
	public int OperationStepGroupId
	{
		get { return operationStepGroupId; }
		set
		{
			if (operationStepGroupId != value)
			{
				SendPropertyChanging();
				operationStepGroupId = value;
				SendPropertyChanged();
			}
		}
	}
}

Ну, отлично, а теперь попробуем прочитать из базы список операций. Одна из этих операций — IdentificationTrackingOperation, у которой отсутствует OperationStepGroupId.

Попытка ожидаемо проваливается, потому что мы получаем InvalidOperationException: CannotAssignNull: System.Int32.

Как с этим бороться? Мы сделали просто — для LINQ разрешили в PerformActionCustomerServiceOperation отсутствие значений для OperationStepGroupId, и в своей валидации это отдельно проверяем.

Тем, кто дочитал, сюрприз


Вы могли заметить, что ошибки в LINQ у меня на скриншотах падают в сборке с названием Mindbox.Data.Linq.dll. Да, мы форкнули LINQ to SQL. Например, теперь для регистрации наследников сущностей можно использовать не только InheritanceMappingAttribute, но и метод void AddInheritance<TRoot, T>(object code) на Mindbox.Data.Linq.Mapping.MindboxMappingConfiguration, что позволяет регистрировать наследников сущностей в разных сборках.

Наш форк можно поставить через Nuget: Mindbox.Data.Linq.

Может быть, вы захотите чем-то помочь или воспользоваться — удачи с этим.
Автор: @timramone
Mindbox
рейтинг 99,56
Похожие публикации

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

  • +3
    А почему форк, а не пуш в апстрим?
    • 0
      1. У LINQ-to-SQL нет собственного upstream. Он доступен только как часть Microsoft .NET Reference Source (https://github.com/Microsoft/referencesource).
      2. Изменения, которые мы вносим, направлены не на развитие самого LINQ-to-SQL, а на плавный и безболезненный переход больших enterprise проектов с LINQ-to-SQL на Entity Framework.
  • +1
    Это очень печально по нескольким причинам: это не документировано никак (на сколько мне известно), из-за этого иногда падают OutOfMemory (так как вычитанные сущности уже никогда не покинут контекст, хотя код будет выглядеть так, как будто ты вычитываешь анонимные объекты, которые будут быстро собираться GC)
    Эта проблема решается использованием 1 контекста на 1 единицу работы — все «скрытые сущности» прекрасно собираются сборщиком мусора вместе с самим контекстом.

    Заодно при этом решается проблема «отравления контекста» — это когда в контекст добавляется некорректная сущность — после чего все последующие вызовы SubmitChanges кидают исключения, пытаясь добавить эту сущность в БД снова и снова.
    • 0
      Контекст у нас свой на каждую транзакцию.
      Тут проблема скорее не в том, что сущности никогда не покинут контекст, а в том, что по коду даже не видно, что они в контекст загружались.

      Например, если выполнении следующего запроса
      var activeActionTemplates = modelContext.Repositories.Get<ActionTemplateRepository>().Items
          .Where(at => at.EffectiveStartDateTimeUtc <= DateTime.UtcNow)
          .Where(at => at.EffectiveEndDateTimeUtc > DateTime.UtcNow)
          .ToArray();
      

      все ActionTemplate'ы загрузятся в память, так как EffectiveStartDateTimeUtc и EffectiveEndDateTimeUtc не мапятся на SQL. Если таких сущностей в базе много, то может даже упасть OutOfMemory, хотя по коду кажется, что мы вычитываем только небольшой массив данных.

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

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