diff --git a/CHANGELOG.md b/CHANGELOG.md index aad20edc..dee37617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#767](https://github.com/shlinkio/shlink/issues/767) Added full support to use emojis everywhere, whether it is custom slugs, titles, referrers, etc. * [#1274](https://github.com/shlinkio/shlink/issues/1274) Added support to filter short URLs lists by all provided tags. The `GET /short-urls` endpoint now accepts a `tagsMode=all` param which will make only short URLs matching **all** the tags in the `tags[]` query param, to be returned. diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index fbeb5ab6..19113c22 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -21,6 +21,13 @@ return (static function (): array { 'mssql' => '1433', default => '3306', }; + $resolveCharset = static fn () => match ($driver) { + // This does not determine charsets or collations in tables or columns, but the charset used in the data + // flowing in the connection, so it has to match what has been set in the database. + 'maria', 'mysql' => 'utf8mb4', + 'postgres' => 'utf8', + default => null, + }; $resolveConnection = static fn () => match ($driver) { null, 'sqlite' => [ 'driver' => 'pdo_sqlite', @@ -34,7 +41,7 @@ return (static function (): array { 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null), 'port' => env('DB_PORT', $resolveDefaultPort()), 'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null, - 'charset' => 'utf8', + 'charset' => $resolveCharset(), ], }; diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index 0624aa51..4a0750a5 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -11,6 +11,7 @@ return [ 'driver' => 'pdo_mysql', 'host' => 'shlink_db_mysql', 'dbname' => 'shlink', + 'charset' => 'utf8mb4', ], ], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index fff7e00a..89807b26 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -55,6 +55,7 @@ $buildDbConnection = static function (): array { 'user' => 'postgres', 'password' => 'root', 'dbname' => 'shlink_test', + 'charset' => 'utf8', ], 'mssql' => [ 'driver' => 'pdo_sqlsrv', @@ -70,6 +71,7 @@ $buildDbConnection = static function (): array { 'user' => 'root', 'password' => 'root', 'dbname' => 'shlink_test', + 'charset' => 'utf8mb4', ], }; }; diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php index 70831eb9..aeb1eb16 100644 --- a/data/migrations/Version20160819142757.php +++ b/data/migrations/Version20160819142757.php @@ -5,45 +5,45 @@ declare(strict_types=1); namespace ShlinkMigrations; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; +use function is_subclass_of; + /** * Auto-generated Migration: Please modify to your needs! */ class Version20160819142757 extends AbstractMigration { - private const MYSQL = 'mysql'; - private const SQLITE = 'sqlite'; - /** * @throws Exception * @throws SchemaException */ public function up(Schema $schema): void { - $db = $this->connection->getDatabasePlatform()->getName(); + $platformClass = $this->connection->getDatabasePlatform(); $table = $schema->getTable('short_urls'); $column = $table->getColumn('short_code'); - if ($db === self::MYSQL) { - $column->setPlatformOption('collation', 'utf8_bin'); - } elseif ($db === self::SQLITE) { - $column->setPlatformOption('collate', 'BINARY'); - } + match (true) { + is_subclass_of($platformClass, MySQLPlatform::class) => $column + ->setPlatformOption('charset', 'utf8mb4') + ->setPlatformOption('collation', 'utf8mb4_bin'), + is_subclass_of($platformClass, SqlitePlatform::class) => $column->setPlatformOption('collate', 'BINARY'), + default => null, + }; } - /** - * @throws Exception - */ public function down(Schema $schema): void { - $this->connection->getDatabasePlatform()->getName(); + // Nothing to roll back } public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20160820191203.php b/data/migrations/Version20160820191203.php index 592e556e..dea327b1 100644 --- a/data/migrations/Version20160820191203.php +++ b/data/migrations/Version20160820191203.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -76,6 +77,6 @@ class Version20160820191203 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20171021093246.php b/data/migrations/Version20171021093246.php index 92c078fa..a810f49c 100644 --- a/data/migrations/Version20171021093246.php +++ b/data/migrations/Version20171021093246.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\DBAL\Types\Types; @@ -48,6 +49,6 @@ class Version20171021093246 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20171022064541.php b/data/migrations/Version20171022064541.php index 88b5f468..fb5f8d7a 100644 --- a/data/migrations/Version20171022064541.php +++ b/data/migrations/Version20171022064541.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\DBAL\Types\Types; @@ -45,6 +46,6 @@ class Version20171022064541 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20180801183328.php b/data/migrations/Version20180801183328.php index 14f2b22c..5fd40030 100644 --- a/data/migrations/Version20180801183328.php +++ b/data/migrations/Version20180801183328.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; @@ -42,6 +43,6 @@ final class Version20180801183328 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php index 23d51d79..fe04a395 100644 --- a/data/migrations/Version20180913205455.php +++ b/data/migrations/Version20180913205455.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkMigrations; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; use PDO; @@ -69,6 +70,6 @@ final class Version20180913205455 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20180915110857.php b/data/migrations/Version20180915110857.php index 8b83053b..b31ac105 100644 --- a/data/migrations/Version20180915110857.php +++ b/data/migrations/Version20180915110857.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; @@ -50,6 +51,6 @@ final class Version20180915110857 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20181020060559.php b/data/migrations/Version20181020060559.php index 85d2c9ba..908bf304 100644 --- a/data/migrations/Version20181020060559.php +++ b/data/migrations/Version20181020060559.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkMigrations; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\DBAL\Schema\Table; @@ -58,7 +59,7 @@ final class Version20181020060559 extends AbstractMigration foreach (self::COLUMNS as $camelCaseName => $snakeCaseName) { $qb->set($snakeCaseName, $camelCaseName); } - $qb->execute(); + $qb->executeStatement(); } public function down(Schema $schema): void @@ -68,6 +69,6 @@ final class Version20181020060559 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20181020065148.php b/data/migrations/Version20181020065148.php index e7b3cf5f..873e7f11 100644 --- a/data/migrations/Version20181020065148.php +++ b/data/migrations/Version20181020065148.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; @@ -41,6 +42,6 @@ final class Version20181020065148 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20181110175521.php b/data/migrations/Version20181110175521.php index 6e26837e..9fb989fa 100644 --- a/data/migrations/Version20181110175521.php +++ b/data/migrations/Version20181110175521.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; @@ -37,6 +38,6 @@ final class Version20181110175521 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20190824075137.php b/data/migrations/Version20190824075137.php index 0681e6fe..663111ff 100644 --- a/data/migrations/Version20190824075137.php +++ b/data/migrations/Version20190824075137.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; @@ -37,6 +38,6 @@ final class Version20190824075137 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20190930165521.php b/data/migrations/Version20190930165521.php index 5699863c..97863843 100644 --- a/data/migrations/Version20190930165521.php +++ b/data/migrations/Version20190930165521.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\DBAL\Types\Types; @@ -55,6 +56,6 @@ final class Version20190930165521 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20191001201532.php b/data/migrations/Version20191001201532.php index 20de0486..fa13b85d 100644 --- a/data/migrations/Version20191001201532.php +++ b/data/migrations/Version20191001201532.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Index; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; @@ -49,6 +50,6 @@ final class Version20191001201532 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20191020074522.php b/data/migrations/Version20191020074522.php index b225f733..c1b9aea9 100644 --- a/data/migrations/Version20191020074522.php +++ b/data/migrations/Version20191020074522.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Column; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; @@ -37,6 +38,6 @@ final class Version20191020074522 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20200105165647.php b/data/migrations/Version20200105165647.php index ed68850a..fb3b7961 100644 --- a/data/migrations/Version20200105165647.php +++ b/data/migrations/Version20200105165647.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace ShlinkMigrations; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -38,7 +40,7 @@ final class Version20200105165647 extends AbstractMigration 'zeroValue' => '0', 'emptyString' => '', ]) - ->execute(); + ->executeStatement(); } } @@ -61,14 +63,14 @@ final class Version20200105165647 extends AbstractMigration */ public function postUp(Schema $schema): void { - $platformName = $this->connection->getDatabasePlatform()->getName(); - $castType = $platformName === 'postgres' ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)'; + $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; + $castType = $isPostgres ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)'; foreach (self::COLUMNS as $newName => $oldName) { $qb = $this->connection->createQueryBuilder(); $qb->update('visit_locations') ->set($newName, 'CAST(' . $oldName . ' AS ' . $castType . ')') - ->execute(); + ->executeStatement(); } } @@ -78,7 +80,7 @@ final class Version20200105165647 extends AbstractMigration $qb = $this->connection->createQueryBuilder(); $qb->update('visit_locations') ->set($oldName, $newName) - ->execute(); + ->executeStatement(); } } @@ -96,6 +98,6 @@ final class Version20200105165647 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20200106215144.php b/data/migrations/Version20200106215144.php index 0b760ced..830daf64 100644 --- a/data/migrations/Version20200106215144.php +++ b/data/migrations/Version20200106215144.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace ShlinkMigrations; use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -47,6 +48,6 @@ final class Version20200106215144 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20200110182849.php b/data/migrations/Version20200110182849.php index 6c66788e..b267bfbc 100644 --- a/data/migrations/Version20200110182849.php +++ b/data/migrations/Version20200110182849.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Exception; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -36,6 +38,9 @@ final class Version20200110182849 extends AbstractMigration ); } + /** + * @throws Exception + */ public function setDefaultValueForColumnInTable(string $tableName, string $columnName): void { $qb = $this->connection->createQueryBuilder(); @@ -43,7 +48,7 @@ final class Version20200110182849 extends AbstractMigration ->set($columnName, ':emptyValue') ->setParameter('emptyValue', self::DEFAULT_EMPTY_VALUE) ->where($qb->expr()->isNull($columnName)) - ->execute(); + ->executeStatement(); } public function down(Schema $schema): void @@ -53,6 +58,6 @@ final class Version20200110182849 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20200323190014.php b/data/migrations/Version20200323190014.php index 92abb87c..f76df5e7 100644 --- a/data/migrations/Version20200323190014.php +++ b/data/migrations/Version20200323190014.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -32,7 +33,7 @@ final class Version20200323190014 extends AbstractMigration ->andWhere($qb->expr()->eq('lon', 0)) ->setParameter('isEmpty', true) ->setParameter('emptyString', '') - ->execute(); + ->executeStatement(); } public function down(Schema $schema): void @@ -45,6 +46,6 @@ final class Version20200323190014 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20200503170404.php b/data/migrations/Version20200503170404.php index 418cbea3..ad2c63df 100644 --- a/data/migrations/Version20200503170404.php +++ b/data/migrations/Version20200503170404.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -27,6 +28,6 @@ final class Version20200503170404 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20201023090929.php b/data/migrations/Version20201023090929.php index 0a36f06a..4655cbd5 100644 --- a/data/migrations/Version20201023090929.php +++ b/data/migrations/Version20201023090929.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -44,6 +45,6 @@ final class Version20201023090929 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20201102113208.php b/data/migrations/Version20201102113208.php index 79cec197..92647c7f 100644 --- a/data/migrations/Version20201102113208.php +++ b/data/migrations/Version20201102113208.php @@ -6,6 +6,7 @@ namespace ShlinkMigrations; use Cake\Chronos\Chronos; use Doctrine\DBAL\Driver\Result; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -86,6 +87,6 @@ final class Version20201102113208 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210102174433.php b/data/migrations/Version20210102174433.php index 60ce36cf..58ea36cd 100644 --- a/data/migrations/Version20210102174433.php +++ b/data/migrations/Version20210102174433.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -52,6 +53,6 @@ final class Version20210102174433 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210118153932.php b/data/migrations/Version20210118153932.php index d81c4857..476f8d84 100644 --- a/data/migrations/Version20210118153932.php +++ b/data/migrations/Version20210118153932.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -26,6 +27,6 @@ final class Version20210118153932 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210202181026.php b/data/migrations/Version20210202181026.php index 4ecfa8de..7a63b814 100644 --- a/data/migrations/Version20210202181026.php +++ b/data/migrations/Version20210202181026.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -36,6 +37,6 @@ final class Version20210202181026 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php index 6d9e9822..706132cc 100644 --- a/data/migrations/Version20210207100807.php +++ b/data/migrations/Version20210207100807.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -43,6 +44,6 @@ final class Version20210207100807 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210306165711.php b/data/migrations/Version20210306165711.php index cb69741f..ba1a4476 100644 --- a/data/migrations/Version20210306165711.php +++ b/data/migrations/Version20210306165711.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -37,6 +38,6 @@ final class Version20210306165711 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210522051601.php b/data/migrations/Version20210522051601.php index 70e0fb34..279c7a7e 100644 --- a/data/migrations/Version20210522051601.php +++ b/data/migrations/Version20210522051601.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -26,6 +27,6 @@ final class Version20210522051601 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210522124633.php b/data/migrations/Version20210522124633.php index f56b8a92..921e0831 100644 --- a/data/migrations/Version20210522124633.php +++ b/data/migrations/Version20210522124633.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -28,6 +29,6 @@ final class Version20210522124633 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20210720143824.php b/data/migrations/Version20210720143824.php index 09e97cfa..407c5c79 100644 --- a/data/migrations/Version20210720143824.php +++ b/data/migrations/Version20210720143824.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; @@ -41,6 +42,6 @@ final class Version20210720143824 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20211002072605.php b/data/migrations/Version20211002072605.php index 03c98885..970d51d6 100644 --- a/data/migrations/Version20211002072605.php +++ b/data/migrations/Version20211002072605.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkMigrations; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -26,6 +27,6 @@ final class Version20211002072605 extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/data/migrations/Version20220110113313.php b/data/migrations/Version20220110113313.php new file mode 100644 index 00000000..2b2fb4ea --- /dev/null +++ b/data/migrations/Version20220110113313.php @@ -0,0 +1,73 @@ + [ + 'original_url' => 'unicode_ci', + 'short_code' => 'bin', + 'import_original_short_code' => 'unicode_ci', + 'title' => 'unicode_ci', + ], + 'domains' => [ + 'authority' => 'unicode_ci', + 'base_url_redirect' => 'unicode_ci', + 'regular_not_found_redirect' => 'unicode_ci', + 'invalid_short_url_redirect' => 'unicode_ci', + ], + 'tags' => [ + 'name' => 'unicode_ci', + ], + 'visits' => [ + 'referer' => 'unicode_ci', + 'user_agent' => 'unicode_ci', + 'visited_url' => 'unicode_ci', + ], + 'visit_locations' => [ + 'country_code' => 'unicode_ci', + 'country_name' => 'unicode_ci', + 'region_name' => 'unicode_ci', + 'city_name' => 'unicode_ci', + 'timezone' => 'unicode_ci', + ], + ]; + + public function up(Schema $schema): void + { + $this->skipIf(! $this->isMySql(), 'This only sets MySQL-specific database options'); + + foreach (self::COLLATIONS as $tableName => $columns) { + $table = $schema->getTable($tableName); + + foreach ($columns as $columnName => $collation) { + $table->getColumn($columnName) + ->setPlatformOption('charset', self::CHARSET) + ->setPlatformOption('collation', self::CHARSET . '_' . $collation); + } + } + } + + public function down(Schema $schema): void + { + // No down + } + + public function isTransactional(): bool + { + return ! $this->isMySql(); + } + + private function isMySql(): bool + { + return $this->connection->getDatabasePlatform() instanceof MySQLPlatform; + } +} diff --git a/data/migrations_template.txt b/data/migrations_template.txt index fa671070..23040083 100644 --- a/data/migrations_template.txt +++ b/data/migrations_template.txt @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; @@ -21,6 +22,6 @@ final class extends AbstractMigration public function isTransactional(): bool { - return $this->connection->getDatabasePlatform()->getName() !== 'mysql'; + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); } } diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php index 596f41da..68427b42 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php @@ -21,21 +21,21 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('authority', Types::STRING) + fieldWithUtf8Charset($builder->createField('authority', Types::STRING), $emConfig) ->unique() ->build(); - $builder->createField('baseUrlRedirect', Types::STRING) + fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::STRING), $emConfig) ->columnName('base_url_redirect') ->nullable() ->build(); - $builder->createField('regular404Redirect', Types::STRING) + fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::STRING), $emConfig) ->columnName('regular_not_found_redirect') ->nullable() ->build(); - $builder->createField('invalidShortUrlRedirect', Types::STRING) + fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::STRING), $emConfig) ->columnName('invalid_short_url_redirect') ->nullable() ->build(); diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php index 83fd7e79..4aefe26b 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php @@ -23,12 +23,12 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('longUrl', Types::STRING) + fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) ->columnName('original_url') ->length(2048) ->build(); - $builder->createField('shortCode', Types::STRING) + fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin') ->columnName('short_code') ->length(255) ->build(); @@ -57,7 +57,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->nullable() ->build(); - $builder->createField('importOriginalShortCode', Types::STRING) + fieldWithUtf8Charset($builder->createField('importOriginalShortCode', Types::STRING), $emConfig) ->columnName('import_original_short_code') ->nullable() ->build(); @@ -85,7 +85,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); - $builder->createField('title', Types::STRING) + fieldWithUtf8Charset($builder->createField('title', Types::STRING), $emConfig) ->columnName('title') ->length(512) ->nullable() diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php index 97d15758..9f02ec72 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php @@ -21,7 +21,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('name', Types::STRING) + fieldWithUtf8Charset($builder->createField('name', Types::STRING), $emConfig) ->unique() ->build(); diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index 8886e141..969bfd1d 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -23,7 +23,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('referer', Types::STRING) + fieldWithUtf8Charset($builder->createField('referer', Types::STRING), $emConfig) ->nullable() ->length(Visitor::REFERER_MAX_LENGTH) ->build(); @@ -40,7 +40,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->nullable() ->build(); - $builder->createField('userAgent', Types::STRING) + fieldWithUtf8Charset($builder->createField('userAgent', Types::STRING), $emConfig) ->columnName('user_agent') ->length(Visitor::USER_AGENT_MAX_LENGTH) ->nullable() @@ -55,7 +55,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->cascadePersist() ->build(); - $builder->createField('visitedUrl', Types::STRING) + fieldWithUtf8Charset($builder->createField('visitedUrl', Types::STRING), $emConfig) ->columnName('visited_url') ->length(Visitor::VISITED_URL_MAX_LENGTH) ->nullable() diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php index 955fa1fa..0216f7aa 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php @@ -29,7 +29,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ]; foreach ($columns as $columnName => $fieldName) { - $builder->createField($fieldName, Types::STRING) + fieldWithUtf8Charset($builder->createField($fieldName, Types::STRING), $emConfig) ->columnName($columnName) ->nullable() ->build(); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index bba0c17b..567fde47 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core; use Cake\Chronos\Chronos; use DateTimeInterface; +use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; @@ -13,13 +14,10 @@ use Shlinkio\Shlink\Common\Util\DateRange; use function Functional\reduce_left; use function is_array; -use function lcfirst; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; -use function str_replace; -use function ucwords; function generateRandomShortCode(int $length): string { @@ -34,7 +32,7 @@ function generateRandomShortCode(int $length): string function parseDateFromQuery(array $query, string $dateName): ?Chronos { - return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]); + return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]); } function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange @@ -100,11 +98,6 @@ function arrayToString(array $array, int $indentSize = 4): string }, ''); } -function kebabCaseToCamelCase(string $name): string -{ - return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $name)))); -} - function isCrawler(string $userAgent): bool { static $detector; @@ -114,3 +107,12 @@ function isCrawler(string $userAgent): bool return $detector->isCrawler($userAgent); } + +function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder +{ + return match ($emConfig['connection']['driver'] ?? null) { + 'pdo_mysql' => $field->option('charset', 'utf8mb4') + ->option('collation', 'utf8mb4_' . $collation), + default => $field, + }; +} diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 1ec36677..3cc98786 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -17,19 +17,17 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface public function stringify(ShortUrl $shortUrl): string { - return (new Uri())->withPath($shortUrl->getShortCode()) - ->withScheme($this->domainConfig['schema'] ?? 'http') - ->withHost($this->resolveDomain($shortUrl)) - ->__toString(); + $uriWithoutShortCode = (new Uri())->withScheme($this->domainConfig['schema'] ?? 'http') + ->withHost($this->resolveDomain($shortUrl)) + ->withPath($this->basePath) + ->__toString(); + + // The short code needs to be appended to avoid it from being URL-encoded + return sprintf('%s/%s', $uriWithoutShortCode, $shortUrl->getShortCode()); } private function resolveDomain(ShortUrl $shortUrl): string { - $domain = $shortUrl->getDomain(); - if ($domain === null) { - return $this->domainConfig['hostname'] ?? ''; - } - - return sprintf('%s%s', $domain->getAuthority(), $this->basePath); + return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? ''; } } diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php index b4acc417..4fed4329 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php @@ -43,6 +43,18 @@ class ShortUrlStringifierTest extends TestCase $shortUrlWithShortCode('bar'), 'http://example.com/bar', ]; + yield 'special chars in short code' => [ + ['hostname' => 'example.com'], + '', + $shortUrlWithShortCode('グーグル'), + 'http://example.com/グーグル', + ]; + yield 'emojis in short code' => [ + ['hostname' => 'example.com'], + '', + $shortUrlWithShortCode('🦣-🍅'), + 'http://example.com/🦣-🍅', + ]; yield 'hostname with base path in config' => [ ['hostname' => 'example.com/foo/bar'], '', diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index e9768a69..0abd2021 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -315,11 +315,22 @@ class CreateShortUrlTest extends ApiTestCase yield ['https://mobile.twitter.com/shlinkio/status/1360637738421268481']; } + /** @test */ + public function canCreateShortUrlsWithEmojis(): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'https://emojipedia.org/fire/', + 'title' => '🔥🔥🔥', + 'customSlug' => '🦣🦣🦣', + ]); + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertEquals('🔥🔥🔥', $payload['title']); + self::assertEquals('🦣🦣🦣', $payload['shortCode']); + self::assertEquals('http://doma.in/🦣🦣🦣', $payload['shortUrl']); + } + /** - * @return array { - * @var int $statusCode - * @var array $payload - * } + * @return array{int $statusCode, array $payload} */ private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array {