Спецификации на стероидах

    Тема абстракций и всяких прелестных паттернов – хорошая почва для развития холиваров и вечных споров: с одной стороны, мы имеем следование мейнстриму, всяким модным словам и чистому коду, с другой стороны, мы имеем практику и реальность, которые всегда диктуют свои правила.

    Что делать, если абстракции начинают «подтекать», как воспользоваться фишками языка и что можно выжать из паттерна «спецификация» — смотри под катом.

    Итак, приступим к делу. Статья будет содержать следующие разделы: для начала, мы рассмотрим, что такое паттерн «спецификация» и почему его применение к выборкам из БД в чистом виде вызывает трудности.

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

    напоследок я продемонстрирую свою реализацию «спецификация» на стероидах.

    Начнем с базовых вещей. Я думаю, что о паттерне «спецификация» слышали уже все, но для тех кто не слышал, вот его определение с Википедии :

    «Спецификация» в программировании — это шаблон проектирования, посредством которого представление правил бизнес логики может быть преобразовано в виде цепочки объектов, связанных операциями булевой логики.

    Этот шаблон выделяет такие спецификации (правила) в бизнес логике, которые подходят для «сцепления» с другими. Объект бизнес логики наследует свою функциональность от абстрактного аггрегирующего класса CompositeSpecification, который содержит всего один метод IsSatisfiedBy, возвращающий булево значение. После инстанцирования, объект объединяется в цепочку с другими объектами. В результате, не теряя гибкости в настройке бизнес логики, мы можем с лёгкостью добавлять новые правила.

    Иными словами, спецификация — это объект, который реализует следующий интерфейс (отбросив методы для построения цепочек):

    public interface ISpecification
    {
      bool IsSatisfiedBy(object candidate);
    }
    

    Тут все просто и понятно. Но теперь рассмотрим пример из реального мира, в котором помимо домена существует инфраструктура, которая та еще безжалостная личность: обратимся к случаю использованию ORM, СУБД и спецификации для фильтрации данных в БД.

    Для того, чтобы не быть голословным и не показывать на пальцам, возмем в качестве примера следующую предметную область: предположим, что мы разрабатываем ММОРПГ, у нас есть пользователи, у каждого пользователя есть 1 или больше персонажей, а у каждого персонажа есть набор предметов (сделаем допущение, что предметы уникальны для каждого пользователя), и к каждому из предметов, в свою очередь, могут быть применены руны улучшения. Итого в виде диаграммы (класс ReadCharacter мы рассмотрим немного позже, когда поговорим о вложенных запросах):

    image

    Данная модель слабо связана с реальным миром, к тому же содержит поля, отражающие некоторую связь с используемыми ORM, но для демонстрации работы нам будет этого достаточно.

    Предположим, мы хотим отфильтровать всех персонажей, созданных после указанной даты.
    Для этого мы пишем спецификацию следующего вида:

    public class CreatedAfter: ISpecification
    {
      private readonly DateTime _target;
    
      public CreatedAfter(DateTime target)
      {
        _target = target;
      }
    
      bool IsSatisfiedBy(object candidate)
      {
        var character = candidate as Character;
        if(character == null)
      	return false;
    
        return character.CreatedAt > target;
      }
    }
    

    Ну и далее, для применения этой спецификации мы делаем следующее (здесь и далее я буду рассматривать код на основе NHibernate):

    var characters = await session.Query<Character>().ToListAsync();
    var filter = new CreatedAfter(new DateTime(2020, 1, 1));
    var newCharacters = characters.Where(x => filter.IsSatisfiedBy(x)).ToArray();
    

    До тех пор, пока наша база мала, все будет работать красиво и быстро, но стоит нашей игре стать более-менее популярной и набрать пару десятков тысяч пользователей, как вся эта прелесть станет жрать память, время и деньги, и лучше этого зверя сразу пристрелить, т.к. он не жилец. На этой грустной ноте мы отложим спецификацию и обратимся немного к моей практике.

    Давным-давно, в одном очень-очень далеком проекте были у меня в коде классы, которые содержали логику по получению данных из БД. Выглядели они примерно так:

    public class ICharacterDal
    {
      IEnumerable<Character> GetCharactersCreatedAfter(DateTime date);
      IEnumerable<Character> GetCharactersCreatedBefore(DateTime date);
      IEnumerable<Character> GetCharactersCreatedBetween(DateTime from, DateTime to);
      ...
    }
    

    и их использование:

    var dal = new CharacterDal();
    var createdCharacters = dal.GetCharactersCreatedAfter(new DateTime(2020, 1, 1));
    

    Внутри классов скрывалась логика по работе с СУБД (в то время это был ADO.NET).

    Вроде бы все было неплохо, но с разрастанием проекта эти классы тоже росли, превращались в трудно поддерживаемые объекты. К тому же, был неприятный осадок — вроде бы бизнес правила, но хранились они на уровне инфраструктуры, потому что были завязаны на конкретную реализацию.

    На смену такому подходу пришел репозиторий IQueryable<T>, который позволил вынести все правила прямо в слой домена.

    public interface IRepository<T>
    {
      T Get(object id);
      IQueryable<T> List();
      void Delete(T obj);
      void Save(T obj);
    }
    

    который использовался примерно так:

    var repository = new Repository();
    var targetDate = new DateTime(2020, 1, 1);
    var createdUsers = await repository.List().Where(x => x.CreatedAd > targetDate).ToListAsync();
    

    Немного приятнее, но проблема в том, что правила расползаются по коду, и одна и та же проверка может встречаться в сотне мест, и нетрудно себе представить, во что это может вылиться при изменении требований.

    Этот подход скрывает в себе еще одну проблему — если не материализовать запрос, то есть шанс выполнить несколько запросов к БД, вместо одного, что, естественно, пагубно сказывается на производительности системы.

    И вот тут на одном из проектов один коллега предложил использовать библиотеку , которая предлагала реализацию паттерна «спецификация» на основе деревьев выражений.

    Если вкратце, то на базе данной библиотеки мы запилили спецификации, которые позволяли создавать фильтры для сущностей и строить более сложные фильтры на основе конкатенаций простых правил. Например, у нас есть спецификация для персонажей, созданных после нового года и есть спецификация для выбора персонажей с определенным предметом — тогда с помощью объединения этих правил мы можем построить запрос на получение списка персонажей, созданных после нового года и имеющих указанный предмет. И если в последующем у нас изменится правило определения новых персонажей (например, мы будем использовать дату китайского нового года), то мы его поправим только в самой спецификации и нет необходимости искать все использования данной логики по коду!

    Данный проект был успешно сдан, и опыт использования данного подхода оказался весьма успешным. Но стоять на месте не хотелось, да и в реализации были некоторые проблемы, а именно:

    • оператор склейки по ИЛИ не работал;
    • объединение работает только для запросов, содержащих фильтры типа Where, а хотелось более богатых правил (вложенные запросы, skip/take, получение проекций);
    • код спецификаций зависел от выбранной ORM;
    • не было возможности использовать фичи ORM, т.к. это приводило к включению зависимости на нее в слой бизнес-логики (например, нельзя было делать fetch).

    Результатом решения данных проблем стал мини-фреймворк Singularis.Secification, который состоит из нескольких сборок:

    • Singularis.Specification.Definition – определяет объект спецификации, а также содержит интерфейс IQuery, с помощью которого формируется правило.
    • Singularis.Specification.Executor.* – реализует репозиторий и объект для исполнения спецификаций под конкретные ORM (на данный момент поддерживается ef.core и NHibernate, в рамках экспериментов я также делал реализацию для mongodb, но в продакшен этот код не пошел).

    Пройдемся более детально по реализации.

    Интерфейс спецификации определяет публичное свойство, которые содержит правило спецификации:

    public interface ISpecification
    {
      IQuery Query { get; }
      Type ResultType { get; }
    }
    
    public interface ISpefication<T>: ISpecification
    {
    }
    

    Помимо этого в интерфейсе содержится свойство ResultType, которое возвращает тип сущности, получаемое в итоге выполнения запроса.

    Его реализация содержится в классе Specification<T>, которая реализует свойство ResultType, вычисляя его на основе правила, которое хранится в Query, а также два метода: Source() и Source<TSource>(). Эти методы служат для формирования источника правила. Source() создает правило с типом, совпадающим с аргументом класса спецификации, а Source<TSource>() позволяет создать правило для произвольного класса (используется при формировании вложенных запросов).

    Кроме этого, есть еще класс SpecificationExtension, который содержит расширяющие методы для объединения запросов в цепочки.

    Поддерживается два типа объединения: конкатенация (можно рассматривать как объединение по условию «И») и объединение по условию «ИЛИ».

    Вернемся к нашему примеру и реализуем два наших правила:

    public class CreatedAfter: Specification<Character>
    {
      public CreatedAfter(DateTime target)
      {
           Query = Source().Where(x => x.CreatedAt > target);
      }
    }
    
    public class CreatedBefore: Specification<Character>
    {
      public CreatedBefore(DateTime target)
      {
        Query = Source().Where(x => x.CreatedAt < target);
    
      }
    }
    

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

    var specification = new CreatedAfter(new DateTime(2019, 1, 1).Combine(new CreatedBefore(new DateTime(2020, 1, 1));
    var users = repository.List(specification);
    

    Объединение с помощью метода Combine поддерживает произвольные правила. Главное, чтобы результирующий тип левой части совпадал с входным типом правой части. Таким образом, вы можете построить правила, содержащие проекции, skip/take для пагинации, правила сортировки, fetch’a и т.д.

    Правило Or более ограничено — оно поддерживает только цепочки, содержащие условия фильтрации Where. Рассмотрим использование на примере: найдем всех персонажей созданных до 2000 года или после 2020:

    var specification = new CreatedAfter(new DateTime(2020, 1, 1).Or(new CreatedBefore(new DateTime(2000, 1, 1));
    var users = repository.List(specification );
    

    Интерфейс IQuery во многом повторяет интерфейс IQueryable, поэтому особых вопросов тут не должно быть. Остановимся только на специфичных методах:

    Fetch/ThenFetch — позволяет включить связанные данные в формируемый запрос с целью оптимизации. Конечно, это немного криво, когда у нас особенности реализации инфраструктуры влияют на бизнес-правила, но, как я уже говорил, реальность сурова и чистые абстракции — это вещь довольно теоретическая.

    WhereIQuery объявляет две перегрузки данного метода, одна принимает в себя просто лямбда-выражение для фильтрации в виде Expression<Func<T, bool>>, а вторая также принимает в себя дополнительные параметр IQueryContext, который позволяет выполнять вложенные подзапросы. Рассмотрим на примере.

    В модели у нас присутствует класс ReadCharacter — предположим, что у нас модель представлена в виде read-части, которая содержит денормализованные данные и служит для быстрой отдачи, и write-части, которая содержит ссылки, нормализованные данные и т.д. Мы хотим вывести всех персонажей, у которых пользователь имеет почту на определенном домене.

    public class CharactersForUserWithEmailDomain: Specification<ReadCharacter>
    {
      public CharactersForUserWithEmailDomain(string domain)
      {
        var usersQuery = Source<User>(x => x.Email.Contains(domain)).Projection(x => x.Id);
        Query = Source().Where((x, ctx) => ctx.GetQueryResult<int>(usersQuery).Contains(x.Id));
      }
    }
    

    В результате выполнение будет сформирован следующий sql-запрос:

    select
        readcharac0_.id as id1_3_,
        readcharac0_.UserId as userid2_3_,
        readcharac0_.Name as name3_3_
    from
        ReadCharacters readcharac0_
    where
        readcharac0_.UserId in (
            select
                user1_.Id
            from
                Users user1_
            where
                user1_.Email like ('%'+@p0+'%')
        );
    @p0 = '@inmagna.ca' [Type: String (4000:0:0)]
    

    Для выполнения всех этих замечательных правил определен интерфейс IRepository, который позволяет получать элементы по идентификатору, получать один (первый подходящий) или список объектов по спецификации, а также сохранять и удалять элементы из хранилища.
    С определением запросов мы разобрались, теперь осталось научить наши ORM понимать это.
    Для этого разберем сборку Singularis.Infrastructure.NHibernate (для ef.core все выглядит аналогично, только со спецификой ef.core).

    Точкой доступа к данных является объект Repository, который реализует интерфейс IRepository. В случае получения объекта по идентификатору, а также для модификации хранилища (сохранения/удаления) данный класс оборачивает сессию и скрывает конкретную реализацию от бизнес-слоя. В случае работы со спецификациями он формирует объект IQueryable, отражающий наш запрос в терминах IQuery, после чего выполняет его на объекте сессии.

    Основная магия и самый некрасивый код кроется в классе, отвечающем за преобразование IQuery в IQueryable — SpecificationExecutor. Этот класс содержит очень много рефлексии, с помощью которой вызываются методы Queryable или расширяющих методов конкретной ORM (EagerFetchingExtensionsMethods для NHiberante).

    Данная библиотека активно используется в наших проектах (если быть честным, то для наших проектов используется уже обновленная библиотека, но постепенно все эти изменения будут выкладываться и в публичный доступ) постоянно претерпевает изменения. Буквально пару недель назад была выпущена очередная версия, которая перешла на асинхронные методы, были исправлены ошибки в executor’e для ef.core, добавлены тесты и семплы. Вполне вероятно, что библиотека содержит ошибки и сотню мест для оптимизации — она родилась как побочный проект в рамках работы над основными проектами, поэтому я буду рад предложениям по улучшению. Кроме того, не стоит кидаться использовать ее — вполне вероятно, что в конкретно вашем случае это будет излишним или неприменимым.

    Когда же стоит использовать описанное решение? Наверное, проще исходить из вопроса “когда не следует”:

    • highload — если вам нужна высокая производительность, то само использование ORM вызывает вопрос. Хотя, конечно, никто не запрещает реализовать executor, который будет транслировать запросы в SQL и выполнять их…
    • совсем маленькие проекты — это очень субъективно, но, согласитесь, что тянуть в проект “todo list” ORM и весь сопутствующий зоопарк — выглядит как стрельба по воробьям из пушки.

    В любом случае, кто осилил дочитать до конца — спасибо за уделенное время. Надеюсь на фидбек для будущего развития!

    Чуть не забыл — код проекта доступен на GitHub’e — https://github.com/SingularisLab/singularis.specification

    Singularis
    Компания

    Комментарии 16

      0

      Браво, изобрели генерацию запросов на лету :)

        0

        Несовсем понял ваш комментарий. Подход и библиотека не о генерации запросов на лету (в конечном счете все опирается на iqueryable), а про то как можно их огранизовать в коде

        0
        У ORM может быть такая фишка как предопределённые части запросов, рассматривали такую возможность?
        Что то типа того что автоматом (через вызов специально написанного метода совместимого с ORM) добавлять к запросу «is_hidden = 0» или «user.active = 1», и при формировании SQL ORM дубли отсечёт и нужные альясы подставит.
          0
          пока не понял о чем речь, можно пример для любой orm,
            0
            www.doctrine-project.org/projects/doctrine-orm/en/2.7/reference/filters.html
            Я думаю в самой Докритн ОРМ побогаче этот функционал, не знаю почему тут так скромно описано.

            И есть же LINQ to SQL, делаете метод, который принимает формируемое условие, что то в это условие добавляет и выдаёт что получилось — прямо так и задумано использовать LINQ, потом из сфомированного выражения LINQ генериться SQL, при этом строиться дерево выражений и конечно оно упрощается, ещё в далеком 2013 оно это умело и умело хорошо (по мнению некоторых более чем я прошаренных товарищей).
              0
              ага, более-менее понял.
              ну по сути да, спецификация — изначально это как раз что-то похожее на фильтры.
              Мы определяем объекты, которые соотвествуют нашим бизнес правилам, можем в последующем их объединять в более сложные правила.
              зачем нужен еще один слой абстракции — я писал в статье и в комменатриях — что бы отделить уровень домена от конкретной реализации orm. в конечном счете (по крайне мере сейчас) внутри executor'a все опирается на linq, который через провайдер для orm и строит запросы, отвечая за все alias'ы и т.д
          +1

          Ужас какой. А что не так с, извините, FSM? Устарело?

            0
            хотелось бы более развернутого комменатрия. Причем тут FSM?
              +1

              Ну просто вот этот «паттерн Спецификация» выглядит как прекрасная иллюстрация десятого правила Гринспуна.


              is an ad-hoc, informally-specified, bug-ridden, slow implementation of half of [FSM].

              Можно просто взять (или сделать) работоспособную имплементацию FSM и построить полнофункциональный query builder с в разы меньшими затратами. Возможно, я просто чего-то недопонимаю, впрочем.

            +1
            Но есть же LinqSpecs, которые работают вообще с чем угодно…
              0
              Это отличный проект, поддерживающий много фич, которые мне нравятся, и которые я пока не реализовал (сериализация и сравнения — две первоочередные). Но в тоже время есть несколько НО:
              1) для того, что бы использовать фичи ORM, придется добавить референс на соотвествующую сборку из слоя домена. что не всегда возможно, если у вас домен пошарен между несколькими проектами, под разные платформы
              2) возможно я ошибаюсь, но она поддерживает только склейку условий, состоящих из условий фильтрации(where), в моей реализации есть возможнолсть склейки произвольных правил, соотвественно я могу определить, к примеру, правило на фильтрацию и правило для пагинации, а потмо получить правило на пагинацию и фильтарцию
                0

                1) Ничего не мешает портировать этот код прямо в домен (в любом случае, вашу сборку либо так же референсить, либо эмбеддить). Код этой сборки очень прост.
                2) Там есть склейка через && и || плюс инвертирование через !spec.


                Использовал данное решение для динамического построения предикатов в проектах, где использовался ORM. Было очень удобно.

                  0
                  1) вы меня не совсем поняли. предположим, я хочу использовать Fetch (Include в ef.core). Что бы использовтаь его с LinqSpec (если это вообще возможно, я не уверен) — мне придется сослаться на библиотеку orm из слоя домена.
                  2) операторы склейки — это удобно, не спорю, только, все таки через & и |. Но судя по коду библиотеки (который на самом деле очень простой, как вы и отметили) склеить правила, которые включают в себя сортировку не получится.

                  я не в коем случае не говорю о том, что мое решение — серебрянная пуля. одной из целей, которую я преследовал, написав эту статью и опубликовав исходники — получить фидбек, что бы понять, «а не фигню ли я делаю» (с).

                  определенно, из LinqSpec есть что почерпунть!
              0
              Я, чесно, пока не понимаю зачем вы так усложняете себе жизнь. За час на коленке я написал простенькое решение, которое умеет комбинировать фильтры. Все просто, вы пишете IQueryable расширения и комбинируете как угодно.
              К примеру используя одни и те же методы:

              class Program
              {
                 static void Main(string[] args)
                 {
                     var query = new[]
                     {
                         new SomeClass { City = "NY", Years = 20 },
                         new SomeClass { City = "Seattle", Years = 16 },
                     }.AsQueryable();
              
                      var semi = query.CombineOr(q => q.IsReadyToDrink(), q => q.LiveIn("NY", "Seattle"))
                          .ToArray();
              
                      var strict = query
                          .IsReadyToDrink()
                          .LiveIn("NY", "Seattle")
                          .ToArray();
              
              
                      var semi2 = query.CombineOr(q => q.CityAndDrinkability("NY", "Seattle"), q => q.IsReadyToDrink())
                      .ToArray();
              
                      var strict2 = query
                          .CityAndDrinkability("NY", "Seattle")
                          .ToArray();
                 }
              }
              

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

              public interface IAge
              {
                  int Years { get; }
              }
              
              public interface ICity
              {
                  string City { get; }
              }
              
              public class SomeClass : IAge, ICity
              {
                  public string SomeValue { get; set; }
                  public int Years { get; set; }
                  public string City { get; set; }
              }
              
              public static class BusinessRules
              {
                  public static IQueryable<T> IsReadyToDrink<T>(this IQueryable<T> source)
                      where T: IAge
                  {
                      return source.Where(s => s.Years >= 18).Where(s => s.Years <= 70);
                  }
              
                  public static IQueryable<T> LiveIn<T>(this IQueryable<T> source, 
                        params string[] cities)
                      where T: ICity
                  {
                      return source.Where(s => cities.Contains(s.City));
                  }
              
                  public static IQueryable<T> CityAndDrinkability<T>(this IQueryable<T> source, params string[] cities)
                      where T: ICity, IAge
                  {
                      return source.LiveIn(cities).IsReadyToDrink();
                  }
              }
              

              Ну и сама реализация (использован метод Transform из билиотеки CodeJam)

              public static class SpecsExtensions
              {
                  private static Expression Unwrap(Expression expr)
                  {
                      if (expr.NodeType == ExpressionType.Quote)
                          return Unwrap(((UnaryExpression) expr).Operand);
                      return expr;
                  }
              
                  private static IEnumerable<Expression> CollectCondition(Expression query, ParameterExpression obj)
                  {
                      if (query.NodeType == ExpressionType.Call)
                      {
                          var mc = (MethodCallExpression) query;
                          if (mc.Method.IsGenericMethod && mc.Method.GetGenericMethodDefinition() == _whereMethodInfo)
                          {
                              var unwrapped = (LambdaExpression)Unwrap(mc.Arguments[1]);
                              foreach (var cond in CollectCondition(mc.Arguments[0], obj))
                              {
                                  yield return cond;
                              }
              
                              var corrected = unwrapped.Body.Transform(e => e == unwrapped.Parameters[0] ? obj : e);
              
                              yield return corrected;
                          }
                          else
                          {
                              var canProcess = mc.Method.DeclaringType != typeof(Queryable) && mc.Arguments.Count > 0;
                              if (canProcess)
                              {
                                  canProcess = mc.Arguments[0].Type.IsGenericType;
                                  if (canProcess)
                                  {
                                      canProcess = mc.Arguments[0].Type.GetGenericTypeDefinition() == typeof(IQueryable<>);
                                  }
                              }
              
                              if (!canProcess)
                              {
                                  throw new NotImplementedException();
                              }
              
                              // processing user defined functions, so filters can be reused in other filters
                              var innerExpression = ((IQueryable) Expression.Lambda(mc).Compile().DynamicInvoke()).Expression;
                              foreach (var cond in CollectCondition(innerExpression, obj))
                              {
                                  yield return cond;
                              }               
                          }
              
                      }
                  }
              
                  public static MethodInfo GetMethodInfo<T>(Expression<Action<T>> expression)
                  {
                      var member = expression.Body as MethodCallExpression;
              
                      if (member != null)
                          return member.Method;
              
                      throw new ArgumentException("Expression is not a method", "expression");
                  }
              
                  private static readonly MethodInfo _whereMethodInfo = GetMethodInfo<IQueryable<int>>(q => q.Where((Expression<Func<int, bool>>)null)).GetGenericMethodDefinition();
              
                  public static IQueryable<T> CombineOr<T>(this IQueryable<T> source, params Func<IQueryable<T>, IQueryable<T>>[] queries)
                  {
                      Expression condition = null;
                      var param = Expression.Parameter(typeof(T), "q");
                      var fake = Enumerable.Empty<T>().AsQueryable();
                      foreach (var query in queries)
                      {
                          var filter = query(fake);
                          var strict = CollectCondition(filter.Expression, param)
                              .Aggregate(Expression.AndAlso);
              
                          condition = condition == null ? strict : Expression.OrElse(condition, strict);
                      }
              
                      if (condition == null)
                          return source;
              
                      var filterBody = Expression.Lambda(condition, param);
              
                      var result = (IQueryable<T>) _whereMethodInfo.MakeGenericMethod(typeof(T))
                          .Invoke(null, new object[] { source, filterBody });
              
                      return result;
                  }
              }
              

              Как на меня просто, наглядно и легко понимаемо. Да тут шаманство с деревьями выражений, но я это сделал за вас.
              Все проверяется компилятором и никаких дополнительных абстракций.
                0
                1) это справедливо для базового понятия спецификации, но, как я рассказывал в статье, мы хотели получить расширенные правила, которые не ограничивались бы фильтрами
                2) если остановиться на iqueryable, то возвращаемся к проблеме, о которой я уже не однократно упоминал и в статье и в комментариях (возможно, она специфична для конкретного проекта/решения) — предположим, у вас домен пошарен между бэкенд частью веб приложения и богатым мобильным приложением. на бэке вы используете полновесную орм, а на мобилке у вас просто in memory хранилище. что бы на основе iqueryable использовать правила, включающие в себя оптимизации для загрузки придется сослаться на сборки орм, которых на мобильной платформе может не оказаться. поэтому и появляется этот слой абстракции…
                кроме того, иногда linq не хватает. в практике был пример, когда спецификации пришлось переписать на использование icriteria. но благодаря этому слою не пришлось перелапачивать весь код приложения, а все правки остались в слое доступа.

                p.s. код лучше было оформить в виде ссылки на codebin или что-то вроде того
                0
                1) Возможно, специфика есть специфика
                2) Так и делают. Тянут ORM на клиент, хотя в случае EF Core это толпа зависимостей. По этому часто используют Remote LINQ или что-то в этом роде.

                О случае когда вам IQueryable не хватило можно поподробнее?

                p.s. код лучше было оформить в виде ссылки на codebin или что-то вроде того

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

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

                Самое читаемое