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