From f725af18d44ad2c1972f75653d296fd4ec9f04a3 Mon Sep 17 00:00:00 2001 From: Kyle Date: Tue, 11 Dec 2018 21:29:57 +0100 Subject: [PATCH] Related to #27 allow to add custom dynamic exceptions (#83) * Add a filter options (custom callback functions that return dynamically opening hours for a given date) * Precise offsetSet exception message * Add tests --- README.md | 25 ++++++ src/Exceptions/NonMutableOffsets.php | 11 +++ src/OpeningHours.php | 45 +++++++++-- src/OpeningHoursForDay.php | 3 +- tests/OpeningHoursFillTest.php | 115 +++++++++++++++++++++++++++ tests/OpeningHoursForDayTest.php | 11 +++ tests/OpeningHoursTest.php | 44 ++++++++++ tests/TimeRangeTest.php | 28 +++++++ 8 files changed, 275 insertions(+), 7 deletions(-) create mode 100644 src/Exceptions/NonMutableOffsets.php diff --git a/README.md b/README.md index 7e3cbaf..b7f893d 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,31 @@ $openingHours = OpeningHours::create([ ]); ``` +The last structure tool is the filter, it allows you to pass closures (or callable function/method reference) that take a date as parameter and returns the settings for the given date. + +```php +$openingHours = OpeningHours::create([ + 'monday' => [ + '09:00-12:00', + ], + 'filters' => [ + function ($date) { + $year = intval($date->format('Y')); + $easterMonday = new DateTimeImmutable('2018-03-21 +'.(easter_days($year) + 1).'days'); + if ($date->format('m-d') === $easterMonday->format('m-d')) { + return []; // Closed on Easter monday + // Any valid exception-array can be returned here (range of hours, with or without data) + } + // Else the filter does not apply to te given date + }, + ], +]); +``` + +If a callable is found in the `"exceptions"` property, it will be added automatically to filters so you can mix filters and exceptions both in the **exceptions** array. The first filter that returns a non-null value will have precedence over next filters and the **filters** array has precedence over the filters inside the **exceptions** array. + +Warning: as we will loop on all filters for each date from which we need to retrieve opening hours and cannot neither predicate nor cache the result (can be random function) so you must be careful with filters, too many filters or long process inside filters can have a significant impact on the performance. + It can also return the next open or close `DateTime` from a given `DateTime`. ```php diff --git a/src/Exceptions/NonMutableOffsets.php b/src/Exceptions/NonMutableOffsets.php new file mode 100644 index 0000000..a864565 --- /dev/null +++ b/src/Exceptions/NonMutableOffsets.php @@ -0,0 +1,11 @@ +filters = $filters; + + return $this; + } + + public function getFilters(): array + { + return $this->filters; + } + public function fill(array $data) { - list($openingHours, $exceptions, $metaData) = $this->parseOpeningHoursAndExceptions($data); + list($openingHours, $exceptions, $metaData, $filters) = $this->parseOpeningHoursAndExceptions($data); foreach ($openingHours as $day => $openingHoursForThisDay) { $this->setOpeningHoursFromStrings($day, $openingHoursForThisDay); @@ -126,7 +141,7 @@ public function fill(array $data) $this->setExceptionsFromStrings($exceptions); - return $this->setData($metaData); + return $this->setFilters($filters)->setData($metaData); } public function forWeek(): array @@ -168,6 +183,14 @@ public function forDate(DateTimeInterface $date): OpeningHoursForDay { $date = $this->applyTimezone($date); + foreach ($this->filters as $filter) { + $result = $filter($date); + + if (is_array($result)) { + return OpeningHoursForDay::fromStrings($result); + } + } + return $this->exceptions[$date->format('Y-m-d')] ?? ($this->exceptions[$date->format('m-d')] ?? $this->forDay(Day::onDateTime($date))); } @@ -291,14 +314,24 @@ public function setTimezone($timezone) protected function parseOpeningHoursAndExceptions(array $data): array { $metaData = Arr::pull($data, 'data', null); - $exceptions = Arr::pull($data, 'exceptions', []); + $exceptions = []; + $filters = Arr::pull($data, 'filters', []); + foreach (Arr::pull($data, 'exceptions', []) as $key => $exception) { + if (is_callable($exception)) { + $filters[] = $exception; + + continue; + } + + $exceptions[$key] = $exception; + } $openingHours = []; foreach ($data as $day => $openingHoursData) { $openingHours[$this->normalizeDayName($day)] = $openingHoursData; } - return [$openingHours, $exceptions, $metaData]; + return [$openingHours, $exceptions, $metaData, $filters]; } protected function setOpeningHoursFromStrings(string $day, array $openingHours) @@ -337,7 +370,7 @@ protected function normalizeDayName(string $day) $day = strtolower($day); if (! Day::isValid($day)) { - throw new InvalidDayName(); + throw InvalidDayName::invalidDayName($day); } return $day; diff --git a/src/OpeningHoursForDay.php b/src/OpeningHoursForDay.php index 0b7c93c..efcc20d 100644 --- a/src/OpeningHoursForDay.php +++ b/src/OpeningHoursForDay.php @@ -8,6 +8,7 @@ use IteratorAggregate; use Spatie\OpeningHours\Helpers\Arr; use Spatie\OpeningHours\Helpers\DataTrait; +use Spatie\OpeningHours\Exceptions\NonMutableOffsets; use Spatie\OpeningHours\Exceptions\OverlappingTimeRanges; class OpeningHoursForDay implements ArrayAccess, Countable, IteratorAggregate @@ -142,7 +143,7 @@ public function offsetGet($offset) public function offsetSet($offset, $value) { - throw new \Exception(); + throw NonMutableOffsets::forClass(static::class); } public function offsetUnset($offset) diff --git a/tests/OpeningHoursFillTest.php b/tests/OpeningHoursFillTest.php index 9493e58..5d3ba5b 100644 --- a/tests/OpeningHoursFillTest.php +++ b/tests/OpeningHoursFillTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Spatie\OpeningHours\TimeRange; use Spatie\OpeningHours\OpeningHours; +use Spatie\OpeningHours\OpeningHoursForDay; use Spatie\OpeningHours\Exceptions\InvalidDate; use Spatie\OpeningHours\Exceptions\InvalidDayName; @@ -48,6 +49,56 @@ public function it_fills_opening_hours() $this->assertCount(0, $openingHours->forDate(new DateTimeImmutable('2016-09-26 11:00:00'))); } + /** @test */ + public function it_can_map_week_with_a_callback() + { + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + 'tuesday' => ['10:00-18:00'], + 'wednesday' => ['09:00-12:00', '14:00-18:00'], + 'thursday' => [], + 'friday' => ['14:00-20:00'], + 'exceptions' => [ + '2016-09-26' => [], + ], + ]); + + $this->assertSame([ + 'monday' => 9, + 'tuesday' => 10, + 'wednesday' => 9, + 'thursday' => null, + 'friday' => 14, + 'saturday' => null, + 'sunday' => null, + ], $openingHours->map(function (OpeningHoursForDay $ranges) { + return $ranges->isEmpty() ? null : $ranges->offsetGet(0)->start()->hours(); + })); + } + + /** @test */ + public function it_can_map_exceptions_with_a_callback() + { + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-18:00'], + 'tuesday' => ['10:00-18:00'], + 'wednesday' => ['09:00-12:00', '14:00-18:00'], + 'thursday' => [], + 'friday' => ['14:00-20:00'], + 'exceptions' => [ + '2016-09-26' => [], + '10-10' => ['14:00-20:00'], + ], + ]); + + $this->assertSame([ + '2016-09-26' => null, + '10-10' => 14, + ], $openingHours->mapExceptions(function (OpeningHoursForDay $ranges) { + return $ranges->isEmpty() ? null : $ranges->offsetGet(0)->start()->hours(); + })); + } + /** @test */ public function it_can_handle_empty_input() { @@ -145,6 +196,69 @@ public function it_store_meta_data() $this->assertSame(1, $hours->forDay('wednesday')->count()); $this->assertSame(['foobar'], $hours->forDay('thursday')[0]->getData()); $this->assertNull($hours->forDay('thursday')[1]->getData()); + + $hours = OpeningHours::create([ + 'monday' => [ + ['09:00-12:00', 'morning'], + ['13:00-18:00', 'afternoon'], + ], + ]); + + $this->assertSame('morning', $hours->forDay('monday')[0]->getData()); + $this->assertSame('afternoon', $hours->forDay('monday')[1]->getData()); + } + + /** @test */ + public function it_handle_filters() + { + $typicalDay = [ + '08:00-12:00', + '14:00-18:00', + ]; + $hours = OpeningHours::create([ + 'monday' => $typicalDay, + 'tuesday' => $typicalDay, + 'wednesday' => $typicalDay, + 'thursday' => $typicalDay, + 'friday' => $typicalDay, + 'exceptions' => [ + // Closure in exceptions will be handled as a filter. + function (DateTimeImmutable $date) { + if ($date->format('Y-m-d') === $date->modify('first monday of this month')->format('Y-m-d')) { + // Big lunch each first monday of the month + return [ + '08:00-11:00', + '15:00-18:00', + ]; + } + }, + ], + 'filters' => [ + function (DateTimeImmutable $date) { + $year = intval($date->format('Y')); + $easterMonday = new DateTimeImmutable('2018-03-21 +'.(easter_days($year) + 1).'days'); + if ($date->format('m-d') === $easterMonday->format('m-d')) { + return []; // Closed on Easter monday + } + }, + function (DateTimeImmutable $date) use ($typicalDay) { + if ($date->format('m') === $date->format('d')) { + return [ + 'hours' => $typicalDay, + 'data' => 'Month equals day', + ]; + } + }, + ], + ]); + + $this->assertCount(3, $hours->getFilters()); + $this->assertSame('08:00-11:00,15:00-18:00', $hours->forDate(new DateTimeImmutable('2018-12-03'))->__toString()); + $this->assertSame('08:00-12:00,14:00-18:00', $hours->forDate(new DateTimeImmutable('2018-12-10'))->__toString()); + $this->assertSame('', $hours->forDate(new DateTimeImmutable('2018-04-02'))->__toString()); + $this->assertSame('04-03 08:00', $hours->nextOpen(new DateTimeImmutable('2018-03-31'))->format('m-d H:i')); + $this->assertSame('12-03 11:00', $hours->nextClose(new DateTimeImmutable('2018-12-03'))->format('m-d H:i')); + $this->assertSame('Month equals day', $hours->forDate(new DateTimeImmutable('2018-12-12'))->getData()); } /** @test */ @@ -152,6 +266,7 @@ public function it_should_merge_ranges_on_explicitly_create_from_overlapping_ran { $hours = OpeningHours::createAndMergeOverlappingRanges([ 'monday' => [ + '08:00-12:00', '08:00-12:00', '11:30-13:30', '13:00-18:00', diff --git a/tests/OpeningHoursForDayTest.php b/tests/OpeningHoursForDayTest.php index 354367a..b137f32 100644 --- a/tests/OpeningHoursForDayTest.php +++ b/tests/OpeningHoursForDayTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Spatie\OpeningHours\TimeRange; use Spatie\OpeningHours\OpeningHoursForDay; +use Spatie\OpeningHours\Exceptions\NonMutableOffsets; use Spatie\OpeningHours\Exceptions\OverlappingTimeRanges; class OpeningHoursForDayTest extends TestCase @@ -77,4 +78,14 @@ public function it_can_get_iterator() $this->assertCount(2, $openingHoursForDay->getIterator()->getArrayCopy()); } + + /** @test */ + public function it_cant_set_iterator_item() + { + $this->expectException(NonMutableOffsets::class); + + $openingHoursForDay = OpeningHoursForDay::fromStrings(['09:00-12:00', '13:00-18:00']); + + $openingHoursForDay[0] = TimeRange::fromString('07:00-11:00'); + } } diff --git a/tests/OpeningHoursTest.php b/tests/OpeningHoursTest.php index c0ebea4..ce2f4e0 100644 --- a/tests/OpeningHoursTest.php +++ b/tests/OpeningHoursTest.php @@ -276,6 +276,50 @@ public function it_can_determine_next_open_hours_from_non_working_date_time() $this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); } + /** @test */ + public function it_can_determine_next_open_hours_from_edges_time() + { + $openingHours = OpeningHours::create([ + 'monday' => ['09:00-11:00', '13:00-19:00'], + 'tuesday' => ['09:00-11:00', '13:00-19:00'], + ]); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 00:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-26 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 09:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 11:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 12:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-26 13:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 13:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 19:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + + $nextTimeOpen = $openingHours->nextOpen(new DateTime('2016-09-26 23:00:00')); + + $this->assertInstanceOf(DateTime::class, $nextTimeOpen); + $this->assertEquals('2016-09-27 09:00:00', $nextTimeOpen->format('Y-m-d H:i:s')); + } + /** @test */ public function it_can_determine_next_open_hours_from_non_working_date_time_immutable() { diff --git a/tests/TimeRangeTest.php b/tests/TimeRangeTest.php index 0082032..05be7ae 100644 --- a/tests/TimeRangeTest.php +++ b/tests/TimeRangeTest.php @@ -5,6 +5,8 @@ use Spatie\OpeningHours\Time; use PHPUnit\Framework\TestCase; use Spatie\OpeningHours\TimeRange; +use Spatie\OpeningHours\Exceptions\InvalidTimeRangeList; +use Spatie\OpeningHours\Exceptions\InvalidTimeRangeArray; use Spatie\OpeningHours\Exceptions\InvalidTimeRangeString; class TimeRangeTest extends TestCase @@ -23,6 +25,32 @@ public function it_cant_be_created_from_an_invalid_range() TimeRange::fromString('16:00/18:00'); } + /** @test */ + public function it_will_throw_an_exception_when_passing_a_invalid_array() + { + $this->expectException(InvalidTimeRangeArray::class); + + TimeRange::fromArray([]); + } + + /** @test */ + public function it_will_throw_an_exception_when_passing_a_empty_array_to_list() + { + $this->expectException(InvalidTimeRangeList::class); + + TimeRange::fromList([]); + } + + /** @test */ + public function it_will_throw_an_exception_when_passing_a_invalid_array_to_list() + { + $this->expectException(InvalidTimeRangeList::class); + + TimeRange::fromList([ + 'foo', + ]); + } + /** @test */ public function it_can_get_the_time_objects() {