Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
5c5bf62
Added support for Snowflake identifiers and all variations
puzzledpolymath Jul 16, 2025
09c697e
Updated UUID and ULID keeping psalm satisfied
puzzledpolymath Jul 16, 2025
ddb97c1
Added tests for Snowflake identifiers as well as updated tests for UU…
puzzledpolymath Jul 16, 2025
5bc9eb0
Updated documentation
puzzledpolymath Jul 16, 2025
eca8ae4
Updated composer dependencies
puzzledpolymath Jul 22, 2025
76ff381
Added classes for handling identifier default values
puzzledpolymath Jul 27, 2025
70758a1
Integrated defaults into identifier classes
puzzledpolymath Jul 27, 2025
289505f
Added tests for identifier defaults
puzzledpolymath Jul 27, 2025
f9b8cc4
Updated documentation
puzzledpolymath Jul 27, 2025
df00756
Updated documentation
puzzledpolymath Jul 27, 2025
759cd17
Add base Snowflake listener
roxblnfk Aug 10, 2025
ed571a2
Refactor SnowflakeDiscord
roxblnfk Aug 10, 2025
5130bbb
Refactor SnowflakeInstagram
roxblnfk Aug 10, 2025
bf24d19
Refactor other Snowflakes
roxblnfk Aug 10, 2025
e51f831
Refactor UUID listeners
roxblnfk Aug 10, 2025
61ec5ef
style(php-cs-fixer): fix coding standards
Aug 10, 2025
fb6df50
Provides fixes after merged changes broke a lot of things
puzzledpolymath Aug 19, 2025
907158d
Provides fixes after merged changes broke a lot of things
puzzledpolymath Aug 19, 2025
259a8ba
Refactor Snowflace typecasters
roxblnfk Sep 9, 2025
376a260
Refactor UUID listeners
roxblnfk Sep 9, 2025
ea368f8
Update dependencies
puzzledpolymath Nov 1, 2025
e0b77a0
Update docblock description for each Snowflake identifier
puzzledpolymath Nov 1, 2025
70611d2
Updated Ulid following a consistent structure
puzzledpolymath Nov 1, 2025
a5e9f10
Updated Ulid following a consistent structure
puzzledpolymath Nov 1, 2025
bbbbc77
Adjustments to keep Psalm happy
puzzledpolymath Nov 2, 2025
c0e8efd
Adjusted code placement
puzzledpolymath Nov 2, 2025
233fce4
Renamed static factory method and added abstract getTypecast method
puzzledpolymath Nov 2, 2025
8b2d2c4
Implemented UUIDv1 factory and getTypecast
puzzledpolymath Nov 2, 2025
0966faf
Implemented UUIDv2 factory and getTypecast, ensure listener arg local…
puzzledpolymath Nov 2, 2025
46d4d3f
Implemented UUIDv3 factory and getTypecast, made namespace and name o…
puzzledpolymath Nov 2, 2025
8bc4244
Implemented UUIDv4 factory and getTypecast
puzzledpolymath Nov 2, 2025
163bed8
Implemented UUIDv5 factory and getTypecast, made namespace and name o…
puzzledpolymath Nov 2, 2025
605adbf
Implemented UUIDv6 factory and getTypecast
puzzledpolymath Nov 2, 2025
18e62bb
Implemented UUIDv7 factory and getTypecast
puzzledpolymath Nov 2, 2025
7fd75d3
Ensure SnowflakeDiscord getProcessId returns int within range
puzzledpolymath Nov 2, 2025
2aac843
Added UUID factory to base UUID Listener
puzzledpolymath Nov 2, 2025
2d61e62
Updated UUIDv1 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
af7cd67
Updated UUIDv2 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
e430af8
Updated UUIDv2 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
47d01a5
Updated UUIDv2 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
ef86bdd
Updated UUIDv5 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
6240cd8
Updated UUIDv6 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
3c87203
Updated UUIDv7 Listener to use factory, changed return type to more s…
puzzledpolymath Nov 2, 2025
21bc7b7
Additional test fixtures
puzzledpolymath Nov 2, 2025
f2c23d8
Updated test cases
puzzledpolymath Nov 2, 2025
2ecded9
Updated documentation
puzzledpolymath Nov 2, 2025
73c830e
style(php-cs-fixer): fix coding standards
Nov 2, 2025
cb7c1c3
Removed superfluous PHP docs
puzzledpolymath Nov 2, 2025
e54123e
Removed unused imports
puzzledpolymath Nov 2, 2025
1962700
Improved test structure for listener classes
puzzledpolymath Nov 2, 2025
8adf742
Merge remote-tracking branch 'origin/snowflake-identifier' into snowf…
puzzledpolymath Nov 2, 2025
17fc49e
Improved documentation format and content
puzzledpolymath Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Cycle ORM Entity Behavior Identifier
[![Latest Stable Version](https://poser.pugx.org/cycle/entity-behavior-Identifier/version)](https://packagist.org/packages/cycle/entity-behavior-identifier)
[![Latest Stable Version](https://poser.pugx.org/cycle/entity-behavior-identifier/version)](https://packagist.org/packages/cycle/entity-behavior-identifier)
[![Build Status](https://github.com/cycle/entity-behavior-identifier/workflows/build/badge.svg)](https://github.com/cycle/entity-behavior-identifier/actions)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/cycle/entity-behavior-identifier/badges/quality-score.png?b=1.x)](https://scrutinizer-ci.com/g/cycle/entity-behavior-identifier/?branch=1.x)
[![Codecov](https://codecov.io/gh/cycle/entity-behavior-identifier/graph/badge.svg)](https://codecov.io/gh/cycle/entity-behavior)
Expand All @@ -19,9 +19,90 @@ composer require cycle/entity-behavior-identifier

## Snowflake Examples

**Snowflake:** A distributed ID generation system developed by Twitter that produces 64-bit unique, sortable identifiers. Each ID encodes a timestamp, machine ID, and sequence number, enabling high-throughput, ordered ID creation suitable for large-scale distributed applications.
**Generic:** A flexible Snowflake format that can use a node identifier and any epoch offset, suitable for various applications requiring unique identifiers.

> **Note:** Support for Snowflake identifiers will arrive soon, stay tuned.
```php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\ORM\Entity\Behavior\Identifier;
use Ramsey\Identifier\Snowflake;

#[Entity]
#[Identifier\SnowflakeGeneric(field: 'id', node: 1, epochOffset: 1738265600000)]
class User
{
#[Column(type: 'snowflake', primary: true)]
private Snowflake $id;
}
```

**Discord:** Snowflake identifier for Discord's platform (voice, text, video), starting from epoch `2015-01-01`. Can incorporate a worker and process ID's to generate distinct Snowflakes.

```php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\ORM\Entity\Behavior\Identifier;
use Ramsey\Identifier\Snowflake;

#[Entity]
#[Identifier\SnowflakeDiscord(field: 'id', workerId: 12, processId: 24)]
class User
{
#[Column(type: 'snowflake', primary: true)]
private Snowflake $id;
}
```

**Instagram:** Snowflake identifier for Instagram's photo and video sharing platform, with an epoch starting at `2011-08-24`. Can incorporate a shard ID to generate distinct Snowflakes.

```php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\ORM\Entity\Behavior\Identifier;
use Ramsey\Identifier\Snowflake;

#[Entity]
#[Identifier\SnowflakeInstagram(field: 'id', shardId: 16)]
class User
{
#[Column(type: 'snowflake', primary: true)]
private Snowflake $id;
}
```

**Mastodon:** Snowflake identifier for Mastodon’s decentralized social network, generated within a database to ensure uniqueness and approximate order within 1ms. Can include a table name for distinct sequences per table; IDs are unique on a single database but not guaranteed across multiple machines.

```php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\ORM\Entity\Behavior\Identifier;
use Ramsey\Identifier\Snowflake;

#[Entity]
#[Identifier\SnowflakeMastodon(field: 'id', tableName: 'users')]
class User
{
#[Column(type: 'snowflake', primary: true)]
private Snowflake $id;
}
```

**Twitter:** Snowflake identifier for Twitter (X), beginning from `2010-11-04`. Can incorporate a machine ID to generate distinct Snowflakes.

```php
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\ORM\Entity\Behavior\Identifier;
use Ramsey\Identifier\Snowflake;

#[Entity]
#[Identifier\SnowflakeTwitter(field: 'id', machineId: 30)]
class User
{
#[Column(type: 'snowflake', primary: true)]
private Snowflake $id;
}
```

## ULID Examples

Expand Down Expand Up @@ -171,7 +252,7 @@ class User
}
```

You can find more information about Entity behavior UUID [here](https://cycle-orm.dev/docs/entity-behaviors-identifier).
You can find more information about Entity behavior Identifier [here](https://cycle-orm.dev/docs/entity-behaviors-identifier).

## License:

Expand Down
31 changes: 31 additions & 0 deletions src/Listener/SnowflakeDiscord.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier\Listener;

use Cycle\ORM\Entity\Behavior\Attribute\Listen;
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command\OnCreate;
use Ramsey\Identifier\Snowflake\DiscordSnowflakeFactory;

final class SnowflakeDiscord
{
public function __construct(
private string $field = 'snowflake',
private int $workerId = 0,
private int $processId = 0,
private bool $nullable = false,
) {}

#[Listen(OnCreate::class)]
public function __invoke(OnCreate $event): void
{
if ($this->nullable || isset($event->state->getData()[$this->field])) {
return;
}

$identifier = (new DiscordSnowflakeFactory($this->workerId, $this->processId))->create();

$event->state->register($this->field, $identifier);
}
}
32 changes: 32 additions & 0 deletions src/Listener/SnowflakeGeneric.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier\Listener;

use Cycle\ORM\Entity\Behavior\Attribute\Listen;
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command\OnCreate;
use Ramsey\Identifier\Snowflake\Epoch;
use Ramsey\Identifier\Snowflake\GenericSnowflakeFactory;

final class SnowflakeGeneric
{
public function __construct(
private string $field = 'snowflake',
private int $node = 0,
private Epoch|int $epochOffset = 0,
private bool $nullable = false,
) {}

#[Listen(OnCreate::class)]
public function __invoke(OnCreate $event): void
{
if ($this->nullable || isset($event->state->getData()[$this->field])) {
return;
}

$identifier = (new GenericSnowflakeFactory($this->node, $this->epochOffset))->create();

$event->state->register($this->field, $identifier);
}
}
30 changes: 30 additions & 0 deletions src/Listener/SnowflakeInstagram.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier\Listener;

use Cycle\ORM\Entity\Behavior\Attribute\Listen;
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command\OnCreate;
use Ramsey\Identifier\Snowflake\InstagramSnowflakeFactory;

final class SnowflakeInstagram
{
public function __construct(
private string $field = 'snowflake',
private int $shardId = 0,
private bool $nullable = false,
) {}

#[Listen(OnCreate::class)]
public function __invoke(OnCreate $event): void
{
if ($this->nullable || isset($event->state->getData()[$this->field])) {
return;
}

$identifier = (new InstagramSnowflakeFactory($this->shardId))->create();

$event->state->register($this->field, $identifier);
}
}
33 changes: 33 additions & 0 deletions src/Listener/SnowflakeMastodon.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier\Listener;

use Cycle\ORM\Entity\Behavior\Attribute\Listen;
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command\OnCreate;
use Ramsey\Identifier\Snowflake\MastodonSnowflakeFactory;

final class SnowflakeMastodon
{
/**
* @param non-empty-string|null $tableName
*/
public function __construct(
private string $field = 'snowflake',
private ?string $tableName = null,
private bool $nullable = false,
) {}

#[Listen(OnCreate::class)]
public function __invoke(OnCreate $event): void
{
if ($this->nullable || isset($event->state->getData()[$this->field])) {
return;
}

$identifier = (new MastodonSnowflakeFactory($this->tableName))->create();

$event->state->register($this->field, $identifier);
}
}
30 changes: 30 additions & 0 deletions src/Listener/SnowflakeTwitter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier\Listener;

use Cycle\ORM\Entity\Behavior\Attribute\Listen;
use Cycle\ORM\Entity\Behavior\Event\Mapper\Command\OnCreate;
use Ramsey\Identifier\Snowflake\TwitterSnowflakeFactory;

final class SnowflakeTwitter
{
public function __construct(
private string $field = 'snowflake',
private int $machineId = 0,
private bool $nullable = false,
) {}

#[Listen(OnCreate::class)]
public function __invoke(OnCreate $event): void
{
if ($this->nullable || isset($event->state->getData()[$this->field])) {
return;
}

$identifier = (new TwitterSnowflakeFactory($this->machineId))->create();

$event->state->register($this->field, $identifier);
}
}
62 changes: 62 additions & 0 deletions src/Snowflake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Cycle\ORM\Entity\Behavior\Identifier;

use Cycle\ORM\Entity\Behavior\Schema\BaseModifier;
use Cycle\ORM\Entity\Behavior\Schema\RegistryModifier;
use Cycle\ORM\Schema\GeneratedField;
use Cycle\Schema\Registry;
use Ramsey\Identifier\SnowflakeFactory;

abstract class Snowflake extends BaseModifier
{
protected ?string $column = null;
protected string $field;
protected bool $nullable = false;

#[\Override]
public function compute(Registry $registry): void
{
$modifier = new RegistryModifier($registry, $this->role);
$this->column = $modifier->findColumnName($this->field, $this->column);
if (\is_string($this->column) && $this->column !== '') {
$modifier->addSnowflakeColumn(
$this->column,
$this->field,
$this->nullable ? null : GeneratedField::BEFORE_INSERT,
)->nullable($this->nullable);

$factory = $this->snowflakeFactory();

$modifier->setTypecast(
$registry->getEntity($this->role)->getFields()->get($this->field),
[$factory, 'createFromInteger'],
);
}
}

#[\Override]
public function render(Registry $registry): void
{
$modifier = new RegistryModifier($registry, $this->role);
/** @var non-empty-string column */
$this->column = $modifier->findColumnName($this->field, $this->column) ?? $this->field;

$modifier->addSnowflakeColumn(
$this->column,
$this->field,
$this->nullable ? null : GeneratedField::BEFORE_INSERT,
)->nullable($this->nullable);

$factory = $this->snowflakeFactory();

$modifier->setTypecast(
$registry->getEntity($this->role)->getFields()->get($this->field),
[$factory, 'createFromInteger'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ORM schema should be serializable. That's why a static factory should be used here. Is it possible?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ramsey/identifier package has taken a different direction and moved away from static classes/factories. Not a bad thing, but it likely means we’ll need to create our own static classes/methods for instantiating these factories 🥲

While I wasn’t aware of the serialisation requirement or that it was an issue, the way I originally implemented this seemed the most logical at the time, given some factories require constructor arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry not at my desk at moment, it’s 3:20am here in Australia. But I’ll throw out any idea.

An option might be: define a static method within each behaviour class. E.g

public static function fromInteger(array $args);

Then there’s the issue of needing to supply the ramsey factories with behaviour values into its constructor. Perhaps we could support a third value in the array?

[self::class, 'fromInteger', [1, 2]

Or allow the callback method to be defined similar to how enum annotations are declared.

[self::class, 'fromInteger(1, 2)']

Obviously this would require some passing, and changes so that the supplied parameters are captured and provided back to the static method when invoked.

Would something like that work?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the ramsey/identifier package has taken a different direction and moved away from static classes/factories. Not a bad thing, but it likely means we’ll need to create our own static classes/methods for instantiating these factories 🥲

It's interesting why there is any state if we want to load already generated IDs. We won't know the state of generators for those IDs that are in the database in advance.

Copy link
Contributor Author

@puzzledpolymath puzzledpolymath Aug 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's interesting why there is any state if we want to load already generated IDs. We won't know the state of generators for those IDs that are in the database in advance.

I'm not seeing an issue when generating a schema. Or am I am misunderstanding the problem?

return [
    'account_role' => [
        ...
        Schema::TYPECAST => [
            'id' => [
                unserialize('O:51:\"Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\":6:{s:73:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0clockSequenceCounter\";i:0;s:66:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0nodeIdShifted\";i:0;s:64:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0epochOffset\";i:0;s:59:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0nodeId\";i:0;s:58:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0clock\";O:43:\"Ramsey\\Identifier\\Service\\Clock\\SystemClock\":0:{}s:61:\"\0Ramsey\\Identifier\\Snowflake\\GenericSnowflakeFactory\0sequence\";O:54:\"Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\":6:{s:68:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0defaultState\";s:12:\"c52375de3d50\";s:61:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0clock\";O:43:\"Ramsey\\Identifier\\Service\\Clock\\SystemClock\":0:{}s:61:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0cache\";O:45:\"Ramsey\\Identifier\\Service\\Cache\\InMemoryCache\":4:{s:52:\"\0Ramsey\\Identifier\\Service\\Cache\\InMemoryCache\0cache\";a:0:{}s:57:\"\0Ramsey\\Identifier\\Service\\Cache\\InMemoryCache\0defaultTtl\";i:120;s:56:\"\0Ramsey\\Identifier\\Service\\Cache\\InMemoryCache\0cacheSize\";i:100;s:52:\"\0Ramsey\\Identifier\\Service\\Cache\\InMemoryCache\0clock\";O:43:\"Ramsey\\Identifier\\Service\\Clock\\SystemClock\":0:{}}s:65:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0precision\";E:53:\"Ramsey\\Identifier\\Service\\Clock\\Precision:Millisecond\";s:68:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0initialValue\";N;s:72:\"\0Ramsey\\Identifier\\Service\\Clock\\MonotonicClockSequence\0initialValueUsed\";b:0;}}'),
                'createFromInteger',
            ],
            'accountId' => 'int',
            'parentId' => 'int',
            'name' => 'string',
            'description' => 'string',
            'locked' => 'bool',
            'createdAt' => 'datetime',
            'updatedAt' => 'datetime',
        ],
    ],
];

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the schema, there should be no function calls, especially unserialize. It should be an array that can be packed into a text cache format, like JSON.

Ideally, we need static factories.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying.

Image below depicts some changes to Cycle\ORM\Parser\Typecast which I think might aid in working around this problem with serialization.

image

Then within the base classes, instead of something like:

            $factory = $this->snowflakeFactory();

            $modifier->setTypecast(
                $registry->getEntity($this->role)->getFields()->get($this->field),
                [$factory, 'createFromInteger'],
            );

You might do:

            $modifier->setTypecast(
                $registry->getEntity($this->role)->getFields()->get($this->field),
                [self::class, 'fromInteger', $this->getListenerArgs()],
            );

Where the fromInteger method looks something like.

    public static function fromInteger(
        int|string $identifier,
        DatabaseInterface $database,
        array $arguments
    ): \Ramsey\Identifier\Snowflake
    {
        return (new GenericSnowflakeFactory(
            $arguments['node'],
            $arguments['epochOffset']
        ))->createFromInteger($identifier);
    }

All theory and completely untested. Just wanted to run the idea by you first before taking it any further.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

);
}

abstract protected function snowflakeFactory(): SnowflakeFactory;
}
Loading
Loading