Thursday, 2 February 2023

PHP 8: a quick look at enums

G'day:

Whilst working on my recent code implemneting a postcode look-up web service, I stumbled across Monolog/Level:

enum Level: int
{
    // …
}

I was unaware of PHP having enums, and it turns out it's a recent addition: arriving in PHP 8.1 (I had moved on from PHP when 7.2 was brand new). See PHP: Enumerations for the docs.

My usage of it was I wanted to get the string "CRITICAL" back from the log level I was testing a log entry for:

public function assertLogEntryIsCorrect(
    TestHandler $testHandler,
    Level $expectedLogLevel,
    int $statusCode,
    string $expectedMessage
): void {
    $logRecords = $testHandler->getRecords();
    $this->assertCount(1, $logRecords);
    $this->assertEquals($expectedLogLevel->getName(), $logRecords[0]["level_name"]);

Monolog/Logger has this:

public const CRITICAL = 500;

But nothing associating that const to the string "CRITICAL", yet in the log entry the level is "CRITICAL". Yeah I could have just used strtoupper, but I had suspected there was a better way, so had a look at how Monolog actually writes the log entry. I traced this back to here:

public static function getLevelName(int|Level $level): string
{
    return self::toMonologLevel($level)->getName();
}

And from there found the Level enum

I wanted to see what enums in PHP can do, so I worked through the docs, and wrote myself an example of each. I did this via TDD, so I have some tests describing the functionality (see the @testdox annotations for the descriptions):

namespace adamcameron\php8\tests\Unit\System;

use adamcameron\php8\tests\Unit\System\Fixtures\MaoriNumbers as MI;
use PHPUnit\Framework\TestCase;

/** @testdox Testing enums */
class EnumTest extends TestCase
{

    /** @testdox It can have static methods */
    public function testStaticMethods()
    {
        $this->assertEquals("one", MI::asEnglish(1));
    }

The underlying method in the enum is:

public static function asEnglish(int $i) : string
{
    return self::EN[$i - 1];
}
    /** @testdox It can have instance methods */
    public function testInstanceMethods()
    {
        $this->assertEquals("two", MI::RUA->toEnglish());
    }

The underlying method in the enum is:

public function toEnglish() : string
{
    return self::EN[$this->value - 1];
}
    /** @testdox Its name can be returned */
    public function testGetName()
    {
        $this->assertEquals("TORU", MI::TORU->name);
    }

    /** @testdox Its value can be returned */
    public function testGetValue()
    {
        $this->assertEquals(4, MI::WHĀ->value);
    }

I purposedly used the correct accented unicode charactcer in WHÄ€ to check if PHP supported non-ASCII charatcers there. Yes.


    /** @testdox It encodes to JSON OK */
    public function testJsonEncode()
    {
        $this->assertEquals('{"rima":5}', json_encode(["rima" => MI::RIMA]));
    }

    /** @testdox It cannot be type-coerced */
    public function testTypeCoercion()
    {
        $this->expectError(); // NB: not an exception; an error
        $this->expectErrorMessageMatches("/.*MaoriNumbers could not be converted to int.*/");
        $this->assertEquals(sprintf("ono: %d", MI::ONO), "ono: 6");
    }

    /** @testdox It has a from method */
    public function testFrom()
    {
        $this->assertEquals(MI::from(7), MI::WHITU);
    }

    /** @testdox It has a from method that throws an exception */
    public function testFromException()
    {
        $this->expectError(\ValueError ::class);
        $this->expectErrorMessage();
        MI::from(0);
    }

I like how IntelliJ warns me I'm going wrong trying to use 0 there:

    /** @testdox It has a tryFrom method */
    public function testTryFrom()
    {
        $this->assertEquals(MI::tryFrom(8), MI::WARU);
    }

    /** @testdox It has a tryFrom method that returns null */
    public function testTryFromNull()
    {
        $this->assertNull(MI::tryFrom(-1));
    }

    /** @testdox It will type-coerce the argument for from and tryFrom */
    public function testTryFromTypeCoercion()
    {
        $this->assertEquals(MI::from("9"), MI::IWA);
        $this->assertEquals(MI::tryFrom(10.0), MI::TEKAU);
    }

    /** @testdox It can be used in a match expression */
    public function testMatch()
    {
        $this->assertEquals("odd", MI::TAHI->getParity());
        $this->assertEquals("even", MI::RUA->getParity());
    }

The underlying method in the enum is:

public function getParity() : string
{
    return match($this) {
        self::TAHI, self::TORU, self::RIMA, self::WHITU, self::IWA => "odd",
        self::RUA, self::WHĀ, self::ONO, self::WARU, self::TEKAU => "even"
    };
}

Match expressions are new to PHP 8.0, and are an expression version of switch statements. I might need to look at these some more later on too.


    /** @testdox It can have consts, and supply the values for same */
    public function testConsts()
    {
        $this->assertEquals(MI::THREE, MI::TORU);
    }

    /** @testdox It can use traits */
    public function testTraits()
    {
        $this->assertEquals(MI::FOUR, MI::WHĀ);
    }

    /** @testdox It is an object and has a class const */
    public function testClassConst()
    {
        $this->assertEquals(get_class(MI::RIMA), MI::CLASS);
    }

    /** @testdox It supports the __invoke magic method */
    public function testInvoke()
    {
        $this->assertEquals("six", (MI::ONO)());
    }

The underlying method in the enum is:

public function __invoke() : string
{
    return $this->toEnglish();
}

    /** @testdox It has a cases method which returns a listing */
    public function testCases()
    {
        $someCases = array_slice(MI::cases(), 6, 4);
        $this->assertEquals(
            [MI::WHITU, MI::WARU, MI::IWA, MI::TEKAU],
            $someCases
        );

    }

    /** @testdox It can be serialised */
    public function testSerialize()
    {
        $this->assertMatchesRegularExpression(
            sprintf('/^E:\d+:"%s:%s";$/', preg_quote(MI::TAHI::CLASS), MI::TAHI->name ),
            serialize(MI::TAHI)
        );
    }
}

And the whole enum:

namespace adamcameron\php8\tests\Unit\System\Fixtures;

enum MaoriNumbers : int {
    case TAHI = 1;
    case RUA = 2;
    case TORU = 3;
    case WHĀ = 4;
    case RIMA = 5;
    case ONO = 6;
    case WHITU = 7;
    case WARU = 8;
    case IWA = 9;
    case TEKAU = 10;

    use MaoriNumbersConstsTrait;

    private CONST EN = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten"];

    public function toEnglish() : string
    {
        return self::EN[$this->value - 1];
    }

    public static function asEnglish(int $i) : string
    {
        return self::EN[$i - 1];
    }

    public function getParity() : string
    {
        return match($this) {
            self::TAHI, self::TORU, self::RIMA, self::WHITU, self::IWA => "odd",
            self::RUA, self::WHĀ, self::ONO, self::WARU, self::TEKAU => "even"
        };
    }

    public function __invoke() : string
    {
        return $this->toEnglish();
    }
}

This also uses a trait:

namespace adamcameron\php8\tests\Unit\System\Fixtures;

use adamcameron\php8\tests\Unit\System\Fixtures\MaoriNumbers as MI;

trait MaoriNumbersConstsTrait {
    public const ONE = MI::TAHI;
    public const TWO = MI::RUA;
    public const THREE = MI::TORU;
    public const FOUR = MI::WHĀ;
    public const FIVE = MI::RIMA;
    public const SIX = MI::ONO;
    public const SEVEN = MI::WHITU;
    public const EIGHT = MI::WARU;
    public const NINE = MI::IWA;
    public const TEN = MI::TEKAU;
}

I would not normally use a trait (in pretty much any situation), but I created this for the purposes of the testing.

That's it. I just wanted to share my code of my investigations into a language feature I was previously unaware of. I find this a helpful way of learning new stuff: putting it through its paces and formally observing the results.

Righto.

--
Adam