Всем привет! Я Игорь Эльяс — бэкенд-разработчик, сейчас работаю в МТС Веб Сервисы. Однажды мне досталась задача «перенести приложение в другую БД, за XX времени», где ХХ — короткий интервал с точки зрения потенциального объема работ при подходе «переделываем все по-нормальному — на Entity Framework» :-)

Проект на C#, изначальная БД — MS SQL + мы использовали самодельный ORM. Возможно, вы сейчас подумали — «очередное легаси!». И да и нет. Проект родился еще во времена Framework 2.0, но регулярно обновлялся и сейчас работает на .NET8. Он пережил десятки циклов рефакторинга, поэтому не безнадежен для доработок.

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

Архитектура проекта и задача

Расскажу подробнее, что под капотом у проекта.

1. Классическая схема изоляции слоев кода Repository <-> Manager <-> Service.

2. Взаимодействие с БД локализовано на уровне Repository и там же происходит конвертация в row-level DTO, которые являются результатом выполнения метода. Трансформация данных на этом уровне минимальная, но все же есть (как же без размытия ответственности?).

ORM жестко завязан на функционал драйвера MS-SQL ADO.NET, использует SqlTypes в качестве целевых типов при передаче параметров, а также при считывании данных из DataReader. В процессе маппинга полей на свойства мы учитываем некоторые типы данных времени из NodaTime (Duration, Instant, LocalDateTime, etc.).

ORM трансформирует данные DataReader в row-level DTO's (есть механизм считывания множества датасетов) и возвращает Tuple.Row-level DTO следуют правилу имя_поля = имя_свойства, для полей в таблицах/table-udf/запросах MS-SQL использовалась pascal нотация.

В моделях row-level можно использовать атрибуты типа [SqlIgnoreIn], [SqlIgnoreOut], [SqlIgnore] — они регулируют использование свойств при конверсии объекта в параметры или в row-level DTO. А еще — атрибуты типа [SqlField("имя_поля")] для переименования.

3. Row-level DTO вынесены в отдельный проект Repository.Contracts, и между слоем Manager'ов и Repositor'иев ходят только простые типы и DTO, которые объявили в этом проекте. В исходной версии, конечно, все было не настолько хорошо, но мы и времени потратили не много, чтобы привести проект к такому виду.

4. Логика методов Repository соответствует шаблонному алгоритму: трансформируем входные объекты в параметры для ORM, дергаем хранимку/запрос — и получаем результат формируем Tuple/ValueTuple либо упаковываем результаты в какой-то композитный объект.

5. Уровень Manager трансформирует сырые объекты row-level in/out в объекты presentation-level, с которыми затем работает Service-слой. Помимо этого, используем специальный уровень композитных Manager'ов, которые управляют транзакциями. Здесь потребовалась доработка из-за интенсивного использования REPEATABLE READ. Ранее мы не учитывали, что для этого уровня изоляции PostgreSQL может выдавать ошибки при COMMIT’e транзакций.

6. Уровень Service также трансформирует данные перед выдачей в UI: в зависимости от прав пользователя некоторые поля могут скрываться.

Мне нужно было внедрить поддержку PostgreSQL и предусмотреть переход на Firebird в будущем. Это позволит предлагать более простое в обслуживании решение для клиентов с небольшими базами данных, без выделенного DBA.

Глубина зоны поражения, или Что мешало просто сделать миграцию

Во-первых, количество SP у нас превышало 300 штук, для многих из них требовалось больше 40 параметров — в основном TVP. Некоторые SP возвращали более 20 наборов строк, но данных в каждом датасете сравнительно немного.

Во-вторых, количество шаблонов для создания запросов на ходу — около 50, а количество параметров в районе 10. Генерация запросов непростая: нужно учитывать множество параметров, в шаблон по условиям добавляются те или иные сегменты JOIN и кусочки условий в WHERE-блоке.

В-третьих, количество row-level DTO больше 600.

Ну и в-четвертых, в row-level DTO мы активно использовали Enum C# от типа byte, а также поля TINYINT — набор битов, с помощью которых можно включать или выключать статусы. А вот в PostgreSQL типа TINYINT нет — мне предстояло заменить его на SMALLINT. 

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

Что и как решили поменять

