From ce0a213f8e9eb1a8c53f1220a5f8f6741bc1a32d Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 15 May 2026 16:45:58 +0600 Subject: [PATCH 1/4] Add real MySQL and PostgreSQL coverage for ORM query and relationship test suites --- .github/workflows/tests.yml | 85 +++- composer.json | 9 +- ...> DatabaseBuilderRelationshipBehavior.php} | 160 +------ .../DatabaseBuilderRelationshipMysqlTest.php | 17 + .../DatabaseBuilderRelationshipPgsqlTest.php | 17 + .../DatabaseBuilderRelationshipSqliteTest.php | 13 + ...est.php => NestedRelationshipBehavior.php} | 170 +------ tests/Builder/NestedRelationshipMysqlTest.php | 17 + tests/Builder/NestedRelationshipPgsqlTest.php | 17 + .../Builder/NestedRelationshipSqliteTest.php | 13 + ...onshipSpecificColumnSelectionBehavior.php} | 204 +------- ...onshipSpecificColumnSelectionMysqlTest.php | 17 + ...onshipSpecificColumnSelectionPgsqlTest.php | 17 + ...nshipSpecificColumnSelectionSqliteTest.php | 13 + ...hp => EntityModelComplexQueryBehavior.php} | 294 +++++------- .../EntityModelComplexQueryMysqlTest.php | 17 + .../EntityModelComplexQueryPgsqlTest.php | 17 + .../EntityModelComplexQuerySqliteTest.php | 13 + ...yTest.php => EntityModelQueryBehavior.php} | 270 ++++------- .../Model/Query/EntityModelQueryMysqlTest.php | 17 + .../Model/Query/EntityModelQueryPgsqlTest.php | 17 + .../Query/EntityModelQuerySqliteTest.php | 13 + ...est.php => EntityRelationshipBehavior.php} | 256 +++------- .../Query/EntityRelationshipMysqlTest.php | 17 + .../Query/EntityRelationshipPgsqlTest.php | 17 + .../Query/EntityRelationshipSqliteTest.php | 13 + .../BuilderRelationshipDriverTestCase.php | 137 ++++++ .../Database/ModelQueryDriverTestCase.php | 451 ++++++++++++++++++ 28 files changed, 1258 insertions(+), 1060 deletions(-) rename tests/Builder/{DatabaseBuilderRelationshipTest.php => DatabaseBuilderRelationshipBehavior.php} (81%) create mode 100644 tests/Builder/DatabaseBuilderRelationshipMysqlTest.php create mode 100644 tests/Builder/DatabaseBuilderRelationshipPgsqlTest.php create mode 100644 tests/Builder/DatabaseBuilderRelationshipSqliteTest.php rename tests/Builder/{NestedRelationshipTest.php => NestedRelationshipBehavior.php} (70%) create mode 100644 tests/Builder/NestedRelationshipMysqlTest.php create mode 100644 tests/Builder/NestedRelationshipPgsqlTest.php create mode 100644 tests/Builder/NestedRelationshipSqliteTest.php rename tests/Builder/{RelationshipSpecificColumnSelectionTest.php => RelationshipSpecificColumnSelectionBehavior.php} (65%) create mode 100644 tests/Builder/RelationshipSpecificColumnSelectionMysqlTest.php create mode 100644 tests/Builder/RelationshipSpecificColumnSelectionPgsqlTest.php create mode 100644 tests/Builder/RelationshipSpecificColumnSelectionSqliteTest.php rename tests/Model/Query/{EntityModelComplexQueryTest.php => EntityModelComplexQueryBehavior.php} (75%) create mode 100644 tests/Model/Query/EntityModelComplexQueryMysqlTest.php create mode 100644 tests/Model/Query/EntityModelComplexQueryPgsqlTest.php create mode 100644 tests/Model/Query/EntityModelComplexQuerySqliteTest.php rename tests/Model/Query/{EntityModelQueryTest.php => EntityModelQueryBehavior.php} (93%) create mode 100644 tests/Model/Query/EntityModelQueryMysqlTest.php create mode 100644 tests/Model/Query/EntityModelQueryPgsqlTest.php create mode 100644 tests/Model/Query/EntityModelQuerySqliteTest.php rename tests/Model/Query/{EntityRelationshipTest.php => EntityRelationshipBehavior.php} (84%) create mode 100644 tests/Model/Query/EntityRelationshipMysqlTest.php create mode 100644 tests/Model/Query/EntityRelationshipPgsqlTest.php create mode 100644 tests/Model/Query/EntityRelationshipSqliteTest.php create mode 100644 tests/Support/Database/BuilderRelationshipDriverTestCase.php create mode 100644 tests/Support/Database/ModelQueryDriverTestCase.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b67c51c2..21a34be6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -71,16 +71,93 @@ jobs: command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress --with="phpunit/phpunit:~${{ matrix.phpunit }}" - name: Execute tests - run: vendor/bin/phpunit + run: vendor/bin/phpunit --exclude-group database-external env: DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_USERNAME: root + database_driver_tests: + runs-on: ubuntu-24.04 + + services: + mysql: + image: mysql:5.7 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: testing + ports: + - 33306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + postgres: + image: postgres:16 + env: + POSTGRES_DB: testing + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 35432:5432 + options: >- + --health-cmd="pg_isready -U postgres -d testing" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + strategy: + fail-fast: false + matrix: + driver: [mysql, pgsql] + + name: External DB - ${{ matrix.driver }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.5 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, pdo_mysql, pgsql, pdo_pgsql + ini-values: error_reporting=E_ALL + tools: composer:v2 + coverage: none + + - name: Set Framework version + run: composer config version "2.x-dev" + + - name: Install dependencies + uses: nick-fields/retry@v3 + with: + timeout_minutes: 5 + max_attempts: 5 + command: composer update --prefer-stable --prefer-dist --no-interaction --no-progress --with="phpunit/phpunit:~12.5.8" + + - name: Execute MySQL model query tests + if: matrix.driver == 'mysql' + run: vendor/bin/phpunit --group mysql + env: + DOPPAR_TEST_MYSQL_HOST: 127.0.0.1 + DOPPAR_TEST_MYSQL_PORT: ${{ job.services.mysql.ports[3306] }} + DOPPAR_TEST_MYSQL_DATABASE: testing + DOPPAR_TEST_MYSQL_USERNAME: root + DOPPAR_TEST_MYSQL_PASSWORD: '' + DOPPAR_TEST_MYSQL_CHARSET: utf8mb4 + + - name: Execute PostgreSQL model query tests + if: matrix.driver == 'pgsql' + run: vendor/bin/phpunit --group pgsql + env: + DOPPAR_TEST_PGSQL_HOST: 127.0.0.1 + DOPPAR_TEST_PGSQL_PORT: ${{ job.services.postgres.ports[5432] }} + DOPPAR_TEST_PGSQL_DATABASE: testing + DOPPAR_TEST_PGSQL_USERNAME: postgres + DOPPAR_TEST_PGSQL_PASSWORD: postgres + - name: Store artifacts if: always() uses: actions/upload-artifact@v4 with: - name: linux-logs-${{ matrix.php }}-${{ matrix.phpunit }}-${{ matrix.stability }} + name: external-db-logs-${{ matrix.driver }} path: | vendor/orchestra/testbench-core/doppar/storage/logs !vendor/**/.gitignore @@ -132,7 +209,7 @@ jobs: command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress --with="phpunit/phpunit:~${{ matrix.phpunit }}" - name: Execute tests - run: vendor/bin/phpunit + run: vendor/bin/phpunit --exclude-group database-external - name: Store artifacts if: always() @@ -141,4 +218,4 @@ jobs: name: windows-logs-${{ matrix.php }}-${{ matrix.phpunit }}-${{ matrix.stability }} path: | vendor/orchestra/testbench-core/doppar/storage/logs - !vendor/**/.gitignore \ No newline at end of file + !vendor/**/.gitignore diff --git a/composer.json b/composer.json index 6664c67f..960a3068 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,13 @@ "Tests\\": "tests/" } }, + "scripts": { + "test": "vendor/bin/phpunit --exclude-group database-external", + "test:sqlite": "@test", + "test:mysql": "vendor/bin/phpunit --group mysql", + "test:pgsql": "vendor/bin/phpunit --group pgsql", + "test:all": "vendor/bin/phpunit" + }, "extra": { "branch-alias": { "dev-master": "3.x-dev" @@ -74,4 +81,4 @@ }, "minimum-stability": "dev", "prefer-stable": true -} \ No newline at end of file +} diff --git a/tests/Builder/DatabaseBuilderRelationshipTest.php b/tests/Builder/DatabaseBuilderRelationshipBehavior.php similarity index 81% rename from tests/Builder/DatabaseBuilderRelationshipTest.php rename to tests/Builder/DatabaseBuilderRelationshipBehavior.php index 1ebe15e3..d15ce477 100644 --- a/tests/Builder/DatabaseBuilderRelationshipTest.php +++ b/tests/Builder/DatabaseBuilderRelationshipBehavior.php @@ -2,155 +2,16 @@ namespace Tests\Unit\Builder; -use Tests\Support\Model\MockUser; use Tests\Support\Model\MockPost; -use Tests\Support\MockContainer; use Phaseolies\Database\Entity\Builder; -use Phaseolies\Database\Database; -use Phaseolies\DI\Container; -use PHPUnit\Framework\TestCase; -use PDO; +use Tests\Support\Database\BuilderRelationshipDriverTestCase; +use Tests\Support\Model\MockUser; -class DatabaseBuilderRelationshipTest extends TestCase +abstract class DatabaseBuilderRelationshipTest extends BuilderRelationshipDriverTestCase { - private $pdo; - - protected function setUp(): void - { - Container::setInstance(new MockContainer()); - - $this->pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - - $this->createTestTables(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - private function createTestTables(): void - { - // Create users table - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE - ) - "); - - // Create posts table - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT NOT NULL, - content TEXT - ) - "); - - // Create comments table - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER, - body TEXT NOT NULL, - approved BOOLEAN DEFAULT 0 - ) - "); - - // Insert test data - $this->pdo->exec(" - INSERT INTO users (name, email) VALUES - ('John Doe', 'john@example.com'), - ('Jane Smith', 'jane@example.com') - "); - - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content) VALUES - (1, 'First Post', 'Content 1'), - (1, 'Second Post', 'Content 2'), - (2, 'Jane Post', 'Content 3') - "); - - $this->pdo->exec(" - INSERT INTO comments (post_id, body, approved) VALUES - (1, 'Great post!', 1), - (1, 'Nice work', 0), - (2, 'Interesting', 1) - "); - } - - /** - * Setup database connections for testing - */ - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo - ]); - } - - /** - * Clean up database connections - */ - private function tearDownDatabaseConnections(): void + protected function createBuilder(string $table = 'users', string $model = MockUser::class): Builder { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - } - - /** - * Helper method to set static properties - */ - private function setStaticProperty(string $className, string $propertyName, $value): void - { - try { - $reflection = new \ReflectionClass($className); - $property = $reflection->getProperty($propertyName); - $property->setValue(null, $value); - } catch (\ReflectionException $e) { - $this->fail("Failed to set static property {$propertyName}: " . $e->getMessage()); - } - } - - /** - * Helper to create a new builder - */ - private function createBuilder(string $table = 'users', string $model = MockUser::class): Builder - { - return new Builder($this->pdo, $table, $model, 15); - } - - /** - * Helper to get builder conditions for assertion - */ - private function getBuilderConditions(Builder $builder): array - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('conditions'); - $conditions = $property->getValue($builder); - return $conditions; - } - - /** - * Helper to get builder eager load for assertion - */ - private function getBuilderEagerLoad(Builder $builder): array - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('eagerLoad'); - $eagerLoad = $property->getValue($builder); - return $eagerLoad; + return parent::createBuilder($table, $model); } public function testEmbedWithSingleRelation() @@ -757,7 +618,7 @@ public function testRelationshipMethodsMaintainBuilderState() $this->assertEquals($originalLimit, $newLimit); } - private function getBuilderOrderBy(Builder $builder): array + protected function getBuilderOrderBy(Builder $builder): array { $reflection = new \ReflectionClass($builder); $property = $reflection->getProperty('orderBy'); @@ -765,11 +626,8 @@ private function getBuilderOrderBy(Builder $builder): array return $orderBy; } - private function getBuilderLimit(Builder $builder): ?int + protected function getBuilderLimit(Builder $builder): ?int { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('limit'); - $limit = $property->getValue($builder); - return $limit; + return parent::getBuilderLimit($builder); } -} \ No newline at end of file +} diff --git a/tests/Builder/DatabaseBuilderRelationshipMysqlTest.php b/tests/Builder/DatabaseBuilderRelationshipMysqlTest.php new file mode 100644 index 00000000..126888b2 --- /dev/null +++ b/tests/Builder/DatabaseBuilderRelationshipMysqlTest.php @@ -0,0 +1,17 @@ +pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->createTestTables(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - private function createTestTables(): void - { - // Create users table - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE - ) - "); - - // Create posts table - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT NOT NULL, - content TEXT, - status BOOLEAN DEFAULT 1 - ) - "); - - // Create comments table - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER, - user_id INTEGER, - body TEXT NOT NULL, - approved BOOLEAN DEFAULT 0 - ) - "); - - $this->pdo->exec(" - CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE TABLE post_tag ( - post_id INTEGER, - tag_id INTEGER, - created_at TEXT - ) - "); - - // Insert test data - $this->pdo->exec(" - INSERT INTO users (name, email) VALUES - ('John Doe', 'john@example.com'), - ('Jane Smith', 'jane@example.com') - "); - - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content, status) VALUES - (1, 'First Post', 'Content 1', 1), - (1, 'Second Post', 'Content 2', 0), - (1, 'Jane Post', 'Content 3', 1) - "); - - $this->pdo->exec(" - INSERT INTO comments (post_id, user_id, body, approved) VALUES - (1, 1, 'Great post!', 1), - (1, 2, 'Nice work', 0), - (2, 1, 'Interesting', 1), - (3, 2, 'Amazing', 1) - "); - - $this->pdo->exec(" - INSERT INTO tags (name) VALUES - ('PHP'), - ('Doppar'), - ('Testing') - "); - - $this->pdo->exec(" - INSERT INTO post_tag (post_id, tag_id) VALUES - (1, 1), - (1, 2), - (2, 1), - (3, 3) - "); - } - - /** - * Setup database connections for testing - */ - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo - ]); - } - - /** - * Clean up database connections - */ - private function tearDownDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - } - - /** - * Helper method to set static properties - */ - private function setStaticProperty(string $className, string $propertyName, $value): void - { - try { - $reflection = new \ReflectionClass($className); - $property = $reflection->getProperty($propertyName); - $property->setValue(null, $value); - } catch (\ReflectionException $e) { - $this->fail("Failed to set static property {$propertyName}: " . $e->getMessage()); - } - } - - /** - * Helper to create a new builder - */ - private function createBuilder(string $table = 'users', string $model = MockUser::class): Builder - { - return new Builder($this->pdo, $table, $model, 15); - } - - /** - * Helper to get builder eager load for assertion - */ - private function getBuilderEagerLoad(Builder $builder): array + protected function createBuilder(string $table = 'users', string $model = MockUser::class): Builder { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('eagerLoad'); - $eagerLoad = $property->getValue($builder); - return $eagerLoad; + return parent::createBuilder($table, $model); } // TEST 1: whereLinked with nested relations diff --git a/tests/Builder/NestedRelationshipMysqlTest.php b/tests/Builder/NestedRelationshipMysqlTest.php new file mode 100644 index 00000000..18f8f1e3 --- /dev/null +++ b/tests/Builder/NestedRelationshipMysqlTest.php @@ -0,0 +1,17 @@ +pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->createTestTables(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - private function createTestTables(): void - { - // Create users table - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE - ) - "); - - // Create posts table - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT NOT NULL, - content TEXT, - status BOOLEAN DEFAULT 1 - ) - "); - - // Create comments table - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER, - user_id INTEGER, - body TEXT NOT NULL, - approved BOOLEAN DEFAULT 0 - ) - "); - - $this->pdo->exec(" - CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE TABLE post_tag ( - post_id INTEGER, - tag_id INTEGER, - created_at TEXT - ) - "); - - // Insert test data - $this->pdo->exec(" - INSERT INTO users (name, email) VALUES - ('John Doe', 'john@example.com'), - ('Jane Smith', 'jane@example.com') - "); - - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content, status) VALUES - (1, 'First Post', 'Content 1', 1), - (1, 'Second Post', 'Content 2', 0), - (1, 'Jane Post', 'Content 3', 1) - "); - - $this->pdo->exec(" - INSERT INTO comments (post_id, user_id, body, approved) VALUES - (1, 1, 'Great post!', 1), - (1, 2, 'Nice work', 0), - (2, 1, 'Interesting', 1), - (3, 2, 'Amazing', 1) - "); - - $this->pdo->exec(" - INSERT INTO tags (name) VALUES - ('PHP'), - ('Doppar'), - ('Testing') - "); - - $this->pdo->exec(" - INSERT INTO post_tag (post_id, tag_id) VALUES - (1, 1), - (1, 2), - (2, 1), - (3, 3) - "); - } - - /** - * Setup database connections for testing - */ - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo - ]); - } - - /** - * Clean up database connections - */ - private function tearDownDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - } - - /** - * Helper method to set static properties - */ - private function setStaticProperty(string $className, string $propertyName, $value): void - { - try { - $reflection = new \ReflectionClass($className); - $property = $reflection->getProperty($propertyName); - $property->setValue(null, $value); - } catch (\ReflectionException $e) { - $this->fail("Failed to set static property {$propertyName}: " . $e->getMessage()); - } - } - - /** - * Helper to create a new builder - */ - private function createBuilder(string $table = 'users', string $model = MockUser::class): Builder - { - return new Builder($this->pdo, $table, $model, 15); - } - - /** - * Helper to get builder conditions for assertion - */ - private function getBuilderConditions(Builder $builder): array - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('conditions'); - $conditions = $property->getValue($builder); - return $conditions; - } - - /** - * Helper to get builder eager load for assertion - */ - private function getBuilderEagerLoad(Builder $builder): array + protected function createBuilder(string $table = 'users', string $model = MockUser::class): Builder { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('eagerLoad'); - $eagerLoad = $property->getValue($builder); - return $eagerLoad; - } - - - /** - * Helper to get builder limit for assertion - */ - private function getBuilderLimit(Builder $builder): ?int - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('limit'); - $limit = $property->getValue($builder); - return $limit; - } - - /** - * Helper to get builder fields for assertion - */ - private function getBuilderFields(Builder $builder): array - { - $reflection = new \ReflectionClass($builder); - $property = $reflection->getProperty('fields'); - $fields = $property->getValue($builder); - return $fields; + return parent::createBuilder($table, $model); } public function testEmbedWithNestedRelationColumnSelection() diff --git a/tests/Builder/RelationshipSpecificColumnSelectionMysqlTest.php b/tests/Builder/RelationshipSpecificColumnSelectionMysqlTest.php new file mode 100644 index 00000000..efd75f5a --- /dev/null +++ b/tests/Builder/RelationshipSpecificColumnSelectionMysqlTest.php @@ -0,0 +1,17 @@ +bind('request', fn() => new Request()); - $container->bind('url', fn() => UrlGenerator::class); - $container->bind('db', fn() => new Database('default')); - - $this->pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->createTestTables(); - $this->seedData(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - private function createTestTables(): void - { - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT, - age INTEGER, - status TEXT DEFAULT 'active', - score REAL DEFAULT 0, - bio TEXT, - created_at TEXT, - updated_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE userss ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE, - age INTEGER, - status TEXT DEFAULT 'active', - created_at TEXT, - updated_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - title TEXT NOT NULL, - content TEXT, - status TEXT DEFAULT 'published', - views INTEGER DEFAULT 0, - created_at TEXT, - updated_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - body TEXT NOT NULL, - approved INTEGER DEFAULT 0, - created_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - "); - - $this->pdo->exec(" - CREATE TABLE post_tag ( - post_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - created_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE product ( - price INTEGER - ) - "); - } - - private function seedData(): void - { - // 10 users: 7 active, 3 inactive; Dave (id=4) has null email; Carol/Grace have null bio - $this->pdo->exec(" - INSERT INTO users (name, email, age, status, score, bio, created_at, updated_at) VALUES - ('Alice Smith', 'alice@example.com', 30, 'active', 95.5, 'Developer', '2024-01-01 10:00:00', '2024-01-01 10:00:00'), - ('Bob Jones', 'bob@example.com', 25, 'active', 72.0, 'Designer', '2024-01-02 10:00:00', '2024-01-02 10:00:00'), - ('Carol White', 'carol@example.com', 35, 'inactive', 55.0, NULL, '2024-01-03 10:00:00', '2024-01-03 10:00:00'), - ('Dave Brown', NULL, 40, 'active', 88.0, 'Manager', '2024-01-04 10:00:00', '2024-01-04 10:00:00'), - ('Eve Davis', 'eve@example.com', 22, 'inactive', 40.0, 'Intern', '2024-01-05 10:00:00', '2024-01-05 10:00:00'), - ('Frank Miller', 'frank@example.com', 45, 'active', 91.0, 'Architect', '2024-01-06 10:00:00', '2024-01-06 10:00:00'), - ('Grace Lee', 'grace@example.com', 28, 'active', 63.0, NULL, '2024-01-07 10:00:00', '2024-01-07 10:00:00'), - ('Henry Wilson', 'henry@example.com', 33, 'inactive', 77.0, 'DevOps', '2024-01-08 10:00:00', '2024-01-08 10:00:00'), - ('Irene Clark', 'irene@example.com', 29, 'active', 82.0, 'QA', '2024-01-09 10:00:00', '2024-01-09 10:00:00'), - ('James Scott', 'james@example.com', 50, 'active', 99.0, 'CTO', '2024-01-10 10:00:00', '2024-01-10 10:00:00') - "); - - // 10 posts across users 1,2,3,4,6,9,10 - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content, status, views, created_at, updated_at) VALUES - (1, 'Alice Post One', 'Content 1', 'published', 1000, '2024-02-01 10:00:00', '2024-02-01 10:00:00'), - (1, 'Alice Post Two', 'Content 2', 'draft', 500, '2024-02-02 10:00:00', '2024-02-02 10:00:00'), - (2, 'Bob Post One', 'Content 3', 'published', 200, '2024-02-03 10:00:00', '2024-02-03 10:00:00'), - (3, 'Carol Post One', 'Content 4', 'published', 750, '2024-02-04 10:00:00', '2024-02-04 10:00:00'), - (4, 'Dave Post One', 'Content 5', 'draft', 300, '2024-02-05 10:00:00', '2024-02-05 10:00:00'), - (6, 'Frank Post One', 'Content 6', 'published', 1500, '2024-02-06 10:00:00', '2024-02-06 10:00:00'), - (6, 'Frank Post Two', 'Content 7', 'published', 100, '2024-02-07 10:00:00', '2024-02-07 10:00:00'), - (9, 'Irene Post One', 'Content 8', 'draft', 600, '2024-02-08 10:00:00', '2024-02-08 10:00:00'), - (10, 'James Post One', 'Content 9', 'published', 2000, '2024-02-09 10:00:00', '2024-02-09 10:00:00'), - (10, 'James Post Two', 'Content 10', 'published', 900, '2024-02-10 10:00:00', '2024-02-10 10:00:00') - "); - - $this->pdo->exec(" - INSERT INTO comments (post_id, user_id, body, approved, created_at) VALUES - (1, 2, 'Great post!', 1, '2024-03-01 10:00:00'), - (1, 3, 'Very helpful.', 1, '2024-03-02 10:00:00'), - (1, 4, 'Thanks Alice!', 0, '2024-03-03 10:00:00'), - (6, 1, 'Love it', 1, '2024-03-04 10:00:00'), - (9, 6, 'Nice draft', 0, '2024-03-05 10:00:00'), - (9, 7, 'Looking forward', 1, '2024-03-06 10:00:00') - "); - - $this->pdo->exec(" - INSERT INTO tags (name) VALUES ('php'), ('orm'), ('database'), ('performance'), ('testing') - "); - - $this->pdo->exec(" - INSERT INTO post_tag (post_id, tag_id, created_at) VALUES - (1, 1, '2024-02-01'), (1, 2, '2024-02-01'), (1, 3, '2024-02-01'), - (6, 1, '2024-02-06'), (6, 4, '2024-02-06'), - (9, 5, '2024-02-08'), - (10,1, '2024-02-09'), (10,2, '2024-02-09'), (10,4, '2024-02-09') - "); - } - - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo, - ]); - } - - private function tearDownDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - } - - private function setStaticProperty(string $class, string $property, mixed $value): void - { - $ref = new \ReflectionClass($class); - $prop = $ref->getProperty($property); - $prop->setValue(null, $value); + protected function tableDefinitions(): array + { + return [ + 'users' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true], + ['name' => 'age', 'type' => 'integer', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'active'], + ['name' => 'score', 'type' => 'real', 'nullable' => true, 'default' => 0], + ['name' => 'bio', 'type' => 'text', 'nullable' => true], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + 'userss' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true, 'unique' => true], + ['name' => 'age', 'type' => 'integer', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'active'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + 'posts' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'user_id', 'type' => 'integer'], + ['name' => 'title', 'type' => 'string'], + ['name' => 'content', 'type' => 'text', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'published'], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + 'comments' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'post_id', 'type' => 'integer'], + ['name' => 'user_id', 'type' => 'integer'], + ['name' => 'body', 'type' => 'text'], + ['name' => 'approved', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'tags' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'post_tag' => [ + ['name' => 'post_id', 'type' => 'integer'], + ['name' => 'tag_id', 'type' => 'integer'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'product' => [ + ['name' => 'price', 'type' => 'integer', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'users' => [ + ['name' => 'Alice Smith', 'email' => 'alice@example.com', 'age' => 30, 'status' => 'active', 'score' => 95.5, 'bio' => 'Developer', 'created_at' => '2024-01-01 10:00:00', 'updated_at' => '2024-01-01 10:00:00'], + ['name' => 'Bob Jones', 'email' => 'bob@example.com', 'age' => 25, 'status' => 'active', 'score' => 72.0, 'bio' => 'Designer', 'created_at' => '2024-01-02 10:00:00', 'updated_at' => '2024-01-02 10:00:00'], + ['name' => 'Carol White', 'email' => 'carol@example.com', 'age' => 35, 'status' => 'inactive', 'score' => 55.0, 'bio' => null, 'created_at' => '2024-01-03 10:00:00', 'updated_at' => '2024-01-03 10:00:00'], + ['name' => 'Dave Brown', 'email' => null, 'age' => 40, 'status' => 'active', 'score' => 88.0, 'bio' => 'Manager', 'created_at' => '2024-01-04 10:00:00', 'updated_at' => '2024-01-04 10:00:00'], + ['name' => 'Eve Davis', 'email' => 'eve@example.com', 'age' => 22, 'status' => 'inactive', 'score' => 40.0, 'bio' => 'Intern', 'created_at' => '2024-01-05 10:00:00', 'updated_at' => '2024-01-05 10:00:00'], + ['name' => 'Frank Miller', 'email' => 'frank@example.com', 'age' => 45, 'status' => 'active', 'score' => 91.0, 'bio' => 'Architect', 'created_at' => '2024-01-06 10:00:00', 'updated_at' => '2024-01-06 10:00:00'], + ['name' => 'Grace Lee', 'email' => 'grace@example.com', 'age' => 28, 'status' => 'active', 'score' => 63.0, 'bio' => null, 'created_at' => '2024-01-07 10:00:00', 'updated_at' => '2024-01-07 10:00:00'], + ['name' => 'Henry Wilson', 'email' => 'henry@example.com', 'age' => 33, 'status' => 'inactive', 'score' => 77.0, 'bio' => 'DevOps', 'created_at' => '2024-01-08 10:00:00', 'updated_at' => '2024-01-08 10:00:00'], + ['name' => 'Irene Clark', 'email' => 'irene@example.com', 'age' => 29, 'status' => 'active', 'score' => 82.0, 'bio' => 'QA', 'created_at' => '2024-01-09 10:00:00', 'updated_at' => '2024-01-09 10:00:00'], + ['name' => 'James Scott', 'email' => 'james@example.com', 'age' => 50, 'status' => 'active', 'score' => 99.0, 'bio' => 'CTO', 'created_at' => '2024-01-10 10:00:00', 'updated_at' => '2024-01-10 10:00:00'], + ], + 'posts' => [ + ['user_id' => 1, 'title' => 'Alice Post One', 'content' => 'Content 1', 'status' => 'published', 'views' => 1000, 'created_at' => '2024-02-01 10:00:00', 'updated_at' => '2024-02-01 10:00:00'], + ['user_id' => 1, 'title' => 'Alice Post Two', 'content' => 'Content 2', 'status' => 'draft', 'views' => 500, 'created_at' => '2024-02-02 10:00:00', 'updated_at' => '2024-02-02 10:00:00'], + ['user_id' => 2, 'title' => 'Bob Post One', 'content' => 'Content 3', 'status' => 'published', 'views' => 200, 'created_at' => '2024-02-03 10:00:00', 'updated_at' => '2024-02-03 10:00:00'], + ['user_id' => 3, 'title' => 'Carol Post One', 'content' => 'Content 4', 'status' => 'published', 'views' => 750, 'created_at' => '2024-02-04 10:00:00', 'updated_at' => '2024-02-04 10:00:00'], + ['user_id' => 4, 'title' => 'Dave Post One', 'content' => 'Content 5', 'status' => 'draft', 'views' => 300, 'created_at' => '2024-02-05 10:00:00', 'updated_at' => '2024-02-05 10:00:00'], + ['user_id' => 6, 'title' => 'Frank Post One', 'content' => 'Content 6', 'status' => 'published', 'views' => 1500, 'created_at' => '2024-02-06 10:00:00', 'updated_at' => '2024-02-06 10:00:00'], + ['user_id' => 6, 'title' => 'Frank Post Two', 'content' => 'Content 7', 'status' => 'published', 'views' => 100, 'created_at' => '2024-02-07 10:00:00', 'updated_at' => '2024-02-07 10:00:00'], + ['user_id' => 9, 'title' => 'Irene Post One', 'content' => 'Content 8', 'status' => 'draft', 'views' => 600, 'created_at' => '2024-02-08 10:00:00', 'updated_at' => '2024-02-08 10:00:00'], + ['user_id' => 10, 'title' => 'James Post One', 'content' => 'Content 9', 'status' => 'published', 'views' => 2000, 'created_at' => '2024-02-09 10:00:00', 'updated_at' => '2024-02-09 10:00:00'], + ['user_id' => 10, 'title' => 'James Post Two', 'content' => 'Content 10', 'status' => 'published', 'views' => 900, 'created_at' => '2024-02-10 10:00:00', 'updated_at' => '2024-02-10 10:00:00'], + ], + 'comments' => [ + ['post_id' => 1, 'user_id' => 2, 'body' => 'Great post!', 'approved' => 1, 'created_at' => '2024-03-01 10:00:00'], + ['post_id' => 1, 'user_id' => 3, 'body' => 'Very helpful.', 'approved' => 1, 'created_at' => '2024-03-02 10:00:00'], + ['post_id' => 1, 'user_id' => 4, 'body' => 'Thanks Alice!', 'approved' => 0, 'created_at' => '2024-03-03 10:00:00'], + ['post_id' => 6, 'user_id' => 1, 'body' => 'Love it', 'approved' => 1, 'created_at' => '2024-03-04 10:00:00'], + ['post_id' => 9, 'user_id' => 6, 'body' => 'Nice draft', 'approved' => 0, 'created_at' => '2024-03-05 10:00:00'], + ['post_id' => 9, 'user_id' => 7, 'body' => 'Looking forward', 'approved' => 1, 'created_at' => '2024-03-06 10:00:00'], + ], + 'tags' => [ + ['name' => 'php'], + ['name' => 'orm'], + ['name' => 'database'], + ['name' => 'performance'], + ['name' => 'testing'], + ], + 'post_tag' => [ + ['post_id' => 1, 'tag_id' => 1, 'created_at' => '2024-02-01'], + ['post_id' => 1, 'tag_id' => 2, 'created_at' => '2024-02-01'], + ['post_id' => 1, 'tag_id' => 3, 'created_at' => '2024-02-01'], + ['post_id' => 6, 'tag_id' => 1, 'created_at' => '2024-02-06'], + ['post_id' => 6, 'tag_id' => 4, 'created_at' => '2024-02-06'], + ['post_id' => 9, 'tag_id' => 5, 'created_at' => '2024-02-08'], + ['post_id' => 10, 'tag_id' => 1, 'created_at' => '2024-02-09'], + ['post_id' => 10, 'tag_id' => 2, 'created_at' => '2024-02-09'], + ['post_id' => 10, 'tag_id' => 4, 'created_at' => '2024-02-09'], + ], + ]; } public function testOrWhereNullOnLargeDataset(): void diff --git a/tests/Model/Query/EntityModelComplexQueryMysqlTest.php b/tests/Model/Query/EntityModelComplexQueryMysqlTest.php new file mode 100644 index 00000000..4046c8e4 --- /dev/null +++ b/tests/Model/Query/EntityModelComplexQueryMysqlTest.php @@ -0,0 +1,17 @@ +bind('request', fn() => new Request()); - $container->bind('url', fn() => UrlGenerator::class); - $container->bind('db', fn() => new Database('default')); - - $this->pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->createTestTables(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - private function createTestTables(): void - { - // Create users table - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE, - age INTEGER, - status TEXT DEFAULT 'active', - created_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE userss ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE, - age INTEGER, - status TEXT DEFAULT 'active', - created_at TEXT, - updated_at TEXT - ) - "); - - // Create posts table - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT NOT NULL, - content TEXT, - status BOOLEAN DEFAULT 1, - views INTEGER DEFAULT 0, - created_at TEXT - ) - "); - - // Create comments table - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER, - user_id INTEGER, - body TEXT NOT NULL, - approved BOOLEAN DEFAULT 0, - created_at TEXT - ) - "); - - // Create tags table - $this->pdo->exec(" - CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - "); - - // Create post_tag pivot table - $this->pdo->exec(" - CREATE TABLE post_tag ( - post_id INTEGER, - tag_id INTEGER, - created_at TEXT - ) - "); - - $this->pdo->exec(" - CREATE TABLE product ( - price INTEGER - ) - "); - - // Insert test data - $this->pdo->exec(" - INSERT INTO product (price) VALUES - (875), - (435), - (999) - "); - - $this->pdo->exec(" - INSERT INTO users (name, email, age, status, created_at) VALUES - ('John Doe', 'john@example.com', 30, 'active', '2024-01-01 10:00:00'), - ('Jane Smith', 'jane@example.com', 25, 'active', '2024-01-02 10:00:00'), - ('Bob Wilson', 'bob@example.com', 35, 'inactive', '2024-01-03 10:00:00') - "); - - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content, status, views, created_at) VALUES - (1, 'First Post', 'Content 1', 1, 100, '2024-01-01 11:00:00'), - (1, 'Second Post', 'Content 2', 0, 50, '2024-01-02 11:00:00'), - (2, 'Jane Post', 'Content 3', 1, 200, '2024-01-03 11:00:00'), - (1, 'Third Post', 'Content 4', 1, 150, '2024-01-04 11:00:00') - "); - - $this->pdo->exec(" - INSERT INTO comments (post_id, user_id, body, approved, created_at) VALUES - (1, 1, 'Great post!', 1, '2024-01-01 12:00:00'), - (1, 2, 'Nice work', 0, '2024-01-01 13:00:00'), - (2, 1, 'Interesting', 1, '2024-01-02 12:00:00'), - (3, 2, 'Amazing', 1, '2024-01-03 12:00:00'), - (1, 3, 'Awesome', 1, '2024-01-01 14:00:00') - "); - - $this->pdo->exec(" - INSERT INTO tags (name) VALUES - ('PHP'), - ('Doppar'), - ('Testing'), - ('Database') - "); - - $this->pdo->exec(" - INSERT INTO post_tag (post_id, tag_id, created_at) VALUES - (1, 1, '2024-01-01 11:00:00'), - (1, 2, '2024-01-01 11:00:00'), - (2, 1, '2024-01-02 11:00:00'), - (3, 3, '2024-01-03 11:00:00'), - (4, 4, '2024-01-04 11:00:00') - "); - } - - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo - ]); - } - - private function tearDownDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); + protected function tableDefinitions(): array + { + return [ + 'users' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true, 'unique' => true], + ['name' => 'age', 'type' => 'integer', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'active'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'userss' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true, 'unique' => true], + ['name' => 'age', 'type' => 'integer', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'active'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ['name' => 'updated_at', 'type' => 'datetime', 'nullable' => true], + ], + 'posts' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'content', 'type' => 'text', 'nullable' => true], + ['name' => 'status', 'type' => 'boolean', 'nullable' => true, 'default' => 1], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'comments' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'text'], + ['name' => 'approved', 'type' => 'boolean', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'tags' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'post_tag' => [ + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'tag_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'product' => [ + ['name' => 'price', 'type' => 'integer', 'nullable' => true], + ], + ]; } - private function setStaticProperty(string $className, string $propertyName, $value): void + protected function seedData(): array { - try { - $reflection = new \ReflectionClass($className); - $property = $reflection->getProperty($propertyName); - $property->setValue(null, $value); - } catch (\ReflectionException $e) { - $this->fail("Failed to set static property {$propertyName}: " . $e->getMessage()); - } + return [ + 'product' => [ + ['price' => 875], + ['price' => 435], + ['price' => 999], + ], + 'users' => [ + ['name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'status' => 'active', 'created_at' => '2024-01-01 10:00:00'], + ['name' => 'Jane Smith', 'email' => 'jane@example.com', 'age' => 25, 'status' => 'active', 'created_at' => '2024-01-02 10:00:00'], + ['name' => 'Bob Wilson', 'email' => 'bob@example.com', 'age' => 35, 'status' => 'inactive', 'created_at' => '2024-01-03 10:00:00'], + ], + 'posts' => [ + ['user_id' => 1, 'title' => 'First Post', 'content' => 'Content 1', 'status' => 1, 'views' => 100, 'created_at' => '2024-01-01 11:00:00'], + ['user_id' => 1, 'title' => 'Second Post', 'content' => 'Content 2', 'status' => 0, 'views' => 50, 'created_at' => '2024-01-02 11:00:00'], + ['user_id' => 2, 'title' => 'Jane Post', 'content' => 'Content 3', 'status' => 1, 'views' => 200, 'created_at' => '2024-01-03 11:00:00'], + ['user_id' => 1, 'title' => 'Third Post', 'content' => 'Content 4', 'status' => 1, 'views' => 150, 'created_at' => '2024-01-04 11:00:00'], + ], + 'comments' => [ + ['post_id' => 1, 'user_id' => 1, 'body' => 'Great post!', 'approved' => 1, 'created_at' => '2024-01-01 12:00:00'], + ['post_id' => 1, 'user_id' => 2, 'body' => 'Nice work', 'approved' => 0, 'created_at' => '2024-01-01 13:00:00'], + ['post_id' => 2, 'user_id' => 1, 'body' => 'Interesting', 'approved' => 1, 'created_at' => '2024-01-02 12:00:00'], + ['post_id' => 3, 'user_id' => 2, 'body' => 'Amazing', 'approved' => 1, 'created_at' => '2024-01-03 12:00:00'], + ['post_id' => 1, 'user_id' => 3, 'body' => 'Awesome', 'approved' => 1, 'created_at' => '2024-01-01 14:00:00'], + ], + 'tags' => [ + ['name' => 'PHP'], + ['name' => 'Doppar'], + ['name' => 'Testing'], + ['name' => 'Database'], + ], + 'post_tag' => [ + ['post_id' => 1, 'tag_id' => 1, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 1, 'tag_id' => 2, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 2, 'tag_id' => 1, 'created_at' => '2024-01-02 11:00:00'], + ['post_id' => 3, 'tag_id' => 3, 'created_at' => '2024-01-03 11:00:00'], + ['post_id' => 4, 'tag_id' => 4, 'created_at' => '2024-01-04 11:00:00'], + ], + ]; } public function testAll() diff --git a/tests/Model/Query/EntityModelQueryMysqlTest.php b/tests/Model/Query/EntityModelQueryMysqlTest.php new file mode 100644 index 00000000..04428468 --- /dev/null +++ b/tests/Model/Query/EntityModelQueryMysqlTest.php @@ -0,0 +1,17 @@ +bind('request', fn() => new Request()); - $container->bind('url', fn() => UrlGenerator::class); - $container->bind('db', fn() => new Database('default')); - - $this->pdo = new PDO('sqlite::memory:'); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - - $this->createSchema(); - $this->seedData(); - $this->setupDatabaseConnections(); - } - - protected function tearDown(): void - { - $this->pdo = null; - $this->tearDownDatabaseConnections(); - } - - // ========================================================================= - // SCHEMA & SEED - // ========================================================================= - - private function createSchema(): void - { - // users - $this->pdo->exec(" - CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - email TEXT UNIQUE, - age INTEGER, - status TEXT DEFAULT 'active', - created_at TEXT - ) - "); - - // posts - $this->pdo->exec(" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER, - title TEXT NOT NULL, - content TEXT, - status BOOLEAN DEFAULT 1, - views INTEGER DEFAULT 0, - created_at TEXT - ) - "); - - // comments - $this->pdo->exec(" - CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER, - user_id INTEGER, - body TEXT NOT NULL, - approved BOOLEAN DEFAULT 0, - created_at TEXT - ) - "); - - // tags - $this->pdo->exec(" - CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL - ) - "); - - // post_tag pivot - $this->pdo->exec(" - CREATE TABLE post_tag ( - post_id INTEGER, - tag_id INTEGER, - created_at TEXT - ) - "); - } - - private function seedData(): void - { - // Users: 3 rows - // id=1 John Doe active age=30 - // id=2 Jane Smith active age=25 - // id=3 Bob Wilson inactive age=35 - $this->pdo->exec(" - INSERT INTO users (name, email, age, status, created_at) VALUES - ('John Doe', 'john@example.com', 30, 'active', '2024-01-01 10:00:00'), - ('Jane Smith', 'jane@example.com', 25, 'active', '2024-01-02 10:00:00'), - ('Bob Wilson', 'bob@example.com', 35, 'inactive', '2024-01-03 10:00:00') - "); - - // Posts: 4 rows - // id=1 user_id=1 First Post status=1 views=100 - // id=2 user_id=1 Second Post status=0 views=50 - // id=3 user_id=2 Jane Post status=1 views=200 - // id=4 user_id=1 Third Post status=1 views=150 - $this->pdo->exec(" - INSERT INTO posts (user_id, title, content, status, views, created_at) VALUES - (1, 'First Post', 'Content 1', 1, 100, '2024-01-01 11:00:00'), - (1, 'Second Post', 'Content 2', 0, 50, '2024-01-02 11:00:00'), - (2, 'Jane Post', 'Content 3', 1, 200, '2024-01-03 11:00:00'), - (1, 'Third Post', 'Content 4', 1, 150, '2024-01-04 11:00:00') - "); - - // Comments: 5 rows - // id=1 post_id=1 user_id=1 Great post! approved=1 - // id=2 post_id=1 user_id=2 Nice work approved=0 - // id=3 post_id=2 user_id=1 Interesting approved=1 - // id=4 post_id=3 user_id=2 Amazing approved=1 - // id=5 post_id=1 user_id=3 Awesome approved=1 - $this->pdo->exec(" - INSERT INTO comments (post_id, user_id, body, approved, created_at) VALUES - (1, 1, 'Great post!', 1, '2024-01-01 12:00:00'), - (1, 2, 'Nice work', 0, '2024-01-01 13:00:00'), - (2, 1, 'Interesting', 1, '2024-01-02 12:00:00'), - (3, 2, 'Amazing', 1, '2024-01-03 12:00:00'), - (1, 3, 'Awesome', 1, '2024-01-01 14:00:00') - "); - - // Tags: 4 rows - $this->pdo->exec(" - INSERT INTO tags (name) VALUES ('PHP'), ('Doppar'), ('Testing'), ('Database') - "); - - // post_tag pivot - // post 1 → tags 1,2 - // post 2 → tag 1 - // post 3 → tag 3 - // post 4 → tag 4 - $this->pdo->exec(" - INSERT INTO post_tag (post_id, tag_id, created_at) VALUES - (1, 1, '2024-01-01 11:00:00'), - (1, 2, '2024-01-01 11:00:00'), - (2, 1, '2024-01-02 11:00:00'), - (3, 3, '2024-01-03 11:00:00'), - (4, 4, '2024-01-04 11:00:00') - "); - } - - private function setupDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - $this->setStaticProperty(Database::class, 'connections', [ - 'default' => $this->pdo, - 'sqlite' => $this->pdo, - ]); - } - - private function tearDownDatabaseConnections(): void - { - $this->setStaticProperty(Database::class, 'connections', []); - $this->setStaticProperty(Database::class, 'transactions', []); - } - - private function setStaticProperty(string $class, string $prop, $value): void - { - $r = new \ReflectionClass($class); - $p = $r->getProperty($prop); - $p->setValue(null, $value); + protected function tableDefinitions(): array + { + return [ + 'users' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true, 'unique' => true], + ['name' => 'age', 'type' => 'integer', 'nullable' => true], + ['name' => 'status', 'type' => 'string', 'nullable' => true, 'default' => 'active'], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'posts' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'content', 'type' => 'text', 'nullable' => true], + ['name' => 'status', 'type' => 'boolean', 'nullable' => true, 'default' => 1], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'comments' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'text'], + ['name' => 'approved', 'type' => 'boolean', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'tags' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'post_tag' => [ + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'tag_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'users' => [ + ['name' => 'John Doe', 'email' => 'john@example.com', 'age' => 30, 'status' => 'active', 'created_at' => '2024-01-01 10:00:00'], + ['name' => 'Jane Smith', 'email' => 'jane@example.com', 'age' => 25, 'status' => 'active', 'created_at' => '2024-01-02 10:00:00'], + ['name' => 'Bob Wilson', 'email' => 'bob@example.com', 'age' => 35, 'status' => 'inactive', 'created_at' => '2024-01-03 10:00:00'], + ], + 'posts' => [ + ['user_id' => 1, 'title' => 'First Post', 'content' => 'Content 1', 'status' => 1, 'views' => 100, 'created_at' => '2024-01-01 11:00:00'], + ['user_id' => 1, 'title' => 'Second Post', 'content' => 'Content 2', 'status' => 0, 'views' => 50, 'created_at' => '2024-01-02 11:00:00'], + ['user_id' => 2, 'title' => 'Jane Post', 'content' => 'Content 3', 'status' => 1, 'views' => 200, 'created_at' => '2024-01-03 11:00:00'], + ['user_id' => 1, 'title' => 'Third Post', 'content' => 'Content 4', 'status' => 1, 'views' => 150, 'created_at' => '2024-01-04 11:00:00'], + ], + 'comments' => [ + ['post_id' => 1, 'user_id' => 1, 'body' => 'Great post!', 'approved' => 1, 'created_at' => '2024-01-01 12:00:00'], + ['post_id' => 1, 'user_id' => 2, 'body' => 'Nice work', 'approved' => 0, 'created_at' => '2024-01-01 13:00:00'], + ['post_id' => 2, 'user_id' => 1, 'body' => 'Interesting', 'approved' => 1, 'created_at' => '2024-01-02 12:00:00'], + ['post_id' => 3, 'user_id' => 2, 'body' => 'Amazing', 'approved' => 1, 'created_at' => '2024-01-03 12:00:00'], + ['post_id' => 1, 'user_id' => 3, 'body' => 'Awesome', 'approved' => 1, 'created_at' => '2024-01-01 14:00:00'], + ], + 'tags' => [ + ['name' => 'PHP'], + ['name' => 'Doppar'], + ['name' => 'Testing'], + ['name' => 'Database'], + ], + 'post_tag' => [ + ['post_id' => 1, 'tag_id' => 1, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 1, 'tag_id' => 2, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 2, 'tag_id' => 1, 'created_at' => '2024-01-02 11:00:00'], + ['post_id' => 3, 'tag_id' => 3, 'created_at' => '2024-01-03 11:00:00'], + ['post_id' => 4, 'tag_id' => 4, 'created_at' => '2024-01-04 11:00:00'], + ], + ]; } // ========================================================================= diff --git a/tests/Model/Query/EntityRelationshipMysqlTest.php b/tests/Model/Query/EntityRelationshipMysqlTest.php new file mode 100644 index 00000000..7cec347d --- /dev/null +++ b/tests/Model/Query/EntityRelationshipMysqlTest.php @@ -0,0 +1,17 @@ + [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ['name' => 'email', 'type' => 'string', 'nullable' => true, 'unique' => true], + ], + 'posts' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'category_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'title', 'type' => 'string'], + ['name' => 'content', 'type' => 'text', 'nullable' => true], + ['name' => 'status', 'type' => 'boolean', 'nullable' => true, 'default' => 1], + ['name' => 'views', 'type' => 'integer', 'nullable' => true, 'default' => 0], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'comments' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'user_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'body', 'type' => 'text'], + ['name' => 'approved', 'type' => 'boolean', 'nullable' => true, 'default' => 0], + ['name' => 'status', 'type' => 'boolean', 'nullable' => true, 'default' => 1], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'tags' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + 'post_tag' => [ + ['name' => 'post_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'tag_id', 'type' => 'integer', 'nullable' => true], + ['name' => 'created_at', 'type' => 'datetime', 'nullable' => true], + ], + 'categories' => [ + ['name' => 'id', 'type' => 'id'], + ['name' => 'name', 'type' => 'string'], + ], + ]; + } + + protected function seedData(): array + { + return [ + 'users' => [ + ['name' => 'John Doe', 'email' => 'john@example.com'], + ['name' => 'Jane Smith', 'email' => 'jane@example.com'], + ], + 'categories' => [ + ['name' => 'Engineering'], + ['name' => 'Product'], + ], + 'posts' => [ + ['user_id' => 1, 'category_id' => 1, 'title' => 'First Post', 'content' => 'Content 1', 'status' => 1, 'views' => 100, 'created_at' => '2024-01-01 11:00:00'], + ['user_id' => 1, 'category_id' => 1, 'title' => 'Second Post', 'content' => 'Content 2', 'status' => 0, 'views' => 50, 'created_at' => '2024-01-02 11:00:00'], + ['user_id' => 1, 'category_id' => 2, 'title' => 'Jane Post', 'content' => 'Content 3', 'status' => 1, 'views' => 150, 'created_at' => '2024-01-03 11:00:00'], + ], + 'comments' => [ + ['post_id' => 1, 'user_id' => 1, 'body' => 'Great post!', 'approved' => 1, 'status' => 1, 'created_at' => '2024-01-01 12:00:00'], + ['post_id' => 1, 'user_id' => 2, 'body' => 'Nice work', 'approved' => 0, 'status' => 0, 'created_at' => '2024-01-01 13:00:00'], + ['post_id' => 2, 'user_id' => 1, 'body' => 'Interesting', 'approved' => 1, 'status' => 1, 'created_at' => '2024-01-02 12:00:00'], + ['post_id' => 3, 'user_id' => 2, 'body' => 'Amazing', 'approved' => 1, 'status' => 1, 'created_at' => '2024-01-03 12:00:00'], + ], + 'tags' => [ + ['name' => 'PHP'], + ['name' => 'Doppar'], + ['name' => 'Testing'], + ], + 'post_tag' => [ + ['post_id' => 1, 'tag_id' => 1, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 1, 'tag_id' => 2, 'created_at' => '2024-01-01 11:00:00'], + ['post_id' => 2, 'tag_id' => 1, 'created_at' => '2024-01-02 11:00:00'], + ['post_id' => 3, 'tag_id' => 3, 'created_at' => '2024-01-03 11:00:00'], + ], + ]; + } + + protected function createBuilder(string $table = 'users', string $model = MockUser::class): Builder + { + return new Builder($this->pdo, $table, $model, 15); + } + + protected function createPostBuilder(): Builder + { + return $this->createBuilder('posts', MockPost::class); + } + + protected function createCommentBuilder(): Builder + { + return $this->createBuilder('comments', MockComment::class); + } + + protected function getBuilderConditions(Builder $builder): array + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('conditions'); + + return $property->getValue($builder); + } + + protected function getBuilderEagerLoad(Builder $builder): array + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('eagerLoad'); + + return $property->getValue($builder); + } + + protected function getBuilderLimit(Builder $builder): ?int + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('limit'); + + return $property->getValue($builder); + } + + protected function getBuilderFields(Builder $builder): array + { + $reflection = new \ReflectionClass($builder); + $property = $reflection->getProperty('fields'); + + return $property->getValue($builder); + } +} diff --git a/tests/Support/Database/ModelQueryDriverTestCase.php b/tests/Support/Database/ModelQueryDriverTestCase.php new file mode 100644 index 00000000..9d072389 --- /dev/null +++ b/tests/Support/Database/ModelQueryDriverTestCase.php @@ -0,0 +1,451 @@ + + */ + private static array $schemaBooted = []; + + final protected function setUp(): void + { + parent::setUp(); + + $this->bootContainer(); + $this->pdo = $this->createDriverPdo(); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + if (!isset(self::$schemaBooted[static::class])) { + $this->dropTablesIfExist(array_keys($this->tableDefinitions())); + $this->createTables(); + self::$schemaBooted[static::class] = true; + } + + $this->resetTables(); + $this->seedTables(); + $this->setupDatabaseConnections(); + } + + final protected function tearDown(): void + { + $this->tearDownDatabaseConnections(); + unset($this->pdo); + + parent::tearDown(); + } + + abstract protected static function driverName(): string; + + /** + * @return array>> + */ + abstract protected function tableDefinitions(): array; + + /** + * @return array>> + */ + abstract protected function seedData(): array; + + protected function bootContainer(): void + { + Container::setInstance(new MockContainer()); + $container = new Container(); + $container->bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + } + + protected function createTables(): void + { + foreach ($this->tableDefinitions() as $table => $columns) { + $this->createTable($table, $columns); + } + } + + protected function seedTables(): void + { + foreach ($this->seedData() as $table => $rows) { + if ($rows === []) { + continue; + } + + $this->insertRows($table, $rows); + } + } + + protected function setupDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + $this->setStaticProperty(Database::class, 'drivers', []); + $this->setStaticProperty(Database::class, 'connections', [ + 'default' => $this->pdo, + static::driverName() => $this->pdo, + ]); + } + + protected function tearDownDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + $this->setStaticProperty(Database::class, 'drivers', []); + } + + /** + * @param array $tables + */ + protected function dropTablesIfExist(array $tables): void + { + foreach (array_reverse($tables) as $table) { + $this->pdo->exec(sprintf('DROP TABLE IF EXISTS %s', $this->quoteIdentifier($table))); + } + } + + protected function resetTables(): void + { + $tables = array_keys($this->tableDefinitions()); + + if ($tables === []) { + return; + } + + match (static::driverName()) { + 'sqlite' => $this->resetSqliteTables($tables), + 'mysql' => $this->resetMysqlTables($tables), + 'pgsql' => $this->resetPgsqlTables($tables), + default => throw new \RuntimeException('Unsupported driver [' . static::driverName() . '].'), + }; + } + + /** + * @param array $tables + */ + protected function resetSqliteTables(array $tables): void + { + foreach (array_reverse($tables) as $table) { + $this->pdo->exec(sprintf('DELETE FROM %s', $this->quoteIdentifier($table))); + } + + $sequenceExists = $this->pdo + ->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'") + ?->fetchColumn(); + + if (!$sequenceExists) { + return; + } + + $statement = $this->pdo->prepare('DELETE FROM sqlite_sequence WHERE name = ?'); + + foreach ($tables as $table) { + $statement->execute([$table]); + } + } + + /** + * @param array $tables + */ + protected function resetMysqlTables(array $tables): void + { + foreach (array_reverse($tables) as $table) { + $this->pdo->exec(sprintf('TRUNCATE TABLE %s', $this->quoteIdentifier($table))); + } + } + + /** + * @param array $tables + */ + protected function resetPgsqlTables(array $tables): void + { + $quotedTables = array_map(fn(string $table): string => $this->quoteIdentifier($table), array_reverse($tables)); + + $this->pdo->exec(sprintf( + 'TRUNCATE TABLE %s RESTART IDENTITY CASCADE', + implode(', ', $quotedTables) + )); + } + + /** + * @param array> $columns + */ + protected function createTable(string $table, array $columns): void + { + $definitions = array_map(fn(array $column): string => $this->compileColumn($column), $columns); + + $sql = sprintf( + 'CREATE TABLE %s (%s)', + $this->quoteIdentifier($table), + implode(', ', $definitions) + ); + + $this->pdo->exec($sql); + } + + /** + * @param array> $rows + */ + protected function insertRows(string $table, array $rows): void + { + foreach ($rows as $row) { + $columns = array_keys($row); + $placeholders = implode(', ', array_fill(0, count($columns), '?')); + $quotedColumns = implode(', ', array_map(fn(string $column): string => $this->quoteIdentifier($column), $columns)); + + $statement = $this->pdo->prepare(sprintf( + 'INSERT INTO %s (%s) VALUES (%s)', + $this->quoteIdentifier($table), + $quotedColumns, + $placeholders + )); + + $statement->execute(array_values($row)); + } + } + + /** + * @param array $column + */ + protected function compileColumn(array $column): string + { + if (($column['type'] ?? null) === 'id') { + return $this->compileIdentityColumn($column['name']); + } + + $sql = sprintf( + '%s %s', + $this->quoteIdentifier($column['name']), + $this->compileType($column) + ); + + if (($column['nullable'] ?? false) === false) { + $sql .= ' NOT NULL'; + } + + if (($column['unique'] ?? false) === true) { + $sql .= ' UNIQUE'; + } + + if (array_key_exists('default', $column)) { + $sql .= ' DEFAULT ' . $this->compileDefaultValue($column['default'], $column['type']); + } + + return $sql; + } + + protected function compileIdentityColumn(string $name): string + { + return match (static::driverName()) { + 'sqlite' => sprintf('%s INTEGER PRIMARY KEY AUTOINCREMENT', $this->quoteIdentifier($name)), + 'mysql' => sprintf('%s INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY', $this->quoteIdentifier($name)), + 'pgsql' => sprintf('%s SERIAL PRIMARY KEY', $this->quoteIdentifier($name)), + default => throw new \RuntimeException('Unsupported driver [' . static::driverName() . '].'), + }; + } + + /** + * @param array $column + */ + protected function compileType(array $column): string + { + return match ($column['type']) { + 'string' => 'VARCHAR(' . ($column['length'] ?? 255) . ')', + 'text' => 'TEXT', + 'integer' => 'INTEGER', + 'boolean' => static::driverName() === 'sqlite' ? 'INTEGER' : 'BOOLEAN', + 'datetime' => match (static::driverName()) { + 'sqlite' => 'TEXT', + 'mysql' => 'DATETIME', + 'pgsql' => 'TIMESTAMP', + }, + 'real' => 'REAL', + 'decimal' => sprintf('DECIMAL(%d,%d)', $column['precision'] ?? 10, $column['scale'] ?? 2), + default => throw new \InvalidArgumentException('Unsupported column type [' . $column['type'] . '].'), + }; + } + + protected function compileDefaultValue(mixed $value, string $type): string + { + if ($value === null) { + return 'NULL'; + } + + if ($type === 'boolean') { + return match (static::driverName()) { + 'pgsql' => $value ? 'TRUE' : 'FALSE', + default => $value ? '1' : '0', + }; + } + + if (is_int($value) || is_float($value)) { + return (string) $value; + } + + return "'" . str_replace("'", "''", (string) $value) . "'"; + } + + protected function quoteIdentifier(string $identifier): string + { + $quote = static::driverName() === 'mysql' ? '`' : '"'; + + return $quote . str_replace($quote, $quote . $quote, $identifier) . $quote; + } + + protected function createDriverPdo(): PDO + { + return match (static::driverName()) { + 'sqlite' => $this->createSqlitePdo(), + 'mysql' => $this->createMysqlPdo(), + 'pgsql' => $this->createPgsqlPdo(), + default => throw new \RuntimeException('Unsupported driver [' . static::driverName() . '].'), + }; + } + + protected function createSqlitePdo(): PDO + { + if (!extension_loaded('pdo_sqlite')) { + $this->markTestSkipped('The pdo_sqlite extension is required for SQLite model query tests.'); + } + + return new PDO('sqlite:' . $this->sqliteDatabasePath()); + } + + protected function createMysqlPdo(): PDO + { + if (!extension_loaded('pdo_mysql')) { + $this->markTestSkipped('The pdo_mysql extension is required for MySQL model query tests.'); + } + + [$dsn, $username, $password, $configured] = $this->mysqlConnectionConfig(); + + if (!$configured) { + $this->markTestSkipped('Configure DOPPAR_TEST_MYSQL_DSN or DOPPAR_TEST_MYSQL_HOST/DATABASE to run MySQL model query tests.'); + } + + try { + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } catch (\Throwable $exception) { + $this->fail('Unable to connect to MySQL test database: ' . $exception->getMessage()); + } + } + + protected function createPgsqlPdo(): PDO + { + if (!extension_loaded('pdo_pgsql')) { + $this->markTestSkipped('The pdo_pgsql extension is required for PostgreSQL model query tests.'); + } + + [$dsn, $username, $password, $configured] = $this->pgsqlConnectionConfig(); + + if (!$configured) { + $this->markTestSkipped('Configure DOPPAR_TEST_PGSQL_DSN or DOPPAR_TEST_PGSQL_HOST/DATABASE to run PostgreSQL model query tests.'); + } + + try { + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]); + } catch (\Throwable $exception) { + $this->fail('Unable to connect to PostgreSQL test database: ' . $exception->getMessage()); + } + } + + /** + * @return array{0: string, 1: string, 2: string, 3: bool} + */ + protected function mysqlConnectionConfig(): array + { + $dsn = getenv('DOPPAR_TEST_MYSQL_DSN') ?: ''; + + if ($dsn !== '') { + return [ + $dsn, + (string) (getenv('DOPPAR_TEST_MYSQL_USERNAME') ?: 'root'), + (string) (getenv('DOPPAR_TEST_MYSQL_PASSWORD') ?: ''), + true, + ]; + } + + $database = getenv('DOPPAR_TEST_MYSQL_DATABASE') ?: ''; + + if ($database === '') { + return ['', '', '', false]; + } + + $host = getenv('DOPPAR_TEST_MYSQL_HOST') ?: '127.0.0.1'; + $port = getenv('DOPPAR_TEST_MYSQL_PORT') ?: '3306'; + $charset = getenv('DOPPAR_TEST_MYSQL_CHARSET') ?: 'utf8mb4'; + + return [ + sprintf('mysql:host=%s;port=%s;dbname=%s;charset=%s', $host, $port, $database, $charset), + (string) (getenv('DOPPAR_TEST_MYSQL_USERNAME') ?: 'root'), + (string) (getenv('DOPPAR_TEST_MYSQL_PASSWORD') ?: ''), + true, + ]; + } + + /** + * @return array{0: string, 1: string, 2: string, 3: bool} + */ + protected function pgsqlConnectionConfig(): array + { + $dsn = getenv('DOPPAR_TEST_PGSQL_DSN') ?: ''; + + if ($dsn !== '') { + return [ + $dsn, + (string) (getenv('DOPPAR_TEST_PGSQL_USERNAME') ?: 'postgres'), + (string) (getenv('DOPPAR_TEST_PGSQL_PASSWORD') ?: ''), + true, + ]; + } + + $database = getenv('DOPPAR_TEST_PGSQL_DATABASE') ?: ''; + + if ($database === '') { + return ['', '', '', false]; + } + + $host = getenv('DOPPAR_TEST_PGSQL_HOST') ?: '127.0.0.1'; + $port = getenv('DOPPAR_TEST_PGSQL_PORT') ?: '5432'; + + return [ + sprintf('pgsql:host=%s;port=%s;dbname=%s', $host, $port, $database), + (string) (getenv('DOPPAR_TEST_PGSQL_USERNAME') ?: 'postgres'), + (string) (getenv('DOPPAR_TEST_PGSQL_PASSWORD') ?: ''), + true, + ]; + } + + protected function sqliteDatabasePath(): string + { + $className = str_replace('\\', '-', static::class); + + return rtrim(sys_get_temp_dir(), '/\\') . DIRECTORY_SEPARATOR . strtolower($className) . '.sqlite'; + } + + protected function setStaticProperty(string $className, string $propertyName, mixed $value): void + { + try { + $reflection = new \ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setValue(null, $value); + } catch (\ReflectionException $exception) { + $this->fail('Failed to set static property ' . $propertyName . ': ' . $exception->getMessage()); + } + } +} From 7dd035181124e0df3c4a16df93d2743d59335dc7 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 15 May 2026 17:14:44 +0600 Subject: [PATCH 2/4] Fix MySQL and PostgreSQL query test compatibility and nested relation aliasing --- .../Query/InteractsWithNestedRelations.php | 7 ++- .../DatabaseBuilderRelationshipBehavior.php | 26 ++++----- tests/Builder/NestedRelationshipBehavior.php | 16 +++--- ...ionshipSpecificColumnSelectionBehavior.php | 10 ++-- .../Model/Query/EntityModelQueryBehavior.php | 57 ++++++++++++++----- 5 files changed, 72 insertions(+), 44 deletions(-) diff --git a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php index 4ae89f1a..4a35c812 100644 --- a/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php +++ b/src/Phaseolies/Database/Entity/Query/InteractsWithNestedRelations.php @@ -187,7 +187,8 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation $escapeValue = fn($val) => $this->escapeValue($val); $currentModel = $model; - $currentTable = $this->table; + $rootAlias = $this->table . '_sub'; + $currentTable = $rootAlias; $joins = []; $lastForeignKey = null; $lastLocalKey = null; @@ -243,7 +244,7 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation } // Build the final subquery - $subquery = "SELECT 1 FROM {$quote($this->table)} AS {$quote($this->table . '_sub')}"; + $subquery = "SELECT 1 FROM {$quote($this->table)} AS {$quote($rootAlias)}"; // Add all the joins $subquery .= ' ' . implode(' ', $joins); @@ -254,7 +255,7 @@ protected function buildNestedRelationshipSubquery(Model $model, array $relation $model->$firstRelation(); $firstLocalKey = $model->getLastLocalKey(); - $subquery .= " WHERE {$quote($this->table . '_sub')}.{$quote($firstLocalKey)} = {$quote($this->table)}.{$quote($firstLocalKey)}"; + $subquery .= " WHERE {$quote($rootAlias)}.{$quote($firstLocalKey)} = {$quote($this->table)}.{$quote($firstLocalKey)}"; } // Add the condition on the final table diff --git a/tests/Builder/DatabaseBuilderRelationshipBehavior.php b/tests/Builder/DatabaseBuilderRelationshipBehavior.php index d15ce477..2096a2d5 100644 --- a/tests/Builder/DatabaseBuilderRelationshipBehavior.php +++ b/tests/Builder/DatabaseBuilderRelationshipBehavior.php @@ -40,7 +40,7 @@ public function testEmbedWithCallback() { $builder = $this->createBuilder(); $callback = function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }; $builder->embed('comments', $callback); @@ -78,7 +78,7 @@ public function testPresentWithCallback() { $builder = $this->createBuilder('posts', MockPost::class); $builder->present('comments', function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }); $conditions = $this->getBuilderConditions($builder); @@ -102,7 +102,7 @@ public function testAbsentAddsNotExistsCondition() public function testWhereLinkedWithSimpleRelation() { $builder = $this->createBuilder('posts', MockPost::class); - $builder->whereLinked('comments', 'approved', 1); + $builder->whereLinked('comments', 'approved', true); $conditions = $this->getBuilderConditions($builder); @@ -149,7 +149,7 @@ public function testRelationshipMethodsReturnBuilder() $this->assertInstanceOf(Builder::class, $builder->embed('comments')); $this->assertInstanceOf(Builder::class, $builder->embedCount('likes')); $this->assertInstanceOf(Builder::class, $builder->present('comments')); - $this->assertInstanceOf(Builder::class, $builder->whereLinked('comments', 'approved', 1)); + $this->assertInstanceOf(Builder::class, $builder->whereLinked('comments', 'approved', true)); $this->assertInstanceOf(Builder::class, $builder->search(['title'], 'test')); } @@ -216,7 +216,7 @@ public function testEmbedWithComplexCallback() { $builder = $this->createBuilder('posts', MockPost::class); $builder->embed('comments', function ($query) { - $query->where('approved', 1)->orderBy('id', 'DESC'); + $query->where('approved', true)->orderBy('id', 'DESC'); }); $eagerLoad = $this->getBuilderEagerLoad($builder); @@ -229,7 +229,7 @@ public function testWhereLinkedWithCallback() { $builder = $this->createBuilder('posts', MockPost::class); $builder->whereLinked('comments', function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }); $conditions = $this->getBuilderConditions($builder); @@ -278,7 +278,7 @@ public function testEmbedWithArrayOfRelationsAndCallbacks() $query->where('title', 'LIKE', '%Test%'); }, 'comments' => function ($query) { - $query->where('approved', 1); + $query->where('approved', true); } ]); @@ -294,7 +294,7 @@ public function testEmbedCountWithCallback() { $builder = $this->createBuilder('users', MockUser::class); $callback = function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }; $builder->embedCount('comments', $callback); @@ -313,7 +313,7 @@ public function testEmbedCountWithArrayOfRelationsAndCallbacks() $query->where('status', 'published'); }, 'comments' => function ($query) { - $query->where('approved', 1); + $query->where('approved', true); } ]); @@ -341,7 +341,7 @@ public function testOrPresentWithCallback() { $builder = $this->createBuilder('posts', MockPost::class); $builder->orPresent('comments', function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }); $conditions = $this->getBuilderConditions($builder); @@ -366,7 +366,7 @@ public function testOrAbsentAddsNotExistsConditionWithOrBoolean() public function testWhereLinkedWithDifferentOperators() { $builder = $this->createBuilder('posts', MockPost::class); - $builder->whereLinked('comments', 'approved', '!=', 1); + $builder->whereLinked('comments', 'approved', '!=', true); $conditions = $this->getBuilderConditions($builder); @@ -403,7 +403,7 @@ public function testWhereLinkedWithNestedRelation() { $builder = $this->createBuilder('users', MockUser::class); - $builder->whereLinked('posts.comments', 'approved', 1); + $builder->whereLinked('posts.comments', 'approved', true); $conditions = $this->getBuilderConditions($builder); @@ -557,7 +557,7 @@ public function testMultipleWhereLinkedConditions() { $builder = $this->createBuilder('users', MockUser::class) ->whereLinked('posts', 'status', 'published') - ->whereLinked('comments', 'approved', 1); + ->whereLinked('comments', 'approved', true); $conditions = $this->getBuilderConditions($builder); diff --git a/tests/Builder/NestedRelationshipBehavior.php b/tests/Builder/NestedRelationshipBehavior.php index c5fccaad..53343143 100644 --- a/tests/Builder/NestedRelationshipBehavior.php +++ b/tests/Builder/NestedRelationshipBehavior.php @@ -23,7 +23,7 @@ public function testWhereLinkedWithNestedRelation() // Test nested relation: users who have posts with approved comments // Only user ID 1 should come - $data = $builder->whereLinked('posts.comments', 'approved', 1)->get(); + $data = $builder->whereLinked('posts.comments', 'approved', true)->get(); assertEquals(1, $data[0]->id); assertEquals('John Doe', $data[0]->name); @@ -55,7 +55,7 @@ public function testMultipleNestedEagerLoadsWithColumnSelection() $builder->embed([ 'posts:id,title' => function ($q) { - $q->where('status', 1); + $q->where('status', true); }, 'posts.comments:id,body', 'comments:id,body,approved' @@ -75,7 +75,7 @@ public function testOrPresentMethod() $data = $builder->where('name', 'John') ->orPresent('posts', function ($q) { - $q->where('status', 1); + $q->where('status', true); })->get(); assertEquals(1, $data[0]->id); @@ -150,7 +150,7 @@ public function testRelationCountWithConstraint() $builder = $this->createBuilder('users', MockUser::class); $builder->embedCount('posts', function ($q) { - $q->where('status', 1); + $q->where('status', true); }); $eagerLoad = $this->getBuilderEagerLoad($builder); @@ -208,7 +208,7 @@ public function testIfExistsAlias() $builder = $this->createBuilder('users', MockUser::class); $data = $builder->ifExists('posts', function ($q) { - $q->where('status', 1); + $q->where('status', true); })->get(); assertEquals(1, $data[0]->id); @@ -280,7 +280,7 @@ public function testLoadMethodWithCallback() $builder = $this->createBuilder('users', MockUser::class); $builder->load('posts', function ($q) { - $q->where('status', 1); + $q->where('status', true); }); $this->assertTrue(method_exists($builder, 'load')); @@ -317,7 +317,7 @@ public function testComplexQueryWithMixedPresentAbsent() // All Posts have commnets // Should retun 0 $users = $builder->present('posts', function ($q) { - $q->where('status', 1); + $q->where('status', true); }) ->absent('comments') ->get(); @@ -332,7 +332,7 @@ public function testEmbedArrayFormatWithMultipleConstraints() $builder->embed([ 'comments:id,body,created_at' => function ($q) { - $q->where('approved', 1)->limit(5); + $q->where('approved', true)->limit(5); }, 'user:id,name,email', 'tags*' diff --git a/tests/Builder/RelationshipSpecificColumnSelectionBehavior.php b/tests/Builder/RelationshipSpecificColumnSelectionBehavior.php index 053ac0af..30666703 100644 --- a/tests/Builder/RelationshipSpecificColumnSelectionBehavior.php +++ b/tests/Builder/RelationshipSpecificColumnSelectionBehavior.php @@ -149,7 +149,7 @@ public function testEmbedWithColumnSelectionAndCallback() // Test column selection combined with additional constraints $builder->embed('comments:id,body,created_at', function ($query) { - $query->where('approved', 1)->oldest('created_at'); + $query->where('approved', true)->oldest('created_at'); }); $eagerLoad = $this->getBuilderEagerLoad($builder); @@ -168,7 +168,7 @@ public function testEmbedWithColumnSelectionAndCallback() $this->assertCount(1, $conditions); $this->assertEquals('approved', $conditions[0][1]); $this->assertEquals('=', $conditions[0][2]); - $this->assertEquals(1, $conditions[0][3]); + $this->assertTrue((bool) $conditions[0][3]); } public function testEmbedWithArrayColumnSelection() @@ -178,7 +178,7 @@ public function testEmbedWithArrayColumnSelection() // Test multiple relations with column selection in array format $builder->embed([ 'comments:id,body,created_at' => function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }, 'user:id,name', 'category:id,name' @@ -231,7 +231,7 @@ public function testEmbedCountWithCallbackAndColumnSelection() // Test embedCount with callback and column selection $builder->embedCount('comments:id,body', function ($query) { - $query->where('approved', 1); + $query->where('approved', true); }); $eagerLoad = $this->getBuilderEagerLoad($builder); @@ -255,7 +255,7 @@ public function testLimitInEmbedCallback() // Test that limit works in embed callbacks $builder->embed('comments:id,body', function ($query) { - $query->where('approved', 1) + $query->where('approved', true) ->limit(2) ->oldest('created_at'); }); diff --git a/tests/Model/Query/EntityModelQueryBehavior.php b/tests/Model/Query/EntityModelQueryBehavior.php index e1dd9f7a..0c3110c7 100644 --- a/tests/Model/Query/EntityModelQueryBehavior.php +++ b/tests/Model/Query/EntityModelQueryBehavior.php @@ -14,6 +14,26 @@ abstract class EntityModelQueryTest extends ModelQueryDriverTestCase { + protected function yearMonthSelectExpression(): string + { + return match (static::driverName()) { + 'sqlite' => "COUNT(*) as total, strftime('%Y', created_at) as year, strftime('%m', created_at) as month", + 'mysql' => "COUNT(*) as total, DATE_FORMAT(created_at, '%Y') as year, DATE_FORMAT(created_at, '%m') as month", + 'pgsql' => "COUNT(*) as total, TO_CHAR(created_at, 'YYYY') as year, TO_CHAR(created_at, 'MM') as month", + default => throw new \RuntimeException('Unsupported driver [' . static::driverName() . '].'), + }; + } + + protected function yearMonthGroupExpression(): string + { + return match (static::driverName()) { + 'sqlite' => "strftime('%Y', created_at), strftime('%m', created_at)", + 'mysql' => "DATE_FORMAT(created_at, '%Y'), DATE_FORMAT(created_at, '%m')", + 'pgsql' => "TO_CHAR(created_at, 'YYYY'), TO_CHAR(created_at, 'MM')", + default => throw new \RuntimeException('Unsupported driver [' . static::driverName() . '].'), + }; + } + protected function tableDefinitions(): array { return [ @@ -593,8 +613,8 @@ public function testOrderByRaw(): void public function testGroupByRawComplex(): void { $user = MockUser::query() - ->selectRaw("COUNT(*) as total, strftime('%Y', created_at) as year, strftime('%m', created_at) as month") - ->groupByRaw("strftime('%Y', created_at), strftime('%m', created_at)") + ->selectRaw($this->yearMonthSelectExpression()) + ->groupByRaw($this->yearMonthGroupExpression()) ->orderByRaw('year DESC, month DESC') ->get(); @@ -641,7 +661,7 @@ public function testWhereIn(): void public function testWhereBetween() { $users = MockUser::query() - ->whereBetween('created_at', ['2025-02-29', '2025-04-29']) + ->whereBetween('created_at', ['2025-02-28', '2025-04-29']) ->get(); $this->assertCount(0, $users); @@ -660,7 +680,7 @@ public function testWhereBetween() public function testWhereNotBetween(): void { $users = MockUser::query() - ->whereNotBetween('created_at', ['2025-02-29', '2025-04-29']) + ->whereNotBetween('created_at', ['2025-02-28', '2025-04-29']) ->get(); $this->assertCount(3, $users); @@ -932,7 +952,7 @@ public function testMatch(): void 'user_id' => [1, 2, 3], function ($query) { $query->where('views', '>', 100) - ->where('status', 1); + ->where('status', true); } ]) ->orderBy('created_at', 'desc') @@ -1262,15 +1282,21 @@ public function testAggregate(): void $this->assertEquals(125, $avg); $this->assertEquals(200.0, $max); $this->assertEquals(50.0, $min); - $this->assertEquals(55.9, number_format($stdDev, 1)); - $this->assertEquals(3125.0, $variance); + if (static::driverName() === 'pgsql') { + $this->assertEqualsWithDelta(64.5, (float) $stdDev, 0.1); + $this->assertEqualsWithDelta(4166.6667, (float) $variance, 0.0001); + } else { + $this->assertEqualsWithDelta(55.9, (float) $stdDev, 0.1); + $this->assertEqualsWithDelta(3125.0, (float) $variance, 0.0001); + } } public function testDistinct(): void { - $posts = MockPost::query()->distinct('user_id'); + $posts = MockPost::query()->distinct('user_id')->toArray(); + sort($posts); - $this->assertEquals([1, 2], $posts->toArray()); + $this->assertEquals([1, 2], $posts); } public function testConditionalGroupBy(): void @@ -1278,6 +1304,7 @@ public function testConditionalGroupBy(): void $posts = MockPost::query() ->select(['user_id', 'SUM(views * views) as total_views']) ->groupBy('user_id') + ->orderBy('user_id') ->get(); $this->assertEquals([ @@ -1360,17 +1387,17 @@ public function testCollectionFilter(): void ->map(function ($item) { return [ 'title' => $item->title, - 'status' => $item->status + 'status' => (bool) $item->status ]; }) ->filter(function ($item) { - return $item['status'] === 1; + return $item['status'] === true; }); $this->assertEquals([ - ['title' => 'First Post', 'status' => 1], - ['title' => 'Jane Post', 'status' => 1], - ['title' => 'Third Post', 'status' => 1], + ['title' => 'First Post', 'status' => true], + ['title' => 'Jane Post', 'status' => true], + ['title' => 'Third Post', 'status' => true], ], $posts->toArray()); } @@ -2759,7 +2786,7 @@ public function testIncrementWithNestedWhere(): void { $affected = MockPost::query() ->where(function ($q) { - $q->where('status', 1) + $q->where('status', true) ->whereIn('user_id', [1, 2]); }) ->increment('views', 100); From 62cdeaa54bf7d865a60d72bb99137ef8b35c7948 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 15 May 2026 17:29:16 +0600 Subject: [PATCH 3/4] Add high-signal ORM regression and complex scenario query tests --- .../Query/EntityModelComplexQueryBehavior.php | 51 +++++++++++++++++++ .../Query/EntityRelationshipBehavior.php | 31 +++++++++++ 2 files changed, 82 insertions(+) diff --git a/tests/Model/Query/EntityModelComplexQueryBehavior.php b/tests/Model/Query/EntityModelComplexQueryBehavior.php index 1dc903aa..32e1815d 100644 --- a/tests/Model/Query/EntityModelComplexQueryBehavior.php +++ b/tests/Model/Query/EntityModelComplexQueryBehavior.php @@ -892,6 +892,57 @@ public function testEmbedCountWithWhereIn(): void $this->assertEquals(3, (int)$post1->comments_count); } + /** + * Complex scenario: published posts with approved comments only, plus a + * constrained embedCount for the same relation. + */ + public function testPublishedPostsWithApprovedCommentCounts(): void + { + $posts = MockPost::query() + ->where('status', 'published') + ->present('comments', function ($q) { + $q->where('approved', 1); + }) + ->embedCount([ + 'comments' => function ($q) { + $q->where('approved', 1); + }, + ]) + ->orderBy('user_id') + ->get(); + + $this->assertEquals([1, 6, 10], $posts->map->user_id->map(fn($id) => (int) $id)->toArray()); + $this->assertEquals([2, 1, 1], $posts->map->comments_count->map(fn($count) => (int) $count)->toArray()); + } + + /** + * Complex scenario: relation filter + aggregate grouping + stable ordering. + */ + public function testAggregatePublishedPostsWithApprovedCommentsByUser(): void + { + $rows = MockPost::query() + ->where('status', 'published') + ->present('comments', function ($q) { + $q->where('approved', 1); + }) + ->select(['user_id', 'COUNT(*) as total_posts', 'SUM(views) as total_views']) + ->groupBy('user_id') + ->orderBy('user_id') + ->get(); + + $this->assertEquals([ + ['user_id' => 1, 'total_posts' => 1, 'total_views' => 1000], + ['user_id' => 6, 'total_posts' => 1, 'total_views' => 1500], + ['user_id' => 10, 'total_posts' => 1, 'total_views' => 2000], + ], $rows->map(function ($row) { + return [ + 'user_id' => (int) $row->user_id, + 'total_posts' => (int) $row->total_posts, + 'total_views' => (int) $row->total_views, + ]; + })->toArray()); + } + /** * Many-to-many: all pivot data serialized correctly through toArray() */ diff --git a/tests/Model/Query/EntityRelationshipBehavior.php b/tests/Model/Query/EntityRelationshipBehavior.php index 67f8f362..b278e5bc 100644 --- a/tests/Model/Query/EntityRelationshipBehavior.php +++ b/tests/Model/Query/EntityRelationshipBehavior.php @@ -751,6 +751,37 @@ public function testWhereLinkedInactivePost(): void $this->assertEquals('John Doe', $users->first()->name); } + /** + * Regression: nested whereLinked() must keep the root alias intact and + * compare boolean columns correctly on external drivers. + */ + public function testWhereLinkedNestedApprovedCommentsWithRootFilter(): void + { + $users = MockUser::query() + ->where('status', 'active') + ->whereLinked('posts.comments', 'approved', true) + ->orderBy('id', 'asc') + ->get(); + + $this->assertCount(2, $users); + $this->assertEquals([1, 2], $users->map->id->toArray()); + } + + /** + * Regression: nested whereLinked() with a false boolean constraint should + * still work across SQLite, MySQL, and PostgreSQL. + */ + public function testWhereLinkedNestedUnapprovedComments(): void + { + $users = MockUser::query() + ->whereLinked('posts.comments', 'approved', false) + ->orderBy('id', 'asc') + ->get(); + + $this->assertCount(1, $users); + $this->assertEquals('John Doe', $users->first()->name); + } + // ========================================================================= // 13. link (attach) — many-to-many // ========================================================================= From d26bbfa4fe2c65ef2deb9c3b0251d36efc6ace21 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Fri, 15 May 2026 17:35:31 +0600 Subject: [PATCH 4/4] fix: pgsql distinct test --- tests/Model/Query/EntityModelQueryBehavior.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/Model/Query/EntityModelQueryBehavior.php b/tests/Model/Query/EntityModelQueryBehavior.php index 0c3110c7..f538290c 100644 --- a/tests/Model/Query/EntityModelQueryBehavior.php +++ b/tests/Model/Query/EntityModelQueryBehavior.php @@ -2746,9 +2746,12 @@ public function testDistinctWithWhereIn(): void // distinct() must work with whereIn, not just simple = conditions $userIds = MockPost::query() ->whereIn('user_id', [1, 2]) - ->distinct('user_id'); + ->distinct('user_id') + ->toArray(); + + sort($userIds); - $this->assertEquals([1, 2], $userIds->toArray()); + $this->assertEquals([1, 2], $userIds); } public function testIncrementWithWhereIn(): void