Забить нельзя доделать
В этом месяца, без фанатизма, пилил очередную подсистему провайдера для EFcore — локальные варианты серверных арифметических операций. Сложение, вычитание, умножение и деление. Третий и первый диалекты.
Вроде все получалось и неделю назад, даже, появилось ложное ощущение что «пронесёт» и все получится 🙂
Не пронесло.
Вчера добрался до последнего этапа этой стадии (или наоборот, не суть) — деление для первого диалекта.
И (снова) нарвался на мину фундаментальные ограничения базовой технологии, лежащей в основе всего этого поделия — LINQ.
- Компилятор может самостоятельно выполнить часть работы с константами (преобразовать тип, выполнить арифметическию операцию) и передать результат работы в LINQ.
- В LINQ не передается информация о явном и неявном конвертировании.
Но обо все попорядку. Попробую описать проблему как можно короче.
Берем простой пример.
int a=int.MaxValue; int b=int.MaxValue;
На C# эти два числа корректно сложить не получится. Потому что результат сложения, по правилам языка, тоже int, а 4294967294 выходит за пределы допустимых значений int: [-2147483648..2147483647].
Сервер (Firebird, третий диалект) эти два числа сложит без проблем. Потому что он будет оперировать 64-битными числами. Запрос
select 1 from RDB$DATABASE where 2147483647+2147483647=4294967294
вернет одну запись.
На EFCore этот SQL запрос можно написать как-то так:
int a=int.MaxValue; int b=int.MaxValue; var recs=db.testTable.Where(r => (a+b==4294967294L);
Здесь компилятор C# начнет капать на мозги предупреждением CS0652 — «Comparison to integral constant is useless; the constant is outside the range of type ‘int’»
Поэтому лучше переписать так:
recs=db.testTable.Where(r => object.Equals(a+b,4294967294L));
Я, у себя в тестах, чтобы зацепить побольше кода, пишу так:
var recs=db.testTable.Where(r => (string)(object)(a+b)=="4294967294");
Поскольку в выражении участвуют локальные переменные, то EFCore применит оптимизацию — попробует самостоятельно сложить и сравнить. При этом, ясный пень, EFCore задействует стандартный алгоритм сложения двух int и посчитает неправильно.
Эту проблему можно обойти в провайдере для EFCore, предварительно преобразовав дерево выражения с подключением собственных алгоритмов (методов), в которых, в том числе, сложение двух Int32 дает Int64.
Не буду вдаваться в подробности, замечу что это достаточно кропотливый и нудный труд.
Здесь мы встречаем первую засаду — компилятор может самостоятельно вычислить часть выражений и передать результат в LINQ (EFCore).
Например, здесь компилятор откажется работать, выдав ошибку CS0220 «The operation overflows at compile time in checked mode«:
recs=db.testTable.Where(r => object.Equals(2147483647+2147483647,4294967294L));
Если написать:
recs=db.testTable.Where(r => object.Equals(unchecked(2147483647+2147483647),4294967294L));
он (компилятор) конечно пропустит этот код, но посчитает не так как нам хочется.
Еще одним примером, где «помощь» компилятора «дает под дых» — сложение строк.
На C# сложение с null обрабатывается как сложение с пустой строкой.
А на SQL — результат такого сложения даст NULL.
Но не будем отвлекаться, едем дальше.
Берем следующий пример.
int a=int.MaxValue; int b=int.MaxValue; long ab=4294967294L; var recs=db.testTable.Where(r => (a+b==ab);
Компилятор приведет выражение «a+b==ab» к выражению «((long)(a+b)==ab». То есть, будет вызываться оператор сравнения для двух long.
Сложение:
Сравнение:
Создание ноды приведения левой части к long (вызов Expression.Convert):
Теперь давайте посмотрим на этот пример с точки зрения первого диалекта подключения к базе данных. Здесь сложение двух INTEGER дает результат DOUBLE. Правда правда. Запрос
select cast((cast(2147483647 as integer)+cast(2147483647 as integer)) as varchar(32)) from RDB$DATABASE
возвращает строку «4294967294.000000».
То есть, в текущем примере, компилятор нам сложение двух чисел будет принудительно приводить к Int64 (long), а у сервера в первом диалекте результатом сложения будет Double.
Заметьте, сами мы в выражении никаких приведений типов не делаем.
Если мы наш пример попробуем выполнить через EFCore на сервере с первым диалектом, то получится что-то вроде «(long)((double)a+(double)b)==ab».
Здесь пробразование к long — это нам компилятор C# подсунул. А приведение к double — ну это так работает сложение в первом диалекте.
По хорошему, в случае первого диалекта, нам нужно чтобы a+b возвращало double. Без всяких приведений. То есть приведение к long нужно выкинуть.
EFCore в подобных случаях, при генерации SQL, предлагает игнорировать преобразования short->int.
if (innerType == convertedType || (convertedType == typeof(int) && (innerType == typeof(byte) || innerType == typeof(sbyte) || innerType == typeof(char) || innerType == typeof(short) || innerType == typeof(ushort)))) { return TryRemoveImplicitConvert(unaryExpression.Operand); }
Эту идею можно позаимствовать и расширить для игнорирования преобразования int->long.
И вот здесь возникает вторая проблема.
А если я наоборот ХОЧУ ЯВНО преобразовать результат сложения к long — «(long)(a+b)»? К примеру, чтобы потом этот результат привести к строке и получить «4294967294» вместо «4294967294.000000».
Да, в первом диалекте можно приводить к 64-битным целым, используя дыру в системе типов сервера. Сами её (дыру) найдете 🙂
…
Получается, что, в общем случае, неявные приведения надо выкидывать. А явные нужно сохранять.
Но компилятор сведения о том, какое преобразование применяется (явном или неявное) в LINQ не передает.
И это создает конкретные проблемы для реализации поддержки первого диалекта с его склонностью большую часть арифметических операций выполнять через … double.
С третьим диалектом вроде серьезных проблем (пока) нет. Он достаточно неплохо согласуется с правилами C#.
—
Так что, походу, про казино и дискотеки поддержку первого диалекта придется забыть.
С одной стороны жаль. А с другой — да и … с ним, с этим первым диалектом.
Будем считать, что его косяки несовместимы с косяками C#/LINQ да и самого EFCore, тоже.
Вести с полей on 11 февраля, 2022
[…] для сохранения поддержки первого диалекта, хотя было желание от неё избавиться… Без этого извращения […]