diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index 94fa782c6..57263c1eb 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -48,7 +48,7 @@ jobs: run: | if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev; fi if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev && composer --no-interaction --no-update require jdorn/sql-formatter; fi - if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/* --dev; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/\* --dev; fi composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - name: "Run tests: SQLite (only for Phpunit)" @@ -144,11 +144,11 @@ jobs: run: | if [ "${{ matrix.type }}" != "Phpunit" ] && [ "${{ matrix.type }}" != "Phpunit Lowest" ] && [ "${{ matrix.type }}" != "Phpunit Burn" ]; then composer remove --no-interaction --no-update phpunit/phpunit johnkary/phpunit-speedtrap --dev; fi if [ "${{ matrix.type }}" != "CodingStyle" ]; then composer remove --no-interaction --no-update friendsofphp/php-cs-fixer --dev && composer --no-update --ansi --prefer-dist --no-interaction --no-progress require jdorn/sql-formatter; fi - if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/* --dev; fi + if [ "${{ matrix.type }}" != "StaticAnalysis" ]; then composer remove --no-interaction --no-update phpstan/\* --dev; fi if [ -n "$LOG_COVERAGE" ]; then composer require --no-interaction --no-update phpunit/phpcov; fi composer update --ansi --prefer-dist --no-interaction --no-progress --optimize-autoloader - if [ "${{ matrix.type }}" == "Phpunit Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader; fi - if [ "${{ matrix.type }}" == "Phpunit Burn" ]; then sed -i 's~ *public function runBare(): void~public function runBare(): void { gc_collect_cycles(); gc_collect_cycles(); $memDiffs = array_fill(0, '"$(if [ \"$GITHUB_EVENT_NAME\" == \"schedule\" ]; then echo 64; else echo 4; fi)"', 0); for ($i = -1; $i < count($memDiffs); ++$i) { $this->_runBare(); gc_collect_cycles(); gc_collect_cycles(); $mem = memory_get_usage(); if ($i !== -1) { $memDiffs[$i] = $mem - $memPrev; } $memPrev = $mem; rsort($memDiffs); if (array_sum($memDiffs) >= 4096 * 1024 || $memDiffs[2] > 0) { $this->onNotSuccessfulTest(new AssertionFailedError( "Memory leak detected! (" . implode(" + ", array_map(fn ($v) => number_format($v / 1024, 3, ".", " "), array_filter($memDiffs))) . " KB, " . ($i + 2) . " iterations)" )); } } } private function _runBare(): void~' vendor/phpunit/phpunit/src/Framework/TestCase.php && cat vendor/phpunit/phpunit/src/Framework/TestCase.php | grep '_runBare('; fi + if [ "${{ matrix.type }}" = "Phpunit Lowest" ]; then composer update --ansi --prefer-dist --prefer-lowest --prefer-stable --no-interaction --no-progress --optimize-autoloader; fi + if [ "${{ matrix.type }}" = "Phpunit Burn" ]; then sed -i 's~ *public function runBare(): void~public function runBare(): void { gc_collect_cycles(); gc_collect_cycles(); $memDiffs = array_fill(0, '"$(if [ \"$GITHUB_EVENT_NAME\" == \"schedule\" ]; then echo 64; else echo 4; fi)"', 0); for ($i = -1; $i < count($memDiffs); ++$i) { $this->_runBare(); gc_collect_cycles(); gc_collect_cycles(); $mem = memory_get_usage(); if ($i !== -1) { $memDiffs[$i] = $mem - $memPrev; } $memPrev = $mem; rsort($memDiffs); if (array_sum($memDiffs) >= 4096 * 1024 || $memDiffs[2] > 0) { $this->onNotSuccessfulTest(new AssertionFailedError( "Memory leak detected! (" . implode(" + ", array_map(fn ($v) => number_format($v / 1024, 3, ".", " "), array_filter($memDiffs))) . " KB, " . ($i + 2) . " iterations)" )); } } } private function _runBare(): void~' vendor/phpunit/phpunit/src/Framework/TestCase.php && cat vendor/phpunit/phpunit/src/Framework/TestCase.php | grep '_runBare('; fi - name: Init run: | @@ -201,9 +201,10 @@ jobs: - name: "Run tests: Oracle (only for coverage or cron)" if: env.LOG_COVERAGE || github.event_name == 'schedule' env: - DB_DSN: "oci:dbname=oracle/xe;charset=UTF8" + DB_DSN: "oci:dbname=oracle/xe" DB_USER: system DB_PASSWORD: oracle + NLS_LANG: AMERICAN_AMERICA.AL32UTF8 run: | php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v \ || php -d opcache.enable_cli=1 vendor/bin/phpunit --exclude-group none $(if [ -n "$LOG_COVERAGE" ]; then echo --coverage-text; else echo --no-coverage; fi) -v diff --git a/bootstrap-types.php b/bootstrap-types.php index 73d9a58e1..6968130cc 100644 --- a/bootstrap-types.php +++ b/bootstrap-types.php @@ -39,6 +39,11 @@ public function convertToPHPValue($value, AbstractPlatform $platform): ?float return $v === null ? null : (float) $v; } + + public function requiresSQLCommentHint(AbstractPlatform $platform) + { + return true; + } } DbalTypes\Type::addType(Types::MONEY, MoneyType::class); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2ef68e10b..3da4eb44e 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -31,7 +31,7 @@ parameters: message: '~^(Call to an undefined method Doctrine\\DBAL\\Driver\\Connection::getWrappedConnection\(\)\.|Caught class Doctrine\\DBAL\\DBALException not found\.|Call to static method notSupported\(\) on an unknown class Doctrine\\DBAL\\DBALException\.|Access to an undefined property Doctrine\\DBAL\\Driver\\PDO\\Connection::\$connection\.|Method Atk4\\Data\\Persistence\\Sql\\Expression::execute\(\) should return Doctrine\\DBAL\\Result\|PDOStatement but returns bool\.|Class Doctrine\\DBAL\\Platforms\\MySqlPlatform referenced with incorrect case: Doctrine\\DBAL\\Platforms\\MySQLPlatform\.)$~' path: '*' # count for DBAL 3.x matched in "src/Persistence/GenericPlatform.php" file - count: 10 + count: 11 # TODO these rules are generated, this ignores should be fixed in the code # for src/Schema/TestCase.php diff --git a/src/Persistence.php b/src/Persistence.php index 7deeb9e9c..941941709 100644 --- a/src/Persistence.php +++ b/src/Persistence.php @@ -45,20 +45,11 @@ public static function connect($dsn, string $user = null, string $password = nul $dsn = \Atk4\Data\Persistence\Sql\Connection::normalizeDsn($dsn, $user, $password); switch ($dsn['driverSchema']) { + case 'sqlite': case 'mysql': - case 'oci': - case 'oci12': - // Omitting UTF8 is always a bad problem, so unless it's specified we will do that - // to prevent nasty problems. This is un-tested on other databases, so moving it here. - // It gives problem with sqlite - if (strpos($dsn['dsn'], ';charset=') === false) { - $dsn['dsn'] .= ';charset=utf8mb4'; - } - - // no break case 'pgsql': case 'sqlsrv': - case 'sqlite': + case 'oci': $db = new \Atk4\Data\Persistence\Sql($dsn['dsn'], $dsn['user'], $dsn['pass'], $args); return $db; diff --git a/src/Persistence/GenericPlatform.php b/src/Persistence/GenericPlatform.php index 2ba533d7d..02aa5f95b 100644 --- a/src/Persistence/GenericPlatform.php +++ b/src/Persistence/GenericPlatform.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Exception as DbalException; use Doctrine\DBAL\Platforms; +use Mvorisek\Atk4\Hintable\Phpstan\PhpstanUtil; class GenericPlatform extends Platforms\AbstractPlatform { @@ -13,7 +14,8 @@ private function createNotSupportedException(): \Exception // DbalException once { if (\Atk4\Data\Persistence\Sql\Connection::isComposerDbal2x()) { // hack for PHPStan, keep ignored error count for DBAL 2.x and DBAL 3.x the same - if (\PHP_MAJOR_VERSION === 0) { + if (PhpstanUtil::alwaysFalseAnalyseOnly()) { + \Doctrine\DBAL\DBALException::notSupported('SQL'); \Doctrine\DBAL\DBALException::notSupported('SQL'); \Doctrine\DBAL\DBALException::notSupported('SQL'); \Doctrine\DBAL\DBALException::notSupported('SQL'); diff --git a/src/Persistence/Sql.php b/src/Persistence/Sql.php index c6e82ed3e..65822f676 100644 --- a/src/Persistence/Sql.php +++ b/src/Persistence/Sql.php @@ -20,6 +20,8 @@ class Sql extends Persistence { + use Sql\BinaryTypeCompatibilityTypecastTrait; + /** @const string */ public const HOOK_INIT_SELECT_QUERY = self::class . '@initSelectQuery'; /** @const string */ diff --git a/src/Persistence/Sql/BinaryTypeCompatibilityTypecastTrait.php b/src/Persistence/Sql/BinaryTypeCompatibilityTypecastTrait.php new file mode 100644 index 000000000..065e1dbe6 --- /dev/null +++ b/src/Persistence/Sql/BinaryTypeCompatibilityTypecastTrait.php @@ -0,0 +1,84 @@ +binaryTypeValueGetPrefixConst() . hash('crc32b', $hex) . $hex; + } + + private function binaryTypeValueIsEncoded(string $value): bool + { + return str_starts_with($value, $this->binaryTypeValueGetPrefixConst()); + } + + private function binaryTypeValueDecode(string $value): string + { + if (!$this->binaryTypeValueIsEncoded($value)) { + throw new Exception('Unexpected unencoded binary value'); + } + + $hexCrc = substr($value, strlen($this->binaryTypeValueGetPrefixConst()), 8); + $hex = substr($value, strlen($this->binaryTypeValueGetPrefixConst()) + 8); + if ((strlen($hex) % 2) !== 0 || $hexCrc !== hash('crc32b', $hex)) { + throw new Exception('Unexpected binary value crc'); + } + + return hex2bin($hex); + } + + private function binaryTypeIsEncodeNeeded(Type $type): bool + { + // TODO PostgreSQL tests fail without binary compatibility typecast + $platform = $this->getDatabasePlatform(); + if ($platform instanceof PostgreSQL94Platform + || $platform instanceof SQLServer2012Platform + || $platform instanceof OraclePlatform) { + if (in_array($type->getName(), ['binary', 'blob'], true)) { + return true; + } + } + + return false; + } + + public function typecastSaveField(Field $field, $value) + { + $value = parent::typecastSaveField($field, $value); + + if ($value !== null && $this->binaryTypeIsEncodeNeeded($field->getTypeObject())) { + $value = $this->binaryTypeValueEncode($value); + } + + return $value; + } + + public function typecastLoadField(Field $field, $value) + { + $value = parent::typecastLoadField($field, $value); + + if ($value !== null && $this->binaryTypeIsEncodeNeeded($field->getTypeObject())) { + $value = $this->binaryTypeValueDecode($value); + } + + return $value; + } +} diff --git a/src/Persistence/Sql/Connection.php b/src/Persistence/Sql/Connection.php index a095adb5e..c2df6131f 100644 --- a/src/Persistence/Sql/Connection.php +++ b/src/Persistence/Sql/Connection.php @@ -212,6 +212,16 @@ protected static function connectDbalConnection(array $dsn) if (isset($dsn['pdo'])) { $pdo = $dsn['pdo']; } else { + $enforceCharset = [ + 'mysql' => 'utf8mb4', + 'oci' => 'AL32UTF8', + ][$dsn['driverSchema']] ?? null; + + if ($enforceCharset !== null) { + $dsn['dsn'] = preg_replace('~; *charset=[^;]+~i', '', $dsn['dsn']) + . ';charset=' . $enforceCharset; + } + $pdo = new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']); } diff --git a/src/Persistence/Sql/Expression.php b/src/Persistence/Sql/Expression.php index 1233d93f1..5957b4ce2 100644 --- a/src/Persistence/Sql/Expression.php +++ b/src/Persistence/Sql/Expression.php @@ -7,6 +7,7 @@ use Atk4\Core\WarnDynamicPropertyTrait; use Doctrine\DBAL\Connection as DbalConnection; use Doctrine\DBAL\Exception as DbalException; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\PostgreSQL94Platform; use Doctrine\DBAL\Result as DbalResult; @@ -512,19 +513,21 @@ public function execute(object $connection = null): object foreach ($this->params as $key => $val) { if (is_int($val)) { - $type = \PDO::PARAM_INT; + $type = ParameterType::INTEGER; } elseif (is_bool($val)) { if ($this->connection->getDatabasePlatform() instanceof PostgreSQL94Platform) { - $type = \PDO::PARAM_STR; + $type = ParameterType::STRING; $val = $val ? '1' : '0'; } else { - $type = \PDO::PARAM_INT; + $type = ParameterType::INTEGER; $val = $val ? 1 : 0; } } elseif ($val === null) { - $type = \PDO::PARAM_NULL; - } elseif (is_string($val) || is_float($val)) { - $type = \PDO::PARAM_STR; + $type = ParameterType::NULL; + } elseif (is_float($val)) { + $type = ParameterType::STRING; + } elseif (is_string($val)) { + $type = ParameterType::STRING; } elseif (is_resource($val)) { throw new Exception('Resource type is not supported, set value as string instead'); } else { @@ -594,9 +597,11 @@ private function getCastValue($v): ?string return $v ? '1' : '0'; } - // for Oracle CLOB/BLOB datatypes and PDO driver - if (is_resource($v) && get_resource_type($v) === 'stream' - && $this->connection->getDatabasePlatform() instanceof OraclePlatform) { + // for PostgreSQL/Oracle CLOB/BLOB datatypes and PDO driver + if (is_resource($v) && get_resource_type($v) === 'stream' && ( + $this->connection->getDatabasePlatform() instanceof PostgreSQL94Platform + || $this->connection->getDatabasePlatform() instanceof OraclePlatform + )) { $v = stream_get_contents($v); } diff --git a/src/Persistence/Sql/Mssql/ExpressionTrait.php b/src/Persistence/Sql/Mssql/ExpressionTrait.php index a791ae574..d25f0b79b 100644 --- a/src/Persistence/Sql/Mssql/ExpressionTrait.php +++ b/src/Persistence/Sql/Mssql/ExpressionTrait.php @@ -23,6 +23,14 @@ private function fixOpenEscapeChar(string $v): string return preg_replace('~(?:\'(?:\'\'|\\\\\'|[^\'])*\')?+\K\]([^\[\]\'"(){}]*?)\]~s', '[$1]', $v); } + private function _render(): string + { + // convert all SQL strings to NVARCHAR, eg 'text' to N'text' + return preg_replace_callback('~(^|.)(\'(?:\'\'|\\\\\'|[^\'])*\')~s', function ($matches) { + return $matches[1] . (!in_array($matches[1], ['N', '\'', '\\'], true) ? 'N' : '') . $matches[2]; + }, parent::render()); + } + // {{{ MSSQL does not support named parameters, so convert them to numerical inside execute /** @var array|null */ @@ -51,7 +59,7 @@ function ($matches) use (&$numParams, &$i, &$j) { return '?'; }, - parent::render() + $this->_render() ); $this->params = $numParams; @@ -69,7 +77,7 @@ public function render(): string return $this->numQueryRender; } - return parent::render(); + return $this->_render(); } public function getDebugQuery(): string diff --git a/src/Persistence/Sql/Mssql/PlatformTrait.php b/src/Persistence/Sql/Mssql/PlatformTrait.php index 1cf35c62c..b93890094 100644 --- a/src/Persistence/Sql/Mssql/PlatformTrait.php +++ b/src/Persistence/Sql/Mssql/PlatformTrait.php @@ -6,6 +6,37 @@ trait PlatformTrait { + // SQL Server database requires explicit conversion when using binary column, + // workaround by using a standard non-binary column with custom encoding/typecast + + protected function getBinaryTypeDeclarationSQLSnippet($length, $fixed) + { + return $this->getVarcharTypeDeclarationSQLSnippet($length, $fixed); + } + + public function getBlobTypeDeclarationSQL(array $column) + { + return $this->getClobTypeDeclarationSQL($column); + } + + // remove once https://github.com/doctrine/dbal/pull/4987 is fixed + // and also $this->markDoctrineTypeCommented('text') below + public function getClobTypeDeclarationSQL(array $column) + { + $res = parent::getClobTypeDeclarationSQL($column); + + return (str_starts_with($res, 'VARCHAR') ? 'N' : '') . $res; + } + + protected function initializeCommentedDoctrineTypes() + { + parent::initializeCommentedDoctrineTypes(); + + $this->markDoctrineTypeCommented('binary'); + $this->markDoctrineTypeCommented('blob'); + $this->markDoctrineTypeCommented('text'); + } + // SQL Server DBAL platform has buggy identifier escaping, fix until fixed officially, see: // https://github.com/doctrine/dbal/pull/4360 diff --git a/src/Persistence/Sql/Oracle/AbstractQuery.php b/src/Persistence/Sql/Oracle/AbstractQuery.php deleted file mode 100644 index 12ea6f0c2..000000000 --- a/src/Persistence/Sql/Oracle/AbstractQuery.php +++ /dev/null @@ -1,24 +0,0 @@ -mode === 'select' && $this->main_table === null) { - $this->table('DUAL'); - } - - return parent::render(); - } - - public function groupConcat($field, string $delimiter = ',') - { - return $this->expr('listagg({field}, []) within group (order by {field})', ['field' => $field, $delimiter]); - } -} diff --git a/src/Persistence/Sql/Oracle/PlatformTrait.php b/src/Persistence/Sql/Oracle/PlatformTrait.php index d83863aa5..2127ba1a3 100644 --- a/src/Persistence/Sql/Oracle/PlatformTrait.php +++ b/src/Persistence/Sql/Oracle/PlatformTrait.php @@ -10,6 +10,14 @@ trait PlatformTrait { + // Oracle database requires explicit conversion when using binary column, + // workaround by using a standard non-binary column with custom encoding/typecast + + protected function getBinaryTypeDeclarationSQLSnippet($length, $fixed) + { + return $this->getVarcharTypeDeclarationSQLSnippet($length, $fixed); + } + // Oracle CLOB/BLOB has limited SQL support, see: // https://stackoverflow.com/questions/12980038/ora-00932-inconsistent-datatypes-expected-got-clob#12980560 // fix this Oracle inconsistency by using VARCHAR/VARBINARY instead (but limited to 4000 bytes) @@ -41,6 +49,15 @@ public function getBlobTypeDeclarationSQL(array $column) return $this->forwardTypeDeclarationSQL('getBinaryTypeDeclarationSQL', $column); } + protected function initializeCommentedDoctrineTypes() + { + parent::initializeCommentedDoctrineTypes(); + + $this->markDoctrineTypeCommented('binary'); + $this->markDoctrineTypeCommented('text'); + $this->markDoctrineTypeCommented('blob'); + } + // Oracle DBAL platform autoincrement implementation does not increment like // Sqlite or MySQL does, unify the behaviour diff --git a/src/Persistence/Sql/Oracle/Query.php b/src/Persistence/Sql/Oracle/Query.php index 97aa846c7..95286253c 100644 --- a/src/Persistence/Sql/Oracle/Query.php +++ b/src/Persistence/Sql/Oracle/Query.php @@ -4,8 +4,24 @@ namespace Atk4\Data\Persistence\Sql\Oracle; -class Query extends AbstractQuery +use Atk4\Data\Persistence\Sql\Query as BaseQuery; + +class Query extends BaseQuery { + public function render(): string + { + if ($this->mode === 'select' && $this->main_table === null) { + $this->table('DUAL'); + } + + return parent::render(); + } + + public function groupConcat($field, string $delimiter = ',') + { + return $this->expr('listagg({field}, []) within group (order by {field})', ['field' => $field, $delimiter]); + } + // {{{ for Oracle 11 and lower to support LIMIT with OFFSET protected $template_select = '[with]select[option] [field] [from] [table][join][where][group][having][order]'; diff --git a/src/Persistence/Sql/Query.php b/src/Persistence/Sql/Query.php index 89aa828dc..85f6ff6a7 100644 --- a/src/Persistence/Sql/Query.php +++ b/src/Persistence/Sql/Query.php @@ -485,20 +485,20 @@ public function _render_join(): ?string * To specify OR conditions: * $q->where($q->orExpr()->where('a', 1)->where('b', 1)); * - * @param string|Expressionable $field Field or Expression - * @param mixed $cond Condition such as '=', '>' or 'is not' - * @param mixed $value Value. Will be quoted unless you pass expression - * @param string $kind Do not use directly. Use having() - * @param int $num_args when $kind is passed, we can't determine number of - * actual arguments, so this argument must be specified + * @param string|Expressionable $field Field or Expression + * @param mixed $cond Condition such as '=', '>' or 'is not' + * @param mixed $value Value. Will be quoted unless you pass expression + * @param string $kind Do not use directly. Use having() + * @param int $numArgs when $kind is passed, we can't determine number of + * actual arguments, so this argument must be specified * * @return $this */ - public function where($field, $cond = null, $value = null, $kind = 'where', $num_args = null) + public function where($field, $cond = null, $value = null, $kind = 'where', $numArgs = null) { // Number of passed arguments will be used to determine if arguments were specified or not - if ($num_args === null) { - $num_args = func_num_args(); + if ($numArgs === null) { + $numArgs = func_num_args(); } // remove in v4.0 @@ -506,68 +506,38 @@ public function where($field, $cond = null, $value = null, $kind = 'where', $num throw new Exception('Array input as OR conditions is no longer supported'); } - // first argument is string containing more than just a field name and no more than 2 - // arguments means that we either have a string expression or embedded condition. - if ($num_args === 2 && is_string($field) && !preg_match('/^[.a-zA-Z0-9_]*$/', $field)) { - // field contains non-alphanumeric values. Look for condition - preg_match( - '/^([^ <>!=]*)([>expr($field); - - $cond = '='; - } else { - ++$num_args; - } - - $field = $matches[1]; + if (is_string($field) && preg_match('~([>addMoreInfo('field', $field); } - switch ($num_args) { - case 1: - if (is_string($field)) { - $field = $this->expr($field); - $field->wrapInParentheses = true; - } elseif (!$field->wrapInParentheses) { - $field = $this->expr('[]', [$field]); - $field->wrapInParentheses = true; - } - - $this->args[$kind][] = [$field]; - - break; - case 2: - if (is_object($cond) && !$cond instanceof Expressionable) { - throw (new Exception('Value cannot be converted to SQL-compatible expression')) - ->addMoreInfo('field', $field) - ->addMoreInfo('value', $cond); - } + if ($numArgs === 1) { + if (is_string($field)) { + $field = $this->expr($field); + $field->wrapInParentheses = true; + } elseif (!$field instanceof Expression || !$field->wrapInParentheses) { + $field = $this->expr('[]', [$field]); + $field->wrapInParentheses = true; + } - $this->args[$kind][] = [$field, $cond]; + $this->args[$kind][] = [$field]; + } else { + if ($numArgs === 2) { + $value = $cond; + unset($cond); + } - break; - case 3: - if (is_object($value) && !$value instanceof Expressionable) { - throw (new Exception('Value cannot be converted to SQL-compatible expression')) - ->addMoreInfo('field', $field) - ->addMoreInfo('cond', $cond) - ->addMoreInfo('value', $value); - } + if (is_object($value) && !$value instanceof Expressionable) { + throw (new Exception('Value cannot be converted to SQL-compatible expression')) + ->addMoreInfo('field', $field) + ->addMoreInfo('value', $value); + } + if ($numArgs === 2) { + $this->args[$kind][] = [$field, $value]; + } else { $this->args[$kind][] = [$field, $cond, $value]; - - break; + } } return $this; diff --git a/src/Schema/Migration.php b/src/Schema/Migration.php index 266393c0a..aa2dedfe2 100644 --- a/src/Schema/Migration.php +++ b/src/Schema/Migration.php @@ -12,6 +12,7 @@ use Atk4\Data\Persistence\Sql\Connection; use Atk4\Data\Reference\HasOne; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; @@ -29,6 +30,9 @@ class Migration /** @var Table */ public $table; + /** @var array */ + private $createdTableNames = []; + /** * Create new migration. * @@ -69,13 +73,25 @@ protected function getSchemaManager(): AbstractSchemaManager public function table(string $tableName): self { $this->table = new Table($this->getDatabasePlatform()->quoteSingleIdentifier($tableName)); + if ($this->getDatabasePlatform() instanceof MySQLPlatform) { + $this->table->addOption('charset', 'utf8mb4'); + } return $this; } + /** + * @return array + */ + public function getCreatedTableNames(): array + { + return $this->createdTableNames; + } + public function create(): self { $this->getSchemaManager()->createTable($this->table); + $this->createdTableNames[] = $this->table->getName(); return $this; } @@ -83,6 +99,7 @@ public function create(): self public function drop(): self { $this->getSchemaManager()->dropTable($this->getDatabasePlatform()->quoteSingleIdentifier($this->table->getName())); + $this->createdTableNames = array_diff($this->createdTableNames, [$this->table->getName()]); return $this; } diff --git a/src/Schema/TestCase.php b/src/Schema/TestCase.php index a1d4eed62..4c759903e 100644 --- a/src/Schema/TestCase.php +++ b/src/Schema/TestCase.php @@ -9,19 +9,20 @@ use Atk4\Data\Persistence; use Doctrine\DBAL\Logging\SQLLogger; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; class TestCase extends BaseTestCase { - /** @var Persistence|Persistence\Sql Persistence instance */ + /** @var Persistence|Persistence\Sql */ public $db; - /** @var array Array of database table names */ - public $tables; - - /** @var bool Debug mode enabled/disabled. In debug mode SQL queries are dumped. */ + /** @var bool If true, SQL queries are dumped. */ public $debug = false; + /** @var Migration[] */ + private $createdMigrators = []; + /** * Setup test database. */ @@ -31,23 +32,35 @@ protected function setUp(): void $this->db = Persistence::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); + if ($this->db->getDatabasePlatform() instanceof MySQLPlatform) { + $this->db->connection->expr( + 'SET SESSION auto_increment_increment = 1, SESSION auto_increment_offset = 1' + )->execute(); + } + $this->db->connection->connection()->getConfiguration()->setSQLLogger( new class($this) implements SQLLogger { - /** @var TestCase */ - public $testCase; + /** @var \WeakReference */ + private $testCaseWeakRef; public function __construct(TestCase $testCase) { - $this->testCase = $testCase; + $this->testCaseWeakRef = \WeakReference::create($testCase); } public function startQuery($sql, $params = null, $types = null): void { - if (!$this->testCase->debug) { + if (!$this->testCaseWeakRef->get()->debug) { return; } - echo "\n" . $sql . "\n" . print_r($params, true) . "\n\n"; + echo "\n" . $sql . "\n" . (is_array($params) ? print_r(array_map(function ($v) { + if (is_string($v) && strlen($v) > 4096) { + $v = '*long string* (length: ' . strlen($v) . ' bytes, sha256: ' . hash('sha256', $v) . ')'; + } + + return $v; + }, $params), true) : '') . "\n\n"; } public function stopQuery(): void @@ -57,6 +70,18 @@ public function stopQuery(): void ); } + protected function tearDown(): void + { + foreach ($this->createdMigrators as $migrator) { + foreach ($migrator->getCreatedTableNames() as $t) { + (clone $migrator)->table($t)->dropIfExists(); + } + } + $this->createdMigrators = []; + + parent::tearDown(); + } + protected function getDatabasePlatform(): AbstractPlatform { return $this->db->connection->getDatabasePlatform(); @@ -90,18 +115,10 @@ protected function assertSameSql(string $expectedSqliteSql, string $actualSql, s public function createMigrator(Model $model = null): Migration { - return new Migration($model ?: $this->db); - } + $migrator = new Migration($model ?: $this->db); + $this->createdMigrators[] = $migrator; - /** - * Use this method to clean up tables after you have created them, - * so that your database would be ready for the next test. - */ - public function dropTableIfExists(string $tableName): self - { - $this->createMigrator()->table($tableName)->dropIfExists(); - - return $this; + return $migrator; } /** @@ -109,16 +126,25 @@ public function dropTableIfExists(string $tableName): self */ public function setDb(array $dbData, bool $importData = true): void { - $this->tables = array_keys($dbData); - // create tables foreach ($dbData as $tableName => $data) { - $this->dropTableIfExists($tableName); + $migrator = $this->createMigrator()->table($tableName); + + // drop table if exists but only if it was created during this test + foreach ($this->createdMigrators as $migr) { + if ($migr->connection === $this->db->connection) { + foreach ($migr->getCreatedTableNames() as $t) { + if ($t === $tableName) { + $migrator->dropIfExists(); + + break 2; + } + } + } + } $first_row = current($data); if ($first_row) { - $migrator = $this->createMigrator()->table($tableName); - $migrator->id('id'); foreach ($first_row as $field => $row) { @@ -171,7 +197,13 @@ public function setDb(array $dbData, bool $importData = true): void public function getDb(array $tableNames = null, bool $noId = false): array { if ($tableNames === null) { - $tableNames = $this->tables; + $tableNames = []; + foreach ($this->createdMigrators as $migrator) { + foreach ($migrator->getCreatedTableNames() as $t) { + $tableNames[$t] = $t; + } + } + $tableNames = array_values($tableNames); } $ret = []; diff --git a/tests/ContainsManyTest.php b/tests/ContainsManyTest.php index 6e54f645d..c43726515 100644 --- a/tests/ContainsManyTest.php +++ b/tests/ContainsManyTest.php @@ -29,8 +29,8 @@ protected function setUp(): void parent::setUp(); // populate database for our models - $this->createMigrator(new VatRate($this->db))->dropIfExists()->create(); - $this->createMigrator(new Invoice($this->db))->dropIfExists()->create(); + $this->createMigrator(new VatRate($this->db))->create(); + $this->createMigrator(new Invoice($this->db))->create(); // fill in some default values $m = new VatRate($this->db); diff --git a/tests/ContainsOneTest.php b/tests/ContainsOneTest.php index a59b04b2d..b52983c85 100644 --- a/tests/ContainsOneTest.php +++ b/tests/ContainsOneTest.php @@ -29,8 +29,8 @@ protected function setUp(): void parent::setUp(); // populate database for our models - $this->createMigrator(new Country($this->db))->dropIfExists()->create(); - $this->createMigrator(new Invoice($this->db))->dropIfExists()->create(); + $this->createMigrator(new Country($this->db))->create(); + $this->createMigrator(new Invoice($this->db))->create(); // fill in some default values $m = new Country($this->db); diff --git a/tests/LookupSqlTest.php b/tests/LookupSqlTest.php index 5f712d3c3..db168676e 100644 --- a/tests/LookupSqlTest.php +++ b/tests/LookupSqlTest.php @@ -144,9 +144,9 @@ protected function setUp(): void parent::setUp(); // populate database for our three models - $this->createMigrator(new LCountry($this->db))->dropIfExists()->create(); - $this->createMigrator(new LUser($this->db))->dropIfExists()->create(); - $this->createMigrator(new LFriend($this->db))->dropIfExists()->create(); + $this->createMigrator(new LCountry($this->db))->create(); + $this->createMigrator(new LUser($this->db))->create(); + $this->createMigrator(new LFriend($this->db))->create(); } /** diff --git a/tests/Persistence/Sql/QueryTest.php b/tests/Persistence/Sql/QueryTest.php index 127623b35..f82bb39c3 100644 --- a/tests/Persistence/Sql/QueryTest.php +++ b/tests/Persistence/Sql/QueryTest.php @@ -720,20 +720,6 @@ public function testWhereBasic(): void $this->q('[where]')->where('id', $this->q()->table('user'))->render() ); - // two parameters - more_than_just_a_field, value - $this->assertSame( - 'where "id" = :a', - $this->q('[where]')->where('id=', 1)->render() - ); - $this->assertSame( - 'where "id" != :a', - $this->q('[where]')->where('id!=', 1)->render() - ); - $this->assertSame( - 'where "id" <> :a', - $this->q('[where]')->where('id<>', 1)->render() - ); - // field name with special symbols - not escape $this->assertSame( 'where now() = :a', @@ -761,6 +747,12 @@ public function testWhereExpression(): void ); } + public function testWhereIncompatibleFieldWithCondition(): void + { + $this->expectException(Exception::class); + $this->q('[where]')->where('id=', 1)->render(); + } + /** * Verify that passing garbage to where throw exception. * @@ -886,35 +878,6 @@ public function testWhereSpecialValues(): void 'where "name" not like :a', $this->q('[where]')->where('name', 'not like', 'foo')->render() ); - - // two parameters - more_than_just_a_field, value - // is | is not - $this->assertSame( - 'where "id" is null', - $this->q('[where]')->where('id=', null)->render() - ); - $this->assertSame( - 'where "id" is not null', - $this->q('[where]')->where('id!=', null)->render() - ); - $this->assertSame( - 'where "id" is not null', - $this->q('[where]')->where('id<>', null)->render() - ); - - // in | not in - $this->assertSame( - 'where "id" in (:a, :b)', - $this->q('[where]')->where('id=', [1, 2])->render() - ); - $this->assertSame( - 'where "id" not in (:a, :b)', - $this->q('[where]')->where('id!=', [1, 2])->render() - ); - $this->assertSame( - 'where "id" not in (:a, :b)', - $this->q('[where]')->where('id<>', [1, 2])->render() - ); } /** @@ -935,7 +898,7 @@ public function testBasicHaving(): void ); $this->assertSame( 'where "id" = :a having "id" > :b', - $this->q('[where][having]')->where('id', 1)->having('id>', 1)->render() + $this->q('[where][having]')->where('id', 1)->having('id', '>', 1)->render() ); } diff --git a/tests/Persistence/Sql/WithDb/ConnectionTest.php b/tests/Persistence/Sql/WithDb/ConnectionTest.php deleted file mode 100644 index c6e29ae55..000000000 --- a/tests/Persistence/Sql/WithDb/ConnectionTest.php +++ /dev/null @@ -1,22 +0,0 @@ -assertSame('1', $c->expr('SELECT 1' . ($c->getDatabasePlatform() instanceof OraclePlatform ? ' FROM DUAL' : ''))->getOne()); - } -} diff --git a/tests/Persistence/Sql/WithDb/SelectTest.php b/tests/Persistence/Sql/WithDb/SelectTest.php index 1e6f14571..09f2c0407 100644 --- a/tests/Persistence/Sql/WithDb/SelectTest.php +++ b/tests/Persistence/Sql/WithDb/SelectTest.php @@ -4,12 +4,12 @@ namespace Atk4\Data\Tests\Persistence\Sql\WithDb; -use Atk4\Core\Phpunit\TestCase; +use Atk4\Data\Model; use Atk4\Data\Persistence\Sql\Connection; use Atk4\Data\Persistence\Sql\Exception; use Atk4\Data\Persistence\Sql\Expression; use Atk4\Data\Persistence\Sql\Query; -use Atk4\Data\Schema\Migration; +use Atk4\Data\Schema\TestCase; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\PostgreSQL94Platform; @@ -20,62 +20,25 @@ class SelectTest extends TestCase /** @var Connection */ protected $c; - private function dropDbIfExists(): void - { - (new Migration($this->c))->table('employee')->dropIfExists(); - } - protected function setUp(): void { - $this->c = Connection::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); - - $this->dropDbIfExists(); - - $strType = $this->c->getDatabasePlatform() instanceof OraclePlatform ? 'varchar2' : 'varchar'; - $boolType = ['mssql' => 'bit', 'oracle' => 'number(1)'][$this->c->getDatabasePlatform()->getName()] ?? 'bool'; - $fixIdentifiersFunc = function ($sql) { - return preg_replace_callback('~(?:\'(?:\'\'|\\\\\'|[^\'])*\')?+\K"([^\'"()\[\]{}]*?)"~s', function ($matches) { - if ($this->c->getDatabasePlatform() instanceof MySQLPlatform) { - return '`' . $matches[1] . '`'; - } elseif ($this->c->getDatabasePlatform() instanceof SQLServer2012Platform) { - return '[' . $matches[1] . ']'; - } - - return '"' . $matches[1] . '"'; - }, $sql); - }; - $this->c->connection()->executeQuery($fixIdentifiersFunc('CREATE TABLE "employee" ("id" int not null, "name" ' . $strType . '(100), "surname" ' . $strType . '(100), "retired" ' . $boolType . ', ' . ($this->c->getDatabasePlatform() instanceof OraclePlatform ? 'CONSTRAINT "employee_pk" ' : '') . 'PRIMARY KEY ("id"))')); - foreach ([ + parent::setUp(); + + $this->c = $this->db->connection; + + $model = new Model($this->db, ['table' => 'employee']); + $model->addField('name'); + $model->addField('surname'); + $model->addField('retired', ['type' => 'boolean']); + + $this->createMigrator($model)->create(); + + $model->import([ ['id' => 1, 'name' => 'Oliver', 'surname' => 'Smith', 'retired' => false], ['id' => 2, 'name' => 'Jack', 'surname' => 'Williams', 'retired' => true], ['id' => 3, 'name' => 'Harry', 'surname' => 'Taylor', 'retired' => true], ['id' => 4, 'name' => 'Charlie', 'surname' => 'Lee', 'retired' => false], - ] as $row) { - $this->c->connection()->executeQuery($fixIdentifiersFunc('INSERT INTO "employee" (' . implode(', ', array_map(function ($v) { - return '"' . $v . '"'; - }, array_keys($row))) . ') VALUES(' . implode(', ', array_map(function ($v) { - if (is_bool($v)) { - if ($this->c->getDatabasePlatform() instanceof PostgreSQL94Platform) { - return $v ? 'true' : 'false'; - } - - return $v ? 1 : 0; - } elseif (is_int($v)) { - return $v; - } - - return '\'' . $v . '\''; - }, $row)) . ')')); - } - } - - protected function tearDown(): void - { - $this->dropDbIfExists(); - - $this->c = null; // @phpstan-ignore-line - - parent::tearDown(); + ]); } /** @@ -304,6 +267,26 @@ public function testExecuteException(): void } } + public function testUtf8mb4Support(): void + { + // remove once https://jira.mariadb.org/browse/MDEV-27050 is fixed + if (str_contains($_ENV['DB_DSN'], 'mariadb')) { + $this->markTestSkipped('MariaDB has broken support of utf8mb4 identifiers'); + } + + $this->assertSame( + ['❤' => 'žlutý_😀'], + $this->q( + $this->q()->field($this->e('\'žlutý_😀\''), '❤'), + '🚀' + ) + ->where('❤', 'žlutý_😀') // as param + ->group('🚀.❤') + ->having($this->e('{}', ['❤'])->render() . ' = \'žlutý_😀\'') // as string literal (mapped to N'xxx' with MSSQL platform) + ->getRow() + ); + } + public function testImportAndAutoincrement(): void { $p = new \Atk4\Data\Persistence\Sql($this->c); @@ -311,7 +294,7 @@ public function testImportAndAutoincrement(): void $m->getField('id')->actual = 'myid'; $m->setOrder('id'); $m->addField('f1'); - (new \Atk4\Data\Schema\Migration($m))->dropIfExists()->create(); + $this->createMigrator($m)->create(); $getLastAiFx = function (): int { $table = 'test'; diff --git a/tests/Persistence/Sql/WithDb/TransactionTest.php b/tests/Persistence/Sql/WithDb/TransactionTest.php index d3e91ebf2..fb52d3cd0 100644 --- a/tests/Persistence/Sql/WithDb/TransactionTest.php +++ b/tests/Persistence/Sql/WithDb/TransactionTest.php @@ -4,78 +4,37 @@ namespace Atk4\Data\Tests\Persistence\Sql\WithDb; -use Atk4\Core\Phpunit\TestCase; +use Atk4\Data\Model; use Atk4\Data\Persistence\Sql\Connection; use Atk4\Data\Persistence\Sql\Exception; use Atk4\Data\Persistence\Sql\Expression; use Atk4\Data\Persistence\Sql\Query; -use Atk4\Data\Schema\Migration; -use Doctrine\DBAL\Platforms\MySQLPlatform; -use Doctrine\DBAL\Platforms\OraclePlatform; -use Doctrine\DBAL\Platforms\PostgreSQL94Platform; -use Doctrine\DBAL\Platforms\SQLServer2012Platform; +use Atk4\Data\Schema\TestCase; class TransactionTest extends TestCase { /** @var Connection */ protected $c; - private function dropDbIfExists(): void - { - (new Migration($this->c))->table('employee')->dropIfExists(); - } - protected function setUp(): void { - $this->c = Connection::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); - - $this->dropDbIfExists(); - - $strType = $this->c->getDatabasePlatform() instanceof OraclePlatform ? 'varchar2' : 'varchar'; - $boolType = ['mssql' => 'bit', 'oracle' => 'number(1)'][$this->c->getDatabasePlatform()->getName()] ?? 'bool'; - $fixIdentifiersFunc = function ($sql) { - return preg_replace_callback('~(?:\'(?:\'\'|\\\\\'|[^\'])*\')?+\K"([^\'"()\[\]{}]*?)"~s', function ($matches) { - if ($this->c->getDatabasePlatform() instanceof MySQLPlatform) { - return '`' . $matches[1] . '`'; - } elseif ($this->c->getDatabasePlatform() instanceof SQLServer2012Platform) { - return '[' . $matches[1] . ']'; - } - - return '"' . $matches[1] . '"'; - }, $sql); - }; - $this->c->connection()->executeQuery($fixIdentifiersFunc('CREATE TABLE "employee" ("id" int not null, "name" ' . $strType . '(100), "surname" ' . $strType . '(100), "retired" ' . $boolType . ', ' . ($this->c->getDatabasePlatform() instanceof OraclePlatform ? 'CONSTRAINT "employee_pk" ' : '') . 'PRIMARY KEY ("id"))')); - foreach ([ + parent::setUp(); + + $this->c = $this->db->connection; + + $model = new Model($this->db, ['table' => 'employee']); + $model->addField('name'); + $model->addField('surname'); + $model->addField('retired', ['type' => 'boolean']); + + $this->createMigrator($model)->create(); + + $model->import([ ['id' => 1, 'name' => 'Oliver', 'surname' => 'Smith', 'retired' => false], ['id' => 2, 'name' => 'Jack', 'surname' => 'Williams', 'retired' => true], ['id' => 3, 'name' => 'Harry', 'surname' => 'Taylor', 'retired' => true], ['id' => 4, 'name' => 'Charlie', 'surname' => 'Lee', 'retired' => false], - ] as $row) { - $this->c->connection()->executeQuery($fixIdentifiersFunc('INSERT INTO "employee" (' . implode(', ', array_map(function ($v) { - return '"' . $v . '"'; - }, array_keys($row))) . ') VALUES(' . implode(', ', array_map(function ($v) { - if (is_bool($v)) { - if ($this->c->getDatabasePlatform() instanceof PostgreSQL94Platform) { - return $v ? 'true' : 'false'; - } - - return $v ? 1 : 0; - } elseif (is_int($v)) { - return $v; - } - - return '\'' . $v . '\''; - }, $row)) . ')')); - } - } - - protected function tearDown(): void - { - $this->dropDbIfExists(); - - $this->c = null; // @phpstan-ignore-line - - parent::tearDown(); + ]); } /** diff --git a/tests/Schema/BasicTest.php b/tests/Schema/BasicTest.php index d3ba28518..5cf66e49e 100644 --- a/tests/Schema/BasicTest.php +++ b/tests/Schema/BasicTest.php @@ -13,8 +13,6 @@ class BasicTest extends TestCase */ public function testCreate(): void { - $this->dropTableIfExists('user'); - $this->createMigrator()->table('user')->id() ->field('foo') ->field('bar', ['type' => 'integer']) @@ -35,8 +33,6 @@ public function testCreate(): void */ public function testCreateAndDrop(): void { - $this->dropTableIfExists('user'); - $this->createMigrator()->table('user')->id() ->field('foo') ->field('bar', ['type' => 'integer']) diff --git a/tests/Schema/ModelTest.php b/tests/Schema/ModelTest.php index ad781dea1..a48998194 100644 --- a/tests/Schema/ModelTest.php +++ b/tests/Schema/ModelTest.php @@ -7,7 +7,6 @@ use Atk4\Data\Model; use Atk4\Data\Schema\TestCase; use Doctrine\DBAL\Platforms\OraclePlatform; -use Doctrine\DBAL\Platforms\SQLServer2012Platform; class ModelTest extends TestCase { @@ -16,7 +15,6 @@ class ModelTest extends TestCase */ public function testSetModelCreate(): void { - $this->dropTableIfExists('user'); $user = new TestUser($this->db); $this->createMigrator($user)->create(); @@ -31,8 +29,6 @@ public function testImportTable(): void return; // TODO enable once import to Model is supported using DBAL // @phpstan-ignore-next-line - $this->dropTableIfExists('user'); - $migrator = $this->createMigrator(); $migrator->table('user')->id() @@ -84,7 +80,6 @@ public function testImportTable(): void */ public function testMigrateTable(): void { - $this->dropTableIfExists('user'); $migrator = $this->createMigrator(); $migrator->table('user')->id() ->field('foo') @@ -106,8 +101,6 @@ public function testCreateModel(): void return; // TODO enable once create from Model is supported using DBAL // @phpstan-ignore-next-line - $this->dropTableIfExists('user'); - $this->createMigrator(new TestUser($this->db))->create(); $user_model = $this->createMigrator()->createModel($this->db, 'user'); @@ -132,16 +125,7 @@ public function testCharacterTypeFieldCaseSensitivity(string $type, bool $isBina $model = new Model($this->db, ['table' => 'user']); $model->addField('v', ['type' => $type]); - $this->createMigrator($model)->dropIfExists()->create(); - - if ($isBinary) { - // TODO insert/update of binary character types must be supported, maybe fix using trigger or store data in hex for MSSQL & Oracle? - if ($this->getDatabasePlatform() instanceof SQLServer2012Platform) { - $this->markTestIncomplete('TODO MSSQL: Implicit conversion from data type char to varbinary(max) is not allowed. Use the CONVERT function to run this query'); - } elseif ($this->getDatabasePlatform() instanceof OraclePlatform) { - $this->markTestIncomplete('TODO Oracle: ORA-01465: invalid hex number'); - } - } + $this->createMigrator($model)->create(); $model->import([['v' => 'mixedcase'], ['v' => 'MIXEDCASE'], ['v' => 'MixedCase']]); @@ -160,6 +144,85 @@ public function providerCharacterTypeFieldCaseSensitivityData(): array ['blob', true], ]; } + + private function makePseudoRandomString(bool $isBinary, int $lengthBytes): string + { + $baseChars = []; + if ($isBinary) { + for ($i = 0; $i <= 0xFF; ++$i) { + $baseChars[crc32($lengthBytes . '_' . $i)] = chr($i); + } + } else { + for ($i = 0; $i <= 0x10FFFF; $i = $i * 1.001 + 1) { + $iInt = (int) $i; + if ($iInt < 0xD800 || $iInt > 0xDFFF) { + $baseChars[crc32($lengthBytes . '_' . $i)] = mb_chr($iInt); + } + } + } + ksort($baseChars); + + $res = str_repeat(implode('', $baseChars), intdiv($lengthBytes, count($baseChars)) + 1); + if ($isBinary) { + return substr($res, 0, $lengthBytes); + } + + $res = mb_strcut($res, 0, $lengthBytes); + $padLength = $lengthBytes - strlen($res); + foreach ($baseChars as $ch) { + if (strlen($ch) === $padLength) { + $res .= $ch; + + break; + } + } + + return $res; + } + + /** + * @dataProvider providerCharacterTypeFieldLongData + */ + public function testCharacterTypeFieldLong(string $type, bool $isBinary, int $lengthBytes): void + { + if ($this->getDatabasePlatform() instanceof OraclePlatform) { + $lengthBytes = min($lengthBytes, 500); + } + + $str = $this->makePseudoRandomString($isBinary, $lengthBytes); + if (!$isBinary) { + $str = preg_replace('~[\x00-\x1f]~', '-', $str); + } + $this->assertSame($lengthBytes, strlen($str)); + + $model = new Model($this->db, ['table' => 'user']); + $model->addField('v', ['type' => $type]); + + $this->createMigrator($model)->create(); + + $model->import([['v' => $str . ($isBinary ? "\0" : '.')]]); + $model->import([['v' => $str]]); + + $model->addCondition('v', $str); + $rows = $model->export(); + $this->assertCount(1, $rows); + $row = reset($rows); + unset($rows); + $this->assertSame(['id', 'v'], array_keys($row)); + $this->assertSame(2, $row['id']); + $this->assertSame(strlen($str), strlen($row['v'])); + $this->assertTrue($str === $row['v']); + } + + public function providerCharacterTypeFieldLongData(): array + { + return [ + ['string', false, 250], + ['binary', true, 100], + ['text', false, 256 * 1024], + ['blob', true, 256 * 1024], + ]; + } } class TestUser extends \Atk4\Data\Model diff --git a/tests/ScopeTest.php b/tests/ScopeTest.php index ad836a8d3..2a3cf1568 100644 --- a/tests/ScopeTest.php +++ b/tests/ScopeTest.php @@ -76,7 +76,7 @@ protected function setUp(): void parent::setUp(); $country = new SCountry($this->db); - $this->createMigrator($country)->dropIfExists()->create(); + $this->createMigrator($country)->create(); $country->import([ ['name' => 'Canada', 'code' => 'CA'], ['name' => 'Latvia', 'code' => 'LV'], @@ -88,7 +88,7 @@ protected function setUp(): void ]); $user = new SUser($this->db); - $this->createMigrator($user)->dropIfExists()->create(); + $this->createMigrator($user)->create(); $user->import([ ['name' => 'John', 'surname' => 'Smith', 'country_code' => 'CA'], ['name' => 'Jane', 'surname' => 'Doe', 'country_code' => 'LV'], @@ -98,7 +98,7 @@ protected function setUp(): void ]); $ticket = new STicket($this->db); - $this->createMigrator($ticket)->dropIfExists()->create(); + $this->createMigrator($ticket)->create(); $ticket->import([ ['number' => '001', 'venue' => 'Best Stadium', 'user' => 1], ['number' => '002', 'venue' => 'Best Stadium', 'user' => 2], diff --git a/tests/SmboTransferTest.php b/tests/SmboTransferTest.php index 53ee3d746..1dcb6d3bd 100644 --- a/tests/SmboTransferTest.php +++ b/tests/SmboTransferTest.php @@ -4,7 +4,6 @@ namespace Atk4\Data\Tests; -use Atk4\Data\Persistence; use Atk4\Data\Schema\TestCase; use Atk4\Data\Tests\Model\Smbo\Account; use Atk4\Data\Tests\Model\Smbo\Company; @@ -17,12 +16,12 @@ protected function setUp(): void { parent::setUp(); - $this->createMigrator()->table('account')->dropIfExists() + $this->createMigrator()->table('account') ->id() ->field('name') ->create(); - $this->createMigrator()->table('document')->dropIfExists() + $this->createMigrator()->table('document') ->id() ->field('reference') ->field('contact_from_id') @@ -31,7 +30,7 @@ protected function setUp(): void ->field('amount', ['type' => 'float']) ->create(); - $this->createMigrator()->table('payment')->dropIfExists() + $this->createMigrator()->table('payment') ->id() ->field('document_id', ['type' => 'integer']) ->field('account_id', ['type' => 'integer']) @@ -108,10 +107,8 @@ public function testRef(): void /* public function testBasicEntities(): void { - $db = Persistence::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); - // Create a new company - $company = new Company($db); + $company = new Company($this->db); $company->set([ 'name' => 'Test Company 1', 'director_name' => 'Tester Little', diff --git a/tests/SubTypesTest.php b/tests/SubTypesTest.php index 2b34fc785..0ca9a3e7a 100644 --- a/tests/SubTypesTest.php +++ b/tests/SubTypesTest.php @@ -154,8 +154,8 @@ protected function setUp(): void parent::setUp(); // populate database for our three models - $this->createMigrator(new StAccount($this->db))->dropIfExists()->create(); - $this->createMigrator(new StTransaction_TransferOut($this->db))->dropIfExists()->create(); + $this->createMigrator(new StAccount($this->db))->create(); + $this->createMigrator(new StTransaction_TransferOut($this->db))->create(); } public function testBasic(): void diff --git a/tests/Util/DeepCopyTest.php b/tests/Util/DeepCopyTest.php index ca5d7ceb0..0d4e818b7 100644 --- a/tests/Util/DeepCopyTest.php +++ b/tests/Util/DeepCopyTest.php @@ -145,11 +145,11 @@ protected function setUp(): void parent::setUp(); // populate database for our three models - $this->createMigrator(new DcClient($this->db))->dropIfExists()->create(); - $this->createMigrator(new DcInvoice($this->db))->dropIfExists()->create(); - $this->createMigrator(new DcQuote($this->db))->dropIfExists()->create(); - $this->createMigrator(new DcInvoiceLine($this->db))->dropIfExists()->create(); - $this->createMigrator(new DcPayment($this->db))->dropIfExists()->create(); + $this->createMigrator(new DcClient($this->db))->create(); + $this->createMigrator(new DcInvoice($this->db))->create(); + $this->createMigrator(new DcQuote($this->db))->create(); + $this->createMigrator(new DcInvoiceLine($this->db))->create(); + $this->createMigrator(new DcPayment($this->db))->create(); } public function testBasic(): void diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index fe28583a0..9a6deab1c 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -61,13 +61,6 @@ protected function setUp(): void $this->m = new MyValidationModel($p); } - protected function tearDown(): void - { - $this->m = null; // @phpstan-ignore-line - - parent::tearDown(); - } - public function testValidate1(): void { $m = $this->m->createEntity();