diff --git a/src/Persistence/Sql/Expression.php b/src/Persistence/Sql/Expression.php index 7b997b6c6..60d83cbd9 100644 --- a/src/Persistence/Sql/Expression.php +++ b/src/Persistence/Sql/Expression.php @@ -680,7 +680,9 @@ final public static function castFloatToString(float $value): string $precisionBackup = ini_get('precision'); ini_set('precision', '-1'); try { - return (string) $value; + $valueStr = (string) $value; + + return $valueStr === (string) (int) $value ? $valueStr . '.0' : $valueStr; } finally { ini_set('precision', $precisionBackup); } @@ -696,7 +698,13 @@ private function castGetValue($v): ?string } elseif (is_int($v)) { return (string) $v; } elseif (is_float($v)) { - return self::castFloatToString($v); + $res = self::castFloatToString($v); + // most DB drivers fetch float as string + if (str_ends_with($res, '.0')) { + $res = substr($res, 0, -2); + } + + return $res; } // for PostgreSQL/Oracle CLOB/BLOB datatypes and PDO driver diff --git a/src/Persistence/Sql/Oracle/Connection.php b/src/Persistence/Sql/Oracle/Connection.php index 3c716c41b..23334a2a2 100644 --- a/src/Persistence/Sql/Oracle/Connection.php +++ b/src/Persistence/Sql/Oracle/Connection.php @@ -14,7 +14,7 @@ class Connection extends BaseConnection protected static function createDbalEventManager(): EventManager { - $evm = new EventManager(); + $evm = parent::createDbalEventManager(); // setup connection globalization to use standard datetime format incl. microseconds support // and make comparison of character types case insensitive diff --git a/src/Persistence/Sql/Sqlite/Connection.php b/src/Persistence/Sql/Sqlite/Connection.php index e242c53f8..fc7bfb0aa 100644 --- a/src/Persistence/Sql/Sqlite/Connection.php +++ b/src/Persistence/Sql/Sqlite/Connection.php @@ -5,8 +5,32 @@ namespace Atk4\Data\Persistence\Sql\Sqlite; use Atk4\Data\Persistence\Sql\Connection as BaseConnection; +use Doctrine\Common\EventManager; +use Doctrine\Common\EventSubscriber; +use Doctrine\DBAL\Event\ConnectionEventArgs; +use Doctrine\DBAL\Events; class Connection extends BaseConnection { protected $queryClass = Query::class; + + protected static function createDbalEventManager(): EventManager + { + $evm = parent::createDbalEventManager(); + + // setup connection to always check foreign keys + $evm->addEventSubscriber(new class() implements EventSubscriber { + public function getSubscribedEvents(): array + { + return [Events::postConnect]; + } + + public function postConnect(ConnectionEventArgs $args): void + { + $args->getConnection()->executeStatement('PRAGMA foreign_keys = 1'); + } + }); + + return $evm; + } } diff --git a/src/Schema/TestCase.php b/src/Schema/TestCase.php index 3ac01c1ba..1107cd05f 100644 --- a/src/Schema/TestCase.php +++ b/src/Schema/TestCase.php @@ -7,9 +7,7 @@ use Atk4\Core\Phpunit\TestCase as BaseTestCase; use Atk4\Data\Model; use Atk4\Data\Persistence; -use Doctrine\DBAL\Logging\SQLLogger; use Doctrine\DBAL\Platforms\AbstractPlatform; -use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Platforms\SqlitePlatform; use Doctrine\DBAL\Platforms\SQLServerPlatform; @@ -25,64 +23,25 @@ abstract class TestCase extends BaseTestCase /** @var Migrator[] */ private $createdMigrators = []; - protected function setUp(): void + /** + * @return static|null + */ + public static function getTestFromBacktrace() { - parent::setUp(); - - $this->db = Persistence::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD']); - - if ($this->getDatabasePlatform() instanceof SqlitePlatform) { - $this->getConnection()->expr( - 'PRAGMA foreign_keys = 1' - )->executeStatement(); - } - if ($this->getDatabasePlatform() instanceof MySQLPlatform) { - $this->getConnection()->expr( - 'SET SESSION auto_increment_increment = 1, SESSION auto_increment_offset = 1' - )->executeStatement(); + foreach (debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT) as $frame) { + if (($frame['object'] ?? null) instanceof static) { + return $frame['object']; // @phpstan-ignore-line https://github.com/phpstan/phpstan/issues/7639 + } } - $this->getConnection()->getConnection()->getConfiguration()->setSQLLogger( - null ?? new class($this) implements SQLLogger { // @phpstan-ignore-line - /** @var \WeakReference */ - private $testCaseWeakRef; - - public function __construct(TestCase $testCase) - { - $this->testCaseWeakRef = \WeakReference::create($testCase); - } - - public function startQuery($sql, array $params = null, array $types = null): void - { - if (!$this->testCaseWeakRef->get()->debug) { - return; - } + return null; + } - echo "\n" . $sql . (substr($sql, -1) !== ';' ? ';' : '') . "\n" - . (is_array($params) && count($params) > 0 ? substr(print_r(array_map(function ($v) { - if ($v === null) { - $v = 'null'; - } elseif (is_bool($v)) { - $v = $v ? 'true' : 'false'; - } elseif (is_float($v) && (string) $v === (string) (int) $v) { - $v = $v . '.0'; - } elseif (is_string($v)) { - if (strlen($v) > 4096) { - $v = '*long string* (length: ' . strlen($v) . ' bytes, sha256: ' . hash('sha256', $v) . ')'; - } else { - $v = '\'' . $v . '\''; - } - } - - return $v; - }, $params), true), 6) : '') . "\n"; - } + protected function setUp(): void + { + parent::setUp(); - public function stopQuery(): void - { - } - } - ); + $this->db = new TestSqlPersistence(); } protected function tearDown(): void @@ -113,6 +72,38 @@ protected function getDatabasePlatform(): AbstractPlatform return $this->getConnection()->getDatabasePlatform(); } + protected function logQuery(string $sql, array $params, array $types): void + { + if (!$this->debug) { + return; + } + + $lines = [$sql . (substr($sql, -1) !== ';' ? ';' : '')]; + if (count($params) > 0) { + $lines[] = '/*'; + foreach ($params as $k => $v) { + if ($v === null) { + $vStr = 'null'; + } elseif (is_bool($v)) { + $vStr = $v ? 'true' : 'false'; + } elseif (is_int($v)) { + $vStr = $v; + } else { + if (strlen($v) > 4096) { + $vStr = '*long string* (length: ' . strlen($v) . ' bytes, sha256: ' . hash('sha256', $v) . ')'; + } else { + $vStr = '\'' . str_replace('\'', '\'\'', $v) . '\''; + } + } + + $lines[] = ' [' . $k . '] => ' . $vStr; + } + $lines[] = '*/'; + } + + echo "\n" . implode("\n", $lines) . "\n\n"; + } + private function convertSqlFromSqlite(string $sql): string { $platform = $this->getDatabasePlatform(); @@ -126,6 +117,12 @@ function ($matches) use ($platform) { $str = substr(preg_replace('~\\\\(.)~s', '$1', $matches[0]), 1, -1); if (substr($matches[0], 0, 1) === '"') { + // keep info queries from DBAL in double quotes + // https://github.com/doctrine/dbal/blob/3.3.7/src/Connection.php#L1298 + if (in_array($str, ['START TRANSACTION', 'COMMIT', 'ROLLBACK'], true)) { + return $matches[0]; + } + return $platform->quoteSingleIdentifier($str); } diff --git a/src/Schema/TestSqlPersistence.php b/src/Schema/TestSqlPersistence.php new file mode 100644 index 000000000..13e089f0f --- /dev/null +++ b/src/Schema/TestSqlPersistence.php @@ -0,0 +1,54 @@ +_connection === null) { + $connection = Persistence::connect($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASSWORD'])->_connection; // @phpstan-ignore-line + $this->_connection = $connection; + + if ($connection->getDatabasePlatform() instanceof MySQLPlatform) { + $connection->expr( + 'SET SESSION auto_increment_increment = 1, SESSION auto_increment_offset = 1' + )->executeStatement(); + } + + $connection->getConnection()->getConfiguration()->setSQLLogger( + // @phpstan-ignore-next-line SQLLogger is deprecated + null ?? new class() implements SQLLogger { + public function startQuery($sql, array $params = null, array $types = null): void + { + $test = TestCase::getTestFromBacktrace(); + \Closure::bind(fn () => $test->logQuery($sql, $params ?? [], $types ?? []), null, TestCase::class)(); + } + + public function stopQuery(): void + { + } + } + ); + } + }, $this, Persistence\Sql::class)(); + + return parent::getConnection(); + } +} diff --git a/src/Util/DeepCopy.php b/src/Util/DeepCopy.php index 270412b06..431ecfa06 100644 --- a/src/Util/DeepCopy.php +++ b/src/Util/DeepCopy.php @@ -289,16 +289,15 @@ protected function _copy(Model $source, Model $destination, array $references, a return $destination; } catch (DeepCopyException $e) { throw $e; - } catch (\Atk4\Core\Exception $e) { - $this->debug('noticed a problem'); + } catch (\Exception $e) { + $this->debug('model copy failed'); - throw (new DeepCopyException('Problem cloning model', 0, $e)) + throw (new DeepCopyException('Model copy failed', 0, $e)) ->addMoreInfo('source', $source) ->addMoreInfo('source_info', $source->__debugInfo()) ->addMoreInfo('source_data', $source->get()) ->addMoreInfo('destination', $destination) - ->addMoreInfo('destination_info', $destination->__debugInfo()) - ->addMoreInfo('depth', $e->getParams()['field'] ?? '?'); + ->addMoreInfo('destination_info', $destination->__debugInfo()); } } } diff --git a/tests/BusinessModelTest.php b/tests/BusinessModelTest.php index dd69267b0..3972a50f5 100644 --- a/tests/BusinessModelTest.php +++ b/tests/BusinessModelTest.php @@ -244,7 +244,7 @@ public function testExampleFromDoc(): void $this->assertSame(1000, $m->get('salary')); $this->assertFalse($m->_isset('salary')); - // Next we load record from $db + // next we load record from $db $dataRef = &$m->getDataRef(); $dataRef = ['salary' => 2000]; $this->assertSame(2000, $m->get('salary')); diff --git a/tests/ConditionTest.php b/tests/ConditionTest.php index bcadb2fee..208ef4a65 100644 --- a/tests/ConditionTest.php +++ b/tests/ConditionTest.php @@ -5,17 +5,17 @@ namespace Atk4\Data\Tests; use Atk4\Core\Phpunit\TestCase; +use Atk4\Data\Exception; use Atk4\Data\Model; class ConditionTest extends TestCase { - public function testException1(): void + public function testUnexistingFieldException(): void { - // not existing field in condition $m = new Model(); $m->addField('name'); - $this->expectException(\Atk4\Core\Exception::class); + $this->expectException(Exception::class); $m->addCondition('last_name', 'Smith'); } diff --git a/tests/ReferenceSqlTest.php b/tests/ReferenceSqlTest.php index a2d933b5c..75a6b97ad 100644 --- a/tests/ReferenceSqlTest.php +++ b/tests/ReferenceSqlTest.php @@ -491,10 +491,7 @@ public function testReferenceHook(): void $this->assertSame('Peters new contact', $uu->get('address')); } - /** - * Tests hasOne::our_key == owner::id_field. - */ - public function testIdFieldReferenceOurFieldCase(): void + public function testHasOneIdFieldAsOurField(): void { $this->setDb([ 'player' => [ @@ -508,18 +505,20 @@ public function testIdFieldReferenceOurFieldCase(): void ], ]); - $p = (new Model($this->db, ['table' => 'player']))->addFields(['name']); - $s = (new Model($this->db, ['table' => 'stadium'])); - $s->addFields(['name']); - $s->hasOne('player_id', ['model' => $p]); + $s->addField('name'); + $s->addField('player_id', ['type' => 'integer']); + $p = new Model($this->db, ['table' => 'player']); + $p->addField('name'); + $p->delete(2); $p->hasOne('Stadium', ['model' => $s, 'our_field' => 'id', 'their_field' => 'player_id']); - $p = $p->load(2); - $p->ref('Stadium')->getModel()->import([['name' => 'Nou camp nou']]); + $s = $p->ref('Stadium')->createEntity()->save(['name' => 'Nou camp nou', 'player_id' => 4]); + $p = $p->createEntity()->save(['name' => 'Ivan']); + $this->assertSame('Nou camp nou', $p->ref('Stadium')->get('name')); - $this->assertSame(2, $p->ref('Stadium')->get('player_id')); + $this->assertSame(4, $p->ref('Stadium')->get('player_id')); } public function testModelProperty(): void diff --git a/tests/Schema/TestCaseTest.php b/tests/Schema/TestCaseTest.php index abcde7230..cb64957d8 100644 --- a/tests/Schema/TestCaseTest.php +++ b/tests/Schema/TestCaseTest.php @@ -4,10 +4,71 @@ namespace Atk4\Data\Tests\Schema; +use Atk4\Data\Model; use Atk4\Data\Schema\TestCase; +use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Platforms\SqlitePlatform; class TestCaseTest extends TestCase { + public function testLogQuery(): void + { + $m = new Model($this->db, ['table' => 't']); + $m->addField('name'); + $m->addField('int', ['type' => 'integer']); + $m->addField('float', ['type' => 'float']); + $m->addField('null'); + $m->addCondition('int', '>', -1); + + ob_start(); + try { + $this->createMigrator($m)->create(); + + $this->debug = true; + + $m->atomic(function () use ($m) { + $m->insert(['name' => 'Ewa', 'int' => 1, 'float' => 1]); + }); + + $this->assertSame(1, $m->loadAny()->getId()); + + $output = ob_get_contents(); + } finally { + ob_end_clean(); + } + + if (!$this->getDatabasePlatform() instanceof SqlitePlatform + && (!$this->getDatabasePlatform() instanceof MySQLPlatform || !$this->getConnection()->getConnection()->getNativeConnection() instanceof \PDO)) { + return; + } + + $this->assertSameSql( + <<<'EOF' + + "START TRANSACTION"; + + + insert into "t" ("name", "int", "float", "null") values (:a, :b, :c, :d); + /* + [:a] => 'Ewa' + [:b] => 1 + [:c] => '1.0' + [:d] => null + */ + + + "COMMIT"; + + + select "id", "name", "int", "float", "null" from "t" where "int" > :a limit 0, 1; + /* + [:a] => -1 + */ + EOF . "\n\n", + $output + ); + } + public function testGetSetDb(): void { $this->assertSame([], $this->getDb([])); diff --git a/tests/UserActionTest.php b/tests/UserActionTest.php index b7d666d44..578a39595 100644 --- a/tests/UserActionTest.php +++ b/tests/UserActionTest.php @@ -4,6 +4,7 @@ namespace Atk4\Data\Tests; +use Atk4\Core\Exception as CoreException; use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Data\Schema\TestCase; @@ -162,7 +163,7 @@ public function testException1(): void { $client = new UaClient($this->pers); - $this->expectException(\Atk4\Core\Exception::class); + $this->expectException(CoreException::class); $client->getUserAction('non_existant_action'); } diff --git a/tests/Util/DeepCopyTest.php b/tests/Util/DeepCopyTest.php index 279f22bb1..585c036fc 100644 --- a/tests/Util/DeepCopyTest.php +++ b/tests/Util/DeepCopyTest.php @@ -277,7 +277,7 @@ public function testError(): void $invoice = new DcInvoice(); $invoice->onHook(DeepCopy::HOOK_AFTER_COPY, static function ($m) { if (!$m->get('ref')) { - throw new \Atk4\Core\Exception('no ref'); + throw new \Exception('no ref'); } }); @@ -318,7 +318,7 @@ public function testDeepError(): void $invoice = new DcInvoice(); $invoice->onHook(DeepCopy::HOOK_AFTER_COPY, static function ($m) { if (!$m->get('ref')) { - throw new \Atk4\Core\Exception('no ref'); + throw new \Exception('no ref'); } });