diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..f27de9c --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,5 @@ +exclude_paths: + - ".github/**" + - "example/**" + - "test/**" + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de7c9d..104bb5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,25 @@ name: CI -on: [push, pull_request] +on: + push: + pull_request: permissions: contents: read actions: read - id-token: none jobs: composer: runs-on: ubuntu-latest strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Cache Composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: /tmp/composer-cache key: ${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} @@ -27,6 +28,7 @@ jobs: uses: php-actions/composer@v6 with: php_version: ${{ matrix.php }} + php_extensions: pcntl - name: Archive build run: mkdir /tmp/github-actions/ && tar --exclude=".git" -cvf /tmp/github-actions/build.tar ./ @@ -42,7 +44,7 @@ jobs: needs: [ composer ] strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] outputs: coverage: ${{ steps.store-coverage.outputs.coverage_text }} @@ -77,7 +79,7 @@ jobs: needs: [ phpunit ] strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] steps: - uses: actions/checkout@v4 @@ -92,13 +94,15 @@ jobs: - name: Upload to Codecov uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} phpstan: runs-on: ubuntu-latest needs: [ composer ] strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] steps: - uses: actions/download-artifact@v4 @@ -114,13 +118,15 @@ jobs: with: php_version: ${{ matrix.php }} path: src/ + level: 6 + memory_limit: 256M phpmd: runs-on: ubuntu-latest needs: [ composer ] strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] steps: - uses: actions/download-artifact@v4 @@ -132,7 +138,7 @@ jobs: run: tar -xvf /tmp/github-actions/build.tar ./ - name: PHP Mess Detector - uses: php-actions/phpmd@v2 + uses: php-actions/phpmd@v1 with: php_version: ${{ matrix.php }} path: src/ @@ -144,7 +150,7 @@ jobs: needs: [ composer ] strategy: matrix: - php: [ 8.1, 8.2, 8.3, 8.4 ] + php: [ 8.2, 8.3, 8.4, 8.5 ] steps: - uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 6967bc6..ed06780 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /test/unit/_coverage/ /docs /composer.phar +/test/phpunit/.phpunit.cache +/test/phpunit/_coverage diff --git a/composer.json b/composer.json index 963d1f9..aebe117 100644 --- a/composer.json +++ b/composer.json @@ -8,7 +8,8 @@ "ext-PDO": "*", "phpgt/config": "^v1.1.0", "phpgt/cli": "^1.3", - "greenlion/php-sql-parser": "^4.6" + "greenlion/php-sql-parser": "^4.6", + "phpgt/sqlbuilder": "^1.0.1" }, "require-dev": { @@ -24,7 +25,7 @@ "authors": [ { "name": "Greg Bowler", - "email": "greg.bowler@g105b.com" + "email": "greg@g105b.com" }, { "name": "James Fellows", @@ -48,6 +49,20 @@ } }, + "scripts": { + "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", + "phpunit:coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --configuration phpunit.xml --coverage-text", + "phpstan": "vendor/bin/phpstan analyse --level 6 src", + "phpcs": "vendor/bin/phpcs src --standard=phpcs.xml", + "phpmd": "vendor/bin/phpmd src/ text phpmd.xml", + "test": [ + "@phpunit", + "@phpstan", + "@phpcs", + "@phpmd" + ] + }, + "bin": [ "bin/migrate" ], diff --git a/composer.lock b/composer.lock index 29d955f..6f5a4ad 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "af19a1a7713ce0c7422a3800c7182263", + "content-hash": "5eee2c3a821a40ece49a135b7f3f1fa5", "packages": [ { "name": "greenlion/php-sql-parser", @@ -178,26 +178,26 @@ }, { "name": "phpgt/config", - "version": "v1.1.0", + "version": "v1.1.1", "source": { "type": "git", - "url": "https://github.com/PhpGt/Config.git", - "reference": "805bba9ef1cb8e087a04e4601045271f73868a96" + "url": "https://github.com/phpgt/Config.git", + "reference": "e693bc69af7b844b9b393e8a88755de962b606fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/Config/zipball/805bba9ef1cb8e087a04e4601045271f73868a96", - "reference": "805bba9ef1cb8e087a04e4601045271f73868a96", + "url": "https://api.github.com/repos/phpgt/Config/zipball/e693bc69af7b844b9b393e8a88755de962b606fe", + "reference": "e693bc69af7b844b9b393e8a88755de962b606fe", "shasum": "" }, "require": { "magicalex/write-ini-file": "v1.2.4", - "php": ">=8.0", - "phpgt/typesafegetter": "1.*" + "php": ">=8.2", + "phpgt/typesafegetter": "^v1.2" }, "require-dev": { - "phpstan/phpstan": ">=0.12.64", - "phpunit/phpunit": "9.*" + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^12.4" }, "bin": [ "bin/config-generate" @@ -220,8 +220,8 @@ ], "description": "Manage configuration with ini files and environment variables.", "support": { - "issues": "https://github.com/PhpGt/Config/issues", - "source": "https://github.com/PhpGt/Config/tree/v1.1.0" + "issues": "https://github.com/phpgt/Config/issues", + "source": "https://github.com/phpgt/Config/tree/v1.1.1" }, "funding": [ { @@ -229,7 +229,7 @@ "type": "github" } ], - "time": "2021-01-30T14:24:07+00:00" + "time": "2025-10-19T12:31:15+00:00" }, { "name": "phpgt/daemon", @@ -272,18 +272,61 @@ ], "time": "2021-02-02T17:33:16+00:00" }, + { + "name": "phpgt/sqlbuilder", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/PhpGt/SqlBuilder.git", + "reference": "ac60cad74bb1ac2d8d667e142a56fd6c9d23291a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PhpGt/SqlBuilder/zipball/ac60cad74bb1ac2d8d667e142a56fd6c9d23291a", + "reference": "ac60cad74bb1ac2d8d667e142a56fd6c9d23291a", + "shasum": "" + }, + "require": { + "php": ">=8.0" + }, + "require-dev": { + "phpmd/phpmd": "^2.13", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.3", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Gt\\SqlBuilder\\": "./src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "description": "Object oriented representation of SQL queries.", + "support": { + "issues": "https://github.com/PhpGt/SqlBuilder/issues", + "source": "https://github.com/PhpGt/SqlBuilder/tree/v1.0.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/PhpGt", + "type": "github" + } + ], + "time": "2023-09-17T12:33:25+00:00" + }, { "name": "phpgt/typesafegetter", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", - "url": "https://github.com/PhpGt/TypeSafeGetter.git", - "reference": "f760c05a37b1cc188dcbf800c5fdfab8a926b4b0" + "url": "https://github.com/phpgt/TypeSafeGetter.git", + "reference": "a0d339103828791989cbb81f760d252f3c2f8b8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PhpGt/TypeSafeGetter/zipball/f760c05a37b1cc188dcbf800c5fdfab8a926b4b0", - "reference": "f760c05a37b1cc188dcbf800c5fdfab8a926b4b0", + "url": "https://api.github.com/repos/phpgt/TypeSafeGetter/zipball/a0d339103828791989cbb81f760d252f3c2f8b8c", + "reference": "a0d339103828791989cbb81f760d252f3c2f8b8c", "shasum": "" }, "require": { @@ -313,8 +356,8 @@ ], "description": "An interface for objects that expose type-safe getter methods.", "support": { - "issues": "https://github.com/PhpGt/TypeSafeGetter/issues", - "source": "https://github.com/PhpGt/TypeSafeGetter/tree/v1.3.2" + "issues": "https://github.com/phpgt/TypeSafeGetter/issues", + "source": "https://github.com/phpgt/TypeSafeGetter/tree/v1.3.3" }, "funding": [ { @@ -322,7 +365,7 @@ "type": "github" } ], - "time": "2023-04-28T14:42:27+00:00" + "time": "2026-03-10T22:28:01+00:00" } ], "packages-dev": [ @@ -473,16 +516,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.13.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/1720ddd719e16cf0db4eb1c6eca108031636d46c", - "reference": "1720ddd719e16cf0db4eb1c6eca108031636d46c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -521,7 +564,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.13.1" + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" }, "funding": [ { @@ -529,20 +572,20 @@ "type": "tidelift" } ], - "time": "2025-04-29T12:36:36+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v5.4.0", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/447a020a1f875a434d62f2a401f53b82a396e494", - "reference": "447a020a1f875a434d62f2a401f53b82a396e494", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -561,7 +604,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -585,9 +628,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.4.0" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2024-12-30T11:07:19+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "pdepend/pdepend", @@ -855,16 +898,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.25", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "e310849a19e02b8bfcbb63147f495d8f872dd96f" - }, + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e310849a19e02b8bfcbb63147f495d8f872dd96f", - "reference": "e310849a19e02b8bfcbb63147f495d8f872dd96f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -909,7 +947,7 @@ "type": "github" } ], - "time": "2025-04-27T12:20:45+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -1234,16 +1272,16 @@ }, { "name": "phpunit/phpunit", - "version": "10.5.46", + "version": "10.5.63", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d" + "reference": "33198268dad71e926626b618f3ec3966661e4d90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/8080be387a5be380dda48c6f41cee4a13aadab3d", - "reference": "8080be387a5be380dda48c6f41cee4a13aadab3d", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", "shasum": "" }, "require": { @@ -1253,7 +1291,7 @@ "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.13.1", + "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.1", @@ -1264,13 +1302,13 @@ "phpunit/php-timer": "^6.0.0", "sebastian/cli-parser": "^2.0.1", "sebastian/code-unit": "^2.0.0", - "sebastian/comparator": "^5.0.3", + "sebastian/comparator": "^5.0.5", "sebastian/diff": "^5.1.1", "sebastian/environment": "^6.1.0", - "sebastian/exporter": "^5.1.2", + "sebastian/exporter": "^5.1.4", "sebastian/global-state": "^6.0.2", "sebastian/object-enumerator": "^5.0.0", - "sebastian/recursion-context": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", "sebastian/type": "^4.0.0", "sebastian/version": "^4.0.1" }, @@ -1315,7 +1353,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.46" + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" }, "funding": [ { @@ -1339,7 +1377,7 @@ "type": "tidelift" } ], - "time": "2025-05-02T06:46:24+00:00" + "time": "2026-01-27T05:48:37+00:00" }, { "name": "psr/container", @@ -1614,16 +1652,16 @@ }, { "name": "sebastian/comparator", - "version": "5.0.3", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e" + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", - "reference": "a18251eb0b7a2dcd2f7aa3d6078b18545ef0558e", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", "shasum": "" }, "require": { @@ -1679,15 +1717,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2024-10-18T14:56:07+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { "name": "sebastian/complexity", @@ -1880,16 +1930,16 @@ }, { "name": "sebastian/exporter", - "version": "5.1.2", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf" + "reference": "0735b90f4da94969541dac1da743446e276defa6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/955288482d97c19a372d3f31006ab3f37da47adf", - "reference": "955288482d97c19a372d3f31006ab3f37da47adf", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", "shasum": "" }, "require": { @@ -1898,7 +1948,7 @@ "sebastian/recursion-context": "^5.0" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -1946,15 +1996,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.2" + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T07:17:12+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { "name": "sebastian/global-state", @@ -2190,23 +2252,23 @@ }, { "name": "sebastian/recursion-context", - "version": "5.0.0", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712" + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/05909fb5bc7df4c52992396d0116aed689f93712", - "reference": "05909fb5bc7df4c52992396d0116aed689f93712", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", "shasum": "" }, "require": { "php": ">=8.1" }, "require-dev": { - "phpunit/phpunit": "^10.0" + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { @@ -2241,15 +2303,28 @@ "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.0" + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2023-02-03T07:05:40+00:00" + "time": "2025-08-10T07:50:56+00:00" }, { "name": "sebastian/type", @@ -2362,16 +2437,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.13.0", + "version": "3.13.5", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "65ff2489553b83b4597e89c3b8b721487011d186" + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/65ff2489553b83b4597e89c3b8b721487011d186", - "reference": "65ff2489553b83b4597e89c3b8b721487011d186", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", "shasum": "" }, "require": { @@ -2388,11 +2463,6 @@ "bin/phpcs" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" @@ -2442,38 +2512,38 @@ "type": "thanks_dev" } ], - "time": "2025-05-11T03:36:00+00:00" + "time": "2025-11-04T16:30:35+00:00" }, { "name": "symfony/config", - "version": "v6.4.14", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "4e55e7e4ffddd343671ea972216d4509f46c22ef" + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/4e55e7e4ffddd343671ea972216d4509f46c22ef", - "reference": "4e55e7e4ffddd343671ea972216d4509f46c22ef", + "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/filesystem": "^5.4|^6.0|^7.0", + "symfony/filesystem": "^7.1|^8.0", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/finder": "<5.4", + "symfony/finder": "<6.4", "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/event-dispatcher": "^5.4|^6.0|^7.0", - "symfony/finder": "^5.4|^6.0|^7.0", - "symfony/messenger": "^5.4|^6.0|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2501,7 +2571,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v6.4.14" + "source": "https://github.com/symfony/config/tree/v7.4.7" }, "funding": [ { @@ -2512,49 +2582,52 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-04T11:33:53+00:00" + "time": "2026-03-06T10:41:14+00:00" }, { "name": "symfony/dependency-injection", - "version": "v6.4.20", + "version": "v7.4.7", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "c49796a9184a532843e78e50df9e55708b92543a" + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/c49796a9184a532843e78e50df9e55708b92543a", - "reference": "c49796a9184a532843e78e50df9e55708b92543a", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "psr/container": "^1.1|^2.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/service-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4.20|^7.2.5" + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" }, "conflict": { "ext-psr": "<1.1|>=2", - "symfony/config": "<6.1", - "symfony/finder": "<5.4", - "symfony/proxy-manager-bridge": "<6.3", - "symfony/yaml": "<5.4" + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" }, "provide": { "psr/container-implementation": "1.1|2.0", "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/config": "^6.1|^7.0", - "symfony/expression-language": "^5.4|^6.0|^7.0", - "symfony/yaml": "^5.4|^6.0|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2582,7 +2655,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v6.4.20" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" }, "funding": [ { @@ -2593,25 +2666,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-03-13T09:55:08+00:00" + "time": "2026-03-03T07:48:48+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.5.1", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", - "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { @@ -2624,7 +2701,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -2649,7 +2726,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" }, "funding": [ { @@ -2665,29 +2742,29 @@ "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.13", + "version": "v7.4.6", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3" + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/4856c9cf585d5a0313d8d35afd681a526f038dd3", - "reference": "4856c9cf585d5a0313d8d35afd681a526f038dd3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", + "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -2715,7 +2792,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.13" + "source": "https://github.com/symfony/filesystem/tree/v7.4.6" }, "funding": [ { @@ -2726,16 +2803,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:07:50+00:00" + "time": "2026-02-25T16:50:00+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -2794,7 +2875,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -2805,6 +2886,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2814,7 +2899,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -2875,7 +2960,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -2886,6 +2971,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -2895,16 +2984,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.5.1", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", - "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -2922,7 +3011,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.5-dev" + "dev-main": "3.6-dev" } }, "autoload": { @@ -2958,7 +3047,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -2969,35 +3058,39 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:20:29+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/var-exporter", - "version": "v6.4.21", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "717e7544aa99752c54ecba5c0e17459c48317472" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/717e7544aa99752c54ecba5c0e17459c48317472", - "reference": "717e7544aa99752c54ecba5c0e17459c48317472", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { - "php": ">=8.1", + "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^5.4|^6.0|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3035,7 +3128,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v6.4.21" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -3046,25 +3139,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T21:06:26+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -3093,7 +3190,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -3101,7 +3198,7 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], @@ -3116,5 +3213,5 @@ "platform-dev": { "ext-sqlite3": "*" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/example/08-sqlbuilder-query-collections.php b/example/08-sqlbuilder-query-collections.php new file mode 100644 index 0000000..d80255d --- /dev/null +++ b/example/08-sqlbuilder-query-collections.php @@ -0,0 +1,81 @@ +into("product") + ->columns("name", "price") + ->values(":name", ":price"); + } + + public function getByMinPrice():SelectBuilder { + return (new SelectBuilder()) + ->select("id", "name", "price") + ->from("product") + ->where("price >= :minPrice") + ->orderBy("price desc", "id"); + } +} +PHP; + + writePhpQueryCollection( + $queryPath, + "Product", + "Demo\\Query", + $classCode + ); + + $settings = new Settings( + $queryPath, + Settings::DRIVER_SQLITE, + $databasePath + ); + $db = new Database($settings); + + $db->executeSql(implode("\n", [ + "create table product(", + "\tid integer primary key autoincrement,", + "\tname text not null,", + "\tprice decimal(10,2) not null", + ")", + ])); + + $productQueries = $db->queryCollection("Product"); + $productQueries->setAppNamespace("Demo\\Query"); + + $productQueries->insert("insert", [ + "name" => "Keyboard", + "price" => 59.99, + ]); + $productQueries->insert("insert", [ + "name" => "Mouse", + "price" => 19.99, + ]); + $productQueries->insert("insert", [ + "name" => "Monitor", + "price" => 249.99, + ]); + + $rows = $productQueries->fetchAll("getByMinPrice", [ + "minPrice" => 50, + ]); + foreach($rows as $row) { + printf("%d %-10s %.2f\n", $row->id, $row->name, $row->getFloat("price")); + } +} +finally { + removeDirectory($workspace); +} diff --git a/example/09-sqlbuilder-overrides-and-reports.php b/example/09-sqlbuilder-overrides-and-reports.php new file mode 100644 index 0000000..a37a784 --- /dev/null +++ b/example/09-sqlbuilder-overrides-and-reports.php @@ -0,0 +1,110 @@ +select( + "category.name as categoryName", + "count(product.id) as productCount", + "round(sum(product.price), 2) as totalValue" + ) + ->from("product") + ->innerJoin("category on category.id = product.categoryId") + ->where( + "product.price >= :minPrice", + "product.name != :excludedName" + ) + ->groupBy("category.name") + ->having("count(product.id) >= 1") + ->orderBy("totalValue desc", "category.name"); + } +} +PHP; + + writePhpQueryCollection( + $queryPath, + "Report", + "Demo\\Query", + $classCode + ); + writeSqlQuery($queryPath, "Report", "getByCode", implode("\n", [ + "select product.code, product.name, category.name as categoryName, product.price", + "from product", + "inner join category on category.id = product.categoryId", + "where product.code = :code", + "limit 1", + ])); + + $settings = new Settings( + $queryPath, + Settings::DRIVER_SQLITE, + $databasePath + ); + $db = new Database($settings); + + $db->executeSql(implode("\n", [ + "create table category(", + "\tid integer primary key autoincrement,", + "\tname text not null", + ")", + ])); + $db->executeSql(implode("\n", [ + "create table product(", + "\tid integer primary key autoincrement,", + "\tcategoryId integer not null,", + "\tcode text not null,", + "\tname text not null,", + "\tprice decimal(10,2) not null,", + "\tforeign key(categoryId) references category(id)", + ")", + ])); + $db->executeSql("insert into category(name) values('Peripherals'), ('Displays')"); + $db->executeSql(implode("\n", [ + "insert into product(categoryId, code, name, price) values", + "(1, 'KEY-01', 'Keyboard', 59.99),", + "(1, 'MOU-01', 'Mouse', 19.99),", + "(2, 'MON-01', 'Monitor', 249.99),", + "(2, 'MON-02', 'Portable Display', 179.99)", + ])); + + $reportQueries = $db->queryCollection("Report"); + $reportQueries->setAppNamespace("Demo\\Query"); + + $product = $reportQueries->fetch("getByCode", [ + "code" => "MON-01", + ]); + printf( + "Override query: %s in %s costs %.2f\n", + $product->name, + $product->categoryName, + $product->getFloat("price") + ); + + $totals = $reportQueries->fetchAll("categoryTotals", [ + "minPrice" => 20, + "excludedName" => "Discontinued", + ]); + foreach($totals as $row) { + printf( + "Category: %-12s Count: %d Total: %.2f\n", + $row->categoryName, + $row->getInt("productCount"), + $row->getFloat("totalValue") + ); + } +} +finally { + removeDirectory($workspace); +} diff --git a/example/README.md b/example/README.md index f6cb627..9860d42 100644 --- a/example/README.md +++ b/example/README.md @@ -21,3 +21,5 @@ Each example: - `05-database-migrations.php`: running SQL migrations with `Migrator` - `06-multiple-connections.php`: named connections and switching context - `07-php-query-collections.php`: PHP-backed query collections with custom namespace +- `08-sqlbuilder-query-collections.php`: simple `phpgt/sqlbuilder` query collection methods +- `09-sqlbuilder-overrides-and-reports.php`: class-backed reports plus SQL overrides diff --git a/src/Migration/Migrator.php b/src/Migration/Migrator.php index 3d583c7..938ad9a 100644 --- a/src/Migration/Migrator.php +++ b/src/Migration/Migrator.php @@ -6,7 +6,6 @@ use Gt\Database\Database; use Gt\Database\Connection\Settings; use Gt\Database\DatabaseException; -use PHPSQLParser\lexer\PHPSQLLexer; use SplFileInfo; use SplFileObject; @@ -197,57 +196,23 @@ public function performMigration( array $migrationFileList, int $existingFileNumber = 0 ):int { - $fileNumber = 0; $numCompleted = 0; + $sqlStatementSplitter = new SqlStatementSplitter(); foreach($migrationFileList as $file) { $fileNumber = $this->extractNumberFromFilename($file); - if($fileNumber <= $existingFileNumber) { continue; } $this->output("Migration $fileNumber: `$file`."); - - $totalSql = file_get_contents($file); $md5 = md5_file($file); - $lexer = new PHPSQLLexer(); - $splitSqlQueryList = []; - $currentQuery = ""; - foreach($lexer->split($totalSql) as $token) { - if($token === ";") { - array_push($splitSqlQueryList, trim($currentQuery)); - $currentQuery = ""; - continue; - } - - $currentQuery .= $token; - } - if(trim($currentQuery)) { - array_push($splitSqlQueryList, trim($currentQuery)); - } - - try { - foreach($splitSqlQueryList as $sql) { - $sql = trim($sql); - if(!$sql) { - continue; - } - - $fname = pathinfo($file, PATHINFO_FILENAME); - if($fname === "070-application-renewCardId") { - echo "RUNNING:\n$sql\n\n"; - } - $this->dbClient->executeSql($sql); - } - - $this->recordMigrationSuccess($fileNumber, $md5); - } - catch(DatabaseException $exception) { - throw $exception; + foreach($sqlStatementSplitter->split(file_get_contents($file)) as $sql) { + $this->dbClient->executeSql($sql); } + $this->recordMigrationSuccess($fileNumber, $md5); $numCompleted++; } diff --git a/src/Migration/SqlStatementSplitter.php b/src/Migration/SqlStatementSplitter.php new file mode 100644 index 0000000..0831313 --- /dev/null +++ b/src/Migration/SqlStatementSplitter.php @@ -0,0 +1,39 @@ + */ + public function split(string $sql):array { + $lexer = new PHPSQLLexer(); + $statementList = []; + $currentStatement = ""; + + foreach($lexer->split($sql) as $token) { + if($token === ";") { + $this->appendStatement($statementList, $currentStatement); + $currentStatement = ""; + continue; + } + + $currentStatement .= $token; + } + + $this->appendStatement($statementList, $currentStatement); + return $statementList; + } + + /** @param array $statementList */ + private function appendStatement( + array &$statementList, + string $statement + ):void { + $statement = trim($statement); + if(!$statement) { + return; + } + + $statementList []= $statement; + } +} diff --git a/src/Query/PhpQuery.php b/src/Query/PhpQuery.php index 42570a9..d87c3df 100644 --- a/src/Query/PhpQuery.php +++ b/src/Query/PhpQuery.php @@ -2,6 +2,7 @@ namespace Gt\Database\Query; use Gt\Database\Connection\Driver; +use Stringable; class PhpQuery extends Query { private string $className; @@ -11,7 +12,6 @@ class PhpQuery extends Query { public function __construct(string $filePathWithFunction, Driver $driver) { [$filePath, $functionName] = explode("::", $filePathWithFunction); -// TODO: Allow PHP files with :: separators to function names if(!is_file($filePath)) { throw new QueryNotFoundException($filePath); } @@ -43,6 +43,11 @@ public function getSql(array &$bindings = []):string { $this->instance = new $fqClassName(); } - return $this->instance->{$this->functionName}(); + $sql = $this->instance->{$this->functionName}(); + if($sql instanceof Stringable) { + return (string)$sql; + } + + return $sql; } } diff --git a/src/Query/QueryCollectionFactory.php b/src/Query/QueryCollectionFactory.php index 3044973..9309eb1 100644 --- a/src/Query/QueryCollectionFactory.php +++ b/src/Query/QueryCollectionFactory.php @@ -60,35 +60,63 @@ protected function recurseLocateDirectory( ?string $basePath = null ):?string { $part = array_shift($parts); - if(is_null($basePath)) { - $basePath = $this->basePath; + $basePath = $this->resolveBasePath($basePath); + [$matchingFilePath, $matchingDirectoryPath] = $this->findMatchingPaths( + $basePath, + $part, + ); + + if(empty($parts)) { + return $matchingFilePath ?? $matchingDirectoryPath; } + if($matchingDirectoryPath) { + return $this->recurseLocateDirectory( + $parts, + $matchingDirectoryPath + ); + } + + return null; + } + + protected function resolveBasePath(?string $basePath):string { + $basePath ??= $this->basePath; + if(!is_dir($basePath)) { throw new BaseQueryPathDoesNotExistException($basePath); } + return $basePath; + } + + /** @return array{0:?string, 1:?string} */ + protected function findMatchingPaths( + string $basePath, + string $part, + ):array { + $matchingFilePath = null; + $matchingDirectoryPath = null; foreach(new DirectoryIterator($basePath) as $fileInfo) { if($fileInfo->isDot()) { continue; } - $basename = $fileInfo->getBasename(".php"); - if(strtolower($part) === strtolower($basename)) { - $realPath = $fileInfo->getRealPath(); + if(strtolower($part) !== strtolower($fileInfo->getBasename(".php"))) { + continue; + } - if(empty($parts)) { - return $realPath; - } + if($fileInfo->isDir() && !$matchingDirectoryPath) { + $matchingDirectoryPath = $fileInfo->getRealPath(); + continue; + } - return $this->recurseLocateDirectory( - $parts, - $realPath - ); + if($fileInfo->isFile() && !$matchingFilePath) { + $matchingFilePath = $fileInfo->getRealPath(); } } - return null; + return [$matchingFilePath, $matchingDirectoryPath]; } protected function getDefaultBasePath():string { diff --git a/src/Query/QueryFactory.php b/src/Query/QueryFactory.php index dcfb193..abfe753 100644 --- a/src/Query/QueryFactory.php +++ b/src/Query/QueryFactory.php @@ -5,6 +5,7 @@ use DirectoryIterator; use Exception; use InvalidArgumentException; +use ReflectionClass; use Gt\Database\Connection\Driver; use Gt\Database\Connection\ConnectionNotConfiguredException; @@ -21,26 +22,154 @@ public function __construct( public function findQueryFilePath(string $name):string { if(is_dir($this->queryHolder)) { - foreach(new DirectoryIterator($this->queryHolder) as $fileInfo) { - if($fileInfo->isDot() - || $fileInfo->isDir()) { - continue; + $queryFilePath = $this->findQueryFilePathInDirectory( + $this->queryHolder, + $name + ); + if($queryFilePath) { + return $queryFilePath; + } + } + elseif(is_file($this->queryHolder)) { + $overrideDirectory = $this->locateOverrideDirectory($this->queryHolder); + if($overrideDirectory && is_dir($overrideDirectory)) { + $queryFilePath = $this->findQueryFilePathInDirectory( + $overrideDirectory, + $name + ); + if($queryFilePath) { + if($this->classDefinesPublicMethod($this->queryHolder, $name)) { + throw new QueryOverrideConflictException( + "Query override conflicts with class method: " + . pathinfo($this->queryHolder, PATHINFO_FILENAME) + . "::$name" + ); + } + + return $queryFilePath; } + } - $this->getExtensionIfValid($fileInfo); - $fileNameNoExtension = strtok($fileInfo->getFilename(), "."); - if($fileNameNoExtension !== $name) { - continue; - } + return "$this->queryHolder::$name"; + } + + throw new QueryNotFoundException($this->queryHolder . ", " . $name); + } + + protected function findQueryFilePathInDirectory( + string $directory, + string $name, + ):?string { + foreach(new DirectoryIterator($directory) as $fileInfo) { + if($fileInfo->isDot() + || $fileInfo->isDir()) { + continue; + } + + $this->getExtensionIfValid($fileInfo); + $fileNameNoExtension = strtok($fileInfo->getFilename(), "."); + if($fileNameNoExtension !== $name) { + continue; + } + return $fileInfo->getRealPath(); + } + + return null; + } + + protected function locateOverrideDirectory(string $classFilePath):?string { + $baseName = pathinfo($classFilePath, PATHINFO_FILENAME); + foreach(new DirectoryIterator(dirname($classFilePath)) as $fileInfo) { + if($fileInfo->isDot() || !$fileInfo->isDir()) { + continue; + } + + if(strtolower($fileInfo->getFilename()) === strtolower($baseName)) { return $fileInfo->getRealPath(); } } - elseif(is_file($this->queryHolder)) { - return "$this->queryHolder::$name"; + + return null; + } + + protected function classDefinesPublicMethod( + string $classFilePath, + string $methodName, + ):bool { + $fqClassName = $this->resolveDeclaredClassName($classFilePath); + if(!$fqClassName) { + return false; } - throw new QueryNotFoundException($this->queryHolder . ", " . $name); + require_once($classFilePath); + $reflectionClass = new ReflectionClass($fqClassName); + if(!$reflectionClass->hasMethod($methodName)) { + return false; + } + + return $reflectionClass->getMethod($methodName)->isPublic(); + } + + protected function resolveDeclaredClassName(string $classFilePath):?string { + $tokens = token_get_all(file_get_contents($classFilePath)); + $namespace = ""; + $className = null; + + foreach($tokens as $i => $token) { + if(!is_array($token)) { + continue; + } + + if($token[0] === T_NAMESPACE) { + $namespace = $this->parseNamespaceTokens($tokens, $i + 1); + } + elseif($token[0] === T_CLASS) { + $className = $this->parseClassNameTokens($tokens, $i + 1); + if($className) { + break; + } + } + } + + if(!$className) { + return null; + } + + return ltrim("$namespace\\$className", "\\"); + } + + /** @param array $tokens */ + protected function parseNamespaceTokens(array $tokens, int $offset):string { + $namespace = ""; + + for($i = $offset; isset($tokens[$i]); $i++) { + $token = $tokens[$i]; + if(is_string($token)) { + if($token === ";" || $token === "{") { + break; + } + continue; + } + + if(in_array($token[0], [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED])) { + $namespace .= $token[1]; + } + } + + return $namespace; + } + + /** @param array $tokens */ + protected function parseClassNameTokens(array $tokens, int $offset):?string { + for($i = $offset; isset($tokens[$i]); $i++) { + $token = $tokens[$i]; + if(is_array($token) && $token[0] === T_STRING) { + return $token[1]; + } + } + + return null; } public function create(string $name):Query { diff --git a/src/Query/QueryOverrideConflictException.php b/src/Query/QueryOverrideConflictException.php new file mode 100644 index 0000000..44f3a71 --- /dev/null +++ b/src/Query/QueryOverrideConflictException.php @@ -0,0 +1,6 @@ +queryBase = Helper::getTmpDir() . "/query"; + $this->db = new Database($this->settingsSingleton()); + + $connection = $this->db->getDriver()->getConnection(); + $output = $connection->exec("CREATE TABLE test_table ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(32), number integer, isEven bool, halfNumber float, timestamp DATETIME DEFAULT current_timestamp); CREATE UNIQUE INDEX test_table_name_uindex ON test_table (name);"); + if($output === false) { + $error = $connection->errorInfo(); + throw new Exception($error[2]); + } + + $insertStatement = $connection->prepare("INSERT INTO test_table (`name`, `number`, `isEven`, `halfNumber`) VALUES ('one', 1, 0, 0.5), ('two', 2, 1, 1), ('three', 3, 0, 1.5)"); + $success = $insertStatement->execute(); + if($success === false) { + $error = $connection->errorInfo(); + throw new Exception($error[2]); + } + } + public function testInterface() { $db = new Database(); static::assertInstanceOf(Database::class, $db); @@ -84,4 +110,211 @@ public function testQueryCollectionPhp( self::assertInstanceOf(QueryCollectionClass::class, $queryCollection); } + + public function testClassCollectionInsertUpdateDeleteBuilders():void { + mkdir($this->queryBase, 0775, true); + mkdir($this->queryBase . "/UserCrud", 0775, true); + file_put_contents( + $this->queryBase . "/UserCrud/getInsertedState.sql", + "SELECT number, halfNumber FROM test_table WHERE name = :name LIMIT 1" + ); + file_put_contents( + $this->queryBase . "/UserCrud.php", + <<into("test_table") + ->columns("name", "number", "isEven", "halfNumber") + ->values(":name", ":number", ":isEven", ":halfNumber"); + } + + public function updateHalfNumber():UpdateQuery { + return new class() extends UpdateQuery { + public function update():array { + return ["test_table"]; + } + + public function set():array { + return ["halfNumber"]; + } + + public function where():array { + return ["name = :name"]; + } + }; + } + + public function deleteByName():DeleteBuilder { + return (new DeleteBuilder()) + ->from("test_table") + ->where("name = :name"); + } + } + PHP + ); + + $insertId = $this->db->insert("UserCrud/insertUser", [ + "name" => "four", + "number" => 4, + "isEven" => true, + "halfNumber" => 2.0, + ]); + self::assertSame("4", $insertId); + + $updated = $this->db->update("UserCrud/updateHalfNumber", [ + "name" => "four", + "halfNumber" => 2.5, + ]); + self::assertSame(1, $updated); + + $row = $this->db->fetch("UserCrud/getInsertedState", [ + "name" => "four", + ]); + self::assertSame(4, $row->getInt("number")); + self::assertSame(2.5, $row->getFloat("halfNumber")); + + $deleted = $this->db->delete("UserCrud/deleteByName", [ + "name" => "four", + ]); + self::assertSame(1, $deleted); + self::assertNull($this->db->fetch("UserCrud/getInsertedState", [ + "name" => "four", + ])); + } + + public function testClassCollectionSupportsCaseInsensitiveOverrideDirectory():void { + mkdir($this->queryBase, 0775, true); + file_put_contents( + $this->queryBase . "/UserQuery.php", + <<select("'class' as source") + ->from("test_table"); + } + } + PHP + ); + mkdir($this->queryBase . "/userquery", 0775, true); + file_put_contents( + $this->queryBase . "/userquery/getNameById.sql", + "select name as source from test_table where id = :id" + ); + + $result = $this->db->fetch("userquery/getNameById", [ + "id" => 2, + ]); + self::assertSame("two", $result->getString("source")); + } + + public function testClassCollectionThrowsOnConflictingOverride():void { + mkdir($this->queryBase, 0775, true); + file_put_contents( + $this->queryBase . "/UserConflict.php", + <<select(":id as id"); + } + } + PHP + ); + mkdir($this->queryBase . "/userconflict", 0775, true); + file_put_contents( + $this->queryBase . "/userconflict/getById.sql", + "select :id as id" + ); + + $this->expectException(QueryOverrideConflictException::class); + $this->db->fetch("UserConflict/getById", [ + "id" => 2, + ]); + } + + public function testClassCollectionComplexSelectBuilder():void { + $connection = $this->db->getDriver()->getConnection(); + $connection->exec("CREATE TABLE parity_lookup ( isEven bool primary key, label varchar(16) );"); + $connection->exec("INSERT INTO parity_lookup (isEven, label) VALUES (0, 'odd'), (1, 'even');"); + + mkdir($this->queryBase, 0775, true); + file_put_contents( + $this->queryBase . "/Report.php", + <<select( + "parity_lookup.label", + "count(*) as total", + "sum(test_table.number) as number_sum" + ) + ->from("test_table") + ->innerJoin("parity_lookup on parity_lookup.isEven = test_table.isEven") + ->where( + "test_table.number >= :minNumber", + "test_table.halfNumber >= :minHalf", + "test_table.name != :excludedName" + ) + ->groupBy("parity_lookup.label") + ->having("count(*) >= 1") + ->orderBy("total desc", "parity_lookup.label"); + } + } + PHP + ); + + $resultSet = $this->db->fetchAll("Report/groupedParity", [ + "minNumber" => 1, + "minHalf" => 0.5, + "excludedName" => "missing", + ]); + + self::assertCount(2, $resultSet); + $row0 = $resultSet->fetch(); + $row1 = $resultSet->fetch(); + self::assertSame("odd", $row0->getString("label")); + self::assertSame(2, $row0->getInt("total")); + self::assertSame(4, $row0->getInt("number_sum")); + self::assertSame("even", $row1->getString("label")); + self::assertSame(1, $row1->getInt("total")); + self::assertSame(2, $row1->getInt("number_sum")); + } + + private function settingsSingleton():Settings { + if(is_null($this->settings)) { + $this->settings = new Settings( + $this->queryBase, + Settings::DRIVER_SQLITE, + Settings::SCHEMA_IN_MEMORY, + "localhost" + ); + } + + return $this->settings; + } } diff --git a/test/phpunit/IntegrationTest.php b/test/phpunit/IntegrationTest.php index 304a50a..3d669ae 100644 --- a/test/phpunit/IntegrationTest.php +++ b/test/phpunit/IntegrationTest.php @@ -226,7 +226,7 @@ public function testMultipleArrayParameterUsage() { "number" => 1, ]); $result2 = $this->db->fetch("exampleCollection/getByNameNumber", - [ "name" => "two"] , + ["name" => "two"], ["number" => 2] ); $result3 = $this->db->fetch("exampleCollection/getByNameNumber", [ diff --git a/test/phpunit/Query/QueryCollectionFactoryTest.php b/test/phpunit/Query/QueryCollectionFactoryTest.php index 9498b32..1c05ce2 100644 --- a/test/phpunit/Query/QueryCollectionFactoryTest.php +++ b/test/phpunit/Query/QueryCollectionFactoryTest.php @@ -3,6 +3,8 @@ use Gt\Database\Connection\Settings; use Gt\Database\Connection\Driver; +use Gt\Database\Query\QueryCollectionClass; +use Gt\Database\Query\QueryCollectionDirectory; use Gt\Database\Query\QueryCollectionFactory; use Gt\Database\Query\QueryCollectionNotFoundException; use Gt\Database\Test\Helper\Helper; @@ -54,4 +56,50 @@ public function testDirectoryNotExists() { self::expectException(QueryCollectionNotFoundException::class); $queryCollectionFactory->create($queryCollectionName); } -} \ No newline at end of file + + public function testClassOverridesDirectoryCaseInsensitively():void { + $baseDir = Helper::getTmpDir(); + $queryBase = "$baseDir/query"; + mkdir("$queryBase/user", 0775, true); + touch("$queryBase/User.php"); + + $driver = new Driver(new Settings( + $queryBase, + Settings::DRIVER_SQLITE, + Settings::SCHEMA_IN_MEMORY, + )); + $queryCollectionFactory = new QueryCollectionFactory($driver); + + try { + $queryCollection = $queryCollectionFactory->create("user"); + self::assertInstanceOf(QueryCollectionClass::class, $queryCollection); + + $queryCollection = $queryCollectionFactory->create("User"); + self::assertInstanceOf(QueryCollectionClass::class, $queryCollection); + } + finally { + Helper::deleteDir($baseDir); + } + } + + public function testDirectoryUsedWhenNoClassExistsCaseInsensitively():void { + $baseDir = Helper::getTmpDir(); + $queryBase = "$baseDir/query"; + mkdir("$queryBase/user", 0775, true); + + $driver = new Driver(new Settings( + $queryBase, + Settings::DRIVER_SQLITE, + Settings::SCHEMA_IN_MEMORY, + )); + $queryCollectionFactory = new QueryCollectionFactory($driver); + + try { + $queryCollection = $queryCollectionFactory->create("User"); + self::assertInstanceOf(QueryCollectionDirectory::class, $queryCollection); + } + finally { + Helper::deleteDir($baseDir); + } + } +} diff --git a/test/phpunit/Query/QueryCollectionTest.php b/test/phpunit/Query/QueryCollectionTest.php index e3c3b3e..d78c5f7 100644 --- a/test/phpunit/Query/QueryCollectionTest.php +++ b/test/phpunit/Query/QueryCollectionTest.php @@ -11,7 +11,7 @@ use Gt\Database\Query\QueryCollectionDirectory; use Gt\Database\Query\QueryFactory; use Gt\Database\Result\ResultSet; -use PHPUnit\Framework\MockObject\MockObject; +use Gt\Database\Test\Helper\Helper; use PHPUnit\Framework\TestCase; class QueryCollectionTest extends TestCase { @@ -210,4 +210,95 @@ public function getTimestamp():string { $actualDateTime->format("Y-m-d H:i"), ); } + + public function testQueryCollectionClass_sqlBuilderMethod():void { + $projectDir = Helper::getTmpDir(); + $baseQueryDirectory = implode(DIRECTORY_SEPARATOR, [ + $projectDir, + "query", + ]); + $queryCollectionClassPath = "$baseQueryDirectory/BuilderUser.php"; + mkdir($baseQueryDirectory, 0775, true); + $php = <<select( + ":id as id", + ":name as name" + ); + } + } + PHP; + file_put_contents($queryCollectionClassPath, $php); + + try { + $sut = new QueryCollectionClass( + $queryCollectionClassPath, + new Driver(new DefaultSettings()), + ); + + $resultSet = $sut->query("getById", [ + "id" => 42, + "name" => "Greg", + ]); + + self::assertInstanceOf(ResultSet::class, $resultSet); + $row = $resultSet->fetch(); + self::assertSame(42, $row->getInt("id")); + self::assertSame("Greg", $row->getString("name")); + } + finally { + Helper::deleteDir($projectDir); + } + } + + public function testQueryCollectionClass_sqlOverrideConflictThrows():void { + $projectDir = Helper::getTmpDir(); + $baseQueryDirectory = implode(DIRECTORY_SEPARATOR, [ + $projectDir, + "query", + ]); + $queryCollectionClassPath = "$baseQueryDirectory/OverrideUser.php"; + $overrideDirectory = "$baseQueryDirectory/OverrideUser"; + mkdir($overrideDirectory, 0775, true); + $php = <<select( + "'class' as source" + ); + } + } + PHP; + file_put_contents($queryCollectionClassPath, $php); + file_put_contents( + "$overrideDirectory/getSource.sql", + "select 'override' as source" + ); + + try { + $sut = new QueryCollectionClass( + $queryCollectionClassPath, + new Driver(new DefaultSettings()), + ); + + self::expectException(\Gt\Database\Query\QueryOverrideConflictException::class); + $sut->query("getSource"); + } + finally { + Helper::deleteDir($projectDir); + } + } } diff --git a/test/phpunit/Query/QueryFactoryTest.php b/test/phpunit/Query/QueryFactoryTest.php index 9404c47..b708ba4 100644 --- a/test/phpunit/Query/QueryFactoryTest.php +++ b/test/phpunit/Query/QueryFactoryTest.php @@ -8,6 +8,8 @@ use Gt\Database\Query\QueryFactory; use Gt\Database\Query\QueryFileExtensionException; use Gt\Database\Query\QueryNotFoundException; +use Gt\Database\Query\QueryOverrideConflictException; +use Gt\Database\Query\SqlQuery; use Gt\Database\Test\Helper\Helper; use PHPUnit\Framework\TestCase; @@ -109,4 +111,148 @@ public function testCreatePhp( $query = $sut->create("getTimestamp"); self::assertInstanceOf(PhpQuery::class, $query); } + + public function testCreatePhpPrefersOverrideDirectoryQuery():void { + $basePath = Helper::getTmpDir(); + $classPath = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "User.php", + ]); + $overrideDirectory = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "User", + ]); + mkdir($overrideDirectory, 0775, true); + touch($classPath); + file_put_contents( + "$overrideDirectory/getById.sql", + "select :id as id" + ); + + try { + $sut = new QueryFactory($classPath, new Driver(new DefaultSettings())); + $query = $sut->create("getById"); + + self::assertInstanceOf(SqlQuery::class, $query); + self::assertSame( + realpath("$overrideDirectory/getById.sql"), + $query->getFilePath() + ); + } + finally { + Helper::deleteDir($basePath); + } + } + + public function testCreatePhpPrefersOverrideDirectoryCaseInsensitively():void { + $basePath = Helper::getTmpDir(); + $classPath = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "User.php", + ]); + $overrideDirectory = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "user", + ]); + mkdir($overrideDirectory, 0775, true); + touch($classPath); + file_put_contents( + "$overrideDirectory/getById.sql", + "select :id as id" + ); + + try { + $sut = new QueryFactory($classPath, new Driver(new DefaultSettings())); + $query = $sut->create("getById"); + + self::assertInstanceOf(SqlQuery::class, $query); + self::assertSame( + realpath("$overrideDirectory/getById.sql"), + $query->getFilePath() + ); + } + finally { + Helper::deleteDir($basePath); + } + } + + public function testCreatePhpPrefersOverrideDirectoryWhenClassIsLowerCase():void { + $basePath = Helper::getTmpDir(); + $classPath = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "user.php", + ]); + $overrideDirectory = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "User", + ]); + mkdir($overrideDirectory, 0775, true); + touch($classPath); + file_put_contents( + "$overrideDirectory/getById.sql", + "select :id as id" + ); + + try { + $sut = new QueryFactory($classPath, new Driver(new DefaultSettings())); + $query = $sut->create("getById"); + + self::assertInstanceOf(SqlQuery::class, $query); + self::assertSame( + realpath("$overrideDirectory/getById.sql"), + $query->getFilePath() + ); + } + finally { + Helper::deleteDir($basePath); + } + } + + public function testCreatePhpThrowsWhenOverrideConflictsWithPublicMethod():void { + $basePath = Helper::getTmpDir(); + $classPath = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "User.php", + ]); + $overrideDirectory = implode(DIRECTORY_SEPARATOR, [ + $basePath, + "query", + "user", + ]); + mkdir($overrideDirectory, 0775, true); + file_put_contents( + $classPath, + <<create("getById"); + } + finally { + Helper::deleteDir($basePath); + } + } }