diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index c3dbd71..6df45c4 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,49 +3,174 @@ name: Checks on: push: branches: - - '**' + - main + tags: + - 'v*.*.*' pull_request: - types: - - opened - - synchronize # when a PR is updated - - reopened # when a PR is reopened - - ready_for_review # when a PR is ready for review - - review_requested # when a PR is requested for review + +permissions: + contents: read + +concurrency: + group: checks-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - ci: - name: Checks + base-sdk: + name: Base SDK (PHP ${{ matrix.php }}) runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: + - '8.0' + - '8.5' steps: - - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v7 - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' - # extensions: mbstring, intl, dom + php-version: ${{ matrix.php }} coverage: none + tools: composer:v2, phpunit:9.6.35 + + - name: Install base SDK dependencies + run: composer install --no-dev --no-interaction --prefer-dist --no-progress + + - name: Test base SDK without OpenFeature + run: phpunit --bootstrap vendor/autoload.php --exclude-group openfeature tests + + openfeature: + name: OpenFeature (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: + - '8.0' + - '8.5' + steps: + - name: Check out repository + uses: actions/checkout@v7 - - uses: actions/setup-node@v4 + - name: Set up PHP + uses: shivammathur/setup-php@v2 with: - node-version-file: '.nvmrc' + php-version: ${{ matrix.php }} + coverage: none + tools: composer:v2 - name: Install dependencies - run: composer install + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Test OpenFeature provider + run: vendor/bin/phpunit --group openfeature tests - - name: SAST + quality: + name: Quality + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + tools: composer:v2 + + - name: Validate Composer configuration + run: composer validate --strict + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Audit dependencies + run: composer audit --locked --no-interaction + + - name: Static analysis run: composer sast - - name: Test + - name: Run complete unit suite run: composer test - - name: Setup Featurevisor example-1 project + package-boundaries: + name: Package boundaries + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + tools: composer:v2 + + - name: Verify base SDK without OpenFeature + shell: bash + run: | + mkdir "${{ runner.temp }}/featurevisor-base-consumer" + cd "${{ runner.temp }}/featurevisor-base-consumer" + composer init --name=featurevisor/base-consumer --no-interaction + composer config minimum-stability dev + composer config prefer-stable true + composer config repositories.featurevisor "{\"type\":\"path\",\"url\":\"$GITHUB_WORKSPACE\",\"options\":{\"symlink\":false}}" + composer require featurevisor/featurevisor-php:@dev --no-interaction --prefer-dist --no-progress + php -r 'require "vendor/autoload.php"; $f = Featurevisor\Featurevisor::createFeaturevisor(["logLevel" => "fatal"]); if ($f->getSchemaVersion() !== "2" || class_exists("Featurevisor\\Logger") || class_exists("Featurevisor\\Internal\\Logger") || class_exists(OpenFeature\OpenFeatureAPI::class)) { exit(1); }' + if composer show psr/log --no-interaction >/dev/null 2>&1; then exit 1; fi + + - name: Verify SDK with OpenFeature + shell: bash + run: | + mkdir "${{ runner.temp }}/featurevisor-openfeature-consumer" + cd "${{ runner.temp }}/featurevisor-openfeature-consumer" + composer init --name=featurevisor/openfeature-consumer --no-interaction + composer config minimum-stability dev + composer config prefer-stable true + composer config repositories.featurevisor "{\"type\":\"path\",\"url\":\"$GITHUB_WORKSPACE\",\"options\":{\"symlink\":false}}" + composer require featurevisor/featurevisor-php:@dev open-feature/sdk:^2.2 --no-interaction --prefer-dist --no-progress + php -r 'require "vendor/autoload.php"; $p = new Featurevisor\OpenFeatureProvider(["logLevel" => "fatal"]); if ($p->getMetadata()->getName() !== "Featurevisor") { exit(1); } $p->shutdown();' + + example-project: + name: Featurevisor example-1 + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.5' + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + package-manager-cache: false + + - name: Set up Featurevisor example-1 project run: | mkdir example-1 - (cd example-1 && npx @featurevisor/cli@2.x init --example=1) + (cd example-1 && npx --yes @featurevisor/cli@3 init --example=1) (cd example-1 && npm install) - (cd example-1 && npx featurevisor test) + (cd example-1 && npx featurevisor build) + (cd example-1 && npx featurevisor test --onlyFailures) - name: Run Featurevisor project tests against PHP SDK - run: (cd ./example-1 && ../featurevisor test) + run: ./featurevisor test --projectDirectoryPath=./example-1 --onlyFailures --quiet diff --git a/.github/workflows/packagist.yml b/.github/workflows/packagist.yml index 581efbf..155a3e1 100644 --- a/.github/workflows/packagist.yml +++ b/.github/workflows/packagist.yml @@ -5,22 +5,51 @@ on: tags: - 'v*' +permissions: + contents: read + +concurrency: + group: packagist-${{ github.ref }} + cancel-in-progress: false + jobs: ci: name: Notify Packagist runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - name: Check out repository + uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer:v2 + + - name: Validate release tag + shell: bash + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v2\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Expected a Featurevisor PHP v2 semantic version tag such as v2.0.0" + exit 1 + fi + + - name: Install and verify package + run: | + composer install --no-interaction --prefer-dist --no-progress + make check - - name: Notify + - name: Notify Packagist shell: bash env: PACKAGIST_USERNAME: ${{ secrets.PACKAGIST_USERNAME }} PACKAGIST_API_TOKEN: ${{ secrets.PACKAGIST_API_TOKEN }} run: | - echo "Notifying Packagist of new release: ${{ github.event.release.tag_name }}" + echo "Notifying Packagist of new release: ${{ github.ref_name }}" - curl -X POST -H 'content-type:application/json' \ + curl --fail-with-body --silent --show-error --request POST \ + --header 'content-type: application/json' \ "https://packagist.org/api/update-package?username=$PACKAGIST_USERNAME&apiToken=$PACKAGIST_API_TOKEN" \ - -d '{"repository":{"url":"https://packagist.org/packages/featurevisor/featurevisor-php"}}' + --data '{"repository":{"url":"https://packagist.org/packages/featurevisor/featurevisor-php"}}' diff --git a/.nvmrc b/.nvmrc index 209e3ef..a45fd52 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20 +24 diff --git a/LICENSE b/LICENSE index d9fbfa5..434d943 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2025 Fahad Heylaal (https://fahad19.com) +Copyright (c) 2026 Fahad Heylaal (https://fahad19.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 1029734..f09e073 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test setup-monorepo update-monorepo test-example-1 test-example-project +.PHONY: install sast test test-base test-openfeature check setup-monorepo update-monorepo test-example-1 test-example-project install: composer install @@ -6,6 +6,21 @@ install: test: composer test +sast: + composer sast + +test-base: + vendor/bin/phpunit --exclude-group openfeature tests + +test-openfeature: + vendor/bin/phpunit --group openfeature tests + +check: + composer validate --strict + composer audit --locked --no-interaction + composer sast + composer test + setup-monorepo: mkdir -p monorepo if [ ! -d "monorepo/.git" ]; then \ @@ -20,6 +35,6 @@ update-monorepo: test-example-1: composer test - ./featurevisor test --projectDirectoryPath="../featurevisor/examples/example-1" --onlyFailures + ./featurevisor test --projectDirectoryPath="../featurevisor/examples/example-1" --onlyFailures --quiet test-example-project: test-example-1 diff --git a/README.md b/README.md index 1728923..42af128 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,14 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje - [Registering modules](#registering-modules) - [Child instance](#child-instance) - [Close](#close) +- [OpenFeature](#openfeature) + - [Installation](#installation-1) + - [Provider setup](#provider-setup) + - [Flag key mapping](#flag-key-mapping) + - [Context mapping](#context-mapping) + - [Resolution details](#resolution-details) + - [Tracking](#tracking) + - [Using an existing Featurevisor instance](#using-an-existing-featurevisor-instance) - [CLI usage](#cli-usage) - [Test](#test) - [Benchmark](#benchmark) @@ -56,7 +64,7 @@ This SDK is compatible with [Featurevisor](https://featurevisor.com/) v3.0 proje ## Installation -In your PHP application, install the SDK using [Composer](https://getcomposer.org/): +The Featurevisor PHP SDK requires PHP 8.0 or newer. Install it using [Composer](https://getcomposer.org/): ``` $ composer require featurevisor/featurevisor-php @@ -76,6 +84,8 @@ $f = Featurevisor::createFeaturevisor([ Most applications only need this factory and the returned `Featurevisor` instance. Public extension and observability APIs include modules, diagnostics, events, and the datafile arrays accepted by the factory. +Treat an instance as request-owned in normal PHP applications. If a long-running parallel runtime shares an instance, serialize calls that mutate or close it. Module, event, and diagnostic callbacks are responsible for synchronizing mutable state that they capture. + ## Initialization The SDK can be initialized by passing [datafile](https://featurevisor.com/docs/building-datafiles/) content directly: @@ -550,7 +560,7 @@ Modules allow you to intercept the evaluation process, report diagnostics, and c ### Defining a module -A module is a simple object with a unique required `name` and optional functions: +A module is a simple array with optional lifecycle callbacks. A `name` is optional, but when provided it must be unique: If `setup` throws, the module is not registered. Featurevisor removes subscriptions created during setup, reports `module_setup_error`, and calls `close` when present. @@ -658,6 +668,8 @@ $f->removeModule('my-custom-module'); ## Child instance +A child snapshots the parent keys that exist when it is spawned. Child values win for those keys. Parent keys introduced later are still inherited. Calling `close()` removes both child-owned listeners and subscriptions delegated to the parent. + When dealing with purely client-side applications, it is understandable that there is only one user involved, like in browser or mobile applications. But when using Featurevisor SDK in server-side applications, where a single server instance can handle multiple user requests simultaneously, it is important to isolate the context for each request. @@ -683,8 +695,11 @@ Similar to parent SDK, child instances also support several additional methods: - `setContext` - `setSticky` +- `evaluateFlag` - `isEnabled` +- `evaluateVariation` - `getVariation` +- `evaluateVariable` - `getVariable` - `getVariableBoolean` - `getVariableString` @@ -761,6 +776,121 @@ $ vendor/bin/featurevisor assess-distribution \ --n=1000 ``` +## OpenFeature + +The provider targets OpenFeature PHP SDK `2.x`. OpenFeature remains optional and is not installed or loaded by the base Featurevisor SDK. + +### Installation + +```bash +composer require featurevisor/featurevisor-php open-feature/sdk:^2.2 +``` + +### Provider setup + +```php +use Featurevisor\OpenFeatureProvider; +use OpenFeature\OpenFeatureAPI; +use OpenFeature\implementation\flags\Attributes; +use OpenFeature\implementation\flags\EvaluationContext; + +$provider = new OpenFeatureProvider([ + 'datafile' => $datafileContent, +]); + +$api = OpenFeatureAPI::getInstance(); +$api->setProvider($provider); + +$client = $api->getClient(); +$enabled = $client->getBooleanValue( + 'checkout', + false, + new EvaluationContext('user-123', new Attributes(['country' => 'nl'])) +); +``` + +The current OpenFeature PHP SDK does not expose provider shutdown through its API. Call `$provider->shutdown()` when your application shuts down. This closes a Featurevisor instance created by the provider and releases provider subscriptions. + +### Flag key mapping + +| OpenFeature key | Featurevisor evaluation | +| --- | --- | +| `checkout` | Boolean flag for `checkout` | +| `checkout:variation` | Variation value for `checkout` | +| `checkout:title` | Variable `title` for `checkout` | + +Boolean variables use the boolean resolver. Integer and double variables use their matching numeric resolvers. Arrays, objects, and JSON variables use the object resolver. + +The first separator divides the feature key from the selector. Use `keySeparator` and `variationKey` when project keys require a different grammar: + +```php +$provider = new OpenFeatureProvider( + options: ['datafile' => $datafileContent], + keySeparator: '/', + variationKey: '$variation' +); +``` + +This makes `checkout/$variation` the variation key and `checkout/title` a variable key. + +### Context mapping + +OpenFeature's targeting key maps to `userId` by default. Use `targetingKeyField` to map it to another Featurevisor context field: + +```php +$provider = new OpenFeatureProvider( + options: ['datafile' => $datafileContent], + targetingKeyField: 'accountId' +); +``` + +OpenFeature context attributes are copied without mutating the incoming context. Nested arrays are preserved. Dates are normalized to UTC ISO strings with millisecond precision, matching the JavaScript provider. + +### Resolution details + +The provider maps Featurevisor evaluation results to OpenFeature details: + +| Featurevisor result | OpenFeature result | +| --- | --- | +| Required, forced, sticky, or rule match | `TARGETING_MATCH` | +| Traffic allocation | `SPLIT` | +| Disabled variation or variable | `DISABLED` | +| No match or variable default | `DEFAULT` | +| Missing feature, variable, or variations | `ERROR` with `FLAG_NOT_FOUND` | +| Wrong resolver type | `ERROR` with `TYPE_MISMATCH` | +| Invalid datafile | `ERROR` with `PARSE_ERROR` | +| Evaluation failure | `ERROR` with `GENERAL` | + +Errors return the default value supplied to OpenFeature. A malformed datafile uses the stable message `Could not parse datafile`. A later successful `setDatafile()` call clears the parse error. + +Selected Featurevisor variations are exposed as the OpenFeature variant when available. OpenFeature PHP SDK 2.x does not expose flag metadata in resolution details, so Featurevisor metadata such as revision, rule key, and bucket value cannot currently be returned by this provider. + +### Tracking + +Tracking is a no-op unless `onTrack` is configured: + +```php +$provider = new OpenFeatureProvider( + options: ['datafile' => $datafileContent], + onTrack: function ($name, $context, $details) { + echo $name; + } +); +``` + +### Using an existing Featurevisor instance + +You can reuse an existing Featurevisor instance: + +```php +$featurevisor = Featurevisor::createFeaturevisor(['datafile' => $datafileContent]); +$provider = new OpenFeatureProvider(featurevisor: $featurevisor); +``` + +The caller owns an instance passed this way. Calling `$provider->shutdown()` does not close it. Call `$featurevisor->close()` when every consumer is finished with it. When the provider creates the instance from options, the provider owns and closes it. If both are supplied, `$featurevisor` takes precedence over `$options`. Shutdown is safe to call more than once. + +See the [OpenFeature provider guide](https://featurevisor.com/docs/sdks/openfeature/) for resolution reasons, errors, lifecycle, and providers for other languages. + ## Development of this package @@ -779,6 +909,14 @@ $ composer install $ composer test ``` +Run the complete local release check with: + +``` +$ make check +``` + +The OpenFeature and base SDK tests can also be run separately with `make test-openfeature` and `make test-base`. + To run the SDK against Featurevisor example-1 from the local monorepo checkout: ``` @@ -788,8 +926,8 @@ $ make test-example-1 ### Releasing - Manually create a new release on [GitHub](https://github.com/featurevisor/featurevisor-php/releases) -- Tag it with a prefix of `v`, like `v1.0.0` -- GitHub Actions is set up to automatically notify [Packagist](https://packagist.org/packages/featurevisor/featurevisor-php) about the new release +- Tag it with a prefix of `v`, like `v2.0.0` +- The Packagist workflow notifies [Packagist](https://packagist.org/packages/featurevisor/featurevisor-php) after the tag is pushed ## License diff --git a/composer.json b/composer.json index d91408f..7c3289f 100644 --- a/composer.json +++ b/composer.json @@ -22,8 +22,6 @@ "source": "https://github.com/featurevisor/featurevisor-php", "docs": "https://featurevisor.com/docs/sdks/php/" }, - "minimum-stability": "dev", - "prefer-stable": true, "autoload": { "psr-4": { "Featurevisor\\": "src/" @@ -34,15 +32,23 @@ "Featurevisor\\Tests\\": "tests/" } }, + "config": { + "platform": { + "php": "8.0.0" + }, + "sort-packages": true + }, "require": { - "php": "^7.4 || ^8.0", - "ext-json": "*", - "psr/log": "^1.1", - "symfony/polyfill-php80": "^1.33" + "php": "^8.0", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "^9", - "phpstan/phpstan": "^2.1" + "open-feature/sdk": "^2.2", + "phpunit/phpunit": "^9.6.33", + "phpstan/phpstan": "^2.2" + }, + "suggest": { + "open-feature/sdk": "Required for Featurevisor\\OpenFeatureProvider (PHP 8.0+)" }, "bin": [ "featurevisor" diff --git a/composer.lock b/composer.lock index e53e202..d8ebee6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,143 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8fd6a427fe3ddfc63a8c85ca1f576421", - "packages": [ - { - "name": "psr/log", - "version": "1.1.4", - "source": { - "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", - "shasum": "" - }, - "require": { - "php": ">=5.3.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Log\\": "Psr/Log/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", - "keywords": [ - "log", - "psr", - "psr-3" - ], - "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" - }, - "time": "2021-05-03T11:20:27+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-01-02T08:10:11+00:00" - } - ], + "content-hash": "5d2d50293138c70b49be2aaf0541d9bc", + "packages": [], "packages-dev": [ { "name": "doctrine/instantiator", @@ -272,22 +137,84 @@ ], "time": "2025-08-01T08:46:24+00:00" }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "source": { + "type": "git", + "url": "https://github.com/myclabs/php-enum.git", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/php-enum/zipball/e7be26966b7398204a234f8673fdad5ac6277802", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/php-enum", + "type": "tidelift" + } + ], + "time": "2025-01-14T11:49:03+00:00" + }, { "name": "nikic/php-parser", - "version": "v5.6.1", + "version": "v5.8.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2" + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", - "reference": "f103601b29efebd7ff4a1ca7b3eeea9e3336a2a2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", "shasum": "" }, "require": { - "ext-ctype": "*", "ext-json": "*", "ext-tokenizer": "*", "php": ">=7.4" @@ -326,9 +253,88 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.1" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" }, - "time": "2025-08-13T20:13:15+00:00" + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "open-feature/sdk", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/open-feature/php-sdk.git", + "reference": "40638875cb9050aec75126d7f714290eb034ca24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/open-feature/php-sdk/zipball/40638875cb9050aec75126d7f714290eb034ca24", + "reference": "40638875cb9050aec75126d7f714290eb034ca24", + "shasum": "" + }, + "require": { + "myclabs/php-enum": "^1.8", + "php": "^8", + "psr/log": "^2.0 || ^3.0" + }, + "require-dev": { + "behat/behat": "^3.11", + "captainhook/captainhook": "^5.10", + "captainhook/plugin-composer": "^5.3", + "dg/bypass-finals": "^1.9", + "ergebnis/composer-normalize": "^2.25", + "hamcrest/hamcrest-php": "^2.0", + "mdwheele/zalgo": "^0.3.1", + "mockery/mockery": "^1.5", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "~1.12.0", + "phpstan/phpstan-mockery": "^1.0", + "phpstan/phpstan-phpunit": "^1.1", + "psalm/plugin-mockery": "^1.0.0", + "psalm/plugin-phpunit": "^0.19.0", + "ramsey/coding-standard": "^2.0.3", + "ramsey/composer-repl": "^1.4", + "ramsey/conventional-commits": "^1.3", + "roave/security-advisories": "dev-latest", + "spatie/phpunit-snapshot-assertions": "^4.2", + "vimeo/psalm": "~5.26.0" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "OpenFeature\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Tom Carrio", + "email": "tom@carrio.dev" + } + ], + "description": "PHP implementation of the OpenFeature SDK", + "keywords": [ + "featureflagging", + "featureflags", + "openfeature" + ], + "support": { + "issues": "https://github.com/open-feature/php-sdk/issues", + "source": "https://github.com/open-feature/php-sdk/tree/2.2.0" + }, + "time": "2026-06-08T13:01:00+00:00" }, { "name": "phar-io/manifest", @@ -450,16 +456,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.22", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpstan.git", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4" - }, + "version": "2.2.5", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/41600c8379eb5aee63e9413fe9e97273e25d57e4", - "reference": "41600c8379eb5aee63e9413fe9e97273e25d57e4", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", + "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0", "shasum": "" }, "require": { @@ -482,6 +483,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -504,7 +516,7 @@ "type": "github" } ], - "time": "2025-08-04T19:17:37+00:00" + "time": "2026-07-05T06:31:06+00:00" }, { "name": "phpunit/php-code-coverage", @@ -827,25 +839,25 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.24", + "version": "9.6.35", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701" + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", - "reference": "ea49afa29aeea25ea7bf9de9fdd7cab163cc0701", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", "shasum": "" }, "require": { "doctrine/instantiator": "^1.5.0 || ^2", "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", - "ext-xml": "*", "ext-xmlwriter": "*", "myclabs/deep-copy": "^1.13.4", "phar-io/manifest": "^2.0.4", @@ -858,10 +870,10 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", - "sebastian/exporter": "^4.0.6", + "sebastian/exporter": "^4.0.8", "sebastian/global-state": "^5.0.8", "sebastian/object-enumerator": "^4.0.4", "sebastian/resource-operations": "^3.0.4", @@ -910,31 +922,65 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://liberapay.com/sebastianbergmann", - "type": "liberapay" - }, - { - "url": "https://thanks.dev/u/gh/sebastianbergmann", - "type": "thanks_dev" - }, + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "time": "2025-08-10T08:32:42+00:00" + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" }, { "name": "sebastian/cli-parser", @@ -1105,16 +1151,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -1167,7 +1213,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -1187,7 +1233,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", @@ -1377,16 +1423,16 @@ }, { "name": "sebastian/exporter", - "version": "4.0.6", + "version": "4.0.8", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72" + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72", - "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", "shasum": "" }, "require": { @@ -1442,15 +1488,27 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/exporter/issues", - "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6" + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2024-03-02T06:33:00+00:00" + "time": "2025-09-24T06:03:27+00:00" }, { "name": "sebastian/global-state", @@ -1937,16 +1995,16 @@ }, { "name": "theseer/tokenizer", - "version": "1.2.3", + "version": "1.3.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2" + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", - "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { @@ -1975,7 +2033,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.2.3" + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { @@ -1983,18 +2041,21 @@ "type": "github" } ], - "time": "2024-03-03T12:36:25+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], "aliases": [], - "minimum-stability": "dev", - "stability-flags": [], - "prefer-stable": true, + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "^7.4 || ^8.0", + "php": "^8.0", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform-dev": {}, + "platform-overrides": { + "php": "8.0.0" + }, + "plugin-api-version": "2.6.0" } diff --git a/conformance/sdk-v3.json b/conformance/sdk-v3.json index ceae692..49396ce 100644 --- a/conformance/sdk-v3.json +++ b/conformance/sdk-v3.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 2, "description": "Featurevisor v3 cross SDK compatibility contracts", "bucketing": { "minimum": 0, @@ -26,7 +26,47 @@ "pattern": "chrome", "flags": "g", "values": ["chrome", "chrome", "firefox", "chrome"], - "matches": [true, true, false, true] + "matches": [true, true, false, true], + "portableCases": [ + { + "pattern": "^chrome$", + "flags": "", + "value": "chrome", + "expected": true + }, + { + "pattern": "^(chrome|firefox)$", + "flags": "i", + "value": "Firefox", + "expected": true + }, + { + "pattern": "^second$", + "flags": "m", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "first.*second", + "flags": "s", + "value": "first\nsecond", + "expected": true + }, + { + "pattern": "\\(literal\\)", + "flags": "g", + "value": "(literal)", + "expected": true + } + ], + "rejectedSyntax": [ + "foo(?=bar)", + "(?<=foo)bar", + "(?:foo|bar)", + "(?foo)", + "(foo)\\1", + "foo++" + ] }, "typedVariables": [ { "type": "integer", "value": 1, "valid": true }, @@ -44,6 +84,135 @@ "diagnostics": { "requiredFields": ["level", "code", "message", "details"], "detailsType": "object", - "emptyDetailsJson": "{}" + "emptyDetailsJson": "{}", + "evaluationDetailFields": ["featureKey", "variableKey", "reason", "evaluation"], + "moduleEnvelopeFields": ["module", "moduleName", "originalError"], + "errorEventLevels": ["error"] + }, + "numericBucketKeys": [ + { "value": 1.2345678901234567, "expected": "1.2345678901234567" }, + { "value": 0.30000000000000004, "expected": "0.30000000000000004" }, + { "value": 0.000001, "expected": "0.000001" }, + { "value": 1e-7, "expected": "1e-7" }, + { "value": 100000000000000000000, "expected": "100000000000000000000" }, + { "value": 1e21, "expected": "1e+21" } + ], + "portableConditions": { + "regexFlags": ["g", "i", "m", "s"], + "rejectedRegexFlags": ["d", "u", "v", "y"], + "dateFormat": "ISO 8601 with an explicit timezone", + "dates": [ + "2024-01-01T00:00:00Z", + "2024-01-01T01:00:00+01:00", + "2024-01-01T00:00:00.250Z", + "2024-01-01T01:00:00.250+01:00" + ], + "semanticVersions": [ + "1.2.3", + "1.2.3-beta.1", + "1.2.3+build.5" + ], + "invalidSemanticVersion": "invalid", + "invalidSemanticVersionDiagnosticCode": "condition_match_error" + }, + "conditionCases": [ + { + "name": "strict primitive equality", + "condition": { + "attribute": "value", + "operator": "equals", + "value": 1 + }, + "context": { "value": "1" }, + "expected": false + }, + { + "name": "not negates implicit and", + "condition": { + "not": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "device", "operator": "equals", "value": "mobile" } + ] + }, + "context": { "country": "us", "device": "desktop" }, + "expected": true + }, + { + "name": "not with nested or means none match", + "condition": { + "not": [ + { + "or": [ + { "attribute": "country", "operator": "equals", "value": "us" }, + { "attribute": "country", "operator": "equals", "value": "nl" } + ] + } + ] + }, + "context": { "country": "de" }, + "expected": true + }, + { + "name": "empty not fails defensively", + "condition": { "not": [] }, + "context": {}, + "expected": false + }, + { + "name": "fractional ISO date with offset", + "condition": { + "attribute": "date", + "operator": "before", + "value": "2024-01-01T00:00:00.500Z" + }, + "context": { "date": "2024-01-01T01:00:00.250+01:00" }, + "expected": true + } + ], + "childInstances": { + "contextModel": "snapshot existing parent keys at spawn, inherit newly introduced parent keys, child keys win", + "closeRemovesLocalAndDelegatedSubscriptions": true, + "detailedEvaluationMethods": ["flag", "variation", "variable"], + "contextCase": { + "parentAtSpawn": { "country": "nl", "plan": "free" }, + "child": { "country": "de" }, + "parentAfterSpawn": { "country": "us", "plan": "pro", "region": "eu" }, + "expected": { "country": "de", "plan": "free", "region": "eu" } + } + }, + "defaults": { + "presenceBased": true, + "values": ["", 0, false, null], + "aggregateEvaluationPreservesEmptyVariation": true, + "aggregateCase": { + "datafile": { + "schemaVersion": "2", + "revision": "defaults", + "segments": {}, + "features": { + "experiment": { + "key": "experiment", + "bucketBy": "userId", + "variations": [{ "value": "control" }], + "traffic": [] + } + } + }, + "defaultVariationValue": "", + "expected": { + "enabled": false, + "variation": "" + } + } + }, + "diagnosticCase": { + "featureKey": "missing", + "expectedLevel": "warn", + "expectedCode": "feature_not_found", + "detailsMustBeObject": true + }, + "nativeContexts": { + "numericTypesUseOneComparisonContract": true, + "primitiveNativeSlicesSupportIncludes": true } } diff --git a/featurevisor b/featurevisor index b4b1b4a..3547be2 100755 --- a/featurevisor +++ b/featurevisor @@ -4,9 +4,7 @@ require __DIR__ . '/vendor/autoload.php'; use Featurevisor\Featurevisor; -use Featurevisor\Internal\DatafileReader; -use Featurevisor\Logger; -use Psr\Log\LogLevel; +use Featurevisor\Conditions; /** * CLI Options @@ -227,12 +225,12 @@ function buildDatafiles(string $featurevisorProjectPath, array $config, array $t return $datafilesByKey; } -function getLoggerLevel(array $cliOptions): string { - $level = LogLevel::WARNING; +function getDiagnosticLevel(array $cliOptions): string { + $level = 'warn'; if ($cliOptions['verbose'] === true) { - $level = LogLevel::DEBUG; + $level = 'debug'; } else if ($cliOptions['quiet'] === true) { - $level = LogLevel::ERROR; + $level = 'error'; } return $level; } @@ -292,9 +290,11 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a // Test expectedVariation if (isset($assertion["expectedVariation"])) { - $variation = $f->getVariation($featureKey, $context, [ - 'defaultVariationValue' => isset($assertion["defaultVariationValue"]) ? $assertion["defaultVariationValue"] : null, - ]); + $variationOptions = []; + if (array_key_exists("defaultVariationValue", $assertion)) { + $variationOptions["defaultVariationValue"] = $assertion["defaultVariationValue"]; + } + $variation = $f->getVariation($featureKey, $context, $variationOptions); if ($variation !== $assertion["expectedVariation"]) { $hasError = true; $errors .= " ✘ expectedVariation: expected " . json_encode($assertion["expectedVariation"]) . " but received " . json_encode($variation) . PHP_EOL; @@ -310,11 +310,11 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a (stringEndsWith($expectedValue, '}') || stringEndsWith($expectedValue, ']'))) { $expectedValue = json_decode($expectedValue, true); } - $actualValue = $f->getVariable($featureKey, $variableKey, $context, [ - 'defaultVariableValue' => isset($assertion["defaultVariableValues"]) && isset($assertion["defaultVariableValues"][$variableKey]) - ? $assertion["defaultVariableValues"][$variableKey] - : null, - ]); + $variableOptions = []; + if (isset($assertion["defaultVariableValues"]) && array_key_exists($variableKey, $assertion["defaultVariableValues"])) { + $variableOptions["defaultVariableValue"] = $assertion["defaultVariableValues"][$variableKey]; + } + $actualValue = $f->getVariable($featureKey, $variableKey, $context, $variableOptions); if ($actualValue !== $expectedValue) { $hasError = true; $errors .= " ✘ expectedVariables.$variableKey: expected " . json_encode($expectedValue) . " but received " . json_encode($actualValue) . PHP_EOL; @@ -337,9 +337,11 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a } if (isset($expectedEvaluations["variation"])) { - $actualEvaluation = $f->evaluateVariation($featureKey, $context, [ - 'defaultVariationValue' => isset($assertion["defaultVariationValue"]) ? $assertion["defaultVariationValue"] : null, - ]); + $variationOptions = []; + if (array_key_exists("defaultVariationValue", $assertion)) { + $variationOptions["defaultVariationValue"] = $assertion["defaultVariationValue"]; + } + $actualEvaluation = $f->evaluateVariation($featureKey, $context, $variationOptions); foreach ($expectedEvaluations["variation"] as $key => $expectedValue) { if ($actualEvaluation[$key] !== $expectedValue) { $hasError = true; @@ -350,11 +352,11 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a if (isset($expectedEvaluations["variables"])) { foreach ($expectedEvaluations["variables"] as $variableKey => $expectedEvaluation) { - $actualEvaluation = $f->evaluateVariable($featureKey, $variableKey, $context, [ - 'defaultVariableValue' => isset($assertion["defaultVariableValues"]) && isset($assertion["defaultVariableValues"][$variableKey]) - ? $assertion["defaultVariableValues"][$variableKey] - : null, - ]); + $variableOptions = []; + if (isset($assertion["defaultVariableValues"]) && array_key_exists($variableKey, $assertion["defaultVariableValues"])) { + $variableOptions["defaultVariableValue"] = $assertion["defaultVariableValues"][$variableKey]; + } + $actualEvaluation = $f->evaluateVariable($featureKey, $variableKey, $context, $variableOptions); foreach ($expectedEvaluation as $key => $expectedValue) { if ($actualEvaluation[$key] !== $expectedValue) { $hasError = true; @@ -388,30 +390,20 @@ function testFeature(array $assertion, string $featureKey, $f, string $level): a ]; } -function testSegment(array $assertion, array $segment, string $level): array { +function testSegment(array $assertion, array $segment): array { $context = isset($assertion["context"]) ? $assertion["context"] : []; $conditions = $segment["conditions"]; - $datafile = [ - 'schemaVersion' => '2', - 'revision' => 'tester', - 'features' => [], - 'segments' => [] - ]; - - $datafileReader = DatafileReader::createFromOptions([ - 'datafile' => $datafile, - 'logger' => Logger::create([ - 'level' => $level, - ]), - ]); - $hasError = false; $errors = ""; $startTime = microtime(true); if (isset($assertion["expectedToMatch"])) { - $actual = $datafileReader->allConditionsAreMatched($conditions, $context); + $actual = Conditions::allConditionsAreMatched( + $conditions, + $context, + static fn(string $pattern, string $flags): string => '~'.str_replace('~', '\\~', $pattern).'~'.str_replace(['g', 'y'], '', $flags) + ); if ($actual !== $assertion["expectedToMatch"]) { $hasError = true; $errors .= " ✘ expectedToMatch: expected " . json_encode($assertion["expectedToMatch"]) . " but received " . json_encode($actual) . PHP_EOL; @@ -437,7 +429,7 @@ function test(array $cliOptions) { echo PHP_EOL; - $level = getLoggerLevel($cliOptions); + $level = getDiagnosticLevel($cliOptions); $tests = getTests($featurevisorProjectPath, $cliOptions); if (count($tests) === 0) { @@ -481,9 +473,7 @@ function test(array $cliOptions) { } else { $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, - 'logger' => Logger::create([ - 'level' => $level, - ]), + 'logLevel' => $level, 'sticky' => $assertion['sticky'] ?? [], 'modules' => [ [ @@ -501,7 +491,7 @@ function test(array $cliOptions) { $testResult = testFeature($assertion, $test["feature"], $f, $level); } } else if (isset($test["segment"])) { - $testResult = testSegment($assertion, $segmentsByKey[$test["segment"]], $level); + $testResult = testSegment($assertion, $segmentsByKey[$test["segment"]]); } $testDuration += $testResult['duration']; @@ -566,7 +556,7 @@ function benchmark(array $cliOptions) { $context = $cliOptions['context'] ? json_decode($cliOptions['context'], true) : []; - $level = getLoggerLevel($cliOptions); + $level = getDiagnosticLevel($cliOptions); $datafile = buildSingleDatafile( $featurevisorProjectPath, $cliOptions['environment'], @@ -576,9 +566,7 @@ function benchmark(array $cliOptions) { $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, - 'logger' => Logger::create([ - 'level' => $level, - ]), + 'logLevel' => $level, ]); $value = null; @@ -657,13 +645,11 @@ function assessDistribution(array $cliOptions) { $populateUuid = $cliOptions['populateUuid']; $datafile = buildSingleDatafile($featurevisorProjectPath, $cliOptions['environment'], $cliOptions['targets'][0] ?? null, $cliOptions['inflate']); - $level = getLoggerLevel($cliOptions); + $level = getDiagnosticLevel($cliOptions); $f = Featurevisor::createFeaturevisor([ 'datafile' => $datafile, - 'logger' => Logger::create([ - 'level' => $level, - ]), + 'logLevel' => $level, ]); $value = null; diff --git a/phpstan.neon b/phpstan.neon index d6e1a40..5a847af 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -19,12 +19,6 @@ parameters: count: 1 path: src/Bucketer.php - - - message: '#^Strict comparison using \!\=\= between mixed and null will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/EvaluateSticky.php - - message: '#^Call to function method_exists\(\) with Featurevisor\\Featurevisor and ''getVariation'' will always evaluate to true\.$#' identifier: function.alreadyNarrowedType diff --git a/src/Bucketer.php b/src/Bucketer.php index c1b1067..84325ba 100644 --- a/src/Bucketer.php +++ b/src/Bucketer.php @@ -22,7 +22,7 @@ public static function getBucketKey(array $options): string $featureKey = $options['featureKey']; $bucketBy = $options['bucketBy']; $context = $options['context']; - $logger = $options['logger']; + $reportDiagnostic = $options['reportDiagnostic'] ?? null; $type = null; $attributeKeys = null; @@ -37,25 +37,32 @@ public static function getBucketKey(array $options): string $type = 'or'; $attributeKeys = $bucketBy['or']; } else { - $logger->error('invalid bucketBy', ['featureKey' => $featureKey, 'bucketBy' => $bucketBy]); + if (is_callable($reportDiagnostic)) { + $reportDiagnostic([ + 'level' => 'error', + 'code' => 'invalid_bucket_by', + 'message' => 'Invalid bucketBy', + 'details' => ['featureKey' => $featureKey, 'bucketBy' => $bucketBy], + ]); + } throw new \Exception('invalid bucketBy'); } $bucketKey = []; foreach ($attributeKeys as $attributeKey) { - $attributeValue = self::getValueFromContext($context, $attributeKey); - - if ($attributeValue === null) { + if (!Conditions::pathExists($context, $attributeKey)) { continue; } + $attributeValue = Conditions::getValueFromContext($context, $attributeKey); + if ($type === 'plain' || $type === 'and') { - $bucketKey[] = $attributeValue; + $bucketKey[] = self::toJavaScriptString($attributeValue); } else { // or if (empty($bucketKey)) { - $bucketKey[] = $attributeValue; + $bucketKey[] = self::toJavaScriptString($attributeValue); } } } @@ -65,8 +72,75 @@ public static function getBucketKey(array $options): string return implode(self::DEFAULT_BUCKET_KEY_SEPARATOR, $bucketKey); } - private static function getValueFromContext(array $context, string $attributeKey) + /** @param mixed $value */ + private static function toJavaScriptString($value): string { - return $context[$attributeKey] ?? null; + if ($value === null) { + return ''; + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_float($value)) { + if (is_nan($value)) { + return 'NaN'; + } + if (is_infinite($value)) { + return $value > 0 ? 'Infinity' : '-Infinity'; + } + if ($value == 0.0) { + return '0'; + } + + $absolute = abs($value); + $shortest = strtolower(json_encode($value, JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR)); + if ($absolute >= 1e-6 && $absolute < 1e21) { + return str_contains($shortest, 'e') + ? self::expandScientificNotation($shortest) + : rtrim(rtrim($shortest, '0'), '.'); + } + + $parts = explode('e', $shortest); + if (count($parts) === 2) { + $coefficient = rtrim(rtrim($parts[0], '0'), '.'); + $exponent = (int) $parts[1]; + return $coefficient . 'e' . ($exponent >= 0 ? '+' : '') . $exponent; + } + } + if (is_array($value)) { + if ($value === [] || array_keys($value) === range(0, count($value) - 1)) { + return implode(',', array_map([self::class, 'toJavaScriptString'], $value)); + } + + return '[object Object]'; + } + if (is_object($value)) { + return '[object Object]'; + } + + return (string) $value; + } + + private static function expandScientificNotation(string $value): string + { + [$coefficient, $rawExponent] = explode('e', $value); + $negative = str_starts_with($coefficient, '-'); + $digits = str_replace(['-', '.'], '', $coefficient); + $decimalIndex = (strpos(ltrim($coefficient, '-'), '.') ?: strlen(ltrim($coefficient, '-'))) + + (int) $rawExponent; + + if ($decimalIndex <= 0) { + $expanded = '0.'.str_repeat('0', -$decimalIndex).$digits; + } elseif ($decimalIndex >= strlen($digits)) { + $expanded = $digits.str_repeat('0', $decimalIndex - strlen($digits)); + } else { + $expanded = substr($digits, 0, $decimalIndex).'.'.substr($digits, $decimalIndex); + } + + if (str_contains($expanded, '.')) { + $expanded = rtrim(rtrim($expanded, '0'), '.'); + } + + return ($negative ? '-' : '').$expanded; } } diff --git a/src/Child.php b/src/Child.php index 5517657..4fa8f3a 100644 --- a/src/Child.php +++ b/src/Child.php @@ -8,6 +8,8 @@ class Child private array $context; private array $sticky; private Emitter $emitter; + /** @var array */ + private array $parentUnsubscribers = []; public function __construct(array $options) { @@ -23,11 +25,34 @@ public function on(string $eventName, callable $callback): callable return $this->emitter->on($eventName, $callback); } - return $this->parent->on($eventName, $callback); + $parentUnsubscribe = $this->parent->on($eventName, $callback); + $active = true; + $unsubscribe = null; + $unsubscribe = function () use (&$active, &$unsubscribe, $parentUnsubscribe): void { + if (!$active) { + return; + } + + $active = false; + $parentUnsubscribe(); + foreach ($this->parentUnsubscribers as $index => $candidate) { + if ($candidate === $unsubscribe) { + unset($this->parentUnsubscribers[$index]); + break; + } + } + }; + $this->parentUnsubscribers[] = $unsubscribe; + + return $unsubscribe; } public function close(): void { + foreach (array_values($this->parentUnsubscribers) as $unsubscribe) { + $unsubscribe(); + } + $this->parentUnsubscribers = []; $this->emitter->clearAll(); } @@ -74,6 +99,15 @@ public function isEnabled(string $featureKey, array $context = [], array $option ); } + public function evaluateFlag(string $featureKey, array $context = [], array $options = []): array + { + return $this->parent->evaluateFlag( + $featureKey, + array_merge($this->context, $context), + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) + ); + } + public function getVariation(string $featureKey, array $context = [], array $options = []) { return $this->parent->getVariation( @@ -83,6 +117,15 @@ public function getVariation(string $featureKey, array $context = [], array $opt ); } + public function evaluateVariation(string $featureKey, array $context = [], array $options = []): array + { + return $this->parent->evaluateVariation( + $featureKey, + array_merge($this->context, $context), + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) + ); + } + public function getVariable(string $featureKey, string $variableKey, array $context = [], array $options = []) { return $this->parent->getVariable( @@ -93,6 +136,16 @@ public function getVariable(string $featureKey, string $variableKey, array $cont ); } + public function evaluateVariable(string $featureKey, string $variableKey, array $context = [], array $options = []): array + { + return $this->parent->evaluateVariable( + $featureKey, + $variableKey, + array_merge($this->context, $context), + array_merge($options, ['__featurevisorChildSticky' => $this->sticky]) + ); + } + public function getVariableBoolean(string $featureKey, string $variableKey, array $context = [], array $options = []): ?bool { return $this->parent->getVariableBoolean( diff --git a/src/CompareVersions.php b/src/CompareVersions.php index b185dd0..cb06bee 100644 --- a/src/CompareVersions.php +++ b/src/CompareVersions.php @@ -32,7 +32,7 @@ public static function compare(string $v1, string $v2): int private static function validateAndParse(string $version): array { - if (!preg_match(self::$semver, $version, $match)) { + if (!preg_match(self::$semver, $version, $match, PREG_UNMATCHED_AS_NULL)) { throw new \Exception("Invalid argument not valid semver ('$version' received)"); } @@ -52,8 +52,7 @@ private static function forceType($a, $b): array private static function tryParse(string $v) { - $n = intval($v); - return is_nan($n) ? $v : $n; + return preg_match('/^\d+$/', $v) ? intval($v) : $v; } private static function compareStrings(string $a, string $b): int diff --git a/src/Conditions.php b/src/Conditions.php index 97a02a2..887892f 100644 --- a/src/Conditions.php +++ b/src/Conditions.php @@ -2,189 +2,377 @@ namespace Featurevisor; -class Conditions +final class Conditions { - // Helper to check if an array is sequential (not associative) - private static function isSequentialArray($array): bool + /** @param mixed $left @param mixed $right */ + private static function primitiveEquals($left, $right): bool { - if (!is_array($array)) return false; - return array_keys($array) === range(0, count($array) - 1); + if ((is_int($left) || is_float($left)) && (is_int($right) || is_float($right))) { + return (float) $left === (float) $right; + } + + if ($left === null || is_string($left) || is_bool($left)) { + return $left === $right; + } + + return false; } - private static function pathExists(array $array, string $path): bool + /** @param array $value */ + private static function isList(array $value): bool { - if (strpos($path, '.') === false) { - return array_key_exists($path, $array); - } + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } - $keys = explode('.', $path); - $current = $array; + /** + * @param array $context + * @return mixed + */ + public static function getValueFromContext(array $context, string $path) + { + $current = $context; - foreach ($keys as $key) { + foreach (explode('.', $path) as $key) { if (!is_array($current) || !array_key_exists($key, $current)) { - return false; + return null; } + $current = $current[$key]; } - return true; + return $current; } - public static function getValueFromContext(array $obj, string $path) + /** @param array $context */ + public static function pathExists(array $context, string $path): bool { - if (strpos($path, '.') === false) { - return $obj[$path] ?? null; - } + $current = $context; - $keys = explode('.', $path); - $current = $obj; - - foreach ($keys as $key) { - if (!is_array($current) || !isset($current[$key])) { - return null; + foreach (explode('.', $path) as $key) { + if (!is_array($current) || !array_key_exists($key, $current)) { + return false; } + $current = $current[$key]; } - return $current; + return true; } + /** + * @param mixed $condition + * @param array $context + */ public static function conditionIsMatched($condition, array $context, callable $getRegex): bool { - // DEBUG: print condition and context - // var_dump(['condition' => $condition, 'context' => $context]); - // Match all via '*' if ($condition === '*') { return true; } - // If not array, cannot match if (!is_array($condition)) { return false; } - // Logical operators - if (isset($condition['and'])) { - $andConditions = self::isSequentialArray($condition['and']) ? $condition['and'] : [$condition['and']]; - foreach ($andConditions as $subCondition) { - if (!self::conditionIsMatched($subCondition, $context, $getRegex)) { + $attribute = $condition['attribute'] ?? ''; + $operator = $condition['operator'] ?? ''; + $value = $condition['value'] ?? null; + $contextValue = self::getValueFromContext($context, $attribute); + + if ($operator === 'equals') { + return self::pathExists($context, $attribute) && self::primitiveEquals($contextValue, $value); + } + + if ($operator === 'notEquals') { + return !self::pathExists($context, $attribute) || !self::primitiveEquals($contextValue, $value); + } + + if ($operator === 'before' || $operator === 'after') { + $contextDate = self::portableDate($contextValue); + $conditionDate = self::portableDate($value); + if ($contextDate === null || $conditionDate === null) { + return false; + } + + return $operator === 'before' ? $contextDate < $conditionDate : $contextDate > $conditionDate; + } + + if (is_array($value) && (is_string($contextValue) || is_int($contextValue) || is_float($contextValue) || $contextValue === null)) { + if (!self::pathExists($context, $attribute)) { + return false; + } + + if ($operator === 'in') { + return count(array_filter($value, fn ($candidate) => self::primitiveEquals($candidate, $contextValue))) > 0; + } + + if ($operator === 'notIn') { + return count(array_filter($value, fn ($candidate) => self::primitiveEquals($candidate, $contextValue))) === 0; + } + } + + if (is_string($contextValue) && is_string($value)) { + if ($operator === 'contains') { + return strpos($contextValue, $value) !== false; + } + if ($operator === 'notContains') { + return strpos($contextValue, $value) === false; + } + if ($operator === 'startsWith') { + return strpos($contextValue, $value) === 0; + } + if ($operator === 'endsWith') { + return $value === '' || substr($contextValue, -strlen($value)) === $value; + } + if ($operator === 'semverEquals') { + return CompareVersions::compare($contextValue, $value) === 0; + } + if ($operator === 'semverNotEquals') { + return CompareVersions::compare($contextValue, $value) !== 0; + } + if ($operator === 'semverGreaterThan') { + return CompareVersions::compare($contextValue, $value) === 1; + } + if ($operator === 'semverGreaterThanOrEquals') { + return CompareVersions::compare($contextValue, $value) >= 0; + } + if ($operator === 'semverLessThan') { + return CompareVersions::compare($contextValue, $value) === -1; + } + if ($operator === 'semverLessThanOrEquals') { + return CompareVersions::compare($contextValue, $value) <= 0; + } + if ($operator === 'matches' || $operator === 'notMatches') { + $result = @preg_match($getRegex($value, (string) ($condition['regexFlags'] ?? '')), $contextValue); + if ($result === false) { + throw new \RuntimeException('Invalid regular expression'); + } + + return $operator === 'matches' ? $result === 1 : $result === 0; + } + } + + if ((is_int($contextValue) || is_float($contextValue)) && (is_int($value) || is_float($value))) { + if ($operator === 'greaterThan') { + return $contextValue > $value; + } + if ($operator === 'greaterThanOrEquals') { + return $contextValue >= $value; + } + if ($operator === 'lessThan') { + return $contextValue < $value; + } + if ($operator === 'lessThanOrEquals') { + return $contextValue <= $value; + } + } + + if ($operator === 'exists') { + return self::pathExists($context, $attribute); + } + if ($operator === 'notExists') { + return !self::pathExists($context, $attribute); + } + + if (is_array($contextValue) && (is_string($value) || is_int($value) || is_float($value) || is_bool($value) || $value === null)) { + if ($operator === 'includes') { + return count(array_filter($contextValue, fn ($candidate) => self::primitiveEquals($candidate, $value))) > 0; + } + if ($operator === 'notIncludes') { + return count(array_filter($contextValue, fn ($candidate) => self::primitiveEquals($candidate, $value))) === 0; + } + } + + return false; + } + + private static function portableDate($value): ?\DateTimeInterface + { + if ($value instanceof \DateTimeInterface) { + return $value; + } + if (!is_string($value) || !preg_match('/T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+\-]\d{2}:\d{2})$/', $value)) { + return null; + } + + try { + return new \DateTimeImmutable($value); + } catch (\Exception $error) { + return null; + } + } + + /** + * @param mixed $conditions + * @param array $context + */ + public static function allConditionsAreMatched($conditions, array $context, callable $getRegex, ?callable $reportDiagnostic = null): bool + { + if (is_string($conditions)) { + return $conditions === '*'; + } + + if (!is_array($conditions)) { + return false; + } + + if (array_key_exists('attribute', $conditions)) { + try { + return self::conditionIsMatched($conditions, $context, $getRegex); + } catch (\Throwable $error) { + if ($reportDiagnostic) { + $reportDiagnostic([ + 'level' => 'warn', + 'code' => 'condition_match_error', + 'message' => $error->getMessage(), + 'originalError' => $error, + 'details' => ['condition' => $conditions, 'context' => $context], + ]); + } + + return false; + } + } + + if (array_key_exists('and', $conditions) && is_array($conditions['and'])) { + foreach ($conditions['and'] as $condition) { + if (!self::allConditionsAreMatched($condition, $context, $getRegex, $reportDiagnostic)) { return false; } } + return true; } - if (isset($condition['or'])) { - $orConditions = self::isSequentialArray($condition['or']) ? $condition['or'] : [$condition['or']]; - foreach ($orConditions as $subCondition) { - if (self::conditionIsMatched($subCondition, $context, $getRegex)) { + + if (array_key_exists('or', $conditions) && is_array($conditions['or'])) { + foreach ($conditions['or'] as $condition) { + if (self::allConditionsAreMatched($condition, $context, $getRegex, $reportDiagnostic)) { return true; } } + return false; } - if (isset($condition['not'])) { - if (is_array($condition['not']) && count($condition['not']) === 0) { + + if (array_key_exists('not', $conditions) && is_array($conditions['not'])) { + if ($conditions['not'] === []) { return false; } - $notConditions = self::isSequentialArray($condition['not']) ? $condition['not'] : [$condition['not']]; - // JS SDK semantics: "not" negates the entire AND group. - return !self::conditionIsMatched(['and' => $notConditions], $context, $getRegex); + + return !self::allConditionsAreMatched(['and' => $conditions['not']], $context, $getRegex, $reportDiagnostic); } - $attribute = $condition['attribute'] ?? ''; - $operator = $condition['operator'] ?? ''; - $value = $condition['value'] ?? null; - $regexFlags = $condition['regexFlags'] ?? ''; + if (self::isList($conditions)) { + foreach ($conditions as $condition) { + if (!self::allConditionsAreMatched($condition, $context, $getRegex, $reportDiagnostic)) { + return false; + } + } + + return true; + } - $contextValueFromPath = self::getValueFromContext($context, $attribute); + return false; + } - if ($operator === 'equals') { - return $contextValueFromPath === $value; - } elseif ($operator === 'notEquals') { - return $contextValueFromPath !== $value; - } elseif ($operator === 'before' || $operator === 'after') { - // date comparisons - $valueInContext = $contextValueFromPath; - - $dateInContext = is_string($valueInContext) ? new \DateTime($valueInContext) : $valueInContext; - $dateInCondition = is_string($value) ? new \DateTime($value) : $value; - - return $operator === 'before' - ? $dateInContext < $dateInCondition - : $dateInContext > $dateInCondition; - } elseif ( - is_array($value) && - (is_string($contextValueFromPath) || is_numeric($contextValueFromPath) || $contextValueFromPath === null) - ) { - // in / notIn (where condition value is an array) - if (!self::pathExists($context, $attribute)) { - return false; + /** @param mixed $conditions @return mixed */ + public static function parseConditionsIfStringified($conditions, ?callable $reportDiagnostic = null) + { + if (!is_string($conditions) || $conditions === '*') { + return $conditions; + } + + try { + return json_decode($conditions, true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $error) { + if ($reportDiagnostic) { + $reportDiagnostic([ + 'level' => 'error', + 'code' => 'conditions_parse_error', + 'message' => 'Error parsing conditions', + 'originalError' => $error, + 'details' => ['conditions' => $conditions], + ]); } - $valueInContext = $contextValueFromPath; - if ($operator === 'in') { - return in_array($valueInContext, $value); - } elseif ($operator === 'notIn') { - return !in_array($valueInContext, $value); + return $conditions; + } + } + + /** @param mixed $segments @return mixed */ + public static function parseSegmentsIfStringified($segments) + { + if (is_string($segments) && ($segments[0] ?? '') !== '{' && ($segments[0] ?? '') !== '[') { + return $segments; + } + + return is_string($segments) + ? json_decode($segments, true, 512, JSON_THROW_ON_ERROR) + : $segments; + } + + /** + * @param mixed $groupSegments + * @param array $context + */ + public static function allSegmentsAreMatched($groupSegments, array $context, callable $getSegment, callable $getRegex, ?callable $reportDiagnostic = null): bool + { + if ($groupSegments === '*') { + return true; + } + + if (is_string($groupSegments)) { + $segment = $getSegment($groupSegments); + + return $segment + ? self::allConditionsAreMatched( + self::parseConditionsIfStringified($segment['conditions'], $reportDiagnostic), + $context, + $getRegex, + $reportDiagnostic + ) + : false; + } + + if (!is_array($groupSegments)) { + return false; + } + + if (array_key_exists('and', $groupSegments) && is_array($groupSegments['and'])) { + foreach ($groupSegments['and'] as $segment) { + if (!self::allSegmentsAreMatched($segment, $context, $getSegment, $getRegex, $reportDiagnostic)) { + return false; + } } - } elseif (is_string($contextValueFromPath) && is_string($value)) { - // string - $valueInContext = $contextValueFromPath; + return true; + } - if ($operator === 'contains') { - return strpos($valueInContext, $value) !== false; - } elseif ($operator === 'notContains') { - return strpos($valueInContext, $value) === false; - } elseif ($operator === 'startsWith') { - return strpos($valueInContext, $value) === 0; - } elseif ($operator === 'endsWith') { - return substr($valueInContext, -strlen($value)) === $value; - } elseif ($operator === 'semverEquals') { - return CompareVersions::compare($valueInContext, $value) === 0; - } elseif ($operator === 'semverNotEquals') { - return CompareVersions::compare($valueInContext, $value) !== 0; - } elseif ($operator === 'semverGreaterThan') { - return CompareVersions::compare($valueInContext, $value) === 1; - } elseif ($operator === 'semverGreaterThanOrEquals') { - return CompareVersions::compare($valueInContext, $value) >= 0; - } elseif ($operator === 'semverLessThan') { - return CompareVersions::compare($valueInContext, $value) === -1; - } elseif ($operator === 'semverLessThanOrEquals') { - return CompareVersions::compare($valueInContext, $value) <= 0; - } elseif ($operator === 'matches') { - $regex = $getRegex($value, $regexFlags); - return preg_match($regex, $valueInContext); - } elseif ($operator === 'notMatches') { - $regex = $getRegex($value, $regexFlags); - return !preg_match($regex, $valueInContext); - } - } elseif (is_numeric($contextValueFromPath) && is_numeric($value)) { - // numeric - $valueInContext = $contextValueFromPath; + if (array_key_exists('or', $groupSegments) && is_array($groupSegments['or'])) { + foreach ($groupSegments['or'] as $segment) { + if (self::allSegmentsAreMatched($segment, $context, $getSegment, $getRegex, $reportDiagnostic)) { + return true; + } + } - if ($operator === 'greaterThan') { - return $valueInContext > $value; - } elseif ($operator === 'greaterThanOrEquals') { - return $valueInContext >= $value; - } elseif ($operator === 'lessThan') { - return $valueInContext < $value; - } elseif ($operator === 'lessThanOrEquals') { - return $valueInContext <= $value; - } - } elseif ($operator === 'exists') { - return self::pathExists($context, $attribute); - } elseif ($operator === 'notExists') { - return !self::pathExists($context, $attribute); - } elseif (is_array($contextValueFromPath) && is_string($value)) { - // includes / notIncludes (where context value is an array) - $valueInContext = $contextValueFromPath; + return false; + } - if ($operator === 'includes') { - return in_array($value, $valueInContext); - } elseif ($operator === 'notIncludes') { - return !in_array($value, $valueInContext); + if (array_key_exists('not', $groupSegments) && is_array($groupSegments['not'])) { + if ($groupSegments['not'] === []) { + return false; + } + + return !self::allSegmentsAreMatched(['and' => $groupSegments['not']], $context, $getSegment, $getRegex, $reportDiagnostic); + } + + if (self::isList($groupSegments)) { + foreach ($groupSegments as $segment) { + if (!self::allSegmentsAreMatched($segment, $context, $getSegment, $getRegex, $reportDiagnostic)) { + return false; + } } + + return true; } return false; diff --git a/src/Emitter.php b/src/Emitter.php index 99ef052..33c8443 100644 --- a/src/Emitter.php +++ b/src/Emitter.php @@ -47,7 +47,7 @@ public function trigger(string $eventName, array $details = []): void foreach ($listeners as $listener) { try { $listener($details); - } catch (\Exception $err) { + } catch (\Throwable $err) { error_log($err->getMessage()); } } diff --git a/src/Evaluate.php b/src/Evaluate.php index 4d91f01..26a126d 100644 --- a/src/Evaluate.php +++ b/src/Evaluate.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Internal\Diagnostics; + class Evaluate { public static function evaluateWithModules(array $opts): array @@ -17,18 +19,19 @@ public static function evaluateWithModules(array $opts): array // default: variation if ( - isset($options['defaultVariationValue']) && + array_key_exists('defaultVariationValue', $options) && $evaluation['type'] === 'variation' && - !isset($evaluation['variationValue']) + !array_key_exists('variationValue', $evaluation) && + !array_key_exists('variation', $evaluation) ) { $evaluation['variationValue'] = $options['defaultVariationValue']; } // default: variable if ( - isset($options['defaultVariableValue']) && + array_key_exists('defaultVariableValue', $options) && $evaluation['type'] === 'variable' && - !isset($evaluation['variableValue']) + !array_key_exists('variableValue', $evaluation) ) { $evaluation['variableValue'] = $options['defaultVariableValue']; } @@ -37,21 +40,19 @@ public static function evaluateWithModules(array $opts): array $evaluation = $modulesManager->runAfterModules($evaluation, $options); return $evaluation; - } catch (\Exception $e) { + } catch (\Throwable $e) { $type = $opts['type']; $featureKey = $opts['featureKey']; $variableKey = $opts['variableKey'] ?? null; - $logger = $opts['logger']; - $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, 'variableKey' => $variableKey, 'reason' => Evaluation::ERROR, - 'error' => $e->getMessage() + 'error' => $e ]; - $logger->error('error during evaluation', $evaluation); + Diagnostics::reportEvaluation($opts, $evaluation, 'Error during evaluation', 'error', 'evaluation_error'); return $evaluation; } @@ -61,8 +62,6 @@ private static function evaluateRequired(array $options, array $feature): ?array { $type = $options['type']; $featureKey = $options['featureKey']; - $logger = $options['logger']; - if ($type === 'flag' && isset($feature['required']) && count($feature['required']) > 0) { $requiredFeaturesAreEnabled = true; @@ -118,7 +117,7 @@ private static function evaluateRequired(array $options, array $feature): ?array 'enabled' => $requiredFeaturesAreEnabled ]; - $logger->debug('required features not enabled', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'required features not enabled'); return $evaluation; } @@ -132,8 +131,6 @@ public static function evaluate(array $options): array $type = $options['type']; $featureKey = $options['featureKey']; $variableKey = $options['variableKey'] ?? null; - $logger = $options['logger']; - $evaluation = null; try { @@ -142,7 +139,7 @@ public static function evaluate(array $options): array if ($type !== 'flag') { // needed by variation and variable evaluations - $flag = $options['flagEvaluation'] ?? self::evaluate(array_merge($options, [ + $flag = self::evaluate(array_merge($options, [ 'type' => 'flag' ])); @@ -201,19 +198,19 @@ public static function evaluate(array $options): array 'enabled' => false ]; - $logger->debug('nothing matched', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'nothing matched'); return $evaluation; - } catch (\Exception $e) { + } catch (\Throwable $e) { $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, 'variableKey' => $variableKey, 'reason' => Evaluation::ERROR, - 'error' => $e->getMessage() + 'error' => $e ]; - $logger->error('error', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'Error during evaluation', 'error', 'evaluation_error'); return $evaluation; } diff --git a/src/EvaluateByBucketing.php b/src/EvaluateByBucketing.php index 4c20e67..60a2637 100644 --- a/src/EvaluateByBucketing.php +++ b/src/EvaluateByBucketing.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Internal\Diagnostics; + class EvaluateByBucketing { public static function evaluate(array $options, array $feature, ?array $variableSchema = null, ?array $force = null): array @@ -10,8 +12,7 @@ public static function evaluate(array $options, array $feature, ?array $variable $featureKey = $options['featureKey']; $context = $options['context']; $variableKey = $options['variableKey'] ?? null; - $logger = $options['logger']; - $datafileReader = $options['datafileReader']; + $datafile = $options['datafile']; $modulesManager = $options['modulesManager']; // bucketKey @@ -19,7 +20,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'featureKey' => $featureKey, 'bucketBy' => $feature['bucketBy'], 'context' => $context, - 'logger' => $logger + 'reportDiagnostic' => $options['reportDiagnostic'] ?? null ]); $bucketKey = $modulesManager->runBucketKeyModules([ @@ -49,13 +50,13 @@ public static function evaluate(array $options, array $feature, ?array $variable $matchedAllocation = null; if ($type !== 'flag') { - $matchedTraffic = $datafileReader->getMatchedTraffic($feature['traffic'], $context); + $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context); if ($matchedTraffic) { - $matchedAllocation = $datafileReader->getMatchedAllocation($matchedTraffic, $bucketValue); + $matchedAllocation = $datafile['getMatchedAllocation']($matchedTraffic, $bucketValue); } } else { - $matchedTraffic = $datafileReader->getMatchedTraffic($feature['traffic'], $context); + $matchedTraffic = $datafile['getMatchedTraffic']($feature['traffic'], $context); } $result = [ @@ -79,7 +80,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => false ]; - $logger->debug('matched rule with 0 percentage', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'matched rule with 0 percentage'); return $result; } @@ -109,7 +110,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => isset($matchedTraffic['enabled']) ? $matchedTraffic['enabled'] : true ]; - $logger->debug('matched', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'matched'); return $result; } @@ -124,7 +125,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => false ]; - $logger->debug('not matched', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'not matched'); return $result; } @@ -142,7 +143,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => $matchedTraffic['enabled'] ]; - $logger->debug('override from rule', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'override from rule'); return $result; } @@ -160,7 +161,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => true ]; - $logger->debug('matched traffic', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'matched traffic'); return $result; } @@ -190,7 +191,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variation' => $variation ]; - $logger->debug('override from rule', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'override from rule'); return $result; } @@ -218,7 +219,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variation' => $variation ]; - $logger->debug('allocated variation', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'allocated variation'); return $result; } @@ -238,11 +239,12 @@ public static function evaluate(array $options, array $feature, ?array $variable foreach ($overrides as $index => $o) { if (isset($o['conditions'])) { - $conditions = is_string($o['conditions']) && $o['conditions'] !== '*' - ? json_decode($o['conditions'], true) - : $o['conditions']; + $conditions = Conditions::parseConditionsIfStringified( + $o['conditions'], + $options['reportDiagnostic'] ?? null + ); - if ($datafileReader->allConditionsAreMatched($conditions, $context)) { + if ($datafile['allConditionsAreMatched']($conditions, $context)) { $override = $o; $overrideIndex = $index; break; @@ -250,8 +252,8 @@ public static function evaluate(array $options, array $feature, ?array $variable } if (isset($o['segments'])) { - $segments = $datafileReader->parseSegmentsIfStringified($o['segments']); - if ($datafileReader->allSegmentsAreMatched($segments, $context)) { + $segments = Conditions::parseSegmentsIfStringified($o['segments']); + if ($datafile['allSegmentsAreMatched']($segments, $context)) { $override = $o; $overrideIndex = $index; break; @@ -274,7 +276,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variableOverrideIndex' => $overrideIndex, ]; - $logger->debug('variable override from rule', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'variable override from rule'); return $result; } @@ -295,7 +297,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variableValue' => $matchedTraffic['variables'][$variableKey] ]; - $logger->debug('override from rule', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'override from rule'); return $result; } @@ -325,22 +327,26 @@ public static function evaluate(array $options, array $feature, ?array $variable $overrides = $variation['variableOverrides'][$variableKey]; $override = null; - foreach ($overrides as $o) { + $overrideIndex = -1; + foreach ($overrides as $index => $o) { if (isset($o['conditions'])) { - $conditions = is_string($o['conditions']) && $o['conditions'] !== '*' - ? json_decode($o['conditions'], true) - : $o['conditions']; + $conditions = Conditions::parseConditionsIfStringified( + $o['conditions'], + $options['reportDiagnostic'] ?? null + ); - if ($datafileReader->allConditionsAreMatched($conditions, $context)) { + if ($datafile['allConditionsAreMatched']($conditions, $context)) { $override = $o; + $overrideIndex = $index; break; } } if (isset($o['segments'])) { - $segments = $datafileReader->parseSegmentsIfStringified($o['segments']); - if ($datafileReader->allSegmentsAreMatched($segments, $context)) { + $segments = Conditions::parseSegmentsIfStringified($o['segments']); + if ($datafile['allSegmentsAreMatched']($segments, $context)) { $override = $o; + $overrideIndex = $index; break; } } @@ -357,16 +363,17 @@ public static function evaluate(array $options, array $feature, ?array $variable 'traffic' => $matchedTraffic, 'variableKey' => $variableKey, 'variableSchema' => $variableSchema, - 'variableValue' => $override['value'] + 'variableValue' => $override['value'], + 'variableOverrideIndex' => $overrideIndex, ]; - $logger->debug('variable override', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'variable override from variation'); return $result; } } - if ($variation && isset($variation['variables'][$variableKey])) { + if ($variation && isset($variation['variables']) && array_key_exists($variableKey, $variation['variables'])) { $result['evaluation'] = [ 'type' => $type, 'featureKey' => $featureKey, @@ -380,7 +387,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variableValue' => $variation['variables'][$variableKey] ]; - $logger->debug('allocated variable', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'allocated variable'); return $result; } @@ -397,7 +404,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'bucketValue' => $bucketValue ]; - $logger->debug('no matched variation', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'no matched variation'); return $result; } @@ -415,7 +422,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variableValue' => $variableSchema['defaultValue'] ]; - $logger->debug('using default value', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'using default value'); return $result; } @@ -429,7 +436,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'bucketValue' => $bucketValue ]; - $logger->debug('variable not found', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'variable not found'); return $result; } diff --git a/src/EvaluateDisabled.php b/src/EvaluateDisabled.php index d5ac424..cf88168 100644 --- a/src/EvaluateDisabled.php +++ b/src/EvaluateDisabled.php @@ -2,16 +2,16 @@ namespace Featurevisor; +use Featurevisor\Internal\Diagnostics; + class EvaluateDisabled { public static function evaluate(array $options, array $flag): ?array { $type = $options['type']; $featureKey = $options['featureKey']; - $datafileReader = $options['datafileReader']; + $datafile = $options['datafile']; $variableKey = $options['variableKey'] ?? null; - $logger = $options['logger']; - if ($type !== 'flag') { $evaluation = null; @@ -22,14 +22,14 @@ public static function evaluate(array $options, array $flag): ?array 'reason' => Evaluation::DISABLED ]; - $feature = $datafileReader->getFeature($featureKey); + $feature = $datafile['getFeature']($featureKey); // serve variable default value if feature is disabled (if explicitly specified) if ($type === 'variable') { if ($feature && $variableKey && isset($feature['variablesSchema'][$variableKey])) { $variableSchema = $feature['variablesSchema'][$variableKey]; - if (isset($variableSchema['disabledValue'])) { + if (array_key_exists('disabledValue', $variableSchema)) { // disabledValue: $evaluation = [ 'type' => $type, @@ -56,7 +56,7 @@ public static function evaluate(array $options, array $flag): ?array } // serve disabled variation value if feature is disabled (if explicitly specified) - if ($type === 'variation' && $feature && isset($feature['disabledVariationValue'])) { + if ($type === 'variation' && $feature && array_key_exists('disabledVariationValue', $feature)) { $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, @@ -66,7 +66,7 @@ public static function evaluate(array $options, array $flag): ?array ]; } - $logger->debug('feature is disabled', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'feature is disabled'); return $evaluation; } diff --git a/src/EvaluateForced.php b/src/EvaluateForced.php index 9cb58bc..14e7847 100644 --- a/src/EvaluateForced.php +++ b/src/EvaluateForced.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Internal\Diagnostics; + class EvaluateForced { public static function evaluate(array $options, array $feature, ?array $variableSchema = null): array @@ -10,10 +12,9 @@ public static function evaluate(array $options, array $feature, ?array $variable $featureKey = $options['featureKey']; $variableKey = $options['variableKey'] ?? null; $context = $options['context']; - $logger = $options['logger']; - $datafileReader = $options['datafileReader']; + $datafile = $options['datafile']; - $forceResult = $datafileReader->getMatchedForce($feature, $context); + $forceResult = $datafile['getMatchedForce']($feature, $context); $force = $forceResult['force'] ?? null; $forceIndex = $forceResult['forceIndex'] ?? null; @@ -24,7 +25,7 @@ public static function evaluate(array $options, array $feature, ?array $variable if ($force) { // flag - if ($type === 'flag' && isset($force['enabled'])) { + if ($type === 'flag' && array_key_exists('enabled', $force)) { $result['evaluation'] = [ 'type' => $type, 'featureKey' => $featureKey, @@ -34,7 +35,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'enabled' => $force['enabled'] ]; - $logger->debug('forced enabled found', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'forced enabled found'); return $result; } @@ -59,14 +60,14 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variation' => $variation ]; - $logger->debug('forced variation found', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'forced variation found'); return $result; } } // variable - if ($variableKey && isset($force['variables'][$variableKey])) { + if ($variableKey && isset($force['variables']) && array_key_exists($variableKey, $force['variables'])) { $result['evaluation'] = [ 'type' => $type, 'featureKey' => $featureKey, @@ -78,7 +79,7 @@ public static function evaluate(array $options, array $feature, ?array $variable 'variableValue' => $force['variables'][$variableKey] ]; - $logger->debug('forced variable', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'forced variable'); return $result; } diff --git a/src/EvaluateNotFound.php b/src/EvaluateNotFound.php index dea8a15..82341e5 100644 --- a/src/EvaluateNotFound.php +++ b/src/EvaluateNotFound.php @@ -2,7 +2,7 @@ namespace Featurevisor; -use Psr\Log\LoggerInterface; +use Featurevisor\Internal\Diagnostics; class EvaluateNotFound { @@ -11,13 +11,11 @@ public static function evaluate(array $options): array $type = $options['type']; $featureKey = $options['featureKey']; $variableKey = $options['variableKey'] ?? null; - /** @var LoggerInterface $logger */ - $logger = $options['logger']; - $datafileReader = $options['datafileReader']; + $datafile = $options['datafile']; $result = []; - $feature = is_string($featureKey) ? $datafileReader->getFeature($featureKey) : $featureKey; + $feature = is_string($featureKey) ? $datafile['getFeature']($featureKey) : $featureKey; // feature: not found if (!$feature) { @@ -27,7 +25,7 @@ public static function evaluate(array $options): array 'reason' => Evaluation::FEATURE_NOT_FOUND ]; - $logger->warning('feature not found', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'Feature not found', 'warn', 'feature_not_found'); return $result; } @@ -36,7 +34,12 @@ public static function evaluate(array $options): array // feature: deprecated if ($type === 'flag' && ($feature['deprecated'] ?? false)) { - $logger->warning('feature is deprecated', ['featureKey' => $featureKey]); + ($options['reportDiagnostic'])([ + 'level' => 'warn', + 'code' => 'deprecated_feature', + 'message' => 'Feature is deprecated', + 'details' => ['featureKey' => $featureKey], + ]); } // variableSchema @@ -56,16 +59,18 @@ public static function evaluate(array $options): array 'variableKey' => $variableKey ]; - $logger->warning('variable schema not found', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'Variable schema not found', 'warn', 'variable_not_found'); return $result; } $result['variableSchema'] = $variableSchema; if ($variableSchema['deprecated'] ?? false) { - $logger->warning('variable is deprecated', [ - 'featureKey' => $featureKey, - 'variableKey' => $variableKey + ($options['reportDiagnostic'])([ + 'level' => 'warn', + 'code' => 'deprecated_variable', + 'message' => 'Variable is deprecated', + 'details' => ['featureKey' => $featureKey, 'variableKey' => $variableKey], ]); } } @@ -78,7 +83,7 @@ public static function evaluate(array $options): array 'reason' => Evaluation::NO_VARIATIONS ]; - $logger->warning('no variations', $result['evaluation']); + Diagnostics::reportEvaluation($options, $result['evaluation'], 'No variations', 'warn', 'no_variations'); return $result; } diff --git a/src/EvaluateSticky.php b/src/EvaluateSticky.php index 48e6639..fd40bde 100644 --- a/src/EvaluateSticky.php +++ b/src/EvaluateSticky.php @@ -2,6 +2,8 @@ namespace Featurevisor; +use Featurevisor\Internal\Diagnostics; + class EvaluateSticky { public static function evaluate(array $options): ?array @@ -10,13 +12,11 @@ public static function evaluate(array $options): ?array $featureKey = $options['featureKey']; $variableKey = $options['variableKey'] ?? null; $sticky = $options['sticky'] ?? null; - $logger = $options['logger']; - - if ($sticky && isset($sticky[$featureKey])) { + if ($sticky && array_key_exists($featureKey, $sticky)) { $evaluation = null; // flag - if ($type === 'flag' && isset($sticky[$featureKey]['enabled'])) { + if ($type === 'flag' && array_key_exists('enabled', $sticky[$featureKey])) { $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, @@ -25,16 +25,15 @@ public static function evaluate(array $options): ?array 'enabled' => $sticky[$featureKey]['enabled'] ]; - $logger->debug('using sticky enabled', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'using sticky enabled'); return $evaluation; } // variation if ($type === 'variation') { - $variationValue = $sticky[$featureKey]['variation'] ?? null; - - if ($variationValue !== null) { + if (array_key_exists('variation', $sticky[$featureKey])) { + $variationValue = $sticky[$featureKey]['variation']; $evaluation = [ 'type' => $type, 'featureKey' => $featureKey, @@ -42,7 +41,7 @@ public static function evaluate(array $options): ?array 'variationValue' => $variationValue ]; - $logger->debug('using sticky variation', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'using sticky variation'); return $evaluation; } @@ -52,22 +51,19 @@ public static function evaluate(array $options): ?array if ($variableKey) { $variables = $sticky[$featureKey]['variables'] ?? null; - if ($variables && isset($variables[$variableKey])) { + if ($variables && array_key_exists($variableKey, $variables)) { $result = $variables[$variableKey]; + $evaluation = [ + 'type' => $type, + 'featureKey' => $featureKey, + 'reason' => Evaluation::STICKY, + 'variableKey' => $variableKey, + 'variableValue' => $result + ]; - if ($result !== null) { - $evaluation = [ - 'type' => $type, - 'featureKey' => $featureKey, - 'reason' => Evaluation::STICKY, - 'variableKey' => $variableKey, - 'variableValue' => $result - ]; - - $logger->debug('using sticky variable', $evaluation); + Diagnostics::reportEvaluation($options, $evaluation, 'using sticky variable'); - return $evaluation; - } + return $evaluation; } } } diff --git a/src/Events.php b/src/Events.php index f5db649..8a91d3c 100644 --- a/src/Events.php +++ b/src/Events.php @@ -2,8 +2,6 @@ namespace Featurevisor; -use Featurevisor\Internal\DatafileReader; - class Events { public static function getParamsForStickySetEvent(array $previousStickyFeatures = [], array $newStickyFeatures = [], bool $replace = false): array @@ -20,13 +18,13 @@ public static function getParamsForStickySetEvent(array $previousStickyFeatures ]; } - public static function getParamsForDatafileSetEvent(DatafileReader $previousDatafileReader, DatafileReader $newDatafileReader, bool $replace = false): array + public static function getParamsForDatafileSetEvent(array $previousDatafile, array $newDatafile, bool $replace = false): array { - $previousRevision = $previousDatafileReader->getRevision(); - $previousFeatureKeys = $previousDatafileReader->getFeatureKeys(); + $previousRevision = $previousDatafile['revision']; + $previousFeatureKeys = array_keys($previousDatafile['features']); - $newRevision = $newDatafileReader->getRevision(); - $newFeatureKeys = $newDatafileReader->getFeatureKeys(); + $newRevision = $newDatafile['revision']; + $newFeatureKeys = array_keys($newDatafile['features']); // results $removedFeatures = []; @@ -42,8 +40,8 @@ public static function getParamsForDatafileSetEvent(DatafileReader $previousData } // feature exists in both datafiles, check if it was changed - $previousFeature = $previousDatafileReader->getFeature($previousFeatureKey); - $newFeature = $newDatafileReader->getFeature($previousFeatureKey); + $previousFeature = $previousDatafile['features'][$previousFeatureKey]; + $newFeature = $newDatafile['features'][$previousFeatureKey]; if (($previousFeature['hash'] ?? null) !== ($newFeature['hash'] ?? null)) { // feature was changed in new datafile diff --git a/src/Featurevisor.php b/src/Featurevisor.php index 4a4dbd9..93853d0 100644 --- a/src/Featurevisor.php +++ b/src/Featurevisor.php @@ -3,15 +3,25 @@ namespace Featurevisor; use Closure; -use Featurevisor\Internal\DatafileReader; -use Psr\Log\LogLevel; class Featurevisor { + private const DEFAULT_LOG_LEVEL = 'info'; + private const LOG_LEVELS = ['fatal', 'error', 'warn', 'info', 'debug']; + private const EMPTY_DATAFILE = [ + 'schemaVersion' => '2', + 'revision' => 'unknown', + 'segments' => [], + 'features' => [], + ]; + private array $context; - private Logger $logger; + private string $logLevel; private ?array $sticky; - private DatafileReader $datafileReader; + /** @var array */ + private array $datafile; + /** @var array */ + private array $regexCache; private ModulesManager $modulesManager; private Emitter $emitter; /** @var callable|null */ @@ -23,11 +33,11 @@ class Featurevisor /** * @param array{ * datafile?: string|array, - * logLevel?: LogLevel::*|string, + * logLevel?: string, * context?: array, * sticky?: array, * modules?: arraylogger = Logger::create([ - 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, - 'handler' => function (string $level, string $message, ?array $details = null): void { - $details = $details ?? []; - $message = preg_replace('/^\[Featurevisor\]\s*/', '', $message); - if ($level === LogLevel::WARNING) { - $level = 'warn'; - } elseif (in_array($level, [LogLevel::EMERGENCY, LogLevel::ALERT, LogLevel::CRITICAL], true)) { - $level = 'fatal'; - } - $code = isset($details['reason']) ? (string) $details['reason'] : $message; - if ($message === 'feature is deprecated') { - $code = 'deprecated_feature'; - } elseif ($message === 'variable is deprecated') { - $code = 'deprecated_variable'; - } elseif ($message === 'feature not found') { - $code = 'feature_not_found'; - } elseif ($message === 'variable schema not found') { - $code = 'variable_not_found'; - } elseif ($message === 'no variations') { - $code = 'no_variations'; - } elseif ($message === 'invalid bucketBy') { - $code = 'invalid_bucket_by'; - } - $this->reportDiagnostic([ - 'level' => $level, - 'code' => $code, - 'message' => $message, - 'details' => $details, - ]); - }, - ]); + $this->logLevel = $this->validateLogLevel($options['logLevel'] ?? self::DEFAULT_LOG_LEVEL); $this->emitter = new Emitter(); $this->context = $options['context'] ?? []; $this->sticky = $options['sticky'] ?? null; $this->onDiagnostic = $options['onDiagnostic'] ?? null; $this->moduleDiagnosticSubscriptions = []; $this->closed = false; - $this->datafileReader = DatafileReader::createEmpty($this->logger); + $this->datafile = self::EMPTY_DATAFILE; + $this->regexCache = []; $this->modulesManager = ModulesManager::createFromOptions([ 'modules' => $options['modules'] ?? [], 'reportDiagnostic' => function(array $diagnostic, ?array $module = null): void { @@ -129,14 +109,13 @@ public function setDatafile($datafile, bool $replace = false): void || !is_array($incomingDatafile['features'] ?? null)) { throw new \InvalidArgumentException('Invalid datafile'); } - $nextDatafile = $replace + $storedDatafile = $replace ? $incomingDatafile - : $this->mergeDatafiles($this->datafileReader->getDatafile(), $incomingDatafile); - $newDatafileReader = DatafileReader::createFromMixed($nextDatafile, $this->logger); - - $details = Events::getParamsForDatafileSetEvent($this->datafileReader, $newDatafileReader, $replace); + : $this->mergeDatafiles($this->datafile, $incomingDatafile); + $details = Events::getParamsForDatafileSetEvent($this->datafile, $storedDatafile, $replace); - $this->datafileReader = $newDatafileReader; + $this->datafile = $storedDatafile; + $this->regexCache = []; $this->reportDiagnostic([ 'level' => 'info', @@ -186,42 +165,60 @@ public function setSticky(array $sticky, bool $replace = false): void public function getRevision(): string { - return $this->datafileReader->getRevision(); + return $this->datafile['revision']; } public function getSchemaVersion(): string { - return $this->datafileReader->getSchemaVersion(); + return $this->datafile['schemaVersion']; } public function getSegment(string $segmentKey): ?array { - return $this->datafileReader->getSegment($segmentKey); + $segment = $this->datafile['segments'][$segmentKey] ?? null; + if (!is_array($segment)) { + return null; + } + + $segment['conditions'] = Conditions::parseConditionsIfStringified( + $segment['conditions'], + function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + } + ); + + return $segment; } public function getFeatureKeys(): array { - return $this->datafileReader->getFeatureKeys(); + return array_keys($this->datafile['features']); } public function getVariableKeys(string $featureKey): array { - return $this->datafileReader->getVariableKeys($featureKey); + $feature = $this->getFeature($featureKey); + + return $feature ? array_keys($feature['variablesSchema'] ?? []) : []; } public function hasVariations(string $featureKey): bool { - return $this->datafileReader->hasVariations($featureKey); + $feature = $this->getFeature($featureKey); + + return $feature && is_array($feature['variations'] ?? null) && $feature['variations'] !== []; } public function getFeature(string $featureKey): ?array { - return $this->datafileReader->getFeature($featureKey); + $feature = $this->datafile['features'][$featureKey] ?? null; + + return is_array($feature) ? $feature : null; } public function setLogLevel(string $level): void { - $this->logger->setLevel($level); + $this->logLevel = $this->validateLogLevel($level); } public function addModule(array $module): ?callable @@ -233,20 +230,21 @@ public function addModule(array $module): ?callable return $this->modulesManager->add($module); } - /** - * @param string|array $nameOrModule - */ - public function removeModule($nameOrModule): void + public function removeModule(string $name): void { if ($this->closed) { return; } - $this->modulesManager->remove($nameOrModule); + $this->modulesManager->remove($name); } public function on(string $eventName, callable $callback): callable { + if ($this->closed) { + return static function (): void {}; + } + return $this->emitter->on($eventName, $callback); } @@ -308,7 +306,7 @@ private function createModuleApi(array $module): array 'id' => uniqid('diagnostic_', true), 'moduleId' => $module['id'] ?? null, 'handler' => $handler, - 'level' => $options['logLevel'] ?? Logger::DEFAULT_LEVEL, + 'level' => $options['logLevel'] ?? self::DEFAULT_LOG_LEVEL, ]; $this->moduleDiagnosticSubscriptions[] = $subscription; @@ -373,8 +371,7 @@ private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null } } - $instanceLevel = $this->logger->getLevel(); - if ($this->levelAllows($diagnostic['level'], $instanceLevel)) { + if ($this->levelAllows($diagnostic['level'], $this->logLevel)) { if ($this->onDiagnostic) { try { ($this->onDiagnostic)($diagnostic); @@ -382,11 +379,7 @@ private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null error_log('[Featurevisor] Diagnostic handler failed: '.$error->getMessage()); } } else { - Logger::create(['level' => $this->logger->getLevel()])->log( - $this->normalizeLogLevel($diagnostic['level']), - $diagnostic['message'] ?? ($diagnostic['code'] ?? 'diagnostic'), - $diagnostic - ); + $this->writeDiagnosticToConsole($diagnostic); } } @@ -402,20 +395,24 @@ private function reportDiagnostic(array $diagnostic, ?array $sourceModule = null */ private function mergeDatafiles(array $previous, array $incoming): array { - return array_merge($previous, $incoming, [ + $merged = [ + 'schemaVersion' => $incoming['schemaVersion'], + 'revision' => $incoming['revision'], 'segments' => array_merge($previous['segments'] ?? [], $incoming['segments'] ?? []), 'features' => array_merge($previous['features'] ?? [], $incoming['features'] ?? []), - ]); - } + ]; - private function normalizeLogLevel(string $level): string - { - if ($level === 'fatal') { - return LogLevel::EMERGENCY; + if (array_key_exists('featurevisorVersion', $incoming)) { + $merged['featurevisorVersion'] = $incoming['featurevisorVersion']; } - if ($level === 'warn') { - return LogLevel::WARNING; + return $merged; + } + + private function validateLogLevel(string $level): string + { + if (!in_array($level, self::LOG_LEVELS, true)) { + throw new \InvalidArgumentException('Invalid log level'); } return $level; @@ -423,25 +420,24 @@ private function normalizeLogLevel(string $level): string private function levelAllows(string $diagnosticLevel, string $configuredLevel): bool { - $levels = [ - LogLevel::EMERGENCY, - LogLevel::ALERT, - LogLevel::CRITICAL, - LogLevel::ERROR, - LogLevel::WARNING, - LogLevel::NOTICE, - LogLevel::INFO, - LogLevel::DEBUG, - ]; - - $diagnosticLevel = $this->normalizeLogLevel($diagnosticLevel); - $configuredLevel = $this->normalizeLogLevel($configuredLevel); - - if (!in_array($diagnosticLevel, $levels, true) || !in_array($configuredLevel, $levels, true)) { + if (!in_array($diagnosticLevel, self::LOG_LEVELS, true) || !in_array($configuredLevel, self::LOG_LEVELS, true)) { return false; } - return array_search($configuredLevel, $levels, true) >= array_search($diagnosticLevel, $levels, true); + return array_search($configuredLevel, self::LOG_LEVELS, true) >= array_search($diagnosticLevel, self::LOG_LEVELS, true); + } + + /** @param array $diagnostic */ + private function writeDiagnosticToConsole(array $diagnostic): void + { + $message = '[Featurevisor] '.($diagnostic['message'] ?? ($diagnostic['code'] ?? 'diagnostic')).' '. + json_encode($diagnostic, JSON_UNESCAPED_SLASHES); + + if (defined('STDOUT')) { + fwrite(STDOUT, $message.PHP_EOL); + } else { + error_log($message); + } } /** @@ -469,29 +465,169 @@ public function spawn(array $context = [], array $options = []): Child ]); } + private function getRegex(string $pattern, string $flags = ''): string + { + $cacheKey = $pattern.'-'.$flags; + if (isset($this->regexCache[$cacheKey])) { + return $this->regexCache[$cacheKey]; + } + + $pcreFlags = ''; + foreach (str_split($flags) as $flag) { + if (strpos('imsu', $flag) !== false && strpos($pcreFlags, $flag) === false) { + $pcreFlags .= $flag; + } elseif ($flag !== 'g' && $flag !== 'y') { + throw new \InvalidArgumentException('Invalid regular expression flag: '.$flag); + } + } + + $this->regexCache[$cacheKey] = '~'.str_replace('~', '\\~', $pattern).'~'.$pcreFlags; + + return $this->regexCache[$cacheKey]; + } + + /** @param mixed $conditions @param array $context */ + private function allConditionsAreMatched($conditions, array $context): bool + { + return Conditions::allConditionsAreMatched( + $conditions, + $context, + function (string $pattern, string $flags): string { + return $this->getRegex($pattern, $flags); + }, + function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + } + ); + } + + /** @param mixed $segments @param array $context */ + private function allSegmentsAreMatched($segments, array $context): bool + { + return Conditions::allSegmentsAreMatched( + $segments, + $context, + function (string $segmentKey): ?array { + return $this->getSegment($segmentKey); + }, + function (string $pattern, string $flags): string { + return $this->getRegex($pattern, $flags); + }, + function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + } + ); + } + + /** @param array> $traffic @param array $context */ + private function getMatchedTraffic(array $traffic, array $context): ?array + { + foreach ($traffic as $trafficItem) { + if ($this->allSegmentsAreMatched(Conditions::parseSegmentsIfStringified($trafficItem['segments']), $context)) { + return $trafficItem; + } + } + + return null; + } + + private function getMatchedAllocation(array $traffic, int $bucketValue): ?array + { + foreach ($traffic['allocation'] ?? [] as $allocation) { + [$start, $end] = $allocation['range']; + if ($start <= $bucketValue && $end >= $bucketValue) { + return $allocation; + } + } + + return null; + } + + /** @param string|array $featureKey @param array $context */ + private function getMatchedForce($featureKey, array $context): array + { + $feature = is_string($featureKey) ? $this->getFeature($featureKey) : $featureKey; + if (!$feature) { + return []; + } + + foreach ($feature['force'] ?? [] as $index => $force) { + if (array_key_exists('conditions', $force) && $this->allConditionsAreMatched( + Conditions::parseConditionsIfStringified( + $force['conditions'], + function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + } + ), + $context + )) { + return ['force' => $force, 'forceIndex' => $index]; + } + + if (array_key_exists('segments', $force) && $this->allSegmentsAreMatched( + Conditions::parseSegmentsIfStringified($force['segments']), + $context + )) { + return ['force' => $force, 'forceIndex' => $index]; + } + } + + return []; + } + /** * @param array $context * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, * __featurevisorChildSticky?: array * } $options * @return array */ private function getEvaluationDependencies(array $context, array $options = []): array { - $sticky = $options['__featurevisorChildSticky'] ?? $this->sticky; - return array_merge($options, [ + $sticky = array_key_exists('__featurevisorChildSticky', $options) + ? $options['__featurevisorChildSticky'] + : $this->sticky; + $datafile = [ + 'getFeature' => function (string $featureKey): ?array { + return $this->getFeature($featureKey); + }, + 'allConditionsAreMatched' => function ($conditions, array $resolvedContext): bool { + return $this->allConditionsAreMatched($conditions, $resolvedContext); + }, + 'allSegmentsAreMatched' => function ($segments, array $resolvedContext): bool { + return $this->allSegmentsAreMatched($segments, $resolvedContext); + }, + 'getMatchedTraffic' => function (array $traffic, array $resolvedContext): ?array { + return $this->getMatchedTraffic($traffic, $resolvedContext); + }, + 'getMatchedAllocation' => function (array $traffic, int $bucketValue): ?array { + return $this->getMatchedAllocation($traffic, $bucketValue); + }, + 'getMatchedForce' => function ($featureKey, array $resolvedContext): array { + return $this->getMatchedForce($featureKey, $resolvedContext); + }, + ]; + + $dependencies = [ 'context' => $this->getContext($context), - 'logger' => $this->logger, + 'reportDiagnostic' => function (array $diagnostic): void { + $this->reportDiagnostic($diagnostic); + }, 'modulesManager' => $this->modulesManager, - 'datafileReader' => $this->datafileReader, + 'datafile' => $datafile, 'sticky' => $sticky, - 'defaultVariationValue' => $options['defaultVariationValue'] ?? null, - 'defaultVariableValue' => $options['defaultVariableValue'] ?? null, - 'flagEvaluation' => $options['flagEvaluation'] ?? null - ]); + ]; + + if (array_key_exists('defaultVariationValue', $options)) { + $dependencies['defaultVariationValue'] = $options['defaultVariationValue']; + } + if (array_key_exists('defaultVariableValue', $options)) { + $dependencies['defaultVariableValue'] = $options['defaultVariableValue']; + } + + return $dependencies; } /** @@ -499,18 +635,9 @@ private function getEvaluationDependencies(array $context, array $options = []): * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, * __featurevisorChildSticky?: array * } $options - * @return array{ - * type: string, - * featureKey: string, - * reason: string, - * bucketKey: string, - * bucketValue: string, - * enabled: bool, - * error?: string, - * } + * @return array */ public function evaluateFlag(string $featureKey, array $context = [], array $options = []): array { @@ -527,14 +654,25 @@ public function evaluateFlag(string $featureKey, array $context = [], array $opt * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function isEnabled(string $featureKey, array $context = [], array $options = []): bool { - $evaluation = $this->evaluateFlag($featureKey, $context, $options); + try { + $evaluation = $this->evaluateFlag($featureKey, $context, $options); - return $evaluation['enabled'] ?? false; + return ($evaluation['enabled'] ?? false) === true; + } catch (\Throwable $error) { + $this->reportDiagnostic([ + 'level' => 'error', + 'code' => 'evaluation_error', + 'message' => 'isEnabled failed', + 'originalError' => $error, + 'details' => ['featureKey' => $featureKey], + ]); + + return false; + } } /** @@ -542,18 +680,8 @@ public function isEnabled(string $featureKey, array $context = [], array $option * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options - * @return array{ - * type: string, - * featureKey: string, - * reason: string, - * bucketKey: string, - * bucketValue: string, - * variation: array, - * enabled: bool, - * error?: string, - * } + * @return array */ public function evaluateVariation(string $featureKey, array $context = [], array $options = []): array { @@ -570,7 +698,6 @@ public function evaluateVariation(string $featureKey, array $context = [], array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options * @return mixed|null */ @@ -579,21 +706,25 @@ public function getVariation(string $featureKey, array $context = [], array $opt try { $evaluation = $this->evaluateVariation($featureKey, $context, $options); - if (isset($evaluation['variationValue'])) { + if (array_key_exists('variationValue', $evaluation)) { return $evaluation['variationValue']; } - if (isset($evaluation['variation']['value'])) { + if (isset($evaluation['variation']) && array_key_exists('value', $evaluation['variation'])) { return $evaluation['variation']['value']; } return null; - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), [ - 'exception' => $e, - 'action' => 'getVariation', - 'featureKey' => $featureKey, - 'error' => $e->getMessage() + } catch (\Throwable $e) { + $this->reportDiagnostic([ + 'level' => 'error', + 'code' => 'evaluation_error', + 'message' => 'getVariation failed', + 'originalError' => $e, + 'details' => [ + 'action' => 'getVariation', + 'featureKey' => $featureKey, + ], ]); return null; } @@ -604,17 +735,8 @@ public function getVariation(string $featureKey, array $context = [], array $opt * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options - * @return array{ - * type: string, - * featureKey: string, - * reason: string, - * bucketKey: string, - * bucketValue: string, - * enabled: bool, - * error?: string, - * } + * @return array */ public function evaluateVariable(string $featureKey, string $variableKey, array $context = [], array $options = []): array { @@ -632,7 +754,6 @@ public function evaluateVariable(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options * @return mixed|null */ @@ -648,20 +769,22 @@ public function getVariable(string $featureKey, string $variableKey, array $cont $evaluation['variableSchema']['type'] === 'json' && is_string($evaluation['variableValue']) ) { - $decoded = json_decode($evaluation['variableValue'], true); - if ($decoded !== null) { - return $decoded; - } + return json_decode($evaluation['variableValue'], true, 512, JSON_THROW_ON_ERROR); } return $evaluation['variableValue']; } return null; - } catch (\Exception $e) { - $this->logger->error($e->getMessage(), [ - 'exception' => $e, - 'action' => 'getVariable', - 'featureKey' => $featureKey, - 'variableKey' => $variableKey, + } catch (\Throwable $e) { + $this->reportDiagnostic([ + 'level' => 'error', + 'code' => 'evaluation_error', + 'message' => 'getVariable failed', + 'originalError' => $e, + 'details' => [ + 'action' => 'getVariable', + 'featureKey' => $featureKey, + 'variableKey' => $variableKey, + ], ]); return null; } @@ -672,7 +795,6 @@ public function getVariable(string $featureKey, string $variableKey, array $cont * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableBoolean(string $featureKey, string $variableKey, array $context = [], array $options = []): ?bool @@ -686,7 +808,6 @@ public function getVariableBoolean(string $featureKey, string $variableKey, arra * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableString(string $featureKey, string $variableKey, array $context = [], array $options = []): ?string @@ -700,7 +821,6 @@ public function getVariableString(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableInteger(string $featureKey, string $variableKey, array $context = [], array $options = []): ?int @@ -714,7 +834,6 @@ public function getVariableInteger(string $featureKey, string $variableKey, arra * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableDouble(string $featureKey, string $variableKey, array $context = [], array $options = []): ?float @@ -728,7 +847,6 @@ public function getVariableDouble(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableArray(string $featureKey, string $variableKey, array $context = [], array $options = []): ?array @@ -742,7 +860,6 @@ public function getVariableArray(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options */ public function getVariableObject(string $featureKey, string $variableKey, array $context = [], array $options = []) @@ -756,7 +873,6 @@ public function getVariableObject(string $featureKey, string $variableKey, array * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array * } $options * @return array|mixed|null */ @@ -764,15 +880,6 @@ public function getVariableJSON(string $featureKey, string $variableKey, array $ { $value = $this->getVariable($featureKey, $variableKey, $context, $options); - if ($value === null) { - return null; - } - - if (is_string($value)) { - $decoded = json_decode($value, true); - return $decoded !== null ? $decoded : $value; - } - return $value; } @@ -782,16 +889,14 @@ public function getVariableJSON(string $featureKey, string $variableKey, array $ * @param array{ * defaultVariationValue?: mixed, * defaultVariableValue?: mixed, - * flagEvaluation?: array, * } $options * @return array */ public function getAllEvaluations(array $context = [], array $featureKeys = [], array $options = []): array { - $deps = $this->getEvaluationDependencies($context, $options); $evaluations = []; if (empty($featureKeys)) { - $featureKeys = $this->datafileReader->getFeatureKeys(); + $featureKeys = $this->getFeatureKeys(); } foreach ($featureKeys as $featureKey) { // isEnabled @@ -799,22 +904,19 @@ public function getAllEvaluations(array $context = [], array $featureKeys = [], $evaluatedFeature = [ 'enabled' => isset($flagEvaluation['enabled']) ? $flagEvaluation['enabled'] === true : false, ]; - $opts = array_merge($options, [ - 'flagEvaluation' => $flagEvaluation, - ]); // variation - if ($this->datafileReader->hasVariations($featureKey)) { - $variation = $this->getVariation($featureKey, $context, $opts); + if ($this->hasVariations($featureKey)) { + $variation = $this->getVariation($featureKey, $context, $options); if ($variation !== null) { $evaluatedFeature['variation'] = $variation; } } // variables - $variableKeys = $this->datafileReader->getVariableKeys($featureKey); + $variableKeys = $this->getVariableKeys($featureKey); if (!empty($variableKeys)) { $evaluatedFeature['variables'] = []; foreach ($variableKeys as $variableKey) { - $evaluatedFeature['variables'][$variableKey] = $this->getVariable($featureKey, $variableKey, $context, $opts); + $evaluatedFeature['variables'][$variableKey] = $this->getVariable($featureKey, $variableKey, $context, $options); } } $evaluations[$featureKey] = $evaluatedFeature; diff --git a/src/Helpers.php b/src/Helpers.php index 8d59dbd..ad8aa33 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -29,15 +29,21 @@ public static function getValueByType($value, string $fieldType) case 'boolean': return is_bool($value) ? $value : null; case 'array': - return is_array($value) ? $value : null; + return is_array($value) && self::isList($value) ? $value : null; case 'object': - return is_array($value) || is_object($value) ? $value : null; + return (is_array($value) && !self::isList($value)) || is_object($value) ? $value : null; // @NOTE: `json` is not handled here intentionally default: return $value; } - } catch (\Exception $e) { + } catch (\Throwable $e) { return null; } } + + /** @param array $value */ + private static function isList(array $value): bool + { + return $value === [] || array_keys($value) === range(0, count($value) - 1); + } } diff --git a/src/Internal/DatafileReader.php b/src/Internal/DatafileReader.php deleted file mode 100644 index d2c10ce..0000000 --- a/src/Internal/DatafileReader.php +++ /dev/null @@ -1,412 +0,0 @@ - '2', - 'revision' => 'unknown', - 'featurevisorVersion' => null, - 'segments' => [], - 'features' => [] - ]; - - private string $schemaVersion; - private string $revision; - private ?string $featurevisorVersion; - private array $segments; - private array $features; - private LoggerInterface $logger; - private array $regexCache; - - public static function createEmpty(LoggerInterface $logger): self - { - return self::createFromOptions([ - 'datafile' => self::EMPTY_CONTENT, - 'logger' => $logger, - ]); - } - - /** - * @param string|array $datafile - * @throws JsonException - */ - public static function createFromMixed($datafile, LoggerInterface $logger): self - { - return is_string($datafile) - ? self::createFromJson($datafile, $logger) - : self::createFromOptions([ - 'datafile' => $datafile, - 'logger' => $logger, - ]); - } - - /** - * @throws JsonException - */ - public static function createFromJson(string $json, LoggerInterface $logger): self - { - $decodedDatafile = json_decode($json, true, 512, JSON_THROW_ON_ERROR); - - return self::createFromOptions([ - 'datafile' => $decodedDatafile, - 'logger' => $logger - ]); - } - - public static function createFromOptions(array $data): self - { - if (array_key_exists('datafile', $data) === false ) { - throw new InvalidArgumentException('Missing datafile key in data array'); - } - - return new self( - $data['datafile'], - $data['logger'] ?? null - ); - } - - public function __construct(array $datafileContent, ?LoggerInterface $logger = null) - { - $this->logger = $logger ?? new NullLogger(); - - $this->schemaVersion = $datafileContent['schemaVersion'] ?? '2'; - $this->revision = $datafileContent['revision'] ?? 'unknown'; - $this->featurevisorVersion = $datafileContent['featurevisorVersion'] ?? null; - $this->segments = $datafileContent['segments'] ?? []; - $this->features = $datafileContent['features'] ?? []; - $this->regexCache = []; - } - - public function getRevision(): string - { - return $this->revision; - } - - public function getSchemaVersion(): string - { - return $this->schemaVersion; - } - - public function getFeaturevisorVersion(): ?string - { - return $this->featurevisorVersion; - } - - public function getDatafile(): array - { - $datafile = [ - 'schemaVersion' => $this->schemaVersion, - 'revision' => $this->revision, - 'featurevisorVersion' => $this->featurevisorVersion, - 'segments' => $this->segments, - 'features' => $this->features, - ]; - - if ($datafile['featurevisorVersion'] === null) { - unset($datafile['featurevisorVersion']); - } - - return $datafile; - } - - public function getSegment(string $segmentKey): ?array - { - $segment = $this->segments[$segmentKey] ?? null; - - if (!$segment) { - return null; - } - - $segment['conditions'] = $this->parseConditionsIfStringified($segment['conditions']); - - return $segment; - } - - public function getFeatureKeys(): array - { - return array_keys($this->features); - } - - public function getFeature(string $featureKey): ?array - { - return $this->features[$featureKey] ?? null; - } - - public function getVariableKeys(string $featureKey): array - { - $feature = $this->getFeature($featureKey); - - if (!$feature) { - return []; - } - - return array_keys($feature['variablesSchema'] ?? []); - } - - public function hasVariations(string $featureKey): bool - { - $feature = $this->getFeature($featureKey); - - if (!$feature) { - return false; - } - - return isset($feature['variations']) && is_array($feature['variations']) && count($feature['variations']) > 0; - } - - public function getRegex(string $regexString, string $regexFlags = ''): string - { - $key = $regexString . $regexFlags; - - if (!isset($this->regexCache[$key])) { - $this->regexCache[$key] = '/' . $regexString . '/' . $regexFlags; - } - - return $this->regexCache[$key]; - } - - public function allConditionsAreMatched($conditions, array $context): bool - { - if (is_string($conditions)) { - if ($conditions === '*') { - return true; - } - // Try to parse as JSON - $parsed = json_decode($conditions, true); - if (json_last_error() === JSON_ERROR_NONE) { - $conditions = $parsed; - } else { - return false; - } - } - - $getRegex = function(string $regexString, string $regexFlags) { - return $this->getRegex($regexString, $regexFlags); - }; - - if (is_array($conditions)) { - // If it's an empty array, always match (true) - if (count($conditions) === 0) { - return true; - } - // Logical operators - if (isset($conditions['and']) && is_array($conditions['and'])) { - foreach ($conditions['and'] as $subCondition) { - if (!$this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; - } - if (isset($conditions['or']) && is_array($conditions['or'])) { - foreach ($conditions['or'] as $subCondition) { - if ($this->allConditionsAreMatched($subCondition, $context)) { - return true; - } - } - return false; - } - if (isset($conditions['not']) && is_array($conditions['not'])) { - return $this->allConditionsAreMatched([ - 'and' => $conditions['not'], - ], $context) === false; - } - // If it's a plain array, treat as AND (all must match) - if (array_keys($conditions) === range(0, count($conditions) - 1)) { - foreach ($conditions as $subCondition) { - if (!$this->allConditionsAreMatched($subCondition, $context)) { - return false; - } - } - return true; - } - // If it's a single condition (associative array) - if (isset($conditions['attribute'])) { - try { - return Conditions::conditionIsMatched($conditions, $context, $getRegex); - } catch (Exception $e) { - $this->logger->warning($e->getMessage(), [ - 'exception' => $e, - 'condition' => $conditions, - 'context' => $context, - ]); - return false; - } - } - } - return false; - } - - public function segmentIsMatched(array $segment, array $context): bool - { - return $this->allConditionsAreMatched($segment['conditions'], $context); - } - - public function allSegmentsAreMatched($groupSegments, array $context): bool - { - if ($groupSegments === '*') { - return true; - } - - if (is_string($groupSegments)) { - $segment = $this->getSegment($groupSegments); - return $segment ? $this->segmentIsMatched($segment, $context) : false; - } - - // Logical operators - if (is_array($groupSegments)) { - if (isset($groupSegments['and']) && is_array($groupSegments['and'])) { - foreach ($groupSegments['and'] as $subSegment) { - if (!$this->allSegmentsAreMatched($subSegment, $context)) { - return false; - } - } - return true; - } - if (isset($groupSegments['or']) && is_array($groupSegments['or'])) { - foreach ($groupSegments['or'] as $subSegment) { - if ($this->allSegmentsAreMatched($subSegment, $context)) { - return true; - } - } - return false; - } - if (isset($groupSegments['not']) && is_array($groupSegments['not'])) { - return $this->allSegmentsAreMatched([ - 'and' => $groupSegments['not'], - ], $context) === false; - } - // If it's a plain array, treat as AND (all must match) - if (array_keys($groupSegments) === range(0, count($groupSegments) - 1)) { - foreach ($groupSegments as $subSegment) { - if (!$this->allSegmentsAreMatched($subSegment, $context)) { - return false; - } - } - return true; - } - } - - return false; - } - - public function getMatchedTraffic(array $traffic, array $context): ?array - { - foreach ($traffic as $trafficItem) { - $segments = $this->parseSegmentsIfStringified($trafficItem['segments']); - if ($this->allSegmentsAreMatched($segments, $context)) { - return $trafficItem; - } - } - return null; - } - - public function getMatchedAllocation(array $traffic, int $bucketValue): ?array - { - if (!isset($traffic['allocation'])) { - return null; - } - foreach ($traffic['allocation'] as $allocation) { - $range = $allocation['range']; - if ($bucketValue >= $range[0] && $bucketValue <= $range[1]) { - return $allocation; - } - } - return null; - } - - public function getMatchedForce($featureKey, array $context): array - { - $feature = is_string($featureKey) ? $this->getFeature($featureKey) : $featureKey; - - if (!$feature || !isset($feature['force'])) { - return []; - } - - foreach ($feature['force'] as $forceIndex => $force) { - if (isset($force['conditions']) && $this->allConditionsAreMatched($this->parseConditionsIfStringified($force['conditions']), $context)) { - return [ - 'force' => $force, - 'forceIndex' => $forceIndex - ]; - } - if (isset($force['segments']) && $this->allSegmentsAreMatched($this->parseSegmentsIfStringified($force['segments']), $context)) { - return [ - 'force' => $force, - 'forceIndex' => $forceIndex - ]; - } - } - return []; - } - - public function parseConditionsIfStringified($conditions) - { - if (is_string($conditions)) { - if ($conditions === '*') { - return $conditions; - } - $trimmed = ltrim($conditions); - if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { - $parsed = json_decode($conditions, true); - if (json_last_error() === JSON_ERROR_NONE) { - return $parsed; - } - } - return $conditions; - } - if (is_array($conditions) && isset($conditions[0])) { - return array_map(function($condition) { - if (is_string($condition)) { - $trimmed = ltrim($condition); - if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { - $parsed = json_decode($condition, true); - if (json_last_error() === JSON_ERROR_NONE) { - return $parsed; - } - } - } - return $condition; - }, $conditions); - } - return $conditions; - } - - public function parseSegmentsIfStringified($segments) - { - if (is_string($segments)) { - $trimmed = ltrim($segments); - if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { - $parsed = json_decode($segments, true); - if (json_last_error() === JSON_ERROR_NONE) { - return $parsed; - } - } - return $segments; - } - if (is_array($segments) && isset($segments[0])) { - return array_map(function($segment) { - if (is_string($segment)) { - $trimmed = ltrim($segment); - if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) { - $parsed = json_decode($segment, true); - if (json_last_error() === JSON_ERROR_NONE) { - return $parsed; - } - } - } - return $segment; - }, $segments); - } - return $segments; - } -} diff --git a/src/Internal/Diagnostics.php b/src/Internal/Diagnostics.php new file mode 100644 index 0000000..e1d085c --- /dev/null +++ b/src/Internal/Diagnostics.php @@ -0,0 +1,41 @@ + $options + * @param array $evaluation + */ + public static function reportEvaluation( + array $options, + array $evaluation, + string $message, + string $level = 'debug', + ?string $code = null + ): void { + $reportDiagnostic = $options['reportDiagnostic'] ?? null; + if (!is_callable($reportDiagnostic)) { + return; + } + + $diagnostic = [ + 'level' => $level, + 'code' => $code ?? ($evaluation['reason'] ?? 'evaluation_error'), + 'message' => $message, + 'details' => [ + 'featureKey' => $evaluation['featureKey'] ?? null, + 'variableKey' => $evaluation['variableKey'] ?? null, + 'reason' => $evaluation['reason'] ?? null, + 'evaluation' => $evaluation, + ], + ]; + + if (array_key_exists('error', $evaluation)) { + $diagnostic['originalError'] = $evaluation['error']; + } + + $reportDiagnostic($diagnostic); + } +} diff --git a/src/Logger.php b/src/Logger.php deleted file mode 100644 index a5dfe01..0000000 --- a/src/Logger.php +++ /dev/null @@ -1,112 +0,0 @@ -handler = $handler ?? static fn ($level, $message, array $context) => self::defaultLogHandler($level, $message, $context); - $this->setLevel($level); - } - - public function setLevel(string $level): void - { - $level = self::normalizeLevel($level); - if (!in_array($level, self::ALL_LEVELS, true)) { - throw new InvalidArgumentException('Invalid log level'); - } - - $this->level = $level; - } - - public function getLevel(): string - { - return $this->level; - } - - public function log($level, $message, array $context = []): void - { - $level = self::normalizeLevel((string) $level); - - if (!in_array($level, self::ALL_LEVELS, true)) { - throw new InvalidArgumentException('Invalid log level'); - } - - $shouldHandle = array_search($this->level, self::ALL_LEVELS, true) >= array_search($level, self::ALL_LEVELS, true); - - if (!$shouldHandle) { - return; - } - - ($this->handler)($level, self::MSG_PREFIX.' '.$message, $context); - } - - private static function normalizeLevel(string $level): string - { - if ($level === 'fatal') { - return LogLevel::EMERGENCY; - } - if ($level === 'warn') { - return LogLevel::WARNING; - } - return $level; - } - - private static function defaultLogHandler($level, $message, ?array $details = null): void - { - if (STDOUT == false) { - return; - } - - fwrite( - STDOUT, - sprintf( - '%s %s', - $message, - $details !== null ? json_encode($details, JSON_THROW_ON_ERROR) : null - ) . PHP_EOL - ); - } - -} diff --git a/src/OpenFeatureProvider.php b/src/OpenFeatureProvider.php new file mode 100644 index 0000000..940ca35 --- /dev/null +++ b/src/OpenFeatureProvider.php @@ -0,0 +1,260 @@ + $options Featurevisor options + * @param callable|null $onTrack function(string, ?EvaluationContext, ?array): void + */ + public function __construct( + array $options = [], + ?Featurevisor $featurevisor = null, + string $targetingKeyField = 'userId', + string $keySeparator = ':', + string $variationKey = 'variation', + ?callable $onTrack = null + ) { + $this->targetingKeyField = $targetingKeyField !== '' ? $targetingKeyField : 'userId'; + $this->keySeparator = $keySeparator !== '' ? $keySeparator : ':'; + $this->variationKey = $variationKey !== '' ? $variationKey : 'variation'; + $this->onTrack = $onTrack; + $this->ownsFeaturevisor = $featurevisor === null; + if ($featurevisor !== null) { + $this->featurevisor = $featurevisor; + } else { + if (isset($options['datafile']) && is_string($options['datafile'])) { + try { + json_decode($options['datafile'], true, 512, JSON_THROW_ON_ERROR); + } catch (\Throwable $error) { + $this->datafileError = 'Could not parse datafile'; + } + } + $originalHandler = $options['onDiagnostic'] ?? null; + $options['onDiagnostic'] = function (array $diagnostic) use ($originalHandler): void { + if (($diagnostic['code'] ?? null) === 'invalid_datafile') { + $this->datafileError = (string) $diagnostic['message']; + } + if (($diagnostic['code'] ?? null) === 'datafile_set') { + $this->datafileError = null; + } + if ($originalHandler !== null) { + $originalHandler($diagnostic); + } + }; + $this->featurevisor = Featurevisor::createFeaturevisor($options); + } + $this->datafileUnsubscribe = $this->featurevisor->on('datafile_set', function (): void { + $this->datafileError = null; + }); + } + + public function getFeaturevisor(): Featurevisor + { + return $this->featurevisor; + } + + public function shutdown(): void + { + if ($this->closed) { + return; + } + + $this->closed = true; + ($this->datafileUnsubscribe)(); + if ($this->ownsFeaturevisor) { + $this->featurevisor->close(); + } + } + + /** @param array|null $details */ + public function track(string $name, ?EvaluationContext $context = null, ?array $details = null): void + { + if ($this->onTrack !== null) { + ($this->onTrack)($name, $context, $details); + } + } + + public function resolveBooleanValue(string $flagKey, bool $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'boolean'); + } + + public function resolveStringValue(string $flagKey, string $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'string'); + } + + public function resolveIntegerValue(string $flagKey, int $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'integer'); + } + + public function resolveFloatValue(string $flagKey, float $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'number'); + } + + /** @param mixed[] $defaultValue */ + public function resolveObjectValue(string $flagKey, array $defaultValue, ?EvaluationContext $context = null): ResolutionDetailsInterface + { + return $this->resolve($flagKey, $defaultValue, $context, 'object'); + } + + /** @param bool|string|int|float|mixed[] $defaultValue */ + private function resolve(string $flagKey, $defaultValue, ?EvaluationContext $evaluationContext, string $expectedType): ResolutionDetailsInterface + { + if ($this->datafileError !== null) { + return $this->error($defaultValue, ErrorCode::PARSE_ERROR(), $this->datafileError); + } + $position = strpos($flagKey, $this->keySeparator); + $featureKey = $position === false ? $flagKey : substr($flagKey, 0, $position); + $selector = $position === false ? null : substr($flagKey, $position + strlen($this->keySeparator)); + $context = $this->context($evaluationContext); + + if ($selector === null || $selector === '') { + if ($expectedType !== 'boolean') { + return $this->typeMismatch($flagKey, $defaultValue, $expectedType); + } + $evaluation = $this->featurevisor->evaluateFlag($featureKey, $context); + $value = $evaluation['enabled'] ?? null; + } elseif ($selector === $this->variationKey) { + $evaluation = $this->featurevisor->evaluateVariation($featureKey, $context); + $value = $evaluation['variationValue'] ?? ($evaluation['variation']['value'] ?? null); + } else { + $evaluation = $this->featurevisor->evaluateVariable($featureKey, $selector, $context); + $value = $evaluation['variableValue'] ?? null; + if (($evaluation['variableSchema']['type'] ?? null) === 'json' && is_string($value)) { + $parsed = json_decode($value, true); + if (json_last_error() === JSON_ERROR_NONE) { + $value = $parsed; + } + } + } + + $errorCode = $this->errorCode($evaluation['reason']); + if ($errorCode !== null) { + return $this->error($defaultValue, $errorCode, $this->errorMessage($evaluation)); + } + if ($value === null) { + $value = $defaultValue; + } elseif (!$this->matches($value, $expectedType)) { + return $this->typeMismatch($flagKey, $defaultValue, $expectedType); + } elseif ($expectedType === 'number' && is_int($value)) { + $value = (float) $value; + } + + $details = new ResolutionDetails(); + $details->setValue($value); + $details->setReason($this->reason($evaluation['reason'])); + $variant = $evaluation['variationValue'] ?? ($evaluation['variation']['value'] ?? null); + if ($variant !== null) { + $details->setVariant((string) $variant); + } + return $details; + } + + /** @return array */ + private function context(?EvaluationContext $context): array + { + $result = $context !== null ? $this->normalize($context->getAttributes()->toArray()) : []; + if ($context !== null && $context->getTargetingKey() !== null && $context->getTargetingKey() !== '') { + $result[$this->targetingKeyField] = $context->getTargetingKey(); + } + return $result; + } + + private function reason(string $reason): string + { + if (in_array($reason, ['feature_not_found', 'variable_not_found', 'no_variations', 'error'], true)) return Reason::ERROR; + if (in_array($reason, ['required', 'forced', 'sticky', 'rule', 'variable_override_variation', 'variable_override_rule'], true)) return Reason::TARGETING_MATCH; + if ($reason === 'allocated') return Reason::SPLIT; + if (in_array($reason, ['disabled', 'variation_disabled', 'variable_disabled'], true)) return Reason::DISABLED; + return Reason::DEFAULT; + } + + private function errorCode(string $reason): ?ErrorCode + { + if (in_array($reason, ['feature_not_found', 'variable_not_found', 'no_variations'], true)) return ErrorCode::FLAG_NOT_FOUND(); + if ($reason === 'error') return ErrorCode::GENERAL(); + return null; + } + + /** @param array $evaluation */ + private function errorMessage(array $evaluation): string + { + if (($evaluation['error'] ?? null) instanceof \Throwable) return $evaluation['error']->getMessage(); + if (is_string($evaluation['error'] ?? null) && $evaluation['error'] !== '') return $evaluation['error']; + if ($evaluation['reason'] === 'feature_not_found') return sprintf('Feature "%s" was not found', $evaluation['featureKey']); + if ($evaluation['reason'] === 'variable_not_found') return sprintf('Variable "%s" was not found for feature "%s"', $evaluation['variableKey'] ?? '', $evaluation['featureKey']); + if ($evaluation['reason'] === 'no_variations') return sprintf('Feature "%s" has no variations', $evaluation['featureKey']); + return 'Featurevisor evaluation failed'; + } + + /** @param mixed $value */ + private function matches($value, string $expectedType): bool + { + if ($expectedType === 'boolean') return is_bool($value); + if ($expectedType === 'string') return is_string($value); + if ($expectedType === 'integer') return is_int($value); + if ($expectedType === 'number') return (is_int($value) || is_float($value)) && is_finite((float) $value); + return is_array($value); + } + + /** @param mixed $value @return mixed */ + private function normalize($value) + { + if ($value instanceof DateTimeInterface) { + return DateTimeImmutable::createFromInterface($value) + ->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d\TH:i:s.v\Z'); + } + if (is_array($value)) return array_map(fn($item) => $this->normalize($item), $value); + return $value; + } + + /** @param bool|string|int|float|mixed[] $value */ + private function error($value, ErrorCode $code, string $message): ResolutionDetailsInterface + { + $details = new ResolutionDetails(); + $details->setValue($value); + $details->setReason(Reason::ERROR); + $details->setError(new ResolutionError($code, $message)); + return $details; + } + + /** @param bool|string|int|float|mixed[] $value */ + private function typeMismatch(string $key, $value, string $expected): ResolutionDetailsInterface + { + return $this->error($value, ErrorCode::TYPE_MISMATCH(), sprintf('Flag "%s" did not resolve to a %s value', $key, $expected)); + } +} diff --git a/tests/BucketerTest.php b/tests/BucketerTest.php index d690663..7aae0f4 100644 --- a/tests/BucketerTest.php +++ b/tests/BucketerTest.php @@ -2,10 +2,8 @@ namespace Featurevisor\Tests; -use Featurevisor\Logger; use PHPUnit\Framework\TestCase; use Featurevisor\Bucketer; -use Psr\Log\LogLevel; class BucketerTest extends TestCase { @@ -42,12 +40,10 @@ public function testGetBucketKeyPlain() { $featureKey = 'test-feature'; $bucketBy = 'userId'; $context = ['userId' => '123', 'browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('123.test-feature', $bucketKey); } @@ -56,26 +52,31 @@ public function testGetBucketKeyPlainMissingContext() { $featureKey = 'test-feature'; $bucketBy = 'userId'; $context = ['browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('test-feature', $bucketKey); } + public function testGetBucketKeyStringifiesWholeFloatsAndNegativeZeroLikeJavaScript() { + $bucketKey = Bucketer::getBucketKey([ + 'featureKey' => 'feature', + 'bucketBy' => ['whole', 'negativeZero', 'small', 'large'], + 'context' => ['whole' => 1.0, 'negativeZero' => -0.0, 'small' => 1e-6, 'large' => 1e21], + ]); + self::assertSame('1.0.0.000001.1e+21.feature', $bucketKey); + } + public function testGetBucketKeyAndAllPresent() { $featureKey = 'test-feature'; $bucketBy = ['organizationId', 'userId']; $context = ['organizationId' => '123', 'userId' => '234', 'browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('123.234.test-feature', $bucketKey); } @@ -84,12 +85,10 @@ public function testGetBucketKeyAndPartial() { $featureKey = 'test-feature'; $bucketBy = ['organizationId', 'userId']; $context = ['organizationId' => '123', 'browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('123.test-feature', $bucketKey); } @@ -102,30 +101,22 @@ public function testGetBucketKeyAndDotSeparated() { 'user' => ['id' => '234'], 'browser' => 'chrome', ]; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); - // Note: The current PHP implementation does not support dot-separated paths in getValueFromContext - // If you add support, this should pass: - // self::assertEquals('123.234.test-feature', $bucketKey); - // For now, it will be '123.test-feature' (since 'user.id' is not resolved) - self::assertEquals('123.test-feature', $bucketKey); + self::assertEquals('123.234.test-feature', $bucketKey); } public function testGetBucketKeyOrFirstAvailable() { $featureKey = 'test-feature'; $bucketBy = ['or' => ['userId', 'deviceId']]; $context = ['deviceId' => 'deviceIdHere', 'userId' => '234', 'browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('234.test-feature', $bucketKey); } @@ -134,12 +125,10 @@ public function testGetBucketKeyOrOnlyDeviceId() { $featureKey = 'test-feature'; $bucketBy = ['or' => ['userId', 'deviceId']]; $context = ['deviceId' => 'deviceIdHere', 'browser' => 'chrome']; - $logger = Logger::create(['level' => LogLevel::WARNING]); $bucketKey = Bucketer::getBucketKey([ 'featureKey' => $featureKey, 'bucketBy' => $bucketBy, 'context' => $context, - 'logger' => $logger, ]); self::assertEquals('deviceIdHere.test-feature', $bucketKey); } diff --git a/tests/ChildTest.php b/tests/ChildTest.php index 97ade04..e6ea72b 100644 --- a/tests/ChildTest.php +++ b/tests/ChildTest.php @@ -173,9 +173,12 @@ public function testCreateChildInstanceAndAllBehaviors() { self::assertTrue($childF->isEnabled('test')); self::assertEquals('control', $childF->getVariation('test')); + self::assertTrue($childF->evaluateFlag('test')['enabled']); + self::assertEquals('control', $childF->evaluateVariation('test')['variation']['value']); self::assertEquals('black', $childF->getVariable('test', 'color')); self::assertEquals('black', $childF->getVariableString('test', 'color')); + self::assertEquals('black', $childF->evaluateVariable('test', 'color')['variableValue']); self::assertEquals(false, $childF->getVariable('test', 'showSidebar')); self::assertEquals(false, $childF->getVariableBoolean('test', 'showSidebar')); @@ -206,10 +209,48 @@ public function testCreateChildInstanceAndAllBehaviors() { 'newFeature' => [ 'enabled' => true ] ]); self::assertTrue($childF->isEnabled('newFeature')); + self::assertEquals('sticky', $childF->evaluateFlag('newFeature')['reason']); $allEvaluations = $childF->getAllEvaluations(); self::assertEquals(['test', 'anotherTest'], array_keys($allEvaluations)); $childF->close(); } + + public function testCloseRemovesDelegatedParentSubscriptions(): void + { + $parent = Featurevisor::createFeaturevisor(['logLevel' => 'fatal']); + $child = $parent->spawn(); + $events = []; + $child->on('datafile_set', static function (array $event) use (&$events): void { + $events[] = $event; + }); + + $child->close(); + $child->close(); + $parent->setDatafile([ + 'schemaVersion' => '2', + 'revision' => 'after-close', + 'segments' => [], + 'features' => [], + ], true); + + self::assertSame([], $events); + } + + public function testContextMatchesJavaScriptSnapshotBehavior(): void + { + $parent = Featurevisor::createFeaturevisor([ + 'context' => ['country' => 'nl', 'plan' => 'free'], + 'logLevel' => 'fatal', + ]); + $child = $parent->spawn(['country' => 'de']); + $parent->setContext(['plan' => 'pro', 'locale' => 'de-DE']); + + self::assertSame([ + 'country' => 'de', + 'plan' => 'free', + 'locale' => 'de-DE', + ], $child->getContext()); + } } diff --git a/tests/ConditionsTest.php b/tests/ConditionsTest.php index c2b8de0..2225deb 100644 --- a/tests/ConditionsTest.php +++ b/tests/ConditionsTest.php @@ -4,244 +4,264 @@ use DateTime; use Featurevisor\Conditions; -use Featurevisor\Internal\DatafileReader; -use Featurevisor\Logger; use PHPUnit\Framework\TestCase; class ConditionsTest extends TestCase { - private DatafileReader $datafileReader; + private object $evaluationData; protected function setUp(): void { - $this->datafileReader = DatafileReader::createEmpty(Logger::create()); + $this->evaluationData = new class { + public function allConditionsAreMatched($conditions, array $context): bool + { + return Conditions::allConditionsAreMatched( + $conditions, + $context, + static fn(string $pattern, string $flags): string => '~'.str_replace('~', '\\~', $pattern).'~'.str_replace(['g', 'y'], '', $flags) + ); + } + }; } public function testMatchAllViaStar() { - self::assertTrue($this->datafileReader->allConditionsAreMatched('*', ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched('blah', ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched('*', ['browser_type' => 'chrome'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched('blah', ['browser_type' => 'chrome'])); } public function testOperatorEquals() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); } public function testOperatorEqualsDotSeparated() { $conditions = [[ 'attribute' => 'browser.type', 'operator' => 'equals', 'value' => 'chrome' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['type' => 'chrome']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['type' => 'firefox']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['blah' => 'firefox']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['type' => 'chrome']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['type' => 'firefox']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['blah' => 'firefox']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => 'firefox'])); } public function testOperatorNotEquals() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'notEquals', 'value' => 'chrome' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); } public function testOperatorExists() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'exists' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['not_browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['not_browser_type' => 'chrome'])); } public function testOperatorExistsDotSeparated() { $conditions = [[ 'attribute' => 'browser.name', 'operator' => 'exists' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['name' => 'chrome']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['version' => '1.2.3']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.2.3'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['name' => 'chrome']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => 'chrome'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['version' => '1.2.3']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.2.3'])); } public function testOperatorNotExists() { $conditions = [[ 'attribute' => 'name', 'operator' => 'notExists' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['not_name' => 'Hello World'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['not_name' => 'Hello Universe'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['not_name' => 'Hello World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['not_name' => 'Hello Universe'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); } public function testOperatorNotExistsDotSeparated() { $conditions = [[ 'attribute' => 'browser.name', 'operator' => 'notExists' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['not_name' => 'Hello World']])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['not_name' => 'Hello Universe'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser' => ['name' => 'Chrome']])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['not_name' => 'Hello World']])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['not_name' => 'Hello Universe'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser' => ['name' => 'Chrome']])); } public function testOperatorEndsWith() { $conditions = [[ 'attribute' => 'name', 'operator' => 'endsWith', 'value' => 'World' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi Universe'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi Universe'])); } public function testOperatorIncludes() { $conditions = [[ 'attribute' => 'permissions', 'operator' => 'includes', 'value' => 'write' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['permissions' => ['read', 'write']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['permissions' => ['read']])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['permissions' => ['read', 'write']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['permissions' => ['read']])); } public function testOperatorNotIncludes() { $conditions = [[ 'attribute' => 'permissions', 'operator' => 'notIncludes', 'value' => 'write' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['permissions' => ['read', 'admin']])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['permissions' => ['read', 'write', 'admin']])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['permissions' => ['read', 'admin']])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['permissions' => ['read', 'write', 'admin']])); } public function testOperatorContains() { $conditions = [[ 'attribute' => 'name', 'operator' => 'contains', 'value' => 'Hello' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Yo! Hello!'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Yo! Hello!'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); } public function testOperatorNotContains() { $conditions = [[ 'attribute' => 'name', 'operator' => 'notContains', 'value' => 'Hello' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Yo! Hello!'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Yo! Hello!'])); } public function testOperatorMatches() { $conditions = [[ 'attribute' => 'name', 'operator' => 'matches', 'value' => '^[a-zA-Z]{2,}$' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Helloooooo'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hell123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => '123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 123])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Helloooooo'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hell123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => '123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 123])); } public function testOperatorMatchesWithRegexFlags() { $conditions = [[ 'attribute' => 'name', 'operator' => 'matches', 'value' => '^[a-zA-Z]{2,}$', 'regexFlags' => 'i' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Helloooooo'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hell123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => '123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 123])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Helloooooo'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello World'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hell123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => '123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 123])); } public function testOperatorNotMatches() { $conditions = [[ 'attribute' => 'name', 'operator' => 'notMatches', 'value' => '^[a-zA-Z]{2,}$' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => '123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hellooooooo'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => '123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hellooooooo'])); } public function testOperatorNotMatchesWithRegexFlags() { $conditions = [[ 'attribute' => 'name', 'operator' => 'notMatches', 'value' => '^[a-zA-Z]{2,}$', 'regexFlags' => 'i' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['name' => '123'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hello'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['name' => 'Hellooooooo'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hi World'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['name' => '123'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hello'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['name' => 'Hellooooooo'])); } public function testOperatorIn() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'in', 'value' => ['chrome', 'firefox'] ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'safari'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'safari'])); } public function testOperatorNotIn() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'notIn', 'value' => ['chrome', 'firefox'] ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'safari'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'safari'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); } public function testOperatorGreaterThan() { $conditions = [[ 'attribute' => 'age', 'operator' => 'greaterThan', 'value' => 18 ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 19])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 17])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 19])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 17])); } public function testOperatorGreaterThanOrEquals() { $conditions = [[ 'attribute' => 'age', 'operator' => 'greaterThanOrEquals', 'value' => 18 ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 18])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 19])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 17])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 16])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 18])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 19])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 17])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 16])); } public function testOperatorLessThan() { $conditions = [[ 'attribute' => 'age', 'operator' => 'lessThan', 'value' => 18 ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 17])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 19])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 17])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 19])); } public function testOperatorLessThanOrEquals() { $conditions = [[ 'attribute' => 'age', 'operator' => 'lessThanOrEquals', 'value' => 18 ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 17])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 18])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 19])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['age' => 20])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 17])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 18])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 19])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['age' => 20])); } public function testOperatorSemverEquals() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverEquals', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); } public function testOperatorSemverNotEquals() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverNotEquals', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); } public function testOperatorSemverGreaterThan() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverGreaterThan', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); } public function testOperatorSemverGreaterThanOrEquals() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverGreaterThanOrEquals', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '2.0.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); } public function testOperatorSemverLessThan() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverLessThan', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '0.9.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.1.0'])); } public function testOperatorSemverLessThanOrEquals() { $conditions = [[ 'attribute' => 'version', 'operator' => 'semverLessThanOrEquals', 'value' => '1.0.0' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['version' => '1.1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.0.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['version' => '1.1.0'])); + } + + public function testSemverPrereleaseAndBuildMetadata() { + self::assertTrue($this->evaluationData->allConditionsAreMatched( + [[ 'attribute' => 'version', 'operator' => 'semverLessThan', 'value' => '1.2.3' ]], + ['version' => '1.2.3-beta.1'] + )); + self::assertTrue($this->evaluationData->allConditionsAreMatched( + [[ 'attribute' => 'version', 'operator' => 'semverEquals', 'value' => '1.2.3+build.9' ]], + ['version' => '1.2.3+build.5'] + )); } public function testOperatorBefore() { $conditions = [[ 'attribute' => 'date', 'operator' => 'before', 'value' => '2023-05-13T16:23:59Z' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['date' => '2023-05-12T00:00:00Z'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-12T00:00:00Z')])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['date' => '2023-05-14T00:00:00Z'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-14T00:00:00Z')])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-12T00:00:00Z'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-12T00:00:00Z')])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-14T00:00:00Z'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-14T00:00:00Z')])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-12T00:00:00'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-13T17:23:59+01:00'])); } public function testOperatorAfter() { $conditions = [[ 'attribute' => 'date', 'operator' => 'after', 'value' => '2023-05-13T16:23:59Z' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['date' => '2023-05-14T00:00:00Z'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-14T00:00:00Z')])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['date' => '2023-05-12T00:00:00Z'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-12T00:00:00Z')])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-14T00:00:00Z'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-14T00:00:00Z')])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => '2023-05-12T00:00:00Z'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['date' => new DateTime('2023-05-12T00:00:00Z')])); } public function testSimpleConditionVariants() { $conditions = [[ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions[0], ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched([], ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched([], ['browser_type' => 'firefox'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched([ + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions[0], ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched([], ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched([], ['browser_type' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched([ ['attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome'], ['attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0'], ], ['browser_type' => 'chrome', 'browser_version' => '1.0', 'foo' => 'bar'])); @@ -251,29 +271,29 @@ public function testAndCondition() { $conditions = [[ 'and' => [ [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); $conditions = [[ 'and' => [ [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0' ], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); } public function testOrCondition() { $conditions = [[ 'or' => [ [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); $conditions = [[ 'or' => [ [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0' ], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_version' => '1.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_version' => '1.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox'])); } public function testNotCondition() { @@ -281,18 +301,18 @@ public function testNotCondition() { [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '1.0' ], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); $conditions = [[ 'not' => [[ 'or' => [ [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'chrome' ], [ 'attribute' => 'browser_type', 'operator' => 'equals', 'value' => 'firefox' ], ]]]]]; - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched([[ 'not' => [] ]], [])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'edge'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched([[ 'not' => [] ]], [])); self::assertFalse(Conditions::conditionIsMatched(['not' => []], [], fn($regex, $flags) => '/' . $regex . '/' . $flags)); } @@ -305,10 +325,10 @@ public function testNestedConditions() { [ 'attribute' => 'browser_version', 'operator' => 'equals', 'value' => '2.0' ], ]], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '3.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_version' => '2.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '3.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_version' => '2.0'])); // plain, then OR inside AND $conditions = [ @@ -321,10 +341,10 @@ public function testNestedConditions() { ]], ]], ]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '1.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '3.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'us', 'browser_version' => '2.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '1.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '3.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'us', 'browser_version' => '2.0'])); // AND inside OR $conditions = [[ 'or' => [ @@ -334,10 +354,10 @@ public function testNestedConditions() { [ 'attribute' => 'orientation', 'operator' => 'equals', 'value' => 'portrait' ], ]], ]]]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'device_type' => 'mobile', 'orientation' => 'portrait'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'device_type' => 'desktop'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'device_type' => 'mobile', 'orientation' => 'portrait'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'device_type' => 'desktop'])); // plain, then AND inside OR $conditions = [ @@ -350,9 +370,9 @@ public function testNestedConditions() { ]], ]], ]; - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '2.0'])); - self::assertTrue($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'firefox', 'device_type' => 'mobile', 'orientation' => 'portrait'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); - self::assertFalse($this->datafileReader->allConditionsAreMatched($conditions, ['country' => 'de', 'browser_type' => 'firefox', 'device_type' => 'desktop'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'chrome', 'browser_version' => '2.0'])); + self::assertTrue($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'nl', 'browser_type' => 'firefox', 'device_type' => 'mobile', 'orientation' => 'portrait'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['browser_type' => 'firefox', 'browser_version' => '2.0'])); + self::assertFalse($this->evaluationData->allConditionsAreMatched($conditions, ['country' => 'de', 'browser_type' => 'firefox', 'device_type' => 'desktop'])); } } diff --git a/tests/DatafileReaderTest.php b/tests/DatafileReaderTest.php deleted file mode 100644 index 2bc92c5..0000000 --- a/tests/DatafileReaderTest.php +++ /dev/null @@ -1,199 +0,0 @@ - 'emergency'])); - $traffic = ['allocation' => $fixture['bucketing']['allocations']]; - foreach ($fixture['bucketing']['allocationExpectations'] as $bucket => $expected) { - $allocation = $reader->getMatchedAllocation($traffic, (int) $bucket); - self::assertSame($expected, $allocation['variation']); - } - } - - public function testV2DatafileSchemaEntities() { - $datafileJson = [ - 'schemaVersion' => '2', - 'revision' => '1', - 'segments' => [ - 'netherlands' => [ - 'key' => 'netherlands', - 'conditions' => [ - [ 'attribute' => 'country', 'operator' => 'equals', 'value' => 'nl' ], - ], - ], - 'germany' => [ - 'key' => 'germany', - 'conditions' => json_encode([ - [ 'attribute' => 'country', 'operator' => 'equals', 'value' => 'de' ], - ]), - ], - ], - 'features' => [ - 'test' => [ - 'key' => 'test', - 'bucketBy' => 'userId', - 'variations' => [ - [ 'value' => 'control' ], - [ 'value' => 'treatment', 'variables' => [ 'showSidebar' => true ] ], - ], - 'traffic' => [ - [ - 'key' => '1', - 'segments' => '*', - 'percentage' => 100000, - 'allocation' => [ - [ 'variation' => 'control', 'range' => [0, 0] ], - [ 'variation' => 'treatment', 'range' => [0, 100000] ], - ], - ], - ], - ], - ], - ]; - $logger = Logger::create(); - $reader = DatafileReader::createFromOptions([ - 'datafile' => $datafileJson, - 'logger' => $logger, - ]); - self::assertEquals('1', $reader->getRevision()); - self::assertEquals('2', $reader->getSchemaVersion()); - self::assertEquals($datafileJson['segments']['netherlands'], $reader->getSegment('netherlands')); - self::assertEquals('de', $reader->getSegment('germany')['conditions'][0]['value']); - self::assertNull($reader->getSegment('belgium')); - self::assertEquals($datafileJson['features']['test'], $reader->getFeature('test')); - self::assertNull($reader->getFeature('test2')); - } - - public function testSegmentsMatching() { - $groups = [ - [ 'key' => '*', 'segments' => '*' ], - [ 'key' => 'dutchMobileUsers', 'segments' => ['mobileUsers', 'netherlands'] ], - [ 'key' => 'dutchMobileUsers2', 'segments' => [ 'and' => ['mobileUsers', 'netherlands'] ] ], - [ 'key' => 'dutchMobileOrDesktopUsers', 'segments' => ['netherlands', [ 'or' => ['mobileUsers', 'desktopUsers'] ]] ], - [ 'key' => 'dutchMobileOrDesktopUsers2', 'segments' => [ 'and' => ['netherlands', [ 'or' => ['mobileUsers', 'desktopUsers'] ]] ] ], - [ 'key' => 'germanMobileUsers', 'segments' => [ [ 'and' => ['mobileUsers', 'germany'] ] ] ], - [ 'key' => 'germanNonMobileUsers', 'segments' => [ [ 'and' => ['germany', [ 'not' => ['mobileUsers'] ]] ] ] ], - [ 'key' => 'notVersion5.5', 'segments' => [ [ 'not' => ['version_5.5'] ] ] ], - ]; - $datafileContent = [ - 'schemaVersion' => '2', - 'revision' => '1', - 'features' => [], - 'segments' => [ - 'mobileUsers' => [ - 'key' => 'mobileUsers', - 'conditions' => [ [ 'attribute' => 'deviceType', 'operator' => 'equals', 'value' => 'mobile' ] ], - ], - 'desktopUsers' => [ - 'key' => 'desktopUsers', - 'conditions' => [ [ 'attribute' => 'deviceType', 'operator' => 'equals', 'value' => 'desktop' ] ], - ], - 'chromeBrowser' => [ - 'key' => 'chromeBrowser', - 'conditions' => [ [ 'attribute' => 'browser', 'operator' => 'equals', 'value' => 'chrome' ] ], - ], - 'firefoxBrowser' => [ - 'key' => 'firefoxBrowser', - 'conditions' => [ [ 'attribute' => 'browser', 'operator' => 'equals', 'value' => 'firefox' ] ], - ], - 'netherlands' => [ - 'key' => 'netherlands', - 'conditions' => [ [ 'attribute' => 'country', 'operator' => 'equals', 'value' => 'nl' ] ], - ], - 'germany' => [ - 'key' => 'germany', - 'conditions' => [ [ 'attribute' => 'country', 'operator' => 'equals', 'value' => 'de' ] ], - ], - 'version_5.5' => [ - 'key' => 'version_5.5', - 'conditions' => [ [ 'or' => [ - [ 'attribute' => 'version', 'operator' => 'equals', 'value' => '5.5' ], - [ 'attribute' => 'version', 'operator' => 'equals', 'value' => 5.5 ], - ] ] ], - ], - ], - ]; - $logger = Logger::create(); - $datafileReader = DatafileReader::createFromOptions([ - 'datafile' => $datafileContent, - 'logger' => $logger, - ]); - // everyone - $group = $groups[0]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['foo' => 'foo'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['bar' => 'bar'])); - // dutchMobileUsers - $group = $groups[1]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); - // dutchMobileUsers2 (same as above) - $group = $groups[2]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); - // dutchMobileOrDesktopUsers - $group = $groups[3]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile', 'browser' => 'chrome'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'desktop'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'desktop', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'desktop'])); - // dutchMobileOrDesktopUsers2 - $group = $groups[4]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile', 'browser' => 'chrome'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'desktop'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'desktop', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'desktop'])); - // germanMobileUsers - $group = $groups[5]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'mobile', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'mobile'])); - // germanNonMobileUsers - $group = $groups[6]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'desktop'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'de', 'deviceType' => 'desktop', 'browser' => 'chrome'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['country' => 'nl', 'deviceType' => 'desktop'])); - // notVersion5.5 - $group = $groups[7]; - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], [])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => '5.6'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => 5.6])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => '5.7'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => 5.7])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => '5.5'])); - self::assertFalse($datafileReader->allSegmentsAreMatched($group['segments'], ['version' => 5.5])); - - $segments = ['not' => ['mobileUsers', 'netherlands']]; - self::assertFalse($datafileReader->allSegmentsAreMatched($segments, ['country' => 'nl', 'deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($segments, ['country' => 'nl', 'deviceType' => 'desktop'])); - - $segments = ['not' => [[ 'or' => ['mobileUsers', 'desktopUsers'] ]]]; - self::assertFalse($datafileReader->allSegmentsAreMatched($segments, ['deviceType' => 'mobile'])); - self::assertTrue($datafileReader->allSegmentsAreMatched($segments, ['deviceType' => 'tv'])); - self::assertFalse($datafileReader->allSegmentsAreMatched(['not' => []], [])); - } -} diff --git a/tests/DiagnosticContractTest.php b/tests/DiagnosticContractTest.php index 40c0dbf..734e6a5 100644 --- a/tests/DiagnosticContractTest.php +++ b/tests/DiagnosticContractTest.php @@ -7,6 +7,12 @@ class DiagnosticContractTest extends TestCase { + public function testLoggerClassesAreAbsent(): void + { + self::assertFalse(class_exists('Featurevisor\\Logger')); + self::assertFalse(class_exists('Featurevisor\\Internal\\Logger')); + } + public function testEmptyDetailsSerializeAsAnObject(): void { $diagnostics = []; @@ -32,4 +38,67 @@ public function testDiagnosticHandlerFailureIsIsolated(): void self::assertFalse($sdk->isEnabled('missing', [])); $sdk->close(); } + + public function testEvaluationsReportStructuredDiagnosticsDirectly(): void + { + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => 'debug', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1', + 'segments' => [], + 'features' => [], + ], + ]); + + self::assertFalse($sdk->isEnabled('missing', ['userId' => 'user-1'])); + + $diagnostic = array_values(array_filter( + $diagnostics, + static fn (array $item): bool => $item['code'] === 'feature_not_found' + ))[0]; + + self::assertSame('warn', $diagnostic['level']); + self::assertSame('Feature not found', $diagnostic['message']); + self::assertSame('missing', $diagnostic['details']['featureKey']); + self::assertSame('feature_not_found', $diagnostic['details']['reason']); + self::assertSame('flag', $diagnostic['details']['evaluation']['type']); + } + + public function testInvalidBucketByReportsDiagnosticsWithoutLoggerInfrastructure(): void + { + $diagnostics = []; + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => 'debug', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + 'datafile' => [ + 'schemaVersion' => '2', + 'revision' => '1', + 'segments' => [], + 'features' => [ + 'invalid' => [ + 'key' => 'invalid', + 'bucketBy' => true, + 'traffic' => [], + ], + ], + ], + ]); + + self::assertFalse($sdk->isEnabled('invalid', [])); + self::assertContains('invalid_bucket_by', array_column($diagnostics, 'code')); + self::assertContains('evaluation_error', array_column($diagnostics, 'code')); + } + + public function testInvalidDiagnosticLevelIsRejected(): void + { + $this->expectException(\InvalidArgumentException::class); + Featurevisor::createFeaturevisor(['logLevel' => 'notice']); + } } diff --git a/tests/EventsTest.php b/tests/EventsTest.php index 20622c9..5afe21d 100644 --- a/tests/EventsTest.php +++ b/tests/EventsTest.php @@ -2,83 +2,58 @@ namespace Featurevisor\Tests; -use Featurevisor\Logger; -use PHPUnit\Framework\TestCase; - use Featurevisor\Events; -use Featurevisor\Internal\DatafileReader; +use PHPUnit\Framework\TestCase; -class EventsTest extends TestCase +final class EventsTest extends TestCase { - public function testGetParamsForStickySetEventEmptyToNew() + public function testGetParamsForStickySetEventEmptyToNew(): void { - $previousStickyFeatures = []; - $newStickyFeatures = [ + self::assertSame([ + 'features' => ['feature2', 'feature3'], + 'replaced' => true, + ], Events::getParamsForStickySetEvent([], [ 'feature2' => ['enabled' => true], 'feature3' => ['enabled' => true], - ]; - $replace = true; - - $result = Events::getParamsForStickySetEvent($previousStickyFeatures, $newStickyFeatures, $replace); - - self::assertEquals([ - 'features' => ['feature2', 'feature3'], - 'replaced' => $replace, - ], $result); + ], true)); } - public function testGetParamsForStickySetEventAddChangeRemove() + public function testGetParamsForStickySetEventAddChangeRemove(): void { - $previousStickyFeatures = [ + self::assertSame([ + 'features' => ['feature1', 'feature2', 'feature3'], + 'replaced' => true, + ], Events::getParamsForStickySetEvent([ 'feature1' => ['enabled' => true], 'feature2' => ['enabled' => true], - ]; - $newStickyFeatures = [ + ], [ 'feature2' => ['enabled' => true], 'feature3' => ['enabled' => true], - ]; - $replace = true; - - $result = Events::getParamsForStickySetEvent($previousStickyFeatures, $newStickyFeatures, $replace); - - self::assertEquals([ - 'features' => ['feature1', 'feature2', 'feature3'], - 'replaced' => $replace, - ], $result); + ], true)); } - public function testGetParamsForDatafileSetEventEmptyToNew() + /** @param array> $features */ + private function datafile(string $revision, array $features): array { - $logger = Logger::create([ - 'level' => 'error', - ]); - - $previousDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '1', - 'features' => [], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $newDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '2', - 'features' => [ - 'feature1' => ['bucketBy' => 'userId', 'hash' => 'hash1', 'traffic' => []], - 'feature2' => ['bucketBy' => 'userId', 'hash' => 'hash2', 'traffic' => []], - ], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $result = Events::getParamsForDatafileSetEvent($previousDatafileReader, $newDatafileReader); + return [ + 'schemaVersion' => '2', + 'revision' => $revision, + 'features' => $features, + 'segments' => [], + ]; + } - self::assertEquals([ + public function testGetParamsForDatafileSetEventEmptyToNew(): void + { + $result = Events::getParamsForDatafileSetEvent( + $this->datafile('1', []), + $this->datafile('2', [ + 'feature1' => ['hash' => 'hash1'], + 'feature2' => ['hash' => 'hash2'], + ]) + ); + + self::assertSame([ 'revision' => '2', 'previousRevision' => '1', 'revisionChanged' => true, @@ -87,89 +62,35 @@ public function testGetParamsForDatafileSetEventEmptyToNew() ], $result); } - public function testGetParamsForDatafileSetEventChangeHashAddition() + public function testGetParamsForDatafileSetEventChangeHashAddition(): void { - $logger = Logger::create([ - 'level' => 'error', - ]); - - $previousDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '1', - 'features' => [ - 'feature1' => ['bucketBy' => 'userId', 'hash' => 'hash-same', 'traffic' => []], - 'feature2' => ['bucketBy' => 'userId', 'hash' => 'hash1-2', 'traffic' => []], - ], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $newDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '2', - 'features' => [ - 'feature1' => ['bucketBy' => 'userId', 'hash' => 'hash-same', 'traffic' => []], - 'feature2' => ['bucketBy' => 'userId', 'hash' => 'hash2-2', 'traffic' => []], - 'feature3' => ['bucketBy' => 'userId', 'hash' => 'hash2-3', 'traffic' => []], - ], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $result = Events::getParamsForDatafileSetEvent($previousDatafileReader, $newDatafileReader); - - self::assertEquals([ - 'revision' => '2', - 'previousRevision' => '1', - 'revisionChanged' => true, - 'features' => ['feature2', 'feature3'], - 'replaced' => false, - ], $result); + $result = Events::getParamsForDatafileSetEvent( + $this->datafile('1', [ + 'feature1' => ['hash' => 'same'], + 'feature2' => ['hash' => 'old'], + ]), + $this->datafile('2', [ + 'feature1' => ['hash' => 'same'], + 'feature2' => ['hash' => 'new'], + 'feature3' => ['hash' => 'added'], + ]) + ); + + self::assertSame(['feature2', 'feature3'], $result['features']); } - public function testGetParamsForDatafileSetEventChangeHashRemoval() + public function testGetParamsForDatafileSetEventChangeHashRemoval(): void { - $logger = Logger::create([ - 'level' => 'error', - ]); - - $previousDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '1', - 'features' => [ - 'feature1' => ['bucketBy' => 'userId', 'hash' => 'hash-same', 'traffic' => []], - 'feature2' => ['bucketBy' => 'userId', 'hash' => 'hash1-2', 'traffic' => []], - ], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $newDatafileReader = DatafileReader::createFromOptions([ - 'datafile' => [ - 'schemaVersion' => '1.0.0', - 'revision' => '2', - 'features' => [ - 'feature2' => ['bucketBy' => 'userId', 'hash' => 'hash2-2', 'traffic' => []], - ], - 'segments' => [], - ], - 'logger' => $logger, - ]); - - $result = Events::getParamsForDatafileSetEvent($previousDatafileReader, $newDatafileReader); - - self::assertEquals([ - 'revision' => '2', - 'previousRevision' => '1', - 'revisionChanged' => true, - 'features' => ['feature1', 'feature2'], - 'replaced' => false, - ], $result); + $result = Events::getParamsForDatafileSetEvent( + $this->datafile('1', [ + 'feature1' => ['hash' => 'same'], + 'feature2' => ['hash' => 'old'], + ]), + $this->datafile('2', [ + 'feature2' => ['hash' => 'new'], + ]) + ); + + self::assertSame(['feature1', 'feature2'], $result['features']); } } diff --git a/tests/FeaturevisorTest.php b/tests/FeaturevisorTest.php index 91e4012..f8c764e 100644 --- a/tests/FeaturevisorTest.php +++ b/tests/FeaturevisorTest.php @@ -3,9 +3,7 @@ namespace Featurevisor\Tests; use Featurevisor\Featurevisor; -use Featurevisor\Logger; use PHPUnit\Framework\TestCase; -use Psr\Log\LogLevel; class FeaturevisorTest extends TestCase { @@ -34,7 +32,7 @@ public function testShouldReportLifecycleMutationDiagnostics() { $diagnostics = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::DEBUG, + 'logLevel' => 'debug', 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -59,7 +57,7 @@ public function testShouldCreateInstanceWithLogLevel() { $diagnostics = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::DEBUG, + 'logLevel' => 'debug', 'onDiagnostic' => function (array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -80,7 +78,7 @@ public function testShouldSetLogLevelAfterInitialization() { $diagnostics = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'onDiagnostic' => function (array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -95,7 +93,7 @@ public function testShouldSetLogLevelAfterInitialization() $sdk->setContext(['userId' => '123']); self::assertNotContains('context_set', array_column($diagnostics, 'code')); - $sdk->setLogLevel(LogLevel::DEBUG); + $sdk->setLogLevel('debug'); $sdk->setContext(['country' => 'nl']); self::assertContains('context_set', array_column($diagnostics, 'code')); } @@ -1509,7 +1507,7 @@ public function testShouldSetDatafileByMergingByDefaultAndReplacingWhenRequested { $events = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'datafile' => [ 'schemaVersion' => '2', 'revision' => 'base', @@ -1587,7 +1585,7 @@ public function testShouldManageModulesAndDuplicateDiagnostics() $closeCalls = 0; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1637,7 +1635,7 @@ public function testShouldReportModuleCloseErrorsAndContinueCleanup() $closed = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1681,7 +1679,7 @@ public function testShouldReportModuleCloseErrorsFromUnsubscribeOnce() $diagnostics = []; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'onDiagnostic' => function(array $diagnostic) use (&$diagnostics) { $diagnostics[] = $diagnostic; }, @@ -1709,7 +1707,7 @@ public function testShouldSupportModuleDiagnosticsSubscriptions() $reporter = null; $sdk = Featurevisor::createFeaturevisor([ - 'logLevel' => LogLevel::ERROR, + 'logLevel' => 'error', 'modules' => [ [ 'name' => 'listener', @@ -1747,4 +1745,33 @@ public function testShouldSupportModuleDiagnosticsSubscriptions() self::assertCount(1, $received); } + + public function testModuleDiagnosticLevelIsIndependentFromInstanceLevel(): void + { + $received = []; + $sdk = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'observer', + 'setup' => static function (array $api) use (&$received): void { + $api['onDiagnostic']( + static function (array $diagnostic) use (&$received): void { + $received[] = $diagnostic; + }, + ['logLevel' => 'debug'] + ); + }, + ]], + ]); + + $sdk->isEnabled('missing'); + + $diagnostics = array_values(array_filter( + $received, + static fn(array $diagnostic): bool => $diagnostic['code'] === 'feature_not_found' + )); + self::assertCount(1, $diagnostics); + self::assertSame('missing', $diagnostics[0]['details']['featureKey']); + self::assertSame('feature_not_found', $diagnostics[0]['details']['reason']); + } } diff --git a/tests/InstanceDatafileTest.php b/tests/InstanceDatafileTest.php new file mode 100644 index 0000000..12f8574 --- /dev/null +++ b/tests/InstanceDatafileTest.php @@ -0,0 +1,188 @@ + */ + private function fixture(): array + { + return json_decode(file_get_contents(__DIR__.'/../conformance/sdk-v3.json'), true, 512, JSON_THROW_ON_ERROR); + } + + /** @return array */ + private function datafile(array $segments = [], array $features = []): array + { + return [ + 'schemaVersion' => '2', + 'revision' => '1', + 'segments' => $segments, + 'features' => $features, + ]; + } + + public function testSharedV3ConformanceFixture(): void + { + $fixture = $this->fixture(); + self::assertSame(2, $fixture['version']); + + $bucketValue = 0; + $featurevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'fixed-bucket', + 'bucketValue' => static function () use (&$bucketValue): int { + return $bucketValue; + }, + ]], + 'datafile' => $this->datafile([], [ + 'test' => [ + 'key' => 'test', + 'bucketBy' => 'userId', + 'variations' => [['value' => 'control'], ['value' => 'treatment']], + 'traffic' => [[ + 'key' => 'everyone', + 'segments' => '*', + 'percentage' => 100000, + 'allocation' => $fixture['bucketing']['allocations'], + ]], + ], + ]), + ]); + + foreach ($fixture['bucketing']['allocationExpectations'] as $bucket => $expected) { + $bucketValue = (int) $bucket; + self::assertSame($expected, $featurevisor->getVariation('test', ['userId' => 'user'])); + } + + $condition = [ + 'attribute' => 'browser', + 'operator' => 'matches', + 'value' => $fixture['regularExpressions']['pattern'], + 'regexFlags' => $fixture['regularExpressions']['flags'], + ]; + $regex = static fn(string $pattern, string $flags): string => '~'.$pattern.'~'.str_replace(['g', 'y'], '', $flags); + foreach ($fixture['regularExpressions']['values'] as $index => $value) { + self::assertSame( + $fixture['regularExpressions']['matches'][$index], + Conditions::conditionIsMatched($condition, ['browser' => $value], $regex) + ); + } + foreach ($fixture['regularExpressions']['portableCases'] as $testCase) { + $portableCondition = [ + 'attribute' => 'value', + 'operator' => 'matches', + 'value' => $testCase['pattern'], + 'regexFlags' => $testCase['flags'], + ]; + self::assertSame( + $testCase['expected'], + Conditions::conditionIsMatched( + $portableCondition, + ['value' => $testCase['value']], + $regex + ), + sprintf('pattern %s, flags %s', $testCase['pattern'], $testCase['flags']) + ); + } + foreach ($fixture['conditionCases'] as $testCase) { + self::assertSame( + $testCase['expected'], + Conditions::allConditionsAreMatched($testCase['condition'], $testCase['context'], $regex), + $testCase['name'] + ); + } + + $aggregateCase = $fixture['defaults']['aggregateCase']; + $aggregateFeaturevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => $aggregateCase['datafile'], + ]); + $evaluated = $aggregateFeaturevisor->getAllEvaluations( + [], + [], + ['defaultVariationValue' => $aggregateCase['defaultVariationValue']] + )['experiment']; + self::assertSame($aggregateCase['expected']['enabled'], $evaluated['enabled']); + self::assertSame($aggregateCase['expected']['variation'], $evaluated['variation']); + + foreach ($fixture['typedVariables'] as $typedVariable) { + $actual = Helpers::getValueByType($typedVariable['value'], $typedVariable['type']); + self::assertSame($typedVariable['valid'], $actual !== null); + } + + foreach ($fixture['numericBucketKeys'] as $testCase) { + self::assertSame( + $testCase['expected'].'.feature', + Bucketer::getBucketKey([ + 'featureKey' => 'feature', + 'bucketBy' => 'value', + 'context' => ['value' => $testCase['value']], + ]) + ); + } + + $diagnostics = []; + $schemaFeaturevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'debug', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + 'datafile' => array_merge($this->datafile(), ['schemaVersion' => 'informational']), + ]); + self::assertSame('informational', $schemaFeaturevisor->getSchemaVersion()); + foreach ($diagnostics as $diagnostic) { + foreach ($fixture['diagnostics']['requiredFields'] as $field) { + self::assertArrayHasKey($field, $diagnostic); + } + } + $initialized = array_values(array_filter($diagnostics, static fn(array $diagnostic): bool => $diagnostic['code'] === 'sdk_initialized'))[0]; + self::assertSame('{}', json_encode($initialized['details'])); + } + + public function testDatafileAccessLivesOnFeaturevisorInstance(): void + { + $datafile = $this->datafile([ + 'germany' => [ + 'key' => 'germany', + 'conditions' => json_encode([['attribute' => 'country', 'operator' => 'equals', 'value' => 'de']]), + ], + ], [ + 'test' => [ + 'key' => 'test', + 'bucketBy' => 'userId', + 'traffic' => [], + ], + ]); + $featurevisor = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $datafile]); + + self::assertSame('1', $featurevisor->getRevision()); + self::assertSame('2', $featurevisor->getSchemaVersion()); + self::assertSame('de', $featurevisor->getSegment('germany')['conditions'][0]['value']); + self::assertNull($featurevisor->getSegment('belgium')); + self::assertSame($datafile['features']['test'], $featurevisor->getFeature('test')); + self::assertNull($featurevisor->getFeature('missing')); + self::assertFalse(class_exists('Featurevisor\\Internal\\DatafileReader')); + } + + public function testSegmentExpressionsMatchJavaScriptSemantics(): void + { + $segments = [ + 'mobile' => ['conditions' => [['attribute' => 'device', 'operator' => 'equals', 'value' => 'mobile']]], + 'dutch' => ['conditions' => [['attribute' => 'country', 'operator' => 'equals', 'value' => 'nl']]], + ]; + $getSegment = static fn(string $key): ?array => $segments[$key] ?? null; + $getRegex = static fn(string $pattern, string $flags): string => '~'.$pattern.'~'.str_replace(['g', 'y'], '', $flags); + + self::assertFalse(Conditions::allSegmentsAreMatched(['not' => ['mobile', 'dutch']], ['device' => 'mobile', 'country' => 'nl'], $getSegment, $getRegex)); + self::assertTrue(Conditions::allSegmentsAreMatched(['not' => ['mobile', 'dutch']], ['device' => 'desktop', 'country' => 'nl'], $getSegment, $getRegex)); + self::assertFalse(Conditions::allSegmentsAreMatched(['not' => []], [], $getSegment, $getRegex)); + self::assertFalse(Conditions::allSegmentsAreMatched('missing', [], $getSegment, $getRegex)); + } +} diff --git a/tests/JavaScriptAlignmentTest.php b/tests/JavaScriptAlignmentTest.php new file mode 100644 index 0000000..73c06f7 --- /dev/null +++ b/tests/JavaScriptAlignmentTest.php @@ -0,0 +1,188 @@ + */ + private function datafile(array $features = [], array $segments = [], string $revision = '1'): array + { + return [ + 'schemaVersion' => '2', + 'revision' => $revision, + 'segments' => $segments, + 'features' => $features, + ]; + } + + /** @return array */ + private function feature(array $overrides = []): array + { + return array_merge([ + 'key' => 'test', + 'bucketBy' => 'userId', + 'variations' => [['value' => 'control']], + 'variablesSchema' => [], + 'traffic' => [[ + 'key' => 'everyone', + 'segments' => '*', + 'percentage' => 100000, + 'allocation' => [['variation' => 'control', 'range' => [0, 100000]]], + ]], + ], $overrides); + } + + public function testDefaultValuesOnlyFillMissingEvaluationValues(): void + { + $featurevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => $this->datafile(['test' => $this->feature()]), + ]); + + $variation = $featurevisor->evaluateVariation('test', ['userId' => '1'], ['defaultVariationValue' => 'fallback']); + self::assertSame('control', $variation['variation']['value']); + self::assertArrayNotHasKey('variationValue', $variation); + + $missing = $featurevisor->evaluateVariation('missing', [], ['defaultVariationValue' => null]); + self::assertArrayHasKey('variationValue', $missing); + self::assertNull($missing['variationValue']); + } + + public function testNullValuesArePreservedAcrossStickyForceAndDisabledPaths(): void + { + $feature = $this->feature([ + 'force' => [['conditions' => '*', 'variables' => ['forced' => null], 'enabled' => true]], + 'variablesSchema' => [ + 'forced' => ['type' => 'json', 'defaultValue' => ['fallback']], + ], + ]); + $featurevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => $this->datafile(['test' => $feature]), + 'sticky' => ['test' => ['variables' => ['forced' => null]]], + ]); + + $sticky = $featurevisor->evaluateVariable('test', 'forced'); + self::assertSame('sticky', $sticky['reason']); + self::assertArrayHasKey('variableValue', $sticky); + self::assertNull($sticky['variableValue']); + + $withoutSticky = Featurevisor::createFeaturevisor(['logLevel' => 'fatal', 'datafile' => $this->datafile(['test' => $feature])]); + $forced = $withoutSticky->evaluateVariable('test', 'forced'); + self::assertSame('forced', $forced['reason']); + self::assertArrayHasKey('variableValue', $forced); + self::assertNull($forced['variableValue']); + + $disabledFeature = $this->feature([ + 'force' => [['conditions' => '*', 'enabled' => false]], + 'variablesSchema' => [ + 'disabled' => ['type' => 'json', 'defaultValue' => ['fallback'], 'disabledValue' => null], + ], + ]); + $disabledFeaturevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'fatal', + 'datafile' => $this->datafile(['test' => $disabledFeature]), + ]); + $disabled = $disabledFeaturevisor->evaluateVariable('test', 'disabled'); + self::assertSame('variable_disabled', $disabled['reason']); + self::assertArrayHasKey('variableValue', $disabled); + self::assertNull($disabled['variableValue']); + } + + public function testConditionTypingAndMissingValuesMatchJavaScript(): void + { + $regex = static fn(string $pattern, string $flags): string => '~'.$pattern.'~'.str_replace(['g', 'y'], '', $flags); + + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'age', 'operator' => 'greaterThan', 'value' => 1], ['age' => '2'], $regex)); + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'value', 'operator' => 'in', 'value' => [1]], ['value' => '1'], $regex)); + self::assertFalse(Conditions::conditionIsMatched(['attribute' => 'missing', 'operator' => 'equals', 'value' => null], [], $regex)); + self::assertTrue(Conditions::conditionIsMatched(['attribute' => 'present', 'operator' => 'equals', 'value' => null], ['present' => null], $regex)); + self::assertTrue(Conditions::conditionIsMatched(['attribute' => 'values', 'operator' => 'includes', 'value' => false], ['values' => [false, null]], $regex)); + self::assertTrue(Conditions::conditionIsMatched(['attribute' => 'name', 'operator' => 'endsWith', 'value' => ''], ['name' => 'Featurevisor'], $regex)); + } + + public function testMalformedExpressionsReportDiagnosticsAndFailSafely(): void + { + $diagnostics = []; + $feature = $this->feature([ + 'force' => [['conditions' => '{bad', 'enabled' => true]], + 'traffic' => [[ + 'key' => 'bad', + 'segments' => '{bad', + 'percentage' => 100000, + ]], + ]); + $featurevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'debug', + 'onDiagnostic' => static function (array $diagnostic) use (&$diagnostics): void { + $diagnostics[] = $diagnostic; + }, + 'datafile' => $this->datafile(['test' => $feature]), + ]); + + $evaluation = $featurevisor->evaluateFlag('test', ['userId' => '1']); + self::assertSame('error', $evaluation['reason']); + self::assertInstanceOf(\Throwable::class, $evaluation['error']); + self::assertContains('conditions_parse_error', array_column($diagnostics, 'code')); + self::assertContains('evaluation_error', array_column($diagnostics, 'code')); + } + + public function testTypedArrayAndObjectGettersDoNotOverlap(): void + { + self::assertSame(['one'], Helpers::getValueByType(['one'], 'array')); + self::assertNull(Helpers::getValueByType(['one'], 'object')); + self::assertSame(['key' => 'value'], Helpers::getValueByType(['key' => 'value'], 'object')); + self::assertNull(Helpers::getValueByType(['key' => 'value'], 'array')); + } + + public function testGetAllEvaluationsDoesNotCacheNestedFlagEvaluations(): void + { + $beforeCalls = 0; + $evaluationDiagnostics = 0; + $feature = $this->feature([ + 'variablesSchema' => [ + 'one' => ['type' => 'string', 'defaultValue' => 'one'], + 'two' => ['type' => 'string', 'defaultValue' => 'two'], + ], + ]); + $featurevisor = Featurevisor::createFeaturevisor([ + 'logLevel' => 'debug', + 'onDiagnostic' => static function (array $diagnostic) use (&$evaluationDiagnostics): void { + if (is_array($diagnostic['details']) && isset($diagnostic['details']['evaluation'])) { + $evaluationDiagnostics++; + } + }, + 'modules' => [[ + 'name' => 'counter', + 'before' => static function (array $options) use (&$beforeCalls): array { + $beforeCalls++; + return $options; + }, + ]], + 'datafile' => $this->datafile(['test' => $feature]), + ]); + + $featurevisor->getAllEvaluations(['userId' => '1']); + self::assertSame(4, $beforeCalls); + self::assertSame(7, $evaluationDiagnostics); + } + + public function testClosedInstanceDoesNotAcceptNewEventListeners(): void + { + $called = false; + $featurevisor = Featurevisor::createFeaturevisor(['logLevel' => 'fatal']); + $featurevisor->close(); + $unsubscribe = $featurevisor->on('context_set', static function () use (&$called): void { + $called = true; + }); + $featurevisor->setContext(['country' => 'nl']); + $unsubscribe(); + + self::assertFalse($called); + } +} diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php deleted file mode 100644 index 7379b5b..0000000 --- a/tests/LoggerTest.php +++ /dev/null @@ -1,234 +0,0 @@ - [LogLevel::DEBUG]; - yield LogLevel::INFO => [LogLevel::INFO]; - yield LogLevel::WARNING => [LogLevel::WARNING]; - yield LogLevel::ERROR => [LogLevel::ERROR]; - } - - protected function setUp(): void - { - parent::setUp(); - $this->logBuffer = ''; - } - - public function testCreateLoggerWithDefaultOptions(): void - { - $logger = Logger::create(); - self::assertInstanceOf(Logger::class, $logger); - } - - public function testCreateLoggerWithCustomLevel(): void - { - $logger = Logger::create(['level' => 'debug']); - self::assertInstanceOf(Logger::class, $logger); - } - - public function testCreateLoggerWithCustomHandler(): void - { - $customHandlerCalled = false; - $customHandler = function($level, $message, $details) use (&$customHandlerCalled) { - $customHandlerCalled = true; - self::assertEquals('info', $level); - self::assertEquals('[Featurevisor] test message', $message); - self::assertSame([], $details); - }; - - $logger = Logger::create(['handler' => $customHandler]); - $logger->info('test message'); - - self::assertTrue($customHandlerCalled); - } - - public function testLoggerConstructorUsesDefaultLogLevelWhenNoneProvided(): void - { - $logger = Logger::create(); - - // Capture output to verify debug is not logged with default level (info) - $logger->debug('debug message'); - - // Debug should not be logged with default level (info) - self::assertEmpty($this->logBuffer); - } - - public function testLoggerConstructorUsesProvidedLogLevel(): void - { - $logger = $this->getLogger(LogLevel::DEBUG); - - $logger->debug('debug message'); - - self::assertEquals('[Featurevisor] debug message' . PHP_EOL, $this->logBuffer); - } - - public function testLoggerConstructorUsesDefaultHandlerWhenNoneProvided(): void - { - $logger = $this->getLogger(); - - $logger->info('test message'); - - self::assertEquals('[Featurevisor] test message' . PHP_EOL, $this->logBuffer); - } - - public function testLoggerConstructorUsesProvidedHandler(): void - { - $customHandlerCalled = false; - $customHandler = function($level, $message, $details) use (&$customHandlerCalled) { - $customHandlerCalled = true; - self::assertEquals('info', $level); - self::assertEquals('[Featurevisor] test message', $message); - self::assertSame([], $details); - }; - - $logger = Logger::create(['handler' => $customHandler]); - $logger->info('test message'); - - self::assertTrue($customHandlerCalled); - } - - public function testSetLevelUpdatesTheLogLevel(): void - { - $logger = $this->getLogger(LogLevel::INFO); - - // Debug should not be logged initially - $logger->debug('first debug message'); - - // Set to debug level - $logger->setLevel(LogLevel::DEBUG); - $logger->debug('second debug message'); - - self::assertEquals( - '[Featurevisor] second debug message' . PHP_EOL, - $this->logBuffer - ); - } - - /** - * @dataProvider levelsLoggingTestDataProvider - */ - public function testLogErrorMessagesAtAllLevels(string $level): void - { - $logger = $this->getLogger($level); - - $logger->error('error message'); - - self::assertEquals( - '[Featurevisor] error message' . PHP_EOL, - $this->logBuffer - ); - } - - public function testLogWarnMessagesAtWarnLevelAndAbove(): void - { - $logger = $this->getLogger(LogLevel::WARNING); - - $logger->warning('warn message'); - $logger->error('error message'); - - self::assertEquals( - '[Featurevisor] warn message' . PHP_EOL . - '[Featurevisor] error message' . PHP_EOL, - $this->logBuffer - ); - } - - public function testNotLogInfoMessagesAtWarnLevel(): void - { - $logger = $this->getLogger(LogLevel::WARNING); - - $logger->info('info message'); - - self::assertEmpty($this->logBuffer); - } - - public function testNotLogDebugMessagesAtInfoLevel(): void - { - $logger = $this->getLogger(LogLevel::INFO); - - $logger->debug('debug message'); - - self::assertEmpty($this->logBuffer); - } - - public function testLogAllMessagesAtDebugLevel(): void - { - $logger = $this->getLogger(LogLevel::DEBUG); - - $logger->debug('debug message'); - $logger->info('info message'); - $logger->warning('warn message'); - $logger->error('error message'); - - self::assertEquals( - '[Featurevisor] debug message' . PHP_EOL . - '[Featurevisor] info message' . PHP_EOL . - '[Featurevisor] warn message' . PHP_EOL . - '[Featurevisor] error message' . PHP_EOL, - $this->logBuffer - ); - } - - public function testHandleDetailsParameter(): void - { - $logger = $this->getLogger(LogLevel::DEBUG); - $details = ['key' => 'value', 'number' => 42]; - - $logger->info('message with details', $details); - - self::assertEquals( - '[Featurevisor] message with details {"key":"value","number":42}' . PHP_EOL, - $this->logBuffer - ); - } - - public function testLogMethodCallsHandlerWithCorrectParameters(): void - { - $customHandlerCalled = false; - $customHandler = function($level, $message, $details) use (&$customHandlerCalled) { - $customHandlerCalled = true; - self::assertEquals('info', $level); - self::assertEquals('[Featurevisor] test message', $message); - self::assertEquals(['test' => true], $details); - }; - - $logger = Logger::create(['handler' => $customHandler, 'level' => 'debug']); - $details = ['test' => true]; - - $logger->log('info', 'test message', $details); - - self::assertTrue($customHandlerCalled); - } - - public function testLogMethodNotCallHandlerWhenLevelIsFilteredOut(): void - { - $customHandlerCalled = false; - $customHandler = function($level, $message, $details) use (&$customHandlerCalled) { - $customHandlerCalled = true; - }; - - $logger = Logger::create(['handler' => $customHandler, 'level' => LogLevel::WARNING]); - - $logger->log('debug', 'debug message'); - self::assertFalse($customHandlerCalled); - } - - private function getLogger(string $level = Logger::DEFAULT_LEVEL): Logger - { - return Logger::create(['level' => $level, 'handler' => function ($level, $message, array $context) { - $context = $context !== [] ? ' ' . json_encode($context, JSON_THROW_ON_ERROR) : ''; - $this->logBuffer .= $message . $context . PHP_EOL; - }]); - } -} diff --git a/tests/OpenFeatureProviderTest.php b/tests/OpenFeatureProviderTest.php new file mode 100644 index 0000000..f8f094f --- /dev/null +++ b/tests/OpenFeatureProviderTest.php @@ -0,0 +1,442 @@ + */ + private function datafile(): array + { + return [ + 'schemaVersion' => '2', + 'revision' => 'revision-1', + 'featurevisorVersion' => '3.0.1', + 'segments' => [], + 'features' => [ + 'checkout' => [ + 'bucketBy' => 'userId', + 'variations' => [[ + 'value' => 'on', + 'variables' => [ + 'title' => 'Hello', + 'count' => 3, + 'ratio' => 1.5, + 'visible' => true, + 'items' => ['a', 'b'], + 'config' => ['color' => 'blue'], + 'json' => '{"nested":true}', + 'invalidJson' => 'not-json', + ], + ]], + 'variablesSchema' => [ + 'title' => ['type' => 'string', 'defaultValue' => 'Default'], + 'count' => ['type' => 'integer', 'defaultValue' => 0], + 'ratio' => ['type' => 'double', 'defaultValue' => 0.0], + 'visible' => ['type' => 'boolean', 'defaultValue' => false], + 'items' => ['type' => 'array', 'defaultValue' => []], + 'config' => ['type' => 'object', 'defaultValue' => []], + 'json' => ['type' => 'json', 'defaultValue' => '{}'], + 'invalidJson' => ['type' => 'json', 'defaultValue' => '{}'], + ], + 'force' => [[ + 'conditions' => ['attribute' => 'userId', 'operator' => 'equals', 'value' => 'forced-user'], + 'enabled' => true, + 'variation' => 'on', + ]], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'variation' => 'on']], + ], + 'disabled' => [ + 'bucketBy' => 'userId', + 'disabledVariationValue' => 'off', + 'variations' => [['value' => 'on']], + 'force' => [[ + 'conditions' => ['attribute' => 'blocked', 'operator' => 'equals', 'value' => true], + 'enabled' => false, + ]], + 'traffic' => [['key' => 'all', 'segments' => '*', 'percentage' => 100000, 'variation' => 'on']], + ], + 'emptyVariation' => [ + 'bucketBy' => 'userId', + 'variations' => [], + ], + ], + ]; + } + + /** @param array $options */ + private function provider(array $options = []): OpenFeatureProvider + { + return new OpenFeatureProvider(array_merge([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + ], $options)); + } + + /** + * @param array $evaluation + * @return array{0: OpenFeatureProvider, 1: Featurevisor} + */ + private function providerReturning(array $evaluation): array + { + $featurevisor = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'evaluation-result', + 'after' => static function () use ($evaluation): array { + return $evaluation; + }, + ]], + ]); + + return [new OpenFeatureProvider(featurevisor: $featurevisor), $featurevisor]; + } + + public function testResolvesFlagsVariationsAndEveryOpenFeatureType(): void + { + $provider = $this->provider(); + $context = new EvaluationContext('forced-user', new Attributes()); + + $flag = $provider->resolveBooleanValue('checkout', false, $context); + self::assertTrue($flag->getValue()); + self::assertSame(Reason::TARGETING_MATCH, $flag->getReason()); + + $variation = $provider->resolveStringValue('checkout:variation', 'fallback', $context); + self::assertSame('on', $variation->getValue()); + self::assertSame('on', $variation->getVariant()); + self::assertSame(Reason::TARGETING_MATCH, $variation->getReason()); + + self::assertSame('Hello', $provider->resolveStringValue('checkout:title', 'fallback', $context)->getValue()); + self::assertSame(3, $provider->resolveIntegerValue('checkout:count', 0, $context)->getValue()); + self::assertSame(3.0, $provider->resolveFloatValue('checkout:count', 0.0, $context)->getValue()); + self::assertSame(1.5, $provider->resolveFloatValue('checkout:ratio', 0.0, $context)->getValue()); + self::assertTrue($provider->resolveBooleanValue('checkout:visible', false, $context)->getValue()); + self::assertSame(['a', 'b'], $provider->resolveObjectValue('checkout:items', [], $context)->getValue()); + self::assertSame(['color' => 'blue'], $provider->resolveObjectValue('checkout:config', [], $context)->getValue()); + self::assertSame(['nested' => true], $provider->resolveObjectValue('checkout:json', [], $context)->getValue()); + self::assertSame('Featurevisor', $provider->getMetadata()->getName()); + } + + public function testMapsTargetingKeyDatesArraysAndNestedContextWithoutMutation(): void + { + $contexts = []; + $createdAt = new DateTime('2026-01-02T04:04:05.123+01:00'); + $nestedDate = new DateTimeImmutable('2026-01-01T00:00:00.456Z'); + $attributes = [ + 'createdAt' => $createdAt, + 'nested' => ['dates' => [$nestedDate]], + ]; + $provider = new OpenFeatureProvider( + ['datafile' => $this->datafile(), 'logLevel' => 'fatal', 'modules' => [[ + 'name' => 'capture-context', + 'before' => static function (array $options) use (&$contexts): array { + $contexts[] = $options['context']; + return $options; + }, + ]]], + null, + 'accountId' + ); + + $provider->resolveBooleanValue( + 'checkout', + false, + new EvaluationContext('subject', new Attributes($attributes)) + ); + + self::assertSame([ + 'createdAt' => '2026-01-02T03:04:05.123Z', + 'nested' => ['dates' => ['2026-01-01T00:00:00.456Z']], + 'accountId' => 'subject', + ], $contexts[0]); + self::assertSame('2026-01-02T04:04:05.123+01:00', $createdAt->format('Y-m-d\TH:i:s.vP')); + self::assertSame($createdAt, $attributes['createdAt']); + self::assertSame($nestedDate, $attributes['nested']['dates'][0]); + } + + public function testSupportsCustomKeySeparatorAndVariationSelector(): void + { + $provider = new OpenFeatureProvider( + ['datafile' => $this->datafile(), 'logLevel' => 'fatal'], + null, + 'userId', + '/', + '$variation' + ); + + self::assertSame('on', $provider->resolveStringValue('checkout/$variation', 'fallback')->getValue()); + self::assertSame('Hello', $provider->resolveStringValue('checkout/title', 'fallback')->getValue()); + } + + public function testReturnsDefaultsAndStandardErrorsForMissingEntitiesAndMalformedDatafiles(): void + { + $provider = $this->provider(); + + $missingFeature = $provider->resolveBooleanValue('missing', true); + self::assertTrue($missingFeature->getValue()); + self::assertSame(Reason::ERROR, $missingFeature->getReason()); + self::assertEquals(ErrorCode::FLAG_NOT_FOUND(), $missingFeature->getError()->getResolutionErrorCode()); + self::assertSame('Feature "missing" was not found', $missingFeature->getError()->getResolutionErrorMessage()); + + $missingVariable = $provider->resolveStringValue('checkout:missing', 'fallback'); + self::assertSame('fallback', $missingVariable->getValue()); + self::assertEquals(ErrorCode::FLAG_NOT_FOUND(), $missingVariable->getError()->getResolutionErrorCode()); + + $noVariations = $provider->resolveStringValue('emptyVariation:variation', 'fallback'); + self::assertSame('fallback', $noVariations->getValue()); + self::assertEquals(ErrorCode::FLAG_NOT_FOUND(), $noVariations->getError()->getResolutionErrorCode()); + + $malformed = new OpenFeatureProvider(['datafile' => '{', 'logLevel' => 'fatal']); + $result = $malformed->resolveBooleanValue('checkout', false); + self::assertFalse($result->getValue()); + self::assertSame(Reason::ERROR, $result->getReason()); + self::assertEquals(ErrorCode::PARSE_ERROR(), $result->getError()->getResolutionErrorCode()); + self::assertSame('Could not parse datafile', $result->getError()->getResolutionErrorMessage()); + } + + public function testRecoversAfterMalformedDatafileIsReplaced(): void + { + $provider = new OpenFeatureProvider(['datafile' => '{', 'logLevel' => 'fatal']); + self::assertEquals( + ErrorCode::PARSE_ERROR(), + $provider->resolveBooleanValue('checkout', false)->getError()->getResolutionErrorCode() + ); + + $provider->getFeaturevisor()->setDatafile($this->datafile(), true); + $result = $provider->resolveBooleanValue( + 'checkout', + false, + new EvaluationContext('forced-user', new Attributes()) + ); + self::assertTrue($result->getValue()); + self::assertNull($result->getError()); + } + + public function testRejectsMismatchedValuesAndInvalidJson(): void + { + $provider = $this->provider(); + $results = [ + $provider->resolveStringValue('checkout', 'fallback'), + $provider->resolveBooleanValue('checkout:title', false), + $provider->resolveObjectValue('checkout:invalidJson', []), + $provider->resolveIntegerValue('checkout:ratio', 0), + ]; + + foreach ($results as $result) { + self::assertSame(Reason::ERROR, $result->getReason()); + self::assertEquals(ErrorCode::TYPE_MISMATCH(), $result->getError()->getResolutionErrorCode()); + } + } + + /** @dataProvider invalidNumericValues */ + public function testRejectsInvalidNumericValues($value, string $resolver): void + { + [$provider, $featurevisor] = $this->providerReturning([ + 'type' => 'variable', + 'featureKey' => 'checkout', + 'variableKey' => 'ratio', + 'reason' => 'allocated', + 'variableValue' => $value, + 'variableSchema' => ['type' => 'double'], + ]); + + $result = $provider->{$resolver}('checkout:ratio', $resolver === 'resolveIntegerValue' ? 0 : 0.0); + self::assertSame(Reason::ERROR, $result->getReason()); + self::assertEquals(ErrorCode::TYPE_MISMATCH(), $result->getError()->getResolutionErrorCode()); + + $provider->shutdown(); + $featurevisor->close(); + } + + /** @return array */ + public function invalidNumericValues(): array + { + return [ + 'NaN as float' => [NAN, 'resolveFloatValue'], + 'positive infinity as float' => [INF, 'resolveFloatValue'], + 'negative infinity as float' => [-INF, 'resolveFloatValue'], + 'boolean as integer' => [true, 'resolveIntegerValue'], + 'boolean as float' => [true, 'resolveFloatValue'], + 'whole float as integer' => [1.0, 'resolveIntegerValue'], + ]; + } + + public function testMapsDisabledEvaluations(): void + { + $provider = $this->provider(); + $context = new EvaluationContext(null, new Attributes(['blocked' => true])); + + $flag = $provider->resolveBooleanValue('disabled', true, $context); + self::assertFalse($flag->getValue()); + self::assertSame(Reason::TARGETING_MATCH, $flag->getReason()); + + $variation = $provider->resolveStringValue('disabled:variation', 'fallback', $context); + self::assertSame('off', $variation->getValue()); + self::assertSame(Reason::DISABLED, $variation->getReason()); + } + + /** @dataProvider reasonMappings */ + public function testMapsEveryFeaturevisorReason(string $featurevisorReason, string $expectedReason): void + { + [$provider, $featurevisor] = $this->providerReturning([ + 'type' => 'flag', + 'featureKey' => 'checkout', + 'reason' => $featurevisorReason, + 'enabled' => true, + ]); + + $result = $provider->resolveBooleanValue('checkout', false); + self::assertSame($expectedReason, $result->getReason()); + self::assertNull($result->getError()); + + $provider->shutdown(); + $featurevisor->close(); + } + + /** @return array */ + public function reasonMappings(): array + { + return [ + 'required' => ['required', Reason::TARGETING_MATCH], + 'forced' => ['forced', Reason::TARGETING_MATCH], + 'sticky' => ['sticky', Reason::TARGETING_MATCH], + 'rule' => ['rule', Reason::TARGETING_MATCH], + 'variation override' => ['variable_override_variation', Reason::TARGETING_MATCH], + 'rule override' => ['variable_override_rule', Reason::TARGETING_MATCH], + 'allocated' => ['allocated', Reason::SPLIT], + 'disabled' => ['disabled', Reason::DISABLED], + 'variation disabled' => ['variation_disabled', Reason::DISABLED], + 'variable disabled' => ['variable_disabled', Reason::DISABLED], + 'out of range' => ['out_of_range', Reason::DEFAULT], + 'no match' => ['no_match', Reason::DEFAULT], + 'variable default' => ['variable_default', Reason::DEFAULT], + ]; + } + + /** @dataProvider generalErrors */ + public function testMapsGeneralEvaluationErrors($error, string $expectedMessage): void + { + [$provider, $featurevisor] = $this->providerReturning([ + 'type' => 'flag', + 'featureKey' => 'checkout', + 'reason' => 'error', + 'error' => $error, + ]); + + $result = $provider->resolveBooleanValue('checkout', false); + self::assertFalse($result->getValue()); + self::assertSame(Reason::ERROR, $result->getReason()); + self::assertEquals(ErrorCode::GENERAL(), $result->getError()->getResolutionErrorCode()); + self::assertSame($expectedMessage, $result->getError()->getResolutionErrorMessage()); + + $provider->shutdown(); + $featurevisor->close(); + } + + /** @return array */ + public function generalErrors(): array + { + return [ + 'throwable' => [new RuntimeException('Evaluation failed'), 'Evaluation failed'], + 'message string' => ['Evaluation failed as text', 'Evaluation failed as text'], + ]; + } + + public function testForwardsTrackingArguments(): void + { + $tracked = []; + $provider = new OpenFeatureProvider( + ['datafile' => $this->datafile(), 'logLevel' => 'fatal'], + null, + 'userId', + ':', + 'variation', + static function (...$arguments) use (&$tracked): void { + $tracked[] = $arguments; + } + ); + $context = new EvaluationContext('user-1', new Attributes()); + $details = ['value' => 10, 'orderId' => '1']; + + $provider->track('purchase', $context, $details); + self::assertSame([['purchase', $context, $details]], $tracked); + } + + public function testClosesOwnedFeaturevisorExactlyOnce(): void + { + $closed = 0; + $provider = $this->provider([ + 'modules' => [[ + 'name' => 'lifecycle', + 'close' => static function () use (&$closed): void { + $closed++; + }, + ]], + ]); + + $provider->shutdown(); + $provider->shutdown(); + self::assertSame(1, $closed); + } + + public function testBorrowsExistingFeaturevisorAndExistingInstanceTakesPrecedence(): void + { + $closed = 0; + $featurevisor = Featurevisor::createFeaturevisor([ + 'datafile' => $this->datafile(), + 'logLevel' => 'fatal', + 'modules' => [[ + 'name' => 'owner', + 'close' => static function () use (&$closed): void { + $closed++; + }, + ]], + ]); + $provider = new OpenFeatureProvider(['datafile' => '{'], $featurevisor); + + self::assertSame($featurevisor, $provider->getFeaturevisor()); + self::assertTrue($provider->resolveBooleanValue( + 'checkout', + false, + new EvaluationContext('forced-user', new Attributes()) + )->getValue()); + + $provider->shutdown(); + $provider->shutdown(); + self::assertSame(0, $closed); + + $featurevisor->setDatafile(array_merge($this->datafile(), ['features' => []]), true); + self::assertSame('feature_not_found', $featurevisor->evaluateFlag('checkout')['reason']); + $featurevisor->close(); + self::assertSame(1, $closed); + } + + public function testWorksThroughOpenFeatureApiForAllNumericTypes(): void + { + $provider = $this->provider(); + $api = OpenFeatureAPI::getInstance(); + $api->setProvider($provider); + $client = $api->getClient(null, null); + $context = new EvaluationContext('forced-user', new Attributes()); + + self::assertTrue($client->getBooleanValue('checkout', false, $context)); + self::assertSame(3, $client->getIntegerValue('checkout:count', 0, $context)); + self::assertSame(3.0, $client->getFloatValue('checkout:count', 0.0, $context)); + self::assertSame(1.5, $client->getFloatValue('checkout:ratio', 0.0, $context)); + + $provider->shutdown(); + } +}