post title image
🧮

Вычисляемые колонки с EF Core и PostgreSQL

devC#EFPostgreSQL

Фильтрация и сортировка в EF

Итак, мы работаем с PostgreSQL, а в качестве ORM используем Entity Framework Core. Предположим, у нас есть следующая сущность:

public class Person
{
    public int Id { get; init; }

    public string FirstName { get; set; }

    public string LastName { get; set; }
}

И задача отфильтровать и/или отсортировать вывод по полям FirstName и LastName.

Можно было бы хранить все в одном поле FullName, но нам не везде нужно полное имя.

Простейший вариант, который может первым прийти в голову - получить все записи и отсортировать в памяти:

var result = dbContext.Persons
    .Where(x => (x.FirstName + " " + x.LastName).Contains(pattern))
    .OrderBy(x => x.FirstName + " " + x.LastName)

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

public string FullName => x.FirstName + " " + x.LastName

Минусы фильтрации в памяти очевидны, мы получаем все данные, а потом просто отбрасываем ненужные, то есть потенциально получаем слишком много лишних данных. Минус сортировки в памяти не столь серьезный, но все таки было бы здорово все делегировать нашему провайдеру БД. Специализированный код должен справиться лучше, чем код общего назначения.

Проекцию аналогичную той, что мы делали в памяти нашего приложения - объединение двух полей в новое - можно построить и в запросе, но начиная с 12й версии PostrgeSQL предлагает другой удобный инструмент.

GeneratedColumns

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

modelBuilder.Entity<Person>()
    .Property(x => x.FullName)
    .HasComputedColumnSql(@"""FirstName"" || ' ' || ""LastName""", stored: true);

Есть два типа генерируемых столбцов: сохранённые и виртуальные. Сохранённый генерируемый столбец вычисляется при записи и, стало быть, храниться в таблице. Виртуальный генерируемый столбец вычисляется при чтении. В настоящее время в PostgreSQL реализованы только сохранённые генерируемые столбцы. Так что это материализованное представление на уровне столбцов.

О том, что мы создаем сохраненный генерируемый столбец говорит параметр stored, без установки этого параметра в true приложение упадет на этапе создания схемы БД. Произвести запись в генерируемый столбец нельзя. Поэтому в командах INSERT или UPDATE нельзя задать значение для таких столбцов, хотя ключевое слово DEFAULT указать можно.

Создавать генерируемые столбцы можно только из других столбцов этой таблицы. В функции генерации нельзя использовать другие генерируемые столбцы.

Immutable functions

Предположим, наши поля не являются обязательными и могут принимать значение NULL. Оператор || (конкатенация строк) принимая одним из параметров NULL вернет NULL. В тех строках, где одно из значений не заполнено мы получим FullName = NULL, а это не совсем то, чего мы пытаемся добиться. Можно поправить дело призвав на помощь еще один оператор:

COALESCE(string, '')

Оператор подставляет второй параметр вместо первого, если первый равен NULL.

Полное выражение будет:

modelBuilder.Entity<Person>()
   .Property(x => x.FullName)
   .HasComputedColumnSql(@"COALESCE(""FirstName"", '') || ' ' || COALESCE(""LastName"", '')", stored: true);

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

SELECT * FROM person;
Id FirstName LastName FullName
1 chuck <NULL> chuck_
2 <NULL> norris _norris

Можно добавить функцию, убирающую пробелы вначале и в конце строки.

TRIM(string)

Но выражение становится все сложнее:

modelBuilder.Entity<Person>()
   .Property(x => x.FullName)
   .HasComputedColumnSql(@"TRIM(COALESCE(""FirstName"", '') || ' ' || COALESCE(""LastName"", ''))", stored: true);

А ведь есть замечательная функция, которая как раз решает нашу задачу:

CONCAT_WS(' ', arg1, arg2)

Эта функция соединяет все аргументы, кроме первого, через разделитель, игнорируя аргументы NULL. Разделитель указывается в первом аргументе.

modelBuilder.Entity<Person>()
                .Property(x => x.FullName)
                .HasComputedColumnSql(@"concat_ws(' ', ""FirstName"", ""LastName"")", stored: true);

Если запустим наше приложение, нас ждет неудача:

Npgsql.PostgresException: 42P17: генерирующее выражение не является постоянным

А дело в том, что функция CONCAT_WS может использовать форматирование строк, которое зависит от настроек текущего региона, поэтому она не считается постоянной и для генерации ее использовать нельзя.

Условные выражения

Можно было бы остановиться на функции TRIM, но что если наша модель данных выглядит так:

public class Person
{
    public int Id { get; init; }

    public string FirstName { get; set; }

    public string SecondName { get; set; }

    public string LastName { get; set; }
}

И нам надо собирать поле FullName уже из трех. Даже если для простоты предположить, что не обязательно только поле SecondName, функция TRIM нам уже не поможет, так как не убирает пробелы из середины строки.

В таком случае мы можем воспользоваться условными выражениями:

modelBuilder.Entity<Person>()
                .Property(x => x.FullName)
                .HasComputedColumnSql(@"CASE WHEN ""SecondName"" IS NULL THEN ""FirstName"" || ' ' || ""LastName""
                                             ELSE ""FirstName"" || ' ' || ""SecondName"" || ' ' || ""LastName"" END", stored: true);

Bonus

Есть интересная библиотека EntityFrameworkCore.Projectables, которая при помощи Source Generators создает выражения для свойств или методов. Она позволяет решить нашу задачу просто добавив аттрибут [Projactable].

[Projectable]
public string FullName => SecondName == null
    ? FirstName + " " + LastName
    : FirstName + " " + SecondName + " " + LastName;