refactored repositories and DB queries (#5026)

* removed unused teams from export order
* added new paginator for query instead of querybuilder
* added field hydrate enums
* hide PARTIAL deprecation
* never log deprecations in production
* replaced InvoiceLoader with native Doctrine feature
* prevent excessive permission queries
* support loading customers of team
* improved findByIds
* internalized API
* fix null string deprecations
This commit is contained in:
Kevin Papst
2024-08-27 10:11:19 +02:00
committed by GitHub
parent 9e3d243b4b
commit 9d933f62c0
81 changed files with 1648 additions and 1436 deletions

251
composer.lock generated
View File

@@ -265,23 +265,23 @@
},
{
"name": "dasprid/enum",
"version": "1.0.5",
"version": "1.0.6",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016"
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/6faf451159fb8ba4126b925ed2d78acfce0dc016",
"reference": "6faf451159fb8ba4126b925ed2d78acfce0dc016",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"reference": "8dfd07c6d2cf31c8da90c53b83c026c7696dda90",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 | ^8 | ^9",
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
@@ -309,9 +309,9 @@
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.5"
"source": "https://github.com/DASPRiD/Enum/tree/1.0.6"
},
"time": "2023-08-25T16:18:39+00:00"
"time": "2024-08-09T14:30:48+00:00"
},
{
"name": "doctrine/cache",
@@ -585,16 +585,16 @@
},
{
"name": "doctrine/dbal",
"version": "3.8.6",
"version": "3.9.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1"
"reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/b7411825cf7efb7e51f9791dea19d86e43b399a1",
"reference": "b7411825cf7efb7e51f9791dea19d86e43b399a1",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/d8f68ea6cc00912e5313237130b8c8decf4d28c6",
"reference": "d8f68ea6cc00912e5313237130b8c8decf4d28c6",
"shasum": ""
},
"require": {
@@ -610,12 +610,12 @@
"doctrine/coding-standard": "12.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.1",
"phpstan/phpstan": "1.11.5",
"phpstan/phpstan": "1.11.7",
"phpstan/phpstan-strict-rules": "^1.6",
"phpunit/phpunit": "9.6.19",
"phpunit/phpunit": "9.6.20",
"psalm/plugin-phpunit": "0.18.4",
"slevomat/coding-standard": "8.13.1",
"squizlabs/php_codesniffer": "3.10.1",
"squizlabs/php_codesniffer": "3.10.2",
"symfony/cache": "^5.4|^6.0|^7.0",
"symfony/console": "^4.4|^5.4|^6.0|^7.0",
"vimeo/psalm": "4.30.0"
@@ -678,7 +678,7 @@
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/3.8.6"
"source": "https://github.com/doctrine/dbal/tree/3.9.0"
},
"funding": [
{
@@ -694,7 +694,7 @@
"type": "tidelift"
}
],
"time": "2024-06-19T10:38:17+00:00"
"time": "2024-08-15T07:34:42+00:00"
},
{
"name": "doctrine/deprecations",
@@ -1388,16 +1388,16 @@
},
{
"name": "doctrine/orm",
"version": "2.19.6",
"version": "2.19.7",
"source": {
"type": "git",
"url": "https://github.com/doctrine/orm.git",
"reference": "c1bb2ccf4b19c845f91ff7c4c01dc7cbba7f4073"
"reference": "168ac31084226f94d42e7461a40ff5607a56bd35"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/c1bb2ccf4b19c845f91ff7c4c01dc7cbba7f4073",
"reference": "c1bb2ccf4b19c845f91ff7c4c01dc7cbba7f4073",
"url": "https://api.github.com/repos/doctrine/orm/zipball/168ac31084226f94d42e7461a40ff5607a56bd35",
"reference": "168ac31084226f94d42e7461a40ff5607a56bd35",
"shasum": ""
},
"require": {
@@ -1483,9 +1483,9 @@
],
"support": {
"issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/2.19.6"
"source": "https://github.com/doctrine/orm/tree/2.19.7"
},
"time": "2024-06-26T17:24:40+00:00"
"time": "2024-08-23T06:54:57+00:00"
},
{
"name": "doctrine/persistence",
@@ -1586,16 +1586,16 @@
},
{
"name": "doctrine/sql-formatter",
"version": "1.4.0",
"version": "1.4.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/sql-formatter.git",
"reference": "d1ac84aef745c69ea034929eb6d65a6908b675cc"
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d1ac84aef745c69ea034929eb6d65a6908b675cc",
"reference": "d1ac84aef745c69ea034929eb6d65a6908b675cc",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/7f83911cc5eba870de7ebb11283972483f7e2891",
"reference": "7f83911cc5eba870de7ebb11283972483f7e2891",
"shasum": ""
},
"require": {
@@ -1635,9 +1635,9 @@
],
"support": {
"issues": "https://github.com/doctrine/sql-formatter/issues",
"source": "https://github.com/doctrine/sql-formatter/tree/1.4.0"
"source": "https://github.com/doctrine/sql-formatter/tree/1.4.1"
},
"time": "2024-05-08T08:12:09+00:00"
"time": "2024-08-05T20:32:22+00:00"
},
{
"name": "egulias/email-validator",
@@ -3115,16 +3115,16 @@
},
{
"name": "nelmio/api-doc-bundle",
"version": "v4.29.0",
"version": "v4.29.3",
"source": {
"type": "git",
"url": "https://github.com/nelmio/NelmioApiDocBundle.git",
"reference": "c9523906023e61351f03dbab8d077173f5ec4883"
"reference": "61a3f8bb95111fade6eace55c071c43da0cc75d9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/c9523906023e61351f03dbab8d077173f5ec4883",
"reference": "c9523906023e61351f03dbab8d077173f5ec4883",
"url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/61a3f8bb95111fade6eace55c071c43da0cc75d9",
"reference": "61a3f8bb95111fade6eace55c071c43da0cc75d9",
"shasum": ""
},
"require": {
@@ -3225,7 +3225,7 @@
],
"support": {
"issues": "https://github.com/nelmio/NelmioApiDocBundle/issues",
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.29.0"
"source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v4.29.3"
},
"funding": [
{
@@ -3233,7 +3233,7 @@
"type": "github"
}
],
"time": "2024-07-22T08:40:00+00:00"
"time": "2024-08-17T13:03:07+00:00"
},
{
"name": "nelmio/cors-bundle",
@@ -4432,16 +4432,16 @@
},
{
"name": "psr/log",
"version": "3.0.0",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/php-fig/log.git",
"reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001"
"reference": "79dff0b268932c640297f5208d6298f71855c03e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001",
"reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001",
"url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e",
"reference": "79dff0b268932c640297f5208d6298f71855c03e",
"shasum": ""
},
"require": {
@@ -4476,9 +4476,9 @@
"psr-3"
],
"support": {
"source": "https://github.com/php-fig/log/tree/3.0.0"
"source": "https://github.com/php-fig/log/tree/3.0.1"
},
"time": "2021-07-14T16:46:02+00:00"
"time": "2024-08-21T13:31:24+00:00"
},
{
"name": "psr/simple-cache",
@@ -9720,16 +9720,16 @@
},
{
"name": "twig/cssinliner-extra",
"version": "v3.10.0",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/cssinliner-extra.git",
"reference": "10e88e9a887b646c58e3d670383208f15295dd22"
"reference": "7312a0275812b86918febb4b7a67d0cb084c5d02"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/10e88e9a887b646c58e3d670383208f15295dd22",
"reference": "10e88e9a887b646c58e3d670383208f15295dd22",
"url": "https://api.github.com/repos/twigphp/cssinliner-extra/zipball/7312a0275812b86918febb4b7a67d0cb084c5d02",
"reference": "7312a0275812b86918febb4b7a67d0cb084c5d02",
"shasum": ""
},
"require": {
@@ -9773,7 +9773,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/cssinliner-extra/tree/v3.10.0"
"source": "https://github.com/twigphp/cssinliner-extra/tree/v3.11.0"
},
"funding": [
{
@@ -9785,20 +9785,20 @@
"type": "tidelift"
}
],
"time": "2024-05-11T07:35:57+00:00"
"time": "2024-06-21T06:22:31+00:00"
},
{
"name": "twig/extra-bundle",
"version": "v3.10.0",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "cdc6e23aeb7f4953c1039568c3439aab60c56454"
"reference": "bf8a304eac15838d7724fdf64c345bdefbb75f03"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/cdc6e23aeb7f4953c1039568c3439aab60c56454",
"reference": "cdc6e23aeb7f4953c1039568c3439aab60c56454",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/bf8a304eac15838d7724fdf64c345bdefbb75f03",
"reference": "bf8a304eac15838d7724fdf64c345bdefbb75f03",
"shasum": ""
},
"require": {
@@ -9847,7 +9847,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.10.0"
"source": "https://github.com/twigphp/twig-extra-bundle/tree/v3.11.0"
},
"funding": [
{
@@ -9859,20 +9859,20 @@
"type": "tidelift"
}
],
"time": "2024-05-11T07:35:57+00:00"
"time": "2024-06-21T06:25:01+00:00"
},
{
"name": "twig/inky-extra",
"version": "v3.10.0",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/inky-extra.git",
"reference": "adfcc3b2becc09e909d30b813cde17351ac82958"
"reference": "f0e6fce7eeab518b5b18b476bc9a5f19a3359354"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/inky-extra/zipball/adfcc3b2becc09e909d30b813cde17351ac82958",
"reference": "adfcc3b2becc09e909d30b813cde17351ac82958",
"url": "https://api.github.com/repos/twigphp/inky-extra/zipball/f0e6fce7eeab518b5b18b476bc9a5f19a3359354",
"reference": "f0e6fce7eeab518b5b18b476bc9a5f19a3359354",
"shasum": ""
},
"require": {
@@ -9917,7 +9917,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/inky-extra/tree/v3.10.0"
"source": "https://github.com/twigphp/inky-extra/tree/v3.11.0"
},
"funding": [
{
@@ -9929,20 +9929,20 @@
"type": "tidelift"
}
],
"time": "2024-05-11T07:35:57+00:00"
"time": "2024-06-21T06:22:31+00:00"
},
{
"name": "twig/intl-extra",
"version": "v3.10.0",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/intl-extra.git",
"reference": "693f6beb8ca91fc6323e01b3addf983812f65c93"
"reference": "e9cadd61342e71e45b2f4f0558122433fd7e4566"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/693f6beb8ca91fc6323e01b3addf983812f65c93",
"reference": "693f6beb8ca91fc6323e01b3addf983812f65c93",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/e9cadd61342e71e45b2f4f0558122433fd7e4566",
"reference": "e9cadd61342e71e45b2f4f0558122433fd7e4566",
"shasum": ""
},
"require": {
@@ -9981,7 +9981,7 @@
"twig"
],
"support": {
"source": "https://github.com/twigphp/intl-extra/tree/v3.10.0"
"source": "https://github.com/twigphp/intl-extra/tree/v3.11.0"
},
"funding": [
{
@@ -9993,20 +9993,20 @@
"type": "tidelift"
}
],
"time": "2024-05-11T07:35:57+00:00"
"time": "2024-06-21T06:25:01+00:00"
},
{
"name": "twig/string-extra",
"version": "v3.10.0",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/string-extra.git",
"reference": "cd76ed8ae081bcd4fddf549e92e20c5df76c358a"
"reference": "d25c61baf38705a72ebb5a92d2e9ecb7c473b8ac"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/string-extra/zipball/cd76ed8ae081bcd4fddf549e92e20c5df76c358a",
"reference": "cd76ed8ae081bcd4fddf549e92e20c5df76c358a",
"url": "https://api.github.com/repos/twigphp/string-extra/zipball/d25c61baf38705a72ebb5a92d2e9ecb7c473b8ac",
"reference": "d25c61baf38705a72ebb5a92d2e9ecb7c473b8ac",
"shasum": ""
},
"require": {
@@ -10048,7 +10048,7 @@
"unicode"
],
"support": {
"source": "https://github.com/twigphp/string-extra/tree/v3.10.0"
"source": "https://github.com/twigphp/string-extra/tree/v3.11.0"
},
"funding": [
{
@@ -10060,20 +10060,20 @@
"type": "tidelift"
}
],
"time": "2024-05-11T07:35:57+00:00"
"time": "2024-08-07T17:34:09+00:00"
},
{
"name": "twig/twig",
"version": "v3.10.3",
"version": "v3.11.0",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig.git",
"reference": "67f29781ffafa520b0bbfbd8384674b42db04572"
"reference": "e80fb8ebba85c7341a97a9ebf825d7fd4b77708d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/67f29781ffafa520b0bbfbd8384674b42db04572",
"reference": "67f29781ffafa520b0bbfbd8384674b42db04572",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/e80fb8ebba85c7341a97a9ebf825d7fd4b77708d",
"reference": "e80fb8ebba85c7341a97a9ebf825d7fd4b77708d",
"shasum": ""
},
"require": {
@@ -10081,7 +10081,8 @@
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8",
"symfony/polyfill-mbstring": "^1.3",
"symfony/polyfill-php80": "^1.22"
"symfony/polyfill-php80": "^1.22",
"symfony/polyfill-php81": "^1.29"
},
"require-dev": {
"psr/container": "^1.0|^2.0",
@@ -10127,7 +10128,7 @@
],
"support": {
"issues": "https://github.com/twigphp/Twig/issues",
"source": "https://github.com/twigphp/Twig/tree/v3.10.3"
"source": "https://github.com/twigphp/Twig/tree/v3.11.0"
},
"funding": [
{
@@ -10139,7 +10140,7 @@
"type": "tidelift"
}
],
"time": "2024-05-16T10:04:27+00:00"
"time": "2024-08-08T16:15:16+00:00"
},
{
"name": "webmozart/assert",
@@ -10447,26 +10448,26 @@
},
{
"name": "composer/pcre",
"version": "3.2.0",
"version": "3.3.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90"
"reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/ea4ab6f9580a4fd221e0418f2c357cdd39102a90",
"reference": "ea4ab6f9580a4fd221e0418f2c357cdd39102a90",
"url": "https://api.github.com/repos/composer/pcre/zipball/1637e067347a0c40bbb1e3cd786b20dcab556a81",
"reference": "1637e067347a0c40bbb1e3cd786b20dcab556a81",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.8"
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.11.8",
"phpstan/phpstan": "^1.11.10",
"phpstan/phpstan-strict-rules": "^1.1",
"phpunit/phpunit": "^8 || ^9"
},
@@ -10506,7 +10507,7 @@
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.2.0"
"source": "https://github.com/composer/pcre/tree/3.3.0"
},
"funding": [
{
@@ -10522,7 +10523,7 @@
"type": "tidelift"
}
],
"time": "2024-07-25T09:36:02+00:00"
"time": "2024-08-19T19:43:53+00:00"
},
{
"name": "composer/xdebug-handler",
@@ -11001,16 +11002,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.61.1",
"version": "v3.62.0",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "94a87189f55814e6cabca2d9a33b06de384a2ab8"
"reference": "627692f794d35c43483f34b01d94740df2a73507"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/94a87189f55814e6cabca2d9a33b06de384a2ab8",
"reference": "94a87189f55814e6cabca2d9a33b06de384a2ab8",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/627692f794d35c43483f34b01d94740df2a73507",
"reference": "627692f794d35c43483f34b01d94740df2a73507",
"shasum": ""
},
"require": {
@@ -11092,7 +11093,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.61.1"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.62.0"
},
"funding": [
{
@@ -11100,7 +11101,7 @@
"type": "github"
}
],
"time": "2024-07-31T14:33:15+00:00"
"time": "2024-08-07T17:03:09+00:00"
},
{
"name": "masterminds/html5",
@@ -11347,16 +11348,16 @@
},
{
"name": "phpstan/phpstan",
"version": "1.11.9",
"version": "1.11.11",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan.git",
"reference": "e370bcddadaede0c1716338b262346f40d296f82"
"reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/e370bcddadaede0c1716338b262346f40d296f82",
"reference": "e370bcddadaede0c1716338b262346f40d296f82",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3",
"reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3",
"shasum": ""
},
"require": {
@@ -11401,7 +11402,7 @@
"type": "github"
}
],
"time": "2024-08-01T16:25:18+00:00"
"time": "2024-08-19T14:37:29+00:00"
},
{
"name": "phpstan/phpstan-deprecation-rules",
@@ -11452,16 +11453,16 @@
},
{
"name": "phpstan/phpstan-doctrine",
"version": "1.4.8",
"version": "1.5.2",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-doctrine.git",
"reference": "fa497c5cf8a3f9cd3db8cb4033daf5244793d3e1"
"reference": "4d17bed8a33aa8220c1f2a21a6b14fcdb0e5b02c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/fa497c5cf8a3f9cd3db8cb4033daf5244793d3e1",
"reference": "fa497c5cf8a3f9cd3db8cb4033daf5244793d3e1",
"url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/4d17bed8a33aa8220c1f2a21a6b14fcdb0e5b02c",
"reference": "4d17bed8a33aa8220c1f2a21a6b14fcdb0e5b02c",
"shasum": ""
},
"require": {
@@ -11518,9 +11519,9 @@
"description": "Doctrine extensions for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-doctrine/issues",
"source": "https://github.com/phpstan/phpstan-doctrine/tree/1.4.8"
"source": "https://github.com/phpstan/phpstan-doctrine/tree/1.5.2"
},
"time": "2024-07-16T11:31:01+00:00"
"time": "2024-08-23T11:07:15+00:00"
},
{
"name": "phpstan/phpstan-phpunit",
@@ -11625,16 +11626,16 @@
},
{
"name": "phpstan/phpstan-symfony",
"version": "1.4.6",
"version": "1.4.8",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "e909a075d69e0d4db262ac3407350ae2c6b6ab5f"
"reference": "14eec8c011b856eee4d744a2a3f709db1e1858bd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/e909a075d69e0d4db262ac3407350ae2c6b6ab5f",
"reference": "e909a075d69e0d4db262ac3407350ae2c6b6ab5f",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/14eec8c011b856eee4d744a2a3f709db1e1858bd",
"reference": "14eec8c011b856eee4d744a2a3f709db1e1858bd",
"shasum": ""
},
"require": {
@@ -11691,41 +11692,41 @@
"description": "Symfony Framework extensions and rules for PHPStan",
"support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.6"
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.4.8"
},
"time": "2024-07-16T11:48:54+00:00"
"time": "2024-08-13T19:43:40+00:00"
},
{
"name": "phpunit/php-code-coverage",
"version": "9.2.31",
"version": "9.2.32",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "48c34b5d8d983006bd2adc2d0de92963b9155965"
"reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/48c34b5d8d983006bd2adc2d0de92963b9155965",
"reference": "48c34b5d8d983006bd2adc2d0de92963b9155965",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
"reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-libxml": "*",
"ext-xmlwriter": "*",
"nikic/php-parser": "^4.18 || ^5.0",
"nikic/php-parser": "^4.19.1 || ^5.1.0",
"php": ">=7.3",
"phpunit/php-file-iterator": "^3.0.3",
"phpunit/php-text-template": "^2.0.2",
"sebastian/code-unit-reverse-lookup": "^2.0.2",
"sebastian/complexity": "^2.0",
"sebastian/environment": "^5.1.2",
"sebastian/lines-of-code": "^1.0.3",
"sebastian/version": "^3.0.1",
"theseer/tokenizer": "^1.2.0"
"phpunit/php-file-iterator": "^3.0.6",
"phpunit/php-text-template": "^2.0.4",
"sebastian/code-unit-reverse-lookup": "^2.0.3",
"sebastian/complexity": "^2.0.3",
"sebastian/environment": "^5.1.5",
"sebastian/lines-of-code": "^1.0.4",
"sebastian/version": "^3.0.2",
"theseer/tokenizer": "^1.2.3"
},
"require-dev": {
"phpunit/phpunit": "^9.3"
"phpunit/phpunit": "^9.6"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -11734,7 +11735,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "9.2-dev"
"dev-main": "9.2.x-dev"
}
},
"autoload": {
@@ -11763,7 +11764,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.31"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
},
"funding": [
{
@@ -11771,7 +11772,7 @@
"type": "github"
}
],
"time": "2024-03-02T06:37:42+00:00"
"time": "2024-08-22T04:23:01+00:00"
},
{
"name": "phpunit/php-file-iterator",

View File

@@ -19,12 +19,6 @@ when@prod:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!deprecation"]
# TODO reactivate once the many Doctrine deprecations are fixed
# deprecation:
# type: stream
# channels: ["deprecation"]
# path: "%kernel.logs_dir%/deprecations.log"
# formatter: monolog.formatter.deprecation
when@dev:
monolog:

View File

@@ -283,11 +283,6 @@ parameters:
count: 2
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$customerIDs of method App\\\\Repository\\\\CustomerRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$datetime of method App\\\\Timesheet\\\\DateTimeFactory\\:\\:createDateTime\\(\\) expects string, mixed given\\.$#"
count: 2
@@ -308,11 +303,6 @@ parameters:
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$projectIds of method App\\\\Repository\\\\ProjectRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$string of function rtrim expects string, mixed given\\.$#"
count: 1
@@ -323,26 +313,11 @@ parameters:
count: 2
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$teamIds of method App\\\\Repository\\\\TeamRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$timezone of class DateTimeZone constructor expects string, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$userIds of method App\\\\Repository\\\\UserRepository\\:\\:findByIds\\(\\) expects array\\<int\\>, mixed given\\.$#"
count: 1
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
count: 4
path: src/Command/ExportCreateCommand.php
-
message: "#^Parameter \\#2 \\$content of method Symfony\\\\Component\\\\Filesystem\\\\Filesystem\\:\\:dumpFile\\(\\) expects resource\\|string, string\\|false given\\.$#"
count: 1
@@ -783,11 +758,6 @@ parameters:
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Activity\\> given\\.$#"
count: 1
path: src/Controller/ActivityController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) expects App\\\\Entity\\\\Activity\\|null, mixed given\\.$#"
count: 1
@@ -833,11 +803,6 @@ parameters:
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Customer\\> given\\.$#"
count: 1
path: src/Controller/CustomerController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\CustomerRepository\\:\\:deleteCustomer\\(\\) expects App\\\\Entity\\\\Customer\\|null, mixed given\\.$#"
count: 1
@@ -973,11 +938,6 @@ parameters:
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#2 \\$entries of method App\\\\Export\\\\Spreadsheet\\\\EntityWithMetaFieldsExporter\\:\\:export\\(\\) expects array, iterable\\<App\\\\Entity\\\\Project\\> given\\.$#"
count: 1
path: src/Controller/ProjectController.php
-
message: "#^Parameter \\#2 \\$replace of method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) expects App\\\\Entity\\\\Project\\|null, mixed given\\.$#"
count: 1
@@ -1133,11 +1093,6 @@ parameters:
count: 1
path: src/Controller/TimesheetAbstractController.php
-
message: "#^Parameter \\#2 \\$page of method App\\\\Controller\\\\TimesheetAbstractController\\:\\:getEditForm\\(\\) expects int, mixed given\\.$#"
count: 1
path: src/Controller/TimesheetAbstractController.php
-
message: "#^PHPDoc tag @var for variable \\$teams contains generic class Doctrine\\\\Common\\\\Collections\\\\ArrayCollection but does not specify its types\\: TKey, T$#"
count: 1
@@ -1548,11 +1503,6 @@ parameters:
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getTeams\\(\\) should return iterable\\<App\\\\Entity\\\\Team\\> but returns array\\<int, App\\\\Entity\\\\Team\\|null\\>\\.$#"
count: 1
path: src/Entity/User.php
-
message: "#^Method App\\\\Entity\\\\User\\:\\:getTimezone\\(\\) should return string but returns bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -2223,16 +2173,6 @@ parameters:
count: 2
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ActivityHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -2248,16 +2188,6 @@ parameters:
count: 2
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/CustomerHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -2273,16 +2203,6 @@ parameters:
count: 2
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#1 \\$string of function substr expects string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#2 \\$replace of function str_replace expects array\\|string, string\\|null given\\.$#"
count: 1
path: src/Form/Helper/ProjectHelper.php
-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, bool\\|float\\|int\\|string given\\.$#"
count: 1
@@ -3338,11 +3258,6 @@ parameters:
count: 1
path: src/Form/Type/SystemConfigurationType.php
-
message: "#^Parameter \\#1 \\$teams of method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:setTeams\\(\\) expects array\\<App\\\\Entity\\\\Team\\>\\|null, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Form/Type/TeamType.php
-
message: "#^Parameter \\#1 \\$format of static method DateTime\\:\\:createFromFormat\\(\\) expects string, mixed given\\.$#"
count: 1
@@ -3823,16 +3738,6 @@ parameters:
count: 1
path: src/Model/ActivityStatistic.php
-
message: "#^Method App\\\\Model\\\\BudgetStatisticModel\\:\\:setStatistic\\(\\) has no return type specified\\.$#"
count: 1
path: src/Model/BudgetStatisticModel.php
-
message: "#^Method App\\\\Model\\\\BudgetStatisticModel\\:\\:setStatisticTotal\\(\\) has no return type specified\\.$#"
count: 1
path: src/Model/BudgetStatisticModel.php
-
message: "#^Parameter \\#3 \\$length of function substr expects int\\|null, int\\<0, max\\>\\|false given\\.$#"
count: 1
@@ -4078,26 +3983,11 @@ parameters:
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:deleteActivity\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:getPermissionCriteria\\(\\) has parameter \\$teams with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\ActivityRepository\\:\\:saveActivity\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ActivityRepository.php
-
message: "#^Method App\\\\Repository\\\\BookmarkRepository\\:\\:deleteBookmark\\(\\) has no return type specified\\.$#"
count: 1
@@ -4153,11 +4043,6 @@ parameters:
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/CustomerRepository.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
@@ -4193,111 +4078,21 @@ parameters:
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/InvoiceRepository.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/ActivityLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Activity\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\ActivityLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/ActivityLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Customer\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\CustomerLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/CustomerLoader.php
-
message: "#^Method App\\\\Repository\\\\Loader\\\\DefaultLoader\\:\\:loadResults\\(\\) has parameter \\$results with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/Loader/DefaultLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Invoice\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\InvoiceLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/InvoiceLoader.php
-
message: "#^Method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\) has parameter \\$results with no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/Loader/LoaderInterface.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/ProjectLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Project\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\ProjectLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/ProjectLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Team\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\TeamLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/TeamLoader.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\Timesheet\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/TimesheetLoader.php
-
message: "#^Parameter \\#1 \\$results \\(array\\<App\\\\Entity\\\\User\\|int\\>\\) of method App\\\\Repository\\\\Loader\\\\UserLoader\\:\\:loadResults\\(\\) should be contravariant with parameter \\$results \\(array\\) of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\)$#"
count: 1
path: src/Repository/Loader/UserLoader.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\LoaderPaginator\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\LoaderPaginator\\:\\:getNbResults\\(\\) should return int\\<0, max\\> but returns int\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\LoaderInterface\\:\\:loadResults\\(\\) expects array, mixed given\\.$#"
count: 1
path: src/Repository/Paginator/LoaderPaginator.php
-
message: "#^Interface App\\\\Repository\\\\Paginator\\\\PaginatorInterface extends generic interface Pagerfanta\\\\Adapter\\\\AdapterInterface but does not specify its types\\: T$#"
count: 1
path: src/Repository/Paginator/PaginatorInterface.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\PaginatorInterface\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/PaginatorInterface.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\QueryBuilderPaginator\\:\\:getAll\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Paginator/QueryBuilderPaginator.php
-
message: "#^Method App\\\\Repository\\\\Paginator\\\\QueryBuilderPaginator\\:\\:getNbResults\\(\\) should return int\\<0, max\\> but returns int\\.$#"
count: 1
path: src/Repository/Paginator/QueryBuilderPaginator.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
@@ -4318,16 +4113,6 @@ parameters:
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:deleteComment\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:deleteProject\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getComments\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4338,31 +4123,6 @@ parameters:
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:getProjectsForQuery\\(\\) should return iterable\\<App\\\\Entity\\\\Project\\> but returns mixed\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:saveComment\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\ProjectRepository\\:\\:saveProject\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\ProjectLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Project\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/ProjectRepository.php
-
message: "#^Method App\\\\Repository\\\\Query\\\\BaseQuery\\:\\:getOrderGroups\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4408,46 +4168,11 @@ parameters:
count: 1
path: src/Repository/Query/TimesheetQuery.php
-
message: "#^Cannot access offset 'counter' on mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Cannot access offset 'duration' on mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:getResults\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:toIterable\\(\\) return type has no value type specified in iterable type iterable\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#1 \\$count of class App\\\\Repository\\\\Result\\\\TimesheetResultStatistic constructor expects int, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Timesheet\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Parameter \\#2 \\$duration of class App\\\\Repository\\\\Result\\\\TimesheetResultStatistic constructor expects int, mixed given\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Property App\\\\Repository\\\\Result\\\\TimesheetResult\\:\\:\\$resultCache \\(array\\<App\\\\Entity\\\\Timesheet\\>\\|null\\) does not accept mixed\\.$#"
count: 1
path: src/Repository/Result/TimesheetResult.php
-
message: "#^Method App\\\\Repository\\\\RoleRepository\\:\\:deleteRole\\(\\) has no return type specified\\.$#"
count: 1
@@ -4468,36 +4193,6 @@ parameters:
count: 1
path: src/Repository/TagRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:deleteTeam\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:removeTeamMember\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Method App\\\\Repository\\\\TeamRepository\\:\\:saveTeam\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Return type \\(array\\<App\\\\Entity\\\\Team\\>\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(array\\<int, App\\\\Entity\\\\Team\\>\\) of method Doctrine\\\\Persistence\\\\ObjectRepository\\<App\\\\Entity\\\\Team\\>\\:\\:findAll\\(\\)$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Return type \\(array\\<App\\\\Entity\\\\Team\\>\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(list\\<App\\\\Entity\\\\Team\\>\\) of method Doctrine\\\\ORM\\\\EntityRepository\\<App\\\\Entity\\\\Team\\>\\:\\:findAll\\(\\)$#"
count: 1
path: src/Repository/TeamRepository.php
-
message: "#^Argument of an invalid type array\\<string\\>\\|string supplied for foreach, only iterables are supported\\.$#"
count: 1
@@ -4563,21 +4258,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:findTimesheetsById\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns iterable\\<App\\\\Entity\\\\Timesheet\\>\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getActiveEntries\\(\\) should return array\\<App\\\\Entity\\\\Timesheet\\> but returns iterable\\<App\\\\Entity\\\\Timesheet\\>\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getHydratedResultsByQuery\\(\\) should return iterable\\<App\\\\Entity\\\\Timesheet\\> but returns mixed\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getRawData\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
@@ -4588,11 +4268,6 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:setExported\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$amountThisMonth of method App\\\\Model\\\\TimesheetStatistic\\:\\:setAmountThisMonth\\(\\) expects float\\|int, mixed given\\.$#"
count: 1
@@ -4623,21 +4298,11 @@ parameters:
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TimesheetLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Timesheet\\|int\\>, mixed given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\$amount of class App\\\\Model\\\\Revenue constructor expects float, mixed given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Repository/TimesheetRepository.php
-
message: "#^Cannot call method getSearchFields\\(\\) on App\\\\Utils\\\\SearchTerm\\|null\\.$#"
count: 1
@@ -4653,16 +4318,6 @@ parameters:
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:addPermissionCriteria\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:deleteUser\\(\\) has no return type specified\\.$#"
count: 1
path: src/Repository/UserRepository.php
-
message: "#^Method App\\\\Repository\\\\UserRepository\\:\\:findByUsername\\(\\) has parameter \\$username with no type specified\\.$#"
count: 1
@@ -5488,16 +5143,6 @@ parameters:
count: 1
path: src/Widget/Type/PaginatedWorkingTimeChart.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\TeamLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\Team\\|int\\>, iterable\\<App\\\\Entity\\\\Team\\> given\\.$#"
count: 1
path: src/Widget/Type/UserTeamProjects.php
-
message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\UserLoader\\:\\:loadResults\\(\\) expects array\\<App\\\\Entity\\\\User\\|int\\>, array\\<int, int\\|null\\> given\\.$#"
count: 1
path: src/Widget/Type/UserTeams.php
-
message: "#^Property App\\\\Widget\\\\WidgetService\\:\\:\\$widgets type has no value type specified in iterable type array\\.$#"
count: 1

View File

@@ -4,6 +4,9 @@ use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
// TODO remove once PARTIAL usage was replaced entirely
\Doctrine\Deprecations\Deprecation::ignoreDeprecations('https://github.com/doctrine/orm/issues/8471');
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

View File

@@ -68,6 +68,7 @@ final class ActivityController extends BaseApiController
$user = $this->getUser();
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -65,6 +65,7 @@ final class CustomerController extends BaseApiController
$user = $this->getUser();
$query = new CustomerQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -74,6 +74,7 @@ final class ProjectController extends BaseApiController
$user = $this->getUser();
$query = new ProjectQuery();
$query->loadTeams();
$query->setCurrentUser($user);
$order = $paramFetcher->get('order');

View File

@@ -18,6 +18,7 @@ use App\Form\API\TeamApiEditForm;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\TeamQuery;
use App\Repository\TeamRepository;
use FOS\RestBundle\View\View;
use FOS\RestBundle\View\ViewHandlerInterface;
@@ -53,7 +54,10 @@ final class TeamController extends BaseApiController
#[Route(methods: ['GET'], path: '', name: 'get_teams')]
public function cgetAction(): Response
{
$data = $this->repository->findAll();
$query = new TeamQuery();
$query->setCurrentUser($this->getUser());
$data = $this->repository->getTeamsForQuery($query);
$view = new View($data, 200);
$view->getContext()->setGroups(self::GROUPS_COLLECTION);

View File

@@ -106,25 +106,25 @@ final class ExportCreateCommand extends Command
$timezone = new \DateTimeZone($timezone);
$dateFactory = new DateTimeFactory($timezone);
$customerIDs = $input->getOption('customer');
$customerIDs = $this->optionToIntArray($input, 'customer');
$customers = [];
if (\count($customerIDs) > 0) {
$customers = $this->customerRepository->findByIds($customerIDs);
}
$projectIDs = $input->getOption('project');
$projectIDs = $this->optionToIntArray($input, 'project');
$projects = [];
if (\count($projectIDs) > 0) {
$projects = $this->projectRepository->findByIds($projectIDs);
}
$teamIDs = $input->getOption('team');
$teamIDs = $this->optionToIntArray($input, 'team');
$teams = [];
if (\count($teamIDs) > 0) {
$teams = $this->teamRepository->findByIds($teamIDs);
}
$userIDs = $input->getOption('user');
$userIDs = $this->optionToIntArray($input, 'user');
$users = [];
if (\count($userIDs) > 0) {
$users = $this->userRepository->findByIds($userIDs);
@@ -287,6 +287,25 @@ final class ExportCreateCommand extends Command
return Command::SUCCESS;
}
/**
* @return array<int>
*/
private function optionToIntArray(InputInterface $input, string $name): array
{
$results = [];
$options = $input->getOption($name);
if (\is_array($options) && \count($options) > 0) {
foreach ($options as $option) {
if (is_numeric($option)) {
$results[] = (int) $option;
}
}
}
return $results;
}
private function savePreview(Response $response, string $directory): string
{
$filename = uniqid('invoice_');

View File

@@ -51,7 +51,12 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/activity')]
final class ActivityController extends AbstractController
{
public function __construct(private ActivityRepository $repository, private SystemConfiguration $configuration, private EventDispatcherInterface $dispatcher, private ActivityService $activityService)
public function __construct(
private readonly ActivityRepository $repository,
private readonly SystemConfiguration $configuration,
private readonly EventDispatcherInterface $dispatcher,
private readonly ActivityService $activityService
)
{
}
@@ -61,6 +66,7 @@ final class ActivityController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ActivityQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -443,7 +449,6 @@ final class ActivityController extends AbstractController
}
/**
* @param ActivityQuery $query
* @return FormInterface<ActivityQuery>
*/
private function getToolbarForm(ActivityQuery $query): FormInterface
@@ -456,7 +461,7 @@ final class ActivityController extends AbstractController
}
/**
* @return FormInterface<ActivityEditForm>
* @return FormInterface<mixed>
*/
private function createEditForm(Activity $activity): FormInterface
{

View File

@@ -54,7 +54,10 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(path: '/admin/customer')]
final class CustomerController extends AbstractController
{
public function __construct(private CustomerRepository $repository, private EventDispatcherInterface $dispatcher)
public function __construct(
private readonly CustomerRepository $repository,
private readonly EventDispatcherInterface $dispatcher
)
{
}
@@ -64,6 +67,7 @@ final class CustomerController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new CustomerQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);
@@ -126,7 +130,6 @@ final class CustomerController extends AbstractController
}
/**
* @param CustomerQuery $query
* @return MetaTableTypeInterface[]
*/
private function findMetaColumns(CustomerQuery $query): array
@@ -539,7 +542,7 @@ final class CustomerController extends AbstractController
}
/**
* @return FormInterface<CustomerComment>
* @return FormInterface<mixed>
*/
private function getCommentForm(CustomerComment $comment): FormInterface
{

View File

@@ -73,6 +73,7 @@ final class ProjectController extends AbstractController
public function indexAction(int $page, Request $request): Response
{
$query = new ProjectQuery();
$query->loadTeams();
$query->setCurrentUser($this->getUser());
$query->setPage($page);

View File

@@ -88,7 +88,7 @@ final class QuickEntryController extends AbstractController
$result = $this->repository->getTimesheetResult($query);
$rows = [];
foreach ($result->getResults(true) as $timesheet) {
foreach ($result->getResults() as $timesheet) {
$i = 0;
$id = $timesheet->getProject()->getId() . '_' . $timesheet->getActivity()->getId();
$day = $timesheet->getBegin()->format('Y-m-d');

View File

@@ -19,7 +19,6 @@ use App\Reporting\YearByUser\YearByUser;
use App\Reporting\YearByUser\YearByUserForm;
use DateTime;
use DateTimeInterface;
use Exception;
use PhpOffice\PhpSpreadsheet\Reader\Html;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -31,11 +30,6 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('report:user')]
final class UserYearController extends AbstractUserReportController
{
/**
* @param Request $request
* @return Response
* @throws Exception
*/
#[Route(path: '/year', name: 'report_user_year', methods: ['GET', 'POST'])]
public function yearByUser(Request $request, SystemConfiguration $systemConfiguration): Response
{

View File

@@ -36,6 +36,7 @@ use App\Utils\PageSetup;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormTypeInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -144,7 +145,9 @@ abstract class TimesheetAbstractController extends AbstractController
$event = new TimesheetMetaDefinitionEvent($entry);
$this->dispatcher->dispatch($event);
$editForm = $this->getEditForm($entry, $request->get('page'));
$page = $request->get('page');
$page = is_numeric($page) ? (int) $page : 1;
$editForm = $this->getEditForm($entry, $page);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
@@ -267,7 +270,7 @@ abstract class TimesheetAbstractController extends AbstractController
if (null === $exporter) {
$form->addError(new FormError('Invalid timesheet exporter given'));
} else {
return $exporter->render($entries->getResults(true), $query);
return $exporter->render($entries->getResults(), $query);
}
}
}
@@ -484,6 +487,9 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
/**
* @param class-string<FormTypeInterface> $formClass
*/
protected function generateCreateForm(Timesheet $entry, string $formClass, string $action): FormInterface
{
$mode = $this->getTrackingMode();
@@ -504,12 +510,7 @@ abstract class TimesheetAbstractController extends AbstractController
]);
}
/**
* @param Timesheet $entry
* @param int $page
* @return FormInterface
*/
protected function getEditForm(Timesheet $entry, $page): FormInterface
private function getEditForm(Timesheet $entry, int $page): FormInterface
{
$mode = $this->getTrackingMode();
@@ -568,6 +569,9 @@ abstract class TimesheetAbstractController extends AbstractController
return 'edit_rate_own_timesheet';
}
/**
* @return class-string<FormTypeInterface>
*/
protected function getEditFormClassName(): string
{
return TimesheetEditForm::class;

View File

@@ -23,7 +23,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: 'App\Repository\CustomerRepository')]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[Serializer\ExclusionPolicy('all')]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable'])]
#[Exporter\Order(['id', 'name', 'company', 'number', 'vatId', 'address', 'contact', 'email', 'phone', 'mobile', 'fax', 'homepage', 'country', 'currency', 'timezone', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable'])]
#[Constraints\Customer]
class Customer implements EntityWithMetaFields, EntityWithBudget
{

View File

@@ -26,7 +26,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[Serializer\ExclusionPolicy('all')]
#[Serializer\VirtualProperty('CustomerName', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()', options: [new Serializer\SerializedName('parentTitle'), new Serializer\Type(name: 'string'), new Serializer\Groups(['Project'])])]
#[Serializer\VirtualProperty('CustomerAsId', exp: 'object.getCustomer() === null ? null : object.getCustomer().getId()', options: [new Serializer\SerializedName('customer'), new Serializer\Type(name: 'integer'), new Serializer\Groups(['Project', 'Team', 'Not_Expanded'])])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'teams', 'comment', 'billable', 'number'])]
#[Exporter\Order(['id', 'name', 'customer', 'orderNumber', 'orderDate', 'start', 'end', 'budget', 'timeBudget', 'budgetType', 'color', 'visible', 'comment', 'billable', 'number'])]
#[Exporter\Expose(name: 'customer', label: 'customer', exp: 'object.getCustomer() === null ? null : object.getCustomer().getName()')]
#[Constraints\Project]
class Project implements EntityWithMetaFields, EntityWithBudget

View File

@@ -56,6 +56,10 @@ class Tag
/**
* This is ONLY here, so we can count the amount of timesheets.
*
* See TagRepository "SIZE(t.timesheets)"
* Removing this makes the count more complicated.
* Should be refactored at some point in the future.
*
* @var Collection<Timesheet>
*/
#[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')]

View File

@@ -9,6 +9,7 @@
namespace App\Entity;
use App\Repository\TeamRepository;
use App\Validator\Constraints as Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -20,7 +21,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'kimai2_teams')]
#[ORM\UniqueConstraint(columns: ['name'])]
#[ORM\Entity(repositoryClass: 'App\Repository\TeamRepository')]
#[ORM\Entity(repositoryClass: TeamRepository::class)]
#[ORM\ChangeTrackingPolicy('DEFERRED_EXPLICIT')]
#[UniqueEntity('name')]
#[Serializer\ExclusionPolicy('all')]
@@ -310,6 +311,7 @@ class Team
}
/**
* @internal
* @return Collection<Customer>
*/
public function getCustomers(): iterable
@@ -343,6 +345,7 @@ class Team
}
/**
* @internal
* @return Collection<Project>
*/
public function getProjects(): iterable
@@ -376,6 +379,7 @@ class Team
}
/**
* @internal
* @return Collection<Activity>
*/
public function getActivities(): iterable