В результате долгих дебатов, в которых мы периодически переходили от весомых аргументов к увесистым :), нам удалось прийти к компромиссу. Учли все возможные решения и ограничения как технического, так и административного характера (где самым главным как обычно было — скорость выполнения задачи).

Вот каким было наше финальное решение.

1. На этапе миграции БД:

  • Оставить прежней структуру БД в PostgreSQL и поменять только имена объектов из нотации pascal в snake.

  • SP с одним выходным датасетом превратить в табличную FUNCTION. SP c множеством датасетов сделать FUNCTION, которая будет возвращать SET OF CURSOR.

  • Переписать шаблоны для генерируемых запросов в терминах PostgreSQL и нотации snake.

  • TVP заменить на массив c использованием пользовательского complex_type.

  • Расширить некоторые числовые поля INTEGER -> BIGINT при миграции (в качестве закладки на будущее).

2. Использовать Dapper.Transaction в качестве ORM. Почему? Как уже было сказано, изначально в проекте использовали самодельный ORM, чем-то похожий на Dapper. Например, идеологически и синтаксически близким способом там выполняются:

  • вызовы SP и запросов в БД и получение результатов;

  • передача параметров (похоже на DynamicParameters, но в Dapper мощнее).

3. Запретить вносить специфику PostgreSQL/Firebird в уже существующие row-level-модели. Примеры:

  • Если Enum: byte в row-level DTO, он должен считываться из SMALLINT (short) в PostgreSQL.

  • GUID как CHAR (16) OCTETS (byte[]) из Firebird, тогда как в PostgreSQL есть специальный тип UUID. 

4. Сделать отдельный проект для реализации репозиториев для PostgreSQL и отдельный для Firebird. Весь специфичный для той или иной БД код должен храниться только в здесь. 

5. Запретить создавать новые row-level DTO, специфические для Dapper в паре с PostgreSQL или Firebird. Зачем? Это добавило бы еще более 600 классов для PostgreSQL и столько же для Firebird, почти идентичных существующим для MS SQL. Было решено, что это избыточно.

6. Разрешить создание специфических для PostgreSQL row-level-классов, только для созданных в БД комплексных типов при помощи CREATE TYPE, которые потом будут зарегистрированы в NpgsqlDataSource при помощи .MapComposite<>().

7. Включить обработку типов NodaTime. В проекте вся работа с временем реализована через NodaTime, самодельный ORM также корректно обрабатывает типы NodaTime для параметров и row-level DTO. 

new NpgsqlDataSourceBuilder(connectionString).UseNodaTime().	

Как использовали Dapper и почему выбрали Dynamitey

Dapper будет возвращать строку в виде внутреннего dynamic-объекта (DapperRow), где имена свойств соответствуют имени поля из БД. Для PostgreSQL и Firebird это будет нотация с нижним или верхним регистром в snake. Dapper возьмет на себя правильную трансформацию DbNull.

Передача объекта в качестве параметров: трансформировать исходный объект row-level в ExpandoObject, исходные имена полей — в нотацию snake (нижний регистр для PostgreSQL и верхний для Firebird), а затем использовать получившийся объект как набор параметров запроса или как порцию для Dapper.DynamicParameters().

Но чтобы все заработало как задумано, нужно было сделать простой маппер объектов dynamic->strong_type и strong_type->ExpandoObject, который включил бы в себя то, что не поддерживает Dapper из коробки:

  1. Преобразование значения из SMALLINT в Enum: byte и обратно.

  2. Преобразование VARCHAR() в Enum, причем в двух вариантах:

    - строка из БД соответствует имени значения Enum,

    - строка из БД соответствует значению из атрибута на значении Enum:

    public enum LockEnum : byte
    {
        [EnumMember(Value = "S")] 
    	Shared,
    
    	[EnumMember(Value = "X")] 
    	Exclusive
    }
    
  3. Перевод числовых полей в другой числовой тип, например, INTEGER -> long, BIGINT -> int и так далее. Это нужно, поскольку некоторые числовые поля в PostgreSQL и Firebird были увеличены.

  4. Возможность указывать имя поля в БД вручную через атрибут, если оно не соответствует трансформации pascal-snake из имени свойства.

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

  6. Возможность трансформировать поле с датой или временем из стандартных .NET DateTime, DateTimeOffset, Timespan в соответствующие типы NodaTime (LocalDateTime, Instant, Duration). Если для PostgreSQL читать такие типы автоматически после включения UseNodaTime() позволяет драйвер Npgsql, то для Firebird нужно делать преобразование самостоятельно.

