diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..56fc68a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(task test:*)", + "Bash(task composer:*)", + "Bash(task lint:*)" + ] + } +} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4ed1f3c..a890d81 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,17 +1,37 @@ on: pull_request + name: Review + +# The checkout belongs to the runner, so the container writes to the bind mount +# as `runner` rather than as the image's `deploy` user. +env: + COMPOSE_USER: runner + jobs: changelog: runs-on: ubuntu-latest name: Changelog should be updated - strategy: - fail-fast: false steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Git fetch run: git fetch - name: Check that changelog has been updated. run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 + + test: + runs-on: ubuntu-latest + name: Unit tests + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Tests run in the project's own compose stack, so the PHP version comes + # from compose.yml rather than being repeated here. + - name: Install dependencies + run: docker compose run --rm --no-deps phpfpm composer install --no-interaction --prefer-dist --no-progress + + - name: Run tests + run: docker compose run --rm --no-deps phpfpm vendor/bin/phpunit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2c24b9..f73c81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Build and create release env: @@ -34,7 +34,7 @@ jobs: release_name="release-${tag_name}" fi - docker compose run --user root --rm php bin/create-release "$tag_name" + docker compose run --user root --rm php-release bin/create-release "$tag_name" # Delete release if it already exists. gh release view "$release_name" > /dev/null 2>&1 && gh release delete "$release_name" --yes diff --git a/.gitignore b/.gitignore index e0d067c..13361a9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ vendor .DS_Store *.cache -composer.lock # Release artifacts release/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bfa34e..a41658d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ ## [Unreleased] +## [1.0.0] - 2026-08-21 + +* [PR-21](https://github.com/ITK-Leantime/data-api/pull/21) + * Paginated the deleted endpoint with `start` and `limit`, so a response is bounded like the entity endpoints instead of carrying the whole deletion history. + * Added `deletionId` to the deleted results — the deletion's own id, which the results are ordered and paged on, as opposed to `id`, which is the deleted entity's. + * Replaced `types` with a required singular `type` on the deleted endpoint, since a request now answers with one type's page; `results` is a flat list rather than an object keyed by type. + * Renamed the deleted endpoint's `deleted` parameter to `deletedAfter`, matching `modifiedAfter` on the entity endpoints. The old name answers 400 rather than being ignored, which would have returned the whole deletion history to a caller that believes it asked for a window. + * Answered the retired `types` parameter with a 400 naming `type`, instead of the generic "type is required" a caller sending it would otherwise get. + * Documented paging against the limit in the response rather than the one requested, since a limit above the cap is answered with the cap, and documented that `start` is a watermark rather than a gap-free cursor across sync runs. +* [PR-20](https://github.com/ITK-Leantime/data-api/pull/20) + * Added a plugin owned `itk_data_api_modified` column, maintained by database triggers, on projects, tickets, timesheets and users, so no write path can leave the sync watermark behind. + * Changed `modifiedAfter` to filter on that column, so edits to existing tickets and milestones are no longer missed and time logged from the weekly grid is picked up. + * Added `modified` to the users endpoint. + * Changed the deletion triggers to stamp `dateDeleted` in UTC, so `deleted` filters against the same clock the responses are read in. + * Moved the schema handling into a SchemaRepository, executing one statement at a time so installation reports failures instead of swallowing them, and made installing idempotent. + * Dropped the `dateDeleted` default on the deletion tables, so a row inserted without a trigger in place is left null rather than stamped with the server's local time. +* [PR-19](https://github.com/ITK-Leantime/data-api/pull/19) + * Validated request parameters, so malformed input answers 400 with a reason instead of failing with a 500. + * Rejected a limit below 1, which previously dropped the LIMIT clause and returned every row, and capped limit at 1000. + * Accepted comma separated ids, projectIds and types, since the endpoints are documented as GET with query parameters. + * Required types on the deleted endpoint, so a bare request answers 400 instead of returning every deleted id ever recorded, and stopped an unknown type reaching the error page. + * Fixed an empty projectIds list dropping the filter, which answered with every row instead of none. + * Trimmed whitespace around ids, projectIds and types elements sent in array form. + * Renamed InvalidRequestException to BadRequestException, matching the 400 it turns into. +* [PR-18](https://github.com/ITK-Leantime/data-api/pull/18) + * Allowed null values in API models, so entries referencing deleted users or deleted tickets no longer fail the whole request. + * Added userId to timesheets, so hours logged by a deleted user stay attributable. + * Stopped resolving ticket status against the session's project when a ticket has no project. + * Allowed a missing worker name, and stopped returning a whitespace-only name for a worker without one. + * Looked up ticket status labels once per project instead of once per ticket. + * Pinned the development dependencies to the Leantime release the plugin targets. + * Added PHPUnit test setup and a Taskfile for running it, and ran the tests in the project's Docker Compose stack on pull requests. + ## [0.1.2] - 2026-03-06 * [PR-12](https://github.com/ITK-Leantime/data-api/pull/12) @@ -28,7 +61,8 @@ * [PR-2](https://github.com/ITK-Leantime/data-api/pull/2) * Initial release -[Unreleased]: https://github.com/ITK-Leantime/data-api/compare/0.1.2...HEAD +[Unreleased]: https://github.com/ITK-Leantime/data-api/compare/1.0.0...HEAD +[1.0.0]: https://github.com/ITK-Leantime/data-api/compare/0.1.2...1.0.0 [0.1.2]: https://github.com/ITK-Leantime/data-api/compare/0.1.1...0.1.2 [0.1.1]: https://github.com/ITK-Leantime/data-api/compare/0.1.0...0.1.1 [0.1.0]: https://github.com/ITK-Leantime/data-api/compare/0.0.2...0.1.0 diff --git a/Controllers/API.php b/Controllers/API.php index 548c1e6..7f1a516 100644 --- a/Controllers/API.php +++ b/Controllers/API.php @@ -3,6 +3,9 @@ namespace Leantime\Plugins\APIData\Controllers; use Leantime\Core\Controller\Controller; +use Leantime\Plugins\APIData\Model\BadRequestException; +use Leantime\Plugins\APIData\Model\DeletedRequestParameters; +use Leantime\Plugins\APIData\Model\RequestParameters; use Leantime\Plugins\APIData\Model\ResponseData; use Leantime\Plugins\APIData\Services\APIData; use Symfony\Component\HttpFoundation\JsonResponse; @@ -21,78 +24,79 @@ public function init(APIData $dataAPIService): void public function deleted(array $input): JsonResponse { - return new JsonResponse($this->getDeleted($input)); + return $this->respond(fn () => $this->getDeleted($input)); } public function projects(array $input): JsonResponse { - return new JsonResponse($this->getResults($input, APIData::TYPE_PROJECTS)); + return $this->respond(fn () => $this->getResults($input, APIData::TYPE_PROJECTS)); } public function milestones(array $input): JsonResponse { - return new JsonResponse($this->getResults($input, APIData::TYPE_MILESTONES)); + return $this->respond(fn () => $this->getResults($input, APIData::TYPE_MILESTONES)); } public function tickets(array $input): JsonResponse { - return new JsonResponse($this->getResults($input, APIData::TYPE_TICKETS)); + return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TICKETS)); } public function timesheets(array $input): JsonResponse { - return new JsonResponse($this->getResults($input, APIData::TYPE_TIMESHEETS)); + return $this->respond(fn () => $this->getResults($input, APIData::TYPE_TIMESHEETS)); } public function workers(array $input): JsonResponse { - return new JsonResponse($this->getResults($input, APIData::TYPE_WORKERS)); + return $this->respond(fn () => $this->getResults($input, APIData::TYPE_WORKERS)); } - private function getDeleted(array $input): array + /** + * A parameter the caller got wrong is their error, not ours, so it answers + * 400 with the reason instead of Leantime's 500 error page. + */ + private function respond(callable $resolve): JsonResponse { - $types = $input['types']; - $deleted = $input['deleted'] ?? null; + try { + return new JsonResponse($resolve()); + } catch (BadRequestException $exception) { + return new JsonResponse(['error' => $exception->getMessage()], JsonResponse::HTTP_BAD_REQUEST); + } + } - $deletedEntries = []; - $count = 0; + private function getDeleted(array $input): array + { + $parameters = DeletedRequestParameters::fromInput($input); - foreach ($types as $type) { - $deletedEntries[$type] = $this->dataAPIService->getDeleted($type, $deleted); - $count = $count + count($deletedEntries[$type]); - } + $results = $this->dataAPIService->getDeleted( + $parameters->type, + $parameters->start, + $parameters->limit, + $parameters->deletedAfter, + ); return (new ResponseData( - ['types' => $types], - $count, - $deletedEntries, + $parameters->toArray(), + count($results), + $results, ))->toArray(); } private function getResults(array $input, string $type): array { - $start = (int) ($input['start'] ?? 0); - $limit = (int) ($input['limit'] ?? 100); - $modifiedAfter = $input['modifiedAfter'] ?? null; - $ids = $input['ids'] ?? null; - $projectIds = $input['projectIds'] ?? null; + $parameters = RequestParameters::fromInput($input); $results = match ($type) { - APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($start, $limit, $modifiedAfter, $ids), - APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($start, $limit, $modifiedAfter, $ids, $projectIds), - APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($start, $limit, $modifiedAfter, $ids, $projectIds), - APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($start, $limit, $modifiedAfter, $ids, $projectIds), - APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($start, $limit, $modifiedAfter, $ids), + APIData::TYPE_PROJECTS => $this->dataAPIService->getProjects($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids), + APIData::TYPE_MILESTONES => $this->dataAPIService->getMilestones($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds), + APIData::TYPE_TICKETS => $this->dataAPIService->getTickets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds), + APIData::TYPE_TIMESHEETS => $this->dataAPIService->getTimesheets($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids, $parameters->projectIds), + APIData::TYPE_WORKERS => $this->dataAPIService->getWorkers($parameters->start, $parameters->limit, $parameters->modifiedAfter, $parameters->ids), }; return (new ResponseData( - [ - 'start' => $start, - 'limit' => $limit, - 'modifiedAfter' => $modifiedAfter, - 'ids' => $ids, - 'projectIds' => $projectIds, - ], + $parameters->toArray(), count($results), $results, ))->toArray(); diff --git a/Dockerfile b/Dockerfile index 08ef1cc..ff2de27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM itkdev/php8.3-fpm:latest USER root -# Add rsync +# Add rsync, which `bin/create-release` needs and the base image does not carry. RUN apt-get update && apt-get --yes install rsync # Clean up diff --git a/Model/BadRequestException.php b/Model/BadRequestException.php new file mode 100644 index 0000000..3ac40d6 --- /dev/null +++ b/Model/BadRequestException.php @@ -0,0 +1,10 @@ +|null + */ + private static function toList(mixed $value, string $name): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + $elements = []; + + foreach ($value as $element) { + if (!is_scalar($element)) { + throw new BadRequestException(sprintf('%s must be a list of values.', $name)); + } + + // Trimmed like the comma separated form, so ?types[]=tickets%20 + // is not a 400 while ?types=tickets,%20timesheets works. Only + // strings, to leave a JSON body's integers as integers. + $elements[] = is_string($element) ? trim($element) : $element; + } + + return $elements; + } + + if (!is_scalar($value)) { + throw new BadRequestException(sprintf('%s must be a list of values.', $name)); + } + + $elements = array_filter( + array_map('trim', explode(',', (string) $value)), + fn ($element) => $element !== '', + ); + + return [] === $elements ? null : array_values($elements); + } +} diff --git a/Model/DeletedData.php b/Model/DeletedData.php index 74e379c..f76e315 100644 --- a/Model/DeletedData.php +++ b/Model/DeletedData.php @@ -4,10 +4,16 @@ use Carbon\CarbonInterface; -class DeletedData +/** + * A single deletion. `deletionId` is the deleted table's own row id, which the + * caller feeds back as `start` to page on; `id` is the id of the entity that was + * deleted, and is nullable because the tracking tables allow it. + */ +readonly class DeletedData { public function __construct( - public int $id, - public CarbonInterface $deletedDate, + public ?int $deletionId, + public ?int $id, + public ?CarbonInterface $deletedDate, ) {} } diff --git a/Model/DeletedRequestParameters.php b/Model/DeletedRequestParameters.php new file mode 100644 index 0000000..2a951b5 --- /dev/null +++ b/Model/DeletedRequestParameters.php @@ -0,0 +1,147 @@ + $input + */ + public static function fromInput(array $input): self + { + self::rejectTheOldTypesName($input); + self::rejectTheOldDeletedName($input); + + return new self( + type: self::toType($input['type'] ?? null), + start: self::toNonNegativeInt($input['start'] ?? 0, 'start'), + limit: self::toLimit($input['limit'] ?? self::DEFAULT_LIMIT), + deletedAfter: self::toTimestamp($input['deletedAfter'] ?? null, 'deletedAfter'), + ); + } + + /** + * The effective parameters, so a caller can see the limit it actually got. + * + * @return array + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'start' => $this->start, + 'limit' => $this->limit, + 'deletedAfter' => $this->deletedAfter, + ]; + } + + /** + * @return list + */ + public static function supportedTypes(): array + { + return [ + APIData::TYPE_PROJECTS, + APIData::TYPE_MILESTONES, + APIData::TYPE_TICKETS, + APIData::TYPE_TIMESHEETS, + ]; + } + + /** + * `types` took a list until the endpoint was narrowed to serving one type per + * request. A caller still sending it has no `type` at all, so without this it + * would be turned away by the generic "type is required" — true, but silent + * about the one thing it needs to change. + * + * @param array $input + */ + private static function rejectTheOldTypesName(array $input): void + { + if (self::wasSent($input['types'] ?? null)) { + throw new BadRequestException('types has been replaced by type, which names exactly one type.'); + } + } + + /** + * `deleted` was this parameter's name until it was aligned with the entity + * endpoints' `modifiedAfter`. Ignoring it would answer with the whole deletion + * history while the caller believes it asked for a window — which is exactly + * how the consumer's timestamp went missing when it sent the other name. + * + * @param array $input + */ + private static function rejectTheOldDeletedName(array $input): void + { + if (self::wasSent($input['deleted'] ?? null)) { + throw new BadRequestException('deleted has been renamed to deletedAfter.'); + } + } + + /** + * A retired parameter is only worth failing over when it carries something. + * An empty value means it was never really sent — a bare `?deleted=` or + * `?types[]=` is what a query string leaves behind, not a request for the old + * behaviour. + */ + private static function wasSent(mixed $value): bool + { + if (is_array($value)) { + return [] !== array_filter($value, fn ($element) => $element !== null && $element !== ''); + } + + return $value !== null && $value !== ''; + } + + private static function toType(mixed $value): string + { + // `type[]=tickets&type[]=timesheets` is rejected rather than reduced to + // its first element, which would answer with a page of a type the caller + // did not ask about. The retired `types` name is caught earlier. + if (is_array($value)) { + throw new BadRequestException('type must be a single value.'); + } + + $type = is_string($value) ? trim($value) : $value; + + if ($type === null || $type === '') { + throw new BadRequestException(sprintf( + 'type is required and must be one of: %s.', + implode(', ', self::supportedTypes()), + )); + } + + // Compared strictly, and the input is never echoed back — an unknown type + // reached the error page verbatim before. + if (!in_array($type, self::supportedTypes(), true)) { + throw new BadRequestException(sprintf( + 'type must be one of: %s.', + implode(', ', self::supportedTypes()), + )); + } + + return $type; + } +} diff --git a/Model/MilestoneData.php b/Model/MilestoneData.php index 0a06bd2..f222b7c 100644 --- a/Model/MilestoneData.php +++ b/Model/MilestoneData.php @@ -8,8 +8,8 @@ { public function __construct( public int $id, - public int $projectId, - public string $name, + public ?int $projectId, + public ?string $name, public ?CarbonInterface $modified, ) {} } diff --git a/Model/ProjectData.php b/Model/ProjectData.php index 0c07e16..388ada8 100644 --- a/Model/ProjectData.php +++ b/Model/ProjectData.php @@ -8,7 +8,7 @@ { public function __construct( public int $id, - public string $name, + public ?string $name, public ?CarbonInterface $modified, ) {} } diff --git a/Model/RequestParameters.php b/Model/RequestParameters.php new file mode 100644 index 0000000..4a9d75d --- /dev/null +++ b/Model/RequestParameters.php @@ -0,0 +1,70 @@ +|null $ids + * @param list|null $projectIds + */ + public function __construct( + public int $start, + public int $limit, + public ?int $modifiedAfter, + public ?array $ids, + public ?array $projectIds, + ) {} + + /** + * @param array $input + */ + public static function fromInput(array $input): self + { + return new self( + start: self::toNonNegativeInt($input['start'] ?? 0, 'start'), + limit: self::toLimit($input['limit'] ?? self::DEFAULT_LIMIT), + modifiedAfter: self::toTimestamp($input['modifiedAfter'] ?? null, 'modifiedAfter'), + ids: self::toIds($input['ids'] ?? null, 'ids'), + projectIds: self::toIds($input['projectIds'] ?? null, 'projectIds'), + ); + } + + /** + * The effective parameters, so a caller can see the limit it actually got. + * + * @return array + */ + public function toArray(): array + { + return [ + 'start' => $this->start, + 'limit' => $this->limit, + 'modifiedAfter' => $this->modifiedAfter, + 'ids' => $this->ids, + 'projectIds' => $this->projectIds, + ]; + } + + /** + * @return list|null + */ + private static function toIds(mixed $value, string $name): ?array + { + $ids = self::toList($value, $name); + + if ($ids === null) { + return null; + } + + return array_map(fn ($id) => self::toNonNegativeInt($id, $name), $ids); + } +} diff --git a/Model/TicketData.php b/Model/TicketData.php index 8826a6a..6ff561f 100644 --- a/Model/TicketData.php +++ b/Model/TicketData.php @@ -8,8 +8,8 @@ { public function __construct( public int $id, - public int $projectId, - public string $name, + public ?int $projectId, + public ?string $name, public ?string $status, public ?int $milestoneId, public array $tags, diff --git a/Model/TimesheetData.php b/Model/TimesheetData.php index b14c145..cd726b5 100644 --- a/Model/TimesheetData.php +++ b/Model/TimesheetData.php @@ -8,13 +8,14 @@ { public function __construct( public int $id, - public int $ticketId, - public int $projectId, + public ?int $ticketId, + public ?int $projectId, public ?string $description, public float $hours, - public string $username, + public ?int $userId, + public ?string $username, + public ?string $kind, public ?CarbonInterface $workDate = null, public ?CarbonInterface $modified = null, - public string $kind, ) {} } diff --git a/Model/WorkerData.php b/Model/WorkerData.php index 41f47f4..c562520 100644 --- a/Model/WorkerData.php +++ b/Model/WorkerData.php @@ -1,16 +1,17 @@ query() - ->select(["id", "name", "modified"]) + ->select(["project.id", "project.name", $this->modifiedSelect("project")]) ->from("zp_projects", "project") ->where("project.id", ">=", $startId) - ->when($modifiedAfter !== null, fn ($query) => $query->where("project.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT))) + ->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("project"), ">=", $this->cutoff($modifiedAfter))) ->when($ids !== null, fn ($query) => $query->whereIn("project.id", $ids)) ->orderBy("id", "ASC") ->limit($limit) @@ -28,33 +28,33 @@ public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null ->toArray(); } - public function getMilestones(int $startId, int $limit, int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array + public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array { return $this->query() - ->select(["id", "headline", "projectId", "modified"]) + ->select(["ticket.id", "ticket.headline", "ticket.projectId", $this->modifiedSelect("ticket")]) ->from("zp_tickets", "ticket") ->where("ticket.id", ">=", $startId) ->where("ticket.type", "=", "milestone") - ->when($modifiedAfter !== null, fn ($query) => $query->where("ticket.date", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT))) + ->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("ticket"), ">=", $this->cutoff($modifiedAfter))) ->when($ids !== null, fn ($query) => $query->whereIn("ticket.id", $ids)) - ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) + ->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) ->orderBy("id", "ASC") ->limit($limit) ->get() ->toArray(); } - public function getTickets(int $startId, int $limit, int $modifiedAfter = null, array $ids = null, ?array $projectIds = null): array + public function getTickets(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array { return $this->query() - ->select(["ticket.id", "ticket.headline", "ticket.projectId", "ticket.status", "ticket.planHours", "ticket.hourRemaining", "ticket.tags", "ticket.dateToFinish", "ticket.editTo", "ticket.milestoneid", "ticket.modified", "user.username"]) + ->select(["ticket.id", "ticket.headline", "ticket.projectId", "ticket.status", "ticket.planHours", "ticket.hourRemaining", "ticket.tags", "ticket.dateToFinish", "ticket.editTo", "ticket.milestoneid", $this->modifiedSelect("ticket"), "user.username"]) ->from("zp_tickets", "ticket") ->where("ticket.id", ">=", $startId) ->where("ticket.type", "<>", "milestone") ->leftJoin('zp_user as user', "user.id", "=", "ticket.editorId") - ->when($modifiedAfter !== null, fn ($query) => $query->where("ticket.date", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT))) + ->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("ticket"), ">=", $this->cutoff($modifiedAfter))) ->when($ids !== null, fn ($query) => $query->whereIn("ticket.id", $ids)) - ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) + ->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) ->orderBy("id", "ASC") ->limit($limit) ->get() @@ -65,14 +65,14 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu { return $this->query() ->from("zp_timesheets", "timesheet") - ->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", "timesheet.modified", "timesheet.ticketId", "timesheet.kind", "user.username", "ticket.projectId"]) + ->select(["timesheet.id", "timesheet.description", "timesheet.hours", "timesheet.workDate", $this->modifiedSelect("timesheet"), "timesheet.ticketId", "timesheet.userId", "timesheet.kind", "user.username", "ticket.projectId"]) ->where("timesheet.id", ">=", $startId) ->whereNotNull("timesheet.hours") ->leftJoin('zp_user as user', "user.id", "=", "timesheet.userId") ->leftJoin('zp_tickets as ticket', "ticket.id", "=", "timesheet.ticketId") - ->when($modifiedAfter !== null, fn ($query) => $query->where("timesheet.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT))) + ->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("timesheet"), ">=", $this->cutoff($modifiedAfter))) ->when($ids !== null, fn ($query) => $query->whereIn("timesheet.id", $ids)) - ->when($projectIds != null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) + ->when($projectIds !== null, fn ($query) => $query->whereIn("ticket.projectId", $projectIds)) ->orderBy("timesheet.id", "ASC") ->limit($limit) ->get() @@ -83,10 +83,13 @@ public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, { return $this->query() ->from("zp_user", "worker") - ->select(["worker.id", "worker.username", DB::raw("CONCAT(worker.firstname, ' ', worker.lastname) as name")]) + // CONCAT_WS skips a missing name part, so a worker with only a + // firstname keeps a usable name. NULLIF turns an all-blank name into + // null rather than a string of whitespace. + ->select(["worker.id", "worker.username", DB::raw("NULLIF(TRIM(CONCAT_WS(' ', worker.firstname, worker.lastname)), '') as name"), $this->modifiedSelect("worker")]) ->where("worker.id", ">=", $startId) ->where("worker.source", "<>", "api") - ->when($modifiedAfter !== null, fn ($query) => $query->where("worker.modified", ">=", CarbonImmutable::createFromTimestamp($modifiedAfter)->format(APIData::DATE_FORMAT))) + ->when($modifiedAfter !== null, fn ($query) => $query->where($this->modified("worker"), ">=", $this->cutoff($modifiedAfter))) ->when($ids !== null, fn ($query) => $query->whereIn("worker.id", $ids)) ->orderBy("worker.id", "ASC") ->limit($limit) @@ -94,7 +97,12 @@ public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ->toArray(); } - public function getDeleted(string $type, ?int $deletedAfter = null): array + /** + * Paged on the tracking table's own row id rather than on `entryId`: rows are + * appended as entities are deleted, so `id` is the only column that both + * orders them and stays put while a client pages through. + */ + public function getDeleted(string $type, int $startId, int $limit, ?int $deletedAfter = null): array { $tableName = match ($type) { APIData::TYPE_PROJECTS => 'itk_projects_deleted', @@ -105,11 +113,40 @@ public function getDeleted(string $type, ?int $deletedAfter = null): array return $this->query() ->from($tableName, "entry") - ->select(["entryId", "dateDeleted"]) + ->select(["entry.id", "entry.entryId", "entry.dateDeleted"]) + ->where("entry.id", ">=", $startId) ->when($type === APIData::TYPE_MILESTONES, fn ($query) => $query->where('type', '=', 'milestone')) ->when($type === APIData::TYPE_TICKETS, fn ($query) => $query->where('type', '<>', 'milestone')) - ->when($deletedAfter !== null, fn ($query) => $query->where("entry.dateDeleted", ">=", CarbonImmutable::createFromTimestamp($deletedAfter)->format(APIData::DATE_FORMAT))) + ->when($deletedAfter !== null, fn ($query) => $query->where("entry.dateDeleted", ">=", $this->cutoff($deletedAfter))) + ->orderBy("entry.id", "ASC") + ->limit($limit) ->get() ->toArray(); } + + /** + * The plugin-owned timestamp column, qualified by the query's table alias. + * Core's own `modified` is not maintained on every write path, so it cannot + * carry the modifiedAfter contract — see SchemaRepository. + */ + private function modified(string $alias): string + { + return sprintf('%s.%s', $alias, SchemaRepository::COLUMN); + } + + /** + * Exposed to consumers as plain `modified`, so the column swap is invisible + * to them and to the mapping in APIData. + */ + private function modifiedSelect(string $alias): string + { + return sprintf('%s as modified', $this->modified($alias)); + } + + private function cutoff(int $timestamp): string + { + // Explicit UTC: Carbon 3 defaults to it, but Carbon comes from the host + // Leantime install, and the triggers write UTC_TIMESTAMP(). + return CarbonImmutable::createFromTimestamp($timestamp, 'UTC')->format(APIData::DATE_FORMAT); + } } diff --git a/Repositories/SchemaRepository.php b/Repositories/SchemaRepository.php new file mode 100644 index 0000000..549479f --- /dev/null +++ b/Repositories/SchemaRepository.php @@ -0,0 +1,338 @@ + 'BEFORE INSERT', 'update' => 'BEFORE UPDATE']; + + /** Deletion tracking, keyed by trigger name: [source table, target table, columns, values]. */ + private const DELETE_TRIGGERS = [ + 'itk_projects_deleted_trigger' => ['zp_projects', 'itk_projects_deleted', 'entryId', 'OLD.id'], + 'itk_tickets_deleted_trigger' => ['zp_tickets', 'itk_tickets_deleted', 'entryId, type', 'OLD.id, OLD.type'], + 'itk_timesheets_deleted_trigger' => ['zp_timesheets', 'itk_timesheets_deleted', 'entryId', 'OLD.id'], + ]; + + public function install(): void + { + $this->execute(...self::deletedTableStatements()); + $this->execute(...self::deletedTableAlterStatements()); + $this->execute(...self::deleteTriggerStatements()); + + foreach (self::TRACKED_TABLES as $table) { + $this->execute(...self::installStatements( + $table, + $this->hasColumn($table, self::COLUMN), + $this->hasIndex($table, self::INDEX), + )); + } + } + + public function uninstall(): void + { + // Columns, indexes and the itk_*_deleted tables are deliberately kept: + // a reinstall then preserves timestamp history instead of forcing every + // consumer through another full resync. + $this->execute(...self::uninstallStatements()); + } + + /** + * The per-table install sequence, in the order it has to run. + * + * The triggers precede the backfill so a row written between the two is + * stamped by the trigger rather than missed by an UPDATE that has already + * passed it. The index goes last: built any earlier it would index an + * all-NULL column and then have every entry rewritten by the backfill. + * + * The column and index are guarded because installing is also migrating — + * Leantime has no upgrade hook, so this runs again on every install. + * + * @return list + */ + public static function installStatements(string $table, bool $hasColumn, bool $hasIndex): array + { + $statements = []; + + if (!$hasColumn) { + $statements[] = self::addColumnStatement($table); + } + + array_push($statements, ...self::triggerStatements($table)); + $statements[] = self::backfillStatement($table); + + if (!$hasIndex) { + $statements[] = self::addIndexStatement($table); + } + + return $statements; + } + + /** + * `zp_timesheets` + `insert` → `itk_data_api_timesheets_modified_insert`. + */ + public static function triggerName(string $table, string $event): string + { + $shortName = str_starts_with($table, 'zp_') ? substr($table, 3) : $table; + + return sprintf('itk_data_api_%s_modified_%s', $shortName, $event); + } + + public static function addColumnStatement(string $table): string + { + return sprintf('ALTER TABLE `%s` ADD COLUMN `%s` DATETIME NULL DEFAULT NULL', $table, self::COLUMN); + } + + public static function addIndexStatement(string $table): string + { + return sprintf('ALTER TABLE `%s` ADD INDEX `%s` (`%s`)', $table, self::INDEX, self::COLUMN); + } + + /** + * Stamps the rows the triggers never saw. Runs on every install, not just the + * first, but `IS NULL` keeps it from rewriting timestamps a previous install + * already set and forcing consumers through a second full resync. + */ + public static function backfillStatement(string $table): string + { + return sprintf( + 'UPDATE `%s` SET `%s` = UTC_TIMESTAMP() WHERE `%s` IS NULL', + $table, + self::COLUMN, + self::COLUMN, + ); + } + + /** + * @return list A DROP IF EXISTS immediately followed by its CREATE, per event. + */ + public static function triggerStatements(string $table): array + { + $statements = []; + + foreach (self::EVENTS as $event => $timing) { + $name = self::triggerName($table, $event); + + $statements[] = self::dropTriggerStatement($name); + // A single-statement body, so it needs no BEGIN … END and contains + // no semicolon — which is what lets the statements be executed one + // at a time instead of as one multi-statement string. + $statements[] = sprintf( + 'CREATE TRIGGER `%s` %s ON `%s` FOR EACH ROW SET NEW.%s = UTC_TIMESTAMP()', + $name, + $timing, + $table, + self::COLUMN, + ); + } + + return $statements; + } + + /** + * @return list + */ + public static function dropTriggerStatements(string $table): array + { + return array_map( + static fn (string $event) => self::dropTriggerStatement(self::triggerName($table, $event)), + array_keys(self::EVENTS), + ); + } + + /** + * Every trigger the plugin owns, modified and deleted alike. + * + * @return list + */ + public static function uninstallStatements(): array + { + $statements = array_map(self::dropTriggerStatement(...), array_keys(self::DELETE_TRIGGERS)); + + foreach (self::TRACKED_TABLES as $table) { + array_push($statements, ...self::dropTriggerStatements($table)); + } + + return $statements; + } + + /** + * The historical baseline, frozen. `CREATE TABLE IF NOT EXISTS` is a bootstrap + * rather than a declaration: a database that already has the table ignores it + * forever, so editing a body here only changes what fresh installs get and lets + * the two populations drift apart silently. Every later change belongs in + * `deletedTableAlterStatements()`, which both populations run. + * + * The one exception is a change that produces an identical column either way — + * `int(11)` lost its display width here, since `int(11)` and `int` are the same + * column and only the former emits an 8.0.17 deprecation. + * + * @return list + */ + public static function deletedTableStatements(): array + { + return [ + 'CREATE TABLE IF NOT EXISTS `itk_projects_deleted` ( + `id` int NOT NULL AUTO_INCREMENT, + `entryId` int DEFAULT NULL, + `dateDeleted` datetime DEFAULT NOW(), + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + + 'CREATE TABLE IF NOT EXISTS `itk_tickets_deleted` ( + `id` int NOT NULL AUTO_INCREMENT, + `entryId` int DEFAULT NULL, + `type` varchar(255) DEFAULT NULL, + `dateDeleted` datetime DEFAULT NOW(), + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + + 'CREATE TABLE IF NOT EXISTS `itk_timesheets_deleted` ( + `id` int NOT NULL AUTO_INCREMENT, + `entryId` int DEFAULT NULL, + `dateDeleted` datetime DEFAULT NOW(), + PRIMARY KEY (`id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci', + ]; + } + + /** + * Changes to the deleted tables made after their original CREATE, expressed so + * that existing databases converge on what a fresh install gets. Re-running one + * has to be safe; `hasColumn()`/`hasIndex()` are there for the changes that + * cannot express that in SQL alone. + * + * Dropping `dateDeleted`'s default is the first of them. The delete triggers set + * the column themselves, so `DEFAULT NOW()` is unreachable while they exist — + * but the tables outlive an uninstall and the triggers do not. Without the + * default, a row inserted with no trigger in place gets a NULL instead of the + * session timezone's clock in a column the read side parses as UTC. + * + * @return list + */ + public static function deletedTableAlterStatements(): array + { + return array_map( + static fn (string $table) => sprintf( + 'ALTER TABLE `%s` ALTER COLUMN `dateDeleted` DROP DEFAULT', + $table, + ), + self::deletedTables(), + ); + } + + /** + * @return list + */ + public static function deletedTables(): array + { + return array_values(array_unique(array_column(self::DELETE_TRIGGERS, 1))); + } + + /** + * Unchanged in name, but re-expressed as single-statement bodies behind a + * DROP IF EXISTS. Reinstalling used to throw here because CREATE TRIGGER hit + * the triggers the previous install left behind. + * + * `dateDeleted` is now set explicitly rather than left to the column's + * `DEFAULT NOW()`, which wrote the session timezone's clock while the read + * side parses the value as UTC. + * + * @return list + */ + public static function deleteTriggerStatements(): array + { + $statements = []; + + foreach (self::DELETE_TRIGGERS as $name => [$source, $target, $columns, $values]) { + $statements[] = self::dropTriggerStatement($name); + $statements[] = sprintf( + 'CREATE TRIGGER `%s` AFTER DELETE ON `%s` FOR EACH ROW' + . ' INSERT INTO `%s`(%s, dateDeleted) VALUES (%s, UTC_TIMESTAMP())', + $name, + $source, + $target, + $columns, + $values, + ); + } + + return $statements; + } + + private static function dropTriggerStatement(string $name): string + { + return sprintf('DROP TRIGGER IF EXISTS `%s`', $name); + } + + private function execute(string ...$statements): void + { + $pdo = app('db')->connection()->getPdo(); + + foreach ($statements as $statement) { + // Laravel sets ERRMODE_EXCEPTION so exec() throws, but guard the + // false return in case the connection is configured differently. + if ($pdo->exec($statement) === false) { + [, $code, $message] = $pdo->errorInfo(); + + throw new \RuntimeException(sprintf('Schema statement failed (%s): %s', $code, $message)); + } + } + } + + private function hasColumn(string $table, string $column): bool + { + return $this->query() + ->from('information_schema.COLUMNS') + ->whereRaw('TABLE_SCHEMA = DATABASE()') + ->where('TABLE_NAME', '=', $table) + ->where('COLUMN_NAME', '=', $column) + ->exists(); + } + + private function hasIndex(string $table, string $index): bool + { + return $this->query() + ->from('information_schema.STATISTICS') + ->whereRaw('TABLE_SCHEMA = DATABASE()') + ->where('TABLE_NAME', '=', $table) + ->where('INDEX_NAME', '=', $index) + ->exists(); + } + + private function query(): Builder + { + return app('db')->connection()->query(); + } +} diff --git a/Services/APIData.php b/Services/APIData.php index 8929f87..d3192fd 100644 --- a/Services/APIData.php +++ b/Services/APIData.php @@ -12,6 +12,7 @@ use Leantime\Plugins\APIData\Model\TimesheetData; use Leantime\Plugins\APIData\Model\WorkerData; use Leantime\Plugins\APIData\Repositories\ApiDataRepository; +use Leantime\Plugins\APIData\Repositories\SchemaRepository; class APIData { @@ -25,91 +26,21 @@ class APIData public function __construct( private readonly TicketRepository $ticketRepository, private readonly ApiDataRepository $apiDataRepository, + private readonly SchemaRepository $schemaRepository, ) {} + /** + * Leantime calls this on every install, and offers no separate upgrade hook, + * so SchemaRepository::install() has to be idempotent. + */ public function install(): void { - $sql = " - CREATE TABLE IF NOT EXISTS `itk_projects_deleted` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `entryId` int(11) DEFAULT NULL, - `dateDeleted` datetime DEFAULT NOW(), - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - - CREATE TABLE IF NOT EXISTS `itk_tickets_deleted` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `entryId` int(11) DEFAULT NULL, - `type` varchar(255) DEFAULT NULL, - `dateDeleted` datetime DEFAULT NOW(), - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - - CREATE TABLE IF NOT EXISTS `itk_timesheets_deleted` ( - `id` int(11) NOT NULL AUTO_INCREMENT, - `entryId` int(11) DEFAULT NULL, - `dateDeleted` datetime DEFAULT NOW(), - PRIMARY KEY (`id`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; - - CREATE TRIGGER itk_projects_deleted_trigger - AFTER DELETE ON zp_projects - FOR EACH ROW - BEGIN - INSERT INTO itk_projects_deleted(entryId) - VALUES (OLD.id); - END; - - CREATE TRIGGER itk_tickets_deleted_trigger - AFTER DELETE ON zp_tickets - FOR EACH ROW - BEGIN - INSERT INTO itk_tickets_deleted(entryId, type) - VALUES (OLD.id, OLD.type); - END; - - CREATE TRIGGER itk_timesheets_deleted_trigger - AFTER DELETE ON zp_timesheets - FOR EACH ROW - BEGIN - INSERT INTO itk_timesheets_deleted(entryId) - VALUES (OLD.id); - END; - "; - - // Use PDO for multi-statement SQL with parameter binding - // We need to use PDO directly because Laravel's statement() method - // may not handle multi-statement SQL properly - $pdo = app('db')->connection()->getPdo(); - $stmn = $pdo->prepare($sql); - - $stmn->execute(); - - $stmn->closeCursor(); + $this->schemaRepository->install(); } public function uninstall(): void { - $sql = " - DROP TRIGGER itk_projects_deleted_trigger; - DROP TRIGGER itk_tickets_deleted_trigger; - DROP TRIGGER itk_timesheets_deleted_trigger; - "; - - // Tables are not remove, to preserve data through install/uninstalls. - // DROP TABLE `itk_projects_deleted`; - // DROP TABLE `itk_tickets_deleted`; - // DROP TABLE `itk_timesheets_deleted`; - - // Use PDO for multi-statement SQL with parameter binding - // We need to use PDO directly because Laravel's statement() method - // may not handle multi-statement SQL properly - $pdo = app('db')->connection()->getPdo(); - $stmn = $pdo->prepare($sql); - - $stmn->execute(); - - $stmn->closeCursor(); + $this->schemaRepository->uninstall(); } public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null): array @@ -125,7 +56,7 @@ public function getProjects(int $startId, int $limit, ?int $modifiedAfter = null }, $values); } - public function getMilestones(int $startId, int $limit, int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array + public function getMilestones(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array { $values = $this->apiDataRepository->getMilestones($startId, $limit, $modifiedAfter, $ids, $projectIds); @@ -139,12 +70,22 @@ public function getMilestones(int $startId, int $limit, int $modifiedAfter = nul }, $values); } - public function getTickets(int $startId, int $limit, int $modifiedAfter = null, array $ids = null, ?array $projectIds = null): array + public function getTickets(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array { $values = $this->apiDataRepository->getTickets($startId, $limit, $modifiedAfter, $ids, $projectIds); - return array_map(function ($value) { - $projectStatuses = $this->ticketRepository->getStateLabels($value->projectId); + // Tickets arrive in batches from the same handful of projects, so the + // labels are looked up once per project instead of once per ticket. Kept + // local to the call, since labels can change between requests. + $statusesByProject = []; + + return array_map(function ($value) use (&$statusesByProject) { + // Asked for labels without a project id, Leantime falls back to + // session('currentProject'), which would resolve the status against + // an unrelated project. + $projectStatuses = $value->projectId !== null + ? $statusesByProject[$value->projectId] ??= $this->ticketRepository->getStateLabels($value->projectId) + : []; return new TicketData( $value->id, @@ -168,40 +109,49 @@ public function getTimesheets(int $startId, int $limit, ?int $modifiedAfter = nu $values = $this->apiDataRepository->getTimesheets($startId, $limit, $modifiedAfter, $ids, $projectIds); return array_map(function ($value) { + // Named arguments: CarbonImmutable has a __toString(), so a + // mis-ordered date would be coerced into one of the string + // parameters instead of raising a TypeError. return new TimesheetData( - $value->id, - $value->ticketId, - $value->projectId, - $value->description, - $value->hours, - $value->username, - $this->getCarbonFromDatabaseValue($value->workDate), - $this->getCarbonFromDatabaseValue($value->modified), - $value->kind, + id: $value->id, + ticketId: $value->ticketId, + projectId: $value->projectId, + description: $value->description, + hours: $value->hours, + userId: $value->userId, + username: $value->username, + kind: $value->kind, + workDate: $this->getCarbonFromDatabaseValue($value->workDate), + modified: $this->getCarbonFromDatabaseValue($value->modified), ); }, $values); } - public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null, ?array $projectIds = null): array + public function getWorkers(int $startId, int $limit, ?int $modifiedAfter = null, ?array $ids = null): array { - $values = $this->apiDataRepository->getWorkers($startId, $limit, $modifiedAfter, $ids, $projectIds); + $values = $this->apiDataRepository->getWorkers($startId, $limit, $modifiedAfter, $ids); return array_map(function ($value) { return new WorkerData( - $value->id, - $value->username, - $value->name, + id: $value->id, + email: $value->username, + name: $value->name, + modified: $this->getCarbonFromDatabaseValue($value->modified), ); }, $values); } - public function getDeleted(string $type, ?int $deletedAfter = null): array + public function getDeleted(string $type, int $startId, int $limit, ?int $deletedAfter = null): array { - $values = $this->apiDataRepository->getDeleted($type, $deletedAfter); + $values = $this->apiDataRepository->getDeleted($type, $startId, $limit, $deletedAfter); + // Named arguments: the row's `id` is the deletion's own id and `entryId` + // the deleted entity's, and both are ints, so a swap would map cleanly + // onto the wrong field instead of raising a TypeError. return array_map(fn ($entry) => new DeletedData( - $entry->entryId, - $this->getCarbonFromDatabaseValue($entry->dateDeleted), + deletionId: $entry->id, + id: $entry->entryId, + deletedDate: $this->getCarbonFromDatabaseValue($entry->dateDeleted), ), $values); } diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..d44d170 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,61 @@ +# https://taskfile.dev — install with `brew install go-task` (or see docs). +# Run `task` (or `task --list-all`) to see all available commands. +# +# This plugin has no long-running stack, so everything runs in a one-off +# itkdev/php8.3-fpm container. + +version: "3" + +vars: + # https://taskfile.dev/reference/templating/ + DOCKER_COMPOSE: '{{ .TASK_DOCKER_COMPOSE | default "docker compose" }}' + +tasks: + default: + desc: List all tasks + cmds: + - task --list-all + silent: true + + # -------------------------------------------------------------- Wrappers --- + + compose: + desc: "Run a docker compose command. Example: task compose -- config." + cmds: + - "{{ .DOCKER_COMPOSE }} {{ .CLI_ARGS }}" + + php: + desc: "Run a command in a one-off php container. Example: task php -- php --version." + cmds: + - task compose -- run --rm --no-deps phpfpm {{ .CLI_ARGS }} + silent: true + + composer: + desc: "Run a composer command. Example: task composer -- install." + cmds: + - task php -- composer {{ .CLI_ARGS }} + silent: true + + # ------------------------------------------------------------ Lifecycle --- + + setup: + desc: Install dev dependencies. + cmds: + - task composer -- install + + # ------------------------------------------------------------ PHP tests --- + + test: + desc: "Run the PHP test suite. Example: task test -- --filter TimesheetData." + cmds: + - task php -- vendor/bin/phpunit {{ .CLI_ARGS }} + silent: true + + lint: + desc: Syntax-check all PHP files. + cmds: + - >- + task php -- sh -c + 'find Controllers Model Repositories Services -name "*.php" -print0 + | xargs -0 -n1 -- php -l' + silent: true diff --git a/bin/create-release b/bin/create-release index 9f47f4d..a6ade89 100755 --- a/bin/create-release +++ b/bin/create-release @@ -37,13 +37,18 @@ plugin_name="$plugin_name" # Configuration end git config --global --add safe.directory "$PWD" -if ! git diff --exit-code composer.json; then - (>&2 echo; echo composer.json is changed. Aborting.) +# Both files are restored with `git checkout` at the end, so refuse to run with +# uncommitted changes in them. +if ! git diff --exit-code composer.json composer.lock; then + (>&2 echo; echo composer.json or composer.lock is changed. Aborting.) exit 1 fi # Make sure that `composer.json` has the correct version. composer config version "${tag}" +# Writing the version invalidates the lock file's content hash, so refresh it to +# keep `composer install` from warning about an out of date lock. +composer update --lock --no-install --no-scripts # Script defined in local.create-release to build project-specific assets buildRelease @@ -68,7 +73,7 @@ sha256sum "$name" >| checksum.txt # Clean up rm -fr "$release_dir" -git checkout composer.json +git checkout composer.json composer.lock echo echo "Release content ($name)" diff --git a/bin/release-exclude.txt b/bin/release-exclude.txt index 3331c0c..dd231cf 100755 --- a/bin/release-exclude.txt +++ b/bin/release-exclude.txt @@ -1,10 +1,17 @@ *.tar.gz +.claude .git* +.idea .php-cs-fixer.dist.php +.phpunit.cache .twig-cs-fixer.dist.php bin checksum.txt -compose.yaml +compose.yml composer.lock vendor Dockerfile +phpunit.xml +phpunit.xml.dist +Taskfile.yml +tests diff --git a/compose.yml b/compose.yml index b40cbb9..4426757 100644 --- a/compose.yml +++ b/compose.yml @@ -1,5 +1,19 @@ services: - php: + # Tests, linting and composer run straight on the itkdev image — nothing in + # that path needs anything added to it. + phpfpm: + image: itkdev/php8.3-fpm:latest + working_dir: /app + # `deploy` is right locally, where Docker Desktop masks the ownership of the + # bind mount. On a Linux CI runner the checkout belongs to `runner`, so CI + # sets COMPOSE_USER to it — otherwise composer cannot create `vendor`. + user: ${COMPOSE_USER:-deploy} + volumes: + - .:/app + + # Only `bin/create-release` needs the built image, for the rsync it adds. + php-release: build: . + working_dir: /app volumes: - .:/app diff --git a/composer.json b/composer.json index bb27426..3b3772b 100644 --- a/composer.json +++ b/composer.json @@ -12,9 +12,22 @@ ], "homepage": "https://github.com/ITK-Leantime/leantime-data-api", "require-dev": { + "illuminate/database": "11.45.1", + "nesbot/carbon": "3.10.1", + "phpunit/phpunit": "^12.0" }, "config": { }, + "autoload-dev": { + "psr-4": { + "Leantime\\Plugins\\APIData\\": "", + "Leantime\\Plugins\\APIData\\Tests\\": "tests/" + }, + "classmap": [ + "tests/Stub/" + ] + }, "scripts": { + "test": "phpunit" } } diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..be93f7b --- /dev/null +++ b/composer.lock @@ -0,0 +1,3200 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "bc479de7cb56c5fdc896005e60d56dee", + "packages": [], + "packages-dev": [ + { + "name": "brick/math", + "version": "0.12.3", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.12.3" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-02-28T13:11:00+00:00" + }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "illuminate/collections", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/collections.git", + "reference": "856b1da953e46281ba61d7c82d337072d3ee1825" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/collections/zipball/856b1da953e46281ba61d7c82d337072d3ee1825", + "reference": "856b1da953e46281ba61d7c82d337072d3ee1825", + "shasum": "" + }, + "require": { + "illuminate/conditionable": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "php": "^8.2" + }, + "suggest": { + "symfony/var-dumper": "Required to use the dump method (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Collections package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/conditionable", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/conditionable.git", + "reference": "319b717e0587bd7c8a3b44464f0e27867b4bcda9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/conditionable/zipball/319b717e0587bd7c8a3b44464f0e27867b4bcda9", + "reference": "319b717e0587bd7c8a3b44464f0e27867b4bcda9", + "shasum": "" + }, + "require": { + "php": "^8.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Conditionable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/container", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/container.git", + "reference": "79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/container/zipball/79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a", + "reference": "79bf9149ad7ddd7e14326ebcdd41197d2c4ee36a", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1" + }, + "provide": { + "psr/container-implementation": "1.1|2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Container\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Container package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/contracts", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "4787042340aae19a7ea0fa82f4073c4826204a48" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/4787042340aae19a7ea0fa82f4073c4826204a48", + "reference": "4787042340aae19a7ea0fa82f4073c4826204a48", + "shasum": "" + }, + "require": { + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/simple-cache": "^1.0|^2.0|^3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-11-27T16:16:07+00:00" + }, + { + "name": "illuminate/database", + "version": "v11.45.1", + "source": { + "type": "git", + "url": "https://github.com/illuminate/database.git", + "reference": "0b3a1d429fb44c8bc2ed6afcc431872cbf3a85b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/database/zipball/0b3a1d429fb44c8bc2ed6afcc431872cbf3a85b2", + "reference": "0b3a1d429fb44c8bc2ed6afcc431872cbf3a85b2", + "shasum": "" + }, + "require": { + "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12", + "ext-pdo": "*", + "illuminate/collections": "^11.0", + "illuminate/container": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "illuminate/support": "^11.0", + "laravel/serializable-closure": "^1.3|^2.0", + "php": "^8.2" + }, + "suggest": { + "ext-filter": "Required to use the Postgres database driver.", + "fakerphp/faker": "Required to use the eloquent factory builder (^1.24).", + "illuminate/console": "Required to use the database commands (^11.0).", + "illuminate/events": "Required to use the observers with Eloquent (^11.0).", + "illuminate/filesystem": "Required to use the migrations (^11.0).", + "illuminate/pagination": "Required to paginate the result set (^11.0).", + "symfony/finder": "Required to use Eloquent model factories (^7.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Database\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Database package.", + "homepage": "https://laravel.com", + "keywords": [ + "database", + "laravel", + "orm", + "sql" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-03-24T11:54:20+00:00" + }, + { + "name": "illuminate/macroable", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/macroable.git", + "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/macroable/zipball/e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", + "reference": "e1cb9e51b9ed5d3c9bc1ab431d0a52fe42a990ed", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Macroable package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2024-06-28T20:10:30+00:00" + }, + { + "name": "illuminate/support", + "version": "v11.51.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "11cc8bd46991e277f6af19eb9dee5d4c1f65c7d2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/11cc8bd46991e277f6af19eb9dee5d4c1f65c7d2", + "reference": "11cc8bd46991e277f6af19eb9dee5d4c1f65c7d2", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^11.0", + "illuminate/conditionable": "^11.0", + "illuminate/contracts": "^11.0", + "illuminate/macroable": "^11.0", + "nesbot/carbon": "^2.72.6|^3.8.4", + "php": "^8.2", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "replace": { + "spatie/once": "*" + }, + "suggest": { + "illuminate/filesystem": "Required to use the Composer class (^11.0).", + "laravel/serializable-closure": "Required to use the once function (^1.3|^2.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.7).", + "league/uri": "Required to use the Uri class (^7.5.1).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the Composer class (^7.0).", + "symfony/uid": "Required to use Str::ulid() (^7.0).", + "symfony/var-dumper": "Required to use the dd function (^7.0).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.6.1)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "11.x-dev" + } + }, + "autoload": { + "files": [ + "functions.php", + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-01-20T15:25:35+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.15", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-07-21T16:49:22+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.10.1", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "reference": "1fd1935b2d90aef2f093c5e35f7ae1257c448d00", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.75.0", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.17", + "phpunit/phpunit": "^10.5.46", + "squizlabs/php_codesniffer": "^3.13.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-06-21T15:19:35+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "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/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.33", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b98e028a26c5c5ba7e4a54be96ccf35f2914d184", + "reference": "b98e028a26c5c5ba7e4a54be96ccf35f2914d184", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.33" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-28T13:58:09+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "501e0ff4553c744209ca1a68790d8a4541563710" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/501e0ff4553c744209ca1a68790d8a4541563710", + "reference": "501e0ff4553c744209ca1a68790d8a4541563710", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:37:26+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..698758f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,27 @@ + + + + + tests + tests/Stub + + + + + Model + Repositories + Services + + + diff --git a/tests/Model/DeletedDataTest.php b/tests/Model/DeletedDataTest.php new file mode 100644 index 0000000..3ba5186 --- /dev/null +++ b/tests/Model/DeletedDataTest.php @@ -0,0 +1,38 @@ +assertNull($deleted->deletionId); + $this->assertNull($deleted->id); + $this->assertNull($deleted->deletedDate); + } + + /** + * `deletionId` is what a client pages on, so it is a field of its own rather + * than a reuse of `id`, which carries the deleted entity's id. + */ + public function testCarriesTheDeletionIdAlongsideTheDeletedEntityId(): void + { + $deleted = new DeletedData(deletionId: 82, id: 4711, deletedDate: null); + + $this->assertSame(82, $deleted->deletionId); + $this->assertSame(4711, $deleted->id); + } +} diff --git a/tests/Model/DeletedRequestParametersTest.php b/tests/Model/DeletedRequestParametersTest.php new file mode 100644 index 0000000..bdac789 --- /dev/null +++ b/tests/Model/DeletedRequestParametersTest.php @@ -0,0 +1,266 @@ +expectException(BadRequestException::class); + $this->expectExceptionMessage('type is required and must be one of: projects, milestones, tickets, timesheets.'); + + DeletedRequestParameters::fromInput([]); + } + + public function testAnEmptyTypeIsRejected(): void + { + $this->expectException(BadRequestException::class); + + DeletedRequestParameters::fromInput(['type' => '']); + } + + /** + * `type[]=tickets&type[]=timesheets` is a caller asking for two pages at once. + * Reducing it to the first element would serve a type it did not ask about. + */ + public function testAListOfTypesIsRejected(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('type must be a single value.'); + + DeletedRequestParameters::fromInput(['type' => ['tickets', 'timesheets']]); + } + + public function testWhitespaceAroundTheTypeIsIgnored(): void + { + $parameters = DeletedRequestParameters::fromInput(['type' => ' tickets ']); + + $this->assertSame('tickets', $parameters->type); + } + + /** + * An unknown type used to reach the repository's match arm, which threw with + * the raw value in the message and rendered it on the error page. + */ + public function testAnUnknownTypeIsRejectedWithoutEchoingTheInput(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('type must be one of: projects, milestones, tickets, timesheets.'); + + DeletedRequestParameters::fromInput(['type' => '