Repository: ethanhann/redisearch-php Branch: main Commit: 4533569846d5 Files: 126 Total size: 278.1 KB Directory structure: gitextract__zin6sq1/ ├── .claude/ │ └── skills/ │ ├── contribute/ │ │ └── SKILL.md │ └── unit-test/ │ └── SKILL.md ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ ├── ci.yml │ └── docs.yml ├── .gitignore ├── .php-cs-fixer.php ├── CLAUDE.md ├── LICENSE ├── README.md ├── bin/ │ └── redisearch ├── composer.json ├── docker-compose.yml ├── docs-site/ │ ├── .gitignore │ ├── astro.config.mjs │ ├── package.json │ ├── src/ │ │ ├── content/ │ │ │ └── docs/ │ │ │ ├── aggregating.mdx │ │ │ ├── changelog.mdx │ │ │ ├── cli.mdx │ │ │ ├── index.mdx │ │ │ ├── indexing.mdx │ │ │ ├── laravel-support.mdx │ │ │ ├── searching.mdx │ │ │ └── suggesting.mdx │ │ ├── content.config.ts │ │ └── styles/ │ │ └── custom.css │ └── tsconfig.json ├── justfile ├── phpunit.xml ├── src/ │ ├── AbstractIndex.php │ ├── AbstractRediSearchClientAdapter.php │ ├── Aggregate/ │ │ ├── AggregationResult.php │ │ ├── Builder.php │ │ ├── BuilderInterface.php │ │ ├── Operations/ │ │ │ ├── AbstractFieldNameOperation.php │ │ │ ├── Apply.php │ │ │ ├── Filter.php │ │ │ ├── GroupBy.php │ │ │ ├── Limit.php │ │ │ ├── Load.php │ │ │ └── SortBy.php │ │ └── Reducers/ │ │ ├── AbstractFieldNameReducer.php │ │ ├── Aliasable.php │ │ ├── Avg.php │ │ ├── Count.php │ │ ├── CountDistinct.php │ │ ├── CountDistinctApproximate.php │ │ ├── FirstValue.php │ │ ├── Max.php │ │ ├── Min.php │ │ ├── Quantile.php │ │ ├── StandardDeviation.php │ │ ├── Sum.php │ │ └── ToList.php │ ├── CanBecomeArrayInterface.php │ ├── Console/ │ │ ├── AbstractRedisCommand.php │ │ ├── Application.php │ │ ├── Command/ │ │ │ ├── AggregateCommand.php │ │ │ ├── DocumentAddCommand.php │ │ │ ├── DocumentDeleteCommand.php │ │ │ ├── DocumentGetCommand.php │ │ │ ├── ExplainCommand.php │ │ │ ├── IndexCreateCommand.php │ │ │ ├── IndexDropCommand.php │ │ │ ├── IndexInfoCommand.php │ │ │ ├── IndexListCommand.php │ │ │ ├── ProfileCommand.php │ │ │ ├── SearchCommand.php │ │ │ └── ShellCommand.php │ │ └── SchemaParser.php │ ├── Document/ │ │ ├── AbstractDocumentFactory.php │ │ ├── Document.php │ │ └── DocumentInterface.php │ ├── Exceptions/ │ │ ├── AliasDoesNotExistException.php │ │ ├── DocumentAlreadyInIndexException.php │ │ ├── FieldNotInSchemaException.php │ │ ├── NoFieldsInIndexException.php │ │ ├── OutOfRangeDocumentScoreException.php │ │ ├── RediSearchException.php │ │ ├── UnknownIndexNameException.php │ │ ├── UnknownIndexNameOrNameIsAnAliasItselfException.php │ │ ├── UnknownRediSearchCommandException.php │ │ └── UnsupportedRediSearchLanguageException.php │ ├── Fields/ │ │ ├── AbstractField.php │ │ ├── FieldFactory.php │ │ ├── FieldInterface.php │ │ ├── GeoField.php │ │ ├── GeoLocation.php │ │ ├── Noindex.php │ │ ├── NumericField.php │ │ ├── Sortable.php │ │ ├── Tag.php │ │ ├── TagField.php │ │ ├── TextField.php │ │ └── VectorField.php │ ├── Index.php │ ├── IndexInterface.php │ ├── Language.php │ ├── Query/ │ │ ├── Builder.php │ │ ├── BuilderInterface.php │ │ └── SearchResult.php │ ├── RediSearchRedisClient.php │ ├── RuntimeConfiguration.php │ └── Suggestion.php └── tests/ ├── RediSearch/ │ ├── Aggregate/ │ │ ├── AggregationResultTest.php │ │ └── BuilderTest.php │ ├── Document/ │ │ └── DocumentTest.php │ ├── Exceptions/ │ │ ├── FieldNotInSchemaExceptionTest.php │ │ ├── NoFieldsInIndexExceptionTest.php │ │ ├── RedisRawCommandExceptionTest.php │ │ └── UnknownIndexNameExceptionTest.php │ ├── Fields/ │ │ ├── FieldFactoryTest.php │ │ ├── GeoFieldTest.php │ │ ├── GeoLocationTest.php │ │ ├── NumericFieldTest.php │ │ └── TextFieldTest.php │ ├── IndexTest.php │ ├── Query/ │ │ └── BuilderTest.php │ ├── Redis/ │ │ └── RedisClientTest.php │ ├── RuntimeConfigurationTest.php │ └── SuggestionTest.php ├── RediSearchTestCase.php ├── Stubs/ │ ├── IndexWithoutFields.php │ ├── TestDocument.php │ └── TestIndex.php └── bootstrap.php ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/skills/contribute/SKILL.md ================================================ # Contribute Skill Guide for implementing a new feature or bug fix in redisearch-php. ## Workflow Make a todo list for all the tasks below and work through them in order. ### 1. Understand the Change Read the relevant existing code before touching anything: - For new field types: look at an existing field in `src/Fields/` - For new query features: look at `src/Query/` - For new aggregation reducers/operations: look at `src/Aggregate/Reducers/` or `src/Aggregate/Operations/` - For index-level changes: look at `src/Index.php` and `src/AbstractIndex.php` Identify the interface(s) the new code must implement or extend. ### 2. Implement the Change **Source code conventions:** - Namespace: `Ehann\RediSearch\` - Use native PHP 8.2+ type hints on all parameters and return types - Interfaces end in `Interface`; abstract classes start with `Abstract` - Follow the fluent builder pattern used throughout (methods return `$this` or a new builder) - Keep RediSearch command names as close to the official docs as possible **Code style** is enforced by php-cs-fixer. After writing code, fix style: ```bash vendor/bin/robo task:fix-code-style ``` ### 3. Write Tests Every change needs a corresponding test. See the `/unit-test` skill for details. Quick checklist: - Add a test file at `tests/RediSearch//Test.php` - Extend `Ehann\Tests\RediSearchTestCase` - Cover the happy path and any notable edge cases ### 4. Verify Everything Passes ```bash # Start Redis if not running docker compose up -d # Run the full test suite vendor/bin/robo test # Check code style vendor/bin/php-cs-fixer fix src --dry-run --diff # Lint all files find src tests -name "*.php" -print0 | xargs -0 php -l ``` All three must pass cleanly before the change is ready. ### 5. Optional: Test Against All Clients If the change touches the Redis command layer, verify it works with every supported client: ```bash vendor/bin/robo test:all ``` ### 6. Commit Write a clear commit message describing *what* changed and *why*. Reference any related issue numbers. ```bash git add git commit -m "Add : " ``` ## Common Patterns ### Adding a new Field type ```php namespace Ehann\RediSearch\Fields; class MyField extends AbstractField implements FieldInterface { public function getTypeString(): string { return 'MYTYPE'; } } ``` ### Adding a new Reducer ```php namespace Ehann\RediSearch\Aggregate\Reducers; class MyReducer extends AbstractReducer { public function __construct(string $property) { parent::__construct('MY_REDUCER', $property); } } ``` ### Adding a new Query Operation Follow the pattern in `src/Aggregate/Operations/` — implement `OperationInterface` and emit the correct RediSearch command fragment from `__toString()`. ================================================ FILE: .claude/skills/unit-test/SKILL.md ================================================ # Unit Test Skill Guide for writing, running, and debugging unit tests in redisearch-php. ## Test Infrastructure - **Framework**: PHPUnit 11 (`vendor/bin/phpunit`) - **Config**: `phpunit.xml` (sets Redis connection, default client library, coverage source) - **Base class**: `Ehann\Tests\RediSearchTestCase` in `tests/RediSearchTestCase.php` - **Redis**: must be running on `localhost:6381` (start with `just up`) ## Writing a Test ### File location and naming Mirror the source path under `tests/RediSearch/`: | Source file | Test file | |----------------------------------|---------------------------------------------------| | `src/Fields/NumericField.php` | `tests/RediSearch/Fields/NumericFieldTest.php` | | `src/Aggregate/Reducers/Avg.php` | `tests/RediSearch/Aggregate/Reducers/AvgTest.php` | ### AAA structure Every test method must follow Arrange-Act-Assert with explicit section comments: ```php field = new NumericField('price'); } public function testGetName(): void { // Arrange — see setUp() // Act $name = $this->field->getName(); // Assert $this->assertSame('price', $name); } public function testSetSortable(): void { // Arrange $expected = true; // Act $result = $this->field->setSortable($expected)->isSortable(); // Assert $this->assertSame($expected, $result); } } ``` **AAA rules:** - Always include all three section comments, even when one section is trivial. - If the full arrange is in `setUp()`, use `// Arrange — see setUp()` as a one-liner with no body. - For exception tests, place `expectException()` in the Assert section (before the act), because PHPUnit registers the expectation before execution: ```php public function testThrowsWhenIndexHasNoFields(): void { // Arrange $index = new IndexWithoutFields($this->redisClient, $this->indexName); // Assert $this->expectException(NoFieldsInIndexException::class); // Act $index->create(); } ``` ### Quality conventions - Use `assertSame` instead of `assertEquals` when type identity matters (e.g., comparing ints, floats, or booleans). - One logical assertion per test where practical; multiple assertions are acceptable when they together verify a single behaviour. - Mark all test methods `void`: `public function testFoo(): void`. - Use `@group ` PHPDoc to tag logical groups (e.g., `@group aggregate`, `@group query`). - Do not commit permanently-skipped tests — fix the underlying issue or remove the test. ### Using the index in tests `RediSearchTestCase` provides `$this->redisClient` and `$this->indexName`. Use the stubs for a pre-configured index: ```php use Ehann\Tests\Stubs\TestIndex; protected function setUp(): void { parent::setUp(); $index = new TestIndex($this->redisClient, $this->indexName); $index->create(); // add documents, run queries… } protected function tearDown(): void { // RediSearchTestCase::tearDown() calls flushAll() automatically parent::tearDown(); } ``` ### Testing against a specific Redis client The `REDIS_LIBRARY` env var selects the client. Skip a test when a client is not in use: ```php public function testSomethingPhpRedisOnly(): void { // Arrange if (!$this->isUsingPhpRedis()) { $this->markTestSkipped('PhpRedis only'); } // Act // … // Assert // … } ``` ## Running Tests ```bash # Start Redis just up # All tests (Predis, the default) vendor/bin/phpunit # or just test # Specific client just test-predis just test-php-redis just test-redis-client # All clients sequentially just test-all # Single test file vendor/bin/phpunit tests/RediSearch/Fields/NumericFieldTest.php # Single test method vendor/bin/phpunit --filter testGetName tests/RediSearch/Fields/NumericFieldTest.php # Group vendor/bin/phpunit --group aggregate # With coverage report (requires Xdebug or PCOV driver) vendor/bin/phpunit --coverage-text ``` ## Code Coverage PHPUnit 11 generates coverage from the `` block in `phpunit.xml`. To produce a report: ```bash # Terminal summary vendor/bin/phpunit --coverage-text # HTML report vendor/bin/phpunit --coverage-html coverage/ ``` Coverage requires a driver: install **Xdebug** (`php -m | grep xdebug`) or **PCOV** (`php -m | grep pcov`). If neither is present, PHPUnit will warn and skip coverage collection. ## Debugging Failures 1. **Connection refused**: Redis isn't running — `just up` 2. **Command not found (FT.*)**: Redis Stack module not loaded — ensure you're using the `redis/redis-stack` or `redis/redis-stack-server` image, not plain Redis 3. **Index already exists**: a previous test run didn't clean up — `tearDown` calls `flushAll`; if interrupted, run `redis-cli -p 6381 flushall` manually 4. **Style errors in test files**: run `vendor/bin/php-cs-fixer fix tests --dry-run --diff` to see what needs fixing ( php-cs-fixer only auto-fixes `src/` by default, but the check applies to `tests/` too) ## Test Conventions - Method names: `test{Feature}` (e.g., `testSortByDescending`, `testGetAverageOfNumeric`) - All three AAA section comments required in every test method - `assertSame` over `assertEquals` when type matters - One logical assertion per test where practical - Group related tests with `@group ` PHPDoc annotation - Do not commit tests that are skipped permanently — remove them or fix the underlying issue ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms ko_fi: ethanhann custom: https://www.paypal.com/paypalme/EthanHann ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: pull_request: jobs: lint-and-format: name: Lint & Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.2' coverage: none - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ~/.composer/cache key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }} restore-keys: composer- - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Check PHP syntax (lint) run: find src tests -name "*.php" -print0 | xargs -0 php -l - name: Check code format run: vendor/bin/php-cs-fixer fix --dry-run --diff src test: name: Test (PHP ${{ matrix.php-version }}) runs-on: ubuntu-latest needs: lint-and-format strategy: fail-fast: false matrix: php-version: ['8.2', '8.3', '8.4', '8.5'] services: redis: image: redis/redis-stack-server:latest ports: - 6381:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - uses: actions/checkout@v4 - name: Setup PHP ${{ matrix.php-version }} uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} coverage: none - name: Cache Composer dependencies uses: actions/cache@v4 with: path: ~/.composer/cache key: composer-${{ matrix.php-version }}-${{ hashFiles('composer.lock') }} restore-keys: composer-${{ matrix.php-version }}- - name: Install dependencies run: composer install --no-interaction --prefer-dist --no-progress - name: Run tests run: vendor/bin/phpunit ================================================ FILE: .github/workflows/docs.yml ================================================ name: Deploy Docs on: push: branches: [ main ] workflow_dispatch: permissions: contents: read pages: write id-token: write concurrency: group: pages cancel-in-progress: false jobs: build: runs-on: ubuntu-latest defaults: run: working-directory: docs-site steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 - name: Install dependencies run: bun install - name: Build site run: bun run astro build - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: path: docs-site/dist deploy: runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4 ================================================ FILE: .gitignore ================================================ /vendor/ .idea/ .php-cs-fixer.cache composer.lock composer.phar tests.log /.phpunit.result.cache /docs/ /.dev/ ================================================ FILE: .php-cs-fixer.php ================================================ in(__DIR__ . '/src'); return (new PhpCsFixer\Config()) ->setRules([ '@PSR12' => true, ]) ->setFinder($finder); ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance for Claude Code when working in this repository. ## Project Overview `redisearch-php` is a PHP client library for the RediSearch module. It wraps the Redis client adapters (Predis, PhpRedis, RedisClient) behind a unified interface and exposes RediSearch commands as fluent PHP objects. ## Development Setup ### Start Redis ```bash docker compose up -d ``` This starts `redis/redis-stack` (includes the RediSearch module) on port **6381**. ### Install Dependencies ```bash composer install ``` ## Running Tests ```bash # Default (Predis client) just test # Specific client just test-predis just test-php-redis just test-redis-client # All clients just test-all # Run phpunit directly (used by CI) vendor/bin/phpunit ``` Tests flush the entire Redis database on teardown. Never run against a Redis instance with important data. ## Code Style ```bash # Fix in place just fmt # Check only (no modifications — used by CI) vendor/bin/php-cs-fixer fix src --dry-run --diff ``` ## Lint ```bash find src tests -name "*.php" -print0 | xargs -0 php -l ``` ## Project Structure ``` src/ # Ehann\RediSearch\ namespace (PSR-4) Aggregate/ # Aggregation pipeline builders Operations/ Reducers/ Query/ # Query string builders Fields/ # Field type definitions Document/ # Document abstraction Exceptions/ Index.php # Primary entry point tests/ # Ehann\Tests\ namespace (PSR-4) RediSearch/ # Mirrors src/ structure Stubs/ # Fixtures (TestIndex, etc.) RediSearchTestCase.php # Base test class ``` ## Key Conventions - **Namespaces**: `Ehann\RediSearch\` for source, `Ehann\Tests\` for tests - **Interfaces**: suffix `Interface` (e.g., `IndexInterface`) - **Abstract classes**: prefix `Abstract` (e.g., `AbstractIndex`) - **Test files**: `{ClassName}Test.php` - **Test methods**: `test{Description}` (e.g., `testGetAverageOfNumeric`) - **PHP version**: 8.2+, use native type hints throughout ## CI GitHub Actions (`.github/workflows/ci.yml`) runs two jobs: 1. **lint-and-format** — PHP syntax check + php-cs-fixer dry-run on PHP 8.2 2. **test** — PHPUnit matrix across PHP 8.2 / 8.3 / 8.4 (requires lint to pass first) ## Custom Skills - `/contribute` — step-by-step guide for adding a new feature or bug fix - `/unit-test` — guide for writing and running unit tests ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Ethan Hann Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # RediSearch PHP Client [![Latest Stable Version](https://poser.pugx.org/ethanhann/redisearch-php/v/stable)](https://packagist.org/packages/ethanhann/redisearch-php) [![Total Downloads](https://poser.pugx.org/ethanhann/redisearch-php/downloads)](https://packagist.org/packages/ethanhann/redisearch-php) [![Latest Unstable Version](https://poser.pugx.org/ethanhann/redisearch-php/v/unstable)](https://packagist.org/packages/ethanhann/redisearch-php) [![License](https://poser.pugx.org/ethanhann/redisearch-php/license)](https://packagist.org/packages/ethanhann/redisearch-php) **What is this?** RediSearch-PHP is a PHP client library for the [RediSearch](http://redisearch.io/) module which adds Full-Text search to Redis. See the [documentation](http://www.ethanhann.com/redisearch-php/) for more information. **Contributing** Contributions are welcome. Before submitting a PR for review, please run confirm all tests in the test suite pass. Start the local Docker dev environment by running: ```shell just up ``` Then run the tests: ```shell just test ``` Specific Redis clients can be tested: ```shell just test-predis just test-php-redis just test-redis-client ``` Or to run tests for all clients: ```shell just test-all ``` Do not run tests on a prod system (of course), or any system that has a Redis instance with data you care about - Redis is flushed between tests. To fix code style, before submitting a PR: ```shell just fmt ``` **Laravel Support** [Laravel-RediSearch](https://github.com/ethanhann/Laravel-RediSearch) - Exposes RediSearch-PHP to Laravel as a Scout driver. ================================================ FILE: bin/redisearch ================================================ #!/usr/bin/env php run(); ================================================ FILE: composer.json ================================================ { "name": "ethanhann/redisearch-php", "type": "library", "autoload": { "psr-4": { "Ehann\\RediSearch\\": "src" } }, "autoload-dev": { "psr-4": { "Ehann\\Tests\\": "tests/" } }, "license": "MIT", "authors": [ { "name": "Ethan Hann", "email": "ethanhann@gmail.com" } ], "minimum-stability": "dev", "prefer-stable": true, "bin": ["bin/redisearch"], "require": { "php": ">=8.2", "psr/log": "^3.0.0", "ethanhann/redis-raw": "^3.0.2", "symfony/console": "^7.0 || ^8.0" }, "suggest": { "predis/predis": "Required for the predis adapter (default CLI adapter)", "ext-redis": "Required for the phpredis adapter", "cheprasov/php-redis-client": "Required for the RedisClient adapter" }, "require-dev": { "phpunit/phpunit": "^11.0", "mockery/mockery": "^1.6.0", "predis/predis": "^v2.0.0", "friendsofphp/php-cs-fixer": "^v3.10.0", "monolog/monolog": "^3.2.0", "ukko/phpredis-phpdoc": "^5.0@beta", "cheprasov/php-redis-client": "^1.9" } } ================================================ FILE: docker-compose.yml ================================================ services: redis: container_name: redisSearchPhpTest image: 'redis/redis-stack' ports: - '6381:6379' ================================================ FILE: docs-site/.gitignore ================================================ node_modules/ dist/ .astro/ ================================================ FILE: docs-site/astro.config.mjs ================================================ import { defineConfig } from 'astro/config'; import starlight from '@astrojs/starlight'; export default defineConfig({ site: 'https://ethanhann.com', base: '/redisearch-php/', integrations: [ starlight({ title: 'RediSearch-PHP', logo: { src: './src/assets/logo.png' }, social: [ { icon: 'github', label: 'GitHub', href: 'https://github.com/ethanhann/redisearch-php', }, ], customCss: ['./src/styles/custom.css'], editLink: { baseUrl: 'https://github.com/ethanhann/redisearch-php/edit/master/docs-site/src/content/docs/', }, sidebar: [ { label: 'Getting Started', link: '/' }, { label: 'Documentation', items: [ { label: 'Indexing', link: '/indexing/' }, { label: 'Searching', link: '/searching/' }, { label: 'Aggregating', link: '/aggregating/' }, { label: 'Suggesting', link: '/suggesting/' }, { label: 'CLI', link: '/cli/' }, ], }, { label: 'Laravel Support', link: '/laravel-support/' }, { label: 'Changelog', link: '/changelog/' }, ], }), ], }); ================================================ FILE: docs-site/package.json ================================================ { "name": "redisearch-php-docs", "type": "module", "scripts": { "dev": "astro dev", "build": "astro build", "preview": "astro preview" }, "dependencies": { "@astrojs/starlight": "latest", "astro": "latest" } } ================================================ FILE: docs-site/src/content/docs/aggregating.mdx ================================================ --- title: Aggregating draft: false description: Aggregation pipelines and cursor-based pagination. --- ## The Basics Make an [index](/redisearch-php/indexing/) and add a few documents to it: ```php use Ehann\RediSearch\Index; $bookIndex = new Index($redis); $bookIndex->add([ 'title' => 'How to be awesome', 'price' => 9.99 ]); $bookIndex->add([ 'title' => 'Aggregating is awesome', 'price' => 19.99 ]); ``` Now group by title and get the average price: ```php $results = $bookIndex->makeAggregateBuilder() ->groupBy('title') ->avg('price'); ``` ## Cursor-Based Pagination For large result sets, use `withCursor()` to retrieve results in batches instead of all at once. The first call returns an initial batch; subsequent batches are read with `cursorRead()`. Always call `cursorDelete()` when done to free the server-side cursor. ```php $builder = $bookIndex->makeAggregateBuilder(); // Request the first batch of up to 50 results. $result = $builder ->groupBy('author') ->count() ->withCursor(50) ->search(); // $result contains the first batch and a cursor ID. $cursorId = $result->getCursorId(); // Read subsequent batches until the cursor is exhausted (cursorId becomes 0). while ($cursorId !== 0) { $next = $builder->cursorRead($cursorId, 50); $cursorId = $next->getCursorId(); // process $next->getDocuments() ... } // If you need to abandon iteration early, delete the cursor explicitly. $builder->cursorDelete($cursorId); ``` ================================================ FILE: docs-site/src/content/docs/changelog.mdx ================================================ --- title: Changelog draft: false description: Version history and release notes. --- ## 3.1.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/3.0.0...3.1.0) ### PHP Compatibility * **PHP 8.5** — Added PHP 8.5 to the CI build matrix. ### New Features * Added `loadFields` method to `Index` to load fields from Redis. * Added `bin/redisearch` CLI tool for managing indexes. ### Bug Fixes * Upgraded redis-raw to 3.0.2 to fix a bug where FT.INFO would respond with garbage. ## 3.0.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/2.1.0...3.0.0) ### PHP Compatibility * **PHP 8.3 support** — fixed "Typed property must not be accessed before initialization" fatal errors. * **PHP 8.4+ compatibility** — fixed implicitly nullable parameters and dynamic property deprecations. ### Tooling * **Replaced Robo with `justfile`** — `just test`, `just fmt`, etc. are now the standard commands. * **GitHub Actions CI** — lint-and-format job plus PHPUnit test matrix across PHP 8.2, 8.3, and 8.4. * Removed deprecated `version` field from `docker-compose.yml`. ### Developer Experience * Added `CLAUDE.md` with contributor guidance and project structure documentation. * Added `/contribute` and `/unit-test` Claude Code skills for contributors. * Upgraded `/unit-test` skill to target PHPUnit 11 and enforce AAA (Arrange-Act-Assert) test style. ## 2.1.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/2.0.0...2.1.0) ### New Features * **Index prefix support** — fixed index creation to correctly apply prefix options. * **Improved hash support** — better `addHash` functionality for adding documents as Redis hashes. ## 2.0.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/1.9.0...2.0.0) ### Breaking Changes * **PHP 8.0+** is now required. * **`FT.ADDHASH` removed** — replaced with `HSET` to align with RediSearch v2 API. ### New Features * **`replaceMany()`** — bulk replace documents alongside the existing `addMany()`. * **`FT.TAGVALS` support** — retrieve all values in a tag field. * **Tag field auto-detection** — array values passed to `FieldFactory::make` are automatically treated as Tag fields. * **Aggregation improvements** — operations now support optional field lists; group-by filter feature added. * **`Index::getFields()` made public.** * **Suggestion scores** — support for retrieving scores with autocomplete suggestions. ### Bug Fixes * Fixed suggestion score incrementation. * Escaped special characters in tag field filters. * Fixed handling of "document already exists" and "unsupported language" return values from Redis. * `DocumentAlreadyInIndexException` now includes the index name and document ID in the message. ## 1.1.2 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/1.1.1...1.1.2) * Loosen version requirement for redis raw to pull in bug fix(es). ## 1.1.1 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/1.1.0...1.1.1) * Fix issue with implicitly named indexes. ## 1.1.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/1.0.1...1.1.0) * Support [aggregations](/redisearch-php/aggregating/). ## 1.0.1 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/1.0.0...1.0.1) * Support NOINDEX fields. ## 1.0.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.11.0...1.0.0) * Support complete RediSearch API, now including RETURN, SUMMARIZE, HIGHLIGHT, EXPANDER, and PAYLOAD in search queries. ## 0.11.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.10.1...0.11.0) * Add [hash indexing](/redisearch-php/indexing/#document-storage-in-v2). ## 0.10.1 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.10.0...0.10.1) * Polished docs, and added a section on the Laravel RediSearch package. * Made internal changes to how numeric and geo search queries are generated. ## 0.10.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.9.0...0.10.0) * Remove RedisClient class and add adapter functionality. * There are now adapters for Predis, PhpRedis, and the Cheprasov client. They all extend [AbstractRedisClient](https://github.com/ethanhann/redisearch-php/blob/master/src/Redis/AbstractRedisClient.php) which implements [RedisClientInterface](https://github.com/ethanhann/redisearch-php/blob/master/src/Redis/RedisClientInterface.php). An additional adapter can be created by extending AbstractRedisClient or by implementing RedisClientInterface if needed for some reason. * Handle RediSearch module error when index is created on a Redis database other than 0. * Return boolean true instead of "OK" when using PredisAdapter. * A new index now requires that a redis client is passed into its constructor - removed the magic behavior where a default RedisClient instance was auto initialized. ## 0.9.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.8.0...0.9.0) * An exception is now thrown when the RediSearch module is not loaded in Redis. * Allow a [language to be specified](/redisearch-php/searching/#setting-a-language) when searching. ## 0.8.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.7.0...0.8.0) * Add [search result sorting](/redisearch-php/searching/#sorting-results). * Remove NoScoreIdx and Optimize methods as they are deprecated and/or non-functional in RediSearch. * Add [explain method](/redisearch-php/searching/#explaining-a-query) for explaining a query. * Add optional [query logging](/redisearch-php/searching/#logging-queries). * Add [suggestions](/redisearch-php/suggesting/). ## 0.7.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.6.0...0.7.0) * Many bug fixes and code quality improvements. ## 0.6.0 [Changes since last release](https://github.com/ethanhann/redisearch-php/compare/0.5.0...0.6.0) * Add [batch indexing](/redisearch-php/indexing/#batch-indexing). ## 0.5.0 * Rename vendor namespace from **Eeh** to **Ehann** * **AbstractIndex** was renamed to **Index** and is no longer abstract. * Custom document ID is now properly set when adding to an index. ================================================ FILE: docs-site/src/content/docs/cli.mdx ================================================ --- title: CLI draft: false description: A command-line interface for interacting with RediSearch directly from the terminal. --- RediSearch-PHP includes a CLI tool powered by Symfony Console. It lets you manage indexes, documents, and queries directly from the terminal — useful for debugging, exploration, and rapid prototyping. ## Installation The CLI is included with the library. After installing via Composer, the binary is available at: ```bash vendor/bin/redisearch ``` Run it without arguments to see all available commands: ```bash vendor/bin/redisearch list ``` ## Global Options All commands accept these connection options: ```bash vendor/bin/redisearch --host 127.0.0.1 --port 6379 --password secret --adapter predis ``` | Option | Default | Description | |--------|---------|-------------| | `--host` | `127.0.0.1` | Redis host | | `--port` / `-p` | `6379` | Redis port | | `--password` / `-a` | — | Redis password | | `--adapter` | `predis` | Adapter: `predis`, `phpredis`, or `redisclient` | ## Index Management ### Creating an Index Create an index from a JSON schema file: ```bash vendor/bin/redisearch index:create products schema.json ``` The schema file defines the fields for the index: ```json { "fields": [ { "name": "title", "type": "TEXT", "weight": 2.0, "sortable": true }, { "name": "price", "type": "NUMERIC", "sortable": true }, { "name": "tags", "type": "TAG", "separator": "," }, { "name": "location", "type": "GEO" } ] } ``` Supported field types: `TEXT`, `NUMERIC`, `TAG`, `GEO`, `VECTOR`. Additional options: ```bash vendor/bin/redisearch index:create products schema.json \ --on HASH \ --prefix "product:" \ --filter "@price > 0" \ --stopwords the a an ``` ### Listing Indexes ```bash vendor/bin/redisearch index:list vendor/bin/redisearch index:list --json ``` ### Viewing Index Info ```bash vendor/bin/redisearch index:info products vendor/bin/redisearch index:info products --json ``` ### Dropping an Index ```bash vendor/bin/redisearch index:drop products vendor/bin/redisearch index:drop products --delete-docs ``` ## Document Management ### Adding a Document ```bash vendor/bin/redisearch document:add products doc1 title="Laptop" price=999 tags="electronics,computer" ``` Use `--replace` to upsert, and optionally set language or score: ```bash vendor/bin/redisearch document:add products doc1 title="Laptop Pro" price=1299 --replace --score 0.9 ``` ### Getting a Document Retrieve a document by its full Redis key: ```bash vendor/bin/redisearch document:get products doc1 vendor/bin/redisearch document:get products doc1 --json ``` ### Deleting a Document ```bash vendor/bin/redisearch document:delete products doc1 ``` ## Searching Run a full-text search query against an index: ```bash vendor/bin/redisearch search products "laptop" ``` ### Search Options ```bash vendor/bin/redisearch search products "laptop" \ --limit 0,20 \ --sort price:ASC \ --fields title,price \ --highlight title \ --scores \ --json ``` | Option | Format | Description | |--------|--------|-------------| | `--limit` | `offset,count` | Paginate results (default `0,10`) | | `--sort` | `field:ASC\|DESC` | Sort by a sortable field | | `--fields` | `field1,field2` | Return only specific fields | | `--highlight` | `field1,field2` | Highlight matching terms | | `--scores` | flag | Include relevance scores | | `--verbatim` | flag | Disable stemming | | `--language` | `name` | Set stemming language | | `--dialect` | `1\|2\|3` | Query dialect version | | `--json` | flag | Output as JSON | ### Filters Apply numeric, tag, or geo filters: ```bash # Numeric filter vendor/bin/redisearch search products "laptop" --numeric-filter price:500:2000 # Tag filter vendor/bin/redisearch search products "*" --tag-filter tags:electronics,computer # Geo filter (field:lon:lat:radius:unit) vendor/bin/redisearch search products "*" --geo-filter location:-73.9:40.7:10:km ``` Filters can be combined and repeated. ## Aggregation Run aggregation queries with grouping and reducers: ```bash vendor/bin/redisearch aggregate products "*" \ --group-by tags \ --reduce avg:price \ --reduce count \ --sort-by price:DESC \ --limit 0,10 ``` | Option | Format | Description | |--------|--------|-------------| | `--group-by` | `field` | Group results by field | | `--reduce` | `func:field` | Apply reducer (repeatable) | | `--sort-by` | `field:ASC\|DESC` | Sort aggregated results | | `--apply` | `expr:alias` | Apply expression | | `--filter` | `expression` | Filter aggregated results | | `--limit` | `offset,count` | Limit results | | `--load` | `field1,field2` | Load additional fields | | `--json` | flag | Output as JSON | Available reducers: `avg`, `sum`, `min`, `max`, `count`, `count_distinct`, `count_distinctish`, `stddev`, `tolist`, `first_value`. ## Developer Tools ### Query Explain View the execution plan for a query using `FT.EXPLAIN`: ```bash vendor/bin/redisearch explain products "laptop" ``` This helps understand how RediSearch parses and executes a query. ### Query Profile Profile a query to see timing and execution details using `FT.PROFILE`: ```bash vendor/bin/redisearch profile products "laptop" vendor/bin/redisearch profile products "laptop" --json ``` ## Interactive Shell Start a REPL session for rapid experimentation: ```bash vendor/bin/redisearch shell --host 127.0.0.1 --port 6379 ``` Example session: ``` redisearch> index:list redisearch> use products redisearch (products)> search "laptop" redisearch (products)> explain "laptop" redisearch (products)> aggregate "*" --group-by tags --reduce count redisearch (products)> exit ``` The shell supports: - `use ` — set a default index so you don't have to repeat it - `help` — list available commands - `exit` / `quit` — leave the shell - Command history via readline - Quoted strings for queries with spaces ## JSON Output Most commands support `--json` for machine-readable output, making the CLI useful in scripts: ```bash vendor/bin/redisearch search products "laptop" --json | jq '.documents[].title' ``` ================================================ FILE: docs-site/src/content/docs/index.mdx ================================================ --- title: RediSearch-PHP draft: false description: A PHP client library for the RediSearch module, adding full-text search to Redis. --- RediSearch-PHP is a PHP client library for the [RediSearch](https://redis.io/docs/latest/develop/ai/search-and-query/) module which adds full-text search to Redis. ## Requirements * [Redis Stack](https://redis.io/about/about-stack/) or Redis with the RediSearch module v2.x loaded. * PHP >=8.2 * [PhpRedis](https://github.com/phpredis/phpredis), [Predis](https://github.com/nrk/predis), or [php-redis-client](https://github.com/cheprasov/php-redis-client). ## Install ```bash composer require ethanhann/redisearch-php ``` ## Load ```php require_once 'vendor/autoload.php'; ``` ## Create a Redis Client ```php use Ehann\RedisRaw\PredisAdapter; use Ehann\RedisRaw\PhpRedisAdapter; use Ehann\RedisRaw\RedisClientAdapter; $redis = (new PredisAdapter())->connect('127.0.0.1', 6379); // or $redis = (new PhpRedisAdapter())->connect('127.0.0.1', 6379); // or $redis = (new RedisClientAdapter())->connect('127.0.0.1', 6379); ``` ## Create the Schema ```php use Ehann\RediSearch\Index; $bookIndex = new Index($redis); $bookIndex->addTextField('title') ->addTextField('author') ->addNumericField('price') ->addNumericField('stock') ->create(); ``` ## Add a Document ```php $bookIndex->add([ new TextField('title', 'Tale of Two Cities'), new TextField('author', 'Charles Dickens'), new NumericField('price', 9.99), new NumericField('stock', 231), ]); ``` ## Search the Index ```php $result = $bookIndex->search('two cities'); $result->getCount(); // Number of documents. $result->getDocuments(); // Array of matches. // Documents are returned as objects by default. $firstResult = $result->getDocuments()[0]; $firstResult->title; $firstResult->author; ``` ================================================ FILE: docs-site/src/content/docs/indexing.mdx ================================================ --- title: Indexing draft: false description: Field types, adding, updating, and batch indexing documents. --- ## Field Types There are five types of fields that can be added to a document: **TextField**, **NumericField**, **GeoField**, **TagField**, and **VectorField**. They are instantiated like this: ```php new TextField('author', 'Charles Dickens'); new NumericField('price', 9.99); new GeoField('place', new GeoLocation(-77.0366, 38.8977)); new TagField('color', 'red'); new VectorField('embedding', VectorField::ALGORITHM_HNSW, VectorField::TYPE_FLOAT32, 128, VectorField::DISTANCE_COSINE); ``` Fields can also be made with the FieldFactory class: ```php // Alternative syntax for: new TextField('author', 'Charles Dickens'); FieldFactory::make('author', 'Charles Dickens'); // Alternative syntax for: new NumericField('price', 9.99); FieldFactory::make('price', 9.99); // Alternative syntax for: new GeoField('place', new GeoLocation(-77.0366, 38.8977)); FieldFactory::make('place', new GeoLocation(-77.0366, 38.8977)); // Alternative syntax for: new TagField('color', 'red'); FieldFactory::make('color', 'red'); ``` ### Vector Fields Vector fields enable nearest-neighbor similarity search (available in RediSearch v2.2+). Use `addVectorField()` when defining the schema: ```php use Ehann\RediSearch\Fields\VectorField; $bookIndex->addVectorField( 'embedding', VectorField::ALGORITHM_HNSW, // FLAT or HNSW VectorField::TYPE_FLOAT32, // FLOAT32 or FLOAT64 128, // number of dimensions VectorField::DISTANCE_COSINE // L2, IP, or COSINE ); ``` ## Adding Documents Add an array of field objects: ```php $bookIndex->add([ new TextField('title', 'Tale of Two Cities'), new TextField('author', 'Charles Dickens'), new NumericField('price', 9.99), new GeoField('place', new GeoLocation(-77.0366, 38.8977)), new TagField('color', 'red'), ]); ``` Add an associative array: ```php $bookIndex->add([ 'title' => 'Tale of Two Cities', 'author' => 'Charles Dickens', 'price' => 9.99, 'place' => new GeoLocation(-77.0366, 38.8977), 'color' => new TagField('color', 'red'), ]); ``` Create a document with the index's makeDocument method, then set field values: ```php $document = $bookIndex->makeDocument(); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->place->setValue(new GeoLocation(-77.0366, 38.8977)); $document->color->setValue(new Tag('red')); $bookIndex->add($document); ``` DocBlocks can (optionally) be used to type hint field property names: ```php /** @var BookDocument $document */ $document = $bookIndex->makeDocument(); // "title" will auto-complete correctly in your IDE provided BookDocument has a "title" property or @property annotation. $document->title->setValue('How to be awesome.'); $bookIndex->add($document); ``` ```php makeDocument(); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->place->setValue(new GeoLocation(-77.0366, 38.8977)); $bookIndex->add($document); // Update a couple fields $document->title->setValue('How to be awesome: Part 2.'); $document->price->setValue(19.99); // Update the document. $bookIndex->replace($document); ``` A document can also be updating when its ID is specified: ```php // Make a document. $document = $bookIndex->makeDocument(); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->place->setValue(new GeoLocation(-77.0366, 38.8977)); $bookIndex->add($document); // Create a new document and assign the old document's ID to it. $newDocument = $bookIndex->makeDocument($document->getId()); // Set a couple fields. $document->title->setValue(''); $document->author->setValue('Jack'); $newDocument->title->setValue('How to be awesome: Part 2.'); $newDocument->price->setValue(19.99); // Update the document. $bookIndex->replace($newDocument); ``` ## Batch Indexing Batch indexing is possible with the **addMany** method. To index an external collection, make sure to set the document's ID to the ID of the record in the external collection. ```php // Get a record set from your DB (or some other datastore). $records = $someDatabase->findAll(); $documents = []; foreach ($records as $record) { // Make a new document with the external record's ID. $newDocument = $bookIndex->makeDocument($record->id); $newDocument->title->setValue($record->title); $newDocument->author->setValue($record->author); $documents[] = $newDocument; } // Add all the documents at once. $bookIndex->addMany($documents); // It is possible to increase indexing speed by disabling atomicity by passing true as the second parameter. // Note that this is only possible when using the phpredis extension. $bookIndex->addMany($documents, true); ``` ## Document Storage in v2 In RediSearch v2, all documents are stored as Redis hashes (key/value pairs) internally. The library writes every document via `HSET` — there is no separate indexing step. `addHash()` and `replaceHash()` are available as aliases for `add()` and `replace()` respectively, both using upsert semantics: ```php $document = $bookIndex->makeDocument('foo'); $document->title->setValue('How to be awesome.'); $bookIndex->addHash($document); // upsert (same as add/replace) $bookIndex->replaceHash($document); // also upsert ``` ## Key Prefixes You can configure one or more key prefixes so RediSearch only indexes hashes whose Redis key starts with a given string. Multiple prefixes are treated as **alternatives** — each is a separate key namespace, not a compound path. When writing documents, the library always uses the **first** configured prefix to build the hash key. Each prefix must include its own separator character (e.g. `'post:'`, not `'post'`). ```php // Index covers keys starting with 'post:' OR 'blog:' $index->setPrefixes(['post:', 'blog:'])->create(); // Documents are written under the first prefix: 'post:{id}' $index->add($document); ``` ## Index Creation Options Several `FT.CREATE` options can be set before calling `create()`: ```php $index ->setIndexType('HASH') // 'HASH' (default) or 'JSON' (requires RedisJSON) ->setFilter('@price > 0') // only index documents matching this expression ->setMaxTextFields() // allow more than 32 TEXT fields ->setTemporary(3600) // auto-expire index after 3600 seconds of inactivity ->setSkipInitialScan() // don't scan existing keys on creation ->addTextField('title') ->addNumericField('price') ->create(); ``` ## Schema Expansion Fields can be added to an existing index without recreating it using `alter()`. Note that existing documents are not retroactively re-indexed for new fields; only newly added or updated documents will include them. ```php use Ehann\RediSearch\Fields\NumericField; $bookIndex->alter(new NumericField('year')); ``` ## Loading Fields from an Existing Index `loadFields()` introspects a pre-existing RediSearch index and reconstructs all of its field definitions on the `Index` object — without you having to re-declare every field manually. This is especially useful when multiple services share the same index, or when you want to instantiate an `Index` that reflects the live schema in Redis. Internally the method calls `FT.INFO` and parses the returned attribute descriptors to create the appropriate `TextField`, `NumericField`, `TagField`, `GeoField`, or `VectorField` objects, preserving weights, sortable flags, separators, and vector parameters. Internal fields (`__score`, `__language`, etc.) are silently ignored. ```php // The index already exists in Redis — no need to call create() or add any fields. $bookIndex = (new Index($redisClient, 'bookIndex'))->loadFields(); // All fields are now available for search, document creation, etc. $results = $bookIndex->search('Dickens'); $document = $bookIndex->makeDocument(); ``` `loadFields()` returns `static`, so it chains with other methods: ```php $results = (new Index($redisClient, 'bookIndex')) ->loadFields() ->search('Dickens'); ``` Field metadata is fully restored: ```php // TEXT field with a custom weight and the SORTABLE flag $bookIndex->addTextField('title', weight: 2.0, sortable: true)->create(); // Later, in a different request / process: $bookIndex2 = (new Index($redisClient, 'bookIndex'))->loadFields(); // $bookIndex2->title is a TextField with weight=2.0 and sortable=true ``` Works with all five field types: ```php $index->addTextField('title', weight: 2.0, sortable: true) ->addNumericField('price', sortable: true) ->addTagField('categories', separator: '|') ->addGeoField('location') ->addVectorField('embedding', VectorField::ALGORITHM_HNSW, VectorField::TYPE_FLOAT32, 128, VectorField::DISTANCE_COSINE) ->create(); // Reconstitute the same schema elsewhere: $index2 = (new Index($redisClient, 'myIndex'))->loadFields(); ``` ## Listing Indexes All index names in the current Redis instance can be retrieved: ```php $names = $bookIndex->listIndexes(); // e.g. ['bookIndex', 'authorIndex'] ``` ## Aliasing Indexes can be aliased. Note that an exception will be thrown if any alias method is called before an index's [schema](/#create-the-schema) is created. ### Adding an Alias An alias can be added for an index like this: ```php $index->addAlias('foo'); ``` ### Updating an Alias Assuming an alias has already been added to an index, like this: ```php $oldIndex->addAlias('foo'); ``` ...it can be reassigned to a different index like this: ```php $newIndex->updateAlias('foo'); ``` ### Deleting an Alias An alias can be deleted like this: ```php $index->deleteAlias('foo'); ``` ## Managing an Index Whether or not an index exists can be checked: ```php $indexExists = $index->exists(); ``` An index can be removed: ```php $index->drop(); ``` Passing `true` also deletes all underlying document hashes from Redis: ```php $index->drop(true); ``` ================================================ FILE: docs-site/src/content/docs/laravel-support.mdx ================================================ --- title: Laravel Support draft: false description: Laravel Scout driver integration for RediSearch. --- [Laravel-RediSearch](https://github.com/ethanhann/Laravel-RediSearch) allows for indexing and searching Laravel models. It provides a [Laravel Scout](https://laravel.com/docs/5.6/scout) driver. ## Getting Started ### Install ```bash composer require ethanhann/laravel-redisearch ``` ### Register the Provider Add this entry to the providers array in config/app.php. ```php Ehann\LaravelRediSearch\RediSearchServiceProvider::class ``` ### Configure the Scout Driver Update the Scout driver in config/scout.php. ```php 'driver' => env('SCOUT_DRIVER', 'ehann-redisearch'), ``` ### Define Searchable Schema Define the field types that will be used on indexing ```php $this->name, "username" => $this->username, "location" => new GeoLocation( $this->longitude, $this->latitude ) "age" => $this->age, ]; } public function searchableSchema() { return [ "name" => TextField::class, "username" => TextField::class, "location" => GeoField::class, "age" => NumericField::class ]; } } ``` ### Import a Model Import a "Product" model that is [configured to be searchable](https://laravel.com/docs/5.6/scout#configuration): ```bash artisan ehann:redisearch:import App\\Product ``` Delete the index before importing: ```bash artisan ehann:redisearch:import App\\Product --recreate-index ``` Import models without an ID field (this should be rarely needed): ```bash artisan ehann:redisearch:import App\\Product --no-id ``` ### Query Filters How To Query Filters? [Filtering Tag Fields](/redisearch-php/searching/#tag-fields) ```php App\User::search("Search Query", function($index){ return $filter->geoFilter("location", 5.56475, 5.75516, 100) ->numericFilter('age', 18, 32) })->get() ``` ## What now? See the [Laravel Scout](https://laravel.com/docs/5.6/scout) documentation for additional information. ================================================ FILE: docs-site/src/content/docs/searching.mdx ================================================ --- title: Searching draft: false description: Text search, filtering, sorting, vector search, spell checking, and synonyms. --- ## Simple Text Search Text fields can be filtered with the index's search method. ```php $result = $bookIndex->search('two cities'); $result->getCount(); // Number of documents. $result->getDocuments(); // Array of stdObjects. ``` Documents can also be returned as arrays instead of objects by passing true as the second parameter to the search method. ```php $result = $bookIndex->search('two cities', true); $result->getDocuments(); // Array of arrays. ``` ## Filtering ### Tag Fields Tag fields can be filtered with the index's tagFilter method. Specifying multiple tags creates a union of documents. ```php $result = $bookIndex ->tagFilter('color', ['blue', 'red']) ->search('two cities'); ``` Use multiple separate tagFilter calls to create an intersection of documents. ```php $result = $bookIndex ->tagFilter('color', ['blue']) ->tagFilter('color', ['red']) ->search('two cities'); ``` ### Numeric Fields Numeric fields can be filtered with the index's numericFilter method. ```php $result = $bookIndex ->numericFilter('price', 4.99, 19.99) ->search('two cities'); ``` ### Geo Fields Geo fields can be filtered with the index's geoFilter method. ```php $result = $bookIndex ->geoFilter('place', -77.0366, 38.897, 100) ->search('two cities'); ``` ## Sorting Results Search results can be sorted with the index's sort method. ```php $result = $bookIndex ->sortBy('price') ->search('two cities'); ``` ## Number of Results The number of documents can be retrieved after performing a search. ```php $result = $bookIndex->search('two cities'); $result->getCount(); // Number of documents. ``` Alternatively, the number of documents can be queried without returning the documents themselves. This is useful if you want to check the total number of documents without returning any other data from the Redis server. ```php $numberOfDocuments = $bookIndex->count('two cities'); ``` ## Setting a Language A supported language can be specified when running a query. Supported languages are represented as constants in the **Ehann\RediSearch\Language** class. ```php $result = $bookIndex ->language(Language::ITALIAN) ->search('two cities'); ``` ## Query Dialect RediSearch v2.4+ supports multiple query dialects that unlock different syntax features. Use `dialect()` to select a version (1, 2, or 3): ```php $result = $bookIndex ->dialect(2) ->search('two cities'); ``` Dialect 2 is required for vector/KNN queries and extended query syntax. ## Vector Search Vector similarity search allows you to find documents whose vector fields are nearest to a query vector. This requires a field indexed with `addVectorField()`, dialect 2, and the `params()` method to pass the query vector as a named parameter. ```php // Pack your float32 values into a binary string. $queryVector = pack('f*', 0.1, 0.2, 0.3, /* ... 128 floats total */); $result = $bookIndex ->params(['vec' => $queryVector]) ->dialect(2) ->search('*=>[KNN 5 @embedding $vec]'); ``` ## Spell Checking `spellCheck()` returns suggestions for potentially misspelled terms in a query. The optional second argument sets the maximum edit distance (1–4, default 1). ```php $suggestions = $bookIndex->spellCheck('helo'); // distance 1 $suggestions = $bookIndex->spellCheck('helo', 2); // distance 2 ``` ## Synonyms Synonym groups let you treat different terms as equivalent during search. ```php // Register 'book', 'novel', and 'tome' as synonyms. $bookIndex->synUpdate('group1', 'book', 'novel', 'tome'); // Inspect all synonym mappings for the index. $map = $bookIndex->synDump(); ``` ## Explaining a Query An explanation for a query can be generated with the index's explain method. This can be helpful for understanding why a query is returning a set of results. ```php $result = $bookIndex ->numericFilter('price', 4.99, 19.99) ->sortBy('price') ->explain('two cities'); ``` ## Logging Queries Logging is optional. It can be enabled by injecting a PSR compliant logger, such as Monolog, into a RedisClient instance. Install Monolog: ```bash composer require monolog/monolog ``` Inject a logger instance (with a stream handler in this example): ```php $logger = new Logger('Ehann\RediSearch'); $logger->pushHandler(new StreamHandler('MyLogFile.log', Logger::DEBUG)); $this->redisClient->setLogger($logger); ``` ================================================ FILE: docs-site/src/content/docs/suggesting.mdx ================================================ --- title: Suggesting draft: false description: Suggestion index creation and management. --- ## Creating a Suggestion Index Create a suggestion index called "MySuggestions": ```php use Ehann\RediSearch\Suggestion; $suggestion = new Suggestion($redisClient, 'MySuggestions'); ``` ## Adding a Suggestion Add a suggestion with a score: ```php $suggestion->add('Tale of Two Cities', 1.10); ``` ## Getting a Suggestion Pass a partial string to the get method: ```php $result = $suggestion->get('Cities'); ``` ## Deleting a Suggestion Pass the entire suggestion string to the delete method: ```php $result = $suggestion->delete('Tale of Two Cities'); ``` ## Getting the Number of Possible Suggestions Simply use the suggestion index's length method: ```php $numberOfPossibleSuggestions = $suggestion->length(); ``` ================================================ FILE: docs-site/src/content.config.ts ================================================ import { defineCollection } from 'astro:content'; import { docsLoader } from '@astrojs/starlight/loaders'; import { docsSchema } from '@astrojs/starlight/schema'; export const collections = { docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), }; ================================================ FILE: docs-site/src/styles/custom.css ================================================ :root { --sl-color-accent-low: #ffcdd2; --sl-color-accent: #e53935; --sl-color-accent-high: #b71c1c; } ================================================ FILE: docs-site/tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict" } ================================================ FILE: justfile ================================================ # Default: run tests with Predis default: test # Start Redis (detached) up: docker compose up -d # Install dependencies install: composer install # Run tests with default client (Predis) test: vendor/bin/phpunit # Run tests with specific clients test-predis: REDIS_LIBRARY=Predis vendor/bin/phpunit test-php-redis: REDIS_LIBRARY=PhpRedis vendor/bin/phpunit test-redis-client: REDIS_LIBRARY=RedisClient vendor/bin/phpunit # Run tests with all three clients sequentially test-all: test-predis test-php-redis test-redis-client # Fix code style in-place fmt: vendor/bin/php-cs-fixer fix src # Check code style without modifying (used by CI) lint-fmt: vendor/bin/php-cs-fixer fix src --dry-run --diff # Lint PHP syntax lint: find src tests -name "*.php" -print0 | xargs -0 php -l # Full build: fmt then test build: fmt test-all ================================================ FILE: phpunit.xml ================================================ ./tests ./src ================================================ FILE: src/AbstractIndex.php ================================================ indexName = $indexName; } } ================================================ FILE: src/AbstractRediSearchClientAdapter.php ================================================ redisClient = new RediSearchRedisClient($redisClient); } /** * @param string $command * @param array $arguments * @return mixed */ protected function rawCommand(string $command, array $arguments) { return $this->redisClient->rawCommand($command, $arguments); } } ================================================ FILE: src/Aggregate/AggregationResult.php ================================================ count = $count; $this->documents = $documents; } public function getCount(): int { return $this->count; } public function getDocuments(): array { return $this->documents; } public static function makeAggregationResult(array $rawRediSearchResult, bool $documentsAsArray) { if (!$rawRediSearchResult) { return false; } $documentWidth = 1; array_shift($rawRediSearchResult); $documents = []; for ($i = 0; $i < count($rawRediSearchResult); $i += $documentWidth) { $document = $documentsAsArray ? [] : new \stdClass(); $fields = $rawRediSearchResult[$i + ($documentWidth - 1)]; if (is_array($fields)) { for ($j = 0; $j < count($fields); $j += 2) { $normalizedKey = preg_replace("/[^A-Za-z0-9 ]/", '_', $fields[$j]); if ($normalizedKey !== '_') { // Avoid a situation where the key is empty by only trimming the key if it is not "_". $normalizedKey = trim($normalizedKey, '_'); } $documentsAsArray ? $document[$normalizedKey] = $fields[$j + 1] : $document->$normalizedKey = $fields[$j + 1]; if (strpos($fields[$j], '(')) { $normalizedKeyField = $normalizedKey . '_field'; $documentsAsArray ? $document[$normalizedKeyField] = $fields[$j] : $document->$normalizedKeyField = $fields[$j]; } } } $documents[] = $document; } return new AggregationResult(count($documents), $documents); } } ================================================ FILE: src/Aggregate/Builder.php ================================================ redis = $redis; $this->indexName = $indexName; } /** * Get pipeline. */ public function getPipeline(): array { return $this->pipeline; } /** * Delete all operations from the aggregation pipeline. */ public function clear() { $this->pipeline = []; } /** * Only use this method if absolutely necessary. It has a detrimental impact on performance. * @param array $fieldNames * @return BuilderInterface */ public function load(array $fieldNames): BuilderInterface { $this->pipeline[] = new Load($fieldNames); return $this; } /** * @param string|array $fieldName * @param CanBecomeArrayInterface|array $reducer * @return BuilderInterface */ public function groupBy($fieldName = [], ?CanBecomeArrayInterface $reducer = null): BuilderInterface { $this->pipeline[] = new GroupBy(is_array($fieldName) ? $fieldName : [$fieldName]); if (!is_null($reducer)) { $this->reduce($reducer); } return $this; } /** * @param CanBecomeArrayInterface $reducer * @return BuilderInterface */ public function reduce(CanBecomeArrayInterface $reducer): BuilderInterface { $this->pipeline[] = $reducer; return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function avg(string $fieldName): BuilderInterface { $this->pipeline[] = new Avg($fieldName); return $this; } /** * @param int $group * @return BuilderInterface */ public function count(int $group = 0): BuilderInterface { $this->pipeline[] = new Count($group); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function countDistinct(string $fieldName): BuilderInterface { $this->pipeline[] = new CountDistinct($fieldName); return $this; } /** * @param array|string $fieldName * @return BuilderInterface */ public function countDistinctApproximate(string $fieldName): BuilderInterface { $this->pipeline[] = new CountDistinctApproximate($fieldName); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function sum(string $fieldName): BuilderInterface { $this->pipeline[] = new Sum($fieldName); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function max(string $fieldName): BuilderInterface { $this->pipeline[] = new Max($fieldName); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function min(string $fieldName): BuilderInterface { $this->pipeline[] = new Min($fieldName); return $this; } /** * @param string $fieldName * @param float $quantile * @return BuilderInterface */ public function quantile(string $fieldName, float $quantile): BuilderInterface { $this->pipeline[] = new Quantile($fieldName, $quantile); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function standardDeviation(string $fieldName): BuilderInterface { $this->pipeline[] = new StandardDeviation($fieldName); return $this; } /** * @param string $fieldName * @param string|null $byFieldName * @param bool $isAscending * @return BuilderInterface */ public function firstValue(string $fieldName, ?string $byFieldName = null, bool $isAscending = true): BuilderInterface { $this->pipeline[] = new FirstValue($fieldName, $byFieldName, $isAscending); return $this; } /** * @param string $fieldName * @return BuilderInterface */ public function toList(string $fieldName): BuilderInterface { $this->pipeline[] = new ToList($fieldName); return $this; } /** * @param array|string $fieldName * @param bool $isAscending * @param int $max * @return BuilderInterface */ public function sortBy($fieldName, $isAscending = true, int $max = -1): BuilderInterface { $this->pipeline[] = new SortBy(is_array($fieldName) ? $fieldName : [$fieldName], $isAscending, $max); return $this; } /** * @param string $expression An expression that can be used to perform arithmetic operations on numeric properties. * @param string $asFieldName The name of the fieldName to add or replace. * @return BuilderInterface */ public function apply(string $expression, string $asFieldName): BuilderInterface { $this->pipeline[] = new Apply($expression, $asFieldName); return $this; } /** * @param string $expression * @return BuilderInterface */ public function filter(string $expression): BuilderInterface { $this->pipeline[] = new Filter($expression); return $this; } /** * @param int $offset * @param int $pageSize * @return BuilderInterface */ public function limit(int $offset, int $pageSize = 10): BuilderInterface { $this->pipeline[] = new Limit($offset, $pageSize); return $this; } /** * Enables cursor-based iteration of aggregate results (WITHCURSOR COUNT {n}). * Use cursorRead() to retrieve subsequent pages. * * @param int $count Number of results to return per cursor batch * @return BuilderInterface */ public function withCursor(int $count = 100): BuilderInterface { $this->cursor = "WITHCURSOR COUNT $count"; return $this; } /** * Reads the next batch from an open aggregate cursor (FT.CURSOR READ). * * @param int $cursorId The cursor ID returned in the previous aggregate result * @param int $count Number of results to return * @return mixed */ public function cursorRead(int $cursorId, int $count = 100): mixed { return $this->redis->rawCommand('FT.CURSOR', ['READ', $this->indexName, $cursorId, 'COUNT', $count]); } /** * Deletes an open aggregate cursor, freeing server-side resources (FT.CURSOR DEL). * * @param int $cursorId * @return mixed */ public function cursorDelete(int $cursorId): mixed { return $this->redis->rawCommand('FT.CURSOR', ['DEL', $this->indexName, $cursorId]); } /** * @param string $query * @return array */ public function makeAggregateCommandArguments(string $query): array { $pipelineOperations = array_map(function (CanBecomeArrayInterface $operation) { return $operation->toArray(); }, $this->pipeline); $pipelineOperations = array_reduce($pipelineOperations, function ($prev, $next) { return is_null($prev) ? $next : array_merge($prev, $next); }); $cursorArgs = $this->cursor !== '' ? explode(' ', $this->cursor) : []; return array_filter( array_merge( trim($query) === '' ? [$this->indexName] : [$this->indexName, $query], $this->load, $pipelineOperations, $cursorArgs ), function ($item) { return !is_null($item) && $item !== ''; } ); } /** * @param string $query * @param bool $documentsAsArray * @return AggregationResult * @throws RedisRawCommandException */ public function search(string $query = '', bool $documentsAsArray = false): AggregationResult { $args = $this->makeAggregateCommandArguments($query === '' ? '*' : $query); $rawResult = $this->redis->rawCommand( 'FT.AGGREGATE', $args ); return $rawResult ? AggregationResult::makeAggregationResult( $rawResult, $documentsAsArray ) : new AggregationResult(0, []); } } ================================================ FILE: src/Aggregate/BuilderInterface.php ================================================ fieldNames = $fieldNames; $this->operationName = $operationName; } public function toArray(): array { return array_merge( [$this->operationName, count($this->fieldNames)], array_map(function ($fieldName) { return "@$fieldName"; }, $this->fieldNames) ); } } ================================================ FILE: src/Aggregate/Operations/Apply.php ================================================ expression = $expression; $this->asFieldName = $asFieldName; } public function toArray(): array { return ['APPLY', $this->expression, 'AS', $this->asFieldName]; } } ================================================ FILE: src/Aggregate/Operations/Filter.php ================================================ expression = $expression; } public function toArray(): array { return ['FILTER', $this->expression]; } } ================================================ FILE: src/Aggregate/Operations/GroupBy.php ================================================ offset = $offset; $this->pageSize = $pageSize; } public function toArray(): array { return ['LIMIT', $this->offset, $this->pageSize]; } } ================================================ FILE: src/Aggregate/Operations/Load.php ================================================ isAscending = $isAscending; $this->max = $max; } public function toArray(): array { $options = [ $this->isAscending ? 'ASC' : 'DESC' ]; $count = count($this->fieldNames) + count($options); if ($this->max >= 0) { $options[] = 'MAX'; $options[] = $this->max; } return $count > 0 ? array_merge( [$this->operationName, $count], array_map(function ($fieldName) { return "@$fieldName"; }, $this->fieldNames), $options ) : []; } } ================================================ FILE: src/Aggregate/Reducers/AbstractFieldNameReducer.php ================================================ fieldName = $fieldName; $this->alias = $alias; } public function toArray(): array { return []; } protected function makeAlias(): string { return empty($this->alias) ? strtolower($this->reducerKeyword) . "_" . $this->fieldName : $this->alias; } } ================================================ FILE: src/Aggregate/Reducers/Aliasable.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/Count.php ================================================ group = $group; } public function toArray(): array { return ['REDUCE', $this->reducerKeyword, $this->group, 'AS', empty($this->alias) ? 'count' : $this->alias]; } } ================================================ FILE: src/Aggregate/Reducers/CountDistinct.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/CountDistinctApproximate.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/FirstValue.php ================================================ byFieldName = $byFieldName; $this->isAscending = $isAscending; } public function toArray(): array { return is_null($this->byFieldName) ? ['REDUCE', $this->reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()] : ['REDUCE', $this->reducerKeyword, '4', $this->fieldName, 'BY', $this->byFieldName, $this->isAscending ? 'ASC' : 'DESC', 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/Max.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/Min.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/Quantile.php ================================================ quantile = $quantile; } public function toArray(): array { return ['REDUCE', $this->reducerKeyword, '2', $this->fieldName, $this->quantile, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/StandardDeviation.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/Sum.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/Aggregate/Reducers/ToList.php ================================================ reducerKeyword, '1', $this->fieldName, 'AS', $this->makeAlias()]; } } ================================================ FILE: src/CanBecomeArrayInterface.php ================================================ addOption('host', null, InputOption::VALUE_REQUIRED, 'Redis host', '127.0.0.1') ->addOption('port', 'p', InputOption::VALUE_REQUIRED, 'Redis port', 6379) ->addOption('password', 'a', InputOption::VALUE_REQUIRED, 'Redis password') ->addOption('adapter', null, InputOption::VALUE_REQUIRED, 'Redis adapter (predis, phpredis, redisclient)', 'predis'); } protected function createClient(InputInterface $input): RediSearchRedisClient { $host = $input->getOption('host'); $port = (int) $input->getOption('port'); $password = $input->getOption('password'); $adapter = strtolower($input->getOption('adapter')); try { $rawClient = match ($adapter) { 'predis' => new \Ehann\RedisRaw\PredisAdapter(), 'phpredis' => new \Ehann\RedisRaw\PhpRedisAdapter(), 'redisclient' => new \Ehann\RedisRaw\RedisClientAdapter(), default => throw new \InvalidArgumentException("Unknown adapter: $adapter. Use predis, phpredis, or redisclient."), }; } catch (\Error $e) { $hints = [ 'predis' => 'Install it with: composer require predis/predis', 'phpredis' => 'Requires the ext-redis PHP extension', 'redisclient' => 'Install it with: composer require cheprasov/php-redis-client', ]; throw new \RuntimeException( "Adapter '$adapter' is not available. " . ($hints[$adapter] ?? $e->getMessage()) ); } $rawClient->connect($host, $port, 0, $password); return new RediSearchRedisClient($rawClient); } protected function createIndex(InputInterface $input, string $indexName): Index { $client = $this->createClient($input); return (new Index($client, $indexName)); } protected function renderTable(OutputInterface $output, array $headers, array $rows): void { $table = new Table($output); $table->setHeaders($headers); $table->setRows($rows); $table->render(); } protected function renderJson(OutputInterface $output, mixed $data): void { $output->writeln(json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } } ================================================ FILE: src/Console/Application.php ================================================ registerCommands( new IndexCreateCommand(), new IndexDropCommand(), new IndexListCommand(), new IndexInfoCommand(), new DocumentAddCommand(), new DocumentGetCommand(), new DocumentDeleteCommand(), new SearchCommand(), new AggregateCommand(), new ExplainCommand(), new ProfileCommand(), new ShellCommand(), ); } private function registerCommands(Command ...$commands): void { foreach ($commands as $command) { if (method_exists($this, 'addCommand')) { $this->addCommand($command); // Symfony 8+ } else { $this->add($command); // Symfony <=7 } } } } ================================================ FILE: src/Console/Command/AggregateCommand.php ================================================ setName('aggregate') ->setDescription('Run an aggregation query on a RediSearch index') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('query', InputArgument::OPTIONAL, 'Search query', '*') ->addOption('group-by', null, InputOption::VALUE_REQUIRED, 'Group by field name') ->addOption('reduce', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Reduce function (func:field, e.g. avg:price, count)') ->addOption('sort-by', null, InputOption::VALUE_REQUIRED, 'Sort by field (field:ASC|DESC)') ->addOption('apply', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Apply expression (expression:alias)') ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter expression') ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit results (offset,count)') ->addOption('load', null, InputOption::VALUE_REQUIRED, 'Load fields (comma-separated)') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $query = $input->getArgument('query'); $index = $this->createIndex($input, $indexName); $builder = $index->makeAggregateBuilder(); // Load $load = $input->getOption('load'); if ($load !== null) { $builder->load(explode(',', $load)); } // Group by with reducers $groupBy = $input->getOption('group-by'); $reducers = $input->getOption('reduce'); if ($groupBy !== null) { if (empty($reducers)) { $builder->groupBy($groupBy); } else { $first = true; foreach ($reducers as $reducer) { $parts = explode(':', $reducer); $func = strtolower($parts[0]); $field = $parts[1] ?? null; if ($first) { $builder->groupBy($groupBy); $first = false; } match ($func) { 'avg' => $builder->avg($field), 'sum' => $builder->sum($field), 'min' => $builder->min($field), 'max' => $builder->max($field), 'count' => $builder->count(), 'count_distinct' => $builder->countDistinct($field), 'count_distinctish' => $builder->countDistinctApproximate($field), 'stddev' => $builder->standardDeviation($field), 'tolist' => $builder->toList($field), 'first_value' => $builder->firstValue($field), default => throw new \InvalidArgumentException("Unknown reducer: $func"), }; } } } // Sort by $sortBy = $input->getOption('sort-by'); if ($sortBy !== null) { $sortParts = explode(':', $sortBy); $builder->sortBy($sortParts[0], $sortParts[1] ?? 'ASC'); } // Apply foreach ($input->getOption('apply') as $apply) { $pos = strrpos($apply, ':'); if ($pos !== false) { $expression = substr($apply, 0, $pos); $alias = substr($apply, $pos + 1); $builder->apply($expression, $alias); } } // Filter $filter = $input->getOption('filter'); if ($filter !== null) { $builder->filter($filter); } // Limit $limit = $input->getOption('limit'); if ($limit !== null) { $limitParts = explode(',', $limit); if (count($limitParts) === 2) { $builder->limit((int) $limitParts[0], (int) $limitParts[1]); } } $result = $builder->search($query); $documents = $result->getDocuments(); $count = $result->getCount(); if ($input->getOption('json')) { $this->renderJson($output, [ 'count' => $count, 'documents' => $documents, ]); return self::SUCCESS; } $output->writeln("Aggregation returned $count result(s)."); if (empty($documents)) { return self::SUCCESS; } $first = $documents[0]; $headers = array_keys(is_array($first) ? $first : (array) $first); $rows = []; foreach ($documents as $doc) { $row = []; $docArray = is_array($doc) ? $doc : (array) $doc; foreach ($headers as $header) { $val = $docArray[$header] ?? ''; $row[] = is_array($val) ? json_encode($val) : (string) $val; } $rows[] = $row; } $this->renderTable($output, $headers, $rows); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/DocumentAddCommand.php ================================================ setName('document:add') ->setDescription('Add a document to a RediSearch index') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('id', InputArgument::REQUIRED, 'Document ID') ->addArgument('fields', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'Field values (field=value ...)') ->addOption('replace', null, InputOption::VALUE_NONE, 'Replace if document already exists') ->addOption('language', null, InputOption::VALUE_REQUIRED, 'Document language') ->addOption('score', null, InputOption::VALUE_REQUIRED, 'Document score (0.0-1.0)'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $docId = $input->getArgument('id'); $fieldArgs = $input->getArgument('fields'); $index = $this->createIndex($input, $indexName); $index->loadFields(); $document = $index->makeDocument($docId); $language = $input->getOption('language'); if ($language !== null) { $document->setLanguage($language); } $score = $input->getOption('score'); if ($score !== null) { $document->setScore((float) $score); } $schema = $index->getFields(); foreach ($fieldArgs as $fieldArg) { $pos = strpos($fieldArg, '='); if ($pos === false) { $output->writeln("Invalid field format: '$fieldArg'. Use field=value."); return self::FAILURE; } $name = substr($fieldArg, 0, $pos); $value = substr($fieldArg, $pos + 1); if (isset($schema[$name]) && $schema[$name] instanceof NumericField) { $value = is_numeric($value) ? (float) $value : $value; } $document->$name = $value; } if ($input->getOption('replace')) { $index->replace($document); } else { $index->add($document); } $output->writeln("Document '$docId' added to index '$indexName'."); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/DocumentDeleteCommand.php ================================================ setName('document:delete') ->setDescription('Delete a document from a RediSearch index') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('id', InputArgument::REQUIRED, 'Document ID'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $docId = $input->getArgument('id'); $index = $this->createIndex($input, $indexName); $deleted = $index->delete($docId); if ($deleted) { $output->writeln("Document '$docId' deleted from index '$indexName'."); } else { $output->writeln("Document '$docId' not found."); return self::FAILURE; } return self::SUCCESS; } } ================================================ FILE: src/Console/Command/DocumentGetCommand.php ================================================ setName('document:get') ->setDescription('Get a document by ID from Redis') ->addArgument('index', InputArgument::REQUIRED, 'Index name (used for connection context)') ->addArgument('id', InputArgument::REQUIRED, 'Document ID (full Redis key)') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $docId = $input->getArgument('id'); $client = $this->createClient($input); $result = $client->rawCommand('HGETALL', [$docId]); if (empty($result)) { $output->writeln("Document '$docId' not found."); return self::FAILURE; } $data = $this->parseHashResult($result); if ($input->getOption('json')) { $this->renderJson($output, $data); return self::SUCCESS; } $rows = []; foreach ($data as $field => $value) { $rows[] = [$field, (string) $value]; } $this->renderTable($output, ['Field', 'Value'], $rows); return self::SUCCESS; } private function parseHashResult(array $result): array { if (!array_is_list($result)) { return array_map(fn ($v) => (string) $v, $result); } $data = []; for ($i = 0; $i < count($result) - 1; $i += 2) { $data[(string) $result[$i]] = (string) $result[$i + 1]; } return $data; } } ================================================ FILE: src/Console/Command/ExplainCommand.php ================================================ setName('explain') ->setDescription('Show the execution plan for a query (FT.EXPLAIN)') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('query', InputArgument::REQUIRED, 'Search query'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $query = $input->getArgument('query'); $index = $this->createIndex($input, $indexName); $explanation = $index->explain($query); $output->writeln($explanation); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/IndexCreateCommand.php ================================================ setName('index:create') ->setDescription('Create a new RediSearch index from a JSON schema') ->addArgument('name', InputArgument::REQUIRED, 'Index name') ->addArgument('schema-file', InputArgument::REQUIRED, 'Path to JSON schema file') ->addOption('on', null, InputOption::VALUE_REQUIRED, 'Index type (HASH or JSON)', 'HASH') ->addOption('prefix', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Key prefix(es)') ->addOption('filter', null, InputOption::VALUE_REQUIRED, 'Filter expression') ->addOption('stopwords', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Stop words') ->addOption('maxtextfields', null, InputOption::VALUE_NONE, 'Allow more than 32 text fields') ->addOption('temporary', null, InputOption::VALUE_REQUIRED, 'TTL in seconds for temporary index') ->addOption('skipinitialscan', null, InputOption::VALUE_NONE, 'Skip scanning existing keys') ->addOption('nooffsets', null, InputOption::VALUE_NONE, 'Disable term offsets') ->addOption('nofields', null, InputOption::VALUE_NONE, 'Disable field flags') ->addOption('nofreqs', null, InputOption::VALUE_NONE, 'Disable term frequencies'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('name'); $schemaFile = $input->getArgument('schema-file'); $index = $this->createIndex($input, $indexName); $index->setIndexType($input->getOption('on')); $prefixes = $input->getOption('prefix'); if (!empty($prefixes)) { $index->setPrefixes($prefixes); } $filter = $input->getOption('filter'); if ($filter !== null) { $index->setFilter($filter); } $stopwords = $input->getOption('stopwords'); if (!empty($stopwords)) { $index->setStopWords($stopwords); } if ($input->getOption('maxtextfields')) { $index->setMaxTextFields(); } $temporary = $input->getOption('temporary'); if ($temporary !== null) { $index->setTemporary((int) $temporary); } if ($input->getOption('skipinitialscan')) { $index->setSkipInitialScan(); } $index->setNoOffsetsEnabled($input->getOption('nooffsets')); $index->setNoFieldsEnabled($input->getOption('nofields')); $index->setNoFrequenciesEnabled($input->getOption('nofreqs')); SchemaParser::applySchema($schemaFile, $index); $index->create(); $output->writeln("Index '$indexName' created successfully."); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/IndexDropCommand.php ================================================ setName('index:drop') ->setDescription('Drop a RediSearch index') ->addArgument('name', InputArgument::REQUIRED, 'Index name') ->addOption('delete-docs', null, InputOption::VALUE_NONE, 'Also delete all indexed documents'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('name'); $deleteDocs = $input->getOption('delete-docs'); $index = $this->createIndex($input, $indexName); $index->drop($deleteDocs); $output->writeln("Index '$indexName' dropped successfully."); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/IndexInfoCommand.php ================================================ setName('index:info') ->setDescription('Show information about a RediSearch index') ->addArgument('name', InputArgument::REQUIRED, 'Index name') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('name'); $index = $this->createIndex($input, $indexName); $info = $index->info(); if ($input->getOption('json')) { $this->renderJson($output, $this->normalizeInfo($info)); return self::SUCCESS; } $rows = $this->infoToRows($info); $this->renderTable($output, ['Property', 'Value'], $rows); return self::SUCCESS; } private function normalizeInfo(mixed $info): array { if (!is_array($info)) { return []; } if (!array_is_list($info)) { return array_map( fn ($v) => is_array($v) ? $v : (string) $v, $info ); } $result = []; for ($i = 0; $i < count($info) - 1; $i += 2) { $key = (string) $info[$i]; $result[$key] = is_array($info[$i + 1]) ? $info[$i + 1] : (string) $info[$i + 1]; } return $result; } private function infoToRows(mixed $info): array { $normalized = $this->normalizeInfo($info); $rows = []; foreach ($normalized as $key => $value) { if (is_array($value)) { $value = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } $rows[] = [$key, (string) $value]; } return $rows; } } ================================================ FILE: src/Console/Command/IndexListCommand.php ================================================ setName('index:list') ->setDescription('List all RediSearch indexes') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $index = $this->createIndex($input, '_cli_tmp'); $indexes = $index->listIndexes(); $indexes = array_map(fn ($i) => (string) $i, $indexes); if ($input->getOption('json')) { $this->renderJson($output, $indexes); return self::SUCCESS; } if (empty($indexes)) { $output->writeln('No indexes found.'); return self::SUCCESS; } $this->renderTable($output, ['Index Name'], array_map(fn ($i) => [$i], $indexes)); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/ProfileCommand.php ================================================ setName('profile') ->setDescription('Profile a search query (FT.PROFILE)') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('query', InputArgument::REQUIRED, 'Search query') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $query = $input->getArgument('query'); $client = $this->createClient($input); $result = $client->rawCommand('FT.PROFILE', [$indexName, 'SEARCH', 'QUERY', $query]); if ($input->getOption('json')) { $this->renderJson($output, $result); return self::SUCCESS; } $this->printProfileResult($output, $result); return self::SUCCESS; } private function printProfileResult(OutputInterface $output, mixed $result, int $depth = 0): void { $indent = str_repeat(' ', $depth); if (is_array($result)) { if (array_is_list($result)) { foreach ($result as $item) { $this->printProfileResult($output, $item, $depth); } } else { foreach ($result as $key => $value) { if (is_array($value)) { $output->writeln("{$indent}{$key}:"); $this->printProfileResult($output, $value, $depth + 1); } else { $output->writeln("{$indent}{$key}: " . (string) $value); } } } } else { $output->writeln($indent . (string) $result); } } } ================================================ FILE: src/Console/Command/SearchCommand.php ================================================ setName('search') ->setDescription('Search a RediSearch index') ->addArgument('index', InputArgument::REQUIRED, 'Index name') ->addArgument('query', InputArgument::REQUIRED, 'Search query') ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit results (offset,count)', '0,10') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'Sort by field (field:ASC|DESC)') ->addOption('fields', null, InputOption::VALUE_REQUIRED, 'Return only these fields (comma-separated)') ->addOption('highlight', null, InputOption::VALUE_REQUIRED, 'Highlight fields (comma-separated)') ->addOption('scores', null, InputOption::VALUE_NONE, 'Include relevance scores') ->addOption('verbatim', null, InputOption::VALUE_NONE, 'Disable stemming') ->addOption('language', null, InputOption::VALUE_REQUIRED, 'Stemming language') ->addOption('dialect', null, InputOption::VALUE_REQUIRED, 'Query dialect version') ->addOption('numeric-filter', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Numeric filter (field:min:max)') ->addOption('tag-filter', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Tag filter (field:val1,val2)') ->addOption('geo-filter', null, InputOption::VALUE_REQUIRED, 'Geo filter (field:lon:lat:radius:unit)') ->addOption('json', null, InputOption::VALUE_NONE, 'Output as JSON'); } protected function execute(InputInterface $input, OutputInterface $output): int { $indexName = $input->getArgument('index'); $query = $input->getArgument('query'); $index = $this->createIndex($input, $indexName); $builder = $index; // Limit $limit = $input->getOption('limit'); $parts = explode(',', $limit); if (count($parts) === 2) { $builder = $builder->limit((int) $parts[0], (int) $parts[1]); } // Sort $sort = $input->getOption('sort'); if ($sort !== null) { $sortParts = explode(':', $sort); $builder = $builder->sortBy($sortParts[0], $sortParts[1] ?? 'ASC'); } // Return fields $fields = $input->getOption('fields'); if ($fields !== null) { $builder = $builder->return(explode(',', $fields)); } // Highlight $highlight = $input->getOption('highlight'); if ($highlight !== null) { $builder = $builder->highlight(explode(',', $highlight)); } // Scores if ($input->getOption('scores')) { $builder = $builder->withScores(); } // Verbatim if ($input->getOption('verbatim')) { $builder = $builder->verbatim(); } // Language $language = $input->getOption('language'); if ($language !== null) { $builder = $builder->language($language); } // Dialect $dialect = $input->getOption('dialect'); if ($dialect !== null) { $builder = $builder->dialect((int) $dialect); } // Numeric filters foreach ($input->getOption('numeric-filter') as $nf) { $nfParts = explode(':', $nf); if (count($nfParts) >= 3) { $builder = $builder->numericFilter($nfParts[0], (float) $nfParts[1], (float) $nfParts[2]); } } // Tag filters foreach ($input->getOption('tag-filter') as $tf) { $pos = strpos($tf, ':'); if ($pos !== false) { $field = substr($tf, 0, $pos); $values = explode(',', substr($tf, $pos + 1)); $builder = $builder->tagFilter($field, $values); } } // Geo filter $geoFilter = $input->getOption('geo-filter'); if ($geoFilter !== null) { $gfParts = explode(':', $geoFilter); if (count($gfParts) >= 5) { $builder = $builder->geoFilter( $gfParts[0], (float) $gfParts[1], (float) $gfParts[2], (float) $gfParts[3], $gfParts[4] ); } } $result = $builder->search($query, true); $documents = $result->getDocuments(); $count = $result->getCount(); if ($input->getOption('json')) { $this->renderJson($output, [ 'count' => $count, 'documents' => $documents, ]); return self::SUCCESS; } $output->writeln("Found $count result(s)."); if (empty($documents)) { return self::SUCCESS; } $first = $documents[0]; $headers = array_keys(is_array($first) ? $first : (array) $first); $rows = []; foreach ($documents as $doc) { $row = []; $docArray = is_array($doc) ? $doc : (array) $doc; foreach ($headers as $header) { $val = $docArray[$header] ?? ''; $row[] = is_array($val) ? json_encode($val) : (string) $val; } $rows[] = $row; } $this->renderTable($output, $headers, $rows); return self::SUCCESS; } } ================================================ FILE: src/Console/Command/ShellCommand.php ================================================ setName('shell') ->setDescription('Start an interactive RediSearch shell'); } protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln('RediSearch Interactive Shell'); $output->writeln('Type "help" for available commands, "exit" to quit.'); $output->writeln('Use "use " to set a default index.'); $output->writeln(''); $globalOptions = [ '--host' => $input->getOption('host'), '--port' => $input->getOption('port'), '--adapter' => $input->getOption('adapter'), ]; $password = $input->getOption('password'); if ($password !== null) { $globalOptions['--password'] = $password; } while (true) { $prompt = $this->defaultIndex !== null ? "redisearch ({$this->defaultIndex})> " : 'redisearch> '; $line = readline($prompt); if ($line === false) { $output->writeln(''); break; } $line = trim($line); if ($line === '') { continue; } readline_add_history($line); if ($line === 'exit' || $line === 'quit') { $output->writeln('Goodbye.'); break; } if ($line === 'help') { $this->showHelp($output); continue; } if (str_starts_with($line, 'use ')) { $this->defaultIndex = trim(substr($line, 4)); $output->writeln("Default index set to '{$this->defaultIndex}'."); continue; } $tokens = $this->tokenize($line); if (empty($tokens)) { continue; } $commandName = array_shift($tokens); try { $app = $this->getApplication(); $command = $app->find($commandName); } catch (\Exception $e) { $output->writeln("Unknown command: $commandName"); continue; } $args = array_merge(['command' => $commandName], $globalOptions); $definition = $command->getDefinition(); // Inject default index for commands that need an 'index' or 'name' argument if ($this->defaultIndex !== null) { if ($definition->hasArgument('index') || $definition->hasArgument('name')) { $argName = $definition->hasArgument('index') ? 'index' : 'name'; $needsIndex = true; // Check if the user already provided the index in tokens foreach ($tokens as $token) { if (!str_starts_with($token, '-')) { $needsIndex = false; break; } } if ($needsIndex) { array_unshift($tokens, $this->defaultIndex); } } } // Parse remaining tokens as positional args and options $positionalArgs = []; $parsedOptions = []; $i = 0; while ($i < count($tokens)) { $token = $tokens[$i]; if (str_starts_with($token, '--')) { $eqPos = strpos($token, '='); if ($eqPos !== false) { $parsedOptions[substr($token, 0, $eqPos)] = substr($token, $eqPos + 1); } else { $optionName = $token; if (isset($tokens[$i + 1]) && !str_starts_with($tokens[$i + 1], '--')) { $parsedOptions[$optionName] = $tokens[$i + 1]; $i++; } else { $parsedOptions[$optionName] = true; } } } else { $positionalArgs[] = $token; } $i++; } // Map positional args to argument names $argDefinitions = $definition->getArguments(); $argIndex = 0; foreach ($argDefinitions as $argDef) { if ($argDef->getName() === 'command') { continue; } if ($argIndex < count($positionalArgs)) { if ($argDef->isArray()) { $args[$argDef->getName()] = array_slice($positionalArgs, $argIndex); break; } $args[$argDef->getName()] = $positionalArgs[$argIndex]; $argIndex++; } } $args = array_merge($args, $parsedOptions); try { $arrayInput = new ArrayInput($args); $arrayInput->setInteractive(false); $command->run($arrayInput, $output); } catch (\Exception $e) { $output->writeln('' . $e->getMessage() . ''); } $output->writeln(''); } return self::SUCCESS; } private function showHelp(OutputInterface $output): void { $output->writeln('Available commands:'); $output->writeln(' index:create Create an index from JSON schema'); $output->writeln(' index:drop Drop an index'); $output->writeln(' index:list List all indexes'); $output->writeln(' index:info Show index information'); $output->writeln(' document:add Add a document'); $output->writeln(' document:get Get a document'); $output->writeln(' document:delete Delete a document'); $output->writeln(' search Search an index'); $output->writeln(' aggregate [query] Aggregate query'); $output->writeln(' explain Explain query plan'); $output->writeln(' profile Profile a query'); $output->writeln(''); $output->writeln('Shell commands:'); $output->writeln(' use Set default index'); $output->writeln(' help Show this help'); $output->writeln(' exit / quit Exit the shell'); } /** * Tokenize input respecting quoted strings. */ private function tokenize(string $input): array { $tokens = []; $current = ''; $inQuote = null; $len = strlen($input); for ($i = 0; $i < $len; $i++) { $char = $input[$i]; if ($inQuote !== null) { if ($char === $inQuote) { $inQuote = null; } else { $current .= $char; } } elseif ($char === '"' || $char === "'") { $inQuote = $char; } elseif ($char === ' ') { if ($current !== '') { $tokens[] = $current; $current = ''; } } else { $current .= $char; } } if ($current !== '') { $tokens[] = $current; } return $tokens; } } ================================================ FILE: src/Console/SchemaParser.php ================================================ $index->addTextField( $name, (float) ($field['weight'] ?? 1.0), $sortable, $noindex ), 'NUMERIC' => $index->addNumericField($name, $sortable, $noindex), 'TAG' => $index->addTagField( $name, $sortable, $noindex, $field['separator'] ?? ',' ), 'GEO' => $index->addGeoField($name, $noindex), 'VECTOR' => $index->addVectorField( $name, $field['algorithm'] ?? 'FLAT', $field['vectorType'] ?? 'FLOAT32', (int) ($field['dim'] ?? 128), $field['distanceMetric'] ?? 'COSINE', $field['extraAttributes'] ?? [] ), default => throw new \RuntimeException("Unknown field type: $type"), }; } return $index; } } ================================================ FILE: src/Document/AbstractDocumentFactory.php ================================================ $field) { if ($field instanceof FieldInterface) { if (!in_array($field->getName(), array_keys($availableSchemaFields))) { throw new FieldNotInSchemaException($field->getName()); } $document->{$field->getName()} = $field; } elseif (is_string($index)) { if (!isset($availableSchemaFields[$index])) { throw new FieldNotInSchemaException($index); } $document->{$index} = ($field instanceof FieldInterface) ? $availableSchemaFields[$index]->setValue($field) : FieldFactory::make($index, $field); } } return $document; } } ================================================ FILE: src/Document/Document.php ================================================ id = $id ?? uniqid(true); } public function __set(string $name, FieldInterface $value): void { $this->fields[$name] = $value; } public function __get(string $name): ?FieldInterface { return $this->fields[$name] ?? null; } public function __isset(string $name): bool { return isset($this->fields[$name]); } protected function addFieldsToProperties($properties): array { /** @var FieldInterface $field */ foreach ($this->fields as $field) { if ($field instanceof FieldInterface && !is_null($field->getValue())) { $properties[] = $field->getName(); $properties[] = $field->getValue(); } } return $properties; } public function getHashDefinition(?array $prefixes = null): array { $id = $this->getId(); $completeId = !is_null($prefixes) && count($prefixes) > 0 ? $prefixes[0] . $id : $id; $properties = [ $completeId, '__score', $this->score, ]; if (!is_null($this->getLanguage())) { $properties[] = '__language'; $properties[] = $this->getLanguage(); } return $this->addFieldsToProperties($properties); } public function getDefinition(): array { $properties = [ $this->getId(), $this->getScore(), ]; if ($this->isNoSave()) { $properties[] = 'NOSAVE'; } if ($this->isReplace()) { $properties[] = 'REPLACE'; if ($this->isPartial()) { $properties[] = 'PARTIAL'; } if ($this->isNoCreate()) { $properties[] = 'NOCREATE'; } } if (!is_null($this->getLanguage())) { $properties[] = 'LANGUAGE'; $properties[] = $this->getLanguage(); } if (!is_null($this->getPayload())) { $properties[] = 'PAYLOAD'; $properties[] = $this->getPayload(); } $properties[] = 'FIELDS'; return $this->addFieldsToProperties($properties); } public function getId(): string { return $this->id; } public function setId(string $id) { $this->id = $id; return $this; } public function getScore(): float { return $this->score; } public function setScore(float $score) { if ($score < 0.0 || $score > 1.0) { throw new OutOfRangeDocumentScoreException(); } $this->score = $score; return $this; } public function isNoSave(): bool { return $this->noSave; } public function setNoSave(bool $noSave): Document { $this->noSave = $noSave; return $this; } public function isReplace(): bool { return $this->replace; } public function setReplace(bool $replace): Document { $this->replace = $replace; return $this; } public function isPartial(): bool { return $this->partial; } public function setPartial(bool $partial): Document { $this->partial = $partial; return $this; } public function isNoCreate(): bool { return $this->noCreate; } public function setNoCreate(bool $noCreate): Document { $this->noCreate = $noCreate; return $this; } public function getPayload() { return $this->payload; } public function setPayload($payload) { $this->payload = $payload; return $this; } public function getLanguage() { return $this->language; } public function setLanguage($language) { $this->language = $language; return $this; } } ================================================ FILE: src/Document/DocumentInterface.php ================================================ name = $name; $this->value = $value; } public function getName(): string { return $this->name; } public function getValue() { return $this->value; } public function setValue($value) { $this->value = $value; return $this; } public function getTypeDefinition(): array { return [ $this->getName(), $this->getType(), ]; } } ================================================ FILE: src/Fields/FieldFactory.php ================================================ setSeparator($tagSeparator); } if ($value instanceof Tag) { return new TagField($name, $value); } if (is_string($value)) { return new TextField($name, $value); } if (is_numeric($value)) { return new NumericField($name, $value); } if ($value instanceof GeoLocation) { return new GeoField($name, $value); } throw new InvalidArgumentException('There is no mapping field type between for the value.'); } } ================================================ FILE: src/Fields/FieldInterface.php ================================================ isNoindex()) { $properties[] = 'NOINDEX'; } return $properties; } } ================================================ FILE: src/Fields/GeoLocation.php ================================================ longitude = $longitude; $this->latitude = $latitude; } public function __toString() { return "{$this->longitude} {$this->latitude}"; } } ================================================ FILE: src/Fields/Noindex.php ================================================ isNoindex; } public function setNoindex(bool $noindex) { $this->isNoindex = $noindex; return $this; } } ================================================ FILE: src/Fields/NumericField.php ================================================ isSortable()) { $properties[] = 'SORTABLE'; } if ($this->isNoindex()) { $properties[] = 'NOINDEX'; } return $properties; } } ================================================ FILE: src/Fields/Sortable.php ================================================ isSortable; } public function setSortable(bool $sortable) { $this->isSortable = $sortable; return $this; } } ================================================ FILE: src/Fields/Tag.php ================================================ value = $value; } public function __toString() { return $this->value; } } ================================================ FILE: src/Fields/TagField.php ================================================ separator; } public function setSeparator(string $separator) { $this->separator = $separator; return $this; } public function getTypeDefinition(): array { $properties = parent::getTypeDefinition(); $properties[] = 'SEPARATOR'; $properties[] = $this->getSeparator(); if ($this->isSortable()) { $properties[] = 'SORTABLE'; } if ($this->isNoindex()) { $properties[] = 'NOINDEX'; } return $properties; } } ================================================ FILE: src/Fields/TextField.php ================================================ weight; } public function setWeight(float $weight) { $this->weight = $weight; return $this; } public function isNoStem(): bool { return $this->noStem; } public function setNoStem(bool $noStem): TextField { $this->noStem = $noStem; return $this; } public function getTypeDefinition(): array { $properties = parent::getTypeDefinition(); if ($this->isNoStem()) { $properties[] = 'NOSTEM'; } $properties[] = 'WEIGHT'; $properties[] = $this->getWeight(); if ($this->isSortable()) { $properties[] = 'SORTABLE'; } if ($this->isNoindex()) { $properties[] = 'NOINDEX'; } return $properties; } } ================================================ FILE: src/Fields/VectorField.php ================================================ addVectorField('embedding', VectorField::ALGORITHM_HNSW, VectorField::TYPE_FLOAT32, 128, VectorField::DISTANCE_COSINE); */ class VectorField extends AbstractField { public const ALGORITHM_FLAT = 'FLAT'; public const ALGORITHM_HNSW = 'HNSW'; public const TYPE_FLOAT32 = 'FLOAT32'; public const TYPE_FLOAT64 = 'FLOAT64'; public const DISTANCE_L2 = 'L2'; public const DISTANCE_IP = 'IP'; public const DISTANCE_COSINE = 'COSINE'; private string $algorithm; private string $type; private int $dim; private string $distanceMetric; private array $extraAttributes; public function __construct( string $name, string $algorithm = self::ALGORITHM_FLAT, string $type = self::TYPE_FLOAT32, int $dim = 128, string $distanceMetric = self::DISTANCE_COSINE, array $extraAttributes = [] ) { $validAlgorithms = [self::ALGORITHM_FLAT, self::ALGORITHM_HNSW]; if (!in_array($algorithm, $validAlgorithms, true)) { throw new \InvalidArgumentException("Invalid algorithm '$algorithm'. Expected one of: " . implode(', ', $validAlgorithms)); } $validTypes = [self::TYPE_FLOAT32, self::TYPE_FLOAT64]; if (!in_array($type, $validTypes, true)) { throw new \InvalidArgumentException("Invalid type '$type'. Expected one of: " . implode(', ', $validTypes)); } $validMetrics = [self::DISTANCE_L2, self::DISTANCE_IP, self::DISTANCE_COSINE]; if (!in_array($distanceMetric, $validMetrics, true)) { throw new \InvalidArgumentException("Invalid distance metric '$distanceMetric'. Expected one of: " . implode(', ', $validMetrics)); } if ($dim < 1) { throw new \InvalidArgumentException("Dimension must be >= 1, got $dim."); } parent::__construct($name); $this->algorithm = $algorithm; $this->type = $type; $this->dim = $dim; $this->distanceMetric = $distanceMetric; $this->extraAttributes = $extraAttributes; } public function getType(): string { return 'VECTOR'; } public function getTypeDefinition(): array { // Base attributes: TYPE, DIM, DISTANCE_METRIC (3 pairs = 6 values) $attributes = [ 'TYPE', $this->type, 'DIM', $this->dim, 'DISTANCE_METRIC', $this->distanceMetric, ]; // Flatten extra attributes (key => value pairs) foreach ($this->extraAttributes as $key => $value) { $attributes[] = strtoupper($key); $attributes[] = $value; } $attributeCount = count($attributes); return array_merge( [$this->getName(), 'VECTOR', $this->algorithm, $attributeCount], $attributes ); } public function getAlgorithm(): string { return $this->algorithm; } public function getDim(): int { return $this->dim; } public function getDistanceMetric(): string { return $this->distanceMetric; } } ================================================ FILE: src/Index.php ================================================ getIndexName()]; $properties[] = 'ON'; $properties[] = $this->indexType; if (!is_null($this->filter)) { $properties[] = 'FILTER'; $properties[] = $this->filter; } if ($this->maxTextFields) { $properties[] = 'MAXTEXTFIELDS'; } if (!is_null($this->temporary)) { $properties[] = 'TEMPORARY'; $properties[] = $this->temporary; } if ($this->skipInitialScan) { $properties[] = 'SKIPINITIALSCAN'; } if (!is_null($this->prefixes)) { $properties[] = 'PREFIX'; $properties[] = count($this->prefixes); $properties = array_merge($properties, $this->prefixes); } if ($this->isNoOffsetsEnabled()) { $properties[] = 'NOOFFSETS'; } if ($this->isNoFieldsEnabled()) { $properties[] = 'NOFIELDS'; } if ($this->isNoFrequenciesEnabled()) { $properties[] = 'NOFREQS'; } if (!is_null($this->stopWords)) { $properties[] = 'STOPWORDS'; $properties[] = count($this->stopWords); $properties = array_merge($properties, $this->stopWords); } $properties[] = 'SCORE_FIELD'; $properties[] = '__score'; $properties[] = 'LANGUAGE_FIELD'; $properties[] = '__language'; $properties[] = 'SCHEMA'; $fieldDefinitions = []; foreach ($this->getFields() as $field) { $fieldDefinitions = array_merge($fieldDefinitions, $field->getTypeDefinition()); } if (count($fieldDefinitions) === 0) { throw new NoFieldsInIndexException(); } return $this->rawCommand('FT.CREATE', array_merge($properties, $fieldDefinitions)); } /** * @return bool */ public function exists(): bool { try { $this->info(); return true; } catch (UnknownIndexNameException $exception) { return false; } } /** * @param string $name * @param FieldInterface $value * * @return void */ public function __set(string $name, FieldInterface $value): void { $this->fields[$name] = $value; } /** * @param string $name * * @return bool */ public function __isset(string $name): bool { return array_key_exists($name, $this->fields) !== false; } /** * @param string $name * * @return ?FieldInterface */ public function __get(string $name): ?FieldInterface { return $this->fields[$name] ?? null; } /** * @return array */ public function getFields(): array { return $this->fields; } /** * Returns an array of fields as cloned objects * * @return array */ public function getFieldsCloned(): array { return array_map(fn ($field) => clone $field, $this->fields); } /** * @param string $name * @param float $weight * @param bool $sortable * @param bool $noindex * @return IndexInterface */ public function addTextField(string $name, float $weight = 1.0, bool $sortable = false, bool $noindex = false): IndexInterface { $this->$name = (new TextField($name))->setSortable($sortable)->setNoindex($noindex)->setWeight($weight); return $this; } /** * @param string $name * @param bool $sortable * @param bool $noindex * @return IndexInterface */ public function addNumericField(string $name, bool $sortable = false, bool $noindex = false): IndexInterface { $this->$name = (new NumericField($name))->setSortable($sortable)->setNoindex($noindex); return $this; } /** * @param string $name * @param bool $noindex * @return IndexInterface */ public function addGeoField(string $name, bool $noindex = false): IndexInterface { $this->$name = (new GeoField($name))->setNoindex($noindex); return $this; } /** * @param string $name * @param bool $sortable * @param bool $noindex * @param string $separator * @return IndexInterface */ public function addTagField(string $name, bool $sortable = false, bool $noindex = false, string $separator = ','): IndexInterface { $this->$name = (new TagField($name))->setSortable($sortable)->setNoindex($noindex)->setSeparator($separator); return $this; } /** * Adds a VECTOR field to the index schema. Available in RediSearch v2.2+. * * @param string $name * @param string $algorithm FLAT or HNSW * @param string $type FLOAT32 or FLOAT64 * @param int $dim Number of vector dimensions * @param string $distanceMetric L2, IP, or COSINE * @param array $extraAttributes Additional algorithm-specific attributes (key => value pairs) * @return IndexInterface */ public function addVectorField( string $name, string $algorithm = VectorField::ALGORITHM_FLAT, string $type = VectorField::TYPE_FLOAT32, int $dim = 128, string $distanceMetric = VectorField::DISTANCE_COSINE, array $extraAttributes = [] ): IndexInterface { $this->$name = new VectorField($name, $algorithm, $type, $dim, $distanceMetric, $extraAttributes); return $this; } /** * @param string $name * @return array */ public function tagValues(string $name): array { return $this->rawCommand('FT.TAGVALS', [$this->getIndexName(), $name]); } /** * @param bool $deleteDocuments When true, also deletes all documents (hashes) associated with this index. * @return mixed */ public function drop(bool $deleteDocuments = false) { $arguments = [$this->getIndexName()]; if ($deleteDocuments) { $arguments[] = 'DD'; } return $this->rawCommand('FT.DROPINDEX', $arguments); } /** * @return mixed */ public function info() { return $this->rawCommand('FT.INFO', [$this->getIndexName()]); } /** * Loads field definitions from an existing RediSearch index by calling FT.INFO and * parsing the schema. This allows working with a pre-existing index without having * to manually re-define all fields on every instantiation. * * @return static */ public function loadFields(): static { $info = $this->info(); // FT.INFO returns either: // RESP2 – a flat [key, value, key, value, …] list (array_is_list === true) // RESP3 – an associative map keyed by string (array_is_list === false) // Handle both so the same code works regardless of the Redis client / protocol version. $attributes = null; if (!array_is_list($info)) { // RESP3: direct associative lookup (keys may be mixed-case). foreach ($info as $k => $v) { if (strtolower((string)$k) === 'attributes') { $attributes = $v; break; } } } else { // RESP2: iterate in pairs, casting to string to handle Predis Status objects. for ($i = 0; $i < count($info) - 1; $i += 2) { if ((string)$info[$i] === 'attributes') { $attributes = $info[$i + 1]; break; } } } if (!is_array($attributes)) { return $this; } foreach ($attributes as $attr) { $map = $this->parseAttributeDescriptor($attr); $name = (string)($map['attribute'] ?? $map['identifier'] ?? ''); if ($name === '' || str_starts_with($name, '__')) { continue; // skip internal fields like __score, __language } // Cast each flag to string to handle Predis Status objects. // Older Redis Stack puts SORTABLE/NOINDEX/NOSTEM inside a 'flags' sub-array; // newer versions represent them as standalone key-value pairs in the descriptor. // Check both forms for compatibility. $rawFlags = $map['flags'] ?? []; $flags = is_array($rawFlags) ? array_map(fn ($f) => strtoupper((string)$f), $rawFlags) : []; $sortable = in_array('SORTABLE', $flags, true) || array_key_exists('sortable', $map); $noindex = in_array('NOINDEX', $flags, true) || array_key_exists('noindex', $map); $type = strtoupper((string)($map['type'] ?? '')); $field = match ($type) { 'TEXT' => (new TextField($name)) ->setWeight((float)($map['weight'] ?? 1.0)) ->setSortable($sortable) ->setNoindex($noindex) ->setNoStem(in_array('NOSTEM', $flags, true) || array_key_exists('nostem', $map)), 'NUMERIC' => (new NumericField($name)) ->setSortable($sortable) ->setNoindex($noindex), 'TAG' => (new TagField($name)) ->setSeparator((string)($map['separator'] ?? ',')) ->setSortable($sortable) ->setNoindex($noindex), 'GEO' => (new GeoField($name)) ->setNoindex($noindex), 'VECTOR' => new VectorField( $name, strtoupper((string)($map['algorithm'] ?? VectorField::ALGORITHM_FLAT)), strtoupper((string)($map['data_type'] ?? VectorField::TYPE_FLOAT32)), (int)($map['dim'] ?? 128), strtoupper((string)($map['distance_metric'] ?? VectorField::DISTANCE_COSINE)), ), default => null, }; if ($field !== null) { $this->fields[$name] = $field; } } return $this; } /** * Converts an attribute descriptor from FT.INFO into an associative array with * lowercased keys. Handles two wire formats: * RESP2 – flat alternating [key, value, key, value, …] list * RESP3 – already an associative map (array_is_list === false) */ private function parseAttributeDescriptor(array $attr): array { if (!array_is_list($attr)) { // RESP3: already a map — just lowercase the keys. $map = []; foreach ($attr as $k => $v) { $map[strtolower((string)$k)] = $v; } return $map; } // RESP2: flat alternating [key, value, …] list. // Boolean flags (SORTABLE, NOSTEM, NOINDEX, UNF) are appended as standalone // elements after the key-value pairs with no paired value. This makes the // array odd-length for one flag, or even-length for two (where they would // mis-parse as a key-value pair). Handle both: // 1. Process the normal pairs. // 2. If count is odd, the last element is a standalone flag. // 3. Re-scan every element for known flag names and mark them explicitly // (catches the even-length multi-flag mis-pairing case). $map = []; $i = 0; $count = count($attr); while ($i < $count - 1) { $map[strtolower((string)$attr[$i])] = $attr[$i + 1]; $i += 2; } if ($count % 2 === 1) { $map[strtolower((string)$attr[$count - 1])] = true; } static $booleanFlags = ['sortable', 'unf', 'nostem', 'noindex']; foreach ($attr as $element) { $lower = strtolower((string)$element); if (in_array($lower, $booleanFlags, true)) { $map[$lower] = true; } } return $map; } /** * Deletes a document by its ID. In RediSearch v2.x documents are stored as Redis hashes, * so this deletes the underlying hash key, removing the document from the index. * * @param string $id The document ID. * @param bool $deleteDocument Kept for API compatibility; deletion always removes the hash in v2.x. * @return bool */ public function delete($id, $deleteDocument = false) { $key = $this->buildDocumentKey($id); return boolval($this->rawCommand('DEL', [$key])); } /** * @param null $id * @return DocumentInterface * @throws Exceptions\FieldNotInSchemaException */ public function makeDocument($id = null): DocumentInterface { $fields = $this->getFieldsCloned(); $document = AbstractDocumentFactory::makeFromArray($fields, $fields, $id); return $document; } /** * @return AggregateBuilderInterface */ public function makeAggregateBuilder(): AggregateBuilderInterface { return new AggregateBuilder($this->getRedisClient(), $this->getIndexName()); } /** * @return RediSearchRedisClient */ public function getRedisClient(): RediSearchRedisClient { return $this->redisClient; } /** * @param RediSearchRedisClient $redisClient * @return IndexInterface */ public function setRedisClient(RediSearchRedisClient $redisClient): IndexInterface { $this->redisClient = $redisClient; return $this; } /** * @return string */ public function getIndexName(): string { return !is_string($this->indexName) || $this->indexName === '' ? self::class : $this->indexName; } /** * @param string $indexName * @return IndexInterface */ public function setIndexName(string $indexName): IndexInterface { $this->indexName = $indexName; return $this; } /** * @return bool */ public function isNoOffsetsEnabled(): bool { return $this->noOffsetsEnabled; } /** * @param bool $noOffsetsEnabled * @return IndexInterface */ public function setNoOffsetsEnabled(bool $noOffsetsEnabled): IndexInterface { $this->noOffsetsEnabled = $noOffsetsEnabled; return $this; } /** * @return bool */ public function isNoFieldsEnabled(): bool { return $this->noFieldsEnabled; } /** * @param bool $noFieldsEnabled * @return IndexInterface */ public function setNoFieldsEnabled(bool $noFieldsEnabled): IndexInterface { $this->noFieldsEnabled = $noFieldsEnabled; return $this; } /** * @return bool */ public function isNoFrequenciesEnabled(): bool { return $this->noFrequenciesEnabled; } /** * @param bool $noFrequenciesEnabled * @return IndexInterface */ public function setNoFrequenciesEnabled(bool $noFrequenciesEnabled): IndexInterface { $this->noFrequenciesEnabled = $noFrequenciesEnabled; return $this; } /** * @param array $stopWords * @return IndexInterface */ public function setStopWords(array $stopWords = []): IndexInterface { $this->stopWords = $stopWords; return $this; } /** * Sets the key prefixes used in both FT.CREATE and document key construction. * * RediSearch supports multiple PREFIX alternatives (e.g. ['post:', 'blog:']) * so the index covers hashes under any of those prefixes. However, when writing * documents via add()/replace(), only the first prefix is used to construct the * hash key. Each prefix must include its own separator (e.g. 'post:', not 'post'). * * @param array $prefixes * @return IndexInterface */ public function setPrefixes(array $prefixes = []): IndexInterface { $this->prefixes = $prefixes; return $this; } /** * Sets the index data type. Use 'HASH' (default) or 'JSON' (requires RedisJSON module). * * @param string $type 'HASH' or 'JSON' * @return IndexInterface */ public function setIndexType(string $type): IndexInterface { $valid = ['HASH', 'JSON']; if (!in_array(strtoupper($type), $valid, true)) { throw new \InvalidArgumentException("Invalid index type '$type'. Expected one of: " . implode(', ', $valid)); } $this->indexType = strtoupper($type); return $this; } /** * Sets a filter expression applied to documents at index creation time. * Only documents for which the expression is true are indexed. * * @param string $expression RediSearch filter expression (e.g. '@age > 18') * @return IndexInterface */ public function setFilter(string $expression): IndexInterface { $this->filter = $expression; return $this; } /** * Enables MAXTEXTFIELDS, allowing more than the default 32 text attributes. * * @return IndexInterface */ public function setMaxTextFields(bool $enable = true): IndexInterface { $this->maxTextFields = $enable; return $this; } /** * Creates a temporary index that expires after the given number of seconds of inactivity. * * @param int $seconds TTL in seconds * @return IndexInterface */ public function setTemporary(int $seconds): IndexInterface { $this->temporary = $seconds; return $this; } /** * When enabled, the index is created without scanning existing keys. * Newly added/modified keys matching the prefix will still be indexed. * * @return IndexInterface */ public function setSkipInitialScan(bool $skip = true): IndexInterface { $this->skipInitialScan = $skip; return $this; } /** * @return QueryBuilder */ protected function makeQueryBuilder(): QueryBuilder { return (new QueryBuilder($this->redisClient, $this->getIndexName())); } /** * @param string $fieldName * @param array $values * @param array|null $charactersToEscape * @return QueryBuilderInterface */ public function tagFilter(string $fieldName, array $values, ?array $charactersToEscape = null): QueryBuilderInterface { return $this->makeQueryBuilder()->tagFilter($fieldName, $values, $charactersToEscape); } /** * @param string $fieldName * @param $min * @param $max * @return QueryBuilderInterface */ public function numericFilter(string $fieldName, $min, $max = null): QueryBuilderInterface { return $this->makeQueryBuilder()->numericFilter($fieldName, $min, $max); } /** * @param string $fieldName * @param float $longitude * @param float $latitude * @param float $radius * @param string $distanceUnit * @return QueryBuilderInterface */ public function geoFilter(string $fieldName, float $longitude, float $latitude, float $radius, string $distanceUnit = 'km'): QueryBuilderInterface { return $this->makeQueryBuilder()->geoFilter($fieldName, $longitude, $latitude, $radius, $distanceUnit); } /** * @param string $fieldName * @param $order * @return QueryBuilderInterface */ public function sortBy(string $fieldName, $order = 'ASC'): QueryBuilderInterface { return $this->makeQueryBuilder()->sortBy($fieldName, $order); } /** * @param string $scoringFunction * @return QueryBuilderInterface */ public function scorer(string $scoringFunction): QueryBuilderInterface { return $this->makeQueryBuilder()->scorer($scoringFunction); } /** * @param string $languageName * @return QueryBuilderInterface */ public function language(string $languageName): QueryBuilderInterface { return $this->makeQueryBuilder()->language($languageName); } /** * @param string $query * @return string */ public function explain(string $query): string { return $this->makeQueryBuilder()->explain($query); } /** * Sets the query dialect. Available in RediSearch v2.4+. * * @param int $version Dialect version (1, 2, or 3) * @return QueryBuilderInterface */ public function dialect(int $version): QueryBuilderInterface { return $this->makeQueryBuilder()->dialect($version); } /** * Sets named parameters for parameterized queries (e.g. vector KNN search). * Emits PARAMS {n} key1 val1 ... in FT.SEARCH. Requires DIALECT 2+. * * @param array $params Associative array of parameter names to values. * @return QueryBuilderInterface */ public function params(array $params): QueryBuilderInterface { return $this->makeQueryBuilder()->params($params); } /** * @param string $query * @param bool $documentsAsArray * @return SearchResult * @throws \Ehann\RedisRaw\Exceptions\RedisRawCommandException */ public function search(string $query = '', bool $documentsAsArray = false): SearchResult { return $this->makeQueryBuilder()->search($query, $documentsAsArray); } /** * @return QueryBuilderInterface */ public function noContent(): QueryBuilderInterface { return $this->makeQueryBuilder()->noContent(); } /** * @param int $offset * @param int $pageSize * @return QueryBuilderInterface */ public function limit(int $offset, int $pageSize = 10): QueryBuilderInterface { return $this->makeQueryBuilder()->limit($offset, $pageSize); } /** * @param int $number * @param array $fields * @return QueryBuilderInterface */ public function inFields(int $number, array $fields): QueryBuilderInterface { return $this->makeQueryBuilder()->inFields($number, $fields); } /** * @param int $number * @param array $keys * @return QueryBuilderInterface */ public function inKeys(int $number, array $keys): QueryBuilderInterface { return $this->makeQueryBuilder()->inKeys($number, $keys); } /** * @param int $slop * @return QueryBuilderInterface */ public function slop(int $slop): QueryBuilderInterface { return $this->makeQueryBuilder()->slop($slop); } /** * @return QueryBuilderInterface */ public function noStopWords(): QueryBuilderInterface { return $this->makeQueryBuilder()->noStopWords(); } /** * @return QueryBuilderInterface */ public function withPayloads(): QueryBuilderInterface { return $this->makeQueryBuilder()->withPayloads(); } /** * @return QueryBuilderInterface */ public function withScores(): QueryBuilderInterface { return $this->makeQueryBuilder()->withScores(); } /** * @return QueryBuilderInterface */ public function verbatim(): QueryBuilderInterface { return $this->makeQueryBuilder()->verbatim(); } /** * Builds the Redis key for a document, incorporating any configured prefix. * * Uses only the first configured prefix. RediSearch's PREFIX option accepts * multiple alternative prefixes (e.g. PREFIX 2 post: blog:), meaning the * index covers hashes under either prefix. When writing a document, a single * concrete prefix must be chosen — the first entry is used. Prefixes should * include their own separator (e.g. 'post:' not 'post'). */ private function buildDocumentKey(string $id): string { return !is_null($this->prefixes) && count($this->prefixes) > 0 ? $this->prefixes[0] . $id : $id; } /** * Core HSET operation — stores a document as a Redis hash. No existence checks. * Used internally by addMany() and addHash(). * * @param DocumentInterface $document * @return mixed */ protected function _add(DocumentInterface $document) { if (is_null($document->getId())) { $document->setId(uniqid(true)); } $properties = $document->getHashDefinition($this->prefixes); return $this->rawCommand('HSET', $properties); } /** * @param array $documents * @param bool $disableAtomicity * @param bool $replace Kept for API compatibility; HSET always upserts. */ public function addMany(array $documents, $disableAtomicity = false, $replace = false) { $result = null; $pipe = $this->redisClient->multi($disableAtomicity); foreach ($documents as $document) { if (is_array($document)) { $document = $this->arrayToDocument($document); } $this->_add($document); } try { $pipe->exec(); } catch (RedisException $exception) { $result = $exception->getMessage(); } catch (RawCommandErrorException $exception) { $result = $exception->getPrevious()->getMessage(); } if ($result) { $this->redisClient->validateRawCommandResults($result, 'PIPE', [$this->indexName, '*MANY']); } } /** * @param $document * @return DocumentInterface * @throws Exceptions\FieldNotInSchemaException */ public function arrayToDocument($document): DocumentInterface { return is_array($document) ? AbstractDocumentFactory::makeFromArray($document, $this->getFields()) : $document; } /** * Adds a new document to the index. Throws if the index does not exist or the * document ID already exists in Redis. * * @param $document * @return bool * @throws Exceptions\FieldNotInSchemaException * @throws DocumentAlreadyInIndexException * @throws UnsupportedRediSearchLanguageException */ public function add($document): bool { $typedDocument = $this->arrayToDocument($document); // Ensure the index exists — throws UnknownIndexNameException if not. $this->info(); // Validate language before storing. if (!is_null($typedDocument->getLanguage()) && !Language::isSupported($typedDocument->getLanguage())) { throw new UnsupportedRediSearchLanguageException(); } if (is_null($typedDocument->getId())) { $typedDocument->setId(uniqid(true)); } $key = $this->buildDocumentKey($typedDocument->getId()); if ($this->rawCommand('EXISTS', [$key])) { throw new DocumentAlreadyInIndexException($this->getIndexName(), $typedDocument->getId()); } return boolval($this->_add($typedDocument)); } /** * Updates (upserts) a document in the index using HSET. * * @param $document * @return bool * @throws Exceptions\FieldNotInSchemaException */ public function replace($document): bool { $this->_add($this->arrayToDocument($document)); return true; } /** * @param array $documents * @param bool $disableAtomicity */ public function replaceMany(array $documents, $disableAtomicity = false) { $this->addMany($documents, $disableAtomicity, true); } /** * Adds or replaces a document stored as a Redis hash. Upsert semantics (HSET). * * @param $document * @return bool * @throws Exceptions\FieldNotInSchemaException */ public function addHash($document): bool { $this->_add($this->arrayToDocument($document)); return true; } /** * Replaces a document stored as a Redis hash. Alias for addHash() — HSET always upserts. * * @param $document * @return bool * @throws Exceptions\FieldNotInSchemaException */ public function replaceHash($document): bool { $this->_add($this->arrayToDocument($document)); return true; } /** * @param array $fields * @return QueryBuilderInterface */ public function return(array $fields): QueryBuilderInterface { return $this->makeQueryBuilder()->return($fields); } /** * @param array $fields * @param int $fragmentCount * @param int $fragmentLength * @param string $separator * @return QueryBuilderInterface */ public function summarize(array $fields, int $fragmentCount = 3, int $fragmentLength = 50, string $separator = '...'): QueryBuilderInterface { return $this->makeQueryBuilder()->summarize($fields, $fragmentCount, $fragmentLength, $separator); } /** * @param array $fields * @param string $openTag * @param string $closeTag * @return QueryBuilderInterface */ public function highlight(array $fields, string $openTag = '', string $closeTag = ''): QueryBuilderInterface { return $this->makeQueryBuilder()->highlight($fields, $openTag, $closeTag); } /** * @param string $expander * @return QueryBuilderInterface */ public function expander(string $expander): QueryBuilderInterface { return $this->makeQueryBuilder()->expander($expander); } /** * @param string $payload * @return QueryBuilderInterface */ public function payload(string $payload): QueryBuilderInterface { return $this->makeQueryBuilder()->payload($payload); } /** * @param string $query * @return int */ public function count(string $query = ''): int { return $this->makeQueryBuilder()->count($query); } /** * @param string $name * @return bool */ public function addAlias(string $name): bool { return $this->rawCommand('FT.ALIASADD', [$name, $this->getIndexName()]); } /** * @param string $name * @return bool */ public function updateAlias(string $name): bool { return $this->rawCommand('FT.ALIASUPDATE', [$name, $this->getIndexName()]); } /** * @param string $name * @return bool */ public function deleteAlias(string $name): bool { return $this->rawCommand('FT.ALIASDEL', [$name]); } /** * Adds one or more fields to an existing index schema (FT.ALTER). * Existing documents are not re-indexed for new fields; only newly * added/updated documents will include the new fields. * * @param FieldInterface ...$fields * @return mixed */ public function alter(FieldInterface ...$fields): mixed { $args = [$this->getIndexName(), 'SCHEMA', 'ADD']; foreach ($fields as $field) { $args = array_merge($args, $field->getTypeDefinition()); } return $this->rawCommand('FT.ALTER', $args); } /** * Returns a list of all index names in the current Redis instance (FT._LIST). * * @return array */ public function listIndexes(): array { return $this->rawCommand('FT._LIST', []) ?? []; } /** * Creates or updates a synonym group with the given terms (FT.SYNUPDATE). * * @param string $synonymGroupId * @param string ...$terms * @return mixed */ public function synUpdate(string $synonymGroupId, string ...$terms): mixed { return $this->rawCommand('FT.SYNUPDATE', array_merge([$this->getIndexName(), $synonymGroupId], $terms)); } /** * Returns all synonym mappings for the index (FT.SYNDUMP). * * @return array */ public function synDump(): array { return $this->rawCommand('FT.SYNDUMP', [$this->getIndexName()]) ?? []; } /** * Performs spell checking on a query string (FT.SPELLCHECK). * Returns suggestions for misspelled terms. * * @param string $query * @param int $distance Maximum Levenshtein distance for suggestions (1–4) * @return array */ public function spellCheck(string $query, int $distance = 1): array { return $this->rawCommand('FT.SPELLCHECK', [$this->getIndexName(), $query, 'DISTANCE', $distance]) ?? []; } /** * Adds terms to a custom dictionary used by FT.SPELLCHECK (FT.DICTADD). * * @param string $dict Dictionary name * @param string ...$terms * @return int Number of terms added */ public function dictAdd(string $dict, string ...$terms): int { return (int) $this->rawCommand('FT.DICTADD', array_merge([$dict], $terms)); } /** * Removes terms from a custom dictionary (FT.DICTDEL). * * @param string $dict Dictionary name * @param string ...$terms * @return int Number of terms removed */ public function dictDelete(string $dict, string ...$terms): int { return (int) $this->rawCommand('FT.DICTDEL', array_merge([$dict], $terms)); } /** * Returns all terms in a custom dictionary (FT.DICTDUMP). * * @param string $dict Dictionary name * @return array */ public function dictDump(string $dict): array { return $this->rawCommand('FT.DICTDUMP', [$dict]) ?? []; } } ================================================ FILE: src/IndexInterface.php ================================================ redis = $redis; $this->indexName = $indexName; } public function noContent(): BuilderInterface { $this->noContent = 'NOCONTENT'; return $this; } public function return(array $fields): BuilderInterface { $count = empty($fields) ? 0 : count($fields); $field = implode(' ', $fields); $this->return = "RETURN $count $field"; return $this; } public function summarize(array $fields, int $fragmentCount = 3, int $fragmentLength = 50, string $separator = '...'): BuilderInterface { $count = empty($fields) ? 0 : count($fields); $field = implode(' ', $fields); $this->summarize = "SUMMARIZE FIELDS $count $field FRAGS $fragmentCount LEN $fragmentLength SEPARATOR $separator"; return $this; } public function highlight(array $fields, string $openTag = '', string $closeTag = ''): BuilderInterface { $count = empty($fields) ? 0 : count($fields); $field = implode(' ', $fields); $this->highlight = "HIGHLIGHT FIELDS $count $field TAGS $openTag $closeTag"; return $this; } public function expander(string $expander): BuilderInterface { $this->expander = "EXPANDER $expander"; return $this; } public function payload(string $payload): BuilderInterface { $this->payload = "PAYLOAD $payload"; return $this; } public function limit(int $offset, int $pageSize = 10): BuilderInterface { $this->limit = "LIMIT $offset $pageSize"; return $this; } public function inFields(int $number, array $fields): BuilderInterface { $this->inFields = "INFIELDS $number " . implode(' ', $fields); return $this; } public function inKeys(int $number, array $keys): BuilderInterface { $this->inKeys = "INKEYS $number " . implode(' ', $keys); return $this; } public function slop(int $slop): BuilderInterface { $this->slop = "SLOP $slop"; return $this; } public function noStopWords(): BuilderInterface { $this->noStopWords = 'NOSTOPWORDS'; return $this; } public function withPayloads(): BuilderInterface { $this->withPayloads = 'WITHPAYLOADS'; return $this; } public function withScores(): BuilderInterface { $this->withScores = 'WITHSCORES'; return $this; } public function verbatim(): BuilderInterface { $this->verbatim = 'VERBATIM'; return $this; } public function tagFilter(string $fieldName, array $values, ?array $charactersToEscape = null): BuilderInterface { if ($charactersToEscape == null) { $charactersToEscape = [' ', '-']; } $escapedValues = []; foreach ($values as $value) { $escapedValue = $value; foreach ($charactersToEscape as $character) { $escapedValue = str_replace($character, "\\$character", $escapedValue); } $escapedValues[] = $escapedValue; } $separatedValues = implode('|', $escapedValues); $this->tagFilters[] = "@$fieldName:{{$separatedValues}}"; return $this; } public function numericFilter(string $fieldName, $min, $max = null): BuilderInterface { $max = $max ?? '+inf'; $this->numericFilters[] = "@$fieldName:[$min $max]"; return $this; } public function geoFilter(string $fieldName, float $longitude, float $latitude, float $radius, string $distanceUnit = 'km'): BuilderInterface { if (!in_array($distanceUnit, self::GEO_FILTER_UNITS)) { throw new InvalidArgumentException($distanceUnit); } $this->geoFilters[] = "@$fieldName:[$longitude $latitude $radius $distanceUnit]"; return $this; } public function sortBy(string $fieldName, $order = 'ASC'): BuilderInterface { $this->sortBy = "SORTBY $fieldName $order"; return $this; } public function scorer(string $scoringFunction): BuilderInterface { $this->scorer = "SCORER $scoringFunction"; return $this; } public function language(string $languageName): BuilderInterface { $this->language = "LANGUAGE $languageName"; return $this; } /** * Sets the query dialect. Available in RediSearch v2.4+. * Dialect 2 enables fuzzy matching syntax, dialect 3 adds more operators. * * @param int $version Dialect version (1, 2, or 3) * @return BuilderInterface */ public function dialect(int $version): BuilderInterface { if (!in_array($version, [1, 2, 3], true)) { throw new \InvalidArgumentException("Invalid dialect version $version. Expected 1, 2, or 3."); } $this->dialect = "DIALECT $version"; return $this; } /** * Sets named parameters for parameterized queries (e.g. vector KNN queries). * Emits PARAMS {n} key1 val1 ... in the FT.SEARCH command. * Requires DIALECT 2 or higher. * * @param array $params Associative array of parameter names to values. * @return BuilderInterface */ public function params(array $params): BuilderInterface { $this->params = $params; return $this; } protected function explodeArgument(?string $argument): array { return explode(' ', $argument ?? ''); } protected function buildParamsArguments(): array { if (empty($this->params)) { return []; } $args = ['PARAMS', count($this->params) * 2]; foreach ($this->params as $key => $value) { $args[] = $key; $args[] = $value; } return $args; } public function makeSearchCommandArguments(string $query): array { $queryParts = array_merge([$query], $this->tagFilters, $this->numericFilters, $this->geoFilters); $queryWithFilters = "'" . trim(implode(' ', $queryParts)) . "'"; return array_filter( array_merge( trim($queryWithFilters) === '' ? [$this->indexName] : [$this->indexName, $queryWithFilters], $this->explodeArgument($this->limit), $this->explodeArgument($this->slop), [ $this->verbatim, $this->withScores, $this->withPayloads, $this->noStopWords, $this->noContent, ], $this->explodeArgument($this->inFields), $this->explodeArgument($this->inKeys), $this->explodeArgument($this->return), $this->explodeArgument($this->summarize), $this->explodeArgument($this->highlight), $this->explodeArgument($this->sortBy), $this->explodeArgument($this->scorer), $this->explodeArgument($this->language), $this->explodeArgument($this->expander), $this->explodeArgument($this->payload), $this->buildParamsArguments(), $this->explodeArgument($this->dialect), ), function ($item) { return !is_null($item) && $item !== ''; } ); } public function search(string $query = '', bool $documentsAsArray = false): SearchResult { $rawResult = $this->redis->rawCommand('FT.SEARCH', $this->makeSearchCommandArguments($query)); if (!$rawResult) { return new SearchResult(0, []); } if (is_array($rawResult) && count($rawResult) == 1) { return new SearchResult($rawResult[0], []); } return SearchResult::makeSearchResult( $rawResult, $documentsAsArray, $this->withScores !== '', $this->withPayloads !== '', $this->noContent !== '' ); } public function explain(string $query): string { return $this->redis->rawCommand('FT.EXPLAIN', $this->makeSearchCommandArguments($query)); } public function count(string $query = ''): int { return $this->limit(0, 0)->search($query)->getCount(); } } ================================================ FILE: src/Query/BuilderInterface.php ================================================ ', string $closeTag = ''): BuilderInterface; public function expander(string $expander): BuilderInterface; public function payload(string $payload): BuilderInterface; public function limit(int $offset, int $pageSize = 10): BuilderInterface; public function inFields(int $number, array $fields): BuilderInterface; public function inKeys(int $number, array $keys): BuilderInterface; public function slop(int $slop): BuilderInterface; public function noStopWords(): BuilderInterface; public function withPayloads(): BuilderInterface; public function withScores(): BuilderInterface; public function verbatim(): BuilderInterface; public function tagFilter(string $fieldName, array $values, ?array $charactersToEscape = null): BuilderInterface; public function numericFilter(string $fieldName, $min, $max = null): BuilderInterface; public function geoFilter(string $fieldName, float $longitude, float $latitude, float $radius, string $distanceUnit = 'km'): BuilderInterface; public function sortBy(string $fieldName, $order = 'ASC'): BuilderInterface; public function scorer(string $scoringFunction): BuilderInterface; public function language(string $languageName): BuilderInterface; public function dialect(int $version): BuilderInterface; public function params(array $params): BuilderInterface; public function search(string $query = '', bool $documentsAsArray = false): SearchResult; public function explain(string $query): string; public function count(string $query = ''): int; } ================================================ FILE: src/Query/SearchResult.php ================================================ count = $count; $this->documents = $documents; } public function getCount(): int { return $this->count; } public function getDocuments(): array { return $this->documents; } public static function makeSearchResult( array $rawRediSearchResult, bool $documentsAsArray, bool $withScores = false, bool $withPayloads = false, bool $noContent = false ) { $documentWidth = $noContent ? 1 : 2; if (!$rawRediSearchResult) { return false; } if (count($rawRediSearchResult) === 1) { return new SearchResult(0, []); } if ($withScores) { $documentWidth++; } if ($withPayloads) { $documentWidth++; } $count = array_shift($rawRediSearchResult); $documents = []; for ($i = 0; $i < count($rawRediSearchResult); $i += $documentWidth) { $document = $documentsAsArray ? [] : new \stdClass(); $documentsAsArray ? $document['id'] = $rawRediSearchResult[$i] : $document->id = $rawRediSearchResult[$i]; if ($withScores) { $documentsAsArray ? $document['score'] = $rawRediSearchResult[$i + 1] : $document->score = $rawRediSearchResult[$i + 1]; } if ($withPayloads) { $j = $withScores ? 2 : 1; $documentsAsArray ? $document['payload'] = $rawRediSearchResult[$i + $j] : $document->payload = $rawRediSearchResult[$i + $j]; } if (!$noContent) { $fields = $rawRediSearchResult[$i + ($documentWidth - 1)]; if (is_array($fields)) { for ($j = 0; $j < count($fields); $j += 2) { $documentsAsArray ? $document[$fields[$j]] = $fields[$j + 1] : $document->{$fields[$j]} = $fields[$j + 1]; } } } $documents[] = $document; } return new SearchResult($count, $documents); } } ================================================ FILE: src/RediSearchRedisClient.php ================================================ redis = $redis; } /** * @throws RediSearchException * @throws DocumentAlreadyInIndexException * @throws UnknownIndexNameException * @throws UnsupportedRediSearchLanguageException * @throws AliasDoesNotExistException * @throws UnknownRediSearchCommandException * @throws UnknownIndexNameOrNameIsAnAliasItselfException */ public function validateRawCommandResults($rawResult, string $command, array $arguments) { $isRawResultException = $rawResult instanceof Exception; $message = $isRawResultException ? $rawResult->getMessage() : $rawResult; if (!is_string($message)) { return; } $message = strtolower($message); if ($message === 'unknown index name') { throw new UnknownIndexNameException(); } if (in_array($message, ['no such language', 'unsupported language', 'unsupported stemmer language', 'bad argument for `language`'])) { throw new UnsupportedRediSearchLanguageException(); } if ($message === 'unknown index name (or name is an alias itself)') { throw new UnknownIndexNameOrNameIsAnAliasItselfException(); } if ($message === 'alias does not exist') { throw new AliasDoesNotExistException(); } if (strpos($message, 'err unknown command \'ft.') !== false) { throw new UnknownRediSearchCommandException($message); } if (in_array($message, ['document already in index', 'document already exists'])) { throw new DocumentAlreadyInIndexException($arguments[0], $arguments[1]); } throw new RediSearchException($rawResult); } public function connect($hostname = '127.0.0.1', $port = 6379, $db = 0, $password = null): RedisRawClientInterface { $this->redis->connect($hostname, $port, $db, $password); return $this; } public function flushAll() { $this->redis->flushAll(); } public function multi(bool $usePipeline = false) { return $this->redis->multi($usePipeline); } /** * @throws RediSearchException * @throws DocumentAlreadyInIndexException * @throws UnknownIndexNameException * @throws UnsupportedRediSearchLanguageException * @throws AliasDoesNotExistException * @throws UnknownRediSearchCommandException * @throws UnknownIndexNameOrNameIsAnAliasItselfException */ public function rawCommand(string $command, array $arguments = []) { try { foreach ($arguments as $index => $value) { /* The various RedisRaw clients have different expectations about arg types, but generally they all * agree that they can be strings. */ $arguments[$index] = strval($value); } $result = $this->redis->rawCommand($command, $arguments); } catch (RawCommandErrorException $exception) { $result = $exception->getPrevious()->getMessage(); } if ($command !== 'FT.EXPLAIN') { $this->validateRawCommandResults($result, $command, $arguments); } return $result; } public function setLogger(LoggerInterface $logger): RedisRawClientInterface { return $this->redis->setLogger($logger); } public function prepareRawCommandArguments(string $command, array $arguments): array { return $this->prepareRawCommandArguments($command, $arguments); } } ================================================ FILE: src/RuntimeConfiguration.php ================================================ rawCommand('FT.CONFIG', ['GET', $name]); } protected function setOption($name, $value) { return $this->rawCommand('FT.CONFIG', ['SET', $name, $value]); } protected function convertRawResponseToString(array $rawResponse): string { $value = $rawResponse[0][1]; if (is_object($value) && method_exists($value, 'getPayload')) { $value = $value->getPayload(); } return $value; } protected function convertRawResponseToInt($rawResponse): int { return intval($this->convertRawResponseToString($rawResponse)); } public function getMinPrefix(): int { return $this->convertRawResponseToInt($this->getOption('MINPREFIX')); } public function setMinPrefix(int $value = 2) { return $this->setOption('MINPREFIX', $value); } public function getMaxExpansions(): int { return $this->convertRawResponseToInt($this->getOption('MAXEXPANSIONS')); } public function setMaxExpansions(int $value = 200): bool { return $this->setOption('MAXEXPANSIONS', $value); } public function getTimeoutInMilliseconds(): int { return $this->convertRawResponseToInt($this->getOption('TIMEOUT')); } public function setTimeoutInMilliseconds(int $value = 500) { return $this->setOption('TIMEOUT', $value); } public function isOnTimeoutPolicyReturn(): bool { return $this->convertRawResponseToString($this->getOption('ON_TIMEOUT')) === 'return'; } public function isOnTimeoutPolicyFail(): bool { return $this->convertRawResponseToString($this->getOption('ON_TIMEOUT')) === 'fail'; } public function setOnTimeoutPolicyToReturn(): bool { return $this->setOption('ON_TIMEOUT', 'return'); } public function setOnTimeoutPolicyToFail(): bool { return $this->setOption('ON_TIMEOUT', 'fail'); } public function getMinPhoneticTermLength(): int { return $this->convertRawResponseToInt($this->getOption('MIN_PHONETIC_TERM_LEN')); } public function setMinPhoneticTermLength(int $value = 3): bool { return $this->setOption('MIN_PHONETIC_TERM_LEN', $value); } } ================================================ FILE: src/Suggestion.php ================================================ indexName, $string, $score ]; if ($increment) { $args[] = 'INCR'; } if (!is_null($payload)) { $args[] = 'PAYLOAD'; $args[] = $payload; } return $this->rawCommand('FT.SUGADD', $args); } /** * Delete a string from a suggestion index. * * @param string $string * @return bool */ public function delete(string $string): bool { return $this->rawCommand('FT.SUGDEL', [$this->indexName, $string]) === 1; } /** * Get the size of an auto-complete suggestion dictionary. * * @return int */ public function length(): int { return $this->rawCommand('FT.SUGLEN', [$this->indexName]); } /** * Get completion suggestions for a prefix. * * @param string $prefix * @param bool $fuzzy * @param bool $withPayloads * @param bool $withScores * @param int $max * @return array */ public function get(string $prefix, bool $fuzzy = false, bool $withPayloads = false, int $max = -1, bool $withScores = false): array { $args = [ $this->indexName, $prefix, ]; if ($fuzzy) { $args[] = 'FUZZY'; } if ($withPayloads) { $args[] = 'WITHPAYLOADS'; } if ($withScores) { $args[] = 'WITHSCORES'; } if ($max >= 0) { $args[] = 'MAX'; $args[] = $max; } return $this->rawCommand('FT.SUGGET', $args); } } ================================================ FILE: tests/RediSearch/Aggregate/AggregationResultTest.php ================================================ expectedDocuments = [ ['title' => 'part1'], ['title' => 'part2'], ]; $this->subject = new AggregationResult(count($this->expectedDocuments), $this->expectedDocuments); } public function testGetCount(): void { // Arrange $expected = count($this->expectedDocuments); // Act $result = $this->subject->getCount(); // Assert $this->assertSame($expected, $result); } public function testGetDocuments(): void { // Arrange — see setUp() // Act $result = $this->subject->getDocuments(); // Assert $this->assertSame($this->expectedDocuments, $result); } public function testMakeAggregationResultWithInvalidRedisResult(): void { // Arrange — no result data, invalid Redis response // Act $result = AggregationResult::makeAggregationResult([], false); // Assert $this->assertFalse($result); } } ================================================ FILE: tests/RediSearch/Aggregate/BuilderTest.php ================================================ indexName = 'AggregateBuilderTest'; $index = (new TestIndex($this->redisClient, $this->indexName)) ->addTextField('title', 1.0, true) ->addTextField('author', true) ->addNumericField('price', true) ->addNumericField('stock', true); $index->create(); $this->expectedResult1 = [ 'title' => 'How to be awesome.', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231, ]; $index->add($this->expectedResult1); $this->expectedResult2 = [ 'title' => 'How to be awesome, part 2 - Electric Boogaloo', 'author' => 'Jessica', 'price' => 18.85, 'stock' => 32, ]; $index->add($this->expectedResult2); $this->expectedResult3 = [ 'title' => 'How to be awesome, part 3, section 13, appendix A', 'author' => 'Jack', 'price' => 38.85, 'stock' => 32, ]; $index->add($this->expectedResult3); $this->expectedResult4 = [ 'title' => 'How to be awesome.', 'author' => 'Barry', 'price' => 19.99, 'stock' => 231, ]; $index->add($this->expectedResult4); $this->expectedResult4 = [ 'title' => 'How to be awesome.', 'author' => 'Lizzy', 'price' => 14.99, 'stock' => 10, ]; $index->add($this->expectedResult4); $this->subject = (new Builder($this->redisClient, $this->indexName)); } public function tearDown(): void { $this->redisClient->flushAll(); } public function testGetAverageOfNumeric(): void { // Arrange $expectedCount = 3; $expectedAveragePrice = 14.99; // Act $result = $this->subject ->groupBy('title') ->avg('price') ->search(); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertEquals($expectedAveragePrice, $result->getDocuments()[0]->avg_price); } public function testGetAggregationAsArray(): void { // Arrange $expectedCount = 3; $expectedAveragePrice = 14.99; // Act $result = $this->subject ->groupBy('title') ->avg('price') ->search('*', true); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertEquals($expectedAveragePrice, $result->getDocuments()[0]['avg_price']); } public function testGetGroupByAndReduce(): void { // Arrange $expectedCount = 3; $expectedAveragePrice = 14.99; // Act $result = $this->subject ->groupBy('title') ->reduce(new Avg('price')) ->search(); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertEquals($expectedAveragePrice, $result->getDocuments()[0]->avg_price); } public function testGetGroupByAndReduceAndFilter(): void { // Arrange $expectedCount = 2; $expectedAveragePrice = 18.85; // Act $result = $this->subject ->groupBy('author') ->reduce(new Avg('price')) ->filter('@author == "Jessica" || @author == "Jack"') ->search(); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertEquals($expectedAveragePrice, $result->getDocuments()[0]->avg_price); } public function testPipelineHasCommands(): void { // Arrange $this->subject ->groupBy('title') ->avg('price'); $this->subject->limit(0, 10); // Act $result = $this->subject->getPipeline(); // Assert $this->assertNotEmpty($result, 'Pipeline has no commands.'); } public function testClearPipeline(): void { // Arrange $this->subject ->groupBy('title') ->avg('price'); // Act $this->subject->clear(); // Assert $this->assertEmpty($this->subject->getPipeline(), 'Failed to clear pipeline.'); } public function testGetCount(): void { // Arrange $expected1 = 3; $expected2 = 1; $expected3 = 1; // Act $result = $this->subject ->groupBy('title') ->count(0) ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->count); $this->assertEquals($expected2, $result->getDocuments()[1]->count); $this->assertEquals($expected3, $result->getDocuments()[2]->count); } public function testGetCountDistinct(): void { // Arrange $expected1 = 1; $expected2 = 1; $expected3 = 1; // Act $result = $this->subject ->groupBy('title') ->countDistinct('title') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->count_distinct_title); $this->assertEquals($expected2, $result->getDocuments()[1]->count_distinct_title); $this->assertEquals($expected3, $result->getDocuments()[2]->count_distinct_title); } public function testGetCountDistinctWithReduceByField(): void { // Arrange $expected1 = 2; $expected2 = 1; // Act $result = $this->subject ->groupBy('stock') ->countDistinct('title') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->count_distinct_title); $this->assertEquals($expected2, $result->getDocuments()[1]->count_distinct_title); } public function testGetCountDistinctApproximate(): void { // Arrange $expected1 = 1; $expected2 = 1; $expected3 = 1; // Act $result = $this->subject ->groupBy('title') ->countDistinctApproximate('title') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->count_distinctish_title); $this->assertEquals($expected2, $result->getDocuments()[1]->count_distinctish_title); $this->assertEquals($expected3, $result->getDocuments()[2]->count_distinctish_title); } public function testGetSum(): void { // Arrange $expected1 = 472; $expected2 = 32; $expected3 = 32; // Act $result = $this->subject ->groupBy('title') ->sum('stock') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->sum_stock); $this->assertEquals($expected2, $result->getDocuments()[1]->sum_stock); $this->assertEquals($expected3, $result->getDocuments()[2]->sum_stock); } public function testGetMax(): void { // Arrange $expected1 = 19.99; // Act $result = $this->subject ->groupBy('title') ->max('price') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->max_price); } public function testGetMin(): void { // Arrange $expected1 = 9.99; // Act $result = $this->subject ->groupBy('title') ->min('price') ->search(); // Assert $this->assertEquals($expected1, $result->getDocuments()[0]->min_price); } public function testGetAbsoluteMin(): void { // Arrange $expected = 9.99; // Act $result = $this->subject ->groupBy() ->min('price') ->search(); // Assert $this->assertEquals($expected, $result->getDocuments()[0]->min_price); } public function testGetAbsoluteMax(): void { // Arrange $expected = 38.85; // Act $result = $this->subject ->groupBy() ->max('price') ->search(); // Assert $this->assertEquals($expected, $result->getDocuments()[0]->max_price); } public function testGetQuantile(): void { // Arrange $expected1 = 19.99; $expected2 = 18.85; $expected3 = 38.85; // Act $result = $this->subject ->groupBy('title') ->quantile('price', 1) ->search(); // Assert $documents = $result->getDocuments(); $this->assertEquals($expected1, $documents[0]->quantile_price); $this->assertEquals($expected2, $documents[1]->quantile_price); $this->assertEquals($expected3, $documents[2]->quantile_price); } public function testGetAbsoluteQuantile(): void { // Arrange $expected = 18.85; // Act $result = $this->subject ->groupBy() ->quantile('price', 0.5) ->search(); // Assert $this->assertEquals($expected, $result->getDocuments()[0]->quantile_price); } public function testGetStandardDeviation(): void { // Arrange $expected = 5; // Act $result = $this->subject ->groupBy('title') ->standardDeviation('price') ->search(); // Assert $this->assertEquals($expected, $result->getDocuments()[0]->stddev_price); } public function testSortByAscending(): void { // Arrange $expected1 = 'how to be awesome, part 2 - electric boogaloo'; $expected2 = 'how to be awesome, part 3, section 13, appendix a'; $expected3 = 'how to be awesome.'; // Act $result = $this->subject ->groupBy('title') ->sortBy('title') ->search(); // Assert $this->assertSame($expected1, $result->getDocuments()[0]->title); $this->assertSame($expected2, $result->getDocuments()[1]->title); $this->assertSame($expected3, $result->getDocuments()[2]->title); } public function testSortByDescending(): void { // Arrange $expected1 = 'how to be awesome.'; $expected2 = 'how to be awesome, part 3, section 13, appendix a'; $expected3 = 'how to be awesome, part 2 - electric boogaloo'; // Act $result = $this->subject ->groupBy('title') ->sortBy('title', false) ->search(); // Assert $this->assertSame($expected1, $result->getDocuments()[0]->title); $this->assertSame($expected2, $result->getDocuments()[1]->title); $this->assertSame($expected3, $result->getDocuments()[2]->title); } public function testSortByWithMax(): void { // Arrange $expected = 1; // Act $result = $this->subject ->groupBy('title') ->sortBy('title', true, $expected) ->search(); // Assert $this->assertSame($expected, $result->getCount()); } } ================================================ FILE: tests/RediSearch/Document/DocumentTest.php ================================================ getDefinition(); // Assert $this->assertCount($expectedNumberOfElements, $definition); $this->assertNotEmpty($definition[0]); $this->assertSame($expectedScore, $definition[1]); $this->assertSame('FIELDS', $definition[2]); } public function testShouldGetDefinitionWithOptions(): void { // Arrange $expectedNumberOfElements = 13; $expectedPayload = 'foo'; $isNoSave = true; $shouldReplace = true; $shouldPartial = true; $shouldNoCreate = true; $expectedId = '9999'; $expectedScore = 0.2; $expectedLanguage = 'EN'; $expectedFieldName = 'field name'; $expectedFieldValue = 'field value'; $subject = (new Document()) ->setNoSave($isNoSave) ->setId($expectedId) ->setLanguage($expectedLanguage) ->setPayload($expectedPayload) ->setReplace($shouldReplace) ->setPartial($shouldPartial) ->setNoCreate($shouldNoCreate) ->setScore($expectedScore); $subject->customField = FieldFactory::make($expectedFieldName, $expectedFieldValue); // Act $definition = $subject->getDefinition(); // Assert $this->assertCount($expectedNumberOfElements, $definition); $this->assertSame($expectedId, $definition[0]); $this->assertSame($expectedScore, $definition[1]); $this->assertSame('NOSAVE', $definition[2]); $this->assertSame('REPLACE', $definition[3]); $this->assertSame('PARTIAL', $definition[4]); $this->assertSame('NOCREATE', $definition[5]); $this->assertSame('LANGUAGE', $definition[6]); $this->assertSame($expectedLanguage, $definition[7]); $this->assertSame('PAYLOAD', $definition[8]); $this->assertSame($expectedPayload, $definition[9]); $this->assertSame('FIELDS', $definition[10]); $this->assertSame($expectedFieldName, $definition[11]); $this->assertSame($expectedFieldValue, $definition[12]); } public function testShouldThrowExceptionWhenScoreIsTooLow(): void { // Arrange $subject = new Document(); // Assert $this->expectException(OutOfRangeDocumentScoreException::class); // Act $subject->setScore(-0.1); } public function testShouldThrowExceptionWhenScoreIsTooHigh(): void { // Arrange $subject = new Document(); // Assert $this->expectException(OutOfRangeDocumentScoreException::class); // Act $subject->setScore(1.1); } } ================================================ FILE: tests/RediSearch/Exceptions/FieldNotInSchemaExceptionTest.php ================================================ getMessage(); // Assert $this->assertSame($expected, $message); } } ================================================ FILE: tests/RediSearch/Exceptions/NoFieldsInIndexExceptionTest.php ================================================ getMessage(); // Assert $this->assertSame($expected, $message); } } ================================================ FILE: tests/RediSearch/Exceptions/RedisRawCommandExceptionTest.php ================================================ getMessage(); // Assert $this->assertSame($expected, $message); } } ================================================ FILE: tests/RediSearch/Exceptions/UnknownIndexNameExceptionTest.php ================================================ getMessage(); // Assert $this->assertSame($expected, $message); } } ================================================ FILE: tests/RediSearch/Fields/FieldFactoryTest.php ================================================ expectException(InvalidArgumentException::class); // Act FieldFactory::make($unknownType, new \stdClass()); } } ================================================ FILE: tests/RediSearch/Fields/GeoFieldTest.php ================================================ getType(); // Assert $this->assertSame($expected, $type); } } ================================================ FILE: tests/RediSearch/Fields/GeoLocationTest.php ================================================ assertSame($expected, $actual); } } ================================================ FILE: tests/RediSearch/Fields/NumericFieldTest.php ================================================ getType(); // Assert $this->assertSame($expected, $type); } } ================================================ FILE: tests/RediSearch/Fields/TextFieldTest.php ================================================ subject = new TextField($this->fieldName); } public function testShouldGetCorrectType(): void { // Arrange — see setUp() // Act $type = $this->subject->getType(); // Assert $this->assertSame($this->fieldType, $type); } public function testShouldGetWeight(): void { // Arrange — see setUp() // Act $weight = $this->subject->getWeight(); // Assert $this->assertSame($this->defaultWeight, $weight); } public function testShouldSetWeight(): void { // Arrange $expected = 243.0; // Act $weight = $this->subject->setWeight($expected)->getWeight(); // Assert $this->assertSame($expected, $weight); } public function testShouldGetTypeDefinition(): void { // Arrange — see setUp() // Act $typeDefinition = $this->subject->getTypeDefinition(); // Assert $this->assertSame($this->fieldName, $typeDefinition[0]); $this->assertSame($this->fieldType, $typeDefinition[1]); $this->assertSame($this->weightKeyword, $typeDefinition[2]); $this->assertSame($this->defaultWeight, $typeDefinition[3]); } } ================================================ FILE: tests/RediSearch/IndexTest.php ================================================ indexName = 'ClientTest'; $this->subject = (new TestIndex($this->redisClient, $this->indexName)) ->addTextField('title') ->addTextField('author') ->addNumericField('price') ->addNumericField('stock') ->addGeoField('place') ->addTagField('color'); $this->logger->debug('setUp...'); } public function tearDown(): void { $this->redisClient->flushAll(); } public function testShouldFailToCreateIndexWhenThereAreNoFieldsDefined(): void { // Arrange $index = new IndexWithoutFields($this->redisClient, $this->indexName); // Assert $this->expectException(NoFieldsInIndexException::class); // Act $index->create(); } public function testShouldCreateIndex(): void { // Arrange — see setUp() // Act $result = $this->subject->create(); // Assert $this->assertTrue($result); } public function testShouldVerifyIndexExists(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->exists(); // Assert $this->assertTrue($result); } public function testShouldVerifyIndexDoesNotExist(): void { // Arrange — see setUp() // Act $result = $this->subject->exists(); // Assert $this->assertFalse($result); } public function testShouldDropIndex(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->drop(); // Assert $this->assertTrue($result); } public function testShouldGetInfo(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->info(); // Assert $this->assertTrue(is_array($result)); $this->assertTrue(count($result) > 0); } public function testShouldLoadFieldsFromExistingIndex(): void { // Arrange — create the full index (title, author, price, stock, place, color) $this->subject->create(); // Act — create a fresh Index with no fields defined and load from Redis $freshIndex = (new TestIndex($this->redisClient, $this->indexName))->loadFields(); // Assert — all six fields are loaded with correct types $fields = $freshIndex->getFields(); $this->assertArrayHasKey('title', $fields); $this->assertInstanceOf(TextField::class, $fields['title']); $this->assertArrayHasKey('author', $fields); $this->assertInstanceOf(TextField::class, $fields['author']); $this->assertArrayHasKey('price', $fields); $this->assertInstanceOf(NumericField::class, $fields['price']); $this->assertArrayHasKey('stock', $fields); $this->assertInstanceOf(NumericField::class, $fields['stock']); $this->assertArrayHasKey('place', $fields); $this->assertInstanceOf(GeoField::class, $fields['place']); $this->assertArrayHasKey('color', $fields); $this->assertInstanceOf(TagField::class, $fields['color']); } public function testLoadedFieldsCanBeUsedToMakeDocuments(): void { // Arrange $this->subject->create(); $freshIndex = (new TestIndex($this->redisClient, $this->indexName))->loadFields(); // Act — makeDocument() requires fields to be defined $document = $freshIndex->makeDocument('doc1'); $document->title->setValue('Test Book'); $result = $freshIndex->add($document); // Assert $this->assertTrue($result); } public function testLoadFieldsShouldNotIncludeInternalFields(): void { // Arrange $this->subject->create(); $freshIndex = new TestIndex($this->redisClient, $this->indexName); // Act $freshIndex->loadFields(); // Assert — only the 6 user-defined fields, no __score / __language $this->assertCount(6, $freshIndex->getFields()); $this->assertArrayNotHasKey('__score', $freshIndex->getFields()); $this->assertArrayNotHasKey('__language', $freshIndex->getFields()); } public function testLoadFieldsShouldRestoreTextFieldWeight(): void { // Arrange $indexName = 'LoadFieldsWeightTest'; (new TestIndex($this->redisClient, $indexName)) ->addTextField('title', 2.5) ->create(); $freshIndex = new TestIndex($this->redisClient, $indexName); // Act $freshIndex->loadFields(); // Assert $this->assertInstanceOf(TextField::class, $freshIndex->getFields()['title']); $this->assertEquals(2.5, $freshIndex->getFields()['title']->getWeight()); } public function testLoadFieldsShouldRestoreSortableFlag(): void { // Arrange $indexName = 'LoadFieldsSortableTest'; (new TestIndex($this->redisClient, $indexName)) ->addTextField('title', 1.0, true) ->create(); $freshIndex = new TestIndex($this->redisClient, $indexName); // Act $freshIndex->loadFields(); // Assert $this->assertTrue($freshIndex->getFields()['title']->isSortable()); } public function testLoadFieldsShouldRestoreTagFieldSeparator(): void { // Arrange $indexName = 'LoadFieldsSeparatorTest'; (new TestIndex($this->redisClient, $indexName)) ->addTagField('keywords', false, false, '|') ->create(); $freshIndex = new TestIndex($this->redisClient, $indexName); // Act $freshIndex->loadFields(); // Assert $this->assertInstanceOf(TagField::class, $freshIndex->getFields()['keywords']); $this->assertSame('|', $freshIndex->getFields()['keywords']->getSeparator()); } public function testLoadFieldsShouldReturnSelfForFluentChaining(): void { // Arrange $this->subject->create(); $freshIndex = new TestIndex($this->redisClient, $this->indexName); // Act $result = $freshIndex->loadFields(); // Assert $this->assertSame($freshIndex, $result); } public function testShouldDeleteDocumentById(): void { // Arrange $this->subject->create(); $expectedId = 'kasdoi13hflkhfdls'; $document = $this->subject->makeDocument($expectedId); $document->title->setValue('My New Book'); $document->author->setValue('Jack'); $document->price->setValue(123); $document->stock->setValue(1123); $this->subject->add($document); // Act $result = $this->subject->delete($expectedId); // Assert $this->assertTrue($result); $this->assertEmpty($this->subject->search('My New Book')->getDocuments()); } public function testShouldPhysicallyDeleteDocumentById(): void { // Arrange $this->subject->create(); $expectedId = 'fio4oihfohsdfl'; $document = $this->subject->makeDocument($expectedId); $document->title->setValue('My New Book'); $document->author->setValue('Jack'); $document->price->setValue(123); $document->stock->setValue(1123); $this->subject->add($document); // Act $result = $this->subject->delete($expectedId, true); // Assert $this->assertTrue($result); $this->assertEmpty($this->subject->search('My New Book')->getDocuments()); } public function testCreateIndexWithSortableFields(): void { // Arrange $indexName = 'IndexWithSortableFieldsTest'; $index = (new TestIndex($this->redisClient, $indexName)) ->addTextField('title', true) ->addTextField('author', true) ->addNumericField('price', true) ->addNumericField('stock', true); // Act $result = $index->create(); // Assert $this->assertTrue($result); } public function testCreateIndexWithNoindexFields(): void { // Arrange $indexName = 'IndexWithNoindexFields'; $index = (new TestIndex($this->redisClient, $indexName)) ->addTextField('title', true) ->addTextField('text_noindex', true, true) ->addNumericField('numeric_noindex', true) ->addGeoField('geo_noindex', true); // Act $result = $index->create(); // Assert $this->assertTrue($result); } public function testCreateIndexWithTagField(): void { // Arrange $indexName = 'IndexWithTag'; $index = (new TestIndex($this->redisClient, $indexName)) ->addTextField('title', true) ->addTagField('tagfield', true, false, ','); // Act $result = $index->create(); // Assert $this->assertTrue($result); } public function testGetTagValues(): void { // Arrange $expectedTagCount = 2; $blue = 'blue'; $red = 'red'; $this->subject->create(); $this->subject->add([ new TextField('title', 'How to be awesome.'), new TextField('author', 'Jack'), new NumericField('price', 9.99), new NumericField('stock', 231), new TagField('color', $red), ]); $this->subject->add([ new TextField('title', 'F.O.W.L'), new TextField('author', 'Jill'), new NumericField('price', 19.99), new NumericField('stock', 31), new TagField('color', $blue), ]); // Act $actual = $this->subject->tagValues('color'); // Assert $this->assertContains($blue, $actual); $this->assertContains($red, $actual); $this->assertSame($expectedTagCount, count($actual)); } public function testAddDocumentWithZeroScore(): void { // Arrange $this->subject->create(); $document = $this->subject->makeDocument(); $expectedTitle = 'Tale of Two Cities'; $document->title = FieldFactory::make('title', $expectedTitle); $expectedScore = 0.0; $document->setScore($expectedScore); $this->subject->add($document); // Act $result = $this->subject->withScores()->search($expectedTitle); // Assert $firstDocument = $result->getDocuments()[0]; $this->assertEquals($expectedScore, $firstDocument->score); $this->assertSame($expectedTitle, $firstDocument->title); } public function testAddDocumentWithNonDefaultScore(): void { // Arrange $this->subject->create(); $document = $this->subject->makeDocument(); $expectedTitle = 'Tale of Two Cities'; $document->title = FieldFactory::make('title', $expectedTitle); $document->setScore(0.9); $this->subject->add($document); // Act $result = $this->subject->withScores()->search($expectedTitle); // Assert $firstDocument = $result->getDocuments()[0]; $this->assertNotEquals(2.0, $firstDocument->score); $this->assertSame($expectedTitle, $firstDocument->title); } public function testAddDocumentUsingArrayOfFieldsCreatedWithFieldFactory(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->add([ FieldFactory::make('title', 'How to be awesome.'), FieldFactory::make('author', 'Jack'), FieldFactory::make('price', 9.99), FieldFactory::make('stock', 231), FieldFactory::make('place', new GeoLocation(-77.0366, 38.8977)), FieldFactory::make('color', new Tag('red')), ]); // Assert $this->assertTrue($result); } public function testAddDocumentUsingArrayOfFields(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->add([ new TextField('title', 'How to be awesome.'), new TextField('author', 'Jack'), new NumericField('price', 9.99), new NumericField('stock', 231), new TagField('color', 'red'), ]); // Assert $this->assertTrue($result); } public function testAddDocumentUsingAssociativeArrayOfValues(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->add([ 'title' => 'How to be awesome.', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231, ]); // Assert $this->assertTrue($result); } public function testAddDocument(): void { // Arrange $this->subject->create(); /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->stock->setValue(231); // Act $result = $this->subject->add($document); // Assert $this->assertTrue($result); } public function testAddDocumentWithUnsupportedLanguage(): void { // Arrange $this->subject->create(); $document = $this->subject->makeDocument(); $document->setLanguage('foo'); $document->title->setValue('How to be awesome.'); // Assert $this->expectException(UnsupportedRediSearchLanguageException::class); // Act $this->subject->add($document); } public function testSearchWithUnsupportedLanguage(): void { // Arrange $this->subject->create(); // Assert $this->expectException(UnsupportedRediSearchLanguageException::class); // Act $this->subject->language('foo')->search('bar'); } public function testAddDocumentToIndexWithAnUndefinedField(): void { // Arrange $this->subject->create(); // Assert $this->expectException(FieldNotInSchemaException::class); // Act $this->subject->add(['foo' => 'bar']); } public function testAddDocumentToUndefinedIndex(): void { // Arrange $index = new Index($this->redisClient); /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue('How to be awesome.'); // Assert $this->expectException(UnknownIndexNameException::class); // Act $result = $index->add($document); $this->assertFalse($result); } public function testAddDocumentAlreadyInIndex(): void { // Arrange $this->subject->create(); /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue('How to be awesome.'); $this->subject->add($document); // Assert $this->expectException(DocumentAlreadyInIndexException::class); // Act $result = $this->subject->add($document); $this->assertFalse($result); } public function testReplaceDocument(): void { // Arrange $this->subject->create(); /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->stock->setValue(231); $this->subject->add($document); $document->title->setValue('How to be awesome: Part 2.'); $document->price->setValue(19.99); // Act $isUpdated = $this->subject->replace($document); // Assert $result = $this->subject->numericFilter('price', 12.99)->search('Part 2'); $this->assertTrue($isUpdated); $this->assertSame(1, $result->getCount()); } public function testAddDocumentFromHash(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->addHash([ 'title' => 'How to be awesome', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231 ]); // Assert $this->assertTrue($result); } public function testFindDocumentAddedWithHash(): void { // Arrange $this->subject->create(); $title = 'How to be awesome'; $this->subject->addHash([ 'title' => 'How to be awesome', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231 ]); // Act $result = $this->subject->search($title); // Assert $this->assertSame(1, $result->getCount()); $this->assertSame($title, $result->getDocuments()[0]->title); } public function testReplaceDocumentFromHash(): void { // Arrange $this->subject->create(); $id = 'gooblegobble'; /** @var TestDocument $originalDocument */ $originalDocument = $this->subject->makeDocument($id); $originalDocument->title->setValue('How to be awesome.'); $originalDocument->author->setValue('Jack'); $originalDocument->price->setValue(9.99); $originalDocument->stock->setValue(231); $this->subject->add($originalDocument); /** @var TestDocument $hashDocument */ $hashDocument = $this->subject->makeDocument($id); $hashDocument->title->setValue('Farming For Fun'); $hashDocument->author->setValue('Fred'); $hashDocument->price->setValue(19.99); $hashDocument->stock->setValue(200); // Act $hasAdded = $this->subject->addHash($hashDocument); // Assert $this->assertTrue($hasAdded); $searchResult = $this->subject->search('Farming'); $this->assertSame($id, $searchResult->getDocuments()[0]->id); } public function testSearch(): void { // Arrange $this->subject->create(); $this->subject->add([ new TextField('title', 'How to be awesome: Part 1.'), new TextField('author', 'Jack'), ]); $this->subject->add([ new TextField('title', 'How to be awesome: Part 2.'), new TextField('author', 'Jack'), ]); // Act $result = $this->subject->search('awesome'); // Assert $this->assertSame(2, $result->getCount()); } public function testGetCountDirectly(): void { // Arrange $this->subject->create(); $this->subject->add([ new TextField('title', 'How to be awesome: Part 1.'), new TextField('author', 'Jack'), ]); $this->subject->add([ new TextField('title', 'How to be awesome: Part 2.'), new TextField('author', 'Jack'), ]); // Act $result = $this->subject->count('awesome'); // Assert $this->assertTrue($result === 2); } public function testSearchForNumeric(): void { // Arrange $this->subject->create(); $this->subject->add([ 'title' => 'How to be awesome.', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231, ]); // Act $result = $this->subject ->numericFilter('price', 1, 500) ->search('awesome'); // Assert $this->assertSame(1, $result->getCount()); } public function testAddDocumentWithGeoField(): void { // Arrange $index = (new TestIndex($this->redisClient)) ->setIndexName('GeoTest'); $index ->addTextField('name') ->addNumericField('population') ->addGeoField('place') ->create(); $index->add([ 'name' => 'Foo Bar', 'population' => 231, 'place' => new GeoLocation(-77.0366, 38.8977), ]); // Act $result = $index ->geoFilter('place', -77.0366, 38.897, 100) ->numericFilter('population', 1, 500) ->search('Foo'); // Assert $this->assertSame(1, $result->getCount()); } public function testAddDocumentWithTagField(): void { // Arrange $index = (new TestIndex($this->redisClient)) ->setIndexName('TagTest'); $index ->addTextField('name') ->addNumericField('population') ->addTagField('color') ->create(); $index->add(['name' => 'Foo', 'color' => 'red']); $index->add(['name' => 'Bar', 'color' => 'blue']); $index->add(['name' => 'Baz', 'color' => 'sky blue']); $index->add(['name' => 'Qux', 'color' => 'sugar-cookie']); // Act $result = $index ->tagFilter('color', ['sugar-cookie']) ->search(); // Assert $this->assertSame(1, $result->getCount()); } public function testAddDocumentWithTagFieldAndAlternateTagSeparator(): void { // Arrange $index = (new TestIndex($this->redisClient)) ->setIndexName('TagTest'); $index ->addTextField('name') ->addNumericField('population') ->addTagField('color', '^^^') ->create(); $index->add(['name' => 'Foo', 'color' => 'red']); $index->add(['name' => 'Bar', 'color' => 'blue']); // Act $result = $index ->tagFilter('color', ['blue']) ->search(); // Assert $this->assertSame(1, $result->getCount()); } public function testFilterTagFieldsAsUnionOfDocuments(): void { // Arrange $index = (new TestIndex($this->redisClient)) ->setIndexName('TagTest'); $index ->addTextField('name') ->addTagField('color') ->create(); $index->add(['name' => 'Foo', 'color' => 'red']); $index->add(['name' => 'Bar', 'color' => 'blue']); // Act $result = $index ->tagFilter('color', ['blue', 'red']) ->search(); // Assert $this->assertSame(2, $result->getCount()); } public function testFilterTagFieldsAsIntersectionOfDocuments(): void { // Arrange $index = (new TestIndex($this->redisClient)) ->setIndexName('TagTest'); $index ->addTextField('name') ->addTagField('color') ->create(); $index->add(['name' => 'Foo', 'color' => 'red']); $index->add(['name' => 'Bar', 'color' => 'blue']); $index->add(['name' => 'Bar', 'color' => 'red,yellow']); // Act $result = $index ->tagFilter('color', ['red']) ->tagFilter('color', ['yellow']) ->search(); // Assert $this->assertSame(1, $result->getCount()); } public function testAddDocumentWithCustomId(): void { // Arrange $this->subject->create(); $expectedId = '1'; /** @var TestDocument $document */ $document = $this->subject->makeDocument($expectedId); $document->title->setValue('How to be awesome.'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->stock->setValue(231); // Act $isDocumentAdded = $this->subject->add($document); $result = $this->subject->search('How to be awesome.'); // Assert $this->assertTrue($isDocumentAdded); $this->assertSame(1, $result->getCount()); $this->assertSame($expectedId, $result->getDocuments()[0]->id); } public function testBatchIndexWithAdd(): void { // Arrange $this->subject->create(); $expectedDocumentCount = 10; $documents = $this->makeDocuments(); $expectedCount = count($documents); // Act $start = microtime(true); foreach ($documents as $document) { $this->subject->add($document); } print 'Batch insert time: ' . round(microtime(true) - $start, 4) . PHP_EOL; $result = $this->subject->search('How to be awesome.'); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($expectedDocumentCount, count($result->getDocuments())); } public function testBatchIndexWithAddMany(): void { // Arrange $this->subject->create(); $expectedDocumentCount = 10; $documents = $this->makeDocuments(); $expectedCount = count($documents); // Act $start = microtime(true); $this->subject->addMany($documents); print 'Batch insert time: ' . round(microtime(true) - $start, 4) . PHP_EOL; $result = $this->subject->search('How to be awesome.'); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($expectedDocumentCount, count($result->getDocuments())); } #[PHPUnit\Framework\Attributes\RequiresPhpExtension('redis')] public function testBatchIndexWithAddManyUsingPhpRedisWithAtomicityDisabled(): void { // Arrange if (!$this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is not configured to use PhpRedis.'); } $rediSearchRedisClient = new RediSearchRedisClient($this->makePhpRedisAdapter()); $rediSearchRedisClient->setLogger($this->logger); $this->subject ->setRedisClient($rediSearchRedisClient) ->create(); $expectedDocumentCount = 10; $documents = $this->makeDocuments(); $expectedCount = count($documents); // Act $start = microtime(true); $this->subject->addMany($documents, true); print 'Batch insert time: ' . round(microtime(true) - $start, 4) . PHP_EOL; $result = $this->subject->search('How to be awesome.'); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($expectedDocumentCount, count($result->getDocuments())); } private function makeDocuments($count = 3000): array { $documents = []; foreach (range(1, $count) as $id) { $document = $this->subject->makeDocument($id); $document->title->setValue('How to be awesome.'); $documents[] = $document; } return $documents; } public function testShouldCreateIndexWithImplicitName(): void { // Arrange $bookIndex = new Index($this->redisClient); // Act $result1 = $bookIndex->addTextField('title')->create(); $result2 = $bookIndex->add([ new TextField('title', 'Tale of Two Cities'), ]); // Assert $this->assertTrue($result1); $this->assertTrue($result2); } public function testSetStopWordsOnCreateIndex(): void { // Arrange $this->subject->setStopWords(['Awesome'])->create(); /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue('Awesome'); $document->author->setValue('Jack'); $document->price->setValue(9.99); $document->stock->setValue(231); $isDocumentAdded = $this->subject->add($document); // Act $resultForStopWord = $this->subject->search('Awesome'); $resultForNonStopWord = $this->subject->search('Jack'); // Assert $this->assertTrue($isDocumentAdded); $this->assertSame(0, $resultForStopWord->getCount()); $this->assertSame(1, $resultForNonStopWord->getCount()); } public function testShouldNotSearchEveryIndexWhenAPrefixIsSpecified(): void { // Arrange $expectedFirstResult = 'Jack'; $firstPrefix = 'Foo'; $secondPrefix = 'Bar'; $firstIndex = (new Index($this->redisClient, 'first')) ->setPrefixes([$firstPrefix]) ->addTextField('name'); $firstIndex->create(); $firstIndex->addHash(['name' => $expectedFirstResult]); $secondIndex = (new Index($this->redisClient, 'second')) ->setPrefixes([$secondPrefix]) ->addTextField('name'); $secondIndex->create(); // Act $firstResult = $firstIndex->search($expectedFirstResult); $secondResult = $secondIndex->search($expectedFirstResult); // Assert $this->assertSame(1, $firstResult->getCount()); $this->assertSame(0, $secondResult->getCount()); $this->assertSame($expectedFirstResult, $firstResult->getDocuments()[0]->name); } public function testShouldSearchEveryIndexWhenAPrefixIsNotSpecified(): void { // Arrange $expectedDocuments = 1; $expectedName = 'Jack'; $firstIndex = (new Index($this->redisClient, 'first'))->addTextField('name'); $firstIndex->create(); $firstIndex->add([new TextField('name', $expectedName)]); $secondIndex = (new Index($this->redisClient, 'second'))->addTextField('name'); $secondIndex->create(); // Act $firstResult = $firstIndex->search($expectedName); $secondResult = $secondIndex->search($expectedName); // Assert $this->assertSame($expectedDocuments, $firstResult->getCount()); $this->assertSame($expectedDocuments, $secondResult->getCount()); $this->assertSame($expectedName, $firstResult->getDocuments()[0]->name); $this->assertSame($expectedName, $secondResult->getDocuments()[0]->name); } public function testShouldCreateIndexWithNoFrequencies(): void { // Arrange $this->subject->setNoFrequenciesEnabled(true)->create(); $expected = 'NOFREQS'; // Act $info = $this->subject->info(); // Assert $this->assertEquals($expected, $info[3][0]); } public function testShouldNotChangeOriginalSchemaFieldWhenAddingNewDocument(): void { // Arrange $expectedId = 'id1'; $expectedTitle = 'Foo'; $documents = []; $newDocument = $this->subject->makeDocument(); $newDocument->setId($expectedId); $newDocument->title->setValue($expectedTitle); $documents[] = $newDocument; $barDocument = $this->subject->makeDocument(); $barDocument->setId('id2'); $barDocument->title->setValue('Bar'); // Act — verify first document is unaffected by creation of second document $actualId = $documents[0]->getId(); $actualTitle = $documents[0]->title->getValue(); // Assert $this->assertSame($expectedId, $actualId); $this->assertSame($expectedTitle, $actualTitle); } public function testShouldCreateAlias(): void { // Arrange $this->subject->create(); // Act $result = $this->subject->addAlias('MyAlias'); // Assert $this->assertTrue($result); } public function testShouldUpdateAlias(): void { // Arrange $this->subject->create(); $this->subject->addAlias('MyAlias'); $index = (new Index($this->redisClient, 'Second')) ->addTextField('foo'); $index->create(); // Act $result = $index->updateAlias('MyAlias'); // Assert $this->assertTrue($result); } public function testShouldDeleteAlias(): void { // Arrange $this->subject->create(); $this->subject->addAlias('MyAlias'); // Act $result = $this->subject->deleteAlias('MyAlias'); // Assert $this->assertTrue($result); } public function testShouldFailToCreateAliasIfIndexDoesNotExist(): void { // Arrange — see setUp(), index not yet created // Assert $this->expectException(UnknownIndexNameOrNameIsAnAliasItselfException::class); // Act $this->subject->addAlias('MyAlias'); } public function testShouldFailToUpdateAliasIfIndexDoesNotExist(): void { // Arrange — see setUp(), index not yet created // Assert $this->expectException(UnknownIndexNameOrNameIsAnAliasItselfException::class); // Act $this->subject->updateAlias('MyAlias'); } public function testShouldFailToDeleteAliasIfIndexDoesNotExist(): void { // Arrange — see setUp(), index not yet created // Assert $this->expectException(AliasDoesNotExistException::class); // Act $this->subject->deleteAlias('MyAlias'); } public function testShouldGetFields(): void { // Arrange $this->subject->create(); $expectedTitle = 'title TEXT WEIGHT 1'; $expectedAuthor = 'author TEXT WEIGHT 1'; $expectedPrice = 'price NUMERIC'; $expectedStock = 'stock NUMERIC'; $expectedPlace = 'place GEO'; $expectedColor = 'color TAG SEPARATOR ,'; // Act $fields = $this->subject->getFields(); // Assert $this->assertSame($expectedTitle, implode(' ', $fields['title']->getTypeDefinition())); $this->assertSame($expectedAuthor, implode(' ', $fields['author']->getTypeDefinition())); $this->assertSame($expectedPrice, implode(' ', $fields['price']->getTypeDefinition())); $this->assertSame($expectedStock, implode(' ', $fields['stock']->getTypeDefinition())); $this->assertSame($expectedPlace, implode(' ', $fields['place']->getTypeDefinition())); $this->assertSame($expectedColor, implode(' ', $fields['color']->getTypeDefinition())); } public function testShouldConvertAnArrayToDocument(): void { // Arrange $title = 'Your Honor'; $arr = ['title' => $title]; /** @var TestDocument $document */ $document = $this->subject->makeDocument(); $document->title->setValue($title); // Act /** @var TestDocument $documentFromArray */ $documentFromArray = $this->subject->arrayToDocument($arr); // Assert $this->assertSame($title, $documentFromArray->title->getValue()); $this->assertSame($title, $document->title->getValue()); $this->assertSame(json_encode($document->title), json_encode($documentFromArray->title)); } } ================================================ FILE: tests/RediSearch/Query/BuilderTest.php ================================================ indexName = 'QueryBuilderTest'; $index = (new TestIndex($this->redisClient, $this->indexName)) ->addTextField('title') ->addTextField('author') ->addNumericField('price') ->addNumericField('stock') ->addGeoField('location') ->addTextField('private', 1.0, false, true); $index->create(); $index->makeDocument(); $this->expectedResult1 = [ 'title' => 'How to be awesome.', 'author' => 'Jack', 'price' => 9.99, 'stock' => 231, 'location' => new GeoLocation(10.9190500, 52.0504100), ]; $index->add($this->expectedResult1); $this->expectedResult2 = [ 'title' => 'Shoes in the 22nd Century', 'author' => 'Jessica', 'price' => 18.85, 'stock' => 32, 'location' => new GeoLocation(50.9190500, 4.0504100), ]; $index->add($this->expectedResult2); $this->expectedResult3 = [ 'title' => 'How to be awesome, part 2, section 13, appendix A', 'author' => 'Jack', 'price' => 18.95, 'stock' => 11, 'location' => new GeoLocation(10.9190500, 52.0504100), 'private' => 'classified' ]; $index->add($this->expectedResult3); $this->subject = (new Builder($this->redisClient, $this->indexName)); } public function tearDown(): void { $this->redisClient->flushAll(); } public function testSearch(): void { // Arrange — see setUp() // Act $result = $this->subject->search('Shoes'); // Assert $this->assertTrue($result->getCount() === 1); } public function testGetCountDirectly(): void { // Arrange — see setUp() // Act $result = $this->subject->count('Shoes'); // Assert $this->assertTrue($result === 1); } public function testReturnsZeroResultsWhenNotIndexed(): void { // Arrange — see setUp() // Act $result = $this->subject->search('classified'); // Assert $this->assertTrue($result->getCount() === 0); } public function testSearchWithReturn(): void { // Arrange $expectedAuthor = 'Jessica'; // Act $result = $this->subject->return(['author'])->search('Shoes'); // Assert $firstResult = $result->getDocuments()[0]; $this->assertSame($expectedAuthor, $firstResult->author); $this->assertTrue(property_exists($firstResult, 'author')); $this->assertFalse(property_exists($firstResult, 'title')); } public function testSearchWithSummarize(): void { // Arrange $expectedTitle = 'Shoes in the 22nd...'; // Act $result = $this->subject->summarize(['title', 'author'])->search('Shoes'); // Assert $firstResult = $result->getDocuments()[0]; $this->assertSame($expectedTitle, $firstResult->title); } public function testSearchWithHighlight(): void { // Arrange $expectedTitle = 'Shoes in the 22nd Century'; // Act $result = $this->subject->highlight(['title', 'author'])->search('Shoes'); // Assert $firstResult = $result->getDocuments()[0]; $this->assertSame($expectedTitle, $firstResult->title); } public function testSearchWithScores(): void { // Arrange — see setUp() // Act $result = $this->subject->withScores()->search('Shoes'); // Assert $this->assertTrue($result->getCount() === 1); $this->assertTrue(property_exists($result->getDocuments()[0], 'score')); } public function testSearchWithPayloads(): void { // Arrange — see setUp() // Act $result = $this->subject->withPayloads()->search('Shoes'); // Assert $this->assertSame(1, $result->getCount()); $this->assertTrue(property_exists($result->getDocuments()[0], 'payload')); } public function testVerbatimSearch(): void { // Arrange — see setUp() // Act $result = $this->subject->verbatim()->search('Shoes in the 22nd Century'); // Assert $this->assertSame(1, $result->getCount()); } public function testVerbatimSearchFails(): void { // Arrange — see setUp() // Act $result = $this->subject->verbatim()->search('Shoess'); // Assert $this->assertSame(0, $result->getCount()); } public function testNumericRangeQuery(): void { // Arrange $expectedCount = 1; // Act $result = $this->subject ->numericFilter('price', 8, 10) ->search(); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($this->expectedResult1['author'], $result->getDocuments()[0]->author); } public function testGeoQuery(): void { // Arrange $expectedCount = 1; // Act $result = $this->subject ->geoFilter('location', '51.0544782', '3.7178716', '100', 'km') ->search('Shoes'); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($this->expectedResult2['author'], $result->getDocuments()[0]->author); } public function testGeoQueryWithoutSearchTerm(): void { // Arrange $expectedCount = 1; // Act $result = $this->subject ->geoFilter('location', '51.0544782', '3.7178716', '100', 'km') ->search(); // Assert $this->assertSame($expectedCount, $result->getCount()); $this->assertSame($this->expectedResult2['author'], $result->getDocuments()[0]->author); } public function testLimitSearch(): void { // Arrange $expectedCount = 1; // Act $result = $this->subject->limit(0, $expectedCount)->search('How'); // Assert $this->assertCount($expectedCount, $result->getDocuments()); } public function testSearchWithNoContent(): void { // Arrange — see setUp() // Act $result = $this->subject->noContent()->search('How'); // Assert $this->assertFalse(property_exists($result->getDocuments()[0], 'title')); $this->assertFalse(property_exists($result->getDocuments()[1], 'title')); } public function testSearchWithDefaultSlop(): void { // Arrange — see setUp() // Act $result = $this->subject->slop(0)->search('How appendix'); // Assert $this->assertCount(0, $result->getDocuments()); } public function testSearchWithNonDefaultSlop(): void { // Arrange — see setUp() // Act $result = $this->subject->slop(10)->search('How awesome'); // Assert $this->assertCount(2, $result->getDocuments()); } public function testExplainSimpleSearchQuery(): void { // Arrange $expectedInExplanation = 'INTERSECT'; // Act $result = $this->subject->explain('How awesome'); // Assert $this->assertStringContainsString($expectedInExplanation, $result); } public function testExplainComplexSearchQuery(): void { // Arrange $expectedInExplanation1 = 'INTERSECT'; $expectedInExplanation2 = 'UNION'; // Act $result = $this->subject->explain('(How awesome)|(22st Century)'); // Assert $this->assertStringContainsString($expectedInExplanation1, $result); $this->assertStringContainsString($expectedInExplanation2, $result); } public function testSearchWithScorerFunction(): void { // Arrange — see setUp() // Act $result = $this->subject->scorer('DISMAX')->search('Shoes'); // Assert $this->assertTrue($result->getCount() === 1); } public function testSearchWithSortBy(): void { // Arrange $indexName = 'QueryBuilderSortByTest'; $index = (new TestIndex($this->redisClient, $indexName)) ->addTextField('title') ->addTextField('author', true) ->addNumericField('price', true) ->addNumericField('stock') ->addGeoField('location'); $index->create(); $expectedResult1 = [ 'title' => 'Cheapest book ever.', 'author' => 'Jane', 'price' => 99.01, 'stock' => 55, 'location' => new GeoLocation(10.9190500, 52.0504100), ]; $index->add($expectedResult1); $expectedResult2 = [ 'title' => 'Ok book.', 'author' => 'John', 'price' => 10.50, 'stock' => 66, 'location' => new GeoLocation(10.9190500, 52.0504100), ]; $index->add($expectedResult2); $expectedResult3 = [ 'title' => 'Expensive book.', 'author' => 'John', 'price' => 1000, 'stock' => 77, 'location' => new GeoLocation(10.9190500, 52.0504100), ]; $index->add($expectedResult3); // Act $result = (new Builder($this->redisClient, $indexName)) ->sortBy('price') ->search('book'); // Assert $this->assertSame($expectedResult1['title'], $result->getDocuments()[1]->title); $this->assertSame($expectedResult2['title'], $result->getDocuments()[0]->title); $this->assertSame($expectedResult3['title'], $result->getDocuments()[2]->title); } } ================================================ FILE: tests/RediSearch/Redis/RedisClientTest.php ================================================ redisClient->flushAll(); // Assert $this->expectException(UnknownIndexNameException::class); // Act $this->redisClient->rawCommand('FT.INFO', ['DOES_NOT_EXIST']); } } ================================================ FILE: tests/RediSearch/RuntimeConfigurationTest.php ================================================ subject = (new RuntimeConfiguration($this->redisClient, 'foo')); } public function tearDown(): void { $this->redisClient->flushAll(); } public function testShouldSetMinPrefix(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $expected = 3; // Act $result = $this->subject->setMinPrefix($expected); // Assert $this->assertTrue($result); $this->assertSame($expected, $this->subject->getMinPrefix()); } public function testShouldSetMaxExpansions(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $expected = 300; // Act $result = $this->subject->setMaxExpansions($expected); // Assert $this->assertTrue($result); $this->assertSame($expected, $this->subject->getMaxExpansions()); } public function testShouldSetTimeout(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $expected = 100; // Act $result = $this->subject->setTimeoutInMilliseconds($expected); // Assert $this->assertTrue($result); $this->assertSame($expected, $this->subject->getTimeoutInMilliseconds()); } public function testIsOnTimeoutPolicyReturn(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $this->subject->setOnTimeoutPolicyToReturn(); // Act $result = $this->subject->isOnTimeoutPolicyReturn(); // Assert $this->assertTrue($result); } public function testIsOnTimeoutPolicyFail(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $this->subject->setOnTimeoutPolicyToFail(); // Act $result = $this->subject->isOnTimeoutPolicyFail(); // Assert $this->assertTrue($result); } public function testShouldSetMinPhoneticTermLength(): void { // Arrange if ($this->isUsingPhpRedis()) { $this->markTestSkipped('Skipping because test suite is configured to use PhpRedis.'); } $expected = 5; // Act $result = $this->subject->setMinPhoneticTermLength($expected); // Assert $this->assertTrue($result); $this->assertSame($expected, $this->subject->getMinPhoneticTermLength()); } } ================================================ FILE: tests/RediSearch/SuggestionTest.php ================================================ subject = (new Suggestion($this->redisClient, 'foo')); } public function tearDown(): void { $this->redisClient->flushAll(); } public function testShouldAddSuggestion(): void { // Arrange $expectedSizeOfIndex = 1; // Act $result = $this->subject->add('bar', 9.23); // Assert $this->assertSame($expectedSizeOfIndex, $result); } public function testShouldIncrementExistingSuggestion(): void { // Arrange $expectedSizeOfIndex = 2; $expectedFirstResult = 'bar'; $expectedSecondResult = 'baz'; $this->subject->add($expectedFirstResult, 5); $this->subject->add($expectedSecondResult, 7); // Act $result = $this->subject->add($expectedFirstResult, 10, true); // Assert $actualSuggestion = $this->subject->get('ba'); $this->assertSame($expectedSizeOfIndex, $result); $this->assertSame($expectedFirstResult, $actualSuggestion[0]); $this->assertSame($expectedSecondResult, $actualSuggestion[1]); } public function testShouldDeleteSuggestion(): void { // Arrange $string = 'bar'; $this->subject->add($string, 9.23); // Act $result = $this->subject->delete($string); // Assert $this->assertTrue($result); } public function testShouldGetDictionaryLength(): void { // Arrange $this->subject->add('bar', 9.23); $this->subject->add('baz', 4.99); $this->subject->add('qux', 14.0); $expectedSizeOfIndex = 3; // Act $result = $this->subject->length(); // Assert $this->assertSame($expectedSizeOfIndex, $result); } public function testShouldGetSuggestion(): void { // Arrange $expectedFirstResult = 'baz'; $expectedSecondResult = 'bar'; $this->subject->add('bar', 1.23); $this->subject->add('baz', 24.99); $this->subject->add('qux', 14.0); $expectedSizeOfResults = 2; // Act $result = $this->subject->get('ba'); // Assert $this->assertCount($expectedSizeOfResults, $result); $this->assertSame($expectedFirstResult, $result[0]); $this->assertSame($expectedSecondResult, $result[1]); } public function testShouldGetSuggestionWithScore(): void { // Arrange $expectedSuggestion = 'bar'; $expectedScore = '2147483648'; $this->subject->add('bar', 1.23); $this->subject->add('baz', 24.99); $this->subject->add('qux', 14.0); $expectedSizeOfResults = 2; // Act $result = $this->subject->get('bar', false, false, 1, true); // Assert $this->assertCount($expectedSizeOfResults, $result); $this->assertSame($expectedSuggestion, $result[0]); $this->assertSame($expectedScore, $result[1]); } } ================================================ FILE: tests/RediSearchTestCase.php ================================================ redisClient = new RediSearchRedisClient($this->$factoryMethod()); if (getenv('IS_LOGGING_ENABLED')) { $logger = new Logger('Ehann\RediSearch'); $handler = new StreamHandler(getenv('LOG_FILE'), Logger::DEBUG); $handler->setFormatter(new LineFormatter("%message%\n", null)); $logger->pushHandler($handler); $this->redisClient->setLogger($logger); $this->logger = $logger; } } protected function makePhpRedisAdapter(): RedisRawClientInterface { return (new PhpRedisAdapter())->connect( getenv('REDIS_HOST') ?? '127.0.0.1', getenv('REDIS_PORT') ?? 6379, getenv('REDIS_DB') ?? 0 ); } protected function makePredisAdapter(): RedisRawClientInterface { return (new PredisAdapter())->connect( getenv('REDIS_HOST') ?? '127.0.0.1', getenv('REDIS_PORT') ?? 6379, getenv('REDIS_DB') ?? 0 ); } protected function makeRedisClientAdapter(): RedisRawClientInterface { return (new RedisClientAdapter())->connect( getenv('REDIS_HOST') ?? '127.0.0.1', getenv('REDIS_PORT') ?? 6379, getenv('REDIS_DB') ?? 0 ); } protected function isUsingPredis(): bool { return getenv('REDIS_LIBRARY') === AbstractRedisRawClient::PREDIS_LIBRARY; } protected function isUsingPhpRedis(): bool { return getenv('REDIS_LIBRARY') === AbstractRedisRawClient::PHP_REDIS_LIBRARY; } protected function isUsingRedisClient(): bool { return getenv('REDIS_LIBRARY') === AbstractRedisRawClient::REDIS_CLIENT_LIBRARY; } } ================================================ FILE: tests/Stubs/IndexWithoutFields.php ================================================