Итого: нужно было сделать микромаппер «из» и «в» динамический объект. Для этих целей идеально подходил Dynamitey — мощный инструмент, с которым я познакомился, когда реализовывал движок исполнения выражений на CSharpScript для ETL. Тогда же я выяснил, что Dynamitey быстрее аналогичной операции при использовании рефлексии. 

Тут же с его помощью можно было быстро реализовать маппинг плоских объектов с учетом всех требований, поскольку не пришлось бы погружаться в особенности C# как языка строгой типизации и представления типов Nullable.

Какие возможности Dynamitey нам были нужны: 

1. Метод object Dynamic.CoerceConvert(object target, Type type) — позволяет преобразовать значение из target в тип из type с помощью DLR (dynamic language runtime) для принудительного преобразования типов. Метод подходит для преобразования short в Enum : Byte и наоборот.

2. Механизм Cacheable Invocation для всего остального:

  • new CacheableInvocation(InvocationKind.Convert, convertType: targetType) конверсии типов по правилам DLR;

  • new CacheableInvocation(InvocationKind.Set, "MyProperty", context: typeof(T)); чтобы установить значение свойства целевого объекта DTO;

  •  new CacheableInvocation(InvocationKind.Get, "MyProperty", context: typeof(T)); — чтения значений из целевого объекта при построении ExpandoObject для параметров.

Реализация решения с помощью Dynamitey

Вот так по шагам выглядит трансформация исходного объекта строки (dynamic, DapperRow) в целево��:

  1. Из целевого объекта берем имена свойств в нотации pascal и трансформируем их в нотацию snake. Разрешаем указывать другое имя поля при помощи атрибута.

  2. Конвертируем считанное из DapperRow значение в целевой тип с использованием неявной конверсии (int->long) либо с применением принудительной конверсии (coerce).

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

  4. Устанавливаем значения свойств целевого объекта. 

Процесс трансформации исходного строгого типа в ExpandoObject для параметров выглядит так:

  1. берем имена из оригинального объекта и трансформируем их в нотацию snake с возможностью изменить целевое имя атрибутом;

  2. предусматриваем смену целевого типа через собственный конвертер;

  3. устанавливаем значение свойства в ExpandoObject.

Понадобится два объекта для конверсии:

Конверсия средствами Dynamitey
public class DynamicConverter : IDynamicConverter
{
	private readonly CacheableInvocation? _invocation;
	private readonly Type _targetType;
		
	public DynamicConverter(Type targetType, bool needsCoerceConvert = false)
	{
		_targetType = targetType;
	
		if (!needsCoerceConvert) _invocation = new CacheableInvocation(InvocationKind.Convert, convertType: targetType);
	}
		
	public dynamic? Convert(dynamic? source)
	{
		return source is null ? null : DirectConvert(source);
	}
		
	private dynamic DirectConvert(dynamic source)
	{
		return _invocation != null
				? _invocation.Invoke(source)
				: Dynamic.CoerceConvert(source, _targetType);
	}
}

Здесь используется new CacheableInvocation(InvocationKind.Convert, convertType: targetType) для конвертации исходного значения по правилам DLR и Dynamic.CoerceConvert(source, _targetType) для принудительной конверсии, для типов Enum, преобразований long-> int и из строки.

Конверсия через делегат
internal class DynamicConverterByDelegate(Func<dynamic, dynamic> converter) : IDynamicConverter
{
	public dynamic? Convert(dynamic? source)
	{
		return source is null ? null : DirectConvert(source);
	}
		
	private dynamic DirectConvert(dynamic source) => converter(source);
}
Копирование одного свойства в объект row-level с конверсией
public class PropertyCopyOperation<T>(
	string fromPropertyName,
	PropertyInfo setProperty,
	IDynamicConverter? converter
)
{
	private readonly CacheableInvocation _getter = new(InvocationKind.Get, fromPropertyName);
	private readonly CacheableInvocation _setter = new(InvocationKind.Set, setProperty.Name, context: typeof(T));
		
    public void Copy(dynamic from, T to)
	{
		var value = converter == null ? _getter.Invoke(from) : converter.Convert(_getter.Invoke(from));
		_setter.Invoke(to, [value]);
	}
}