View File

@@ -128,7 +128,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
* @var Collection<UserPreference>|null
*/
#[ORM\OneToMany(mappedBy: 'user', targetEntity: UserPreference::class, cascade: ['persist'])]
private ?Collection $preferences = null;
private ?Collection $preferences;
/**
* List of all team memberships.
*
@@ -663,11 +663,13 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas
#[Serializer\SerializedName('teams')]
#[Serializer\Groups(['User_Entity'])]
#[OA\Property(type: 'array', items: new OA\Items(ref: '#/components/schemas/Team'))]
public function getTeams(): iterable
public function getTeams(): array
{
$teams = [];
foreach ($this->memberships as $membership) {
$teams[] = $membership->getTeam();
if ($membership->getTeam() !== null) {
$teams[] = $membership->getTeam();
}
}
return $teams;

View File

@@ -13,8 +13,6 @@ use App\Repository\Query\CustomerQuery;
/**
* Dynamically find possible meta fields for a customer query.
*
* @method CustomerQuery getQuery()
*/
final class CustomerMetaDisplayEvent extends AbstractMetaDisplayEvent
{

View File

@@ -32,13 +32,12 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$allowCreate = $this->isGranted('create_invoice');
$allowView = $this->isGranted('view_invoice');
$allowCustomer = $this->isGranted('access', $invoice->getCustomer());
if ($allowCustomer && $allowCreate) {
if ($allowCreate) {
$event->addEdit($this->path('admin_invoice_edit', ['id' => $invoice->getId()]));
}
if ($allowCustomer && $allowView) {
if ($allowView) {
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
}
@@ -46,7 +45,7 @@ final class InvoiceSubscriber extends AbstractActionsSubscriber
$event->addDivider();
}
if ($allowCustomer && $allowCreate) {
if ($allowCreate) {
if (!$invoice->isPending()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending', 'token' => $payload['token']])]);
} else {

View File

@@ -18,7 +18,9 @@ use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\ExportFilename;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\LocaleFormatExtensions;
use App\Utils\StringHelper;
@@ -461,9 +463,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['customer-meta'])) {
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery());
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
$columns['customer-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($customerMetaFields): int {
@@ -491,7 +491,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['project-meta'])) {
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
$columns['project-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($projectMetaFields): int {
foreach ($projectMetaFields as $metaField) {
@@ -518,7 +518,7 @@ abstract class AbstractSpreadsheetRenderer
}
if (isset($columns['activity-meta'])) {
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
$columns['activity-meta'] = [
'header' => function (Worksheet $sheet, int $row, int $column) use ($activityMetaFields): int {
foreach ($activityMetaFields as $metaField) {

View File

@@ -19,7 +19,9 @@ use App\Event\ProjectMetaDisplayEvent;
use App\Event\TimesheetMetaDisplayEvent;
use App\Event\UserPreferenceDisplayEvent;
use App\Project\ProjectStatisticService;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Twig\SecurityPolicy\ExportPolicy;
use Psr\EventDispatcher\EventDispatcherInterface;
@@ -81,13 +83,10 @@ class HtmlRenderer
*/
public function render(array $timesheets, TimesheetQuery $query): Response
{
/** @var CustomerQuery $customerQuery */
$customerQuery = $query->copyTo(new CustomerQuery());
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($customerQuery, CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query, ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query, ActivityMetaDisplayEvent::EXPORT));
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
$this->dispatcher->dispatch($event);

View File

@@ -44,7 +44,7 @@ final class ActivityHelper
public function getChoiceLabel(Activity $activity): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $activity->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $activity->getName() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $activity->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $activity->getComment() ?? '', $name);
@@ -57,7 +57,7 @@ final class ActivityHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $activity->getName();
$name = $activity->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -45,7 +45,7 @@ final class CustomerHelper
public function getChoiceLabel(Customer $customer): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $customer->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $customer->getName() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $customer->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $customer->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMPANY, $customer->getCompany() ?? '', $name);
@@ -59,7 +59,7 @@ final class CustomerHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $customer->getName();
$name = $customer->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -70,7 +70,7 @@ final class ProjectHelper
public function getChoiceLabel(Project $project): string
{
$name = $this->getChoicePattern();
$name = str_replace(self::PATTERN_NAME, $project->getName(), $name);
$name = str_replace(self::PATTERN_NAME, $project->getName() ?? '', $name);
$name = str_replace(self::PATTERN_NUMBER, $project->getNumber() ?? '', $name);
$name = str_replace(self::PATTERN_COMMENT, $project->getComment() ?? '', $name);
$name = str_replace(self::PATTERN_CUSTOMER, $project->getCustomer()?->getName() ?? '', $name);
@@ -115,7 +115,7 @@ final class ProjectHelper
}
if ($name === '' || $name === self::SPACER) {
$name = $project->getName();
$name = $project->getName() ?? '';
}
return substr($name, 0, 110);

View File

@@ -27,7 +27,10 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
*/
final class ProjectType extends AbstractType
{
public function __construct(private ProjectHelper $projectHelper, private CustomerHelper $customerHelper)
public function __construct(
private readonly ProjectHelper $projectHelper,
private readonly CustomerHelper $customerHelper
)
{
}

View File

@@ -22,7 +22,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
private ?BudgetStatistic $statistic = null;
private ?BudgetStatistic $statisticTotal = null;
public function __construct(private EntityWithBudget $entity)
public function __construct(private readonly EntityWithBudget $entity)
{
}
@@ -36,7 +36,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
return $this->statistic;
}
public function setStatistic(BudgetStatistic $statistic)
public function setStatistic(BudgetStatistic $statistic): void
{
$this->statistic = $statistic;
}
@@ -46,7 +46,7 @@ class BudgetStatisticModel implements BudgetStatisticModelInterface
return $this->statisticTotal;
}
public function setStatisticTotal(BudgetStatistic $statistic)
public function setStatisticTotal(BudgetStatistic $statistic): void
{
$this->statisticTotal = $statistic;
}

View File

@@ -29,7 +29,6 @@ use App\Reporting\ProjectInactive\ProjectInactiveQuery;
use App\Reporting\ProjectView\ProjectViewModel;
use App\Reporting\ProjectView\ProjectViewQuery;
use App\Repository\ActivityRepository;
use App\Repository\Loader\ProjectLoader;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
@@ -85,7 +84,7 @@ class ProjectStatisticService
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p, c')
->select('p')
->leftJoin('p.customer', 'c')
->andWhere($qb->expr()->eq('p.visible', true))
->andWhere($qb->expr()->eq('c.visible', true))
@@ -108,15 +107,9 @@ class ProjectStatisticService
;
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
/**
@@ -128,6 +121,10 @@ class ProjectStatisticService
$begin = $dateRange->getBegin();
$end = $dateRange->getEnd();
if ($begin === null || $end === null) {
throw new \InvalidArgumentException('Missing date in date-range for project statistics');
}
$qb = $this->projectRepository->createQueryBuilder('p');
$qb
->select('p')
@@ -191,15 +188,9 @@ class ProjectStatisticService
}
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
public function getBudgetStatisticModel(Project $project, DateTimeInterface $today): ProjectBudgetStatisticModel
@@ -687,15 +678,9 @@ class ProjectStatisticService
}
$this->projectRepository->addPermissionCriteria($qb, $user);
$query = $this->projectRepository->prepareProjectQuery($qb->getQuery());
/** @var Project[] $projects */
$projects = $qb->getQuery()->getResult();
// pre-cache customer objects instead of joining them
$loader = new ProjectLoader($this->projectRepository->createQueryBuilder('p')->getEntityManager(), false, false, false);
$loader->loadResults($projects);
return $projects;
return $this->projectRepository->getProjects($query);
}
/**

View File

@@ -16,14 +16,16 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\ActivityLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ActivityFormTypeQuery;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\ActivityQueryHydrate;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -37,53 +39,53 @@ class ActivityRepository extends EntityRepository
/**
* @param Project $project
* @return Activity[]
* @return array<Activity>
*/
public function findByProject(Project $project): array
{
return $this->findBy(['project' => $project]);
$query = new ActivityQuery();
$query->addProject($project);
return $this->getActivitiesForQuery($query);
}
/**
* @param int[] $activityIds
* @return Activity[]
* @return array<Activity>
*/
public function findByIds(array $activityIds): array
{
$ids = array_filter(
array_unique($activityIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('a');
$qb
->where($qb->expr()->in('a.id', ':id'))
->setParameter('id', $activityIds)
->setParameter('id', $ids)
;
$activities = $qb->getQuery()->getResult();
$loader = new ActivityLoader($qb->getEntityManager(), true);
$loader->loadResults($activities);
return $activities;
return $this->getActivities($this->prepareActivityQuery($qb->getQuery()));
}
/**
* @param Activity $activity
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveActivity(Activity $activity)
public function saveActivity(Activity $activity): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($activity);
$entityManager->flush();
}
/**
* @param null|bool $visible
* @return int
*/
public function countActivity($visible = null): int
public function countActivity(?bool $visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
return $this->count(['visible' => $visible]);
}
return $this->count([]);
@@ -157,8 +159,7 @@ class ActivityRepository extends EntityRepository
/**
* Returns a query builder that is used for ActivityType and your own 'query_builder' option.
*
* @param ActivityFormTypeQuery $query
* @return QueryBuilder
* @internal
*/
public function getQueryBuilderForFormType(ActivityFormTypeQuery $query): QueryBuilder
{
@@ -252,15 +253,17 @@ class ActivityRepository extends EntityRepository
private function getQueryBuilderForQuery(ActivityQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb = $this->createQueryBuilder('a');
$qb
->select('a')
->from(Activity::class, 'a')
->leftJoin('a.project', 'p')
->leftJoin('p.customer', 'c')
;
if (\count($query->getActivityIds()) > 0) {
$qb->andWhere($qb->expr()->in('a.id', ':id'))->setParameter('id', $query->getActivityIds());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'project':
@@ -356,6 +359,9 @@ class ActivityRepository extends EntityRepository
return ['a.name', 'a.comment', 'a.number'];
}
/**
* @return int<0, max>
*/
public function countActivitiesForQuery(ActivityQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -366,7 +372,7 @@ class ActivityRepository extends EntityRepository
->select($qb->expr()->countDistinct('a.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function getPagerfantaForQuery(ActivityQuery $query): Pagination
@@ -374,33 +380,41 @@ class ActivityRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Activity>
*/
private function getPaginatorForQuery(ActivityQuery $activityQuery): PaginatorInterface
{
$counter = $this->countActivitiesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countActivitiesForQuery($activityQuery);
$query = $this->createActivityQuery($activityQuery);
return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new ActivityLoader($this->getEntityManager()), $query, $counter);
}
/**
* @param ActivityQuery $query
* @return Activity[]
*/
public function getActivitiesForQuery(ActivityQuery $query): iterable
public function getActivitiesForQuery(ActivityQuery $query): array
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $paginator->getAll();
return $this->getActivities($this->createActivityQuery($query));
}
/**
* @param Activity $delete
* @param Activity|null $replace
* @throws \Doctrine\ORM\Exception\ORMException
* @param Query<Activity> $query
* @return Activity[]
*/
public function deleteActivity(Activity $delete, ?Activity $replace = null)
public function getActivities(Query $query): array
{
/** @var array<Activity> $activities */
$activities = $query->execute();
$loader = new ActivityLoader($this->getEntityManager());
$loader->loadResults($activities);
return $activities;
}
public function deleteActivity(Activity $delete, ?Activity $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -425,4 +439,47 @@ class ActivityRepository extends EntityRepository
throw $ex;
}
}
/**
* @return Query<Activity>
*/
private function createActivityQuery(ActivityQuery $activityQuery): Query
{
$query = $this->getQueryBuilderForQuery($activityQuery)->getQuery();
$query = $this->prepareActivityQuery($query);
foreach ($activityQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case ActivityQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Activity::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case ActivityQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Activity::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Activity> $query
* @return Query<Activity>
*/
public function prepareActivityQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Activity::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Activity::class, 'project', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -16,14 +16,16 @@ use App\Entity\Project;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\CustomerFormTypeQuery;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\CustomerQueryHydrate;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -37,22 +39,28 @@ class CustomerRepository extends EntityRepository
/**
* @param int[] $customerIDs
* @return Customer[]
* @return array<Customer>
*/
public function findByIds(array $customerIDs): array
{
$ids = array_filter(
array_unique($customerIDs),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('c');
$qb
->where($qb->expr()->in('c.id', ':id'))
->setParameter('id', $customerIDs)
->setParameter('id', $ids)
;
$customers = $qb->getQuery()->getResult();
$loader = new CustomerLoader($qb->getEntityManager(), true);
$loader->loadResults($customers);
return $customers;
return $this->getCustomers($this->prepareCustomerQuery($qb->getQuery()), new CustomerQuery());
}
public function saveCustomer(Customer $customer): void
@@ -120,6 +128,8 @@ class CustomerRepository extends EntityRepository
/**
* Returns a query builder that is used for CustomerType and your own 'query_builder' option.
*
* @internal
*/
public function getQueryBuilderForFormType(CustomerFormTypeQuery $query): QueryBuilder
{
@@ -163,18 +173,14 @@ class CustomerRepository extends EntityRepository
private function getQueryBuilderForQuery(CustomerQuery $query): QueryBuilder
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb = $this->createQueryBuilder('c');
$qb
->select('c')
->from(Customer::class, 'c')
;
if (\count($query->getCustomerIds()) > 0) {
$qb->andWhere($qb->expr()->in('c.id', ':id'))->setParameter('id', $query->getCustomerIds());
}
if ($query->getCountry() !== null) {
$qb
->andWhere($qb->expr()->eq('c.country', ':country'))
->setParameter('country', $query->getCountry())
;
$qb->andWhere($qb->expr()->eq('c.country', ':country'))->setParameter('country', $query->getCountry());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
@@ -190,11 +196,9 @@ class CustomerRepository extends EntityRepository
}
if ($query->isShowVisible()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', true, ParameterType::BOOLEAN);
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'))->setParameter('visible', true, ParameterType::BOOLEAN);
} elseif ($query->isShowHidden()) {
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'));
$qb->setParameter('visible', false, ParameterType::BOOLEAN);
$qb->andWhere($qb->expr()->eq('c.visible', ':visible'))->setParameter('visible', false, ParameterType::BOOLEAN);
}
$this->addPermissionCriteria($qb, $query->getCurrentUser(), $query->getTeams());
@@ -227,6 +231,10 @@ class CustomerRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* FIXME make this private and remove the widget that this currently uses
* @return int<0, max>
*/
public function countCustomersForQuery(CustomerQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -237,27 +245,81 @@ class CustomerRepository extends EntityRepository
->select($qb->expr()->countDistinct('c.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Customer>
*/
private function getPaginatorForQuery(CustomerQuery $customerQuery): PaginatorInterface
{
$counter = $this->countCustomersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countCustomersForQuery($customerQuery);
$query = $this->createCustomerQuery($customerQuery);
return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new CustomerLoader($this->getEntityManager(), $customerQuery), $query, $counter);
}
/**
* @return Query<Customer>
*/
private function createCustomerQuery(CustomerQuery $customerQuery): Query
{
$query = $this->getQueryBuilderForQuery($customerQuery)->getQuery();
$query = $this->prepareCustomerQuery($query);
foreach ($customerQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case CustomerQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Customer::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case CustomerQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Customer::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Customer> $query
* @return Query<Customer>
*/
public function prepareCustomerQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Customer::class, 'meta', ClassMetadata::FETCH_EAGER);
return $query;
}
/**
* @return Customer[]
*/
public function getCustomersForQuery(CustomerQuery $query): iterable
public function getCustomersForQuery(CustomerQuery $customerQuery): array
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $this->getCustomers($this->createCustomerQuery($customerQuery), $customerQuery);
}
return $paginator->getAll();
/**
* @param Query<Customer> $query
* @return Customer[]
*/
public function getCustomers(Query $query, CustomerQuery $customerQuery): array
{
/** @var array<Customer> $customers */
$customers = $query->execute();
$loader = new CustomerLoader($this->getEntityManager(), $customerQuery);
$loader->loadResults($customers);
return $customers;
}
public function deleteCustomer(Customer $delete, ?Customer $replace = null): void

View File

@@ -14,12 +14,13 @@ use App\Entity\Invoice;
use App\Entity\InvoiceMeta;
use App\Entity\Team;
use App\Entity\User;
use App\Repository\Loader\InvoiceLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\InvoiceArchiveQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
@@ -254,7 +255,10 @@ class InvoiceRepository extends EntityRepository
return ['i.comment', 'customer.name', 'customer.company'];
}
public function countInvoicesForQuery(InvoiceArchiveQuery $query): int
/**
* @return int<0, max>
*/
private function countInvoicesForQuery(InvoiceArchiveQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -264,7 +268,7 @@ class InvoiceRepository extends EntityRepository
->select($qb->expr()->countDistinct('i.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
/**
@@ -273,23 +277,38 @@ class InvoiceRepository extends EntityRepository
*/
public function getInvoicesForQuery(InvoiceArchiveQuery $query): iterable
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $paginator->getAll();
return $this->createInvoiceQuery($query)->execute(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(InvoiceArchiveQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Invoice>
*/
private function getPaginatorForQuery(InvoiceArchiveQuery $query): PaginatorInterface
{
$counter = $this->countInvoicesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$query = $this->createInvoiceQuery($query);
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
return new QueryPaginator($query, $counter);
}
public function getPagerfantaForQuery(InvoiceArchiveQuery $query): Pagination
{
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return Query<Invoice>
*/
private function createInvoiceQuery(InvoiceArchiveQuery $invoiceArchiveQuery): Query
{
$query = $this->getQueryBuilderForQuery($invoiceArchiveQuery)->getQuery();
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Invoice::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Invoice::class, 'user', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Invoice::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -11,10 +11,11 @@ namespace App\Repository;
use App\Entity\InvoiceTemplate;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\BaseQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
@@ -49,12 +50,16 @@ class InvoiceTemplateRepository extends EntityRepository
return $qb;
}
protected function getPaginatorForQuery(BaseQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<InvoiceTemplate>
*/
private function getPaginatorForQuery(BaseQuery $baseQuery): PaginatorInterface
{
$counter = $this->countTemplatesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countTemplatesForQuery($baseQuery);
/** @var Query<InvoiceTemplate> $query */
$query = $this->getQueryBuilderForQuery($baseQuery)->getQuery();
return new QueryBuilderPaginator($qb, $counter);
return new QueryPaginator($query, $counter);
}
public function getPagerfantaForQuery(BaseQuery $query): Pagination
@@ -62,6 +67,9 @@ class InvoiceTemplateRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return int<0, max>
*/
public function countTemplatesForQuery(BaseQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -71,7 +79,7 @@ class InvoiceTemplateRepository extends EntityRepository
->select($qb->expr()->countDistinct('t.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function saveTemplate(InvoiceTemplate $template): void

View File

@@ -12,65 +12,44 @@ namespace App\Repository\Loader;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Activity>
*/
final class ActivityLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
/**
* @param array<int|Activity> $results
* @param array<Activity> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($activity) {
if ($activity instanceof Activity) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$activity->getName();
$activityIds = array_filter(array_unique(array_map(function (Activity $activity) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$activity->getName();
// using reporting controller tests will show that error
$activity->getProject()?->getName();
return $activity->getId();
}
return $activity;
}, $results);
return $activity->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Activity[] $activities */
$activities = $qb->select('PARTIAL a.{id}', 'project')
->from(Activity::class, 'a')
->leftJoin('a.project', 'project')
->andWhere($qb->expr()->isNotNull('a.project'))
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'meta')
->from(Activity::class, 'a')
->leftJoin('a.meta', 'meta')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
$projectIds = array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getProject()?->getId();
}, $results)), function ($value) { return $value !== null; });
// global activities don't have projects
if (!empty($activities)) {
$projectIds = array_unique(array_map(function (Activity $activity) {
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getId();
}, $activities));
if (\count($projectIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
@@ -79,14 +58,6 @@ final class ActivityLoader implements LoaderInterface
->getQuery()
->execute();
$customerIds = array_unique(array_map(function (Activity $activity) {
if (null === $activity->getProject()) {
return null;
}
return $activity->getProject()->getCustomer()->getId();
}, $activities));
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
@@ -95,44 +66,28 @@ final class ActivityLoader implements LoaderInterface
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
}
$customerIds = array_filter(array_unique(array_map(function (Activity $activity) {
return $activity->getProject()?->getCustomer()?->getId();
}, $results)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $ids))
->getQuery()
->execute();
// do not load team members or leads by default, because they will only be used on detail pages
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->fullyHydrated) {
$teamIds = [];
foreach ($activities as $activity) {
foreach ($activity->getTeams() as $team) {
$teamIds[] = $team->getId();
}
}
$teamIds = array_unique($teamIds);
if (\count($teamIds) > 0) {
if (\count($customerIds) > 0) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $teamIds))
$qb->select('PARTIAL customer.{id}', 'teams')
->from(Customer::class, 'customer')
->leftJoin('customer.teams', 'teams')
->andWhere($qb->expr()->in('customer.id', $customerIds))
->getQuery()
->execute();
}
}
// required on "Activity listing" page for non super-admins
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL a.{id}', 'teams')
->from(Activity::class, 'a')
->leftJoin('a.teams', 'teams')
->andWhere($qb->expr()->in('a.id', $activityIds))
->getQuery()
->execute();
}
}

View File

@@ -11,58 +11,73 @@ namespace App\Repository\Loader;
use App\Entity\Customer;
use App\Entity\Team;
use App\Repository\Query\CustomerQuery;
use App\Repository\Query\CustomerQueryHydrate;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Customer>
*/
final class CustomerLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CustomerQuery $query
)
{
}
/**
* @param array<int|Customer> $results
* @param array<Customer> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($customer) {
if ($customer instanceof Customer) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$customer->getName();
$customerIds = array_filter(array_unique(array_map(function (Customer $customer) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$customer->getName();
return $customer->getId();
return $customer->getId();
}, $results)), function ($value) { return $value !== null; });
$hydrateTeams = false;
$hydrateTeamMembers = false;
foreach ($this->query->getHydrate() as $hydrate) {
switch ($hydrate) {
case CustomerQueryHydrate::TEAMS:
$hydrateTeams = true;
break;
case CustomerQueryHydrate::TEAM_MEMBER:
$hydrateTeams = true;
$hydrateTeamMembers = true;
break;
}
}
return $customer;
}, $results);
if (!$hydrateTeams) {
return;
}
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Customer[] $customers */
$customers = $qb->select('PARTIAL c.{id}', 'meta')
->from(Customer::class, 'c')
->leftJoin('c.meta', 'meta')
->andWhere($qb->expr()->in('c.id', $ids))
->getQuery()
->execute();
// required where we need to check team permissions, e.g. "Customer listing"
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'teams')
->from(Customer::class, 'c')
->leftJoin('c.teams', 'teams')
->andWhere($qb->expr()->in('c.id', $ids))
->andWhere($qb->expr()->in('c.id', $customerIds))
->getQuery()
->execute();
// do not load team members or leads by default, because they will only be used on detail pages
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->fullyHydrated) {
if ($hydrateTeamMembers) {
$teamIds = [];
foreach ($customers as $customer) {
foreach ($results as $customer) {
foreach ($customer->getTeams() as $team) {
$teamIds[] = $team->getId();
}

View File

@@ -9,6 +9,10 @@
namespace App\Repository\Loader;
/**
* @deprecated use QueryBuilderPaginator instead
* @implements LoaderInterface<mixed>
*/
final class DefaultLoader implements LoaderInterface
{
public function loadResults(array $results): void

View File

@@ -1,65 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Loader;
use App\Entity\Invoice;
use Doctrine\ORM\EntityManagerInterface;
final class InvoiceLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
/**
* @param array<int|Invoice> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
return;
}
$ids = array_map(function ($invoice) {
if ($invoice instanceof Invoice) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$invoice->getInvoiceNumber();
return $invoice->getId();
}
return $invoice;
}, $results);
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'customer')
->from(Invoice::class, 'i')
->leftJoin('i.customer', 'customer')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'user')
->from(Invoice::class, 'i')
->leftJoin('i.user', 'user')
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL i.{id}', 'meta')
->from(Invoice::class, 'i')
->leftJoin('i.meta', 'meta')
->andWhere($qb->expr()->in('i.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -9,12 +9,15 @@
namespace App\Repository\Loader;
/**
* @template T
*/
interface LoaderInterface
{
/**
* Prepares the given database results, to prevent lazy loading.
*
* @param array $results
* @param array<array-key, T> $results
*/
public function loadResults(array $results): void;
}

View File

@@ -14,63 +14,50 @@ use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Project>
*/
final class ProjectLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $hydrateTeamMembers = false, private bool $hydrateTeams = true, private bool $hydrateMeta = true)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $hydrateTeamMembers = false,
private readonly bool $hydrateTeams = true
)
{
}
/**
* @param array<int|Project> $results
* @param array<Project> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($project) {
if ($project instanceof Project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
$projectIds = array_filter(array_unique(array_map(function (Project $project) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$project->getName();
// using reporting controller tests will show that error
$project->getCustomer()?->getName();
return $project->getId();
}
return $project;
}, $results);
return $project->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var Project[] $projects */
$projects = $qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
->leftJoin('project.customer', 'customer')
->andWhere($qb->expr()->in('project.id', $ids))
->getQuery()
->execute();
$customerIds = array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects));
if ($this->hydrateMeta) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'meta')
->from(Project::class, 'project')
->leftJoin('project.meta', 'meta')
->andWhere($qb->expr()->in('project.id', $ids))
->getQuery()
->execute();
}
if ($this->hydrateTeams) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $results)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'teams')
->from(Project::class, 'project')
->leftJoin('project.teams', 'teams')
->andWhere($qb->expr()->in('project.id', $ids))
->andWhere($qb->expr()->in('project.id', $projectIds))
->getQuery()
->execute();
@@ -87,7 +74,7 @@ final class ProjectLoader implements LoaderInterface
// and there is no benefit in adding multiple queries for most requests when they are only needed in one place
if ($this->hydrateTeamMembers) {
$teamIds = [];
foreach ($projects as $project) {
foreach ($results as $project) {
foreach ($project->getTeams() as $team) {
$teamIds[] = $team->getId();
}

View File

@@ -9,52 +9,77 @@
namespace App\Repository\Loader;
use App\Entity\Project;
use App\Entity\Team;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Team>
*/
final class TeamLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $loadCustomer = false
)
{
}
/**
* @param array<int|Team> $results
* @param array<Team> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($team) {
if ($team instanceof Team) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$team->getName();
$teamIds = array_filter(array_unique(array_map(function (Team $team) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$team->getName();
return $team->getId();
}
return $team;
}, $results);
return $team->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
// required wherever users are shown, e.g. on "Custom details" page
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'members', 'user')
->from(Team::class, 'team')
->leftJoin('team.members', 'members')
->leftJoin('members.user', 'user')
->andWhere($qb->expr()->in('team.id', $ids))
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL team.{id}', 'projects')
/** @var array<Team> $teams */
$teams = $qb->select('PARTIAL team.{id}', 'projects')
->from(Team::class, 'team')
->leftJoin('team.projects', 'projects')
->andWhere($qb->expr()->in('team.id', $ids))
->andWhere($qb->expr()->in('team.id', $teamIds))
->getQuery()
->execute();
$projectIds = [];
foreach ($results as $team) {
foreach ($team->getProjects() as $project) {
$projectIds[] = $project->getId();
}
}
if ($this->loadCustomer) {
// used in UserTeamProjects widget
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL project.{id}', 'customer')
->from(Project::class, 'project')
->leftJoin('project.customer', 'customer')
->andWhere($qb->expr()->in('project.id', $projectIds))
->getQuery()
->execute();
}
}
}

View File

@@ -15,46 +15,40 @@ use App\Entity\Project;
use App\Entity\Timesheet;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<Timesheet>
*/
final class TimesheetLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false, private bool $basicHydrated = true)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $fullyHydrated = false
)
{
}
/**
* @param array<int|Timesheet> $results
* @param array<Timesheet> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($timesheet) {
if ($timesheet instanceof Timesheet) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$timesheet->getType();
$ids = array_filter(array_unique(array_map(function (Timesheet $timesheet) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$timesheet->getType();
return $timesheet->getId();
}
return $timesheet;
}, $results);
return $timesheet->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
$qb = $em->createQueryBuilder();
/** @var array<Timesheet> $timesheets */
$timesheets = $qb->select('PARTIAL t.{id}', 'project')
->from(Timesheet::class, 't')
->leftJoin('t.project', 'project')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$projectIds = array_map(function ($timesheet) {
return $timesheet->getProject()->getId();
}, $timesheets);
$projectIds = array_filter(array_unique(array_map(function (Timesheet $timesheet) {
return $timesheet->getProject()?->getId();
}, $results)), function ($value) { return $value !== null; });
if ($this->fullyHydrated) {
$qb = $em->createQueryBuilder();
@@ -76,9 +70,9 @@ final class TimesheetLoader implements LoaderInterface
->execute();
if ($this->fullyHydrated) {
$customerIds = array_map(function ($project) {
$customerIds = array_filter(array_unique(array_map(function (Project $project) {
return $project->getCustomer()->getId();
}, $projects);
}, $projects)), function ($value) { return $value !== null; });
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL c.{id}', 'meta')
@@ -89,18 +83,10 @@ final class TimesheetLoader implements LoaderInterface
->execute();
}
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'activity')
->from(Timesheet::class, 't')
->leftJoin('t.activity', 'activity')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
if ($this->fullyHydrated) {
$activityIds = array_filter(array_map(function (Timesheet $timesheet) {
return $timesheet->getActivity()?->getId();
}, $timesheets), function ($id): bool {
}, $results), function ($id): bool {
return $id !== null;
});
@@ -113,30 +99,12 @@ final class TimesheetLoader implements LoaderInterface
->execute();
}
if ($this->basicHydrated) {
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'user')
->from(Timesheet::class, 't')
->leftJoin('t.user', 'user')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'tags')
->from(Timesheet::class, 't')
->leftJoin('t.tags', 'tags')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'meta')
->from(Timesheet::class, 't')
->leftJoin('t.meta', 'meta')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL t.{id}', 'tags')
->from(Timesheet::class, 't')
->leftJoin('t.tags', 'tags')
->andWhere($qb->expr()->in('t.id', $ids))
->getQuery()
->execute();
}
}

View File

@@ -13,49 +13,54 @@ use App\Entity\Team;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
* @implements LoaderInterface<User>
*/
final class UserLoader implements LoaderInterface
{
public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false)
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly bool $fullyHydrated = false
)
{
}
/**
* @param array<int|User> $results
* @param array<User> $results
*/
public function loadResults(array $results): void
{
if (empty($results)) {
if (\count($results) === 0) {
return;
}
$ids = array_map(function ($user) {
if ($user instanceof User) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$user->getDisplayName();
$userIds = array_filter(array_unique(array_map(function (User $user) {
// make sure that this potential doctrine proxy is initialized and filled with all data
$user->getDisplayName();
return $user->getId();
}
return $user;
}, $results);
return $user->getId();
}, $results)), function ($value) { return $value !== null; });
$em = $this->entityManager;
// this is currently needed, as it does not work via the Doctrine eager fetch method
// on user listing pages, if users are already in the unit of work from another load
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL user.{id}', 'preferences')
->from(User::class, 'user')
->leftJoin('user.preferences', 'preferences')
->andWhere($qb->expr()->in('user.id', $userIds))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
/** @var User[] $users */
$users = $qb->select('PARTIAL user.{id}', 'memberships', 'team')
->from(User::class, 'user')
->leftJoin('user.memberships', 'memberships')
->leftJoin('memberships.team', 'team')
->andWhere($qb->expr()->in('user.id', $ids))
->getQuery()
->execute();
$qb = $em->createQueryBuilder();
$qb->select('PARTIAL user.{id}', 'preferences')
->from(User::class, 'user')
->leftJoin('user.preferences', 'preferences')
->andWhere($qb->expr()->in('user.id', $ids))
->andWhere($qb->expr()->in('user.id', $userIds))
->getQuery()
->execute();

View File

@@ -13,9 +13,21 @@ use App\Repository\Loader\LoaderInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class LoaderPaginator implements PaginatorInterface
{
public function __construct(private LoaderInterface $loader, private QueryBuilder $query, private int $results)
/**
* @param LoaderInterface<T> $loader
* @param int<0, max> $results
*/
public function __construct(
private readonly LoaderInterface $loader,
private readonly QueryBuilder $queryBuilder,
private readonly int $results
)
{
}
@@ -25,12 +37,20 @@ final class LoaderPaginator implements PaginatorInterface
}
/**
* @return iterable<array-key, iterable<mixed>>
* @return Query<null, T>
*/
private function getQuery(): Query
{
return $this->queryBuilder->getQuery(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->getQuery()
/** @var Query<null, T> $query */
$query = $this->getQuery()
->setFirstResult($offset)
->setMaxResults($length);
@@ -38,20 +58,24 @@ final class LoaderPaginator implements PaginatorInterface
}
/**
* @param Query<null, mixed> $query
* @return iterable<array-key, iterable<mixed>>
* @param Query<null, T> $query
* @return array<array-key, T>
*/
private function getResults(Query $query)
private function getResults(Query $query): array
{
/** @var array<array-key, T> $results */
$results = $query->execute();
$this->loader->loadResults($results);
return $results; // @phpstan-ignore-line
return $results;
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query->getQuery());
return $this->getResults($this->getQuery());
}
}

