From cd463bf0f68d803600e45319acc3e060e13fa1d0 Mon Sep 17 00:00:00 2001 From: Mohannad Najjar Date: Tue, 23 Jan 2024 14:01:12 +0300 Subject: [PATCH] add `DateFormat` function --- README.md | 66 ++++++ composer.json | 3 +- src/Function/Date/DateFormat.php | 182 ++++++++++++++++ src/Function/Date/DirectDateFormatTrait.php | 93 ++++++++ src/Function/Date/EmulatedDateFormatTrait.php | 198 +++++++++++++++++ tests/Function/Date/DateFormatTest.php | 206 ++++++++++++++++++ 6 files changed, 747 insertions(+), 1 deletion(-) create mode 100644 src/Function/Date/DateFormat.php create mode 100644 src/Function/Date/DirectDateFormatTrait.php create mode 100644 src/Function/Date/EmulatedDateFormatTrait.php create mode 100644 tests/Function/Date/DateFormatTest.php diff --git a/README.md b/README.md index 3ce2fcf..13b4b9d 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,72 @@ Schema::table('users', function (Blueprint $table): void { }); ``` +#### Date Format + +Use [PHP's date format patterns](https://www.php.net/manual/en/datetime.format.php#refsect1-datetime.format-parameters) to format a date column. + +```php +use Tpetry\QueryExpressions\Function\Date\DateFormat; +use Tpetry\QueryExpressions\Language\Alias; + +// MySQL: +// SELECT url, DATE_FORMAT(created_at, '%Y-%m-%d') AS date, [....] +// PostgreSQL: +// SELECT url, TO_CHAR(created_at, 'YYYY-MM-DD') AS date, [....] +// SQLite: +// SELECT url, STRFTIME('%Y-%m-%d', created_at) AS date, [....] +// SQL Server: +// SELECT url, FORMAT(created_at, 'yyyy-MM-dd') AS date, [....] +BlogVisit::select([ + 'url', + new Alias(new DateFormat('created_at', 'Y-m-d'), 'date'), + new Count('*'), +])->groupBy( + 'url', + new DateFormat('created_at', 'Y-m-d') +)->get(); +// | url | date | count | +// |-----------|------------|-------| +// | /example1 | 2023-05-16 | 2 | +// | /example1 | 2023-05-17 | 1 | +// | /example1 | 2023-05-18 | 1 | +``` + +
+Supported Formats: + + + + + + + + + + + + + + + + + + + + + + + + + +
Format2021-01-01 09:00:00Description
Dayd01Day of the month, 2 digits with leading zeros (01 to 31)
DFriA textual representation of a day, three letters (Mon through Sun)
j1Day of the month without leading zeros (1 to 31)
lFridayA full textual representation of the day of the week (Sunday through Saturday)
w5Numeric representation of the day of the week (0 for Sunday through 6 for Saturday)
WeekW53ISO 8601 week number of year, weeks starting on Monday (Example: 42 - the 42nd week in the year)
MonthFJanuaryA full textual representation of a month, such as January or March (January through December)
m01Numeric representation of a month, with leading zeros (01 through 12)
MJanA short textual representation of a month, three letters (Jan through Dec)
n1Numeric representation of a month, without leading zeros (1 through 12)
t31Number of days in the given month (28 through 31)
Yearo2020ISO 8601 week-numbering year. This has the same value as Y, except that if the ISO week number (W) belongs to the previous or next year, that year is used instead. (Examples: 1999 or 2003)
Y2021A full numeric representation of a year, at least 4 digits, with - for years BCE. (Examples: -0055, 0787, 1999, 2003, 10191)
y21A two digit representation of a year (Examples: 99 or 03)
TimeaamLowercase Ante meridiem and Post meridiem (am or pm)
AAMUppercase Ante meridiem and Post meridiem (AM or PM)
g912-hour format of an hour without leading zeros (1 through 12)
G924-hour format of an hour without leading zeros (0 through 23)
h0912-hour format of an hour with leading zeros (01 through 12)
H0924-hour format of an hour with leading zeros (00 through 23)
i00Minutes with leading zeros (00 to 59)
s00Seconds with leading zeros (00 through 59)
Full Date/TimeU1609491600Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
+ +
+ +> **Note** +> When using SQLite, characters that produces a textual result (for example: `D` -> `Sun`,`F` -> `January`, `l` -> `Sunday`, `M` -> `Jan`), [Carbon's default localization](https://carbon.nesbot.com/docs/#api-localization) will be used to build the SQL query, `\Carbon\Carbon::setLocale(xx)` can be used to change the localization. + + ## Changelog Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. diff --git a/composer.json b/composer.json index 4280109..a4de055 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,8 @@ "php": "^8.1", "illuminate/contracts": "^10.13.1", "illuminate/database": "^10.13.1", - "illuminate/support": "^10.0" + "illuminate/support": "^10.0", + "nesbot/carbon": "^2.72" }, "require-dev": { "larastan/larastan": "^2.7.0", diff --git a/src/Function/Date/DateFormat.php b/src/Function/Date/DateFormat.php new file mode 100644 index 0000000..76d7709 --- /dev/null +++ b/src/Function/Date/DateFormat.php @@ -0,0 +1,182 @@ + + */ + protected array $unsupportedCharacters = [ + 'B', + 'c', + 'e', + 'I', + 'L', + 'N', + 'O', + 'P', + 'p', + 'r', + 'S', + 'T', + 'u', + 'v', + 'X', + 'x', + 'z', + 'Z', + ]; + + public function __construct( + private readonly string|Expression $expression, + private readonly string $format + ) { + } + + public function getValue(Grammar $grammar): string + { + $expressions = $this->buildExpressions($grammar); + + return $this->concatenateExpressions($expressions, $grammar); + } + + /** + * @return non-empty-array + */ + protected function buildExpressions(Grammar $grammar): array + { + $characters = $this->getFormatCharacters(); + + /** @var non-empty-array $expressions */ + $expressions = array_map(function (string $character) use ($grammar) { + $emulatedCharacter = $this->getEmulatableCharacter($grammar, $character); + $formatCharacter = $this->formatCharacters[$this->identify($grammar)][$character] ?? null; + + if ($emulatedCharacter) { + return $this->getEmulatedExpression($grammar, $emulatedCharacter); + } + + if ($formatCharacter) { + return $formatCharacter; + } + + return $character; + }, $characters); + + return $this->processExpressions($expressions, $grammar); + } + + /** + * @param non-empty-array $expressions + * @return non-empty-array + */ + protected function processExpressions(array $expressions, Grammar $grammar): array + { + $expressions = array_reduce(array_keys($expressions), function (array $expressions, int $index) use ($grammar) { + $currentExpression = $expressions[$index]; + + if (! is_string($currentExpression)) { + return $expressions; + } + + $nextExpression = $expressions[$index + 1] ?? null; + $isLiteral = mb_strlen($currentExpression) == 1; + $isEscaped = str_starts_with($currentExpression, '\\'); + + // First, process escaped characters + if ($isEscaped) { + $expressions[$index] = new QueryExpression( + $grammar->quoteString(stripslashes($currentExpression)) + ); + + return $expressions; + } + + // Next, concatenate adjacent strings + if (is_string($nextExpression)) { + $expressions[$index + 1] = $currentExpression.$nextExpression; + + unset($expressions[$index]); + + return $expressions; + } + + // Since it's a single character it's guaranteed to be a non-formatting literal. + // In SQL Server, calls like FORMAT(column, '-') will just return null. + if ( + $grammar instanceof SqlServerGrammar && + $isLiteral + ) { + $expressions[$index] = new QueryExpression( + $grammar->quoteString($currentExpression) + ); + } + + // Finally, convert direct date formats to expressions + if (is_string($expressions[$index])) { + $expressions[$index] = $this->getDirectDateFormat($grammar, $expressions[$index]); + } + + return $expressions; + }, $expressions); + + /** @var non-empty-array $expressions */ + return array_values($expressions); + } + + /** + * @param non-empty-array $expressions + */ + protected function concatenateExpressions(array $expressions, Grammar $grammar): string + { + if (count($expressions) == 1) { + return (string) $expressions[0]->getValue($grammar); + } + + return (new Concat($expressions))->getValue($grammar); + } + + /** + * @return array + */ + protected function getFormatCharacters(): array + { + $characters = str_split($this->format); + + $characters = array_reduce(array_keys($characters), function (array $characters, int $index) { + if ($characters[$index] == '\\') { + $characters[$index + 1] = $characters[$index].($characters[$index + 1] ?? null); + unset($characters[$index]); + } + + return $characters; + }, $characters); + + array_walk($characters, function (string $character) { + if (in_array($character, $this->unsupportedCharacters)) { + throw new \InvalidArgumentException(sprintf( + 'Unsupported format character: %s', + $character, + )); + } + }); + + return $characters; + } +} diff --git a/src/Function/Date/DirectDateFormatTrait.php b/src/Function/Date/DirectDateFormatTrait.php new file mode 100644 index 0000000..44cf9ef --- /dev/null +++ b/src/Function/Date/DirectDateFormatTrait.php @@ -0,0 +1,93 @@ +> + */ + protected array $formatCharacters = [ + 'mysql' => [ + 'A' => '%p', + 'd' => '%d', + 'D' => '%a', + 'F' => '%M', + 'H' => '%H', + 'h' => '%h', + 'i' => '%i', + 'j' => '%e', + 'l' => '%W', + 'm' => '%m', + 'M' => '%b', + 'n' => '%c', + 'o' => '%x', + 's' => '%s', + 'W' => '%v', + 'Y' => '%Y', + 'y' => '%y', + ], + 'sqlite' => [ + 'd' => '%d', + 'H' => '%H', + 'i' => '%M', + 'm' => '%m', + 's' => '%S', + 'U' => '%s', + 'w' => '%w', + 'Y' => '%Y', + ], + 'pgsql' => [ + 'A' => 'AM', + 'd' => 'DD', + 'D' => 'Dy', + 'h' => 'HH12', + 'H' => 'HH24', + 'i' => 'MI', + 'j' => 'FMDD', + 'm' => 'MM', + 'M' => 'Mon', + 'n' => 'FMMM', + 'o' => 'IYYY', + 's' => 'SS', + 'W' => 'IW', + 'y' => 'YY', + 'Y' => 'YYYY', + ], + 'sqlsrv' => [ + 'A' => 'tt', + 'd' => 'dd', + 'D' => 'ddd', + 'h' => 'hh', + 'H' => 'HH', + 'i' => 'mm', + 'm' => 'MM', + 's' => 'ss', + 'Y' => 'yyyy', + ], + ]; + + protected function getDirectDateFormat(Grammar $grammar, string $format): Expression + { + return new QueryExpression( + match ($this->identify($grammar)) { + 'mysql' => "DATE_FORMAT({$this->stringize($grammar, $this->expression)}, '{$format}')", + 'sqlite' => "STRFTIME('{$format}', {$this->stringize($grammar, $this->expression)})", + 'pgsql' => "TO_CHAR({$this->stringize($grammar, $this->expression)}, '{$format}')", + 'sqlsrv' => "FORMAT({$this->stringize($grammar, $this->expression)}, '{$format}')", + } + ); + } +} diff --git a/src/Function/Date/EmulatedDateFormatTrait.php b/src/Function/Date/EmulatedDateFormatTrait.php new file mode 100644 index 0000000..35e9761 --- /dev/null +++ b/src/Function/Date/EmulatedDateFormatTrait.php @@ -0,0 +1,198 @@ +isExpression($emulatedCharacter)) { + $emulatedCharacter = $this->stringize($grammar, $emulatedCharacter); + } + + /** @var string $emulatedCharacter */ + return new QueryExpression(sprintf( + $emulatedCharacter, + ...array_fill( + start_index: 0, + count: substr_count($emulatedCharacter, '%s'), + value: $this->stringize($grammar, $this->expression) + ) + )); + } + + protected function getEmulatableCharacter(Grammar $grammar, string $character): string|Expression|null + { + return match ($this->identify($grammar)) { + 'mysql' => $this->getEmulatableCharacterForMysql($character), + 'sqlite' => $this->getEmulatableCharacterForSqlite($character), + 'pgsql' => $this->getEmulatableCharacterForPgsql($character), + 'sqlsrv' => $this->getEmulatableCharacterForSqlsrv($character), + }; + } + + protected function getEmulatableCharacterForMysql(string $character): string|Expression|null + { + return match ($character) { + 'a' => 'LOWER(DATE_FORMAT(%s, \'%%p\'))', + 'g' => '((HOUR(%s) + 11) %% 12 + 1)', + 'G' => 'HOUR(%s)', + 't' => 'DAY(LAST_DAY(%s))', + 'U' => 'UNIX_TIMESTAMP(%s)', + 'w' => '(DAYOFWEEK(%s) - 1)', + default => null, + }; + } + + protected function getEmulatableCharacterForSqlite(string $character): string|Expression|null + { + /** @var array $daysRange */ + $daysRange = range(0, 6); + /** @var array $monthsRange */ + $monthsRange = range(1, 12); + + /** @var array $daysFullNames */ + $daysFullNames = array_map( + // @phpstan-ignore-next-line + fn ($dayIndex) => Carbon::now()->weekday($dayIndex)->getTranslatedDayName(), + $daysRange + ); + + /** @var array $daysAbbreviations */ + $daysAbbreviations = array_map( + // @phpstan-ignore-next-line + fn ($dayIndex) => Carbon::now()->weekday($dayIndex)->getTranslatedShortDayName(), + $daysRange + ); + + /** @var array $monthsFullNames */ + $monthsFullNames = array_map( + fn ($monthIndex) => Carbon::now()->month($monthIndex)->getTranslatedMonthName(), + $monthsRange + ); + + /** @var array $monthsShortNames */ + $monthsShortNames = array_map( + fn ($monthIndex) => Carbon::now()->month($monthIndex)->getTranslatedShortMonthName(), + $monthsRange + ); + + return match ($character) { + 'a' => '(CASE WHEN CAST(STRFTIME(\'%%H\', %s) AS INTEGER) < 12 ' + .sprintf( + 'THEN \'%s\' ELSE \'%s\' END)', + Carbon::now()->hour(0)->meridiem(true), + Carbon::now()->hour(12)->meridiem(true) + ), + 'A' => '(CASE WHEN CAST(STRFTIME(\'%%H\', %s) AS INTEGER) < 12 ' + .sprintf( + 'THEN \'%s\' ELSE \'%s\' END)', + Carbon::now()->hour(0)->meridiem(false), + Carbon::now()->hour(12)->meridiem(false) + ), + 'D' => sprintf( + '(CASE %s END)', + implode( + ' ', + array_map( + fn ($dayIndex, $dayAbbrev) => "WHEN CAST(STRFTIME('%%w', %s) AS INTEGER) = $dayIndex THEN '$dayAbbrev'", + $daysRange, + $daysAbbreviations + ) + ) + ), + 'F' => sprintf( + '(CASE %s END)', + implode( + ' ', + array_map( + fn ($monthIndex, $monthFull) => "WHEN CAST(STRFTIME('%%m', %s) AS INTEGER) = $monthIndex THEN '$monthFull'", + $monthsRange, + $monthsFullNames + ) + ) + ), + 'g' => '((STRFTIME(\'%%H\', %s) + 11) %% 12 + 1)', + 'G' => '(CASE WHEN STRFTIME(\'%%H\', %s) = \'00\' THEN \'0\' ELSE LTRIM(STRFTIME(\'%%H\', %s), \'0\') END)', + 'h' => '(CASE WHEN CAST(STRFTIME(\'%%H\', %s) AS INTEGER) = 0 THEN \'12\' WHEN CAST(STRFTIME(\'%%H\', %s) AS INTEGER) <= 12 THEN PRINTF(\'%%02d\', CAST(STRFTIME(\'%%H\', %s) AS INTEGER)) ELSE PRINTF(\'%%02d\', CAST(STRFTIME(\'%%H\', %s) AS INTEGER) - 12) END)', + 'j' => 'LTRIM(STRFTIME(\'%%d\', %s), \'0\')', + 'l' => sprintf( + '(CASE %s END)', + implode( + ' ', + array_map( + fn ($dayIndex, $dayFull) => "WHEN CAST(STRFTIME('%%w', %s) AS INTEGER) = $dayIndex THEN '$dayFull'", + $daysRange, + $daysFullNames + ) + ) + ), + 'M' => sprintf( + '(CASE %s END)', + implode( + ' ', + array_map( + fn ($monthIndex, $monthShort) => "WHEN CAST(STRFTIME('%%m', %s) AS INTEGER) = $monthIndex THEN '$monthShort'", + $monthsRange, + $monthsShortNames + ) + ) + ), + 'n' => 'LTRIM(STRFTIME(\'%%m\', %s), \'0\')', + 'o' => 'STRFTIME(\'%%Y\', %s, \'weekday 0\', \'-3 days\')', + 't' => 'STRFTIME(\'%%d\', DATE(%s, \'+1 month\', \'start of month\', \'-1 day\'))', + 'W' => 'PRINTF(\'%%02d\', (STRFTIME(\'%%j\', DATE(%s, \'-3 days\', \'weekday 4\')) - 1) / 7 + 1)', + 'y' => 'SUBSTR(STRFTIME(\'%%Y\', %s), 3, 2)', + default => null, + }; + } + + protected function getEmulatableCharacterForPgsql(string $character): string|Expression|null + { + return match ($character) { + 'a' => 'LOWER(TO_CHAR(%s, \'AM\'))', + 'F' => 'TRIM(TO_CHAR(%s, \'Month\'))', + 'g' => '(CAST((EXTRACT(HOUR FROM %s)::INTEGER + 11) %% 12 + 1 AS VARCHAR(2)))', + 'G' => 'CAST(EXTRACT(HOUR FROM %s)::INTEGER AS VARCHAR(2))', + 'l' => 'TRIM(TO_CHAR(%s, \'Day\'))', + 't' => 'EXTRACT(DAY FROM DATE_TRUNC(\'month\', %s) + INTERVAL \'1 month - 1 day\')::INTEGER', + 'U' => 'EXTRACT(EPOCH FROM %s)::INTEGER', + 'w' => 'EXTRACT(DOW FROM %s)::INTEGER', + default => null, + }; + } + + protected function getEmulatableCharacterForSqlsrv(string $character): string|Expression|null + { + return match ($character) { + 'a' => 'LOWER(FORMAT(%s, \'tt\'))', + 'F' => 'DATENAME(MONTH, %s)', + 'g' => '((DATEPART(HOUR, %s) + 11) %% 12 + 1)', + 'G' => 'CAST(DATEPART(HOUR, %s) AS VARCHAR(2))', + 'j' => 'CAST(DAY(%s) AS VARCHAR(2))', + 'l' => 'DATENAME(WEEKDAY, %s)', + 'M' => 'LEFT(DATENAME(MONTH, %s), 3)', + 'n' => 'CAST(MONTH(%s) AS VARCHAR(2))', + 'o' => '(CASE WHEN DATEPART(iso_week, %s) = 1 AND MONTH(%s) = 12 THEN YEAR(%s) + 1 WHEN DATEPART(iso_week, %s) > 1 AND MONTH(%s) = 1 THEN YEAR(%s) - 1 ELSE YEAR(%s) END)', + 't' => 'CAST(DAY(EOMONTH(%s)) AS VARCHAR(2))', + 'U' => 'DATEDIFF(SECOND, \'1970-01-01\', %s)', + 'w' => '(CAST(DATEPART(WEEKDAY, %s) AS VARCHAR(2)) - 1) %% 7', + 'W' => '(CASE WHEN DATEPART(ISO_WEEK, %s) = 1 THEN \'01\' WHEN DATEPART(ISO_WEEK, %s) < 10 THEN \'0\' + CAST(DATEPART(ISO_WEEK, %s) AS VARCHAR(2)) ELSE CAST(DATEPART(ISO_WEEK, %s) AS VARCHAR(2)) END)', + 'y' => 'RIGHT(CAST(YEAR(%s) AS VARCHAR(4)), 2)', + default => null, + }; + } +} diff --git a/tests/Function/Date/DateFormatTest.php b/tests/Function/Date/DateFormatTest.php new file mode 100644 index 0000000..ea5e295 --- /dev/null +++ b/tests/Function/Date/DateFormatTest.php @@ -0,0 +1,206 @@ +expect(new DateFormat('created_at', format: 'Y-m-d H:i:s')) + ->toBeExecutable(function (Blueprint $table) { + $table->dateTime('created_at'); + }) + ->toBePgsql('TO_CHAR("created_at", \'YYYY-MM-DD HH24:MI:SS\')') + ->toBeSqlite('STRFTIME(\'%Y-%m-%d %H:%M:%S\', "created_at")') + ->toBeMysql('DATE_FORMAT(`created_at`, \'%Y-%m-%d %H:%i:%s\')') + ->toBeSqlsrv('FORMAT([created_at], \'yyyy-MM-dd HH:mm:ss\')'); + +it('can format dates: [Y] direct character, no concat') + ->expect(new DateFormat('created_at', format: 'Y')) + ->toBeExecutable(function (Blueprint $table) { + $table->dateTime('created_at'); + }) + ->toBePgsql('TO_CHAR("created_at", \'YYYY\')') + ->toBeSqlite('STRFTIME(\'%Y\', "created_at")') + ->toBeMysql('DATE_FORMAT(`created_at`, \'%Y\')') + ->toBeSqlsrv('FORMAT([created_at], \'yyyy\')'); + +it('can format dates: [U] emulated character, no concat') + ->expect(new DateFormat('created_at', format: 'U')) + ->toBeExecutable(function (Blueprint $table) { + $table->dateTime('created_at'); + }) + ->toBePgsql('EXTRACT(EPOCH FROM "created_at")::INTEGER') + ->toBeSqlite('STRFTIME(\'%s\', "created_at")') + ->toBeMysql('UNIX_TIMESTAMP(`created_at`)') + ->toBeSqlsrv('DATEDIFF(SECOND, \'1970-01-01\', [created_at])'); + +it('can format dates: [U-Y-m] emulated and direct characters, no concat for sqlite') + ->expect(new DateFormat('created_at', format: 'U-Y-m')) + ->toBeExecutable(function (Blueprint $table) { + $table->dateTime('created_at'); + }) + ->toBePgsql('(EXTRACT(EPOCH FROM "created_at")::INTEGER||TO_CHAR("created_at", \'-YYYY-MM\'))') + ->toBeSqlite('STRFTIME(\'%s-%Y-%m\', "created_at")') + ->toBeMysql('(concat(UNIX_TIMESTAMP(`created_at`),DATE_FORMAT(`created_at`, \'-%Y-%m\')))') + ->toBeSqlsrv('(concat(DATEDIFF(SECOND, \'1970-01-01\', [created_at]),FORMAT([created_at], \'-yyyy-MM\')))'); + +it('can format dates correctly', function () { + + if ( + DB::connection()->getDriverName() === 'mysql' + ) { + DB::statement('SET time_zone = \'+00:00\''); + } + + $testFormats = [ + '\yy', + 'a j-n-o F w W g G h H i s', + 'Y-m-d\TH:i:s', + 'a', + 'A', + 'd', + 'D', + 'F', + 'G', + 'g', + 'h', + 'H', + 'i', + 'j', + 'l', + 'm', + 'M', + 'n', + 'o', + 's', + 't', + 'U', + 'w', + 'W', + 'Y', + 'y', + ]; + + $testDates = [ + '1970-01-01 00:00:00', + '1970-06-01 00:00:00', + '1970-12-31 23:59:59', + + '2023-01-01 00:00:00', + '2023-06-01 00:00:00', + '2023-12-31 23:59:59', + + '2024-01-01 00:00:00', + '2024-06-01 00:00:00', + '2024-12-31 23:59:59', + + '2025-01-01 00:00:00', + '2025-06-01 00:00:00', + '2025-12-31 23:59:59', + + '2026-01-01 00:00:00', + '2026-06-01 00:00:00', + '2026-12-31 23:59:59', + + '2037-01-01 00:00:00', + '2037-06-01 00:00:00', + '2037-12-31 23:59:59', + ]; + + foreach ($testDates as $date) { + Schema::create($table = 'example_'.mt_rand(), function (Blueprint $table) { + $table->dateTime('created_at'); + }); + + DB::table($table)->insert([ + 'created_at' => $date, + ]); + + $date = new DateTime($date); + + $grammar = DB::connection()->getQueryGrammar(); + + foreach ($testFormats as $format) { + $sql = new DateFormat('created_at', $format); + + expect( + $value = DB::table($table)->selectRaw( + $sql + )->value('created_at') + )->toEqual( + $expected = $date->format($format), + "expected: {$expected}, value: {$value}, format: {$format}, date: {$date->format('Y-m-d H:i:s')}" + .PHP_EOL + .'SQL: ' + .$sql->getValue( + $grammar + ) + ); + } + } +}); + +it('throws exception for unsupported characters', function () { + Schema::create($table = 'example_'.mt_rand(), function (Blueprint $table) { + $table->dateTime('created_at'); + }); + + DB::table($table)->insert([ + 'created_at' => '2021-01-01 09:00:00', + ]); + + DB::table($table)->selectRaw( + new DateFormat('created_at', 'N') + )->value('created_at'); + +})->throws(InvalidArgumentException::class, 'Unsupported format character: N'); + +it('doesn\'t throw exception for escaped characters', function () { + Schema::create($table = 'example_'.mt_rand(), function (Blueprint $table) { + $table->dateTime('created_at'); + }); + + DB::table($table)->insert([ + 'created_at' => '2021-01-01 09:00:00', + ]); + + expect( + DB::table($table)->selectRaw( + new DateFormat('created_at', '\N') + )->value('created_at') + )->toEqual('N'); +}); + +it('can change locale for sqlite', function () { + if ( + DB::connection()->getDriverName() !== 'sqlite' + ) { + $this->markTestSkipped('Only for sqlite'); + } + + Carbon::setLocale('de'); + + Schema::create($table = 'example_'.mt_rand(), function (Blueprint $table) { + $table->dateTime('created_at'); + }); + + DB::table($table)->insert([ + 'created_at' => '2021-01-01 09:00:00', + ]); + + $format = 'a-A-D-F-l-M'; + + $expected = Carbon::make('2021-01-01 09:00:00')->translatedFormat($format); + + $value = DB::table($table)->selectRaw( + new DateFormat('created_at', $format) + )->value('created_at'); + + expect($value)->toEqual($expected); + + Carbon::setLocale('en'); +});