Для считывания значения свойства исходный тип нам неизвестен, поэтому для CacheableInvocation мы не указываем контекст, а для целевого типа это row-level DTO. Мы его знаем, поэтому устанавливаем контекст, что помогает отлавливать некоторые проблемы.

Чтение из row-level DTO в ExpandoObject
public class PropertyGetOperation<T>(
	string targetPropertyName,
	PropertyInfo sourceProperty,
	IDynamicConverter? converter
  )
{
	private readonly CacheableInvocation _getter = new(InvocationKind.Get, sourceProperty.Name, context: typeof(T));
		
	public string TargetPropertyName => targetPropertyName;
		
	public dynamic? Get(T row) => converter == null ? _getter.Invoke(row) : converter.Convert(_getter.Invoke(row));
}

Здесь известен исходный тип, потому передаем его для конструктора CacheableInvocation.

Создание и заполнение row-level DTO
public class PropertyMap<T>(IEnumerable<PropertyCopyOperation<T>> map)
	where T : class, new()
{
	private readonly ImmutableArray<PropertyCopyOperation<T>> _map = [..map];
		
	public T New(dynamic from)
	{
		if (from == null) throw new ArgumentNullException(nameof(from));
		
    	var instance = new T();
		
    	foreach (var operation in _map)
    	{
			operation.Copy(from, instance);
    	}
		
		return instance;
	}
		
	public IEnumerable<T> ToEnumerable(IEnumerable<dynamic> from) => from.Select(row => (T)New(row));
		
	public T[] ToArray(IEnumerable<dynamic> rows) => ToEnumerable(rows).ToArray();
}
Создание и заполнение ExpandoObject для параметров
public class PropertyExpandoMap<T>(IEnumerable<PropertyGetOperation<T>> map)
	where T : class, new()
{
	private readonly ImmutableDictionary<string, PropertyGetOperation<T>> _map = map.ToImmutableDictionary(k => k.TargetPropertyName, v => v);
		
	public ExpandoObject New(T from, ISet<string>? exclude = null)
	{
		IDictionary<string, object?> dict = new ExpandoObject();
		
		foreach (var (propertyName, getOperation) in _map)
		{
			if (exclude?.Contains(propertyName) != true)
			{
				dict[propertyName] = getOperation.Get(from);
			}
		}
		
		return (ExpandoObject)dict;
	}
}

Теперь самая интересная часть — как работают билдеры PropertyMapBuilder<T> и PropertyExpandoMapBuilder<T>:

1. Методы UseSnakeCase и UseCamelCase устанавливают конвертер имени свойства объекта row-level, без использования этих методов берется оригинальное имя.

2. Метод UseNamingAttribute позволяет использовать имя свойства, которое извлекается из свойства атрибута.

3. Метод UseIgnoreAttribute — свойство с указанным атрибутом будет проигнорировано при маппинге.

4. Метод AddConverter позволит задать собственный конвертер типа данных.

5. Атрибут [CoerceConvert] на свойстве активирует принудительную конверсию типа при считывании из источника.

PropertyMapBuilder<T>

Аналогичным образом строится PropertyExpandoMapBuilder, который можно посмотреть в исходниках.

public class PropertyMapBuilder<T> where T : class, new()
{
	private Func<string, string> _nameTranslator = name => name;
	private readonly Dictionary<string, Func<dynamic, dynamic>> _customTypeConverters = new();
	private Type? _ignoreAttributeType;
	private Func<Attribute, string>? _nameFromAttribute;
	private Type? _nameAttributeType;
		
	public PropertyMapBuilder<T> UseSnakeCase(bool useUpperCase = false)
	{
		if (useUpperCase)
			_nameTranslator = name => name.ToSnakeCase().ToUpper();
		else
			_nameTranslator = name => name.ToSnakeCase();
		
		return this;
	}
		
	public PropertyMapBuilder<T> UseCamelCase()
	{
		_nameTranslator = pascalName => pascalName.ToCamelCase();
		return this;
	}
		
	public PropertyMapBuilder<T> AddConverter<TProperty>(string propertyName, Func<dynamic, TProperty> toConverter)
	{
		_customTypeConverters.Add(propertyName, c => toConverter(c));
		return this;
	}
		
