thephpleague / period

PHP's time range API

Home Page:https://period.thephpleague.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Should we add a PeriodInterface or not ?

nyamsprod opened this issue · comments

I'm currently developping the new major release of League\Period.
What's new in this new release apart from deprecations and removal of old PHP versions support is the addition of a League\Period\Collection class and a League\Period\PeriodInterface interface.

The main issue is do we really need a PeriodInterface ?

Pros

  • This interface is uses by the Period class and the Collection which let's people implements their own Period class if they want knowing the the Collection class will always works for them.
  • This makes making the Period class a final class easier as you can rely on a interface

Cons

  • The interface does not contains all the methods of the Period class because:
    • most of the Period modifier methods can be rebuilt using PeriodInterface::startingOn and PeriodInterface::endingOn. for instance PeriodInterface::withDurationBeforeEnd is built using PeriodInterface::startingOn so you don't need it in the interface;
    • all the comparisons methods uses PeriodInterface::compareDuration so their is no need IMHO to add a PeriodInterface::greaterThan method in the interface for instance.
  • Period most valuable methods are its named constructors which by definition won't be in an interface
  • While Period implements the interface, the class also uses the parameter widening concept to allow more flexibility on users input without having to add more methods. In other words, the interface only exposes a fraction of what Period can do. This means that the Collection object is restricted to a set of method for better interoperability.

TL;DR: do we really need the PeriodInterface at all ?

Thoughts @shadowhand @frankdejonge @bpolaszek ?

I think having final class Period along with PeriodInterface is a good approach. I would suggest adding one more class:

abstract class PeriodProxy implements PeriodInterface
{
    /** var PeriodInterface */
    protected $period;

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

    // implement every method in period with as a proxy call to $this->period
}

This makes it far easier for any developer that wants to customize Period with additional methods to do so. It becomes as simple as:

class MyPeriod extends PeriodProxy
{
    public function getStartDate(): DateTimeImmutable
    {
        // or any other method i want to customize
        // all other methods work as normal
    }
}

Isn't a period just a value object? What any extension could simply, instead of inheriting from a base class, use the Period class instead. I'd advice against this because it's a whole different level of BC you'll now have to support. Even protected method changes will be BC breaks. It's not an enjoyable thing to have to do. I strongly recommend against it.

@frankdejonge yes Period is a value object.

Before (ie in v3) it was only protected, now it is made final. Making it final prevents any extension by inheritance.

Currently in the master I've added:
1 - the interface League\Period\Interval
2 - An implementing final class League\Period\Period
3 - An abstract proxy class for those who wants to add more functionalities League\Period\ProxyInterval build using an internal League\Period\Interval implementing object.

I'll be honest I'm not totally sold on the Interface addition hence this issue being brought on on regular basis. If we choose not to include the Interface, then the proxy class will have no meaning to still be included in the library.

Ah, ok, I read over that, sorry! This makes it a lot less harmful for maintaining it and it gives a better light on how @shadowhand imagined it. Personally I'd still recommend against using such a proxy and I always promote composition instead it because it's clearer, interfaces are smaller thus more effective. But for quick wins I guess it's ok. 🤷‍♂️

@frankdejonge indeed I too prefer composition that's the main reason why this issue exists.

  • Should we promote composition by removing the Interface ?
  • Does having an Interface rules out automatically the fact that composition should be preferred over inheritance ?

We should/must be clear about this on the get/go so that issues related to this discussion can be clearly responded to

@shadowhand @frankdejonge I've been undecided about what to do as the new version is feature complete and until this is resolve I won't release the new version. I think I'm going to go opinionated and remove the Interface. Like @frankdejonge pointed Period is a immutable value object so the best way to use Period is via composition so let's promote this way of coding.

Looking at some recently released code in another popular package made me realize that introducing an interface even for the sake of making the code more "interoperable" as the negative side effect of restricting any VO usage even making some dangerous assumption about the code you are using. As such let's favor composition over inheritance and remove the Interface.

