From 9d933f62c024b44ce694b05f7b3ea7e84e3d9916 Mon Sep 17 00:00:00 2001 From: Kevin Papst Date: Tue, 27 Aug 2024 10:11:19 +0200 Subject: [PATCH] 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 --- composer.lock | 251 +++++++------ config/packages/monolog.yaml | 6 - phpstan.neon | 355 ------------------ public/index.php | 3 + src/API/ActivityController.php | 1 + src/API/CustomerController.php | 1 + src/API/ProjectController.php | 1 + src/API/TeamController.php | 6 +- src/Command/ExportCreateCommand.php | 27 +- src/Controller/ActivityController.php | 11 +- src/Controller/CustomerController.php | 9 +- src/Controller/ProjectController.php | 1 + src/Controller/QuickEntryController.php | 2 +- .../Reporting/UserYearController.php | 6 - .../TimesheetAbstractController.php | 20 +- src/Entity/Customer.php | 2 +- src/Entity/Project.php | 2 +- src/Entity/Tag.php | 4 + src/Entity/Team.php | 6 +- src/Entity/User.php | 8 +- src/Event/CustomerMetaDisplayEvent.php | 2 - .../Actions/InvoiceSubscriber.php | 7 +- .../Base/AbstractSpreadsheetRenderer.php | 10 +- src/Export/Base/HtmlRenderer.php | 11 +- src/Form/Helper/ActivityHelper.php | 4 +- src/Form/Helper/CustomerHelper.php | 4 +- src/Form/Helper/ProjectHelper.php | 4 +- src/Form/Type/ProjectType.php | 5 +- src/Model/BudgetStatisticModel.php | 6 +- src/Project/ProjectStatisticService.php | 37 +- src/Repository/ActivityRepository.php | 145 ++++--- src/Repository/CustomerRepository.php | 126 +++++-- src/Repository/InvoiceRepository.php | 43 ++- src/Repository/InvoiceTemplateRepository.php | 20 +- src/Repository/Loader/ActivityLoader.php | 115 ++---- src/Repository/Loader/CustomerLoader.php | 61 +-- src/Repository/Loader/DefaultLoader.php | 4 + src/Repository/Loader/InvoiceLoader.php | 65 ---- src/Repository/Loader/LoaderInterface.php | 5 +- src/Repository/Loader/ProjectLoader.php | 61 ++- src/Repository/Loader/TeamLoader.php | 55 ++- src/Repository/Loader/TimesheetLoader.php | 88 ++--- src/Repository/Loader/UserLoader.php | 47 +-- src/Repository/Paginator/LoaderPaginator.php | 42 ++- .../Paginator/LoaderQueryPaginator.php | 73 ++++ .../Paginator/PaginatorInterface.php | 5 +- .../Paginator/QueryBuilderPaginator.php | 26 +- src/Repository/Paginator/QueryPaginator.php | 64 ++++ src/Repository/ProjectRepository.php | 138 +++++-- src/Repository/Query/ActivityQuery.php | 70 +++- src/Repository/Query/ActivityQueryHydrate.php | 16 + src/Repository/Query/BaseQuery.php | 14 +- src/Repository/Query/CustomerQuery.php | 76 +++- src/Repository/Query/CustomerQueryHydrate.php | 16 + src/Repository/Query/CustomerTrait.php | 63 ++++ src/Repository/Query/ProjectQuery.php | 104 ++--- src/Repository/Query/ProjectQueryHydrate.php | 16 + src/Repository/Query/TimesheetQuery.php | 11 + src/Repository/Query/UserQuery.php | 21 ++ src/Repository/Result/TimesheetResult.php | 46 ++- .../Result/TimesheetResultStatistic.php | 9 +- src/Repository/TagRepository.php | 5 +- src/Repository/TeamRepository.php | 131 ++++--- src/Repository/TimesheetRepository.php | 129 +++++-- src/Repository/UserRepository.php | 138 ++++--- src/Timesheet/FavoriteRecordService.php | 2 +- src/Utils/DataTable.php | 5 +- src/Widget/Type/UserTeamProjects.php | 33 +- src/Widget/Type/UserTeams.php | 14 +- .../AbstractUserPeriodControllerTest.php | 2 +- tests/DataFixtures/TimesheetFixtures.php | 10 +- .../Repository/Loader/ActivityLoaderTest.php | 10 +- .../Repository/Loader/CustomerLoaderTest.php | 11 +- tests/Repository/Loader/DefaultLoaderTest.php | 29 -- tests/Repository/Loader/InvoiceLoaderTest.php | 31 -- tests/Repository/Loader/ProjectLoaderTest.php | 6 +- .../Paginator/QueryBuilderPaginatorTest.php | 30 -- tests/Repository/Query/BaseQueryTest.php | 9 +- tests/Widget/Type/AmountYearTest.php | 1 - tests/Widget/Type/UserAmountYearTest.php | 1 - tests/phpstan.neon | 30 -- 81 files changed, 1648 insertions(+), 1436 deletions(-) delete mode 100644 src/Repository/Loader/InvoiceLoader.php create mode 100644 src/Repository/Paginator/LoaderQueryPaginator.php create mode 100644 src/Repository/Paginator/QueryPaginator.php create mode 100644 src/Repository/Query/ActivityQueryHydrate.php create mode 100644 src/Repository/Query/CustomerQueryHydrate.php create mode 100644 src/Repository/Query/CustomerTrait.php create mode 100644 src/Repository/Query/ProjectQueryHydrate.php delete mode 100644 tests/Repository/Loader/DefaultLoaderTest.php delete mode 100644 tests/Repository/Loader/InvoiceLoaderTest.php delete mode 100644 tests/Repository/Paginator/QueryBuilderPaginatorTest.php diff --git a/composer.lock b/composer.lock index 803a9422..216b4e9b 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/config/packages/monolog.yaml b/config/packages/monolog.yaml index 062ec877..37922ab4 100644 --- a/config/packages/monolog.yaml +++ b/config/packages/monolog.yaml @@ -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: diff --git a/phpstan.neon b/phpstan.neon index 87745b7a..5962a725 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -283,11 +283,6 @@ parameters: count: 2 path: src/Command/ExportCreateCommand.php - - - message: "#^Parameter \\#1 \\$customerIDs of method App\\\\Repository\\\\CustomerRepository\\:\\:findByIds\\(\\) expects array\\, 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\\, 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\\, 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\\, 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\\ 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\\ 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\\ 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\\ but returns array\\\\.$#" - 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\\\\|null, iterable\\ 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\\ 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\\ 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\\ 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\\\\) 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\\\\) 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\\\\) 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\\\\) 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\\\\) 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\\\\) 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\\\\) 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\\ 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\\, mixed given\\.$#" - count: 1 - path: src/Repository/ProjectRepository.php - - - - message: "#^Parameter \\#2 \\.\\.\\.\\$arrays of function array_merge expects array, iterable\\ 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\\ 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\\, 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\\\\|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\\\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(array\\\\) of method Doctrine\\\\Persistence\\\\ObjectRepository\\\\:\\:findAll\\(\\)$#" - count: 1 - path: src/Repository/TeamRepository.php - - - - message: "#^Return type \\(array\\\\) of method App\\\\Repository\\\\TeamRepository\\:\\:findAll\\(\\) should be covariant with return type \\(list\\\\) of method Doctrine\\\\ORM\\\\EntityRepository\\\\:\\:findAll\\(\\)$#" - count: 1 - path: src/Repository/TeamRepository.php - - message: "#^Argument of an invalid type array\\\\|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\\ but returns iterable\\\\.$#" - count: 1 - path: src/Repository/TimesheetRepository.php - - - - message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getActiveEntries\\(\\) should return array\\ but returns iterable\\\\.$#" - count: 1 - path: src/Repository/TimesheetRepository.php - - - - message: "#^Method App\\\\Repository\\\\TimesheetRepository\\:\\:getHydratedResultsByQuery\\(\\) should return iterable\\ 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\\, 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\\ 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\\, iterable\\ given\\.$#" - count: 1 - path: src/Widget/Type/UserTeamProjects.php - - - - message: "#^Parameter \\#1 \\$results of method App\\\\Repository\\\\Loader\\\\UserLoader\\:\\:loadResults\\(\\) expects array\\, array\\ 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 diff --git a/public/index.php b/public/index.php index 9982c218..581f7ea0 100644 --- a/public/index.php +++ b/public/index.php @@ -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']); }; diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index 4d6e2780..d659b8f2 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -68,6 +68,7 @@ final class ActivityController extends BaseApiController $user = $this->getUser(); $query = new ActivityQuery(); + $query->loadTeams(); $query->setCurrentUser($user); $order = $paramFetcher->get('order'); diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index ef839453..947b299c 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -65,6 +65,7 @@ final class CustomerController extends BaseApiController $user = $this->getUser(); $query = new CustomerQuery(); + $query->loadTeams(); $query->setCurrentUser($user); $order = $paramFetcher->get('order'); diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index 80e787da..6ff5a32b 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -74,6 +74,7 @@ final class ProjectController extends BaseApiController $user = $this->getUser(); $query = new ProjectQuery(); + $query->loadTeams(); $query->setCurrentUser($user); $order = $paramFetcher->get('order'); diff --git a/src/API/TeamController.php b/src/API/TeamController.php index d04af22f..cc4783cf 100644 --- a/src/API/TeamController.php +++ b/src/API/TeamController.php @@ -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); diff --git a/src/Command/ExportCreateCommand.php b/src/Command/ExportCreateCommand.php index 5c21ce40..61033327 100644 --- a/src/Command/ExportCreateCommand.php +++ b/src/Command/ExportCreateCommand.php @@ -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 + */ + 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_'); diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php index 40cd30a2..2730b2b9 100644 --- a/src/Controller/ActivityController.php +++ b/src/Controller/ActivityController.php @@ -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 */ private function getToolbarForm(ActivityQuery $query): FormInterface @@ -456,7 +461,7 @@ final class ActivityController extends AbstractController } /** - * @return FormInterface + * @return FormInterface */ private function createEditForm(Activity $activity): FormInterface { diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php index 9b16e8c3..c122f73e 100644 --- a/src/Controller/CustomerController.php +++ b/src/Controller/CustomerController.php @@ -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 + * @return FormInterface */ private function getCommentForm(CustomerComment $comment): FormInterface { diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php index a07fb22e..91f8e5c7 100644 --- a/src/Controller/ProjectController.php +++ b/src/Controller/ProjectController.php @@ -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); diff --git a/src/Controller/QuickEntryController.php b/src/Controller/QuickEntryController.php index 0c510122..384cb032 100644 --- a/src/Controller/QuickEntryController.php +++ b/src/Controller/QuickEntryController.php @@ -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'); diff --git a/src/Controller/Reporting/UserYearController.php b/src/Controller/Reporting/UserYearController.php index ee8a916f..b1b541fd 100644 --- a/src/Controller/Reporting/UserYearController.php +++ b/src/Controller/Reporting/UserYearController.php @@ -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 { diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php index 4711c533..bd141a32 100644 --- a/src/Controller/TimesheetAbstractController.php +++ b/src/Controller/TimesheetAbstractController.php @@ -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 $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 + */ protected function getEditFormClassName(): string { return TimesheetEditForm::class; diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index ed455e01..2030efa4 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -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 { diff --git a/src/Entity/Project.php b/src/Entity/Project.php index b50afce0..fc437a8b 100644 --- a/src/Entity/Project.php +++ b/src/Entity/Project.php @@ -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 diff --git a/src/Entity/Tag.php b/src/Entity/Tag.php index a47e7146..018bb985 100644 --- a/src/Entity/Tag.php +++ b/src/Entity/Tag.php @@ -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 */ #[ORM\ManyToMany(targetEntity: Timesheet::class, mappedBy: 'tags', fetch: 'EXTRA_LAZY')] diff --git a/src/Entity/Team.php b/src/Entity/Team.php index 222e9f98..6ab9cdf9 100644 --- a/src/Entity/Team.php +++ b/src/Entity/Team.php @@ -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 */ public function getCustomers(): iterable @@ -343,6 +345,7 @@ class Team } /** + * @internal * @return Collection */ public function getProjects(): iterable @@ -376,6 +379,7 @@ class Team } /** + * @internal * @return Collection */ public function getActivities(): iterable diff --git a/src/Entity/User.php b/src/Entity/User.php index 56f31251..c0615ce6 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -128,7 +128,7 @@ class User implements UserInterface, EquatableInterface, ThemeUserInterface, Pas * @var Collection|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; diff --git a/src/Event/CustomerMetaDisplayEvent.php b/src/Event/CustomerMetaDisplayEvent.php index 8b718a98..3bb3ce63 100644 --- a/src/Event/CustomerMetaDisplayEvent.php +++ b/src/Event/CustomerMetaDisplayEvent.php @@ -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 { diff --git a/src/EventSubscriber/Actions/InvoiceSubscriber.php b/src/EventSubscriber/Actions/InvoiceSubscriber.php index fcc2e49c..4bab9cb4 100644 --- a/src/EventSubscriber/Actions/InvoiceSubscriber.php +++ b/src/EventSubscriber/Actions/InvoiceSubscriber.php @@ -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 { diff --git a/src/Export/Base/AbstractSpreadsheetRenderer.php b/src/Export/Base/AbstractSpreadsheetRenderer.php index 0b1d1797..d927c83b 100644 --- a/src/Export/Base/AbstractSpreadsheetRenderer.php +++ b/src/Export/Base/AbstractSpreadsheetRenderer.php @@ -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) { diff --git a/src/Export/Base/HtmlRenderer.php b/src/Export/Base/HtmlRenderer.php index 469587c0..cd680579 100644 --- a/src/Export/Base/HtmlRenderer.php +++ b/src/Export/Base/HtmlRenderer.php @@ -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); diff --git a/src/Form/Helper/ActivityHelper.php b/src/Form/Helper/ActivityHelper.php index 34e37481..19880667 100644 --- a/src/Form/Helper/ActivityHelper.php +++ b/src/Form/Helper/ActivityHelper.php @@ -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); diff --git a/src/Form/Helper/CustomerHelper.php b/src/Form/Helper/CustomerHelper.php index 1c3429b2..ad69ff77 100644 --- a/src/Form/Helper/CustomerHelper.php +++ b/src/Form/Helper/CustomerHelper.php @@ -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); diff --git a/src/Form/Helper/ProjectHelper.php b/src/Form/Helper/ProjectHelper.php index eb89232a..61432c3d 100644 --- a/src/Form/Helper/ProjectHelper.php +++ b/src/Form/Helper/ProjectHelper.php @@ -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); diff --git a/src/Form/Type/ProjectType.php b/src/Form/Type/ProjectType.php index 617383e6..e61ba23d 100644 --- a/src/Form/Type/ProjectType.php +++ b/src/Form/Type/ProjectType.php @@ -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 + ) { } diff --git a/src/Model/BudgetStatisticModel.php b/src/Model/BudgetStatisticModel.php index 689c6157..6218fc0b 100644 --- a/src/Model/BudgetStatisticModel.php +++ b/src/Model/BudgetStatisticModel.php @@ -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; } diff --git a/src/Project/ProjectStatisticService.php b/src/Project/ProjectStatisticService.php index 74d7cea3..c804da81 100644 --- a/src/Project/ProjectStatisticService.php +++ b/src/Project/ProjectStatisticService.php @@ -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); } /** diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php index 57b615a5..1aac36b0 100644 --- a/src/Repository/ActivityRepository.php +++ b/src/Repository/ActivityRepository.php @@ -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 */ 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 */ 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 + */ + 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 $query + * @return Activity[] */ - public function deleteActivity(Activity $delete, ?Activity $replace = null) + public function getActivities(Query $query): array + { + /** @var array $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 + */ + 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 $query + * @return Query + */ + 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; + } } diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php index 3a3c4bf2..a7e0b749 100644 --- a/src/Repository/CustomerRepository.php +++ b/src/Repository/CustomerRepository.php @@ -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 */ 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 + */ + 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 + */ + 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 $query + * @return Query + */ + 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 $query + * @return Customer[] + */ + public function getCustomers(Query $query, CustomerQuery $customerQuery): array + { + /** @var array $customers */ + $customers = $query->execute(); + + $loader = new CustomerLoader($this->getEntityManager(), $customerQuery); + $loader->loadResults($customers); + + return $customers; } public function deleteCustomer(Customer $delete, ?Customer $replace = null): void diff --git a/src/Repository/InvoiceRepository.php b/src/Repository/InvoiceRepository.php index de426192..17df255a 100644 --- a/src/Repository/InvoiceRepository.php +++ b/src/Repository/InvoiceRepository.php @@ -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 + */ + 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 + */ + 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; + } } diff --git a/src/Repository/InvoiceTemplateRepository.php b/src/Repository/InvoiceTemplateRepository.php index ebbda3d2..bcb7465c 100644 --- a/src/Repository/InvoiceTemplateRepository.php +++ b/src/Repository/InvoiceTemplateRepository.php @@ -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 + */ + private function getPaginatorForQuery(BaseQuery $baseQuery): PaginatorInterface { - $counter = $this->countTemplatesForQuery($query); - $qb = $this->getQueryBuilderForQuery($query); + $counter = $this->countTemplatesForQuery($baseQuery); + /** @var Query $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 diff --git a/src/Repository/Loader/ActivityLoader.php b/src/Repository/Loader/ActivityLoader.php index c30ad02e..7c821a5a 100644 --- a/src/Repository/Loader/ActivityLoader.php +++ b/src/Repository/Loader/ActivityLoader.php @@ -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 + */ final class ActivityLoader implements LoaderInterface { - public function __construct(private EntityManagerInterface $entityManager, private bool $fullyHydrated = false) + public function __construct(private readonly EntityManagerInterface $entityManager) { } /** - * @param array $results + * @param array $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(); } } diff --git a/src/Repository/Loader/CustomerLoader.php b/src/Repository/Loader/CustomerLoader.php index 24c35abf..417a47b2 100644 --- a/src/Repository/Loader/CustomerLoader.php +++ b/src/Repository/Loader/CustomerLoader.php @@ -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 + */ 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 $results + * @param array $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(); } diff --git a/src/Repository/Loader/DefaultLoader.php b/src/Repository/Loader/DefaultLoader.php index e536bad0..51c238f1 100644 --- a/src/Repository/Loader/DefaultLoader.php +++ b/src/Repository/Loader/DefaultLoader.php @@ -9,6 +9,10 @@ namespace App\Repository\Loader; +/** + * @deprecated use QueryBuilderPaginator instead + * @implements LoaderInterface + */ final class DefaultLoader implements LoaderInterface { public function loadResults(array $results): void diff --git a/src/Repository/Loader/InvoiceLoader.php b/src/Repository/Loader/InvoiceLoader.php deleted file mode 100644 index 1af29fb2..00000000 --- a/src/Repository/Loader/InvoiceLoader.php +++ /dev/null @@ -1,65 +0,0 @@ - $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(); - } -} diff --git a/src/Repository/Loader/LoaderInterface.php b/src/Repository/Loader/LoaderInterface.php index 7104d632..7c03e2db 100644 --- a/src/Repository/Loader/LoaderInterface.php +++ b/src/Repository/Loader/LoaderInterface.php @@ -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 $results */ public function loadResults(array $results): void; } diff --git a/src/Repository/Loader/ProjectLoader.php b/src/Repository/Loader/ProjectLoader.php index 843098e5..73371a72 100644 --- a/src/Repository/Loader/ProjectLoader.php +++ b/src/Repository/Loader/ProjectLoader.php @@ -14,63 +14,50 @@ use App\Entity\Project; use App\Entity\Team; use Doctrine\ORM\EntityManagerInterface; +/** + * @internal + * @implements LoaderInterface + */ 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 $results + * @param array $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(); } diff --git a/src/Repository/Loader/TeamLoader.php b/src/Repository/Loader/TeamLoader.php index c82e1d21..6166e6be 100644 --- a/src/Repository/Loader/TeamLoader.php +++ b/src/Repository/Loader/TeamLoader.php @@ -9,52 +9,77 @@ namespace App\Repository\Loader; +use App\Entity\Project; use App\Entity\Team; use Doctrine\ORM\EntityManagerInterface; +/** + * @internal + * @implements LoaderInterface + */ 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 $results + * @param array $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 $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(); + } } } diff --git a/src/Repository/Loader/TimesheetLoader.php b/src/Repository/Loader/TimesheetLoader.php index 52a67c22..6dcdffd9 100644 --- a/src/Repository/Loader/TimesheetLoader.php +++ b/src/Repository/Loader/TimesheetLoader.php @@ -15,46 +15,40 @@ use App\Entity\Project; use App\Entity\Timesheet; use Doctrine\ORM\EntityManagerInterface; +/** + * @internal + * @implements LoaderInterface + */ 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 $results + * @param array $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 $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(); } } diff --git a/src/Repository/Loader/UserLoader.php b/src/Repository/Loader/UserLoader.php index 598ad8ed..2256a807 100644 --- a/src/Repository/Loader/UserLoader.php +++ b/src/Repository/Loader/UserLoader.php @@ -13,49 +13,54 @@ use App\Entity\Team; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; +/** + * @internal + * @implements LoaderInterface + */ 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 $results + * @param array $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(); diff --git a/src/Repository/Paginator/LoaderPaginator.php b/src/Repository/Paginator/LoaderPaginator.php index 22a319fc..68088924 100644 --- a/src/Repository/Paginator/LoaderPaginator.php +++ b/src/Repository/Paginator/LoaderPaginator.php @@ -13,9 +13,21 @@ use App\Repository\Loader\LoaderInterface; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; +/** + * @template T + * @implements PaginatorInterface + */ final class LoaderPaginator implements PaginatorInterface { - public function __construct(private LoaderInterface $loader, private QueryBuilder $query, private int $results) + /** + * @param LoaderInterface $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> + * @return Query + */ + private function getQuery(): Query + { + return $this->queryBuilder->getQuery(); // @phpstan-ignore-line + } + + /** + * @return iterable */ public function getSlice(int $offset, int $length): iterable { - $query = $this->query - ->getQuery() + /** @var Query $query */ + $query = $this->getQuery() ->setFirstResult($offset) ->setMaxResults($length); @@ -38,20 +58,24 @@ final class LoaderPaginator implements PaginatorInterface } /** - * @param Query $query - * @return iterable> + * @param Query $query + * @return array */ - private function getResults(Query $query) + private function getResults(Query $query): array { + /** @var array $results */ $results = $query->execute(); $this->loader->loadResults($results); - return $results; // @phpstan-ignore-line + return $results; } + /** + * @return iterable + */ public function getAll(): iterable { - return $this->getResults($this->query->getQuery()); + return $this->getResults($this->getQuery()); } } diff --git a/src/Repository/Paginator/LoaderQueryPaginator.php b/src/Repository/Paginator/LoaderQueryPaginator.php new file mode 100644 index 00000000..f114082f --- /dev/null +++ b/src/Repository/Paginator/LoaderQueryPaginator.php @@ -0,0 +1,73 @@ + + */ +final class LoaderQueryPaginator implements PaginatorInterface +{ + /** + * @param LoaderInterface $loader + * @param Query $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 + */ + public function getSlice(int $offset, int $length): iterable + { + /** @var Query $query */ + $query = $this->query + ->setFirstResult($offset) + ->setMaxResults($length); + + return $this->getResults($query); + } + + /** + * @param Query $query + * @return iterable + */ + private function getResults(Query $query): iterable + { + /** @var array $results */ + $results = $query->execute(); + + $this->loader->loadResults($results); + + return $results; + } + + /** + * @return iterable + */ + public function getAll(): iterable + { + return $this->getResults($this->query); + } +} diff --git a/src/Repository/Paginator/PaginatorInterface.php b/src/Repository/Paginator/PaginatorInterface.php index c129b842..61869d4b 100644 --- a/src/Repository/Paginator/PaginatorInterface.php +++ b/src/Repository/Paginator/PaginatorInterface.php @@ -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 */ public function getAll(): iterable; } diff --git a/src/Repository/Paginator/QueryBuilderPaginator.php b/src/Repository/Paginator/QueryBuilderPaginator.php index a3d8c7c9..bb6b747c 100644 --- a/src/Repository/Paginator/QueryBuilderPaginator.php +++ b/src/Repository/Paginator/QueryBuilderPaginator.php @@ -12,9 +12,19 @@ namespace App\Repository\Paginator; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; +/** + * @deprecated use QueryPaginator instead + * @implements PaginatorInterface + */ 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> + * @return iterable */ public function getSlice(int $offset, int $length): iterable { - $query = $this->query + /** @var Query $query */ + $query = $this->queryBuilder ->getQuery() ->setFirstResult($offset) ->setMaxResults($length); @@ -38,15 +49,18 @@ final class QueryBuilderPaginator implements PaginatorInterface /** * @param Query $query - * @return iterable> + * @return iterable */ - private function getResults(Query $query) + private function getResults(Query $query): iterable { return $query->execute(); // @phpstan-ignore-line } + /** + * @return iterable + */ public function getAll(): iterable { - return $this->getResults($this->query->getQuery()); + return $this->getResults($this->queryBuilder->getQuery()); } } diff --git a/src/Repository/Paginator/QueryPaginator.php b/src/Repository/Paginator/QueryPaginator.php new file mode 100644 index 00000000..ee55b144 --- /dev/null +++ b/src/Repository/Paginator/QueryPaginator.php @@ -0,0 +1,64 @@ + + */ +final class QueryPaginator implements PaginatorInterface +{ + /** + * @param Query $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 + */ + public function getSlice(int $offset, int $length): iterable + { + $query = $this->query + ->setFirstResult($offset) + ->setMaxResults($length); + + return $this->getResults($query); + } + + /** + * @param Query $query + * @return iterable + */ + private function getResults(Query $query): iterable + { + return $query->execute(); // @phpstan-ignore-line + } + + /** + * @return iterable + */ + public function getAll(): iterable + { + return $this->getResults($this->query); + } +} diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 23f1729e..0c89dbfb 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -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 */ 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 + */ + 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 $query + * @return Project[] */ - public function deleteProject(Project $delete, ?Project $replace = null) + public function getProjects(Query $query): array + { + /** @var array $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 + */ + 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 $query + * @return Query + */ + 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; + } } diff --git a/src/Repository/Query/ActivityQuery.php b/src/Repository/Query/ActivityQuery.php index 0d54b808..7688c6d3 100644 --- a/src/Repository/Query/ActivityQuery.php +++ b/src/Repository/Query/ActivityQuery.php @@ -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 + */ + private array $activityIds = []; + /** + * @var array + */ + 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 $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); + } } diff --git a/src/Repository/Query/ActivityQueryHydrate.php b/src/Repository/Query/ActivityQueryHydrate.php new file mode 100644 index 00000000..652a9fe7 --- /dev/null +++ b/src/Repository/Query/ActivityQueryHydrate.php @@ -0,0 +1,16 @@ +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)) { diff --git a/src/Repository/Query/CustomerQuery.php b/src/Repository/Query/CustomerQuery.php index 3c84ad2e..5d662028 100644 --- a/src/Repository/Query/CustomerQuery.php +++ b/src/Repository/Query/CustomerQuery.php @@ -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 + */ + private array $customerIds = []; + /** + * @var array + */ + 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 $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); + } } diff --git a/src/Repository/Query/CustomerQueryHydrate.php b/src/Repository/Query/CustomerQueryHydrate.php new file mode 100644 index 00000000..99281175 --- /dev/null +++ b/src/Repository/Query/CustomerQueryHydrate.php @@ -0,0 +1,16 @@ + + */ + private array $customers = []; + + public function addCustomer(Customer $customer): self + { + $this->customers[] = $customer; + + return $this; + } + + /** + * @param array $customers + * @return $this + */ + public function setCustomers(array $customers): self + { + $this->customers = $customers; + + return $this; + } + + /** + * @return array + */ + public function getCustomers(): array + { + return $this->customers; + } + + /** + * @return array + */ + 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); + } +} diff --git a/src/Repository/Query/ProjectQuery.php b/src/Repository/Query/ProjectQuery.php index baac1836..87275001 100644 --- a/src/Repository/Query/ProjectQuery.php +++ b/src/Repository/Query/ProjectQuery.php @@ -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 - */ - private array $customers = []; private ?\DateTime $projectStart = null; private ?\DateTime $projectEnd = null; private ?bool $globalActivities = null; + /** + * @var array + */ + private array $projectIds = []; + /** + * @var array + */ + 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 $customers - * @return $this - */ - public function setCustomers(array $customers): self - { - $this->customers = $customers; - - return $this; - } - - /** - * @return array - */ - public function getCustomers(): array - { - return $this->customers; - } - - /** - * @return array - */ - 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 $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); + } } diff --git a/src/Repository/Query/ProjectQueryHydrate.php b/src/Repository/Query/ProjectQueryHydrate.php new file mode 100644 index 00000000..4085f849 --- /dev/null +++ b/src/Repository/Query/ProjectQueryHydrate.php @@ -0,0 +1,16 @@ +getUsers() as $user) { + $this->addUser($user); + } + } + } + public function getMaxResults(): ?int { return $this->maxResults; diff --git a/src/Repository/Query/UserQuery.php b/src/Repository/Query/UserQuery.php index 481b9230..3ca548ca 100644 --- a/src/Repository/Query/UserQuery.php +++ b/src/Repository/Query/UserQuery.php @@ -26,6 +26,10 @@ class UserQuery extends BaseQuery implements VisibilityInterface */ private array $searchTeams = []; private ?bool $systemAccount = null; + /** + * @var array + */ + 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 $ids + */ + public function setUserIds(array $ids): void + { + $this->userIds = $ids; + } + + /** + * @return int[] + */ + public function getUserIds(): array + { + return $this->userIds; + } } diff --git a/src/Repository/Result/TimesheetResult.php b/src/Repository/Result/TimesheetResult.php index 88c13134..6b41ab57 100644 --- a/src/Repository/Result/TimesheetResult.php +++ b/src/Repository/Result/TimesheetResult.php @@ -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|null */ @@ -27,16 +31,22 @@ final class TimesheetResult /** * @internal + * @param Query $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 */ - 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 $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; } diff --git a/src/Repository/Result/TimesheetResultStatistic.php b/src/Repository/Result/TimesheetResultStatistic.php index c4eaba90..69b5b034 100644 --- a/src/Repository/Result/TimesheetResultStatistic.php +++ b/src/Repository/Result/TimesheetResultStatistic.php @@ -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; diff --git a/src/Repository/TagRepository.php b/src/Repository/TagRepository.php index 0aa55e58..074a8541 100644 --- a/src/Repository/TagRepository.php +++ b/src/Repository/TagRepository.php @@ -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()); diff --git a/src/Repository/TeamRepository.php b/src/Repository/TeamRepository.php index eb552653..6292d360 100644 --- a/src/Repository/TeamRepository.php +++ b/src/Repository/TeamRepository.php @@ -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 + * @extends EntityRepository */ 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 + */ + 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 $query + * @return Team[] + */ + public function getTeams(Query $query): array + { + /** @var array $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 + */ + private function createTeamQuery(TeamQuery $teamQuery): Query + { + $query = $this->getQueryBuilderForQuery($teamQuery)->getQuery(); + $query = $this->prepareTeamQuery($query); + + return $query; + } + + /** + * @param Query $query + * @return Query + */ + 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; + } } diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php index 908638a4..d1ce8881 100644 --- a/src/Repository/TimesheetRepository.php +++ b/src/Repository/TimesheetRepository.php @@ -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 + * @extends EntityRepository */ 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 + */ + 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 $query */ + $query = $qb->getQuery(); + $query = $this->prepareTimesheetQuery($query); - $loader = new TimesheetLoader($qb->getEntityManager(), $fullyHydrated, $basicHydrated); - $loader->loadResults($results); + /** @var array $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 */ - 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 $ids - * @param bool $fullyHydrated - * @param bool $basicHydrated * @return array */ - 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 + */ + private function createTimesheetQuery(TimesheetQuery $timesheetQuery): Query + { + $query = $this->getQueryBuilderForQuery($timesheetQuery)->getQuery(); + $query = $this->prepareTimesheetQuery($query); + + return $query; + } + + /** + * @param Query $query + * @return Query + */ + 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; + } } diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php index 99994da0..957fe483 100644 --- a/src/Repository/UserRepository.php +++ b/src/Repository/UserRepository.php @@ -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 + * @extends EntityRepository * @template-implements PasswordUpgraderInterface * @template-implements UserProviderInterface */ @@ -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 + */ + 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 $query * @return User[] */ - protected function getHydratedResultsByQuery(QueryBuilder $qb): array + public function getUsers(Query $query): array { - /** @var array $results */ - $results = $qb->getQuery()->getResult(); + /** @var array $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 + */ + private function createUserQuery(UserQuery $userQuery): Query + { + $query = $this->getQueryBuilderForQuery($userQuery)->getQuery(); + $query = $this->prepareUserQuery($query); + + return $query; + } + + /** + * @param Query $query + * @return Query + */ + 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; + } } diff --git a/src/Timesheet/FavoriteRecordService.php b/src/Timesheet/FavoriteRecordService.php index d0f2915a..51742206 100644 --- a/src/Timesheet/FavoriteRecordService.php +++ b/src/Timesheet/FavoriteRecordService.php @@ -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) { diff --git a/src/Utils/DataTable.php b/src/Utils/DataTable.php index 65dc06a1..a3897970 100644 --- a/src/Utils/DataTable.php +++ b/src/Utils/DataTable.php @@ -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 + ) { } diff --git a/src/Widget/Type/UserTeamProjects.php b/src/Widget/Type/UserTeamProjects.php index 37b4177a..d8df8036 100644 --- a/src/Widget/Type/UserTeamProjects.php +++ b/src/Widget/Type/UserTeamProjects.php @@ -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); } } diff --git a/src/Widget/Type/UserTeams.php b/src/Widget/Type/UserTeams.php index 8fa30503..20f5e032 100644 --- a/src/Widget/Type/UserTeams.php +++ b/src/Widget/Type/UserTeams.php @@ -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(); } } diff --git a/tests/Controller/Reporting/AbstractUserPeriodControllerTest.php b/tests/Controller/Reporting/AbstractUserPeriodControllerTest.php index e9e4d3e2..c95c3d61 100644 --- a/tests/Controller/Reporting/AbstractUserPeriodControllerTest.php +++ b/tests/Controller/Reporting/AbstractUserPeriodControllerTest.php @@ -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); diff --git a/tests/DataFixtures/TimesheetFixtures.php b/tests/DataFixtures/TimesheetFixtures.php index 44fc2df4..e4988cde 100644 --- a/tests/DataFixtures/TimesheetFixtures.php +++ b/tests/DataFixtures/TimesheetFixtures.php @@ -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 $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'); diff --git a/tests/Repository/Loader/ActivityLoaderTest.php b/tests/Repository/Loader/ActivityLoaderTest.php index 6db7f037..64607194 100644 --- a/tests/Repository/Loader/ActivityLoaderTest.php +++ b/tests/Repository/Loader/ActivityLoaderTest.php @@ -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]); } diff --git a/tests/Repository/Loader/CustomerLoaderTest.php b/tests/Repository/Loader/CustomerLoaderTest.php index d5918567..80d1990a 100644 --- a/tests/Repository/Loader/CustomerLoaderTest.php +++ b/tests/Repository/Loader/CustomerLoaderTest.php @@ -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]); } } diff --git a/tests/Repository/Loader/DefaultLoaderTest.php b/tests/Repository/Loader/DefaultLoaderTest.php deleted file mode 100644 index 138f82d3..00000000 --- a/tests/Repository/Loader/DefaultLoaderTest.php +++ /dev/null @@ -1,29 +0,0 @@ -loadResults($input); - - self::assertEquals([], $input); - } -} diff --git a/tests/Repository/Loader/InvoiceLoaderTest.php b/tests/Repository/Loader/InvoiceLoaderTest.php deleted file mode 100644 index 5738d9bc..00000000 --- a/tests/Repository/Loader/InvoiceLoaderTest.php +++ /dev/null @@ -1,31 +0,0 @@ -getEntityManagerMock(3); - - $sut = new InvoiceLoader($em); - - $entity = $this->createMock(Invoice::class); - $entity->expects($this->once())->method('getId')->willReturn(1); - - $sut->loadResults([$entity]); - } -} diff --git a/tests/Repository/Loader/ProjectLoaderTest.php b/tests/Repository/Loader/ProjectLoaderTest.php index f541fc8d..4f753ab8 100644 --- a/tests/Repository/Loader/ProjectLoaderTest.php +++ b/tests/Repository/Loader/ProjectLoaderTest.php @@ -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]); } } diff --git a/tests/Repository/Paginator/QueryBuilderPaginatorTest.php b/tests/Repository/Paginator/QueryBuilderPaginatorTest.php deleted file mode 100644 index 5ea43984..00000000 --- a/tests/Repository/Paginator/QueryBuilderPaginatorTest.php +++ /dev/null @@ -1,30 +0,0 @@ -createMock(EntityManager::class); - $qb = new QueryBuilder($em); - $sut = new QueryBuilderPaginator($qb, 10); - - self::assertEquals(10, $sut->getNbResults()); - } -} diff --git a/tests/Repository/Query/BaseQueryTest.php b/tests/Repository/Query/BaseQueryTest.php index 08d2f639..4307ab38 100644 --- a/tests/Repository/Query/BaseQueryTest.php +++ b/tests/Repository/Query/BaseQueryTest.php @@ -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()); diff --git a/tests/Widget/Type/AmountYearTest.php b/tests/Widget/Type/AmountYearTest.php index 0cef2db0..2c94c501 100644 --- a/tests/Widget/Type/AmountYearTest.php +++ b/tests/Widget/Type/AmountYearTest.php @@ -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); diff --git a/tests/Widget/Type/UserAmountYearTest.php b/tests/Widget/Type/UserAmountYearTest.php index 1f1e0e46..18ea9191 100644 --- a/tests/Widget/Type/UserAmountYearTest.php +++ b/tests/Widget/Type/UserAmountYearTest.php @@ -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); diff --git a/tests/phpstan.neon b/tests/phpstan.neon index 3737a9b1..ecda03b4 100644 --- a/tests/phpstan.neon +++ b/tests/phpstan.neon @@ -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\\\\.$#" - 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\\\\.$#" - count: 1 - path: Entity/UserTest.php - - message: "#^Parameter \\#1 \\$string of function mb_strlen expects string, string\\|null given\\.$#" count: 5