	public PropertyMapBuilder<T> UseNamingAttribute<TAttribute>(Func<TAttribute, string> nameTranslator)
		where TAttribute : Attribute
	{
		_nameAttributeType = typeof(TAttribute);
		_nameFromAttribute = attr => nameTranslator((TAttribute)attr);
		return this;
	}
		
	public PropertyMapBuilder<T> UseIgnoreAttribute<TAttribute>()
		where TAttribute : Attribute
	{
		_ignoreAttributeType = typeof(TAttribute);
		return this;
	}
		
	public PropertyMap<T> Build()
	{
		if (typeof(T).IsInterface) throw new ArgumentException($"{nameof(T)} interface type not supported.");
		
		LinkedList<PropertyCopyOperation<T>> map = new();
		
		foreach (var property in typeof(T).GetPropertiesReadWrite(canWrite: true))
		{
			if (property.GetAccessors().Any(v => v.IsStatic)) continue;
		
			if (_ignoreAttributeType != null && property.GetCustomAttribute(_ignoreAttributeType) != null) continue;
		
			var useCoerceConvert = property.GetCustomAttribute<CoerceConvertAttribute>() != null;
		
			IDynamicConverter setConverter =
				_customTypeConverters.TryGetValue(property.Name, out var typeConverter)
					? new DynamicConverterByDelegate(typeConverter)
					: new DynamicConverter(property.PropertyType, useCoerceConvert || property.PropertyType.IsEnumOrNullableEnum());
		
			if (_nameAttributeType != null && property.GetCustomAttribute(_nameAttributeType) != null)
			{
				var attribute = property.GetCustomAttribute(_nameAttributeType)!;
				var name = _nameFromAttribute!(attribute);
				map.AddLast(new PropertyCopyOperation<T>(name, property, setConverter));
			}
			else
			{
				map.AddLast(new PropertyCopyOperation<T>(_nameTranslator(property.Name), property, setConverter));
			}
		}
		
		return new PropertyMap<T>(map);
	}
}
Пример готового объекта row-level с кейсами перехода в PostgreSQL
public class RowExample : IPropertyMap<RowExample>
{
	public static PropertyMap<RowExample> Map { get; } =
		new PropertyMapBuilder<RowExample>()
			.UseSnakeCase()
			.AddConverter(nameof(StringEnumSpecial), p => EnumConverter.ToEnum<ExampleEnum, EnumMemberAttribute>((string)p, a => a.Value!))
			.AddConverter<Instant>(nameof(DateTimeOffsetInstant), p => Instant.FromDateTimeOffset(p))
			.AddConverter<Duration>(nameof(TimeSpanDuration), p => Duration.FromTimeSpan(p))
			.UseNamingAttribute<DataMemberAttribute>(n => n.Name!)
			.UseIgnoreAttribute<IgnoreDataMemberAttribute>()
			.AddConverter(nameof(NullInstantDemo), p => Instant.FromDateTimeOffset(p))
			.Build();
		
	[CoerceConvert] 
	public byte? ShortByte { get; set; } // неявная смена типа на INullable<Int16>
			
	public long IntegerLong { get; set; }
			
	[CoerceConvert] 
	public int LongInt { get; set; }
			
	public string? Varchar { get; set; }
			
	[CoerceConvert] 
	public float DecimalFloat { get; set; }
			
	public double FloatDouble { get; set; }
			
	public ExampleEnum StringEnumSpecial { get; set; } // для этого поля кастомный конвертер
			
	[CoerceConvert] 
	public ExampleEnum? ShortEnum { get; set; }
			
	[CoerceConvert] 
	public ExampleEnum? StringEnum { get; set; }
			
	public Instant? DateTimeOffsetInstant { get; set; } // для этого поля кастомный конвертер
			
	public Duration? TimeSpanDuration { get; set; }
			
	[DataMember(Name = "map_by_attribute_field")] // изменение имени свойства через атрибут 
	public string? ByAttribute { get; set; } 
			
	[IgnoreDataMember] 
	public string? IgnoreField { get; set; }
			
	public Instant? NullInstantDemo { get; set; } = Instant.FromDateTimeOffset(DateTimeOffset.UtcNow);
}
Пример теста
public static class RowFromExample
{
	public static DateTimeOffset DateTimeOffset =
		new(new DateTime(new DateOnly(2025, 10, 10), new TimeOnly(12, 30, 33, 500)), TimeSpan.FromHours(1));

	/// <summary>
	/// Создаем все необходимые варианты конверсии из динамического объекта.
	/// </summary>
	/// <returns></returns>
	public static ExpandoObject CreateDynamicRow()
	{
		dynamic r = new ExpandoObject();

		r.short_byte = (short)255; // CoerceConvert
		r.integer_long = 123; // конверсия по правилам DLR
		r.long_int = 123L; //  CoerceConvert

		r.varchar = "text"; // поле без конверсии

		r.decimal_float = 0.25m; // CoerceConvert
		r.float_double = 3.14f; // конверсия по правилам DLR

		r.string_enum_special = "X"; //  конверсия в ModeEnum по атрибуту
		r.short_enum = (short)1; // прямая конверсия числа в ModeEnum
		r.string_enum = nameof(ExampleEnum.Shared);// Dynamic.CoerceConvert преобразует из строки

		// DateTimeOffset -> Instance
		r.date_time_offset_instant = DateTimeOffset;

		// TimeSpan -> Duration
		r.time_span_duration = TimeSpan.FromMinutes(12);

		r.map_by_attribute_field = "map_by_attribute_field";
		r.ignore_field = "ignore_field";
				
		// демонстрация - кастомный конвертер самостоятельно обрабатывает входные null
		r.null_instant_demo = null;

		return r;
	}
}
		
[TestClass]
public class RowExamplesTest
{
	[TestMethod]
	public void RowExample1Test()
	{
		dynamic src = RowFromExample.CreateDynamicRow();

		RowExample t = RowExample.Map.New(src);

		Assert.AreEqual((byte)255, t.ShortByte);
		Assert.AreEqual(123, t.IntegerLong);
		Assert.AreEqual(123L, t.LongInt);
		Assert.AreEqual("text", t.Varchar);
		Assert.AreEqual(0.25f, t.DecimalFloat);
		Assert.AreEqual(3.14f, t.FloatDouble);
		Assert.AreEqual(ExampleEnum.Exclusive, t.StringEnumSpecial);
		Assert.AreEqual(ExampleEnum.Shared, t.ShortEnum);
		Assert.AreEqual(ExampleEnum.Shared, t.StringEnum);
		Assert.AreEqual(Instant.FromDateTimeOffset(RowFromExample.DateTimeOffset), t.DateTimeOffsetInstant);
		Assert.AreEqual(Duration.FromMinutes(12), t.TimeSpanDuration);
		Assert.AreEqual("map_by_attribute_field", t.ByAttribute);
		Assert.IsNull(t.IgnoreField); // значение проигнорировано
		Assert.IsNull(t.NullInstantDemo); // значение поля заменено на null
	}
}

Реализация основной части идеи есть в проекте ProofOfConcept, а в исходниках — тесты с кейсами применения и примеры взаимодействия с PostgreSQL через Dapper.

Для целевого применения исходные классы из PoC пришлось расширить и доработать, а в Dapper добавить SqlMapper.TypeHandler'ов для NodaTime.

Что в итоге

Благодаря такому нестандартному подходу нам удалось:

1. Сохранить набор свойств всех объектов row-level. Добавились только статические поля in/out-маппера, билдер маппера и атрибуты, которые не влияют на старый код. Код уровня Manager’ов не изменился.

2. Сохранить логику старых методов репозиториев и последовательность операций из старого ORM, а также читаемость для сравнения на глаз: «было — стало», потому что текст исходного метода Repository очень близок к тому, что получилось для PostgreSQL.

3. Минимизировать количество ошибок при тестировании. Поскольку разработчики проверяли логику еще на этапе написания метода репозитория, все опечатки, несовпадения параметров и имен были сразу видны. Так 95% ошибок миграции мы сразу отправляли разработчикам БД.

А еще — уложиться в оговоренные сроки и оставить довольным заказчика и остальных участников процесса. 

Проект продолжает работать, а мы продолжаем его поддерживать. В планах — отказаться от собственного ORM для MS-SQL и тем самым унифицировать кодовую базу при использовании всех БД. Вывод простой: если заглядывать в дополнительные возможности, искать новое и пробовать — иногда есть шанс придумать быстрое и элегантное решение непростой задачи.