@nyamsprod My opinion is the following:

  • Period should be a simple, immutable value object - its only responsibility is to handle a startDate and an endDate and some helper-getters to read info about them (getDateInterval(), getTimestampInterval(), compareDuration(), ...). Some helper-cloners methods may be added in order to clone the object with different start / end dates, i. e. withDurationAfterStart(), ... We're then in a true value object, which just handles values and reads them. It indeed doesn't need any interface at all and can be marked final.
  • What a developer may want to change is the process of instanciating Periods. My use case was that I needed periods from A 00:00:00 to B 23:59:59 (instead of default B 00:00:00), and keeping the same behavior and helpers for the whole library. So I suggest creating a PeriodFactory along with a PeriodFactoryInterface, responsible of creating, and even cloning, Period objects. As a consequence, we remove all static methods from Period.

So what comes to my mind is a design like this:

Period, a final, simple value object with accessors

namespace League\Period;

use DateInterval;
use DateTimeImmutable;
use InvalidArgumentException;

final class Period
{

    /**
     * @var DateTimeImmutable
     */
    private $startDate;

    /**
     * @var DateTimeImmutable
     */
    private $endDate;

    /**
     * @var PeriodFactoryInterface
     */
    private $factory;

    public function __construct(DateTimeImmutable $startDate, DateTimeImmutable $endDate, PeriodFactoryInterface $factory = null)
    {
        if ($startDate > $endDate) {
            throw new InvalidArgumentException('The ending datepoint must be greater or equal to the starting datepoint');
        }
        $this->startDate = $startDate;
        $this->endDate = $endDate;
        $this->factory = $factory ?? new PeriodFactory();
    }

    public function getStartDate(): DateTimeImmutable
    {
        return $this->startDate;
    }

    public function getEndDate(): DateTimeImmutable
    {
        return $this->endDate;
    }

    public function getDateInterval(): DateInterval
    {
        return $this->startDate->diff($this->endDate);
    }

    // ...

    public function endingOn($datepoint): self
    {
        return $this->factory->endingOn($this, $datepoint);
    }

}

A contract for instanciating Period objects

namespace League\Period;

use DatePeriod;

interface PeriodFactoryInterface
{

    /**
     * @param mixed $startDate
     * @param mixed $endDate
     * @return Period
     */
    public function createFromDatepoints($startDate, $endDate): Period;

    /**
     * The implementation MUST throw an \InvalidArgumentException if $datePeriod contains no end date.
     * 
     * @param DatePeriod $datePeriod
     * @return Period
     */
    public function createFromDatePeriod(DatePeriod $datePeriod): Period;

    /**
     * @param Period $period
     * @param        $datepoint
     * @return Period
     */
    public function endingOn(Period $period, $datepoint): Period;

    // ...

}

A built-in Period factory

namespace League\Period;

use DatePeriod;
use DateTimeInterface;
use InvalidArgumentException;

final class PeriodFactory implements PeriodFactoryInterface
{
    /**
     * @inheritDoc
     */
    public function createFromDatepoints($startDate, $endDate): Period
    {
        return new Period(datepoint($startDate), datepoint($endDate), $this);
    }

    /**
     * @inheritDoc
     */
    public function createFromDatePeriod(DatePeriod $datePeriod): Period
    {
        if (!$datePeriod->getEndDate() instanceof DateTimeInterface) {
            throw new InvalidArgumentException('The submitted DatePeriod object does not contain an end datepoint');
        }
        return new Period(datepoint($datePeriod->getStartDate()), datepoint($datePeriod->getEndDate()), $this);
    }

    /**
     * @inheritDoc
     */
    public function endingOn(Period $period, $datepoint): Period
    {
        return new Period($period->getStartDate(), datepoint($datepoint), $this);
    }

    // ...

}

The developer can create his own

namespace MyApp\Period;

use DatePeriod;
use DateTimeImmutable;
use DateTimeInterface;
use InvalidArgumentException;
use function League\Period\datepoint;
use League\Period\Period;
use League\Period\PeriodFactoryInterface;

final class BpolaszekPeriodFactory implements PeriodFactoryInterface
{
    /**
     * @inheritDoc
     */
    public function createFromDatepoints($startDate, $endDate): Period
    {
        return new Period(datepoint($startDate), $this->ensure235959($endDate), $this);
    }
    /**
     * @inheritDoc
     */
    public function createFromDatePeriod(DatePeriod $datePeriod): Period
    {
        if (!$datePeriod->getEndDate() instanceof DateTimeInterface) {
            throw new InvalidArgumentException('The submitted DatePeriod object does not contain an end datepoint');
        }
        return new Period(datepoint($datePeriod->getStartDate()), $this->ensure235959($datePeriod->getEndDate()), $this);
    }

