Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add binary/blob column support for MSSQL and Oracle #917

Merged
merged 33 commits into from
Nov 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4666ee6
simplify Query::where and call it again if cond in 1st arg
mvorisek Nov 13, 2021
23e6914
money type requires column comment for diff
mvorisek Nov 13, 2021
0e5d08b
drop Oracle\AbstractQuery class
mvorisek Nov 13, 2021
823bd07
do not dump long strings on debug
mvorisek Nov 13, 2021
7de19ea
fix Oracle connection charset
mvorisek Nov 14, 2021
d9fc53f
fix MySQL default table charset
mvorisek Nov 14, 2021
a2428ea
Add binary/blob column support for MSSQL and Oracle
mvorisek Nov 14, 2021
0146c6c
fix MSSQL faked binary column create
mvorisek Nov 13, 2021
6be9357
fix Oracle faked binary column create
mvorisek Nov 13, 2021
4b0a454
fix Oracle CLOB/BLOB column def diff
mvorisek Nov 13, 2021
d6ede23
fix MSSQL CLOB column type
mvorisek Nov 14, 2021
87ab17b
fix stan
mvorisek Nov 14, 2021
45d134a
rm TypeUtil
mvorisek Nov 14, 2021
5658474
add comment
mvorisek Nov 14, 2021
c603bbf
fix PostgreSQL tests by using compatibility typecast as well
mvorisek Nov 14, 2021
8b04590
fix Oracle max length
mvorisek Nov 14, 2021
83a567d
use Alpine image and set NLS_LANG env for Oracle
mvorisek Nov 15, 2021
c6be56a
cond to where must be passed separately
mvorisek Nov 15, 2021
d9f34a8
add TypeUtil
mvorisek Nov 14, 2021
62732af
rm TypeUtil
mvorisek Nov 15, 2021
7eaf533
add utf8mb4 test
mvorisek Nov 15, 2021
914533c
transform string literals for MSSQL
mvorisek Nov 15, 2021
97a6ccc
skip utf8mb4 test for Maria DB
mvorisek Nov 15, 2021
e68ec3e
fix test for MSSQL
mvorisek Nov 15, 2021
d76e992
enforce utf8mb4 charset when creating PDO
mvorisek Nov 15, 2021
25b5d37
add comment
mvorisek Nov 15, 2021
1dacebd
auto drop test tables
mvorisek Nov 15, 2021
9b7b8c0
drop redundant Test::tables prop
mvorisek Nov 15, 2021
06a17df
setup MySQL auto_increment config in TestCase::setUp
mvorisek Nov 15, 2021
9a05394
drop dummy ConnectionTest
mvorisek Nov 15, 2021
09582cb
fic cs
mvorisek Nov 15, 2021
87d7c22
improve skip message
mvorisek Nov 15, 2021
06aaa61
never try to drop a table we did not created
mvorisek Nov 15, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/test-unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions bootstrap-types.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
2 changes: 1 addition & 1 deletion phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 2 additions & 11 deletions src/Persistence.php
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason which I don't remember now we had separated Oracle 12 support.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a different compatibility layer previously for Oracle 12, but I purged some time ago it in favor of generic layer for Oracle 11 and all tests are run againt this version in CI.

// 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;
Expand Down
4 changes: 3 additions & 1 deletion src/Persistence/GenericPlatform.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@

use Doctrine\DBAL\Exception as DbalException;
use Doctrine\DBAL\Platforms;
use Mvorisek\Atk4\Hintable\Phpstan\PhpstanUtil;

class GenericPlatform extends Platforms\AbstractPlatform
{
private function createNotSupportedException(): \Exception // DbalException once DBAL 2.x support is dropped
{
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');
Expand Down
2 changes: 2 additions & 0 deletions src/Persistence/Sql.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

class Sql extends Persistence
{
use Sql\BinaryTypeCompatibilityTypecastTrait;

/** @const string */
public const HOOK_INIT_SELECT_QUERY = self::class . '@initSelectQuery';
/** @const string */
Expand Down
84 changes: 84 additions & 0 deletions src/Persistence/Sql/BinaryTypeCompatibilityTypecastTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace Atk4\Data\Persistence\Sql;

use Atk4\Data\Exception;
use Atk4\Data\Field;
use Doctrine\DBAL\Platforms\OraclePlatform;
use Doctrine\DBAL\Platforms\PostgreSQL94Platform;
use Doctrine\DBAL\Platforms\SQLServer2012Platform;
use Doctrine\DBAL\Types\Type;

trait BinaryTypeCompatibilityTypecastTrait
{
private function binaryTypeValueGetPrefixConst(): string
{
return 'atk__binary__u5f8mzx4vsm8g2c9__';
}

private function binaryTypeValueEncode(string $value): string
{
$hex = bin2hex($value);

return $this->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;
}
}
10 changes: 10 additions & 0 deletions src/Persistence/Sql/Connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we always enforce utf8? What if user database is not utf8? Previously utf8 was used by default, but user could use different charset too.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because of many hidden issues. UTF-8 is defacto standard. This is about the connection charset, you can store any data you want in binary fields, actually, this is one of the feature thi PR adds and fixes.

. ';charset=' . $enforceCharset;
}

$pdo = new \PDO($dsn['dsn'], $dsn['user'], $dsn['pass']);
}

Expand Down
23 changes: 14 additions & 9 deletions src/Persistence/Sql/Expression.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}

Expand Down
12 changes: 10 additions & 2 deletions src/Persistence/Sql/Mssql/ExpressionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -51,7 +59,7 @@ function ($matches) use (&$numParams, &$i, &$j) {

return '?';
},
parent::render()
$this->_render()
);
$this->params = $numParams;

Expand All @@ -69,7 +77,7 @@ public function render(): string
return $this->numQueryRender;
}

return parent::render();
return $this->_render();
}

public function getDebugQuery(): string
Expand Down
31 changes: 31 additions & 0 deletions src/Persistence/Sql/Mssql/PlatformTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 0 additions & 24 deletions src/Persistence/Sql/Oracle/AbstractQuery.php

This file was deleted.

17 changes: 17 additions & 0 deletions src/Persistence/Sql/Oracle/PlatformTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading