diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml new file mode 100644 index 0000000..4a34685 --- /dev/null +++ b/.github/workflows/style.yml @@ -0,0 +1,29 @@ +name: styling + +on: [push] + +jobs: + style: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Run PHP CS Fixer + uses: docker://oskarstark/php-cs-fixer-ga + with: + args: --config=tools/.php-cs-fixer.php --allow-risky=yes + + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + + - name: Commit changes + uses: stefanzweifel/git-auto-commit-action@v2.3.0 + with: + commit_message: Fix styling + branch: ${{ steps.extract_branch.outputs.branch }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1e27e4d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: tests + +on: [push, pull_request] + +jobs: + phpunit: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: [8.1] + laravel: [8.*] + dependency-version: [prefer-stable] + include: + - laravel: 8.* + testbench: 6.* + + name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install SQLite + run: | + sudo apt-get update + sudo apt-get install sqlite3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv + coverage: none + + - name: Install dependencies + run: | + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + - name: Execute tests + run: vendor/bin/phpunit \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..a68e7e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build +composer.lock +vendor +storage +tests/World/database.sqlite +.DS_Store +coverage +.phpunit.result.cache +.idea +.php_cs.cache \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..7eaad29 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright © Matt Kingshott and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100755 index 0000000..a0a49ff --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +<!-- Screenshot --> +<p align="center"> + <img src="resources/wallpaper.jpg" alt="Wallpaper"> +</p> + +<!-- Badges --> +<p align="center"> + <img src="resources/version.svg" alt="Version"> + <img src="resources/license.svg" alt="License"> +</p> + +# Snowflake + +This package enables a Laravel application to create Twitter Snowflake identifiers. It is a very thin wrapper around the excellent [Snowflake PHP](https://github.com/godruoyi/php-snowflake) library created by Godruoyi. + +## What are Snowflakes? + +Snowflakes are a form of unique identifier devised by Twitter. In this respect, they are similar to other unique identifier algorithms such as UUID or ULID. + +## Why should I use them? + +I've written an [article](https://itnext.io/choosing-the-right-data-type-means-of-generating-unique-primary-keys-d7aac92968c6) exploring the benefits of Snowflakes over other unique identifiers. However, in short: + +- They consists entirely of integers. +- They uses less space (16 characters, so it fits in a `BIGINT`). +- Indexing of integers is much faster than indexing a string. +- Keys begin with a timestamp, so are sortable. +- Keys end with a random number, so guessing table size is not possible. +- Databases handle integers more efficiently than strings. +- Generation of new keys is faster (less than 1 ms). + +## Installation + +Pull in the package using Composer: + +```bash +composer require mattkingshott/snowflake +``` + +## Configuration + +Snowflake includes a configuration file that allows you to set: + +1. The data center number. +2. The worker node number. +3. The starting timestamp. +4. The sequence resolver. + +Most developers won't need to alter these values unless they need to set up a distributed architecture for generating Snowflakes. + +If you want to change any of the values, publish the configuration file using Artisan: + +```bash +php artisan vendor:publish +``` + +## Usage + +You can generate a Snowflake by resolving the service out of the container and calling its `id` method: + +```php +resolve('snowflake')->id(); // (string) "5585066784854016" +``` + +Since this is a little cumbersome, the package also registers a global `snowflake()` helper method that you can use anywhere. This helper also converts the Snowflake from a `string` into an `integer`, which better reflects its data type: + +```php +snowflake(); // (int) 5585066784854016 +``` + +### Eloquent models + +If you want to use a Snowflake as the primary key for an Eloquent model, then you'll need to perform a couple of steps. + +First, modify the model's migration so that it no longer uses auto-incrementing integers e.g. + +```php +// Before +$table->id(); + +// After +$table->unsignedBigInteger('id')->primary(); +``` + +Here's an example: + +```php +class CreateUsersTable extends Migration +{ + public function up() + { + Schema::create('users', function(Blueprint $table) { + $table->unsignedBigInteger('id')->primary(); + $table->string('name', 100); + $table->timestamps(); + }); + } +} +``` + +Finally, add the package's `Snowflakes` trait to the model: + +```php +<?php + +namespace App\Models; + +use Snowflake\Snowflakes; + +class User extends Model +{ + use Snowflakes; +} +``` + +## Contributing + +Thank you for considering a contribution to Snowflake. You are welcome to submit a PR containing improvements, however if they are substantial in nature, please also be sure to include a test or tests. + +## Support the project + +If you'd like to support the development of Snowflake, then please consider [sponsoring me](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YBEHLHPF3GUVY&source=url). Thanks so much! + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100755 index 0000000..8976ec7 --- /dev/null +++ b/composer.json @@ -0,0 +1,45 @@ +{ + "name": "mattkingshott/snowflake", + "description": "A package to create Twitter Snowflake identifiers", + "keywords": [ + "snowflake", + "php", + "laravel", + "database" + ], + "type": "library", + "license": "MIT", + "homepage": "https://github.com/mattkingshott/snowflake", + "autoload": { + "psr-4": { + "Snowflake\\": "src" + }, + "files": [ + "src/Helpers.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Snowflake\\Tests\\": "tests" + } + }, + "require": { + "php": "^8.0", + "godruoyi/php-snowflake": "^2.0" + }, + "require-dev": { + "orchestra/testbench": "^6.0", + "phpunit/phpunit": "^9.0" + }, + "extra": { + "laravel": { + "providers": [ + "Snowflake\\ServiceProvider" + ] + } + }, + "scripts": { + "test": "vendor/bin/phpunit" + }, + "minimum-stability": "stable" +} diff --git a/config/snowflake.php b/config/snowflake.php new file mode 100644 index 0000000..bc8e26e --- /dev/null +++ b/config/snowflake.php @@ -0,0 +1,57 @@ +<?php declare(strict_types = 1); + +use Godruoyi\Snowflake\RandomSequenceResolver; + +return [ + + /* + |-------------------------------------------------------------------------- + | Data Center + |-------------------------------------------------------------------------- + | + | This value represents the data center reference that should be used by + | Snowflake when generating unique identifiers. The value must be 1 - 31. + | + */ + + 'data_center' => 1, + + /* + |-------------------------------------------------------------------------- + | Worker Node + |-------------------------------------------------------------------------- + | + | This value represents the worker node reference that should be used by + | Snowflake when generating unique identifiers. The value must be 1 - 31. + | + */ + + 'worker_node' => 1, + + /* + |-------------------------------------------------------------------------- + | Start Timestamp + |-------------------------------------------------------------------------- + | + | This value represents the starting date for generating new timestamps. + | Snowflakes can be created for 69 years past this date. In most cases, + | you should set this value to the current date when building a new app. + | + */ + + 'start_timestamp' => '2022-01-01', + + /* + |-------------------------------------------------------------------------- + | Sequence Resolver + |-------------------------------------------------------------------------- + | + | This value represents the sequencing strategy that should be used to + | ensure that multiple Snowflakes generated within the same millisecond + | are unique. The default is a good choice, as it has no dependencies. + | + */ + + 'sequence_resolver' => RandomSequenceResolver::class, + +]; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..f4b880c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="vendor/autoload.php" backupGlobals="false" backupStaticAttributes="false" colors="true" verbose="true" convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" processIsolation="false" stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"> + <coverage> + <include> + <directory suffix=".php">src/</directory> + </include> + </coverage> + <testsuites> + <testsuite name="Test Suite"> + <directory>tests</directory> + </testsuite> + </testsuites> + <php> + <env name="APP_NAME" value="waterfall"/> + </php> +</phpunit> \ No newline at end of file diff --git a/resources/license.svg b/resources/license.svg new file mode 100644 index 0000000..d43fcb9 --- /dev/null +++ b/resources/license.svg @@ -0,0 +1,20 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="81" height="20"> + <linearGradient id="b" x2="0" y2="100%"> + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> + <stop offset="1" stop-opacity=".1"/> + </linearGradient> + <mask id="a"> + <rect width="81" height="20" rx="3" fill="#fff"/> + </mask> + <g mask="url(#a)"> + <rect width="50" height="20" fill="#555"/> + <rect x="50" width="31" height="20" fill="#428F7E"/> + <rect width="81" height="20" fill="url(#b)"/> + </g> + <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="26" y="15" fill="#010101" fill-opacity=".3">license</text> + <text x="26" y="14">license</text> + <text x="64.5" y="15" fill="#010101" fill-opacity=".3">MIT</text> + <text x="64.5" y="14">MIT</text> + </g> +</svg> \ No newline at end of file diff --git a/resources/version.svg b/resources/version.svg new file mode 100644 index 0000000..fafce6e --- /dev/null +++ b/resources/version.svg @@ -0,0 +1,20 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="92" height="20"> + <linearGradient id="b" x2="0" y2="100%"> + <stop offset="0" stop-color="#bbb" stop-opacity=".1"/> + <stop offset="1" stop-opacity=".1"/> + </linearGradient> + <mask id="a"> + <rect width="92" height="20" rx="3" fill="#fff"/> + </mask> + <g mask="url(#a)"> + <rect width="45" height="20" fill="#555"/> + <rect x="45" width="47" height="20" fill="#28a3df"/> + <rect width="92" height="20" fill="url(#b)"/> + </g> + <g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11"> + <text x="23.5" y="15" fill="#010101" fill-opacity=".3">stable</text> + <text x="23.5" y="14">stable</text> + <text x="67.5" y="15" fill="#010101" fill-opacity=".3">v1.0.0</text> + <text x="67.5" y="14">v1.0.0</text> + </g> +</svg> diff --git a/resources/wallpaper.jpg b/resources/wallpaper.jpg new file mode 100644 index 0000000..c3caa75 Binary files /dev/null and b/resources/wallpaper.jpg differ diff --git a/src/Helpers.php b/src/Helpers.php new file mode 100644 index 0000000..c45ca86 --- /dev/null +++ b/src/Helpers.php @@ -0,0 +1,10 @@ +<?php declare(strict_types = 1); + +/** + * Generate a new Snowflake identifier. + * + */ +function snowflake() : int +{ + return (int) resolve('snowflake')->id(); +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..d24c92f --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,49 @@ +<?php declare(strict_types=1); + +namespace Snowflake; + +use Godruoyi\Snowflake\Snowflake; +use Godruoyi\Snowflake\RandomSequenceResolver; +use Illuminate\Support\ServiceProvider as Provider; + +class ServiceProvider extends Provider +{ + /** + * Bootstrap any package services. + * + */ + public function boot() : void + { + $this->publishes([__DIR__ . '/../config/snowflake.php' => config_path('snowflake.php')]); + } + + /** + * Register any package services. + * + */ + public function register() : void + { + $this->mergeConfigFrom(__DIR__ . '/../config/snowflake.php', 'snowflake'); + + $this->app->singleton('snowflake', fn() => $this->singleton()); + } + + /** + * Register the Snowflake singleton service. + * + */ + protected function singleton() : Snowflake + { + $service = new Snowflake( + config('snowflake.data_center', 1), + config('snowflake.worker_node', 1) + ); + + $timestamp = strtotime(config('snowflake.start_timestamp', '2022-01-01')) * 1000; + $resolver = config('snowflake.sequence_resolver', RandomSequenceResolver::class); + + return $service + ->setStartTimeStamp($timestamp) + ->setSequenceResolver(new $resolver()); + } +} diff --git a/src/Snowflakes.php b/src/Snowflakes.php new file mode 100644 index 0000000..1e925fb --- /dev/null +++ b/src/Snowflakes.php @@ -0,0 +1,28 @@ +<?php declare(strict_types=1); + +namespace Snowflake; + +trait Snowflakes +{ + /** + * Bootstrap the trait. + * + */ + public static function bootSnowflakes() : void + { + static::creating(function($model) { + if (! $model->getKey()) { + $model->{$model->getKeyName()} = snowflake(); + } + }); + } + + /** + * Disable auto-incrementing integers. + * + */ + public function getIncrementing() : bool + { + return false; + } +} \ No newline at end of file diff --git a/tests/Test.php b/tests/Test.php new file mode 100644 index 0000000..bbc0c7c --- /dev/null +++ b/tests/Test.php @@ -0,0 +1,36 @@ +<?php declare(strict_types=1); + +namespace Snowflake\Tests; + +use Snowflake\ServiceProvider; +use Orchestra\Testbench\TestCase; + +class Test extends TestCase +{ + /** + * Setup the test environment. + * + */ + protected function setUp() : void + { + parent::setUp(); + + (new ServiceProvider(app()))->register(); + } + + /** @test */ + public function it_can_resolve_the_snowflake_service_and_generate_an_identifier() : void + { + $this->assertTrue(is_string(resolve('snowflake')->id())); + + $this->assertEquals(16, strlen(resolve('snowflake')->id())); + } + + /** @test */ + public function it_can_generate_a_snowflake_identifier_using_the_global_helper() : void + { + $this->assertTrue(is_int(snowflake())); + + $this->assertEquals(16, strlen((string) snowflake())); + } +} diff --git a/tools/.php-cs-fixer.php b/tools/.php-cs-fixer.php new file mode 100644 index 0000000..7378429 --- /dev/null +++ b/tools/.php-cs-fixer.php @@ -0,0 +1,52 @@ +<?php declare(strict_types = 1); + +$finder = Symfony\Component\Finder\Finder::create() + ->notPath(dirname(__DIR__, 1) . '/bootstrap/*') + ->notPath(dirname(__DIR__, 1) . '/storage/*') + ->notPath(dirname(__DIR__, 1) . '/vendor') + ->notPath(dirname(__DIR__, 1) . '/resources/view/mail/*') + ->in([ + dirname(__DIR__, 1) . '/src', + dirname(__DIR__, 1) . '/tests', + ]) + ->name('*.php') + ->notName('*.blade.php') + ->ignoreDotFiles(true) + ->ignoreVCS(true); + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PSR2' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'length'], + 'no_unused_imports' => true, + 'not_operator_with_successor_space' => true, + 'trailing_comma_in_multiline' => ['elements' => ['arrays']], + 'phpdoc_scalar' => true, + 'unary_operator_spaces' => true, + 'binary_operator_spaces' => [ + 'operators' => ['=' => 'align', '=>' => 'align'], + ], + 'blank_line_before_statement' => [ + 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], + ], + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_var_without_name' => true, + 'class_attributes_separation' => [ + 'elements' => [ + 'method' => 'one', + 'property' => 'one', + ], + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + 'keep_multiple_spaces_after_comma' => true, + ], + 'method_chaining_indentation' => true, + 'object_operator_without_whitespace' => true, + 'no_superfluous_phpdoc_tags' => true, + 'function_declaration' => [ + 'closure_function_spacing' => 'none', + ], + ]) + ->setFinder($finder);