    /**
     * @inheritDoc
     */
    public function endingOn(Period $period, $datepoint): Period
    {
        return new Period($period->getStartDate(), $this->ensure235959($datepoint), $this);
    }

    private function ensure235959($datepoint): DateTimeImmutable
    {
        // if this fails, it's none of @nyamsprod's business
        return datepoint($datepoint)->modify('midnight -1 second'); // todo improve
    }

    // ...

}

What do you think?

@bpolaszek I like the idea of removing the factory part of Period but I don't like the idea of creating a Factory and a Factory interface. I mean depending on business requirement what you call a month or week may drastically change so making it part of any interface is an open can of worms don't you think ?
I would however maybe consider creating small functions in the Period namespace like

use League\Period;

$period = Period\month(2018, 9); 
$altPeriod = Period\month_from_datepoint('2018-09-15');
$period->equalsTo($altPeriod); // should return true

And remove any createFromXXX named constructors from the main class. Leaving anyone who has a different definition to create his own Period Factory.

But that's a different topic maybe we should create a separate issue for that

@bpolaszek I like the idea of removing the factory part of Period but I don't like the idea of creating a Factory and a Factory interface. I mean depending on business requirement what you call a month or week may drastically change so making it part of any interface is an open can of worms don't you think ?

I honestly don't think so.

With the PeriodFactoryInterface the only contract is to instanciate Period objects. Whether or not a business requirement changes the internal values of a Period object is none of your business, the job is done anyway.

Another use case I can see is a createWeek() function. Most of the time (and according to international standard ISO 8601) a week begins on monday. So $periodFactory->createWeek('2018-09-06') should return a Period starting at 2018-09-03 to 2018-06-09. But wait, I'm an American developer. Then $myOwnPeriodFactory->createWeek('2018-09-06') will return a Period starting at 2018-09-02 to 2018-06-08. Both Period and PeriodFactory are still valid, aren't they?

Another use case I can see is a createWeek() function. Most of the time (and according to international standard ISO 8601) a week begins on monday. So $periodFactory->createWeek('2018-09-06') should return a Period starting at 2018-09-03 to 2018-06-09. But wait, I'm an American developer. Then $myOwnPeriodFactory->createWeek('2018-09-06')will return a Period starting at2018-09-02to2018-06-08. Both Period and PeriodFactory `are still valid, aren't they?

I expect any implementation of any interface to always returns the same result (maybe with a different implementation but the same input should returns the same output minus 🐛 ) otherwise there is simply no point in having an interface. So even thought the returned Period is valid the PeriodFactoryInterface contract is not respected.
Let's take a simpler example ... DateTime and DateTimeImmutable shares the same methods and the same methods signature BUT their common interface DateTimeInterface only exposes their getter methods Why ? because otherwise the contract would be broken. because even thought both classes share the same modifiers methods signature their return type are completely different. By only exposing the getter methods the interface make sense if you add the modifier methods then all is broken because you've broken code expectation.

As a sidenote that is why I renamed createFromWeek to createFromISOWeek to make it more clear. And to remove any ambiguity.

DateTime and DateTimeImmutable shares the same methods and the same methods signature BUT their common interface DateTimeInterface only exposes their getter methods.

Hm, that's not completely true.

DateTimeInterface::diff(), for instance, is a contract for a DateInterval factory.

I can create my own class CompletelyStupidDateTime implements DateTimeInterface implementation and, when CompletelyStupidDateTime::diff() is called, I can return a DateInterval object with a random value in it, because the contract absolutely doesn't care about implementation requirements. As long as I return a DateInterval object, the contract cannot be considered broken.

On the other hand, DateTimeInterface::modify() does not exist because it can either return a DateTime or a DateTimeImmutable which do not technically behave the same way: this would indeed be a Liskov violation.

The PeriodFactoryInterface I suggest would only provide final, well defined, Period objects (no PeriodInterface). So we're not talking about getters or modifiers here, we're talking about object instanciation. And having interfaces for factories is something quite common, isn't it?

Besides, since PHP 7 the return type can be enforced. So, no matter how you implement the interface, the implementation MUST return a Period object (even with dummy values) or the contract will be broken. A TypeError will be raised in such case.