View File

@@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Paginator;
use App\Repository\Loader\LoaderInterface;
use Doctrine\ORM\Query;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class LoaderQueryPaginator implements PaginatorInterface
{
/**
* @param LoaderInterface<T> $loader
* @param Query<T> $query
* @param int<0, max> $results
*/
public function __construct(
private readonly LoaderInterface $loader,
private readonly Query $query,
private readonly int $results
)
{
}
public function getNbResults(): int
{
return $this->results;
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
/** @var Query<null, T> $query */
$query = $this->query
->setFirstResult($offset)
->setMaxResults($length);
return $this->getResults($query);
}
/**
* @param Query<null, T> $query
* @return iterable<array-key, T>
*/
private function getResults(Query $query): iterable
{
/** @var array<T> $results */
$results = $query->execute();
$this->loader->loadResults($results);
return $results;
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query);
}
}

View File

@@ -11,12 +11,15 @@ namespace App\Repository\Paginator;
use Pagerfanta\Adapter\AdapterInterface;
/**
* @template-covariant T
*/
interface PaginatorInterface extends AdapterInterface
{
/**
* Returns all available results without pagination.
*
* @return iterable
* @return iterable<array-key, T>
*/
public function getAll(): iterable;
}

View File

@@ -12,9 +12,19 @@ namespace App\Repository\Paginator;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @deprecated use QueryPaginator instead
* @implements PaginatorInterface<mixed>
*/
final class QueryBuilderPaginator implements PaginatorInterface
{
public function __construct(private QueryBuilder $query, private int $results)
/**
* @param int<0, max> $results
*/
public function __construct(
private readonly QueryBuilder $queryBuilder,
private readonly int $results
)
{
}
@@ -24,11 +34,12 @@ final class QueryBuilderPaginator implements PaginatorInterface
}
/**
* @return iterable<array-key, iterable<mixed>>
* @return iterable<array-key, mixed>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
/** @var Query<null, mixed> $query */
$query = $this->queryBuilder
->getQuery()
->setFirstResult($offset)
->setMaxResults($length);
@@ -38,15 +49,18 @@ final class QueryBuilderPaginator implements PaginatorInterface
/**
* @param Query<null, mixed> $query
* @return iterable<array-key, iterable<mixed>>
* @return iterable<array-key, mixed>
*/
private function getResults(Query $query)
private function getResults(Query $query): iterable
{
return $query->execute(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, mixed>
*/
public function getAll(): iterable
{
return $this->getResults($this->query->getQuery());
return $this->getResults($this->queryBuilder->getQuery());
}
}

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Paginator;
use Doctrine\ORM\Query;
/**
* @template T
* @implements PaginatorInterface<T>
*/
final class QueryPaginator implements PaginatorInterface
{
/**
* @param Query<null, T> $query
* @param int<0, max> $results
*/
public function __construct(
private readonly Query $query,
private readonly int $results
)
{
}
public function getNbResults(): int
{
return $this->results;
}
/**
* @return iterable<array-key, T>
*/
public function getSlice(int $offset, int $length): iterable
{
$query = $this->query
->setFirstResult($offset)
->setMaxResults($length);
return $this->getResults($query);
}
/**
* @param Query<null, T> $query
* @return iterable<array-key, T>
*/
private function getResults(Query $query): iterable
{
return $query->execute(); // @phpstan-ignore-line
}
/**
* @return iterable<array-key, T>
*/
public function getAll(): iterable
{
return $this->getResults($this->query);
}
}

View File

@@ -17,16 +17,18 @@ use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\ProjectFormTypeQuery;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\ProjectQueryHydrate;
use App\Utils\Pagination;
use DateTime;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\QueryBuilder;
@@ -40,30 +42,31 @@ class ProjectRepository extends EntityRepository
/**
* @param int[] $projectIds
* @return Project[]
* @return array<Project>
*/
public function findByIds(array $projectIds): array
{
$ids = array_filter(
array_unique($projectIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('p');
$qb
->where($qb->expr()->in('p.id', ':id'))
->setParameter('id', $projectIds)
->setParameter('id', $ids)
;
$projects = $qb->getQuery()->getResult();
$loader = new ProjectLoader($qb->getEntityManager(), true);
$loader->loadResults($projects);
return $projects;
return $this->getProjects($this->prepareProjectQuery($qb->getQuery()));
}
/**
* @param Project $project
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveProject(Project $project)
public function saveProject(Project $project): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($project);
@@ -71,13 +74,12 @@ class ProjectRepository extends EntityRepository
}
/**
* @param null|bool $visible
* @return int
* @return int<0, max>
*/
public function countProject($visible = null): int
public function countProject(?bool $visible = null): int
{
if (null !== $visible) {
return $this->count(['visible' => (bool) $visible]);
return $this->count(['visible' => $visible]);
}
return $this->count([]);
@@ -91,7 +93,7 @@ class ProjectRepository extends EntityRepository
}
}
public function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
private function getPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): Andx
{
$andX = $qb->expr()->andX();
@@ -140,8 +142,7 @@ class ProjectRepository extends EntityRepository
/**
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
*
* @param ProjectFormTypeQuery $query
* @return QueryBuilder
* @internal
*/
public function getQueryBuilderForFormType(ProjectFormTypeQuery $query): QueryBuilder
{
@@ -213,6 +214,10 @@ class ProjectRepository extends EntityRepository
->leftJoin('p.customer', 'c')
;
if (\count($query->getProjectIds()) > 0) {
$qb->andWhere($qb->expr()->in('p.id', ':id'))->setParameter('id', $query->getProjectIds());
}
foreach ($query->getOrderGroups() as $orderBy => $order) {
switch ($orderBy) {
case 'customer':
@@ -332,6 +337,9 @@ class ProjectRepository extends EntityRepository
return $and;
}
/**
* @return int<0, max>
*/
public function countProjectsForQuery(ProjectQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -342,7 +350,7 @@ class ProjectRepository extends EntityRepository
->select($qb->expr()->countDistinct('p.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
public function getPagerfantaForQuery(ProjectQuery $query): Pagination
@@ -350,34 +358,41 @@ class ProjectRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Project>
*/
private function getPaginatorForQuery(ProjectQuery $projectQuery): PaginatorInterface
{
$counter = $this->countProjectsForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countProjectsForQuery($projectQuery);
$query = $this->createProjectQuery($projectQuery);
return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new ProjectLoader($this->getEntityManager(), false, true), $query, $counter);
}
/**
* @param ProjectQuery $query
* @return Project[]
*/
public function getProjectsForQuery(ProjectQuery $query): iterable
public function getProjectsForQuery(ProjectQuery $query): array
{
$qb = $this->getQueryBuilderForQuery($query);
$results = $qb->getQuery()->execute();
$loader = new ProjectLoader($qb->getEntityManager());
$loader->loadResults($results);
return $results;
return $this->getProjects($this->createProjectQuery($query));
}
/**
* @param Project $delete
* @param Project|null $replace
* @throws \Doctrine\ORM\Exception\ORMException
* @param Query<Project> $query
* @return Project[]
*/
public function deleteProject(Project $delete, ?Project $replace = null)
public function getProjects(Query $query): array
{
/** @var array<Project> $projects */
$projects = $query->execute();
$loader = new ProjectLoader($this->getEntityManager(), false, true);
$loader->loadResults($projects);
return $projects;
}
public function deleteProject(Project $delete, ?Project $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -429,17 +444,58 @@ class ProjectRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
public function saveComment(ProjectComment $comment)
public function saveComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($comment);
$entityManager->flush();
}
public function deleteComment(ProjectComment $comment)
public function deleteComment(ProjectComment $comment): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($comment);
$entityManager->flush();
}
/**
* @return Query<Project>
*/
private function createProjectQuery(ProjectQuery $projectQuery): Query
{
$query = $this->getQueryBuilderForQuery($projectQuery)->getQuery();
$query = $this->prepareProjectQuery($query);
foreach ($projectQuery->getHydrate() as $hydrate) {
switch ($hydrate) {
case ProjectQueryHydrate::TEAMS:
// does not yet work, see https://github.com/doctrine/orm/pull/8391
// $query->setFetchMode(Project::class, 'teams', ClassMetadata::FETCH_EAGER);
break;
case ProjectQueryHydrate::TEAM_MEMBER:
// does not yet work, see https://github.com/doctrine/orm/issues/11254
// $query->setFetchMode(Project::class, 'teams', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(TeamMember::class, 'user', ClassMetadata::FETCH_EAGER);
break;
}
}
return $query;
}
/**
* @param Query<Project> $query
* @return Query<Project>
*/
public function prepareProjectQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Project::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -14,8 +14,11 @@ use App\Entity\Project;
/**
* Can be used for advanced queries with the: ActivityRepository
*/
class ActivityQuery extends ProjectQuery
class ActivityQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
use CustomerTrait;
public const ACTIVITY_ORDER_ALLOWED = [
'name',
'description' => 'comment',
@@ -33,18 +36,45 @@ class ActivityQuery extends ProjectQuery
private array $projects = [];
private bool $globalsOnly = false;
private bool $excludeGlobals = false;
/**
* @var array<int>
*/
private array $activityIds = [];
/**
* @var array<ActivityQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
parent::__construct();
$this->setDefaults([
'orderBy' => 'name',
'customers' => [],
'projects' => [],
'globalsOnly' => false,
'excludeGlobals' => false,
'activityIds' => [],
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if (method_exists($query, 'getCustomers')) {
$this->setCustomers($query->getCustomers());
}
if ($query instanceof ActivityQuery) {
$this->setActivityIds($query->getActivityIds());
$this->setGlobalsOnly($query->isGlobalsOnly());
$this->setExcludeGlobals($query->isExcludeGlobals());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function isGlobalsOnly(): bool
{
return $this->globalsOnly;
@@ -115,4 +145,40 @@ class ActivityQuery extends ProjectQuery
{
return !empty($this->projects);
}
/**
* @param array<int> $ids
*/
public function setActivityIds(array $ids): void
{
$this->activityIds = $ids;
}
/**
* @return int[]
*/
public function getActivityIds(): array
{
return $this->activityIds;
}
private function addHydrate(ActivityQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return ActivityQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(ActivityQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum ActivityQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -316,7 +316,13 @@ class BaseQuery
return array_pop($shortClass);
}
public function copyTo(BaseQuery $query): BaseQuery
/**
* @template T of BaseQuery
* @param T $query
* @return T
* @internal
*/
final public function copyTo(BaseQuery $query): BaseQuery
{
$query->setDefaults($this->defaults);
if (null !== $this->getCurrentUser()) {
@@ -336,9 +342,15 @@ class BaseQuery
$query->setVisibility($this->getVisibility());
}
$query->copyFrom($this);
return $query;
}
protected function copyFrom(BaseQuery $query): void
{
}
public function isDefaultFilter(string $filter): bool
{
if (!\array_key_exists($filter, $this->defaults)) {

View File

@@ -14,11 +14,33 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
use VisibilityTrait;
public const CUSTOMER_ORDER_ALLOWED = [
'name', 'description' => 'comment', 'country', 'number', 'homepage', 'email', 'mobile', 'fax',
'phone', 'currency', 'address', 'contact', 'company', 'vat_id', 'budget', 'timeBudget', 'visible'
'name',
'description' => 'comment',
'country', 'number',
'homepage',
'email',
'mobile',
'fax',
'phone',
'currency',
'address',
'contact',
'company',
'vat_id',
'budget',
'timeBudget',
'visible'
];
private ?string $country = null;
/**
* @var array<int>
*/
private array $customerIds = [];
/**
* @var array<CustomerQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
@@ -26,9 +48,23 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
'orderBy' => 'name',
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'country' => null,
'customerIds' => [],
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if ($query instanceof CustomerQuery) {
$this->setCustomerIds($query->getCustomerIds());
$this->setCountry($query->getCountry());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function getCountry(): ?string
{
return $this->country;
@@ -38,4 +74,40 @@ class CustomerQuery extends BaseQuery implements VisibilityInterface
{
$this->country = $country;
}
/**
* @param array<int> $ids
*/
public function setCustomerIds(array $ids): void
{
$this->customerIds = $ids;
}
/**
* @return int[]
*/
public function getCustomerIds(): array
{
return $this->customerIds;
}
private function addHydrate(CustomerQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return CustomerQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(CustomerQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum CustomerQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -0,0 +1,63 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
use App\Entity\Customer;
trait CustomerTrait
{
/**
* @var array<Customer>
*/
private array $customers = [];
public function addCustomer(Customer $customer): self
{
$this->customers[] = $customer;
return $this;
}
/**
* @param array<Customer> $customers
* @return $this
*/
public function setCustomers(array $customers): self
{
$this->customers = $customers;
return $this;
}
/**
* @return array<Customer>
*/
public function getCustomers(): array
{
return $this->customers;
}
/**
* @return array<int>
*/
public function getCustomerIds(): array
{
return array_filter(array_values(array_unique(array_map(function (Customer $customer) {
return $customer->getId();
}, $this->customers))), function ($id) {
return $id !== null;
});
}
public function hasCustomers(): bool
{
return !empty($this->customers);
}
}

View File

@@ -9,11 +9,10 @@
namespace App\Repository\Query;
use App\Entity\Customer;
class ProjectQuery extends BaseQuery implements VisibilityInterface
{
use VisibilityTrait;
use CustomerTrait;
public const PROJECT_ORDER_ALLOWED = [
'name',
@@ -29,13 +28,17 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
'visible'
];
/**
* @var array<Customer>
*/
private array $customers = [];
private ?\DateTime $projectStart = null;
private ?\DateTime $projectEnd = null;
private ?bool $globalActivities = null;
/**
* @var array<int>
*/
private array $projectIds = [];
/**
* @var array<ProjectQueryHydrate>
*/
private array $hydrate = [];
public function __construct()
{
@@ -46,50 +49,27 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
'projectEnd' => null,
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'globalActivities' => null,
'projectIds' => [],
]);
}
public function addCustomer(Customer $customer): self
protected function copyFrom(BaseQuery $query): void
{
$this->customers[] = $customer;
parent::copyFrom($query);
return $this;
}
if (method_exists($query, 'getCustomers')) {
$this->setCustomers($query->getCustomers());
}
/**
* @param array<Customer> $customers
* @return $this
*/
public function setCustomers(array $customers): self
{
$this->customers = $customers;
return $this;
}
/**
* @return array<Customer>
*/
public function getCustomers(): array
{
return $this->customers;
}
/**
* @return array<int>
*/
public function getCustomerIds(): array
{
return array_filter(array_values(array_unique(array_map(function (Customer $customer) {
return $customer->getId();
}, $this->customers))), function ($id) {
return $id !== null;
});
}
public function hasCustomers(): bool
{
return !empty($this->customers);
if ($query instanceof ProjectQuery) {
$this->setProjectIds($query->getProjectIds());
$this->setProjectStart($query->getProjectStart());
$this->setProjectEnd($query->getProjectEnd());
$this->setGlobalActivities($query->getGlobalActivities());
foreach ($query->getHydrate() as $hydrate) {
$this->addHydrate($hydrate);
}
}
}
public function getProjectStart(): ?\DateTime
@@ -125,4 +105,40 @@ class ProjectQuery extends BaseQuery implements VisibilityInterface
{
$this->globalActivities = $globalActivities;
}
/**
* @param array<int> $ids
*/
public function setProjectIds(array $ids): void
{
$this->projectIds = $ids;
}
/**
* @return int[]
*/
public function getProjectIds(): array
{
return $this->projectIds;
}
private function addHydrate(ProjectQueryHydrate $hydrate): void
{
if (!\in_array($hydrate, $this->hydrate, true)) {
$this->hydrate[] = $hydrate;
}
}
/**
* @return ProjectQueryHydrate[]
*/
public function getHydrate(): array
{
return $this->hydrate;
}
public function loadTeams(): void
{
$this->addHydrate(ProjectQueryHydrate::TEAMS);
}
}

View File

@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository\Query;
enum ProjectQueryHydrate
{
case TEAMS;
case TEAM_MEMBER;
}

View File

@@ -59,6 +59,17 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface, DateRan
]);
}
protected function copyFrom(BaseQuery $query): void
{
parent::copyFrom($query);
if ($query instanceof TimesheetQuery) {
foreach ($this->getUsers() as $user) {
$this->addUser($user);
}
}
}
public function getMaxResults(): ?int
{
return $this->maxResults;

View File

@@ -26,6 +26,10 @@ class UserQuery extends BaseQuery implements VisibilityInterface
*/
private array $searchTeams = [];
private ?bool $systemAccount = null;
/**
* @var array<int>
*/
private array $userIds = [];
public function __construct()
{
@@ -34,6 +38,7 @@ class UserQuery extends BaseQuery implements VisibilityInterface
'searchTeams' => [],
'visibility' => VisibilityInterface::SHOW_VISIBLE,
'systemAccount' => null,
'userIds' => [],
]);
}
@@ -74,4 +79,20 @@ class UserQuery extends BaseQuery implements VisibilityInterface
{
$this->systemAccount = $systemAccount;
}
/**
* @param array<int> $ids
*/
public function setUserIds(array $ids): void
{
$this->userIds = $ids;
}
/**
* @return int[]
*/
public function getUserIds(): array
{
return $this->userIds;
}
}

View File

@@ -11,15 +11,19 @@ namespace App\Repository\Result;
use App\Entity\Timesheet;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Query\TimesheetQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @internal
*/
final class TimesheetResult
{
private ?TimesheetResultStatistic $statisticCache = null;
private bool $cachedFullyHydrated = false;
/**
* @var array<Timesheet>|null
*/
@@ -27,16 +31,22 @@ final class TimesheetResult
/**
* @internal
* @param Query<null, Timesheet> $query
*/
public function __construct(private TimesheetQuery $query, private QueryBuilder $queryBuilder)
public function __construct(
private readonly TimesheetQuery $timesheetQuery,
private readonly EntityManagerInterface $entityManager,
private readonly QueryBuilder $statisticQb,
private readonly Query $query
)
{
}
public function getStatistic(): TimesheetResultStatistic
{
if ($this->statisticCache === null) {
$withDuration = $this->query->countFilter() > 0;
$qb = clone $this->queryBuilder;
$withDuration = $this->timesheetQuery->countFilter() > 0;
$qb = clone $this->statisticQb;
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
@@ -47,6 +57,7 @@ final class TimesheetResult
$qb->addSelect('COALESCE(SUM(t.duration), 0) as duration');
}
/** @var array{'duration': int<0, max>, 'counter': int<0, max>} $result */
$result = $qb->getQuery()->getArrayResult()[0];
$duration = $withDuration ? $result['duration'] : 0;
@@ -58,39 +69,34 @@ final class TimesheetResult
public function toIterable(): iterable
{
$query = $this->queryBuilder->getQuery();
return $query->toIterable();
return $this->query->toIterable();
}
/**
* @param bool $fullyHydrated
* @return array<Timesheet>
*/
public function getResults(bool $fullyHydrated = false): array
public function getResults(): array
{
if ($this->resultCache === null || ($fullyHydrated && $this->cachedFullyHydrated === false)) {
$query = $this->queryBuilder->getQuery();
$results = $query->getResult();
if ($this->resultCache === null) {
/** @var array<Timesheet> $results */
$results = $this->query->getResult();
$loader = new TimesheetLoader($this->queryBuilder->getEntityManager(), $fullyHydrated);
$loader = new TimesheetLoader($this->entityManager, true);
$loader->loadResults($results);
$this->cachedFullyHydrated = $fullyHydrated;
$this->resultCache = $results;
}
return $this->resultCache;
}
public function getPagerfanta(bool $fullyHydrated = false): Pagination
public function getPagerfanta(): Pagination
{
$qb = clone $this->queryBuilder;
$loader = new LoaderQueryPaginator(new TimesheetLoader($this->entityManager), $this->query, $this->getStatistic()->getCount());
$loader = new LoaderPaginator(new TimesheetLoader($qb->getEntityManager(), $fullyHydrated), $qb, $this->getStatistic()->getCount());
$paginator = new Pagination($loader);
$paginator->setMaxPerPage($this->query->getPageSize());
$paginator->setCurrentPage($this->query->getPage());
$paginator->setMaxPerPage($this->timesheetQuery->getPageSize());
$paginator->setCurrentPage($this->timesheetQuery->getPage());
return $paginator;
}

View File

@@ -11,10 +11,17 @@ namespace App\Repository\Result;
final class TimesheetResultStatistic
{
public function __construct(private int $count, private int $duration)
/**
* @param int<0, max> $count
* @param int<0, max> $duration
*/
public function __construct(private readonly int $count, private readonly int $duration)
{
}
/**
* @return int<0, max>
*/
public function getCount(): int
{
return $this->count;

View File

@@ -10,7 +10,7 @@
namespace App\Repository;
use App\Entity\Tag;
use App\Repository\Paginator\QueryBuilderPaginator;
use App\Repository\Paginator\QueryPaginator;
use App\Repository\Query\TagFormTypeQuery;
use App\Repository\Query\TagQuery;
use App\Utils\Pagination;
@@ -115,9 +115,10 @@ class TagRepository extends EntityRepository
->resetDQLPart('orderBy')
->select($qb->expr()->count('tag.id'))
;
/** @var int<0, max> $counter */
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$paginator = new QueryBuilderPaginator($qb1, $counter);
$paginator = new QueryPaginator($qb1->getQuery(), $counter);
$pager = new Pagination($paginator);
$pager->setMaxPerPage($query->getPageSize());

View File

@@ -11,83 +11,62 @@ namespace App\Repository;
use App\Entity\Team;
use App\Entity\TeamMember;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Repository\Loader\TeamLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TeamQuery;
use App\Utils\Pagination;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
/**
* @extends \Doctrine\ORM\EntityRepository<Team>
* @extends EntityRepository<Team>
*/
class TeamRepository extends EntityRepository
{
/**
* @return Team[]
*/
public function findAll(): array
{
$result = parent::findAll();
$loader = new TeamLoader($this->getEntityManager());
$loader->loadResults($result);
return $result;
}
/**
* @param int[] $teamIds
* @return Team[]
*/
public function findByIds(array $teamIds): array
{
$ids = array_filter(
array_unique($teamIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('t');
$qb
->where($qb->expr()->in('t.id', ':id'))
->setParameter('id', $teamIds)
->setParameter('id', $ids)
;
$teams = $qb->getQuery()->getResult();
$loader = new TeamLoader($qb->getEntityManager());
$loader->loadResults($teams);
return $teams;
return $this->getTeams($this->prepareTeamQuery($qb->getQuery()));
}
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveTeam(Team $team)
public function saveTeam(Team $team): void
{
$entityManager = $this->getEntityManager();
$entityManager->persist($team);
$entityManager->flush();
}
/**
* @param TeamMember $member
* @throws ORMException
*/
public function removeTeamMember(TeamMember $member)
public function removeTeamMember(TeamMember $member): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($member);
}
/**
* @param Team $team
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function deleteTeam(Team $team)
public function deleteTeam(Team $team): void
{
$entityManager = $this->getEntityManager();
$entityManager->remove($team);
@@ -96,9 +75,6 @@ class TeamRepository extends EntityRepository
/**
* Returns a query builder that is used for TeamType and your own 'query_builder' option.
*
* @param TeamQuery $query
* @return QueryBuilder
*/
public function getQueryBuilderForFormType(TeamQuery $query): QueryBuilder
{
@@ -118,32 +94,46 @@ class TeamRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
protected function getPaginatorForQuery(TeamQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<Team>
*/
private function getPaginatorForQuery(TeamQuery $teamQuery): PaginatorInterface
{
$qb = $this->getQueryBuilderForQuery($query);
$qb = $this->getQueryBuilderForQuery($teamQuery);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('t.id'))
;
/** @var int<0, max> $counter */
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
$query = $this->createTeamQuery($teamQuery);
return new LoaderPaginator(new TeamLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new TeamLoader($qb->getEntityManager()), $query, $counter);
}
/**
* @param TeamQuery $query
* @return Timesheet[]
* @return Team[]
*/
public function getTeamsForQuery(TeamQuery $query): iterable
{
// this is using the paginator internally, as it will load all joined entities into the working unit
// do not "optimize" to use the query directly, as it would results in hundreds of additional lazy queries
$paginator = $this->getPaginatorForQuery($query);
return $this->getTeams($this->createTeamQuery($query));
}
return $paginator->getAll();
/**
* @param Query<Team> $query
* @return Team[]
*/
public function getTeams(Query $query): array
{
/** @var array<Team> $teams */
$teams = $query->execute();
$loader = new TeamLoader($this->getEntityManager());
$loader->loadResults($teams);
return $teams;
}
private function getQueryBuilderForQuery(TeamQuery $query): QueryBuilder
@@ -208,11 +198,9 @@ class TeamRepository extends EntityRepository
}
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
@@ -228,7 +216,7 @@ class TeamRepository extends EntityRepository
// OR we query for all teams where the user is a member - in later case $teams is not empty
$or = $qb->expr()->orX();
// this query should limit to teams where the user is a teamlead (eg. in dropdowns or listing page)
// this query should limit to teams where the user is a teamlead (e.g. in dropdowns or listing page)
if (null !== $user) {
$qb->leftJoin('t.members', 'members');
$or->add(
@@ -255,4 +243,31 @@ class TeamRepository extends EntityRepository
$qb->andWhere($or);
}
}
/**
* @return Query<Team>
*/
private function createTeamQuery(TeamQuery $teamQuery): Query
{
$query = $this->getQueryBuilderForQuery($teamQuery)->getQuery();
$query = $this->prepareTeamQuery($query);
return $query;
}
/**
* @param Query<Team> $query
* @return Query<Team>
*/
public function prepareTeamQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
// $query->setFetchMode(Team::class, 'members', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'customers', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'projects', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Team::class, 'activities', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -21,7 +21,7 @@ use App\Entity\User;
use App\Model\Revenue;
use App\Model\TimesheetStatistic;
use App\Repository\Loader\TimesheetLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use App\Repository\Result\TimesheetResult;
@@ -30,13 +30,15 @@ use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
* @extends EntityRepository<Timesheet>
*/
class TimesheetRepository extends EntityRepository
{
@@ -230,7 +232,6 @@ class TimesheetRepository extends EntityRepository
/**
* @param string|string[] $select
* @return int|mixed
* @throws \Doctrine\ORM\NonUniqueResultException
*/
private function queryTimeRange(string|array $select, ?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?User $user, ?bool $billable = null): mixed
{
@@ -346,9 +347,12 @@ class TimesheetRepository extends EntityRepository
return $qb->getQuery()->getResult();
}
return $this->getHydratedResultsByQuery($qb, false);
return $this->getHydratedResultsByQuery($qb);
}
/**
* @return int<0, max>
*/
public function countActiveEntries(?User $user = null): int
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -366,9 +370,12 @@ class TimesheetRepository extends EntityRepository
;
}
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
/**
* @return int<0, max>
*/
public function countActiveUsers(?\DateTimeInterface $begin, ?\DateTimeInterface $end, ?bool $billable = null): int
{
$tmp = $this->queryTimeRange('COUNT(DISTINCT(t.user))', $begin, $end, null, $billable);
@@ -377,7 +384,7 @@ class TimesheetRepository extends EntityRepository
return 0;
}
return (int) $tmp;
return (int) $tmp; // @phpstan-ignore-line
}
/**
@@ -442,7 +449,10 @@ class TimesheetRepository extends EntityRepository
return new Pagination($this->getPaginatorForQuery($query), $query);
}
private function getPaginatorForQuery(TimesheetQuery $query): PaginatorInterface
/**
* @return int<0, max>
*/
private function countTimesheetsForQuery(TimesheetQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -450,50 +460,60 @@ class TimesheetRepository extends EntityRepository
->resetDQLPart('orderBy')
->select($qb->expr()->count('t.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query);
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
return new LoaderPaginator(new TimesheetLoader($qb->getEntityManager()), $qb, $counter);
/**
* @return PaginatorInterface<Timesheet>
*/
private function getPaginatorForQuery(TimesheetQuery $timesheetQuery): PaginatorInterface
{
$counter = $this->countTimesheetsForQuery($timesheetQuery);
$query = $this->createTimesheetQuery($timesheetQuery);
return new LoaderQueryPaginator(new TimesheetLoader($this->getEntityManager()), $query, $counter);
}
/**
* When switching $fullyHydrated to true, the call gets even more expensive.
* You normally don't need this, unless you want to access deeply nested attributes for many entries.
*
* @param TimesheetQuery $query
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return Timesheet[]
*/
public function getTimesheetsForQuery(TimesheetQuery $query, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
public function getTimesheetsForQuery(TimesheetQuery $query, bool $fullyHydrated = false): array
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated);
}
public function getTimesheetResult(TimesheetQuery $query): TimesheetResult
{
$qb = $this->getQueryBuilderForQuery($query);
return new TimesheetResult($query, $qb);
return new TimesheetResult(
$query,
$this->getEntityManager(),
$this->getQueryBuilderForQuery($query),
$this->createTimesheetQuery($query)
);
}
/**
* @param QueryBuilder $qb
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return Timesheet[]
*/
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false, bool $basicHydrated = true): iterable
private function getHydratedResultsByQuery(QueryBuilder $qb, bool $fullyHydrated = false): array
{
$results = $qb->getQuery()->getResult();
/** @var Query<Timesheet> $query */
$query = $qb->getQuery();
$query = $this->prepareTimesheetQuery($query);
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated, $basicHydrated);
$loader->loadResults($results);
/** @var array<Timesheet> $timesheets */
$timesheets = $query->getResult();
return $results;
$loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated);
$loader->loadResults($timesheets);
return $timesheets;
}
private function getQueryBuilderForQuery(TimesheetQuery $query): QueryBuilder
@@ -660,12 +680,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param DateTime|null $startFrom
* @param int $limit
* @return Timesheet[]
*/
public function getRecentActivities(User $user, DateTime $startFrom = null, int $limit = 10): array
public function getRecentActivities(User $user, ?\DateTimeInterface $startFrom = null, int $limit = 10): array
{
return $this->findTimesheetsById(
$user,
@@ -674,12 +691,9 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param DateTime|null $startFrom
* @param int $limit
* @return array<int>
*/
public function getRecentActivityIds(User $user, DateTime $startFrom = null, int $limit = 10): array
public function getRecentActivityIds(User $user, ?\DateTimeInterface $startFrom = null, int $limit = 10): array
{
$qb = $this->getEntityManager()->createQueryBuilder();
@@ -707,7 +721,7 @@ class TimesheetRepository extends EntityRepository
if (null !== $startFrom) {
$qb->andWhere($qb->expr()->gte('t.begin', ':begin'))
->setParameter('begin', $startFrom);
->setParameter('begin', \DateTimeImmutable::createFromInterface($startFrom), Types::DATETIME_IMMUTABLE);
}
$qb->join('t.project', 'p');
@@ -725,13 +739,10 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param User $user
* @param array<int> $ids
* @param bool $fullyHydrated
* @param bool $basicHydrated
* @return array<Timesheet>
*/
public function findTimesheetsById(User $user, array $ids, bool $fullyHydrated = false, bool $basicHydrated = true): array
public function findTimesheetsById(User $user, array $ids): array
{
if (\count($ids) === 0) {
return [];
@@ -749,13 +760,13 @@ class TimesheetRepository extends EntityRepository
$this->addPermissionCriteria($qb, $user);
return $this->getHydratedResultsByQuery($qb, $fullyHydrated, $basicHydrated);
return $this->getHydratedResultsByQuery($qb);
}
/**
* @param Timesheet[]|int[] $timesheets
*/
public function setExported(array $timesheets)
public function setExported(array $timesheets): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -896,4 +907,40 @@ class TimesheetRepository extends EntityRepository
return $result > 0;
}
/**
* @return Query<Timesheet>
*/
private function createTimesheetQuery(TimesheetQuery $timesheetQuery): Query
{
$query = $this->getQueryBuilderForQuery($timesheetQuery)->getQuery();
$query = $this->prepareTimesheetQuery($query);
return $query;
}
/**
* @param Query<Timesheet> $query
* @return Query<Timesheet>
*/
public function prepareTimesheetQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
$query->setFetchMode(Timesheet::class, 'meta', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'activity', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'project', ClassMetadata::FETCH_EAGER);
$query->setFetchMode(Timesheet::class, 'user', ClassMetadata::FETCH_EAGER);
// not yet supported by Doctrine
// $query->setFetchMode(Activity::class, 'meta', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'customer', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Project::class, 'meta', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(Customer::class, 'meta', ClassMetadata::FETCH_EAGER);
// ManyToMany not supported by Doctrine yet
// $query->setFetchMode(Timesheet::class, 'tags', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -15,15 +15,16 @@ use App\Entity\Timesheet;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\Loader\UserLoader;
use App\Repository\Paginator\LoaderPaginator;
use App\Repository\Paginator\LoaderQueryPaginator;
use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\UserFormTypeQuery;
use App\Repository\Query\UserQuery;
use App\Repository\Query\VisibilityInterface;
use App\Utils\Pagination;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Exception\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bridge\Doctrine\Security\User\UserLoaderInterface;
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
@@ -33,7 +34,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
/**
* @extends \Doctrine\ORM\EntityRepository<User>
* @extends EntityRepository<User>
* @template-implements PasswordUpgraderInterface<User>
* @template-implements UserProviderInterface<User>
*/
@@ -48,11 +49,6 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
}
/**
* @param User $user
* @throws ORMException
* @throws \Doctrine\ORM\OptimisticLockException
*/
public function saveUser(User $user): void
{
$entityManager = $this->getEntityManager();
@@ -78,23 +74,15 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
}
/**
* Used to fetch a user by its ID.
*
* @param int $id
* @return null|User
*/
public function getUserById($id): ?User
public function getUserById(int $id): ?User
{
/** @var User|null $user */
$user = $this->find($id);
$users = $this->findByIds([$id]);
if ($user !== null) {
$loader = new UserLoader($this->getEntityManager(), true);
$loader->loadResults([$user]);
if (\count($users) === 1) {
return $users[0];
}
return $user;
return null;
}
/**
@@ -103,18 +91,24 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function findByIds(array $userIds): array
{
$ids = array_filter(
array_unique($userIds),
function ($value) {
return $value > 0;
}
);
if (\count($ids) === 0) {
return [];
}
$qb = $this->createQueryBuilder('u');
$qb
->where($qb->expr()->in('u.id', ':id'))
->setParameter('id', $userIds)
->setParameter('id', $ids)
;
$users = $qb->getQuery()->getResult();
$loader = new UserLoader($qb->getEntityManager(), true);
$loader->loadResults($users);
return $users;
return $this->getUsers($this->prepareUserQuery($qb->getQuery()));
}
/**
@@ -135,6 +129,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
return parent::findOneBy(['username' => $username]);
}
/**
* @return int<0, max>
*/
public function countUser(?bool $enabled = null): int
{
if (null !== $enabled) {
@@ -200,11 +197,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
/**
* @param QueryBuilder $qb
* @param User|null $user
* @param Team[] $teams
*/
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
{
// make sure that all queries without a user see all user
if (null === $user && empty($teams)) {
@@ -259,25 +254,16 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
}
/**
* @param string $role
* @return User[]
* @internal
*/
public function findUsersWithRole(string $role): array
{
if ($role === User::ROLE_USER) {
return $this->findAll();
}
$query = new UserQuery();
$query->setRole($role);
$query->setVisibility(VisibilityInterface::SHOW_BOTH);
$qb = $this->getEntityManager()->createQueryBuilder();
$qb
->select('u')
->from(User::class, 'u')
->andWhere('u.roles LIKE :role');
$qb->setParameter('role', '%' . $role . '%');
return $qb->getQuery()->getResult();
return $this->getUsersForQuery($query);
}
private function getQueryBuilderForQuery(UserQuery $query): QueryBuilder
@@ -377,6 +363,9 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
return new Pagination($this->getPaginatorForQuery($query), $query);
}
/**
* @return int<0, max>
*/
public function countUsersForQuery(UserQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
@@ -386,15 +375,18 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
->select($qb->expr()->countDistinct('u.id'))
;
return (int) $qb->getQuery()->getSingleScalarResult();
return (int) $qb->getQuery()->getSingleScalarResult(); // @phpstan-ignore-line
}
protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface
/**
* @return PaginatorInterface<User>
*/
private function getPaginatorForQuery(UserQuery $userQuery): PaginatorInterface
{
$counter = $this->countUsersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
$counter = $this->countUsersForQuery($userQuery);
$query = $this->createUserQuery($userQuery);
return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter);
return new LoaderQueryPaginator(new UserLoader($this->getEntityManager()), $query, $counter);
}
/**
@@ -403,27 +395,25 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
*/
public function getUsersForQuery(UserQuery $query): array
{
$qb = $this->getQueryBuilderForQuery($query);
return $this->getHydratedResultsByQuery($qb);
return $this->getUsers($this->createUserQuery($query));
}
/**
* @param QueryBuilder $qb
* @param Query<User> $query
* @return User[]
*/
protected function getHydratedResultsByQuery(QueryBuilder $qb): array
public function getUsers(Query $query): array
{
/** @var array<User> $results */
$results = $qb->getQuery()->getResult();
/** @var array<User> $users */
$users = $query->execute();
$loader = new UserLoader($qb->getEntityManager());
$loader->loadResults($results);
$loader = new UserLoader($this->getEntityManager());
$loader->loadResults($users);
return $results;
return $users;
}
public function deleteUser(User $delete, ?User $replace = null)
public function deleteUser(User $delete, ?User $replace = null): void
{
$em = $this->getEntityManager();
$em->beginTransaction();
@@ -454,9 +444,35 @@ class UserRepository extends EntityRepository implements UserLoaderInterface, Us
$em->remove($delete);
$em->flush();
$em->commit();
} catch (ORMException $ex) {
} catch (\Exception $ex) {
$em->rollback();
throw $ex;
}
}
/**
* @return Query<User>
*/
private function createUserQuery(UserQuery $userQuery): Query
{
$query = $this->getQueryBuilderForQuery($userQuery)->getQuery();
$query = $this->prepareUserQuery($query);
return $query;
}
/**
* @param Query<User> $query
* @return Query<User>
*/
public function prepareUserQuery(Query $query): Query
{
$this->getEntityManager()->getConfiguration()->setEagerFetchBatchSize(300);
// $query->setFetchMode(User::class, 'preferences', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(User::class, 'supervisor', ClassMetadata::FETCH_EAGER);
// $query->setFetchMode(User::class, 'memberships', ClassMetadata::FETCH_EAGER);
return $query;
}
}

View File

@@ -51,7 +51,7 @@ final class FavoriteRecordService
$all = [];
if (\count($ids) > 0) {
$timesheets = $this->repository->findTimesheetsById($user, $ids, false, false);
$timesheets = $this->repository->findTimesheetsById($user, $ids);
foreach ($timesheets as $timesheet) {
$id = $timesheet->getId();
if ($id === null) {

View File

@@ -29,7 +29,10 @@ final class DataTable implements \Countable, \IteratorAggregate
private bool $sticky = true;
private ?string $paginationRoute = null;
public function __construct(private string $tableName, private BaseQuery $query)
public function __construct(
private readonly string $tableName,
private readonly BaseQuery $query
)
{
}

View File

@@ -9,17 +9,17 @@
namespace App\Widget\Type;
use App\Entity\Project;
use App\Entity\Team;
use App\Project\ProjectStatisticService;
use App\Repository\Loader\ProjectLoader;
use App\Repository\Loader\TeamLoader;
use App\Widget\WidgetInterface;
use Doctrine\ORM\EntityManagerInterface;
final class UserTeamProjects extends AbstractWidget
{
public function __construct(private ProjectStatisticService $statisticService, private EntityManagerInterface $entityManager)
public function __construct(
private readonly ProjectStatisticService $statisticService,
private readonly EntityManagerInterface $entityManager
)
{
}
@@ -65,34 +65,23 @@ final class UserTeamProjects extends AbstractWidget
public function getData(array $options = []): mixed
{
$user = $this->getUser();
$teams = $user->getTeams();
$now = new \DateTime('now', new \DateTimeZone($user->getTimezone()));
$loader = new TeamLoader($this->entityManager);
$loader->loadResults($user->getTeams());
$loader = new TeamLoader($this->entityManager, true);
$loader->loadResults($teams);
$teamProjects = [];
$projects = [];
/** @var Team $team */
foreach ($user->getTeams() as $team) {
/** @var Project $project */
foreach ($teams as $team) {
foreach ($team->getProjects() as $project) {
if (!isset($teamProjects[$project->getId()])) {
$teamProjects[$project->getId()] = $project;
if (!$project->isVisibleAtDate($now) || !$project->hasBudgets()) {
continue;
}
$projects[$project->getId()] = $project;
}
}
$loader = new ProjectLoader($this->entityManager, false, false, false);
$loader->loadResults($teamProjects);
foreach ($teamProjects as $id => $project) {
if (!$project->isVisibleAtDate($now) || !$project->hasBudgets()) {
continue;
}
$projects[$project->getId()] = $project;
}
return $this->statisticService->getBudgetStatisticModelForProjects($projects, $now);
}
}

View File

@@ -9,16 +9,10 @@
namespace App\Widget\Type;
use App\Repository\Loader\UserLoader;
use App\Widget\WidgetInterface;
use Doctrine\ORM\EntityManagerInterface;
final class UserTeams extends AbstractWidget
{
public function __construct(private EntityManagerInterface $entityManager)
{
}
public function getWidth(): int
{
return WidgetInterface::WIDTH_HALF;
@@ -57,12 +51,6 @@ final class UserTeams extends AbstractWidget
*/
public function getData(array $options = []): mixed
{
$user = $this->getUser();
// without this, every user would be lazy loaded
$loader = new UserLoader($this->entityManager, true);
$loader->loadResults([$user->getId()]);
return $user->getTeams();
return $this->getUser()->getTeams();
}
}

View File

@@ -18,7 +18,7 @@ use App\Tests\DataFixtures\TimesheetFixtures;
*/
abstract class AbstractUserPeriodControllerTest extends ControllerBaseTest
{
protected function importReportingFixture(string $role)
protected function importReportingFixture(string $role): void
{
$fixture = new TimesheetFixtures();
$fixture->setAmount(50);

View File

@@ -356,16 +356,10 @@ final class TimesheetFixtures implements TestFixture
}
/**
* @param User $user
* @param Activity $activity
* @param Project $project
* @param string $description
* @param \DateTime $start
* @param null|array $tagArray
* @param bool $setEndDate
* @return Timesheet
* @param array<Tag> $tagArray
*/
private function createTimesheetEntry(User $user, Activity $activity, Project $project, $description, \DateTime $start, $tagArray = [], $setEndDate = true)
private function createTimesheetEntry(User $user, Activity $activity, Project $project, ?string $description, \DateTime $start, array $tagArray = [], bool $setEndDate = true): Timesheet
{
$end = clone $start;
$end = $end->modify('+ ' . (rand(1, 86400)) . ' seconds');

View File

@@ -10,6 +10,7 @@
namespace App\Tests\Repository\Loader;
use App\Entity\Activity;
use App\Entity\Project;
use App\Repository\Loader\ActivityLoader;
/**
@@ -19,12 +20,15 @@ class ActivityLoaderTest extends AbstractLoaderTest
{
public function testLoadResults(): void
{
$em = $this->getEntityManagerMock(3);
$sut = new ActivityLoader($em);
$project = $this->createMock(Project::class);
$project->expects($this->once())->method('getId')->willReturn(13);
$entity = $this->createMock(Activity::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$entity->expects($this->exactly(3))->method('getProject')->willReturn($project);
$em = $this->getEntityManagerMock(3);
$sut = new ActivityLoader($em);
$sut->loadResults([$entity]);
}

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Repository\Loader;
use App\Entity\Customer;
use App\Repository\Loader\CustomerLoader;
use App\Repository\Query\CustomerQuery;
/**
* @covers \App\Repository\Loader\CustomerLoader
@@ -19,13 +20,15 @@ class CustomerLoaderTest extends AbstractLoaderTest
{
public function testLoadResults(): void
{
$em = $this->getEntityManagerMock(2);
$em = $this->getEntityManagerMock(1);
$sut = new CustomerLoader($em);
$query = new CustomerQuery();
$query->loadTeams();
$sut = new CustomerLoader($em, $query);
$entity = $this->createMock(Customer::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity, 4711]);
$sut->loadResults([$entity]);
}
}

View File

@@ -1,29 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Repository\Loader;
use App\Repository\Loader\DefaultLoader;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Repository\Loader\DefaultLoader
*/
class DefaultLoaderTest extends TestCase
{
public function testLoadResults(): void
{
$sut = new DefaultLoader();
$input = [];
$sut->loadResults($input);
self::assertEquals([], $input);
}
}

View File

@@ -1,31 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Repository\Loader;
use App\Entity\Invoice;
use App\Repository\Loader\InvoiceLoader;
/**
* @covers \App\Repository\Loader\InvoiceLoader
*/
class InvoiceLoaderTest extends AbstractLoaderTest
{
public function testLoadResults(): void
{
$em = $this->getEntityManagerMock(3);
$sut = new InvoiceLoader($em);
$entity = $this->createMock(Invoice::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$sut->loadResults([$entity]);
}
}

View File

@@ -25,13 +25,13 @@ class ProjectLoaderTest extends AbstractLoaderTest
$entity = $this->createMock(Project::class);
$entity->expects($this->once())->method('getId')->willReturn(1);
$entity->expects($this->once())->method('getCustomer')->willReturn($customer);
$entity->expects($this->exactly(2))->method('getCustomer')->willReturn($customer);
$results = [$entity];
$em = $this->getEntityManagerMock(4, $results);
$em = $this->getEntityManagerMock(2, $results);
$sut = new ProjectLoader($em);
$sut->loadResults([$entity, 4711]);
$sut->loadResults([$entity]);
}
}

View File

@@ -1,30 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Repository\Paginator;
use App\Repository\Paginator\QueryBuilderPaginator;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\QueryBuilder;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Repository\Paginator\QueryBuilderPaginator
*/
class QueryBuilderPaginatorTest extends TestCase
{
public function testPaginator(): void
{
$em = $this->createMock(EntityManager::class);
$qb = new QueryBuilder($em);
$sut = new QueryBuilderPaginator($qb, 10);
self::assertEquals(10, $sut->getNbResults());
}
}

View File

@@ -18,7 +18,6 @@ use App\Form\Model\DateRange;
use App\Repository\Query\ActivityQuery;
use App\Repository\Query\BaseQuery;
use App\Repository\Query\DateRangeInterface;
use App\Repository\Query\ProjectQuery;
use App\Repository\Query\TimesheetQuery;
use App\Utils\SearchTerm;
use PHPUnit\Framework\TestCase;
@@ -251,8 +250,14 @@ class BaseQueryTest extends TestCase
$this->assertEquals([13, 27], $sut->getActivityIds());
}
protected function assertCustomer(ProjectQuery $sut): void
protected function assertCustomer(BaseQuery $sut): void
{
$this->assertTrue(method_exists($sut, 'getCustomers'));
$this->assertTrue(method_exists($sut, 'setCustomers'));
$this->assertTrue(method_exists($sut, 'hasCustomers'));
$this->assertTrue(method_exists($sut, 'addCustomer'));
$this->assertTrue(method_exists($sut, 'getCustomerIds'));
$this->assertEquals([], $sut->getCustomers());
$this->assertFalse($sut->hasCustomers());

View File

@@ -35,7 +35,6 @@ class AmountYearTest extends AbstractWidgetTypeTest
public function createSut(): AbstractWidgetType
{
$repository = $this->createMock(TimesheetRepository::class);
$repository->method('getStatistic')->willReturn([]);
$configuration = SystemConfigurationFactory::createStub();
$dispatcher = $this->createMock(EventDispatcherInterface::class);

View File

@@ -35,7 +35,6 @@ class UserAmountYearTest extends AbstractWidgetTypeTest
public function createSut(): AbstractWidgetType
{
$repository = $this->createMock(TimesheetRepository::class);
$repository->method('getStatistic')->willReturn([]);
$configuration = SystemConfigurationFactory::createStub();
$dispatcher = $this->createMock(EventDispatcherInterface::class);

View File

@@ -1218,11 +1218,6 @@ parameters:
count: 1
path: Controller/Reporting/AbstractUserPeriodControllerTest.php
-
message: "#^Method App\\\\Tests\\\\Controller\\\\Reporting\\\\AbstractUserPeriodControllerTest\\:\\:importReportingFixture\\(\\) has no return type specified\\.$#"
count: 1
path: Controller/Reporting/AbstractUserPeriodControllerTest.php
-
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
count: 2
@@ -1468,21 +1463,6 @@ parameters:
count: 1
path: DataFixtures/TestFixture.php
-
message: "#^Method App\\\\Tests\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) has parameter \\$tagArray with no value type specified in iterable type array\\.$#"
count: 1
path: DataFixtures/TimesheetFixtures.php
-
message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, array\\|null given\\.$#"
count: 1
path: DataFixtures/TimesheetFixtures.php
-
message: "#^Parameter \\#4 \\$description of method App\\\\Tests\\\\DataFixtures\\\\TimesheetFixtures\\:\\:createTimesheetEntry\\(\\) expects string, string\\|null given\\.$#"
count: 1
path: DataFixtures/TimesheetFixtures.php
-
message: "#^Cannot access offset 'foo\\-bundle\\.bar' on array\\|bool\\|float\\|int\\|string\\|null\\.$#"
count: 1
@@ -1598,11 +1578,6 @@ parameters:
count: 1
path: Entity/ProjectTest.php
-
message: "#^Cannot access offset 0 on iterable\\<App\\\\Entity\\\\Team\\>\\.$#"
count: 1
path: Entity/TeamTest.php
-
message: "#^Cannot call method getUser\\(\\) on mixed\\.$#"
count: 1
@@ -1633,11 +1608,6 @@ parameters:
count: 1
path: Entity/TimesheetValidationTest.php
-
message: "#^Cannot access offset 0 on iterable\\<App\\\\Entity\\\\Team\\>\\.$#"
count: 1
path: Entity/UserTest.php
-
message: "#^Parameter \\#1 \\$string of function mb_strlen expects string, string\\|null given\\.$#"
count: 5