Не зависимый от локали парсинг даты из строки в DateTime
Содержание
В одном из проектов, волею судеб у меня приключилось что дата приходит из сторонней системы в виде строки формата Y-m-d и эту дату нужно сложить в столбец типа date в MS SQL сервере. В самой программе осуществляется запрос INSERT с этими данными. И одна ленивая задница (конечно это был я) решила не преобразовывать данные внутри программы, а просто сунуть их в параметр запроса. И вот что из этого получилось.
А получилось как всегда приключения в продуктиве. Через несколько лет эксплуатации программы появился клиент у которого настройки MS SQL подразумевали форматирование даты в виде Y-d-m, то есть день месяца теперь в середине строки, а не в конце. Маковкой на тортике было условие, что нельзя изменять настройки у сервера. То есть жить с тем, что дадено.
Возможные варианты конвертирования даты в MS SQL
После консультации с гуру MS SQL мне были предложены варианты решения проблемы:
- При начале сессии изменять формат строки даты по-умолчанию с помощью команды SET DATEFORMAT https://docs.microsoft.com/ru-ru/sql/t-sql/statements/set-dateformat-transact-sql?view=sql-server-ver15. Вариант подкупает своей простотой. Но уж очень он похож на костыль.
- Изменить саму строку для передачи в запрос. Вариант упоротый, конечно. Получить значение текущего формата даты MS SQL сервера можно с помощью запроса select date_format from sys.dm_exec_sessions where session_id = @@spid . Делать так мы, конечно, не будем.
- Прицеплять к параметру запроса нормальный тип DateTime и пусть драйвер ODBC MS SQL делает свою работу какой бы ни был в последствии формат даты. Вот это вариант хорош и закрывает технический долг который я сам себе сделал.
Выбор места в коде для конвертирования
Для задания параметров запроса в проекте я использую метод AddWithValue
1 2 3 4 5 6 7 8 9 10 11 12 |
var queryServ = @"INSERT INTO [dbo].[TMP_SERV] ( [BIRTHDAY] ) VALUES ( @BIRTHDAY ) "; var cmdServ = new SqlCommand(queryServ, conn); cmdServ.Parameters.AddWithValue("@BIRTHDAY", serv.Birthday ?? (object)DBNull.Value); |
В переменной serv у нас data class в котором находятся параметры из другой части программы и где мы получаем в поле Birthday нашу строку с датой.
Возникает логичный вопрос: а где правильно будет поменять этот параметр? В data class или в момент подготовки запроса?
Самый-самый правильный вариант: конечно, в data class. То есть мы должны в момент получения данных преобразовать строку в тип DateTime и уже внутри программы манипулировать ей.
Другой вариант. Взвешивания «за» и «против». Юнит тестами не получится протестировать все кейсы взаимодействия с data class-ом, нужно поднимать среду интеграционного тестировать и UAC + подключать тестировщиков. Что выйдет в очень дорого и долго. Если изменить только перед конкретным применением в запросе, то можно будет быстро исправить кейс и утилита будет приносить счастье пользователям.
Да, в общем, я выбрал второй вариант ибо бюджеты, сроки, и всё такое. Ну и в который раз убедился, что срезав углы при разработке, получаем эти углы в виде кочек при эксплуатации.
Конвертирование строки в DateTime в C#
Отбросив лирику, приступим к реализации. Нам дан формат строки Y-m-d и нам нужно его вставить в столбец DateTime в MS SQL.
Сходу могу предложить два варианта:
- Использовать метод класса Convert.ToDateTime
- Использовать метод класса DateTime.Parse
Для начала, проведём исследование через юнит-тестирование как эти методы помогут нам в решении задачи. Все исходные коды собраны в проект и расположены на GitHub https://github.com/a13xg0/str_to_date_csharp.
Тест для проверки нашего условия довольно прост и включает в себя дату, выходящую за пределы количества месяцев:
1 2 3 4 5 6 7 8 9 |
[Test] public void UseConvert_ExpectedFormat_RightAssignment() { DateTime result = StringConverter.UseConvert("2017-01-23"); Assert.AreEqual(2017, result.Year); Assert.AreEqual(1, result.Month); Assert.AreEqual(23, result.Day); } |
После того, как проверим что тест успешно упал на возврате из метода DateTime.Now приступим к проверке кода:
1 2 3 4 |
public static DateTime UseConvert(string date) { return Convert.ToDateTime(date); } |
Тест успешно пройден. Но какие подводные камни нас могут подстерегать? Меня смущает тот факт, что форматов даты много, но как класс Convert угадал наш вариант? Не поймаем ли мы следующий баг на настройках компьютера под какой-нибудь другой локалью?
Идём курить мануалы: https://docs.microsoft.com/en-us/dotnet/api/system.convert.todatetime?view=net-5.0#System_Convert_ToDateTime_System_String_ и видим, что да:
If
value
is notnull
, the return value is the result of invoking the DateTime.Parse method onvalue
using the formatting information in a DateTimeFormatInfo object that is initialized for the current culture.
Никакого угадывания, а чёткий посыл к настройкам системы.
Конвертирование без учёта системной локали
И ещё, здесь же мы видим, что внутри происходит обращение к методу DateTime.Parse, так что мы не будем плодить обёрток вызовов и продолжим эксперименты над DateTime.
1 2 3 4 5 6 7 8 9 |
[Test] public void UseParse_ExpectedFormat_RightAssignment() { DateTime result = StringConverter.UseParse("2017-01-23"); Assert.AreEqual(2017, result.Year); Assert.AreEqual(1, result.Month); Assert.AreEqual(23, result.Day); } |
Наш тест практически не отличается. Убеждаемся что он успешно падает и преобразуем дату сначала в лоб:
1 2 3 4 |
public static DateTime UseParse(string date) { return DateTime.Parse(date); } |
И да, это работает, как и ожидалось. Теперь проверим как это работает с другими настройками системной локали. Для этого проверим работу с форматом в виде точек.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
[Test] public void UseParseWithDots_WrongFormat_Exception() { Assert.Catch( delegate { StringConverter.UseParseWithDots("2017-01-23"); } ); } [Test] public void UseParseWithDots_ExpectedFormat_RightAssignment() { DateTime result = StringConverter.UseParseWithDots("23.01.2017"); Assert.AreEqual(2017, result.Year); Assert.AreEqual(1, result.Month); Assert.AreEqual(23, result.Day); } |
И код, который мы проверяем:
Итоговый код конвертирования строки в дату
1 2 3 4 5 6 7 8 9 10 11 |
public static DateTime UseParseWithDots(string date) { DateTimeFormatInfo dtfi = new DateTimeFormatInfo { ShortDatePattern = "dd.MM.yyyy", DateSeparator = ".", }; return DateTime.ParseExact(date, "d", dtfi); } |
Оба теста проходят успешно. То есть, мы убедились что прежний (системный) формат даты перестал работать, а наш новый формат успешно и корректно распознаётся. Значит этот код с ожидаемым форматом ‘yyyy-MM-dd’ мы и будем использовать для преобразования из строки в дату.
Подытожив, можно сказать:
- Технический долг приходится платить всегда. Или вам или вашим пользователям
- Ожидайте неожиданного, полагаться на значения по умолчанию не нужно.
- Лучший способ парсить дату в не зависимости от настроек это использовать DateTime.ParseExact с настройками шаблона даты который ожидается увидеть.
Код проекта с тестами доступен https://github.com/a13xg0/str_to_date_csharp