As a sidenote that is why I renamed createFromWeek to createFromISOWeek to make it more clear. And to remove any ambiguity.

If you rename a method createFromWeek to createFromISOWeek() you're assuming people using your library will necessarily create weeks starting on Mondays. To my eyes, this is an implementation enforcement and has nothing to deal with the concept of Period. Because the concept of week can be different from one point of the world to another, the concept of a period isn't.

I can create my own class CompletelyStupidDateTime implements DateTimeInterface implementation and, when CompletelyStupidDateTime::diff() is called, I can return a DateInterval object with a random value in it, because the contract absolutely doesn't care about implementation requirements. As long as I return a DateInterval object, the contract cannot be considered broken.

That's where we differ in interpretation, the Interface includes the return type and the docblock comments/verbal or written informationx which is associated with it. For instance DateTimeInterface::diff does not simply return a random DateInterval object. It returns

Returns the difference between two DateTime objects

Those objects are the subject DateTime and the argument DateTime object you compare to it as defined in the PHP documentation website.

If you choose to return any kind of DateInterval object you are breaking the contract. Maybe a PHP static analyzer or lack of test suite won't catch this error but it is a clear violation of the expected contract.

If you rename a method createFromWeek to createFromISOWeek() you're assuming people using your library will necessarily create weeks starting on Mondays.

No I expect people who uses this named constructor to know the difference between an ISO week and a "standard" week aka a period of 7 days which can start whenever you want. If they required another week definition they can simply use the default constructor or the createFromDurationAfterStart named constructors. Or just build the factory they want. Bottom like the best factory right now on the Period class is its constructor.

And If I remove all the named constructor and replace them with tiny function is becomes even clearer

That's where we differ in interpretation, the Interface includes the return type and the docblock comments/verbal or written information which is associated with it. For instance DateTimeInterface::diff does not simply return a random DateInterval object. It returns "the difference between two DateTime objects"

We indeed differ in interpretation. To my eyes, an interface must have no knowledge of how right or wrong the implementation should work. Consider this example:

interface PersonInterface
{

    /**
     * The implementation MUST return "hello".
     * 
     * @return string
     */
    public function sayHello(): string;
    
}

final class Asshole implements PersonInterface
{
    /**
     * @inheritDoc
     */
    public function sayHello(): string
    {
        return 'fuck you';
    }

}

This is completely valid because in this case, checking the validity of the returned content is the domain responsibility, not the contract one.

Returning a DateInterval with a random value will still return a difference between 2 DateTime objects, which can be right, wrong, or mocked. Of course these examples are extreme and insane, but if an interface says too much about what the implementation must do (apart from when it should throw an exception, for instance), then this interface is completely useless, in my humble opinion.

I believe you are mistaking return types and interfaces. If I'm only looking at return types your are right but Interfaces are more than just stubs with clearly defined return types. The metadata released with the Interface is as important. For instance if I were to follow your thought I could never rely on PSR-7 and it's withXXX methods. as the return type returns states to return the same object BUT the metadata (comments/phpdoc) states returns the same object with property X changed. If I were to follow your principle then it means that avec using one of those method I should double check that what I expected really happened 😢 . This kills the principle of least astonishment.

To stay on topic. Named constructors are just simple functions to generate specific Period instance If they do not fit your business requirement that's OK there's still the main __construct class that can be use to create virtually any type of Period object so I fail to see the added value of having a PeriodFactoryInterface which If I follow your rationality would just be something like this

interface PeriodModifierInterface
{
     public function process(Period $period): Period;
}

final class PeriodMinus1Second implements PeriodModifierInterface
{
     public function process(Period $period): Period
     {
          //naive implementation
          return $period->endingOn($period->getEndDate()->sub(new DateInterva('PT1S')));
     }
}
$period = (new PeriodMinus1Second())->process(Period::createFromMonth(2018, 9));

This is also the reason why I think have them as companion functions define in the League\Period namespace as opposed to having them as named constructor as you suggested would IMO improve their usage/understanding by disconnecting them from the main VO

@nyamsprod after reviewing all the responses here, I think that @frankdejonge is right. There is little to no value in having a proxy object. Just leave Period as a final VO and call it done. Composition over inheritance.

@shadowhand that's what I've done already. I'll consider this issue resolve. Thanks everyone for participating 👍