diff --git a/composer.json b/composer.json index aa0e3ad5..ac45eaab 100644 --- a/composer.json +++ b/composer.json @@ -54,8 +54,8 @@ "symfony/polyfill-php83": "^1.28" }, "require-dev": { - "ergebnis/composer-normalize": "^2.42", "buggregator/trap": "^1.15", + "ergebnis/composer-normalize": "^2.42", "infection/infection": ">=0.26.10", "mockery/mockery": "^1.5", "phpunit/phpunit": "^9.5", diff --git a/composer.lock b/composer.lock index 50d30317..0d2cb6ff 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": "e335030ca122ed10073799c30cad8f2d", + "content-hash": "21215412da39ffe732c6a7083364f562", "packages": [ { "name": "psr/container", @@ -161,22 +161,22 @@ }, { "name": "spiral/core", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/core.git", - "reference": "fc9b81afd87a61ae9e523d86d1bff277a3e81b95" + "reference": "914c62757ed4aba8363ae63ed07aad3755ba8ca6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/core/zipball/fc9b81afd87a61ae9e523d86d1bff277a3e81b95", - "reference": "fc9b81afd87a61ae9e523d86d1bff277a3e81b95", + "url": "https://api.github.com/repos/spiral/core/zipball/914c62757ed4aba8363ae63ed07aad3755ba8ca6", + "reference": "914c62757ed4aba8363ae63ed07aad3755ba8ca6", "shasum": "" }, "require": { "php": ">=8.1", "psr/container": "^1.1|^2.0", - "spiral/security": "^3.16.1" + "spiral/security": "^3.16.2" }, "provide": { "psr/container-implementation": "^1.1|^2.0" @@ -231,27 +231,27 @@ "type": "github" } ], - "time": "2026-02-23T11:41:23+00:00" + "time": "2026-04-09T18:45:22+00:00" }, { "name": "spiral/hmvc", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/hmvc.git", - "reference": "63c6b9c5ac84c30926fdc4488a8c44f6f5d5664d" + "reference": "65d82e9c0f26d81b2cefda9f83270b44c3a52109" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/hmvc/zipball/63c6b9c5ac84c30926fdc4488a8c44f6f5d5664d", - "reference": "63c6b9c5ac84c30926fdc4488a8c44f6f5d5664d", + "url": "https://api.github.com/repos/spiral/hmvc/zipball/65d82e9c0f26d81b2cefda9f83270b44c3a52109", + "reference": "65d82e9c0f26d81b2cefda9f83270b44c3a52109", "shasum": "" }, "require": { "php": ">=8.1", "psr/event-dispatcher": "^1.0", - "spiral/core": "^3.16.1", - "spiral/interceptors": "^3.16.1" + "spiral/core": "^3.16.2", + "spiral/interceptors": "^3.16.2" }, "require-dev": { "phpunit/phpunit": "^10.5.41", @@ -305,26 +305,26 @@ "type": "github" } ], - "time": "2026-02-23T11:41:35+00:00" + "time": "2026-04-09T18:45:28+00:00" }, { "name": "spiral/interceptors", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/interceptors.git", - "reference": "306ab72c945acc03ef67398155de95ee3adbe617" + "reference": "b7cec5a71b6dc37d80981835f2fc5ef7178c9ac2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/interceptors/zipball/306ab72c945acc03ef67398155de95ee3adbe617", - "reference": "306ab72c945acc03ef67398155de95ee3adbe617", + "url": "https://api.github.com/repos/spiral/interceptors/zipball/b7cec5a71b6dc37d80981835f2fc5ef7178c9ac2", + "reference": "b7cec5a71b6dc37d80981835f2fc5ef7178c9ac2", "shasum": "" }, "require": { "php": ">=8.1", "psr/event-dispatcher": "^1.0", - "spiral/core": "^3.16.1" + "spiral/core": "^3.16.2" }, "require-dev": { "phpunit/phpunit": "^10.5.41", @@ -383,11 +383,11 @@ "type": "github" } ], - "time": "2026-02-23T11:45:15+00:00" + "time": "2026-04-09T18:49:45+00:00" }, { "name": "spiral/pagination", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/pagination.git", @@ -456,27 +456,27 @@ }, { "name": "spiral/security", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/security.git", - "reference": "15c7aa202fd91f7b93218350ebcf5107724a8656" + "reference": "c809d9ff14d38db3ab820456ec07e020c9cb3e3d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/security/zipball/15c7aa202fd91f7b93218350ebcf5107724a8656", - "reference": "15c7aa202fd91f7b93218350ebcf5107724a8656", + "url": "https://api.github.com/repos/spiral/security/zipball/c809d9ff14d38db3ab820456ec07e020c9cb3e3d", + "reference": "c809d9ff14d38db3ab820456ec07e020c9cb3e3d", "shasum": "" }, "require": { "php": ">=8.1", - "spiral/core": "^3.16.1", - "spiral/hmvc": "^3.16.1" + "spiral/core": "^3.16.2", + "spiral/hmvc": "^3.16.2" }, "require-dev": { "mockery/mockery": "^1.6.12", "phpunit/phpunit": "^10.5.41", - "spiral/console": "^3.16.1", + "spiral/console": "^3.16.2", "vimeo/psalm": "^6.0" }, "type": "library", @@ -524,20 +524,20 @@ "type": "github" } ], - "time": "2026-02-23T11:41:32+00:00" + "time": "2026-04-09T18:44:11+00:00" }, { "name": "symfony/polyfill-php83", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", - "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", "shasum": "" }, "require": { @@ -584,7 +584,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" }, "funding": [ { @@ -604,7 +604,7 @@ "type": "tidelift" } ], - "time": "2025-07-08T02:45:35+00:00" + "time": "2026-04-10T17:25:58+00:00" } ], "packages-dev": [ @@ -920,16 +920,16 @@ }, { "name": "amphp/parallel", - "version": "v2.3.3", + "version": "v2.3.4", "source": { "type": "git", "url": "https://github.com/amphp/parallel.git", - "reference": "296b521137a54d3a02425b464e5aee4c93db2c60" + "reference": "3ad45d1cff1bfbfe832c79671e6a4a1017dd9921" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/296b521137a54d3a02425b464e5aee4c93db2c60", - "reference": "296b521137a54d3a02425b464e5aee4c93db2c60", + "url": "https://api.github.com/repos/amphp/parallel/zipball/3ad45d1cff1bfbfe832c79671e6a4a1017dd9921", + "reference": "3ad45d1cff1bfbfe832c79671e6a4a1017dd9921", "shasum": "" }, "require": { @@ -949,7 +949,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -992,7 +992,7 @@ ], "support": { "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v2.3.3" + "source": "https://github.com/amphp/parallel/tree/v2.3.4" }, "funding": [ { @@ -1000,7 +1000,7 @@ "type": "github" } ], - "time": "2025-11-15T06:23:42+00:00" + "time": "2026-05-06T19:26:51+00:00" }, { "name": "amphp/parser", @@ -1066,16 +1066,16 @@ }, { "name": "amphp/pipeline", - "version": "v1.2.3", + "version": "v1.2.4", "source": { "type": "git", "url": "https://github.com/amphp/pipeline.git", - "reference": "7b52598c2e9105ebcddf247fc523161581930367" + "reference": "a044733e080940d1483f56caff0c412ad6982776" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", - "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776", + "reference": "a044733e080940d1483f56caff0c412ad6982776", "shasum": "" }, "require": { @@ -1087,7 +1087,7 @@ "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "phpunit/phpunit": "^9", - "psalm/phar": "^5.18" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -1121,7 +1121,7 @@ ], "support": { "issues": "https://github.com/amphp/pipeline/issues", - "source": "https://github.com/amphp/pipeline/tree/v1.2.3" + "source": "https://github.com/amphp/pipeline/tree/v1.2.4" }, "funding": [ { @@ -1129,7 +1129,7 @@ "type": "github" } ], - "time": "2025-03-16T16:33:53+00:00" + "time": "2026-05-06T05:37:57+00:00" }, { "name": "amphp/process", @@ -1201,24 +1201,27 @@ }, { "name": "amphp/serialization", - "version": "v1.0.0", + "version": "v1.1.0", "source": { "type": "git", "url": "https://github.com/amphp/serialization.git", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1" + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1", - "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1", + "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0", + "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "ext-json": "*", + "ext-zlib": "*", + "phpunit/phpunit": "^9", + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -1253,22 +1256,28 @@ ], "support": { "issues": "https://github.com/amphp/serialization/issues", - "source": "https://github.com/amphp/serialization/tree/master" + "source": "https://github.com/amphp/serialization/tree/v1.1.0" }, - "time": "2020-03-25T21:39:07+00:00" + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-04-05T15:59:53+00:00" }, { "name": "amphp/socket", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/amphp/socket.git", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", - "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314", + "reference": "dadb63c5d3179fd83803e29dfeac27350e619314", "shasum": "" }, "require": { @@ -1277,17 +1286,17 @@ "amphp/dns": "^2", "ext-openssl": "*", "kelunik/certificate": "^1.1", - "league/uri": "^6.5 | ^7", - "league/uri-interfaces": "^2.3 | ^7", + "league/uri": "^7", + "league/uri-interfaces": "^7", "php": ">=8.1", - "revolt/event-loop": "^1 || ^0.2" + "revolt/event-loop": "^1" }, "require-dev": { "amphp/php-cs-fixer-config": "^2", "amphp/phpunit-util": "^3", "amphp/process": "^2", "phpunit/phpunit": "^9", - "psalm/phar": "5.20" + "psalm/phar": "6.16.1" }, "type": "library", "autoload": { @@ -1331,7 +1340,7 @@ ], "support": { "issues": "https://github.com/amphp/socket/issues", - "source": "https://github.com/amphp/socket/tree/v2.3.1" + "source": "https://github.com/amphp/socket/tree/v2.4.0" }, "funding": [ { @@ -1339,7 +1348,7 @@ "type": "github" } ], - "time": "2024-04-21T14:33:03+00:00" + "time": "2026-04-19T15:09:56+00:00" }, { "name": "amphp/sync", @@ -1418,16 +1427,16 @@ }, { "name": "buggregator/trap", - "version": "1.15.1", + "version": "1.15.2", "source": { "type": "git", "url": "https://github.com/buggregator/trap.git", - "reference": "2530255bee5e1c5fa9b251018376f97ea7e54e64" + "reference": "f146f04ee7be14740d8b35311f1a136a929976ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/buggregator/trap/zipball/2530255bee5e1c5fa9b251018376f97ea7e54e64", - "reference": "2530255bee5e1c5fa9b251018376f97ea7e54e64", + "url": "https://api.github.com/repos/buggregator/trap/zipball/f146f04ee7be14740d8b35311f1a136a929976ed", + "reference": "f146f04ee7be14740d8b35311f1a136a929976ed", "shasum": "" }, "require": { @@ -1505,7 +1514,7 @@ ], "support": { "issues": "https://github.com/buggregator/trap/issues", - "source": "https://github.com/buggregator/trap/tree/1.15.1" + "source": "https://github.com/buggregator/trap/tree/1.15.2" }, "funding": [ { @@ -1513,7 +1522,7 @@ "type": "boosty" } ], - "time": "2025-12-27T20:39:37+00:00" + "time": "2026-04-07T21:19:09+00:00" }, { "name": "clue/stream-filter", @@ -2144,16 +2153,16 @@ }, { "name": "ergebnis/composer-normalize", - "version": "2.50.0", + "version": "2.51.0", "source": { "type": "git", "url": "https://github.com/ergebnis/composer-normalize.git", - "reference": "80971fe24ff10709789942bcbe9368b2c704097c" + "reference": "36fb17dce18579ccab50f71b411a32ed55e6d4bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/80971fe24ff10709789942bcbe9368b2c704097c", - "reference": "80971fe24ff10709789942bcbe9368b2c704097c", + "url": "https://api.github.com/repos/ergebnis/composer-normalize/zipball/36fb17dce18579ccab50f71b411a32ed55e6d4bc", + "reference": "36fb17dce18579ccab50f71b411a32ed55e6d4bc", "shasum": "" }, "require": { @@ -2169,25 +2178,25 @@ "require-dev": { "composer/composer": "^2.9.4", "ergebnis/license": "^2.7.0", - "ergebnis/php-cs-fixer-config": "^6.59.0", + "ergebnis/php-cs-fixer-config": "^6.61.1", "ergebnis/phpstan-rules": "^2.13.1", - "ergebnis/phpunit-slow-test-detector": "^2.20.0", - "ergebnis/rector-rules": "^1.9.0", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.18.1", "fakerphp/faker": "^1.24.1", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.38", - "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.12", - "phpstan/phpstan-strict-rules": "^2.0.8", + "phpstan/phpstan": "^2.1.47", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", "phpunit/phpunit": "^9.6.33", - "rector/rector": "^2.3.5", + "rector/rector": "^2.4.1", "symfony/filesystem": "^5.4.41" }, "type": "composer-plugin", "extra": { "class": "Ergebnis\\Composer\\Normalize\\NormalizePlugin", "branch-alias": { - "dev-main": "2.49-dev" + "dev-main": "2.51-dev" }, "plugin-optional": true, "composer-normalize": { @@ -2224,7 +2233,7 @@ "security": "https://github.com/ergebnis/composer-normalize/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/composer-normalize" }, - "time": "2026-02-09T20:57:47+00:00" + "time": "2026-04-14T11:17:04+00:00" }, { "name": "ergebnis/json", @@ -2383,36 +2392,38 @@ }, { "name": "ergebnis/json-pointer", - "version": "3.7.1", + "version": "3.8.0", "source": { "type": "git", "url": "https://github.com/ergebnis/json-pointer.git", - "reference": "43bef355184e9542635e35dd2705910a3df4c236" + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/43bef355184e9542635e35dd2705910a3df4c236", - "reference": "43bef355184e9542635e35dd2705910a3df4c236", + "url": "https://api.github.com/repos/ergebnis/json-pointer/zipball/b58c3c468a7ff109fdf9a255f17de29ecbe5276c", + "reference": "b58c3c468a7ff109fdf9a255f17de29ecbe5276c", "shasum": "" }, "require": { "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" }, "require-dev": { - "ergebnis/composer-normalize": "^2.43.0", - "ergebnis/data-provider": "^3.2.0", - "ergebnis/license": "^2.4.0", - "ergebnis/php-cs-fixer-config": "^6.32.0", - "ergebnis/phpunit-slow-test-detector": "^2.15.0", - "fakerphp/faker": "^1.23.1", + "ergebnis/composer-normalize": "^2.50.0", + "ergebnis/data-provider": "^3.6.0", + "ergebnis/license": "^2.7.0", + "ergebnis/php-cs-fixer-config": "^6.60.2", + "ergebnis/phpstan-rules": "^2.13.1", + "ergebnis/phpunit-slow-test-detector": "^2.24.0", + "ergebnis/rector-rules": "^1.16.0", + "fakerphp/faker": "^1.24.1", "infection/infection": "~0.26.6", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^1.12.10", - "phpstan/phpstan-deprecation-rules": "^1.2.1", - "phpstan/phpstan-phpunit": "^1.4.0", - "phpstan/phpstan-strict-rules": "^1.6.1", - "phpunit/phpunit": "^9.6.19", - "rector/rector": "^1.2.10" + "phpstan/phpstan": "^2.1.46", + "phpstan/phpstan-deprecation-rules": "^2.0.4", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^9.6.34", + "rector/rector": "^2.4.0" }, "type": "library", "extra": { @@ -2452,7 +2463,7 @@ "security": "https://github.com/ergebnis/json-pointer/blob/main/.github/SECURITY.md", "source": "https://github.com/ergebnis/json-pointer" }, - "time": "2025-09-06T09:28:19+00:00" + "time": "2026-04-07T14:52:13+00:00" }, { "name": "ergebnis/json-printer", @@ -3194,16 +3205,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "5.3.2", + "version": "5.3.4", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2f7abf648939847a789c55c206d4cb9dd0d53e2c" + "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2f7abf648939847a789c55c206d4cb9dd0d53e2c", - "reference": "2f7abf648939847a789c55c206d4cb9dd0d53e2c", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", + "reference": "7df70ffaf31d98726801b4bc099e1fbdbe2e5e54", "shasum": "" }, "require": { @@ -3253,9 +3264,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.2" + "source": "https://github.com/jsonrainbow/json-schema/tree/5.3.4" }, - "time": "2026-02-27T12:33:19+00:00" + "time": "2026-05-04T18:54:58+00:00" }, { "name": "kelunik/certificate", @@ -4080,16 +4091,16 @@ }, { "name": "php-cs-fixer/shim", - "version": "v3.94.2", + "version": "v3.95.1", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/shim.git", - "reference": "80fd29f44a736136a2f05bae5464816a444b91d1" + "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/80fd29f44a736136a2f05bae5464816a444b91d1", - "reference": "80fd29f44a736136a2f05bae5464816a444b91d1", + "url": "https://api.github.com/repos/PHP-CS-Fixer/shim/zipball/f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", + "reference": "f81ccf51ca60cc9dd21358ffba0e79ebd2ebb78a", "shasum": "" }, "require": { @@ -4126,9 +4137,9 @@ "description": "A tool to automatically fix PHP code style", "support": { "issues": "https://github.com/PHP-CS-Fixer/shim/issues", - "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.94.2" + "source": "https://github.com/PHP-CS-Fixer/shim/tree/v3.95.1" }, - "time": "2026-02-20T16:14:17+00:00" + "time": "2026-04-12T17:00:34+00:00" }, { "name": "php-http/message", @@ -6298,22 +6309,22 @@ }, { "name": "spiral/logger", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/logger.git", - "reference": "133f5069311114809984f3d53634c4e6246ae650" + "reference": "1b37ee534b68a7812a7acc6f8e1b04c017cf6916" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/logger/zipball/133f5069311114809984f3d53634c4e6246ae650", - "reference": "133f5069311114809984f3d53634c4e6246ae650", + "url": "https://api.github.com/repos/spiral/logger/zipball/1b37ee534b68a7812a7acc6f8e1b04c017cf6916", + "reference": "1b37ee534b68a7812a7acc6f8e1b04c017cf6916", "shasum": "" }, "require": { "php": ">=8.1", "psr/log": "1 - 3", - "spiral/core": "^3.16.1" + "spiral/core": "^3.16.2" }, "require-dev": { "mockery/mockery": "^1.6.12", @@ -6365,35 +6376,35 @@ "type": "github" } ], - "time": "2026-02-23T11:45:15+00:00" + "time": "2026-04-09T18:49:44+00:00" }, { "name": "spiral/tokenizer", - "version": "3.16.1", + "version": "3.16.2", "source": { "type": "git", "url": "https://github.com/spiral/tokenizer.git", - "reference": "59cda1f8fdac4a618b76d7e0ee01199bdbef72fb" + "reference": "006f5406fddad5155acf9de16b1f0495770cef99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spiral/tokenizer/zipball/59cda1f8fdac4a618b76d7e0ee01199bdbef72fb", - "reference": "59cda1f8fdac4a618b76d7e0ee01199bdbef72fb", + "url": "https://api.github.com/repos/spiral/tokenizer/zipball/006f5406fddad5155acf9de16b1f0495770cef99", + "reference": "006f5406fddad5155acf9de16b1f0495770cef99", "shasum": "" }, "require": { "ext-tokenizer": "*", "php": ">=8.1", - "spiral/core": "^3.16.1", - "spiral/logger": "^3.16.1", + "spiral/core": "^3.16.2", + "spiral/logger": "^3.16.2", "symfony/finder": "^6.4.30 || ^7.4 || ^8.0" }, "require-dev": { "mockery/mockery": "^1.6.12", "phpunit/phpunit": "^10.5.41", "spiral/attributes": "^2.8|^3.0", - "spiral/boot": "^3.16.1", - "spiral/files": "^3.16.1", + "spiral/boot": "^3.16.2", + "spiral/files": "^3.16.2", "vimeo/psalm": "^6.0" }, "type": "library", @@ -6441,20 +6452,20 @@ "type": "github" } ], - "time": "2026-02-23T11:45:59+00:00" + "time": "2026-04-09T18:45:14+00:00" }, { "name": "symfony/console", - "version": "v6.4.35", + "version": "v6.4.37", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "49257c96304c508223815ee965c251e7c79e614e" + "reference": "7bbcaf3fdb1e18fa42a7f0b84a10d091c10548f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/49257c96304c508223815ee965c251e7c79e614e", - "reference": "49257c96304c508223815ee965c251e7c79e614e", + "url": "https://api.github.com/repos/symfony/console/zipball/7bbcaf3fdb1e18fa42a7f0b84a10d091c10548f5", + "reference": "7bbcaf3fdb1e18fa42a7f0b84a10d091c10548f5", "shasum": "" }, "require": { @@ -6519,7 +6530,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.35" + "source": "https://github.com/symfony/console/tree/v6.4.37" }, "funding": [ { @@ -6539,20 +6550,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T13:31:08+00:00" + "time": "2026-04-13T15:27:04+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v3.6.0", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", - "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -6565,7 +6576,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -6590,7 +6601,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.6.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -6601,25 +6612,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": "2024-09-25T14:21:43+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/filesystem", - "version": "v6.4.34", + "version": "v6.4.37", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3" + "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/01ffe0411b842f93c571e5c391f289c3fdd498c3", - "reference": "01ffe0411b842f93c571e5c391f289c3fdd498c3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/29f792d7dc30cc670fc4cdd50d7c6653d067ce7b", + "reference": "29f792d7dc30cc670fc4cdd50d7c6653d067ce7b", "shasum": "" }, "require": { @@ -6656,7 +6671,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v6.4.34" + "source": "https://github.com/symfony/filesystem/tree/v6.4.37" }, "funding": [ { @@ -6676,7 +6691,7 @@ "type": "tidelift" } ], - "time": "2026-02-24T17:51:06+00:00" + "time": "2026-04-13T15:27:04+00:00" }, { "name": "symfony/finder", @@ -6748,16 +6763,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { @@ -6807,7 +6822,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" }, "funding": [ { @@ -6827,20 +6842,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", - "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { @@ -6889,7 +6904,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" }, "funding": [ { @@ -6909,11 +6924,11 @@ "type": "tidelift" } ], - "time": "2025-06-27T09:58:17+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -6974,7 +6989,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" }, "funding": [ { @@ -6998,16 +7013,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", - "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { @@ -7059,7 +7074,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" }, "funding": [ { @@ -7079,20 +7094,20 @@ "type": "tidelift" } ], - "time": "2024-12-23T08:48:59+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", - "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", "shasum": "" }, "require": { @@ -7139,7 +7154,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" }, "funding": [ { @@ -7159,7 +7174,7 @@ "type": "tidelift" } ], - "time": "2025-06-24T13:30:11+00:00" + "time": "2026-04-10T18:47:49+00:00" }, { "name": "symfony/process", @@ -7228,16 +7243,16 @@ }, { "name": "symfony/service-contracts", - "version": "v3.6.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", - "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", "shasum": "" }, "require": { @@ -7255,7 +7270,7 @@ "name": "symfony/contracts" }, "branch-alias": { - "dev-main": "3.6-dev" + "dev-main": "3.7-dev" } }, "autoload": { @@ -7291,7 +7306,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" }, "funding": [ { @@ -7311,7 +7326,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T11:30:57+00:00" + "time": "2026-03-28T09:44:51+00:00" }, { "name": "symfony/string", @@ -7404,16 +7419,16 @@ }, { "name": "symfony/var-dumper", - "version": "v6.4.32", + "version": "v6.4.36", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "131fc9915e0343052af5ed5040401b481ca192aa" + "reference": "7c8ad9ce4faf6c8a99948e70ce02b601a0439782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/131fc9915e0343052af5ed5040401b481ca192aa", - "reference": "131fc9915e0343052af5ed5040401b481ca192aa", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7c8ad9ce4faf6c8a99948e70ce02b601a0439782", + "reference": "7c8ad9ce4faf6c8a99948e70ce02b601a0439782", "shasum": "" }, "require": { @@ -7468,7 +7483,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v6.4.32" + "source": "https://github.com/symfony/var-dumper/tree/v6.4.36" }, "funding": [ { @@ -7488,7 +7503,7 @@ "type": "tidelift" } ], - "time": "2026-01-01T13:34:06+00:00" + "time": "2026-03-30T15:36:00+00:00" }, { "name": "thecodingmachine/safe", @@ -7686,16 +7701,16 @@ }, { "name": "vimeo/psalm", - "version": "6.16.0", + "version": "6.16.1", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "7cf3e8b988edd75e0766963b0b9e671b220f5785" + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/7cf3e8b988edd75e0766963b0b9e671b220f5785", - "reference": "7cf3e8b988edd75e0766963b0b9e671b220f5785", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", + "reference": "f1f5de594dc76faf8784e02d3dc4716c91c6f6ac", "shasum": "" }, "require": { @@ -7800,7 +7815,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2026-03-17T11:15:56+00:00" + "time": "2026-03-19T10:56:09+00:00" }, { "name": "webmozart/assert", diff --git a/src/Database.php b/src/Database.php index dcacb0da..4d1d36c3 100644 --- a/src/Database.php +++ b/src/Database.php @@ -13,8 +13,12 @@ use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\DriverInterface; +use Cycle\Database\Driver\CursorableInterface; +use Cycle\Database\Driver\CursorOptions; +use Cycle\Database\Exception\DriverException; use Cycle\Database\Query\DeleteQuery; use Cycle\Database\Query\InsertQuery; +use Cycle\Database\Query\QueryParameters; use Cycle\Database\Query\SelectQuery; use Cycle\Database\Query\UpdateQuery; @@ -131,6 +135,40 @@ public function query(string $query, array $parameters = []): StatementInterface ->query($query, $parameters); } + /** + * Open a server-side cursor for a compiled SELECT query and yield rows lazily. + * + * Not on {@see DatabaseInterface} for BC; exposed via `@method` annotation. + * The read driver must implement {@see CursorableInterface} (Postgres, SQLite, + * SQL Server); otherwise a {@see DriverException} is thrown. Cursor semantics — + * including snapshot consistency within the transaction — are preserved: + * this method intentionally has no fallback strategy. + * + * Driver-specific knobs are passed via {@see CursorOptions} subclasses + * (e.g. {@see \Cycle\Database\Driver\Postgres\PostgresCursorOptions}, + * {@see \Cycle\Database\Driver\SQLServer\SQLServerCursorOptions}). + * + * @return \Generator> + */ + public function cursor( + SelectQuery $query, + CursorOptions $options = new CursorOptions(), + int $mode = StatementInterface::FETCH_ASSOC, + ): \Generator { + $driver = $this->getDriver(self::READ); + if (!$driver instanceof CursorableInterface) { + throw new DriverException(\sprintf( + 'Server-side cursors are not supported for driver `%s`.', + $driver->getType(), + )); + } + + $parameters = new QueryParameters(); + $sql = $query->sqlStatement($parameters); + + return $driver->cursor($sql, $parameters->getParameters(), $options, $mode); + } + public function insert(?string $table = null): InsertQuery { return $this->getDriver(self::WRITE) diff --git a/src/DatabaseInterface.php b/src/DatabaseInterface.php index dc7f5c30..42ef1da3 100644 --- a/src/DatabaseInterface.php +++ b/src/DatabaseInterface.php @@ -25,6 +25,14 @@ * * @method DatabaseInterface withoutCache() Get a new Database instance without query cache or the same instance * if no cache is used. Will be added the next major release. + * @method \Generator cursor(SelectQuery $query, \Cycle\Database\Driver\CursorOptions $options = new \Cycle\Database\Driver\CursorOptions(), int $mode = StatementInterface::FETCH_ASSOC) + * Open a server-side cursor for the SELECT query and yield rows lazily, with snapshot + * consistency for the duration of the enclosing transaction. Requires the read driver to + * implement {@see \Cycle\Database\Driver\CursorableInterface} (Postgres, SQLite, SQL Server) + * and an active transaction. Driver-specific knobs (FETCH FORWARD batch, WITH HOLD, + * SQL Server cursor type, …) are passed via driver-specific {@see CursorOptions} subclasses. + * Throws {@see \Cycle\Database\Exception\DriverException} when the driver lacks cursor + * support. Will be added to the interface in the next major release. */ interface DatabaseInterface { diff --git a/src/Driver/CursorOptions.php b/src/Driver/CursorOptions.php new file mode 100644 index 00000000..8476eaa0 --- /dev/null +++ b/src/Driver/CursorOptions.php @@ -0,0 +1,20 @@ +> + * + * @throws DriverException + * @throws StatementException + */ + public function cursor( + string $statement, + iterable $parameters = [], + CursorOptions $options = new CursorOptions(), + int $mode = StatementInterface::FETCH_ASSOC, + ): \Generator; +} diff --git a/src/Driver/Postgres/PostgresCursorOptions.php b/src/Driver/Postgres/PostgresCursorOptions.php new file mode 100644 index 00000000..094360c1 --- /dev/null +++ b/src/Driver/Postgres/PostgresCursorOptions.php @@ -0,0 +1,50 @@ + $chunkSize Number of rows pulled by each `FETCH FORWARD N FROM cursor` + * round-trip. Higher values reduce network overhead at the cost of buffering + * a larger result chunk in client memory between yields. + * @param bool $withHold When true, declares the cursor `WITH HOLD`. The cursor materializes + * the rest of its result on the server at `COMMIT` time and remains open for further + * `FETCH` calls outside the transaction. Use for long-running exports that should + * not keep a write-blocking transaction alive. + * @param string|null $name Optional cursor name. When null, the driver generates a random + * unique name per call (collision-free, suitable for reusing the same options DTO + * across multiple invocations). When set, the value is used verbatim — caller is + * responsible for uniqueness. Must be a valid PostgreSQL identifier + * (letter/underscore start, alphanumerics/underscores, ≤63 chars). + */ + public function __construct( + public readonly int $chunkSize = 1000, + public readonly bool $withHold = false, + public readonly ?string $name = null, + ) { + if ($name !== null && (!\preg_match('/^[a-zA-Z_][\w]*$/', $name) || \strlen($name) > 63)) { + throw new \InvalidArgumentException(\sprintf( + 'Cursor name `%s` must be a valid PostgreSQL identifier: start with letter or underscore, ' + . 'contain only alphanumerics and underscores, and be ≤63 characters.', + $name, + )); + } + + parent::__construct(); + } + + public static function from(CursorOptions $options): self + { + return $options instanceof self ? $options : new self(); + } +} diff --git a/src/Driver/Postgres/PostgresDriver.php b/src/Driver/Postgres/PostgresDriver.php index 7adee3ad..f2cc15a1 100644 --- a/src/Driver/Postgres/PostgresDriver.php +++ b/src/Driver/Postgres/PostgresDriver.php @@ -19,14 +19,17 @@ use Cycle\Database\Driver\Postgres\Query\PostgresInsertQuery; use Cycle\Database\Driver\Postgres\Query\PostgresSelectQuery; use Cycle\Database\Driver\Postgres\Query\PostgresUpdateQuery; +use Cycle\Database\Driver\CursorableInterface; +use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\QueryBuilder; +use Cycle\Database\StatementInterface; /** * Talks to postgres databases. */ -class PostgresDriver extends Driver +class PostgresDriver extends Driver implements CursorableInterface { /** * Cached list of primary keys associated with their table names. Used by InsertBuilder to @@ -196,6 +199,75 @@ public function beginTransaction(?string $isolationLevel = null): bool return true; } + /** + * Open a Postgres server-side cursor for the given SELECT and yield rows + * lazily. Provides snapshot consistency within the enclosing transaction. + * + * Requires an active transaction (cursor lifetime is bound to it unless + * {@see PostgresCursorOptions::$withHold} is set, in which case the cursor + * survives `COMMIT` and the result is materialized on the server). The + * cursor is declared with NO SCROLL — only forward fetches are performed. + * The cursor is closed when the generator is fully consumed or + * garbage-collected. + * + * @return \Generator> + * + * @throws DriverException + */ + #[\Override] + public function cursor( + string $statement, + iterable $parameters = [], + CursorOptions $options = new PostgresCursorOptions(), + int $mode = StatementInterface::FETCH_ASSOC, + ): \Generator { + $opts = PostgresCursorOptions::from($options); + + if ($opts->chunkSize < 1) { + throw new DriverException('Chunk size must be a positive integer.'); + } + + if ($this->getTransactionLevel() === 0) { + throw new DriverException( + 'Postgres server-side cursor requires an active transaction. ' + . 'Wrap the cursor iteration in Database::transaction() or call beginTransaction() before cursor().', + ); + } + + $cursorName = '"' . ($opts->name ?? 'c_' . \bin2hex(\random_bytes(8))) . '"'; + $holdClause = $opts->withHold ? ' WITH HOLD' : ''; + $declareSql = "DECLARE {$cursorName} NO SCROLL CURSOR{$holdClause} FOR {$statement}"; + $fetchSql = "FETCH FORWARD {$opts->chunkSize} FROM {$cursorName}"; + $closeSql = "CLOSE {$cursorName}"; + + try { + $this->statement($declareSql, $parameters); + + do { + $chunkStatement = $this->statement($fetchSql); + $rows = $chunkStatement->fetchAll($mode); + $chunkStatement->close(); + + foreach ($rows as $row) { + yield $row; + } + } while (\count($rows) === $opts->chunkSize); + } finally { + try { + $this->statement($closeSql); + } catch (\Throwable) { + // Cursor may already be gone (e.g. transaction was rolled back) — swallow. + } + + // Avoid polluting the prepared-statement cache with single-use SQL strings. + unset( + $this->queryCache[$declareSql], + $this->queryCache[$fetchSql], + $this->queryCache[$closeSql], + ); + } + } + /** * Parse the table name and extract the schema and table. * diff --git a/src/Driver/SQLServer/CursorType.php b/src/Driver/SQLServer/CursorType.php new file mode 100644 index 00000000..c7ba4b47 --- /dev/null +++ b/src/Driver/SQLServer/CursorType.php @@ -0,0 +1,45 @@ + 128)) { + throw new \InvalidArgumentException(\sprintf( + 'Cursor name `%s` must be a valid T-SQL identifier: start with letter or underscore, ' + . 'contain only alphanumerics and underscores, and be ≤128 characters.', + $name, + )); + } + + parent::__construct(); + } + + public static function from(CursorOptions $options): self + { + return $options instanceof self ? $options : new self(); + } +} diff --git a/src/Driver/SQLServer/SQLServerDriver.php b/src/Driver/SQLServer/SQLServerDriver.php index b39cabe1..dd745183 100644 --- a/src/Driver/SQLServer/SQLServerDriver.php +++ b/src/Driver/SQLServer/SQLServerDriver.php @@ -13,6 +13,8 @@ use Cycle\Database\Config\DriverConfig; use Cycle\Database\Config\SQLServerDriverConfig; +use Cycle\Database\Driver\CursorableInterface; +use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\PDOStatementInterface; use Cycle\Database\Driver\SQLServer\Query\SQLServerDeleteQuery; @@ -23,8 +25,9 @@ use Cycle\Database\Exception\StatementException; use Cycle\Database\Injection\ParameterInterface; use Cycle\Database\Query\QueryBuilder; +use Cycle\Database\StatementInterface; -class SQLServerDriver extends Driver +class SQLServerDriver extends Driver implements CursorableInterface { /** * @var non-empty-string @@ -62,6 +65,91 @@ public function getType(): string return 'SQLServer'; } + /** + * Open a SQL Server server-side cursor for the given SELECT. + * + * Default cursor flavor is `STATIC` (snapshot in tempdb). Callers can request + * a different mode (KEYSET/DYNAMIC/FAST_FORWARD) by passing a + * {@see SQLServerCursorOptions} with a non-default {@see CursorType}; + * note that only STATIC fulfills the snapshot-consistency contract of + * {@see CursorableInterface} — the others are exposed for users who accept + * different visibility semantics. + * + * The cursor is always `GLOBAL FORWARD_ONLY READ_ONLY`: + * - `GLOBAL` — connection-scoped, survives across separate batches. + * Each prepared statement is its own batch in pdo_sqlsrv; a `LOCAL` + * cursor would die between DECLARE and OPEN. + * - `FORWARD_ONLY READ_ONLY` — only `FETCH NEXT`, no UPDATEs via cursor. + * + * `FETCH NEXT` returns one row per round-trip, so no chunkSize knob applies. + * + * @return \Generator> + * + * @throws DriverException + */ + #[\Override] + public function cursor( + string $statement, + iterable $parameters = [], + CursorOptions $options = new SQLServerCursorOptions(), + int $mode = StatementInterface::FETCH_ASSOC, + ): \Generator { + $opts = SQLServerCursorOptions::from($options); + + if ($this->getTransactionLevel() === 0) { + throw new DriverException( + 'SQLServer cursor requires an active transaction. ' + . 'Wrap the cursor iteration in Database::transaction() or call beginTransaction() before cursor().', + ); + } + + $cursorName = $opts->name ?? 'c_' . \bin2hex(\random_bytes(8)); + $declareSql = "DECLARE [{$cursorName}] CURSOR GLOBAL FORWARD_ONLY {$opts->type->value} READ_ONLY FOR {$statement}"; + $openSql = "OPEN [{$cursorName}]"; + $fetchSql = "FETCH NEXT FROM [{$cursorName}]"; + $closeSql = "CLOSE [{$cursorName}]"; + $deallocateSql = "DEALLOCATE [{$cursorName}]"; + + try { + // Parameters are bound to DECLARE; SQL Server captures their values + // and substitutes them when OPEN materializes the cursor. + $this->statement($declareSql, $parameters); + $this->statement($openSql); + + while (true) { + $fetchStatement = $this->statement($fetchSql); + $row = $fetchStatement->fetch($mode); + $fetchStatement->close(); + + if ($row === false) { + break; + } + + yield $row; + } + } finally { + try { + $this->statement($closeSql); + } catch (\Throwable) { + // Cursor may already be gone (e.g. transaction was rolled back) — swallow. + } + try { + $this->statement($deallocateSql); + } catch (\Throwable) { + // Same as above. + } + + // Avoid polluting the prepared-statement cache with single-use SQL strings. + unset( + $this->queryCache[$declareSql], + $this->queryCache[$openSql], + $this->queryCache[$fetchSql], + $this->queryCache[$closeSql], + $this->queryCache[$deallocateSql], + ); + } + } + /** * Bind parameters into statement. SQLServer need encoding to be specified for binary parameters. */ diff --git a/src/Driver/SQLite/SQLiteDriver.php b/src/Driver/SQLite/SQLiteDriver.php index 9e40c180..7f27f81f 100644 --- a/src/Driver/SQLite/SQLiteDriver.php +++ b/src/Driver/SQLite/SQLiteDriver.php @@ -13,15 +13,19 @@ use Cycle\Database\Config\DriverConfig; use Cycle\Database\Config\SQLiteDriverConfig; +use Cycle\Database\Driver\CursorableInterface; +use Cycle\Database\Driver\CursorOptions; use Cycle\Database\Driver\Driver; use Cycle\Database\Driver\SQLite\Query\SQLiteDeleteQuery; use Cycle\Database\Driver\SQLite\Query\SQLiteSelectQuery; use Cycle\Database\Driver\SQLite\Query\SQLiteUpdateQuery; +use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Query\InsertQuery; use Cycle\Database\Query\QueryBuilder; +use Cycle\Database\StatementInterface; -class SQLiteDriver extends Driver +class SQLiteDriver extends Driver implements CursorableInterface { /** * @param SQLiteDriverConfig $config @@ -46,6 +50,53 @@ public function getType(): string return 'SQLite'; } + /** + * Iterate rows from a SELECT lazily. + * + * SQLite has no SQL-level DECLARE CURSOR, but its core engine is already + * row-oriented: every `PDOStatement::fetch()` advances the prepared + * statement by exactly one row via `sqlite3_step()`, pulling it from disk + * without buffering the full result set. The {@see CursorableInterface} + * contract (snapshot consistency for the duration of the enclosing + * transaction) is therefore satisfied by the engine + an active + * transaction: + * + * - WAL mode: a reader snapshot is taken on the first read and kept + * until COMMIT / ROLLBACK; concurrent writers do not affect it. + * - Rollback journal mode: a SHARED lock is held that blocks writers + * until the transaction completes. + * + * SQLite has no driver-specific cursor options; the base {@see CursorOptions} + * (`mode` only) is sufficient. + * + * @return \Generator> + * + * @throws DriverException When no transaction is active. + */ + public function cursor( + string $statement, + iterable $parameters = [], + CursorOptions $options = new CursorOptions(), + int $mode = StatementInterface::FETCH_ASSOC, + ): \Generator { + if ($this->getTransactionLevel() === 0) { + throw new DriverException( + 'SQLite cursor requires an active transaction to guarantee snapshot consistency. ' + . 'Wrap the cursor iteration in Database::transaction() or call beginTransaction() before cursor().', + ); + } + + $sqliteStatement = $this->statement($statement, $parameters); + + try { + while (($row = $sqliteStatement->fetch($mode)) !== false) { + yield $row; + } + } finally { + $sqliteStatement->close(); + } + } + protected function mapException(\Throwable $exception, string $query): StatementException { if ((int) $exception->getCode() === 23000) { diff --git a/tests/Database/Functional/Driver/Common/Driver/CursorTest.php b/tests/Database/Functional/Driver/Common/Driver/CursorTest.php new file mode 100644 index 00000000..a2d3d556 --- /dev/null +++ b/tests/Database/Functional/Driver/Common/Driver/CursorTest.php @@ -0,0 +1,189 @@ +fillRows(3); + + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/active transaction/i'); + + $gen = $this->database->cursor($this->database->select()->from('sample_table')); + // Generator is lazy — must iterate to trigger the check. + \iterator_to_array($gen); + } + + public function testCursorYieldsAllRowsForSmallDataset(): void + { + $this->fillRows(5); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + ), + false, + ), + ); + + $this->assertCount(5, $rows); + $this->assertSame(\md5('0'), $rows[0]['name']); + $this->assertSame(40, (int) $rows[4]['value']); + } + + public function testCursorYieldsAllRowsForLargerDataset(): void + { + $this->fillRows(25); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + ), + false, + ), + ); + + $this->assertCount(25, $rows); + + for ($i = 0; $i < 25; $i++) { + $this->assertSame(\md5((string) $i), $rows[$i]['name']); + $this->assertSame($i * 10, (int) $rows[$i]['value']); + } + } + + public function testCursorEmptyResult(): void + { + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table'), + ), + false, + ), + ); + + $this->assertSame([], $rows); + } + + public function testCursorRespectsFetchNumMode(): void + { + $this->fillRows(2); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select('id', 'name', 'value')->from('sample_table')->orderBy('id'), + mode: StatementInterface::FETCH_NUM, + ), + false, + ), + ); + + $this->assertCount(2, $rows); + $this->assertArrayHasKey(0, $rows[0]); + $this->assertArrayNotHasKey('id', $rows[0]); + $this->assertSame(\md5('0'), $rows[0][1]); + } + + public function testCursorWithBoundParameters(): void + { + $this->fillRows(10); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select() + ->from('sample_table') + ->where('value', '>=', 50) + ->orderBy('id'), + ), + false, + ), + ); + + $this->assertCount(5, $rows); + $this->assertSame(50, (int) $rows[0]['value']); + $this->assertSame(90, (int) $rows[4]['value']); + } + + public function testCursorReleasesOnEarlyBreak(): void + { + $this->fillRows(50); + + $seen = $this->database->transaction(function () { + $count = 0; + foreach ( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + ) as $row + ) { + $count++; + if ($count === 3) { + break; + } + } + + // After break, the cursor/statement must be released (finally fires when + // the generator is GC'd). We should be able to run a fresh query on the + // same transaction immediately. + $follow = $this->database->select('COUNT(*)')->from('sample_table')->run()->fetch(StatementInterface::FETCH_NUM); + return ['count' => $count, 'total' => (int) $follow[0]]; + }); + + $this->assertSame(3, $seen['count']); + $this->assertSame(50, $seen['total']); + } + + public function testCursorFinishesCleanlyAndAllowsNextQuery(): void + { + $this->fillRows(7); + + $this->database->transaction(function (): void { + $rows = \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + ), + false, + ); + $this->assertCount(7, $rows); + + // Same transaction, fresh query — must work + $next = $this->database->select('COUNT(*)')->from('sample_table')->run()->fetch(StatementInterface::FETCH_NUM); + $this->assertSame(7, (int) $next[0]); + }); + } + + public function setUp(): void + { + parent::setUp(); + + $schema = $this->database->table('sample_table')->getSchema(); + $schema->primary('id'); + $schema->string('name', 64); + $schema->integer('value'); + $schema->save(); + } + + protected function fillRows(int $count): void + { + $table = $this->database->table('sample_table'); + for ($i = 0; $i < $count; $i++) { + $table->insertOne([ + 'name' => \md5((string) $i), + 'value' => $i * 10, + ]); + } + } +} diff --git a/tests/Database/Functional/Driver/Common/Driver/DriverTest.php b/tests/Database/Functional/Driver/Common/Driver/DriverTest.php index e09519b9..77d14cc1 100644 --- a/tests/Database/Functional/Driver/Common/Driver/DriverTest.php +++ b/tests/Database/Functional/Driver/Common/Driver/DriverTest.php @@ -6,6 +6,7 @@ use Cycle\Database\Config\DriverConfig; use Cycle\Database\Driver\Driver; +use Cycle\Database\Exception\DriverException; use Cycle\Database\Exception\StatementException; use Cycle\Database\Tests\Functional\Driver\Common\BaseTest; @@ -75,6 +76,21 @@ public function testClearCache(): void self::assertEmpty($driver->testGetCache()); } + public function testCursorThrowsByDefault(): void + { + if (\in_array(static::DRIVER, ['postgres', 'sqlite', 'sqlserver'], true)) { + $this->markTestSkipped(\sprintf('Driver `%s` implements CursorableInterface.', static::DRIVER)); + } + + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/cursors are not supported/i'); + + // Database::cursor() checks CursorableInterface and throws for drivers that don't implement it. + \iterator_to_array( + $this->database->cursor($this->database->select('1')), + ); + } + public function testWithoutCache(): void { $driver = $this->mockDriver(); diff --git a/tests/Database/Functional/Driver/Postgres/Driver/CursorTest.php b/tests/Database/Functional/Driver/Postgres/Driver/CursorTest.php new file mode 100644 index 00000000..147038ec --- /dev/null +++ b/tests/Database/Functional/Driver/Postgres/Driver/CursorTest.php @@ -0,0 +1,132 @@ +fillRows(25); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new PostgresCursorOptions(chunkSize: 10), + ), + false, + ), + ); + + $this->assertCount(25, $rows); + + // 25 rows / chunkSize 10 → three FETCH FORWARD calls: 10 + 10 + 5. + for ($i = 0; $i < 25; $i++) { + $this->assertSame(\md5((string) $i), $rows[$i]['name']); + $this->assertSame($i * 10, (int) $rows[$i]['value']); + } + } + + public function testCursorYieldsExactlyOneChunk(): void + { + $this->fillRows(10); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new PostgresCursorOptions(chunkSize: 10), + ), + false, + ), + ); + + $this->assertCount(10, $rows); + } + + public function testCursorChunkSizeMustBePositive(): void + { + $this->expectException(DriverException::class); + $this->expectExceptionMessageMatches('/chunk size/i'); + + $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table'), + new PostgresCursorOptions(chunkSize: 0), + ), + false, + ), + ); + } + + public function testCursorUsesExplicitName(): void + { + $this->fillRows(3); + $name = 'my_export_cursor'; + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new PostgresCursorOptions(name: $name), + ), + false, + ), + ); + + $this->assertCount(3, $rows); + } + + public function testCursorRejectsInvalidName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/valid postgresql identifier/i'); + + new PostgresCursorOptions(name: 'bad"name; DROP TABLE'); + } + + public function testCursorWithHoldSurvivesCommit(): void + { + $this->fillRows(5); + + // Open cursor inside a transaction with WITH HOLD, commit, then keep fetching. + $this->database->begin(); + $gen = $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new PostgresCursorOptions(withHold: true), + ); + + // Pull first row inside the transaction. + $gen->rewind(); + $first = $gen->current(); + $this->assertSame(\md5('0'), $first['name']); + + // Commit the transaction. WITH HOLD materializes the rest on the server. + $this->database->commit(); + + // Continue iterating after COMMIT — must still work. + $remaining = []; + $gen->next(); + while ($gen->valid()) { + $remaining[] = $gen->current(); + $gen->next(); + } + + $this->assertCount(4, $remaining); + $this->assertSame(\md5('4'), $remaining[3]['name']); + } +} diff --git a/tests/Database/Functional/Driver/SQLServer/Driver/CursorTest.php b/tests/Database/Functional/Driver/SQLServer/Driver/CursorTest.php new file mode 100644 index 00000000..14012227 --- /dev/null +++ b/tests/Database/Functional/Driver/SQLServer/Driver/CursorTest.php @@ -0,0 +1,74 @@ +fillRows(5); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new SQLServerCursorOptions(type: $type), + ), + false, + ), + ); + + $this->assertCount(5, $rows); + $this->assertSame(\md5('0'), $rows[0]['name']); + $this->assertSame(40, (int) $rows[4]['value']); + } + + public function testCursorUsesExplicitName(): void + { + $this->fillRows(3); + + $rows = $this->database->transaction( + fn() => \iterator_to_array( + $this->database->cursor( + $this->database->select()->from('sample_table')->orderBy('id'), + new SQLServerCursorOptions(name: 'my_export_cursor'), + ), + false, + ), + ); + + $this->assertCount(3, $rows); + } + + public function testCursorRejectsInvalidName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/valid t-sql identifier/i'); + + new SQLServerCursorOptions(name: 'bad name'); + } + + public function cursorTypes(): \Generator + { + yield 'STATIC' => [CursorType::Static]; + yield 'KEYSET' => [CursorType::Keyset]; + yield 'DYNAMIC' => [CursorType::Dynamic]; + yield 'FAST_FORWARD' => [CursorType::FastForward]; + } +} diff --git a/tests/Database/Functional/Driver/SQLite/Driver/CursorTest.php b/tests/Database/Functional/Driver/SQLite/Driver/CursorTest.php new file mode 100644 index 00000000..aba5edbe --- /dev/null +++ b/tests/Database/Functional/Driver/SQLite/Driver/CursorTest.php @@ -0,0 +1,17 @@ +