22

Filtering Carbon Period For Flexibility And Performance

 2 years ago
source link: https://tomerbe.co.uk/articles/filtering-carbon-period-for-flexability-and-performance
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

💡 The Solution

I used a couple different concepts within PHP to create, what I feel, is a well structured, fast (can probably be faster) and easy to extend/maintain system. To start, let’s look at a basic example. Assuming each of our users have their working hours defined in the following format:

$workingHours = [
	1 => [
		'day' => 1, // Monday
		'start_time' => '09:00',
		'end_time' => '17:00',
	],
	3 => [
		'day' => 3, // Wednesday
		'start_time' => '12:00',
		'end_time' => '17:00',
	],
	// ...
];

We could in a naive way write this as follows, without filters:

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes')->toArray();

$availableDates = [];

foreach($dates as $date) {
	// Check working hours
	$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
	$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

	if(
		$date->greaterThanOrEqualTo($startTime)
		&& $date->lessThanOrEqualTo($endTime)
	) {
		$availableDates[] = $date;
	}
}

The example above may not be perfect. But it would get the job done. We construct two Carbon instances to represent the time we start work on the same day, and the time we finish work. We then check to see if we’re between our working hours. If we are, great the date is available.

This would work, our first consideration is handled. We’re looking over known slots. Our second consideration could also be met, there are a couple of ways we could tackle adding more conditions; we could chain more optional checks onto the if statement, preventing earlier checks from being executed. Or we could add additional if conditions to handle those. Both of these options didn’t feel right though.

So, lets see how filters can help with this.

Carbon Period Filters

Our example above would be rewritten as follows:

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(function($date) {
	$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
	$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

	return
		$date->greaterThanOrEqualTo($startTime)
		&& $date->lessThanOrEqualTo($endTime)
});

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

While the amount of code is very similar, using filters like this offers two key benefits:

  1. You’re not using memory up front as you don’t start constructing your available dates until you call toArray or start looping over the results.
  2. Each filter is isolated in it’s own call, keeping it easier to maintain.

Filters in the Carbon Period are also stored in a stack and are run after each other. You can add as many filters as you like. The first the returns false will “remove” that date from the range and no other filters will be run. Meaning the deeper your filters get the, technically, less dates it has to process. This means your more processing heavy filters should be last. In the example I gave at the beginning of this article, this means data where we need to call as external service should be last, as we may never get that far so save the need to process it.

It’s worth noting too that passing a string to addFilter will call the function on the Carbon instance. For example:

$period->addFilter('isWeekday');

This will only include dates which are on a weekday.

Carbon Period offers some additional helpers to work with the filter stack, these are:

  • prependFilter which will add your filter to the start of the filters stack, making it get called first.
  • hasFilter returns true or false on whether the given filter exists on the Carbon Period.
  • setFilters replaces the filter stack with what you provide.
  • resetFilters clears all filters defined on the period.

Let’s tacking making this a bit more organised and easier to expand on and maintain moving forward.

Invokable Classes

The addFilter method requires a callable to be passed. This means you cannot simply pass a class instance. However, if you make the class invokable it will be treated as a callable. Allowing you to create small classes to handle the filtering for you, if needed you can also pass through state based data in the constructor. This has multiple benefits including:

  1. Your filtering logic is kept in it’s own class file
  2. Your filters become reusable on multiple carbon period instances
  3. Your filtering logic is kept isolated from the rest of your application, or other filters

Let’s take our example above and move it into an invokable class, for the purposes of this post it’ll all be in a single “file” but in your application these would be different files.

class WorkingHoursFilter {
	public function __invoke($date) {
		$startTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['start_time']);
		$endTime = $date->copy()->setTimeFromTimeString($workingHours[$date->dayOfWeek]['end_time']);

		return
			$date->greaterThanOrEqualTo($startTime)
			&& $date->lessThanOrEqualTo($endTime)
	}
}

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(new WorkingHoursFilter);

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

As you can see above, our code is the same. However, we’re organising our filters in a much better way, by keeping them separate from the rest of the code and reusable across our application.

We can take this a step further and allow our filter to accept state data that is valid for the entire period. For example, what if we’re checking this for multiple users, we would want to pass the working hours into our filter:

class WorkingHoursFilter {
	protected $workingHours;

	public function __construct($workingHours) {
		$this->workingHours = $workingHours;
	}

	public function __invoke($date) {
		$startTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['start_time']);
		$endTime = $date->copy()->setTimeFromTimeString($this->workingHours[$date->dayOfWeek]['end_time']);

		return
			$date->greaterThanOrEqualTo($startTime)
			&& $date->lessThanOrEqualTo($endTime)
	}
}

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates->addFilter(new WorkingHoursFilter($workingHours));

$availableDates = $dates->toArray();

// or

foreach($dates as $date) { ... }

PHP will keep the passed in state in the constructor for each invocation of the class.

Now for a slightly more complete example, none of the filter classes are included here but it should give the idea of how powerful this feature can be.

$startDate = '2021-11-27 00:00:00'; // Friday
$dates = $startDate->toPeriod('2021-11-29 00:00:00', 30, 'minutes');

$dates
	->addFilter(new WorkingHoursFilter($workingHours))
	->prependFilter(new IsPastFilter())
	->addFilter(new AbsenceFilter($absences))
	->addFilter(new OtherBookingsFilter($absences))
	->addFilter(new ExternalCalendarFilter($events))
;

$availableDates = $dates->toArray(); // This is our filterd range of dates

// or

foreach($dates as $date) {
	// $date will always be a date that has passed all of our filters.
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK