diff --git a/.gitattributes b/.gitattributes
index 9dfeb39..4722e63 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,30 +1,27 @@
-*         text=auto
-*.md      text diff=markdown
-*.json    text
-*.toml    text
-*.xml     text
-*.yaml    text
-*.yml     text
-*.php     text diff=php
-.php_cs   text diff=php
-*.lock    text -diff
-Makefile  text
+*               text=auto
+
+*.*ignore       text
+.gitattributes  text
+*.lock          text -diff
+Makefile        text
+*.md            text diff=markdown
+*.neon          text
+*.php           text diff=php
+*.xml           text
+*.xml.dist      text
 
 #
 # Exclude files from exporting
 #
-
-/.phan            export-ignore
 /tests            export-ignore
 /.gitattributes   export-ignore
 /.gitignore       export-ignore
-/.php_cs          export-ignore
-/.unused.php      export-ignore
+/.gitkeep         export-ignore
 /CHANGELOG.md     export-ignore
 /CONTRIBUTING.md  export-ignore
-/infection.json   export-ignore
 /Makefile         export-ignore
-/phpmd.xml        export-ignore
+/*.dist.php       export-ignore
+/*.xml.dist       export-ignore
 /phpstan.neon     export-ignore
-/phpunit.xml      export-ignore
-/psalm.xml        export-ignore
\ No newline at end of file
+/psalm.xml        export-ignore
+/.github          export-ignore
diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml
new file mode 100644
index 0000000..91fcf0c
--- /dev/null
+++ b/.github/workflows/validate.yaml
@@ -0,0 +1,72 @@
+on: pull_request
+
+jobs:
+  analyze:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - #&setup-php
+        name: Setup PHP with PECL extension
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.0'
+          extensions: intl, mbstring
+          coverage: xdebug
+      - #&composer-cache
+        name: Cache Composer packages
+        id: composer-cache
+        uses: actions/cache@v2
+        with:
+          path: vendor
+          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json,') }}
+          restore-keys: |
+            ${{ runner.os }}-php-
+      - name: Validate code
+        run: make analyze
+
+  unit-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - #*setup-php
+        name: Setup PHP with PECL extension
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.0'
+          extensions: intl, mbstring
+          coverage: xdebug
+      - #*composer-cache
+        name: Cache Composer packages
+        id: composer-cache
+        uses: actions/cache@v2
+        with:
+          path: vendor
+          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json,') }}
+          restore-keys: |
+            ${{ runner.os }}-php-
+      - name: Run unit test
+        id: make-test
+        run: make test
+
+  coverage:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - #*setup-php
+        name: Setup PHP with PECL extension
+        uses: shivammathur/setup-php@v2
+        with:
+          php-version: '8.0'
+          extensions: intl, mbstring
+          coverage: xdebug
+      - #*composer-cache
+        name: Cache Composer packages
+        id: composer-cache
+        uses: actions/cache@v2
+        with:
+          path: vendor
+          key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json,') }}
+          restore-keys: |
+            ${{ runner.os }}-php-
+      - name: Code coverage
+        run: make coverage
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 93bf08c..48607c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,13 @@
+### Composer
 /composer.phar
 /vendor/
 /composer.lock
 
-/.php_cs.cache
-/.phpunit.cache/
+### PHP CS Fixer
+/.php-cs-fixer.cache
+
+### PHPUnit
 /.phpunit.result.cache
-/infection.log
+
+### Local test
+/test*.php
diff --git a/.phan/config.php b/.phan/config.php
deleted file mode 100644
index 5ab5135..0000000
--- a/.phan/config.php
+++ /dev/null
@@ -1,344 +0,0 @@
-<?php
-
-use Phan\Issue;
-
-/**
- * This configuration file was automatically generated by 'phan --init --init-level=1'
- *
- * TODOs (added by 'phan --init'):
- *
- * - Go through this file and verify that there are no missing/unnecessary files/directories.
- *   (E.g. this only includes direct composer dependencies - You may have to manually add indirect composer dependencies to 'directory_list')
- * - Look at 'plugins' and add or remove plugins if appropriate (see https://github.com/phan/phan/tree/master/.phan/plugins#plugins)
- * - Add global suppressions for pre-existing issues to suppress_issue_types (https://github.com/phan/phan/wiki/Tutorial-for-Analyzing-a-Large-Sloppy-Code-Base)
- *
- * This configuration will be read and overlaid on top of the
- * default configuration. Command line arguments will be applied
- * after this file is read.
- *
- * @see src/Phan/Config.php
- * See Config for all configurable options.
- *
- * A Note About Paths
- * ==================
- *
- * Files referenced from this file should be defined as
- *
- * ```
- *   Config::projectPath('relative_path/to/file')
- * ```
- *
- * where the relative path is relative to the root of the
- * project which is defined as either the working directory
- * of the phan executable or a path passed in via the CLI
- * '-d' flag.
- */
-return [
-
-    // Supported values: `'5.6'`, `'7.0'`, `'7.1'`, `'7.2'`, `'7.3'`, `'7.4'`, `null`.
-    // If this is set to `null`,
-    // then Phan assumes the PHP version which is closest to the minor version
-    // of the php executable used to execute Phan.
-    //
-    // Note that the **only** effect of choosing `'5.6'` is to infer that functions removed in php 7.0 exist.
-    // (See `backward_compatibility_checks` for additional options)
-    'target_php_version' => '7.1',
-
-    // If enabled, missing properties will be created when
-    // they are first seen. If false, we'll report an
-    // error message if there is an attempt to write
-    // to a class property that wasn't explicitly
-    // defined.
-    'allow_missing_properties' => false,
-
-    // If enabled, null can be cast to any type and any
-    // type can be cast to null. Setting this to true
-    // will cut down on false positives.
-    'null_casts_as_any_type' => false,
-
-    // If enabled, allow null to be cast as any array-like type.
-    //
-    // This is an incremental step in migrating away from `null_casts_as_any_type`.
-    // If `null_casts_as_any_type` is true, this has no effect.
-    'null_casts_as_array' => false,
-
-    // If enabled, allow any array-like type to be cast to null.
-    // This is an incremental step in migrating away from `null_casts_as_any_type`.
-    // If `null_casts_as_any_type` is true, this has no effect.
-    'array_casts_as_null' => false,
-
-    // If enabled, scalars (int, float, bool, string, null)
-    // are treated as if they can cast to each other.
-    // This does not affect checks of array keys. See `scalar_array_key_cast`.
-    'scalar_implicit_cast' => false,
-
-    // If enabled, any scalar array keys (int, string)
-    // are treated as if they can cast to each other.
-    // E.g. `array<int,stdClass>` can cast to `array<string,stdClass>` and vice versa.
-    // Normally, a scalar type such as int could only cast to/from int and mixed.
-    'scalar_array_key_cast' => false,
-
-    // If this has entries, scalars (int, float, bool, string, null)
-    // are allowed to perform the casts listed.
-    //
-    // E.g. `['int' => ['float', 'string'], 'float' => ['int'], 'string' => ['int'], 'null' => ['string']]`
-    // allows casting null to a string, but not vice versa.
-    // (subset of `scalar_implicit_cast`)
-    'scalar_implicit_partial' => [],
-
-    // If enabled, Phan will warn if **any** type in a method invocation's object
-    // is definitely not an object,
-    // or if **any** type in an invoked expression is not a callable.
-    // Setting this to true will introduce numerous false positives
-    // (and reveal some bugs).
-    'strict_method_checking' => true,
-
-    // If enabled, Phan will warn if **any** type of the object expression for a property access
-    // does not contain that property.
-    'strict_object_checking' => true,
-
-    // If enabled, Phan will warn if **any** type in the argument's union type
-    // cannot be cast to a type in the parameter's expected union type.
-    // Setting this to true will introduce numerous false positives
-    // (and reveal some bugs).
-    'strict_param_checking' => true,
-
-    // If enabled, Phan will warn if **any** type in a property assignment's union type
-    // cannot be cast to a type in the property's declared union type.
-    // Setting this to true will introduce numerous false positives
-    // (and reveal some bugs).
-    'strict_property_checking' => true,
-
-    // If enabled, Phan will warn if **any** type in a returned value's union type
-    // cannot be cast to the declared return type.
-    // Setting this to true will introduce numerous false positives
-    // (and reveal some bugs).
-    'strict_return_checking' => true,
-
-    // If true, seemingly undeclared variables in the global
-    // scope will be ignored.
-    //
-    // This is useful for projects with complicated cross-file
-    // globals that you have no hope of fixing.
-    'ignore_undeclared_variables_in_global_scope' => false,
-
-    // Set this to false to emit `PhanUndeclaredFunction` issues for internal functions that Phan has signatures for,
-    // but aren't available in the codebase, or from Reflection.
-    // (may lead to false positives if an extension isn't loaded)
-    //
-    // If this is true(default), then Phan will not warn.
-    //
-    // Even when this is false, Phan will still infer return values and check parameters of internal functions
-    // if Phan has the signatures.
-    'ignore_undeclared_functions_with_known_signatures' => false,
-
-    // Backwards Compatibility Checking. This is slow
-    // and expensive, but you should consider running
-    // it before upgrading your version of PHP to a
-    // new version that has backward compatibility
-    // breaks.
-    //
-    // If you are migrating from PHP 5 to PHP 7,
-    // you should also look into using
-    // [php7cc (no longer maintained)](https://github.com/sstalle/php7cc)
-    // and [php7mar](https://github.com/Alexia/php7mar),
-    // which have different backwards compatibility checks.
-    'backward_compatibility_checks' => false,
-
-    // If true, check to make sure the return type declared
-    // in the doc-block (if any) matches the return type
-    // declared in the method signature.
-    'check_docblock_signature_return_type_match' => true,
-
-    // This setting maps case-insensitive strings to union types.
-    //
-    // This is useful if a project uses phpdoc that differs from the phpdoc2 standard.
-    //
-    // If the corresponding value is the empty string,
-    // then Phan will ignore that union type (E.g. can ignore 'the' in `@return the value`)
-    //
-    // If the corresponding value is not empty,
-    // then Phan will act as though it saw the corresponding UnionTypes(s)
-    // when the keys show up in a UnionType of `@param`, `@return`, `@var`, `@property`, etc.
-    //
-    // This matches the **entire string**, not parts of the string.
-    // (E.g. `@return the|null` will still look for a class with the name `the`, but `@return the` will be ignored with the below setting)
-    //
-    // (These are not aliases, this setting is ignored outside of doc comments).
-    // (Phan does not check if classes with these names exist)
-    //
-    // Example setting: `['unknown' => '', 'number' => 'int|float', 'char' => 'string', 'long' => 'int', 'the' => '']`
-    'phpdoc_type_mapping' => [],
-
-    // Set to true in order to attempt to detect dead
-    // (unreferenced) code. Keep in mind that the
-    // results will only be a guess given that classes,
-    // properties, constants and methods can be referenced
-    // as variables (like `$class->$property` or
-    // `$class->$method()`) in ways that we're unable
-    // to make sense of.
-    'dead_code_detection' => false,
-
-    // Set to true in order to attempt to detect unused variables.
-    // `dead_code_detection` will also enable unused variable detection.
-    //
-    // This has a few known false positives, e.g. for loops or branches.
-    'unused_variable_detection' => true,
-
-    // Set to true in order to attempt to detect redundant and impossible conditions.
-    //
-    // This has some false positives involving loops,
-    // variables set in branches of loops, and global variables.
-    'redundant_condition_detection' => true,
-
-    // If enabled, Phan will act as though it's certain of real return types of a subset of internal functions,
-    // even if those return types aren't available in reflection (real types were taken from php 7.3 or 8.0-dev, depending on target_php_version).
-    //
-    // Note that with php 7 and earlier, php would return null or false for many internal functions if the argument types or counts were incorrect.
-    // As a result, enabling this setting with target_php_version 8.0 may result in false positives for `--redundant-condition-detection` when codebases also support php 7.x.
-    'assume_real_types_for_internal_functions' => true,
-
-    // If true, this runs a quick version of checks that takes less
-    // time at the cost of not running as thorough
-    // of an analysis. You should consider setting this
-    // to true only when you wish you had more **undiagnosed** issues
-    // to fix in your code base.
-    //
-    // In quick-mode the scanner doesn't rescan a function
-    // or a method's code block every time a call is seen.
-    // This means that the problem here won't be detected:
-    //
-    // ```php
-    // <?php
-    // function test($arg):int {
-    //     return $arg;
-    // }
-    // test("abc");
-    // ```
-    //
-    // This would normally generate:
-    //
-    // ```
-    // test.php:3 PhanTypeMismatchReturn Returning type string but test() is declared to return int
-    // ```
-    //
-    // The initial scan of the function's code block has no
-    // type information for `$arg`. It isn't until we see
-    // the call and rescan `test()`'s code block that we can
-    // detect that it is actually returning the passed in
-    // `string` instead of an `int` as declared.
-    'quick_mode' => false,
-
-    // Override to hardcode existence and types of (non-builtin) globals in the global scope.
-    // Class names should be prefixed with `\`.
-    //
-    // (E.g. `['_FOO' => '\FooClass', 'page' => '\PageClass', 'userId' => 'int']`)
-    'globals_type_map' => [],
-
-    // The minimum severity level to report on. This can be
-    // set to `Issue::SEVERITY_LOW`, `Issue::SEVERITY_NORMAL` or
-    // `Issue::SEVERITY_CRITICAL`. Setting it to only
-    // critical issues is a good place to start on a big
-    // sloppy mature code base.
-    'minimum_severity' => Issue::SEVERITY_LOW,
-
-    // Add any issue types (such as `'PhanUndeclaredMethod'`)
-    // to this black-list to inhibit them from being reported.
-    'suppress_issue_types' => [],
-
-    // A regular expression to match files to be excluded
-    // from parsing and analysis and will not be read at all.
-    //
-    // This is useful for excluding groups of test or example
-    // directories/files, unanalyzable files, or files that
-    // can't be removed for whatever reason.
-    // (e.g. `'@Test\.php$@'`, or `'@vendor/.*/(tests|Tests)/@'`)
-    'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@',
-
-    // A list of files that will be excluded from parsing and analysis
-    // and will not be read at all.
-    //
-    // This is useful for excluding hopelessly unanalyzable
-    // files that can't be removed for whatever reason.
-    'exclude_file_list' => [],
-
-    // A directory list that defines files that will be excluded
-    // from static analysis, but whose class and method
-    // information should be included.
-    //
-    // Generally, you'll want to include the directories for
-    // third-party code (such as "vendor/") in this list.
-    //
-    // n.b.: If you'd like to parse but not analyze 3rd
-    //       party code, directories containing that code
-    //       should be added to the `directory_list` as well as
-    //       to `exclude_analysis_directory_list`.
-    'exclude_analysis_directory_list' => [
-        'vendor/',
-    ],
-
-    // Enable this to enable checks of require/include statements referring to valid paths.
-    // The settings `include_paths` and `warn_about_relative_include_statement` affect the checks.
-    'enable_include_path_checks' => true,
-
-    // The number of processes to fork off during the analysis
-    // phase.
-    'processes' => 1,
-
-    // List of case-insensitive file extensions supported by Phan.
-    // (e.g. `['php', 'html', 'htm']`)
-    'analyzed_file_extensions' => [
-        'php',
-    ],
-
-    // You can put paths to stubs of internal extensions in this config option.
-    // If the corresponding extension is **not** loaded, then Phan will use the stubs instead.
-    // Phan will continue using its detailed type annotations,
-    // but load the constants, classes, functions, and classes (and their Reflection types)
-    // from these stub files (doubling as valid php files).
-    // Use a different extension from php to avoid accidentally loading these.
-    // The `tools/make_stubs` script can be used to generate your own stubs (compatible with php 7.0+ right now)
-    //
-    // (e.g. `['xdebug' => '.phan/internal_stubs/xdebug.phan_php']`)
-    'autoload_internal_extension_signatures' => [],
-
-    // A list of plugin files to execute.
-    //
-    // Plugins which are bundled with Phan can be added here by providing their name (e.g. `'AlwaysReturnPlugin'`)
-    //
-    // Documentation about available bundled plugins can be found [here](https://github.com/phan/phan/tree/master/.phan/plugins).
-    //
-    // Alternately, you can pass in the full path to a PHP file with the plugin's implementation (e.g. `'vendor/phan/phan/.phan/plugins/AlwaysReturnPlugin.php'`)
-    'plugins' => [
-        'AlwaysReturnPlugin',
-        'DollarDollarPlugin',
-        'DuplicateArrayKeyPlugin',
-        'DuplicateExpressionPlugin',
-        'PregRegexCheckerPlugin',
-        'PrintfCheckerPlugin',
-        'SleepCheckerPlugin',
-        'UnreachableCodePlugin',
-        'UseReturnValuePlugin',
-        'EmptyStatementListPlugin',
-        'StrictComparisonPlugin',
-        'LoopVariableReusePlugin',
-    ],
-
-    // A list of directories that should be parsed for class and
-    // method information. After excluding the directories
-    // defined in `exclude_analysis_directory_list`, the remaining
-    // files will be statically analyzed for errors.
-    //
-    // Thus, both first-party and third-party code being used by
-    // your application should be included in this list.
-    'directory_list' => [
-        'src',
-        'vendor/predis/predis',
-        'vendor/sgh/comparable/src'
-    ],
-
-    // A list of individual files to include in analysis
-    // with a path relative to the root directory of the
-    // project.
-    'file_list' => [],
-];
\ No newline at end of file
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..aac2fbe
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * ----------
+ *
+ * This document has been generated with
+ * https://mlocati.github.io/php-cs-fixer-configurator/#version:3.0.0|configurator
+ * you can change this configuration by importing this file.
+ */
+
+$config = new PhpCsFixer\Config();
+
+return $config
+    ->setRiskyAllowed(true)
+    ->setRules([
+        '@PHP54Migration' => true,
+        '@PHP70Migration' => true,
+        '@PHP71Migration' => true,
+        '@PHP73Migration' => true,
+        '@PSR1' => true,
+        '@PSR12' => true,
+        '@PhpCsFixer' => true,
+        'combine_nested_dirname' => true,
+        'dir_constant' => true,
+        'global_namespace_import' => ['import_functions' => true],
+        'header_comment' => ['comment_type' => 'comment', 'header' => "Copyright MacFJA\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the\nSoftware.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\nWARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\nOTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.", 'location' => 'after_declare_strict'],
+        'implode_call' => true,
+        'logical_operators' => true,
+        'modernize_types_casting' => true,
+        'native_function_invocation' => true,
+        'no_alias_functions' => ['sets' => ['@all']],
+        'no_php4_constructor' => true,
+        'no_superfluous_phpdoc_tags' => ['allow_mixed' => true, 'remove_inheritdoc' => true],
+        'no_unneeded_control_parentheses' => ['statements' => ['break', 'clone', 'continue', 'echo_print', 'others', 'return', 'switch_case', 'yield', 'yield_from']],
+        'no_unreachable_default_argument_value' => true,
+        'no_useless_sprintf' => true,
+        'ordered_imports' => ['imports_order' => ['const', 'function', 'class']],
+        'php_unit_dedicate_assert' => true,
+        'php_unit_dedicate_assert_internal_type' => true,
+        'php_unit_expectation' => true,
+        'php_unit_mock' => true,
+        'php_unit_mock_short_will_return' => true,
+        'php_unit_namespaced' => true,
+        'php_unit_set_up_tear_down_visibility' => true,
+        'php_unit_test_case_static_method_calls' => true,
+        'self_static_accessor' => true,
+        'set_type_to_cast' => true,
+        'static_lambda' => true,
+        'declare_strict_types' => true,
+        'ternary_to_elvis_operator' => true,
+        'void_return' => true,
+    ])
+    ->setFinder(
+        PhpCsFixer\Finder::create()
+            ->exclude('vendor')
+            ->in(__DIR__.'/src')
+            ->in(__DIR__.'/tests')
+    )
+;
diff --git a/.php_cs b/.php_cs
deleted file mode 100644
index 9f35830..0000000
--- a/.php_cs
+++ /dev/null
@@ -1,273 +0,0 @@
-<?php
-/*
- * This document has been generated with
- * https://mlocati.github.io/php-cs-fixer-configurator/#version:2.16.3|configurator
- * you can change this configuration by importing this file.
- */
-return PhpCsFixer\Config::create()
-    ->setRiskyAllowed(true)
-    ->setRules([
-        '@PhpCsFixer' => true,
-        '@PSR1' => true,
-        '@PSR2' => true,
-        '@PHP70Migration' => true,
-        '@PHP71Migration' => true,
-        // Each line of multi-line DocComments must have an asterisk [PSR-5] and must be aligned with the first one.
-        'align_multiline_comment' => true,
-        // Each element of an array must be indented exactly once.
-        'array_indentation' => true,
-        // PHP arrays should be declared using the configured syntax.
-        'array_syntax' => ['syntax'=>'short'],
-        // Binary operators should be surrounded by space as configured.
-        'binary_operator_spaces' => true,
-        // There MUST be one blank line after the namespace declaration.
-        'blank_line_after_namespace' => true,
-        // Ensure there is no code on the same line as the PHP open tag and it is followed by a blank line.
-        'blank_line_after_opening_tag' => true,
-        // An empty line feed must precede any configured statement.
-        'blank_line_before_statement' => true,
-        // The body of each structure MUST be enclosed by braces. Braces should be properly placed. Body of braces should be properly indented.
-        'braces' => true,
-        // A single space or none should be between cast and variable.
-        'cast_spaces' => true,
-        // Class, trait and interface elements must be separated with one blank line.
-        'class_attributes_separation' => true,
-        // Whitespace around the keywords of a class, trait or interfaces definition should be one space.
-        'class_definition' => true,
-        // Using `isset($var) &&` multiple times should be done in one call.
-        'combine_consecutive_issets' => false,
-        // Calling `unset` on multiple items should be done in one call.
-        'combine_consecutive_unsets' => false,
-        // Remove extra spaces in a nullable typehint.
-        'compact_nullable_typehint' => true,
-        // Concatenation should be spaced according configuration.
-        'concat_space' => true,
-        // The PHP constants `true`, `false`, and `null` MUST be written using the correct casing.
-        'constant_case' => true,
-        // Equal sign in declare statement should be surrounded by spaces or not following configuration.
-        'declare_equal_normalize' => true,
-        // The keyword `elseif` should be used instead of `else if` so that all control keywords look like single words.
-        'elseif' => true,
-        // PHP code MUST use only UTF-8 without BOM (remove BOM).
-        'encoding' => true,
-        // Escape implicit backslashes in strings and heredocs to ease the understanding of which are special chars interpreted by PHP and which not.
-        'escape_implicit_backslashes' => true,
-        // Add curly braces to indirect variables to make them clear to understand. Requires PHP >= 7.0.
-        'explicit_indirect_variable' => true,
-        // Converts implicit variables into explicit ones in double-quoted strings or heredoc syntax.
-        'explicit_string_variable' => true,
-        // PHP code must use the long `<?php` tags or short-echo `<?=` tags and not other tag variations.
-        'full_opening_tag' => true,
-        // Transforms imported FQCN parameters and return types in function arguments to short version.
-        'fully_qualified_strict_types' => true,
-        // Spaces should be properly placed in a function declaration.
-        'function_declaration' => true,
-        // Ensure single space between function's argument and its typehint.
-        'function_typehint_space' => true,
-        // Convert `heredoc` to `nowdoc` where possible.
-        'heredoc_to_nowdoc' => true,
-        // Include/Require and file path should be divided with a single space. File path should not be placed under brackets.
-        'include' => true,
-        // Pre- or post-increment and decrement operators should be used if possible.
-        'increment_style' => false,
-        // Code MUST use configured indentation type.
-        'indentation_type' => true,
-        // All PHP files must use same line ending.
-        'line_ending' => true,
-        // Cast should be written in lower case.
-        'lowercase_cast' => true,
-        // PHP keywords MUST be in lower case.
-        'lowercase_keywords' => true,
-        // Class static references `self`, `static` and `parent` MUST be in lower case.
-        'lowercase_static_reference' => true,
-        // Magic constants should be referred to using the correct casing.
-        'magic_constant_casing' => true,
-        // Magic method definitions and calls must be using the correct casing.
-        'magic_method_casing' => true,
-        // In method arguments and method call, there MUST NOT be a space before each comma and there MUST be one space after each comma. Argument lists MAY be split across multiple lines, where each subsequent line is indented once. When doing so, the first item in the list MUST be on the next line, and there MUST be only one argument per line.
-        'method_argument_space' => true,
-        // Method chaining MUST be properly indented. Method chaining with different levels of indentation is not supported.
-        'method_chaining_indentation' => true,
-        // DocBlocks must start with two asterisks, multiline comments must start with a single asterisk, after the opening slash. Both must end with a single asterisk before the closing slash.
-        'multiline_comment_opening_closing' => true,
-        // Forbid multi-line whitespace before the closing semicolon or move the semicolon to the new line for chained calls.
-        'multiline_whitespace_before_semicolons' => true,
-        // Function defined by PHP should be called using the correct casing.
-        'native_function_casing' => true,
-        // Native type hints for functions should use the correct case.
-        'native_function_type_declaration_casing' => true,
-        // All instances created with new keyword must be followed by braces.
-        'new_with_braces' => true,
-        // Replace control structure alternative syntax to use braces.
-        'no_alternative_syntax' => true,
-        // There should not be a binary flag before strings.
-        'no_binary_string' => true,
-        // There should be no empty lines after class opening brace.
-        'no_blank_lines_after_class_opening' => true,
-        // There should not be blank lines between docblock and the documented element.
-        'no_blank_lines_after_phpdoc' => true,
-        // There must be a comment when fall-through is intentional in a non-empty case body.
-        'no_break_comment' => true,
-        // The closing `? >` tag MUST be omitted from files containing only PHP.
-        'no_closing_tag' => true,
-        // There should not be any empty comments.
-        'no_empty_comment' => true,
-        // There should not be empty PHPDoc blocks.
-        'no_empty_phpdoc' => true,
-        // Remove useless semicolon statements.
-        'no_empty_statement' => true,
-        // Removes extra blank lines and/or blank lines following configuration.
-        'no_extra_blank_lines' => true,
-        // Remove leading slashes in `use` clauses.
-        'no_leading_import_slash' => true,
-        // The namespace declaration line shouldn't contain leading whitespace.
-        'no_leading_namespace_whitespace' => true,
-        // Either language construct `print` or `echo` should be used.
-        'no_mixed_echo_print' => true,
-        // Operator `=>` should not be surrounded by multi-line whitespaces.
-        'no_multiline_whitespace_around_double_arrow' => true,
-        // Properties MUST not be explicitly initialized with `null` except when they have a type declaration (PHP 7.4).
-        'no_null_property_initialization' => true,
-        // Short cast `bool` using double exclamation mark should not be used.
-        'no_short_bool_cast' => true,
-        // Replace short-echo `<?=` with long format `<?php echo` syntax.
-        'no_short_echo_tag' => true,
-        // Single-line whitespace before closing semicolon are prohibited.
-        'no_singleline_whitespace_before_semicolons' => true,
-        // When making a method or function call, there MUST NOT be a space between the method or function name and the opening parenthesis.
-        'no_spaces_after_function_name' => true,
-        // There MUST NOT be spaces around offset braces.
-        'no_spaces_around_offset' => true,
-        // There MUST NOT be a space after the opening parenthesis. There MUST NOT be a space before the closing parenthesis.
-        'no_spaces_inside_parenthesis' => true,
-        // Replaces superfluous `elseif` with `if`.
-        'no_superfluous_elseif' => true,
-        // Removes `@param`, `@return` and `@var` tags that don't provide any useful information.
-        'no_superfluous_phpdoc_tags' => ['allow_mixed'=>true,'remove_inheritdoc'=>true],
-        // Remove trailing commas in list function calls.
-        'no_trailing_comma_in_list_call' => true,
-        // PHP single-line arrays should not have trailing comma.
-        'no_trailing_comma_in_singleline_array' => true,
-        // Remove trailing whitespace at the end of non-blank lines.
-        'no_trailing_whitespace' => true,
-        // There MUST be no trailing spaces inside comment or PHPDoc.
-        'no_trailing_whitespace_in_comment' => true,
-        // Removes unneeded parentheses around control statements.
-        'no_unneeded_control_parentheses' => true,
-        // Removes unneeded curly braces that are superfluous and aren't part of a control structure's body.
-        'no_unneeded_curly_braces' => ['namespaces'=>true],
-        // Variables must be set `null` instead of using `(unset)` casting.
-        'no_unset_cast' => false,
-        // Unused `use` statements must be removed.
-        'no_unused_imports' => true,
-        // There should not be useless `else` cases.
-        'no_useless_else' => true,
-        // There should not be an empty `return` statement at the end of a function.
-        'no_useless_return' => false,
-        // In array declaration, there MUST NOT be a whitespace before each comma.
-        'no_whitespace_before_comma_in_array' => true,
-        // Remove trailing whitespace at the end of blank lines.
-        'no_whitespace_in_blank_line' => true,
-        // Array index should always be written by using square braces.
-        'normalize_index_brace' => true,
-        // There should not be space before or after object `T_OBJECT_OPERATOR` `->`.
-        'object_operator_without_whitespace' => true,
-        // Orders the elements of classes/interfaces/traits.
-        'ordered_class_elements' => true,
-        // Ordering `use` statements.
-        'ordered_imports' => true,
-        // PHPUnit annotations should be a FQCNs including a root namespace.
-        'php_unit_fqcn_annotation' => false,
-        // All PHPUnit test classes should be marked as internal.
-        'php_unit_internal_class' => false,
-        // Enforce camel (or snake) case for PHPUnit test methods, following configuration.
-        'php_unit_method_casing' => false,
-        // Order `@covers` annotation of PHPUnit tests.
-        'php_unit_ordered_covers' => false,
-        // Adds a default `@coversNothing` annotation to PHPUnit test classes that have no `@covers*` annotation.
-        'php_unit_test_class_requires_covers' => false,
-        // PHPDoc should contain `@param` for all params.
-        'phpdoc_add_missing_param_annotation' => false,
-        // All items of the given phpdoc tags must be either left-aligned or (by default) aligned vertically.
-        'phpdoc_align' => true,
-        // PHPDoc annotation descriptions should not be a sentence.
-        'phpdoc_annotation_without_dot' => false,
-        // Docblocks should have the same indentation as the documented subject.
-        'phpdoc_indent' => true,
-        // `@access` annotations should be omitted from PHPDoc.
-        'phpdoc_no_access' => true,
-        // No alias PHPDoc tags should be used.
-        'phpdoc_no_alias_tag' => false,
-        // `@return void` and `@return null` annotations should be omitted from PHPDoc.
-        'phpdoc_no_empty_return' => false,
-        // `@package` and `@subpackage` annotations should be omitted from PHPDoc.
-        'phpdoc_no_package' => true,
-        // Classy that does not inherit must not have `@inheritdoc` tags.
-        'phpdoc_no_useless_inheritdoc' => true,
-        // Annotations in PHPDoc should be ordered so that `@param` annotations come first, then `@throws` annotations, then `@return` annotations.
-        'phpdoc_order' => true,
-        // The type of `@return` annotations of methods returning a reference to itself must the configured one.
-        'phpdoc_return_self_reference' => true,
-        // Scalar types should always be written in the same form. `int` not `integer`, `bool` not `boolean`, `float` not `real` or `double`.
-        'phpdoc_scalar' => true,
-        // Annotations in PHPDoc should be grouped together so that annotations of the same type immediately follow each other, and annotations of a different type are separated by a single blank line.
-        'phpdoc_separation' => true,
-        // Single line `@var` PHPDoc should have proper spacing.
-        'phpdoc_single_line_var_spacing' => true,
-        // PHPDoc summary should end in either a full stop, exclamation mark, or question mark.
-        'phpdoc_summary' => true,
-        // Docblocks should only be used on structural elements.
-        'phpdoc_to_comment' => false,
-        // PHPDoc should start and end with content, excluding the very first and last line of the docblocks.
-        'phpdoc_trim' => true,
-        // Removes extra blank lines after summary and after description in PHPDoc.
-        'phpdoc_trim_consecutive_blank_line_separation' => true,
-        // The correct case must be used for standard PHP types in PHPDoc.
-        'phpdoc_types' => true,
-        // Sorts PHPDoc types.
-        'phpdoc_types_order' => true,
-        // Convert double quotes to single quotes for simple strings.
-        'single_quote' => ['strings_containing_single_quote_chars'=>true],
-        // Visibility MUST be declared on all properties and methods; `abstract` and `final` MUST be declared before the visibility; `static` MUST be declared after the visibility.
-        'visibility_required' => ['elements'=>['property','method','const']],
-        // Use `null` coalescing operator `??` where possible. Requires PHP >= 7.0.
-        'ternary_to_null_coalescing' => true,
-        // Replaces `dirname(__FILE__)` expression with equivalent `__DIR__` constant.
-        'dir_constant' => true,
-        // Converts `static` access to `self` access in `final` classes.
-        'final_static_access' => true,
-        // Imports or fully qualifies global classes/functions/constants.
-        'global_namespace_import' => ['import_functions'=>true],
-        // Add, replace or remove header comment.
-        'header_comment' => ['comment_type'=>'comment','header'=>"Copyright MacFJA\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the\nrights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the\nSoftware.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE\nWARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR\nOTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.",'location'=>'after_declare_strict'],
-        // Replaces `is_null($var)` expression with `null === $var`.
-        'is_null' => true,
-        // Ensure there is no code on the same line as the PHP open tag.
-        'linebreak_after_opening_tag' => true,
-        // Use `&&` and `||` logical operators instead of `and` and `or`.
-        'logical_operators' => true,
-        // Replaces `intval`, `floatval`, `doubleval`, `strval` and `boolval` function calls with according type casting operator.
-        'modernize_types_casting' => true,
-        // Add leading `\` before constant invocation of internal constant to speed up resolving. Constant name match is case-sensitive, except for `null`, `false` and `true`.
-        'native_constant_invocation' => true,
-        // Add leading `\` before function invocation to speed up resolving.
-        'native_function_invocation' => true,
-        // Convert PHP4-style constructors to `__construct`.
-        'no_php4_constructor' => true,
-        // Adds or removes `?` before type declarations for parameters with a default `null` value.
-        'nullable_type_declaration_for_default_null_value' => true,
-        // Changes doc blocks from single to multi line, or reversed. Works for class constants, properties and methods only.
-        'phpdoc_line_span' => ['const'=>'multi','property'=>'single'],
-        // Function `implode` must be called with 2 arguments in the documented order.
-        'implode_call' => true,
-        // Functions should be used with `$strict` param set to `true`.
-        'strict_param' => true,
-        'declare_strict_types' => true,
-    ])
-    ->setFinder(PhpCsFixer\Finder::create()
-        ->exclude('vendor')
-        ->in(__DIR__.'/src')
-        ->in(__DIR__.'/alias')
-        ->in(__DIR__.'/tests')
-    )
-    ;
\ No newline at end of file
diff --git a/.unused.dist.php b/.unused.dist.php
new file mode 100644
index 0000000..a9c78bd
--- /dev/null
+++ b/.unused.dist.php
@@ -0,0 +1,33 @@
+
+<?php
+
+$projectPath = __DIR__.'/';
+
+$scanDirectories = [
+    $projectPath.'/src/',
+    $projectPath.'/tests/',
+];
+
+return [
+    // Required params
+    'composerJsonPath' => $projectPath.'/composer.json',
+    'vendorPath' => $projectPath.'/vendor/',
+    'scanDirectories' => $scanDirectories,
+
+    // Optional params
+    'skipPackages' => [
+        'phpmd/phpmd', // QA tool
+        'vimeo/psalm', // QA tool
+        'friendsofphp/php-cs-fixer', // QA tool
+        'rskuipers/php-assumptions', // QA tool
+        'ergebnis/composer-normalize', // QA tool
+        'enlightn/security-checker', // QA tool
+        'php-parallel-lint/php-parallel-lint', // QA tool
+        'sebastian/phpcpd', // QA tool
+        'ukko/phpredis-phpdoc', // Stubs
+    ],
+    'excludeDirectories' => [],
+    'scanFiles' => [],
+    'extensions' => ['*.php'],
+    'requireDev' => true,
+];
diff --git a/.unused.php b/.unused.php
deleted file mode 100644
index c43f02f..0000000
--- a/.unused.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-$projectPath = __DIR__ . '/';
-
-$scanDirectories = [
-    $projectPath . '/src/',
-];
-
-$scanFiles = [];
-$excludeDirectories = [];
-return [
-    /**
-     * Required params
-     **/
-    'composerJsonPath' => $projectPath . '/composer.json',
-    'vendorPath' => $projectPath . '/vendor/',
-    'scanDirectories' => $scanDirectories,
-
-    /**
-     * Optional params
-     **/
-    'skipPackages' => [
-    ],
-    'excludeDirectories' => $excludeDirectories,
-    'scanFiles' => $scanFiles,
-    'extensions' => ['*.php'],
-    'requireDev' => false
-];
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e95ff14..9cf22d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,128 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
-## [Unreleased]
+## [Unreleased v2.x]
+
+### Added
+
+- Path validation for JSON fields
+- Helper function get current RedisJSON version + (abstraction)
+- (dev) More unit tests
+
+### Changed
+
+- (dev) Update PHP-CS-Fixer to version `3.12.0`
+
+## [2.2.0]
+
+### Added
+
+- Documentation on paginated responses
+- Throw custom exception is client is missing
+- Add compatibility for Predis version 2.x
+- `LOAD ALL` option on Aggregate command (RediSearch `2.0.13`)
+- Profile command (RediSearch `2.2.0`) ([Issue#4])
+- Vector feature (RediSearch `2.4.0` / `2.4.2`)
+- Add `DIALECT` option (RediSearch `2.4.3`) ([Issue#35])
+- (dev) Add `make clean` to remove all generated files
+- (dev) Add code coverage to `\MacFJA\RediSearch\Index`
+- (dev) Add integration test for document insertion
+
+### Fixed
+
+- Missing default values on highlight option ([Issue#38])
+- Don't throw an exception if the result of the index definition is incorrect ([Issue#46])
+- Phpredis extension don't return raw response ([Issue#46])
+- Error with version reading if `MODULE LIST` command is protected ([PR#42])
+
+### Changed
+
+- Rework paginated responses
+- Add warning for Predis commands initialization
+- Lazy load RediSearch version and index information in `\MacFJA\RediSearch\Index`
+- Allow to provide RediSearch version manually to `\MacFJA\RediSearch\Index`
+- (dev) Remove deprecated `--no-suggest` option of Composer in the `Makefile`
+- (dev) Update PHP-CS-Fixer version
+
+### Deprecated
+
+- `\MacFJA\RediSearch\Redis\Response\AggregateResponseItem::getValue` (use `\MacFJA\RediSearch\Redis\Response\AggregateResponseItem::getFieldValue` instead)
+
+## [2.1.2]
+
+### Fixed
+
+- Allow Phpredis raw command to have 0 arguments ([Issue#39])
+
+### Changed
+
+- (dev) Update version and rules of PHP-CS-Fixer and PHPStan
+
+## [2.1.1]
+
+### Fixed
+
+- Summarize Search option enabled by default (without possibility to disable it) ([Issue#26])
+- Highlight Search option enabled by default (without possibility to disable it) ([Issue#26])
+- Fix error when parsing response from Search with NOCONTENT flag
+- Make PaginatedResponse `valid` iterator function work
+
+## [2.1.0]
+
+### Added
+
+- Support from JSON document (require RediSearch 2.2)
+- (dev) GitHub Actions
+- Protection against division by 0
+
+### Fixed
+
+- (dev) Code coverage with XDebug 3
+- (dev) Defined list of allowed Composer plugins
+- Fix `PaginatedResponse` index (now start at 0, and have a linear progression)
+
+## [2.0.2]
+
+### Added
+
+- `reset` method for builder (`\MacFJA\RediSearch\IndexBuilder` and `\MacFJA\RediSearch\Query\Builder`)
+- Allow PHP 8.0
+- (dev) Allow `podman` (in addition to `docker`) as container runner for integration test
+
+### Changed
+
+- `APPLY` option of Aggregate command is now dependent of the last inserted option (if the option have a meaning for `APPLY`)
+- (dev) Update `phpstan` (from `0.12` to `1.2`)
+
+### Fixed
+
+- Fix the `APPLY` option of the Aggregate command
+
+## [2.0.1]
+
+### Fixed
+
+- (doc) Fix GPS coordinate (should be longitude first)
+- `AmpRedisClient` pipeline not sequential
+
+## [2.0.0]
+
+### Added
+
+- Allow multiple connector for Redis ([PR#17])
+- Allow specific feature of RediSearch depending of its version ([Issue#6])
+- Add more language ([Issue#5])
+- Add more language ([Issue#5])
+- Add `SORTABLE` flag on `GEO` field ([Issue#5])
+- Add `CASESENSITIVE` flag on fields ([Issue#5])
+- Add `UNF` flag on fields ([Issue#5])
+
+### Changed
+
+- Remove hard dependency on Predis ([Issue#16])
+- Full rework of the library
+
+## [Unreleased v1.x]
 
 ## [1.4.0]
 
@@ -104,7 +225,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 First version
 
-[Unreleased]: https://github.com/MacFJA/php-redisearch/compare/1.4.0...HEAD
+[Unreleased v2.x]: https://github.com/MacFJA/php-redisearch/compare/2.2.0...HEAD
+[2.2.0]: https://github.com/MacFJA/php-redisearch/compare/2.1.2...2.2.0
+[2.1.2]: https://github.com/MacFJA/php-redisearch/compare/2.1.1...2.1.2
+[2.1.1]: https://github.com/MacFJA/php-redisearch/compare/2.1.0...2.1.1
+[2.1.0]: https://github.com/MacFJA/php-redisearch/compare/2.0.2...2.1.0
+[2.0.2]: https://github.com/MacFJA/php-redisearch/compare/2.0.1...2.0.2
+[2.0.1]: https://github.com/MacFJA/php-redisearch/compare/2.0.0...2.0.1
+[2.0.0]: https://github.com/MacFJA/php-redisearch/releases/tag/2.0.0
+[Unreleased v1.x]: https://github.com/MacFJA/php-redisearch/compare/1.4.0...1.x
 [1.4.0]: https://github.com/MacFJA/php-redisearch/compare/1.3.0...1.4.0
 [1.3.0]: https://github.com/MacFJA/php-redisearch/compare/1.2.0...1.3.0
 [1.2.0]: https://github.com/MacFJA/php-redisearch/compare/1.1.0...1.2.0
@@ -112,13 +241,24 @@ First version
 [1.0.0]: https://github.com/MacFJA/php-redisearch/releases/tag/1.0.0
 
 [Issue#2]: https://github.com/MacFJA/php-redisearch/issues/2
+[Issue#4]: https://github.com/MacFJA/php-redisearch/issues/4
+[Issue#5]: https://github.com/MacFJA/php-redisearch/issues/5
+[Issue#6]: https://github.com/MacFJA/php-redisearch/issues/6
 [Issue#9]: https://github.com/MacFJA/php-redisearch/issues/9
 [Issue#10]: https://github.com/MacFJA/php-redisearch/issues/10
 [Issue#11]: https://github.com/MacFJA/php-redisearch/issues/11
 [Issue#12]: https://github.com/MacFJA/php-redisearch/issues/12
+[Issue#16]: https://github.com/MacFJA/php-redisearch/issues/16
+[Issue#26]: https://github.com/MacFJA/php-redisearch/issues/26
+[Issue#35]: https://github.com/MacFJA/php-redisearch/issues/35
+[Issue#38]: https://github.com/MacFJA/php-redisearch/issues/38
+[Issue#39]: https://github.com/MacFJA/php-redisearch/issues/39
+[Issue#46]: https://github.com/MacFJA/php-redisearch/issues/46
 [PR#1]: https://github.com/MacFJA/php-redisearch/pull/1
 [PR#3]: https://github.com/MacFJA/php-redisearch/pull/3
 [PR#8]: https://github.com/MacFJA/php-redisearch/pull/8
 [PR#13]: https://github.com/MacFJA/php-redisearch/pull/13
 [PR#14]: https://github.com/MacFJA/php-redisearch/pull/14
 [PR#15]: https://github.com/MacFJA/php-redisearch/pull/15
+[PR#17]: https://github.com/MacFJA/php-redisearch/pull/17
+[PR#42]: https://github.com/MacFJA/php-redisearch/pull/42
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fdd742a..e6ab3f3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -44,9 +44,7 @@ The command will output any information worth knowing. No error should be left.
 If Xdebug is installed, you can also run the commands:
 ```sh
 make coverage
-make mutation-test
 ```
-Ideally, coverage should only increase, and **Covered Code MSI** shouldn't be below 95%
 
 ----
 
diff --git a/Makefile b/Makefile
index 01b9684..150e368 100644
--- a/Makefile
+++ b/Makefile
@@ -1,39 +1,52 @@
-.PHONY: analyze fix-code test coverage mutation-test clean
+.PHONY: analyze fix-code clean test test-with-integration coverage coverage-with-integration validation integration-test integration-coverage
 
 analyze: | vendor
 	$(COMPOSER) exec -v parallel-lint -- src
-	$(COMPOSER) exec -v php-cs-fixer -- fix --dry-run
-	$(COMPOSER) exec -v unused_scanner -- .unused.php
+	$(COMPOSER) exec -v php-cs-fixer -- fix --dry-run -v
+	$(COMPOSER) exec -v unused_scanner -- .unused.dist.php
 	$(COMPOSER) exec -v security-checker -- security:check
 	$(COMPOSER) exec -v phpcpd -- --fuzzy src
-	$(COMPOSER) exec -v phpmd -- src ansi phpmd.xml
+	$(COMPOSER) exec -v phpmd -- src ansi codesize,controversial,design,naming,unusedcode
 	$(COMPOSER) exec -v phpa -- src
-	$(COMPOSER) exec -v phpstan -- analyse --level=8 src
-	$(COMPOSER) exec -v psalm -- --show-info=true src
-	$(COMPOSER) exec -v phan -- --allow-polyfill-parser --color --color-scheme=light --output-mode=text
+	$(COMPOSER) exec -v phpstan -- analyse
+	$(COMPOSER) exec -v psalm -- src
 
-fix-code: |vendor
+clean:
+	rm -rf composer.phar
+	rm -rf vendor/
+	rm -rf composer.lock
+	rm -rf .php-cs-fixer.cache
+	rm -rf .phpunit.result.cache
+
+fix-code: | vendor
 	$(COMPOSER) normalize
-	$(COMPOSER) exec -v php-cs-fixer -- fix
-	@#$(COMPOSER) exec -v psalm -- --alter --issues=all src
+	$(COMPOSER) exec -v php-cs-fixer -- fix -v
 
 test: | vendor
 	$(COMPOSER) exec -v phpunit
 
+test-with-integration: | vendor
+	$(COMPOSER) exec -v phpunit -- --group default,integration
+
 coverage: | vendor
 	@if [ -z "`php -v | grep -i 'xdebug'`" ]; then echo "You need to install Xdebug in order to do this action"; exit 1; fi
-	$(COMPOSER) exec -v phpunit -- --coverage-text --color
+	XDEBUG_MODE=coverage $(COMPOSER) exec -v phpunit -- --coverage-text --color
 
-mutation-test: | vendor
+coverage-with-integration: | vendor
 	@if [ -z "`php -v | grep -i 'xdebug'`" ]; then echo "You need to install Xdebug in order to do this action"; exit 1; fi
-	$(COMPOSER) exec -v infection -- --only-covered --min-covered-msi=95
+	XDEBUG_MODE=coverage $(COMPOSER) exec -v phpunit -- --group default,integration --coverage-text --color
 
-clean:
-	rm -rf .phpunit.cache vendor
-	rm -f .php_cs.cache .phpunit.result.cache composer.phar composer.lock infection.log
+integration-test: | vendor
+	$(COMPOSER) exec -v phpunit -- --group integration
+
+integration-coverage: | vendor
+	@if [ -z "`php -v | grep -i 'xdebug'`" ]; then echo "You need to install Xdebug in order to do this action"; exit 1; fi
+	XDEBUG_MODE=coverage $(COMPOSER) exec -v phpunit -- --group integration --coverage-text --color
+
+validation: fix-code analyze test-with-integration coverage-with-integration
 
 vendor: composer.json
-	$(COMPOSER) install --optimize-autoloader --no-suggest --prefer-dist
+	$(COMPOSER) install --optimize-autoloader --prefer-dist
 	touch vendor
 
 composer.phar:
diff --git a/README.md b/README.md
index 4a907ce..38d8ac0 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
 
 [MacFJA/redisearch](https://packagist.org/packages/macfja/redisearch) is a PHP Client for [RediSearch](https://oss.redislabs.com/redisearch/).
 
-The implemented API is for RediSearch 2.0
+The implemented API is for RediSearch 2.x
 
 ## Installation
 
@@ -12,118 +12,169 @@ composer require macfja/redisearch
 
 ## Usage
 
+### Get a Redis client
+
+This lib can use several connector for Redis:
+ - [Predis](https://github.com/predis/predis/wiki) - Pure PHP implementation
+ - [Phpredis](https://github.com/phpredis/phpredis) - PHP extension
+ - [Phpiredis](https://github.com/nrk/phpiredis) - PHP extension depending on [hiredis](https://github.com/redis/hiredis)
+ - [Amp\Redis](https://github.com/amphp/redis) - Pure PHP Async implementation
+ - [cheprasov/php-redis-client](https://github.com/cheprasov/php-redis-client) - Pure PHP implementation
+ - [Credis](https://github.com/colinmollenhour/credis) - Pure PHP implementation
+ - [Rediska](https://github.com/Shumkov/Rediska) - Pure PHP implementation
+ - [Redisent](https://github.com/jdp/redisent) - Pure PHP implementation
+ - [TinyRedis](https://github.com/ptrofimov/tinyredisclient) - Pure PHP implementation
+
+You can pick the connector depending on your need.
+
+```php
+$clientFacade = new \MacFJA\RediSearch\Redis\Client\ClientFacade();
+
+// With Predis
+$client = $clientFacade->getClient(new \Predis\Client(/* ... */));
+
+// With Phpredis extension
+$client = $clientFacade->getClient(new \Redis([/* ... */]));
+
+// With Phpiredis extension
+$client = $clientFacade->getClient(phpiredis_connect($host));
+
+// With Amp\Redis
+$client = $clientFacade->getClient(new \Amp\Redis\Redis(new RemoteExecutor(Config::fromUri(/* ... */))));
+
+// With Cheprasov
+$client = $clientFacade->getClient(new \RedisClient\Client\Version\RedisClient6x0([/* ... */]));
+
+// With Rediska
+$client = $clientFacade->getClient(new \Rediska(['servers' => [[/* ... */]]]));
+
+// With Redisent
+$client = $clientFacade->getClient(new \redisent\Redis(/* ... */));
+
+// With TinyRedisClient
+$client = $clientFacade->getClient(new \TinyRedisClient(/* ... */));
+
+// With Credis
+$client = $clientFacade->getClient(new \Credis_Client(/* ... */));
+```
+
+You can add your own implementation, all you need is to implement the interface `\MacFJA\RediSearch\Redis\Client` and add it to the client facace with:
+```php
+$clientFacade = new \MacFJA\RediSearch\Redis\Client\ClientFacade();
+$clientFacade->addFactory(\MyVendor\MyPackage\MyRedisClient::class);
+```
+
 ### Create a new index
 
 ```php
-$client = new \Predis\Client(/* ... */);
-$builder = new \MacFJA\RediSearch\Index\Builder($client);
+$client = /* ... */;
+$builder = new \MacFJA\RediSearch\IndexBuilder();
 
-// Field can be create in advance
-$address = new \MacFJA\RediSearch\Index\Builder\GeoField('address');
+// Field can be created in advance
+$address = (new \MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption())
+    ->setField('address');
 
 $builder
-    ->withName('person')
+    ->setIndex('person')
     ->addField($address)
-    // Or field can be create "inline"
+    // Or field can be created "inline"
     ->addTextField('lastname', false, null, null, true)
     ->addTextField('firstname')
     ->addNumericField('age')
-    ->create();
+    ->create($client);
 ```
 
+The builder can also be used with `withXxx` and `withAddedXxx` instead of `setXxx` and `addXxx`.
+This will give you a new instance of the builder with the configured data.
+
 ### Add a document
 
 ```php
-$client = new \Predis\Client(/* ... */);
+$client = /* ... */;
 $index = new \MacFJA\RediSearch\Index('person', $client);
-$index->addFromArray([
+$index->addDocumentFromArray([
     'firstname' => 'Joe',
     'lastname' => 'Doe',
     'age' => 30,
-    'address' => '40.689247,-74.044502'
+    'address' => '-74.044502,40.689247'
 ]);
 ```
 
 ### Search
 
 ```php
-$client = new \Predis\Client(/* ... */);
-$search = new \MacFJA\RediSearch\Search($client);
-
-$results = $search
-    ->withIndex('person')
-    ->withQuery('Doe')
-    ->withHighlight(['lastname'])
-    ->withScores()
-    ->search();
+$client = /* ... */;
+$search = new \MacFJA\RediSearch\Redis\Command\Search();
+
+$search
+    ->setIndex('person')
+    ->setQuery('Doe')
+    ->setHighlight(['lastname'])
+    ->setWithScores();
+$results = $client->execute($search);
 ```
 
 #### Create a search query
 
 ```php
-use \MacFJA\RediSearch\Search\QueryBuilder\NumericFacet;
-use \MacFJA\RediSearch\Search\QueryBuilder\Negation;
-use \MacFJA\RediSearch\Search\QueryBuilder\GeoFacet;
-use \MacFJA\RediSearch\Search\QueryBuilder\Optional;
-use \MacFJA\RediSearch\Search\QueryBuilder\Word;
-use \MacFJA\RediSearch\Search\GeoFilter;
-
-$queryBuilder = \MacFJA\RediSearch\Search\QueryBuilder::create();
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\Negation;
+use MacFJA\RediSearch\Query\Builder\NumericFacet;
+use MacFJA\RediSearch\Query\Builder\Optional;
+use MacFJA\RediSearch\Query\Builder\Word;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+
+$queryBuilder = new \MacFJA\RediSearch\Query\Builder();
 $query = $queryBuilder
-    ->addExpression(NumericFacet::greaterThan('age', 17))
+    ->addElement(NumericFacet::greaterThan('age', 17))
     ->addString('Doe')
-    ->addExpression(
+    ->addElement(
         new Negation(
-            new GeoFacet('address', 40.589247, -74.044502, 40, GeoFilter::UNIT_KILOMETERS)
+            new GeoFacet(['address'], -74.044502, 40.589247, 40, GeoFilterOption::UNIT_KILOMETERS)
         )
     )
-    ->addExpression(new Optional(new Word('John')))
+    ->addElement(new Optional(new Word('John')))
     ->render();
 
 // The value of $query is:
-// Doe ~John @age:[(17 +inf] -@address:[40.589247 -74.044502 40.000000 km]
+// @age:[(17 +inf] Doe -@address:[-74.044502 40.589247 40.000000 km] ~John
 ```
 
 ### Pipeline
 
 ```php
-use MacFJA\RediSearch\Aggregate;
-use MacFJA\RediSearch\Aggregate\Reducer;
-use MacFJA\RediSearch\Pipeline;
-use MacFJA\RediSearch\Search;
-use MacFJA\RediSearch\Suggestions;
-use Predis\Client;
-
-$client = new Client(/* ... */);
-$suggestion = new Suggestions($client);
-$pipeline = new Pipeline($client);
-$query = '@age:[(17 +inf] %john%';
-$search = new Search($client);
-$aggregate = new Aggregate($client);
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption;
+use MacFJA\RediSearch\Redis\Command\Search;
+use MacFJA\RediSearch\Redis\Command\SugGet;
 
-$pipeline
-    ->addPipeable(
-        $search->withIndex('people')->withQuery($query)
-    )
-    ->addPipeable(
-        $aggregate
-            ->withIndexName('people')
-            ->withQuery($query)
-            ->addGroupBy([], [
-                Reducer::average('age', 'avg'),
-                Reducer::maximum('age', 'oldest')
-            ])
-    )
-    ->addPipeable(
-        $aggregate
-            ->withIndexName('people')
-            ->withQuery($query)
-            ->addGroupBy(['lastname'], [Reducer::count('count')])
-    )
-    ->addItem(
-        $suggestion->pipeableGet('john', true)
-    );
-$result = $pipeline->executePipeline();
+$client = /* ... */;
+
+$query = '@age:[(17 +inf] %john%';
+$search = new Search();
+$search->setIndex('people')
+    ->setQuery($query);
+
+$stats = new Aggregate();
+$stats->setIndex('people')
+    ->setQuery($query)
+    ->addGroupBy(new GroupByOption([], [
+        ReduceOption::average('age', 'avg'),
+        ReduceOption::maximum('age', 'oldest')
+    ]));
+
+$aggregate = new Aggregate();
+$aggregate->setIndex('people')
+    ->setQuery($query)
+    ->addGroupBy(new GroupByOption(['lastname'], [ReduceOption::count('count')]));
+
+$suggestion = new SugGet();
+$suggestion->setDictionary('names')
+    ->setPrefix('john')
+    ->setFuzzy();
+
+$result = $client->pipeline($search, $stats, $aggregate, $suggestion);
 
 // $result[0] is the search result
 // $result[1] is the first aggregation result
@@ -131,6 +182,26 @@ $result = $pipeline->executePipeline();
 // $result[3] is the suggestion result
 ```
 
+### Use Predis (v1.x) shorthand syntax
+
+```php
+$client = new \Predis\Client(/* ... */);
+\MacFJA\RediSearch\Redis\Initializer::registerCommandsPredis($client->getProfile());
+
+$client->ftsearch('people', '@age:[(17 +inf] %john%');
+// But you will have raw Redis output.
+```
+
+### Use Rediska shorthand syntax
+
+```php
+$client = new \Rediska(/* ... */);
+\MacFJA\RediSearch\Redis\Initializer::registerCommandsRediska();
+
+$client->ftsearch('people', '@age:[(17 +inf] %john%');
+// But you will have raw Redis output.
+```
+
 ## Similar projects
 
 - [ethanhann/redisearch-php](https://packagist.org/packages/ethanhann/redisearch-php) - Abandoned
@@ -148,10 +219,10 @@ To do so, you have Github issues to:
 You also have PR to:
  - suggest a correction
  - suggest a new feature
- - and much more... 
+ - and much more...
  
 See [CONTRIBUTING](CONTRIBUTING.md) for more information.
 
 ## License
 
-The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
\ No newline at end of file
+The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
diff --git a/alias/Aggregate.php b/alias/Aggregate.php
deleted file mode 100644
index e9d56a6..0000000
--- a/alias/Aggregate.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate */
-final class Aggregate extends \MacFJA\RediSearch\Aggregate
-{
-}
diff --git a/alias/Aggregate/Exception/NotEnoughPropertiesException.php b/alias/Aggregate/Exception/NotEnoughPropertiesException.php
deleted file mode 100644
index d0f22ad..0000000
--- a/alias/Aggregate/Exception/NotEnoughPropertiesException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Aggregate\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\Exception\NotEnoughPropertiesException */
-final class NotEnoughPropertiesException extends \MacFJA\RediSearch\Aggregate\Exception\NotEnoughPropertiesException
-{
-}
diff --git a/alias/Aggregate/Exception/NotEnoughReducersException.php b/alias/Aggregate/Exception/NotEnoughReducersException.php
deleted file mode 100644
index 56153fd..0000000
--- a/alias/Aggregate/Exception/NotEnoughReducersException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Aggregate\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\Exception\NotEnoughReducersException */
-final class NotEnoughReducersException extends \MacFJA\RediSearch\Aggregate\Exception\NotEnoughReducersException
-{
-}
diff --git a/alias/Aggregate/Exception/UnknownSortDirectionException.php b/alias/Aggregate/Exception/UnknownSortDirectionException.php
deleted file mode 100644
index b68f154..0000000
--- a/alias/Aggregate/Exception/UnknownSortDirectionException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Aggregate\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\Exception\UnknownSortDirectionException */
-final class UnknownSortDirectionException extends \MacFJA\RediSearch\Aggregate\Exception\UnknownSortDirectionException
-{
-}
diff --git a/alias/Aggregate/Result.php b/alias/Aggregate/Result.php
deleted file mode 100644
index ca12774..0000000
--- a/alias/Aggregate/Result.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Aggregate;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\Result */
-final class Result extends \MacFJA\RediSearch\Aggregate\Result
-{
-}
diff --git a/alias/Aggregate/SortBy.php b/alias/Aggregate/SortBy.php
deleted file mode 100644
index efa4e1c..0000000
--- a/alias/Aggregate/SortBy.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Aggregate;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\SortBy */
-final class SortBy extends \MacFJA\RediSearch\Aggregate\SortBy
-{
-}
diff --git a/alias/Builder.php b/alias/Builder.php
deleted file mode 100644
index 9e28424..0000000
--- a/alias/Builder.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Builder */
-interface Builder extends \MacFJA\RediSearch\Builder
-{
-}
diff --git a/alias/Configuration.php b/alias/Configuration.php
deleted file mode 100644
index 4fcba6b..0000000
--- a/alias/Configuration.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Configuration */
-final class Configuration extends \MacFJA\RediSearch\Configuration
-{
-}
diff --git a/alias/Helper/DataHelper.php b/alias/Helper/DataHelper.php
deleted file mode 100644
index 6d3c2c3..0000000
--- a/alias/Helper/DataHelper.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Helper;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Helper\DataHelper */
-final class DataHelper extends \MacFJA\RediSearch\Helper\DataHelper
-{
-}
diff --git a/alias/Helper/RedisHelper.php b/alias/Helper/RedisHelper.php
deleted file mode 100644
index 0838239..0000000
--- a/alias/Helper/RedisHelper.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Helper;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Helper\RedisHelper */
-final class RedisHelper extends \MacFJA\RediSearch\Helper\RedisHelper
-{
-}
diff --git a/alias/Index/Builder.php b/alias/Index/Builder.php
deleted file mode 100644
index f8446e2..0000000
--- a/alias/Index/Builder.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder */
-final class Builder extends \MacFJA\RediSearch\Index\Builder
-{
-}
diff --git a/alias/Index/Builder/AbstractField.php b/alias/Index/Builder/AbstractField.php
deleted file mode 100644
index e3fce7e..0000000
--- a/alias/Index/Builder/AbstractField.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\AbstractField */
-final class AbstractField extends \MacFJA\RediSearch\Index\Builder\AbstractField
-{
-}
diff --git a/alias/Index/Builder/Exception/MissingNameException.php b/alias/Index/Builder/Exception/MissingNameException.php
deleted file mode 100644
index 1bed956..0000000
--- a/alias/Index/Builder/Exception/MissingNameException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\Exception\MissingNameException */
-final class MissingNameException extends \MacFJA\RediSearch\Index\Builder\Exception\MissingNameException
-{
-}
diff --git a/alias/Index/Builder/Exception/MustBeSingleCharException.php b/alias/Index/Builder/Exception/MustBeSingleCharException.php
deleted file mode 100644
index c5ca43f..0000000
--- a/alias/Index/Builder/Exception/MustBeSingleCharException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\Exception\MustBeSingleCharException */
-final class MustBeSingleCharException extends \MacFJA\RediSearch\Index\Builder\Exception\MustBeSingleCharException
-{
-}
diff --git a/alias/Index/Builder/Exception/TooManyFields.php b/alias/Index/Builder/Exception/TooManyFields.php
deleted file mode 100644
index 975dbc8..0000000
--- a/alias/Index/Builder/Exception/TooManyFields.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\Exception\TooManyFields */
-final class TooManyFields extends \MacFJA\RediSearch\Index\Builder\Exception\TooManyFields
-{
-}
diff --git a/alias/Index/Builder/Exception/UnsupportedLanguageException.php b/alias/Index/Builder/Exception/UnsupportedLanguageException.php
deleted file mode 100644
index 1fd1a0a..0000000
--- a/alias/Index/Builder/Exception/UnsupportedLanguageException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\Exception\UnsupportedLanguageException */
-final class UnsupportedLanguageException extends \MacFJA\RediSearch\Index\Builder\Exception\UnsupportedLanguageException
-{
-}
diff --git a/alias/Index/Builder/Field.php b/alias/Index/Builder/Field.php
deleted file mode 100644
index 5a4e160..0000000
--- a/alias/Index/Builder/Field.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\Field */
-interface Field extends \MacFJA\RediSearch\Index\Builder\Field
-{
-}
diff --git a/alias/Index/Builder/GeoField.php b/alias/Index/Builder/GeoField.php
deleted file mode 100644
index 78bf59f..0000000
--- a/alias/Index/Builder/GeoField.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\GeoField */
-final class GeoField extends \MacFJA\RediSearch\Index\Builder\GeoField
-{
-}
diff --git a/alias/Index/Builder/NumericField.php b/alias/Index/Builder/NumericField.php
deleted file mode 100644
index c4b29c5..0000000
--- a/alias/Index/Builder/NumericField.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\NumericField */
-final class NumericField extends \MacFJA\RediSearch\Index\Builder\NumericField
-{
-}
diff --git a/alias/Index/Builder/TagField.php b/alias/Index/Builder/TagField.php
deleted file mode 100644
index f3f3e18..0000000
--- a/alias/Index/Builder/TagField.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\TagField */
-final class TagField extends \MacFJA\RediSearch\Index\Builder\TagField
-{
-}
diff --git a/alias/Index/Builder/TextField.php b/alias/Index/Builder/TextField.php
deleted file mode 100644
index 7c40a0d..0000000
--- a/alias/Index/Builder/TextField.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Builder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Builder\TextField */
-final class TextField extends \MacFJA\RediSearch\Index\Builder\TextField
-{
-}
diff --git a/alias/Index/Exception/IndexNotFoundException.php b/alias/Index/Exception/IndexNotFoundException.php
deleted file mode 100644
index d8e018b..0000000
--- a/alias/Index/Exception/IndexNotFoundException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\Exception\IndexNotFoundException */
-final class IndexNotFoundException extends \MacFJA\RediSearch\Index\Exception\IndexNotFoundException
-{
-}
diff --git a/alias/Index/InfoResult.php b/alias/Index/InfoResult.php
deleted file mode 100644
index ffa248a..0000000
--- a/alias/Index/InfoResult.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Index;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index\InfoResult */
-final class InfoResult extends \MacFJA\RediSearch\Index\InfoResult
-{
-}
diff --git a/alias/Search/Exception/UnknownUnitException.php b/alias/Search/Exception/UnknownUnitException.php
deleted file mode 100644
index 2300a68..0000000
--- a/alias/Search/Exception/UnknownUnitException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Exception\UnknownUnitException */
-final class UnknownUnitException extends \MacFJA\RediSearch\Search\Exception\UnknownUnitException
-{
-}
diff --git a/alias/Search/Exception/UnsupportedLanguageException.php b/alias/Search/Exception/UnsupportedLanguageException.php
deleted file mode 100644
index 43c16e3..0000000
--- a/alias/Search/Exception/UnsupportedLanguageException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Exception\UnsupportedLanguageException */
-final class UnsupportedLanguageException extends \MacFJA\RediSearch\Search\Exception\UnsupportedLanguageException
-{
-}
diff --git a/alias/Search/Filter.php b/alias/Search/Filter.php
deleted file mode 100644
index 7ee524e..0000000
--- a/alias/Search/Filter.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Filter */
-final class Filter extends \MacFJA\RediSearch\Search\Filter
-{
-}
diff --git a/alias/Search/GeoFilter.php b/alias/Search/GeoFilter.php
deleted file mode 100644
index 08e3185..0000000
--- a/alias/Search/GeoFilter.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\GeoFilter */
-final class GeoFilter extends \MacFJA\RediSearch\Search\GeoFilter
-{
-}
diff --git a/alias/Search/Highlight.php b/alias/Search/Highlight.php
deleted file mode 100644
index 3ac4b15..0000000
--- a/alias/Search/Highlight.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Highlight */
-final class Highlight extends \MacFJA\RediSearch\Search\Highlight
-{
-}
diff --git a/alias/Search/QueryBuilder.php b/alias/Search/QueryBuilder.php
deleted file mode 100644
index 7b651dc..0000000
--- a/alias/Search/QueryBuilder.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder */
-final class QueryBuilder extends \MacFJA\RediSearch\Search\QueryBuilder
-{
-}
diff --git a/alias/Search/QueryBuilder/AndGroup.php b/alias/Search/QueryBuilder/AndGroup.php
deleted file mode 100644
index 12b2395..0000000
--- a/alias/Search/QueryBuilder/AndGroup.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\AndGroup */
-final class AndGroup extends \MacFJA\RediSearch\Search\QueryBuilder\AndGroup
-{
-}
diff --git a/alias/Search/QueryBuilder/ExactMatch.php b/alias/Search/QueryBuilder/ExactMatch.php
deleted file mode 100644
index 7c05c2b..0000000
--- a/alias/Search/QueryBuilder/ExactMatch.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\ExactMatch */
-final class ExactMatch extends \MacFJA\RediSearch\Search\QueryBuilder\ExactMatch
-{
-}
diff --git a/alias/Search/QueryBuilder/FuzzyWord.php b/alias/Search/QueryBuilder/FuzzyWord.php
deleted file mode 100644
index b731ede..0000000
--- a/alias/Search/QueryBuilder/FuzzyWord.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\FuzzyWord */
-final class FuzzyWord extends \MacFJA\RediSearch\Search\QueryBuilder\FuzzyWord
-{
-}
diff --git a/alias/Search/QueryBuilder/GeoFacet.php b/alias/Search/QueryBuilder/GeoFacet.php
deleted file mode 100644
index 7da79f5..0000000
--- a/alias/Search/QueryBuilder/GeoFacet.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\GeoFacet */
-final class GeoFacet extends \MacFJA\RediSearch\Search\QueryBuilder\GeoFacet
-{
-}
diff --git a/alias/Search/QueryBuilder/GroupPartialQuery.php b/alias/Search/QueryBuilder/GroupPartialQuery.php
deleted file mode 100644
index 12ebb4f..0000000
--- a/alias/Search/QueryBuilder/GroupPartialQuery.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\GroupPartialQuery */
-interface GroupPartialQuery extends \MacFJA\RediSearch\Search\QueryBuilder\GroupPartialQuery
-{
-}
diff --git a/alias/Search/QueryBuilder/Negation.php b/alias/Search/QueryBuilder/Negation.php
deleted file mode 100644
index 451a1ac..0000000
--- a/alias/Search/QueryBuilder/Negation.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\Negation */
-final class Negation extends \MacFJA\RediSearch\Search\QueryBuilder\Negation
-{
-}
diff --git a/alias/Search/QueryBuilder/NumericFacet.php b/alias/Search/QueryBuilder/NumericFacet.php
deleted file mode 100644
index 95458f8..0000000
--- a/alias/Search/QueryBuilder/NumericFacet.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\NumericFacet */
-final class NumericFacet extends \MacFJA\RediSearch\Search\QueryBuilder\NumericFacet
-{
-}
diff --git a/alias/Search/QueryBuilder/Optional.php b/alias/Search/QueryBuilder/Optional.php
deleted file mode 100644
index c454a38..0000000
--- a/alias/Search/QueryBuilder/Optional.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\Optional */
-final class Optional extends \MacFJA\RediSearch\Search\QueryBuilder\Optional
-{
-}
diff --git a/alias/Search/QueryBuilder/PartialQuery.php b/alias/Search/QueryBuilder/PartialQuery.php
deleted file mode 100644
index dad1dcc..0000000
--- a/alias/Search/QueryBuilder/PartialQuery.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\PartialQuery */
-interface PartialQuery extends \MacFJA\RediSearch\Search\QueryBuilder\PartialQuery
-{
-}
diff --git a/alias/Search/QueryBuilder/QueryExpressionAttribute.php b/alias/Search/QueryBuilder/QueryExpressionAttribute.php
deleted file mode 100644
index 0b76597..0000000
--- a/alias/Search/QueryBuilder/QueryExpressionAttribute.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\QueryExpressionAttribute */
-final class QueryExpressionAttribute extends \MacFJA\RediSearch\Search\QueryBuilder\QueryExpressionAttribute
-{
-}
diff --git a/alias/Search/QueryBuilder/TagFacet.php b/alias/Search/QueryBuilder/TagFacet.php
deleted file mode 100644
index 77d0daa..0000000
--- a/alias/Search/QueryBuilder/TagFacet.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\TagFacet */
-final class TagFacet extends \MacFJA\RediSearch\Search\QueryBuilder\TagFacet
-{
-}
diff --git a/alias/Search/QueryBuilder/TextFacet.php b/alias/Search/QueryBuilder/TextFacet.php
deleted file mode 100644
index 948def1..0000000
--- a/alias/Search/QueryBuilder/TextFacet.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\TextFacet */
-final class TextFacet extends \MacFJA\RediSearch\Search\QueryBuilder\TextFacet
-{
-}
diff --git a/alias/Search/QueryBuilder/Word.php b/alias/Search/QueryBuilder/Word.php
deleted file mode 100644
index f000840..0000000
--- a/alias/Search/QueryBuilder/Word.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\Word */
-final class Word extends \MacFJA\RediSearch\Search\QueryBuilder\Word
-{
-}
diff --git a/alias/Search/Result.php b/alias/Search/Result.php
deleted file mode 100644
index 550feae..0000000
--- a/alias/Search/Result.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Result */
-final class Result extends \MacFJA\RediSearch\Search\Result
-{
-}
diff --git a/alias/Search/Summarize.php b/alias/Search/Summarize.php
deleted file mode 100644
index c27c7e4..0000000
--- a/alias/Search/Summarize.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Search;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\Summarize */
-final class Summarize extends \MacFJA\RediSearch\Search\Summarize
-{
-}
diff --git a/alias/Suggestion/Result.php b/alias/Suggestion/Result.php
deleted file mode 100644
index 43ff7a7..0000000
--- a/alias/Suggestion/Result.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Suggestion;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Suggestion\Result */
-final class Result extends \MacFJA\RediSearch\Suggestion\Result
-{
-}
diff --git a/alias/Suggestions.php b/alias/Suggestions.php
deleted file mode 100644
index 70251dd..0000000
--- a/alias/Suggestions.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Suggestions */
-final class Suggestions extends \MacFJA\RediSearch\Suggestions
-{
-}
diff --git a/alias/Words.php b/alias/Words.php
deleted file mode 100644
index 38ebbe1..0000000
--- a/alias/Words.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Words */
-final class Words extends \MacFJA\RediSearch\Words
-{
-}
diff --git a/alias/Words/Exception/NotEnoughTermsException.php b/alias/Words/Exception/NotEnoughTermsException.php
deleted file mode 100644
index c3425ac..0000000
--- a/alias/Words/Exception/NotEnoughTermsException.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Words\Exception;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Words\Exception\NotEnoughTermsException */
-final class NotEnoughTermsException extends \MacFJA\RediSearch\Words\Exception\NotEnoughTermsException
-{
-}
diff --git a/alias/Words/SynonymResult.php b/alias/Words/SynonymResult.php
deleted file mode 100644
index 8eec560..0000000
--- a/alias/Words/SynonymResult.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RedisSearch\Words;
-
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Words\SynonymResult */
-final class SynonymResult extends \MacFJA\RediSearch\Words\SynonymResult
-{
-}
diff --git a/composer.json b/composer.json
index ec229b8..03926c5 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,8 @@
 {
     "name": "macfja/redisearch",
-    "type": "library",
     "description": "PHP Client for RediSearch",
+    "license": "MIT",
+    "type": "library",
     "keywords": [
         "Redis",
         "RediSearch",
@@ -9,45 +10,70 @@
         "Suggestion"
     ],
     "readme": "README.md",
-    "license": "MIT",
     "authors": [
         {
             "name": "MacFJA"
         }
     ],
+    "support": {
+        "issues": "https://github.com/MacFJA/php-redisearch/issues",
+        "source": "https://github.com/MacFJA/php-redisearch"
+    },
     "require": {
-        "php": ">= 7.1",
-        "predis/predis": "^1.1",
-        "sgh/comparable": "^1.1"
+        "php": "^7.2 || ^8.0",
+        "ext-intl": "*",
+        "composer/semver": "^3.2",
+        "respect/validation": "^2.0",
+        "softcreatr/jsonpath": "^0.7.6 || ^0.8.1"
     },
     "require-dev": {
-        "enlightn/security-checker": "^1.5",
+        "ext-mbstring": "*",
+        "amphp/redis": "^1.0",
+        "cheprasov/php-redis-client": "^1.10",
+        "colinmollenhour/credis": "^1.12",
+        "enlightn/security-checker": "^1.9",
         "ergebnis/composer-normalize": "^2.13",
-        "friendsofphp/php-cs-fixer": "^2.16",
-        "infection/infection": "^0.18.2",
-        "insolita/unused-scanner": "^2.2",
-        "phan/phan": "^3.2",
-        "php-parallel-lint/php-parallel-lint": "^1.2",
-        "phpmd/phpmd": "^2.9",
-        "phpstan/phpstan": "^0.12.57",
-        "phpunit/phpunit": "^9.5",
+        "friendsofphp/php-cs-fixer": "^3.0",
+        "geometria-lab/rediska": "^0.5.10",
+        "insolita/unused-scanner": "^2.3",
+        "php-parallel-lint/php-parallel-lint": "^1.3",
+        "phpmd/phpmd": "^2.10",
+        "phpstan/phpstan": "^1.2.0",
+        "phpunit/phpunit": "^8.5 || ^9.3",
+        "predis/predis": "^1.1 || ^2.0",
+        "ptrofimov/tinyredisclient": "^1.1",
+        "redisent/redisent": "dev-master",
+        "roave/security-advisories": "dev-latest",
         "rskuipers/php-assumptions": "^0.8.0",
-        "sebastian/phpcpd": "^6.0",
-        "vimeo/psalm": "^4.2"
+        "sebastian/phpcpd": "^4.1 || ^6.0",
+        "ukko/phpredis-phpdoc": "dev-master",
+        "vimeo/psalm": "^4.7"
+    },
+    "suggest": {
+        "ext-phpiredis": "To use Phpiredis extension implementation",
+        "ext-redis": "To use Phpredis extension implementation",
+        "amphp/redis": "To use AmpPhp implementation",
+        "cheprasov/php-redis-client": "To use Cheprasov implementation",
+        "colinmollenhour/credis": "To use Credis implementation",
+        "geometria-lab/rediska": "To use Rediska implementation",
+        "predis/predis": "To use Predis implementation",
+        "ptrofimov/tinyredisclient": "To use TinyRedisClient implementation",
+        "redisent/redisent": "To use Redisent implementation"
     },
     "autoload": {
         "psr-4": {
-            "MacFJA\\RediSearch\\": "src/",
-            "MacFJA\\RedisSearch\\": "alias/"
+            "MacFJA\\RediSearch\\": "src/"
         }
     },
     "autoload-dev": {
         "psr-4": {
-            "Tests\\MacFJA\\RediSearch\\": "tests/"
+            "MacFJA\\RediSearch\\tests\\": "tests/"
         }
     },
-    "support": {
-        "issues": "https://github.com/MacFJA/php-redisearch/issues",
-        "source": "https://github.com/MacFJA/php-redisearch"
+    "config": {
+        "allow-plugins": {
+            "composer/package-versions-deprecated": true,
+            "ergebnis/composer-normalize": true
+        }
     }
 }
diff --git a/docs/BuildQuery.md b/docs/BuildQuery.md
new file mode 100644
index 0000000..f525e59
--- /dev/null
+++ b/docs/BuildQuery.md
@@ -0,0 +1,27 @@
+# Building a Query
+
+To help create RediSearch, the library offer a builder.
+
+It all start with the class `\MacFJA\RediSearch\Query\Builder`. From there you can add groups and query elements.
+
+---
+
+For example, for search all people named `John`, and preferably with the last name `Doe` that work as an accountant in the 10 km around Washington DC, and the resume contain text `cake` the request will be:
+```php
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\TextFacet;
+use MacFJA\RediSearch\Query\Builder\Optional;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+
+$queryBuilder = new Builder();
+$query = $queryBuilder
+    ->addTextFacet('firstname', 'John') // people named `John`
+    ->addElement(new Optional(new TextFacet(['lastname'], 'Doe'))) // preferably with the last name `Doe`
+    ->addTextFacet('job', 'accountant') // work as an accountant
+    ->addGeoFacet('address', -77.0365427, 38.8950368, 10, GeoFilterOption::UNIT_KILOMETERS) // in the 10 km around Washington DC
+    ->addString('cake') // the resume contain text `cake`
+    ->render();
+
+// @firstname:John ~@lastname:Doe @job:accountant @address:[-77.0365427 38.8950368 10 km] cake
+```
diff --git a/docs/UsingResult.md b/docs/UsingResult.md
new file mode 100644
index 0000000..e590606
--- /dev/null
+++ b/docs/UsingResult.md
@@ -0,0 +1,70 @@
+# Using result
+
+As you can set an offset and limit on `FT.SEARCH` and `FT.AGGREGATE`, they are paginated commands.
+The result of those commands is a page, that can contain one or more document.
+
+---
+
+So in this library, the result of `\MacFJA\RediSearch\Redis\Command\Search` and `\MacFJA\RediSearch\Redis\Command\Aggregate` is also a paginated object:
+either a `\MacFJA\RediSearch\Redis\Response\PaginatedResponse` (for Search and Aggregate) or a `\MacFJA\RediSearch\Redis\Response\CursorResponse` (for Aggregate)
+
+The 2 classes are representing a page.
+
+Both `\MacFJA\RediSearch\Redis\Response\PaginatedResponse` and `\MacFJA\RediSearch\Redis\Response\CursorResponse` can be read inside a `foreach` loop or with any iterator function.
+(As they implement the [`\Iterator` interface](https://www.php.net/manual/en/class.iterator.php))
+What you are iterating over, are the pages (not the documents inside the page).
+
+```php
+// If your RediSearch search have 15 results in total, but you set the pagination to 10 per page:
+/** @var PaginatedResponse $results */
+
+/** @var array<SearchResponseItem> $items */
+$items = $results->current();
+// $items is a list of 10 SearchResponseItem
+
+$results->next(); // To be able to class `->next()`, you need to call `->setClient()` first !
+
+/** @var array<SearchResponseItem> $items */
+$items = $results->current();
+// $items is a list of 5 SearchResponseItem
+```
+
+## Get all items on all pages
+
+```php
+/** @var \MacFJA\RediSearch\Redis\Client $client */
+/** @var \MacFJA\RediSearch\Redis\Command\Search $search */
+
+/** @var \MacFJA\RediSearch\Redis\Response\PaginatedResponse $results */
+$results = $client->execute($search);
+$results->setClient($client);
+
+$items = [];
+foreach ($results as $pageIndex => $pageContent) {
+    $items = array_merge($items, $pageContent);
+}
+/** @var \MacFJA\RediSearch\Redis\Response\SearchResponseItem $item */
+foreach ($items as $item) {
+    doSomething($item);
+}
+```
+Be careful, this will load all items in the memory.
+This will also call Redis multiple time if there are several pages.
+
+---
+
+If you need to avoid loading all items in the memory, you can use [`ResponseItemIterator`](https://github.com/MacFJA/php-redisearch-integration/blob/main/src/Iterator/ResponseItemIterator.php) from the sister project [macfja/redisearch-integration](https://github.com/MacFJA/php-redisearch-integration).
+This class it a custom iterator that will load in memory only one page at the time.
+
+```php
+/** @var \MacFJA\RediSearch\Redis\Client $client */
+/** @var \MacFJA\RediSearch\Redis\Command\Search $search */
+
+$results = $client->execute($search);
+$allItems = new \MacFJA\RediSearch\Integration\Iterator\ResponseItemIterator($results, $client);
+/** @var \MacFJA\RediSearch\Redis\Response\SearchResponseItem $item */
+foreach ($allItems as $item) {
+    // Only one page exist in the memory
+    doSomething($item);
+}
+```
diff --git a/infection.json b/infection.json
deleted file mode 100644
index db0b265..0000000
--- a/infection.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
-    "source": {
-        "directories": [
-            "src"
-        ]
-    },
-    "logs": {
-        "text": "infection.log"
-    },
-    "mutators": {
-        "@default": true,
-        "global-ignoreSourceCodeByRegex": [
-            "throw new RuntimeException\\(sprintf\\('The assertion of %s::%s at line %d failed\\.'.+",
-            "for \\(.+\\) \\{"
-        ],
-        "DecrementInteger": {
-            "ignore": [
-                "MacFJA\\RediSearch\\Aggregate\\Exception\\UnknownSortDirectionException",
-                "MacFJA\\RediSearch\\Search\\Exception\\UnknownUnitException"
-            ]
-        },
-        "OneZeroInteger": {
-            "ignore": [
-                "MacFJA\\RediSearch\\Aggregate\\Exception\\UnknownSortDirectionException",
-                "MacFJA\\RediSearch\\Search\\Exception\\UnknownUnitException"
-            ]
-        }
-    }
-}
\ No newline at end of file
diff --git a/phpmd.xml b/phpmd.xml
deleted file mode 100644
index ace9289..0000000
--- a/phpmd.xml
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<ruleset name="pcsg-generated-ruleset"
-         xmlns="http://pmd.sf.net/ruleset/1.0.0"
-         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:schemaLocation="http://pmd.sf.net/ruleset/1.0.0 http://pmd.sf.net/ruleset_xml_schema.xsd"
-         xsi:noNamespaceSchemaLocation="http://pmd.sf.net/ruleset_xml_schema.xsd">
-    <description>Created with the PHP Coding Standard Generator. http://edorian.github.com/php-coding-standard-generator/
-    </description>
-    <rule ref="rulesets/codesize.xml"/>
-    <rule ref="rulesets/controversial.xml"/>
-    <rule ref="rulesets/design.xml"/>
-    <rule ref="rulesets/naming.xml"/>
-    <rule ref="rulesets/unusedcode.xml"/>
-</ruleset>
\ No newline at end of file
diff --git a/phpstan.neon b/phpstan.neon
index 48f4986..5e00e5c 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,6 +1,19 @@
 parameters:
-	ignoreErrors:
-		- '#type has no value type specified in iterable type Predis\\Client#'
-		- '#with no value type specified in iterable type Predis\\Client#'
-		- message: '#MacFJA\\RediSearch\\Index\\Builder\\\w+ constructor expects [\w\|]+, bool\|float\|int\|string\|null given#'
-		  path: src/Index/InfoResult.php
\ No newline at end of file
+    level: 8
+    paths:
+        - src
+        - tests
+    stubFiles:
+        - vendor/ukko/phpredis-phpdoc/src/Redis.php
+    excludePaths:
+        analyse:
+            - tests/psalm/stubs/phpiredis.php
+    ignoreErrors:
+        - '#Method MacFJA\\RediSearch\\Redis\\Command\\\w+::getLanguageOptions\(\) should return#'
+        - '#uses generic trait MacFJA\\RediSearch\\Redis\\Command\\Option\\DecoratedOptionTrait but does not specify its types: T#'
+        - '#Call to an undefined method MacFJA\\RediSearch\\Redis\\Command\\Option\\CustomValidatorOption::setValue\(\).#'
+        - '#Method MacFJA\\RediSearch\\tests\\Redis.+::dataProvider\(\) return type has no value type specified in iterable type (array|Generator).#'
+        -
+            message: '#Call to an undefined method MacFJA\\RediSearch\\Redis\\Command\\Option\\CommandOption::set\w+\(\).#'
+            path: src/Redis/Command/Option/GroupedOption.php
+        - '#Predis\\Profile\\RedisProfile#'
diff --git a/phpunit.xml b/phpunit.xml
deleted file mode 100644
index 6fe1f1c..0000000
--- a/phpunit.xml
+++ /dev/null
@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.5/phpunit.xsd"
-         bootstrap="vendor/autoload.php"
-         cacheResultFile=".phpunit.cache/test-results"
-         executionOrder="depends,defects"
-         forceCoversAnnotation="true"
-         beStrictAboutCoversAnnotation="true"
-         beStrictAboutOutputDuringTests="true"
-         beStrictAboutTodoAnnotatedTests="true"
-         stopOnFailure="true"
-         stopOnError="true"
-         stopOnDefect="true"
-         failOnEmptyTestSuite="true"
-         stopOnWarning="true"
-         beStrictAboutTestsThatDoNotTestAnything="true"
-         beStrictAboutResourceUsageDuringSmallTests="true"
-         beStrictAboutChangesToGlobalState="true"
-         cacheResult="true"
-         convertDeprecationsToExceptions="true"
-         convertErrorsToExceptions="true"
-         convertNoticesToExceptions="true"
-         convertWarningsToExceptions="true"
-         failOnIncomplete="false"
-         failOnSkipped="false"
-         stopOnIncomplete="false"
-         stopOnRisky="false"
-         stopOnSkipped="false"
-         failOnRisky="true"
-         failOnWarning="true"
-         verbose="true">
-    <testsuites>
-        <testsuite name="default">
-            <directory suffix="Test.php">tests</directory>
-        </testsuite>
-    </testsuites>
-
-    <coverage cacheDirectory=".phpunit.cache/code-coverage"
-              processUncoveredFiles="true">
-        <include>
-            <directory suffix=".php">src</directory>
-        </include>
-    </coverage>
-</phpunit>
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..d20a507
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         executionOrder="depends,defects"
+         forceCoversAnnotation="true"
+         beStrictAboutCoversAnnotation="true"
+         beStrictAboutOutputDuringTests="true"
+         beStrictAboutTodoAnnotatedTests="true"
+         verbose="true">
+    <testsuites>
+        <testsuite name="default">
+            <directory suffix="Test.php">tests</directory>
+            <exclude>tests/integration</exclude>
+        </testsuite>
+        <testsuite name="integration">
+            <directory suffix="Test.php">tests/integration</directory>
+        </testsuite>
+    </testsuites>
+
+    <groups>
+        <exclude>
+            <group>integration</group>
+        </exclude>
+    </groups>
+
+    <filter>
+        <whitelist addUncoveredFilesFromWhitelist="true" processUncoveredFilesFromWhitelist="true">
+            <directory suffix=".php">src</directory>
+        </whitelist>
+    </filter>
+</phpunit>
diff --git a/psalm.xml b/psalm.xml
index 29dffb1..429bc62 100644
--- a/psalm.xml
+++ b/psalm.xml
@@ -1,11 +1,10 @@
 <?xml version="1.0"?>
 <psalm
-        totallyTyped="false"
-        errorLevel="5"
-        resolveFromConfigFile="true"
-        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-        xmlns="https://getpsalm.org/schema/config"
-        xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+    errorLevel="7"
+    resolveFromConfigFile="true"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns="https://getpsalm.org/schema/config"
+    xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
 >
     <projectFiles>
         <directory name="src" />
@@ -13,11 +12,8 @@
             <directory name="vendor" />
         </ignoreFiles>
     </projectFiles>
-
-<!--    <issueHandlers>-->
-<!--        <PropertyNotSetInConstructor/>-->
-<!--        <MissingParamType/>-->
-<!--        <PossiblyNullArgument/>-->
-<!--        <ImplicitToStringCast errorLevel="suppress" />-->
-<!--    </issueHandlers>-->
-</psalm>
\ No newline at end of file
+    <stubs>
+        <file name="vendor/ukko/phpredis-phpdoc/src/Redis.php"/>
+        <file name="tests/psalm/stubs/phpiredis.php"/>
+    </stubs>
+</psalm>
diff --git a/src/Aggregate.php b/src/Aggregate.php
deleted file mode 100644
index b49c75e..0000000
--- a/src/Aggregate.php
+++ /dev/null
@@ -1,282 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch;
-
-use function array_keys;
-use function array_map;
-use function array_merge;
-use function array_shift;
-use function assert;
-use function is_int;
-use function is_string;
-use MacFJA\RediSearch\Aggregate\GroupBy;
-use MacFJA\RediSearch\Aggregate\Reducer;
-use MacFJA\RediSearch\Aggregate\Result;
-use MacFJA\RediSearch\Aggregate\SortBy;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\PaginatedResult;
-use MacFJA\RediSearch\Helper\PipelineItem;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use Predis\Client;
-use Throwable;
-
-class Aggregate implements Builder, Pipeable
-{
-    /** @var Client */
-    private $redis;
-
-    /** @var null|string */
-    private $indexName;
-
-    /** @var null|string */
-    private $query;
-
-    /** @var bool */
-    private $verbatim = false;
-
-    /** @var array<string> */
-    private $load = [];
-
-    /** @var array<GroupBy> */
-    private $groupBy = [];
-
-    /** @var null|SortBy */
-    private $sortBy;
-
-    /** @var array<string,string> */
-    private $applies = [];
-
-    /** @var null|int */
-    private $resultOffset;
-
-    /** @var null|int */
-    private $resultLimit;
-
-    /** @var null|string */
-    private $filter;
-
-    public function __construct(Client $redis)
-    {
-        $this->redis = $redis;
-    }
-
-    public function reset(): Builder
-    {
-        $this->indexName = null;
-        $this->query = null;
-        $this->verbatim = false;
-        $this->load = [];
-        $this->groupBy = [];
-        $this->sortBy = null;
-        $this->applies = [];
-        $this->resultOffset = null;
-        $this->resultLimit = null;
-        $this->filter = null;
-
-        return $this;
-    }
-
-    public function withIndexName(string $indexName): Aggregate
-    {
-        $this->indexName = $indexName;
-
-        return $this;
-    }
-
-    public function withQuery(string $query): Aggregate
-    {
-        $this->query = $query;
-
-        return $this;
-    }
-
-    public function withVerbatim(bool $verbatim = true): Aggregate
-    {
-        $this->verbatim = $verbatim;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $load
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function withLoad(array $load): Aggregate
-    {
-        DataHelper::assertArrayOf($load, 'string');
-        $this->load = $load;
-
-        return $this;
-    }
-
-    /**
-     * @param array<GroupBy> $groupBy
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function withGroupBy(array $groupBy): Aggregate
-    {
-        DataHelper::assertArrayOf($groupBy, GroupBy::class);
-        $this->groupBy = $groupBy;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string>  $properties
-     * @param array<Reducer> $reducers
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function addGroupBy(array $properties, array $reducers): self
-    {
-        $this->groupBy[] = new GroupBy($properties, $reducers);
-
-        return $this;
-    }
-
-    /**
-     * @param array<string,string> $properties
-     * @psalm-param array<string,"ASC"|"DESC"> $properties
-     * @phpstan-param array<string,"ASC"|"DESC"> $properties
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function withSortBy(array $properties, ?int $max): Aggregate
-    {
-        $this->sortBy = new SortBy($properties, $max);
-
-        return $this;
-    }
-
-    /**
-     * @param array<string,string> $applies
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function withApplies(array $applies): Aggregate
-    {
-        DataHelper::assertArrayOf(array_keys($applies), 'string');
-        DataHelper::assertArrayOf($applies, 'string');
-        $this->applies = $applies;
-
-        return $this;
-    }
-
-    public function withResultOffset(int $resultOffset): Aggregate
-    {
-        $this->resultOffset = $resultOffset;
-
-        return $this;
-    }
-
-    public function withResultLimit(int $resultLimit): Aggregate
-    {
-        $this->resultLimit = $resultLimit;
-
-        return $this;
-    }
-
-    public function withFilter(string $filter): Aggregate
-    {
-        $this->filter = $filter;
-
-        return $this;
-    }
-
-    /**
-     * @return array<Result>
-     *
-     * @deprecated Use \MacFJA\RediSearch\Aggregate::execute
-     */
-    public function aggregate(): array
-    {
-        return $this->execute()->getItems();
-    }
-
-    /**
-     * @return PaginatedResult<Result>
-     */
-    public function execute()
-    {
-        $request = $this->asPipelineItem();
-        $rawResult = $this->redis->executeCommand($request->getCommand());
-
-        return $request->transform($rawResult);
-    }
-
-    public function asPipelineItem(): PipelineItem
-    {
-        try {
-            return PipelineItem::createFromRaw(
-                $this->buildQuery(),
-                static function ($rawResult, array $context): PaginatedResult {
-                    $totalCount = array_shift($rawResult);
-                    assert(is_int($totalCount));
-
-                    $items = array_map(function (array $document) {
-                        return new Result(RedisHelper::getPairs($document));
-                    }, $rawResult);
-
-                    return new class($totalCount, $items, $context['offset'] ?? 0, $context['limit'] ?? 0) extends PaginatedResult {
-                    };
-                },
-                ['offset' => $this->resultOffset, 'limit' => $this->resultLimit]
-            );
-        } finally {
-            $this->reset();
-        }
-    }
-
-    /**
-     * @return array<float|int|string>
-     */
-    private function buildQuery(): array
-    {
-        assert(is_string($this->indexName));
-        assert(is_string($this->query));
-        $query = ['FT.AGGREGATE', $this->indexName, $this->query];
-        $query = RedisHelper::buildQueryBoolean($query, ['VERBATIM' => $this->verbatim]);
-        $query = RedisHelper::buildQueryList($query, ['LOAD' => $this->load]);
-        $query = RedisHelper::buildQueryNotNull($query, ['FILTER' => $this->filter]);
-        $query = RedisHelper::buildQueryPartial($query, array_merge([$this->sortBy], $this->groupBy));
-        foreach ($this->applies as $expression => $alias) {
-            $query = array_merge($query, ['APPLY', $expression, 'AS', $alias]);
-        }
-        if (is_int($this->resultOffset) && is_int($this->resultLimit)) {
-            $query = array_merge($query, ['LIMIT', $this->resultOffset, $this->resultLimit]);
-        }
-
-        return $query;
-    }
-}
diff --git a/src/Aggregate/SortBy.php b/src/Aggregate/SortBy.php
deleted file mode 100644
index e9d4b96..0000000
--- a/src/Aggregate/SortBy.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Aggregate;
-
-use function array_diff;
-use function array_keys;
-use function array_merge;
-use function array_unique;
-use function count;
-use MacFJA\RediSearch\Aggregate\Exception\UnknownSortDirectionException;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use OutOfRangeException;
-use function strpos;
-use Throwable;
-
-class SortBy implements \MacFJA\RediSearch\PartialQuery
-{
-    public const SORT_ASC = 'ASC';
-
-    public const SORT_DESC = 'DESC';
-
-    private const SORT_ACCEPTABLE_VALUES = [self::SORT_ASC, self::SORT_DESC];
-
-    /**
-     * @psalm-var array<string,"ASC"|"DESC">
-     * @phpstan-var array<string,"ASC"|"DESC">
-     *
-     * @var array<string,string>
-     */
-    private $properties = [];
-
-    /** @var null|int */
-    private $max;
-
-    /**
-     * @psalm-param array<string,"ASC"|"DESC"> $properties
-     * @phpstab-param array<string,"ASC"|"DESC"> $properties
-     *
-     * @param array<string,string> $properties
-     *
-     * @throws Throwable
-     */
-    public function __construct(array $properties, ?int $max = null)
-    {
-        DataHelper::assertArrayOf(array_keys($properties), 'string');
-        $allValues = array_unique(array_merge($properties, self::SORT_ACCEPTABLE_VALUES));
-        DataHelper::assert(
-            2 === count($allValues),
-            new UnknownSortDirectionException(array_diff($allValues, self::SORT_ACCEPTABLE_VALUES))
-
-        );
-        DataHelper::assert(null === $max || $max >= 0, new OutOfRangeException('MAX such by greater or equals to 0'));
-        $this->properties = $properties;
-        $this->max = $max;
-    }
-
-    public function getQueryParts(): array
-    {
-        $query = ['SORTBY', count($this->properties) * 2];
-        foreach ($this->properties as $property => $direction) {
-            if (!(0 === strpos($property, '@'))) {
-                $property = '@'.$property;
-            }
-            $query[] = $property;
-            $query[] = $direction;
-        }
-
-        return RedisHelper::buildQueryNotNull($query, ['MAX' => $this->max]);
-    }
-}
diff --git a/src/Exception/InvalidJSONPathException.php b/src/Exception/InvalidJSONPathException.php
new file mode 100644
index 0000000..e4eba00
--- /dev/null
+++ b/src/Exception/InvalidJSONPathException.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Exception;
+
+use Exception;
+use InvalidArgumentException;
+
+class InvalidJSONPathException extends InvalidArgumentException
+{
+    public function __construct(string $path, Exception $previous = null)
+    {
+        parent::__construct(sprintf('The provided JSON Path is not valid. (path: "%s")', $path), 0, $previous);
+    }
+}
diff --git a/src/Index/Builder/GeoField.php b/src/Exception/MissingClientException.php
similarity index 79%
rename from src/Index/Builder/GeoField.php
rename to src/Exception/MissingClientException.php
index 50ff85e..498cbd1 100644
--- a/src/Index/Builder/GeoField.php
+++ b/src/Exception/MissingClientException.php
@@ -19,17 +19,15 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\Exception;
 
-class GeoField extends AbstractField
-{
-    public function __construct(string $name, bool $noIndex = false)
-    {
-        parent::__construct($name, false, $noIndex);
-    }
+use RuntimeException;
+use Throwable;
 
-    public function getType(): string
+class MissingClientException extends RuntimeException
+{
+    public function __construct(Throwable $previous = null)
     {
-        return self::TYPE_GEO;
+        parent::__construct('The Redis client is missing. You need to manually set it.', 0, $previous);
     }
 }
diff --git a/src/Aggregate/Exception/NotEnoughReducersException.php b/src/Exception/NotEnoughFieldsException.php
similarity index 83%
rename from src/Aggregate/Exception/NotEnoughReducersException.php
rename to src/Exception/NotEnoughFieldsException.php
index 67dcfb0..53b45bd 100644
--- a/src/Aggregate/Exception/NotEnoughReducersException.php
+++ b/src/Exception/NotEnoughFieldsException.php
@@ -19,19 +19,14 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate\Exception;
+namespace MacFJA\RediSearch\Exception;
 
 use OutOfRangeException;
 use Throwable;
 
-/**
- * @codeCoverageIgnore
- *
- * @deprecated This exception is never used.
- */
-class NotEnoughReducersException extends OutOfRangeException
+class NotEnoughFieldsException extends OutOfRangeException
 {
-    public function __construct(string $message = 'You must have at least one reducer', int $code = 0, ?Throwable $previous = null)
+    public function __construct(string $message = 'You must have at least one field', int $code = 0, ?Throwable $previous = null)
     {
         parent::__construct($message, $code, $previous);
     }
diff --git a/src/Words/Exception/NotEnoughTermsException.php b/src/Exception/NotEnoughTermsException.php
similarity index 96%
rename from src/Words/Exception/NotEnoughTermsException.php
rename to src/Exception/NotEnoughTermsException.php
index 8e1b511..5806995 100644
--- a/src/Words/Exception/NotEnoughTermsException.php
+++ b/src/Exception/NotEnoughTermsException.php
@@ -19,7 +19,7 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Words\Exception;
+namespace MacFJA\RediSearch\Exception;
 
 use OutOfRangeException;
 use Throwable;
diff --git a/src/Search/Exception/OutOfRangeLevenshteinDistanceException.php b/src/Exception/OutOfRangeLevenshteinDistanceException.php
similarity index 97%
rename from src/Search/Exception/OutOfRangeLevenshteinDistanceException.php
rename to src/Exception/OutOfRangeLevenshteinDistanceException.php
index 15c7b22..b21a6d6 100644
--- a/src/Search/Exception/OutOfRangeLevenshteinDistanceException.php
+++ b/src/Exception/OutOfRangeLevenshteinDistanceException.php
@@ -19,10 +19,11 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\Exception;
+namespace MacFJA\RediSearch\Exception;
 
-use OutOfRangeException;
 use function sprintf;
+
+use OutOfRangeException;
 use Throwable;
 
 class OutOfRangeLevenshteinDistanceException extends OutOfRangeException
diff --git a/src/Exception/UnexpectedServerResponseException.php b/src/Exception/UnexpectedServerResponseException.php
new file mode 100644
index 0000000..2fd5e02
--- /dev/null
+++ b/src/Exception/UnexpectedServerResponseException.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Exception;
+
+use function is_string;
+
+use UnexpectedValueException;
+
+class UnexpectedServerResponseException extends UnexpectedValueException
+{
+    /** @var mixed */
+    private $response;
+
+    /**
+     * @param mixed $response
+     */
+    public function __construct($response, ?string $message = null)
+    {
+        $this->response = $response;
+        $additional = '';
+        if (is_string($message)) {
+            $additional = ': '.$message;
+        }
+        parent::__construct('Unexpected response from the Redis server'.$additional);
+    }
+
+    /**
+     * @codeCoverageIgnore
+     *
+     * @return mixed
+     */
+    public function getResponse()
+    {
+        return $this->response;
+    }
+}
diff --git a/src/Search/Exception/UnknownUnitException.php b/src/Exception/UnknownUnitException.php
similarity index 96%
rename from src/Search/Exception/UnknownUnitException.php
rename to src/Exception/UnknownUnitException.php
index 5590c4c..26f2683 100644
--- a/src/Search/Exception/UnknownUnitException.php
+++ b/src/Exception/UnknownUnitException.php
@@ -19,9 +19,10 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\Exception;
+namespace MacFJA\RediSearch\Exception;
 
 use function sprintf;
+
 use Throwable;
 use UnexpectedValueException;
 
diff --git a/src/Helper/DataHelper.php b/src/Helper/DataHelper.php
deleted file mode 100644
index bfae3e0..0000000
--- a/src/Helper/DataHelper.php
+++ /dev/null
@@ -1,162 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Helper;
-
-use function assert;
-use function class_exists;
-use function debug_backtrace;
-use function end;
-use function gettype;
-use function in_array;
-use function is_array;
-use function is_scalar;
-use function is_string;
-use MacFJA\RediSearch\Index\Exception\IndexNotFoundException;
-use MacFJA\RediSearch\Search\Exception\SyntaxErrorException;
-use RuntimeException;
-use function settype;
-use function sprintf;
-use function sscanf;
-use function strpos;
-use function strtolower;
-use function substr;
-use Throwable;
-use UnexpectedValueException;
-
-class DataHelper
-{
-    /**
-     * @param null|string|Throwable $exception
-     *
-     * @throws Throwable
-     */
-    public static function assert(bool $condition, $exception = null, int $deep = 1): void
-    {
-        if (false === $condition) {
-            if ($exception instanceof Throwable) {
-                throw $exception;
-            }
-            if (is_string($exception) && class_exists($exception)) {
-                $instance = new $exception();
-                if ($instance instanceof Throwable) {
-                    throw $instance;
-                }
-            }
-            $trace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 1 + $deep);
-            $trace = end($trace);
-            assert(is_array($trace));
-
-            throw new RuntimeException(sprintf(
-                'The assertion of %s::%s at line %d failed.',
-                $trace['class'] ?? '',
-                $trace['function'] ?? '',
-                $trace['line'] ?? 0
-            ));
-        }
-    }
-
-    /**
-     * @param array<mixed>          $array
-     * @param null|string|Throwable $exception
-     *
-     * @throws Throwable
-     */
-    public static function assertArrayOf(array $array, string $type, $exception = null): void
-    {
-        $allowNull = false;
-        if (0 === strpos($type, '?')) {
-            $allowNull = true;
-            $type = substr($type, 1);
-            assert(is_string($type));
-        }
-        $type = self::getPhpType($type);
-        foreach ($array as $item) {
-            if (true === $allowNull && null === $item) {
-                continue;
-            }
-            if (in_array($type, ['string', 'boolean', 'integer', 'double'], true)) {
-                self::assert(gettype($item) === $type, $exception, 2);
-            } elseif ('scalar' === $type) {
-                self::assert(is_scalar($item), $exception, 2);
-            } else {
-                self::assert($item instanceof $type, $exception, 2);
-            }
-        }
-    }
-
-    /**
-     * @param array<mixed>|mixed $result
-     *
-     * @throws IndexNotFoundException
-     * @throws SyntaxErrorException
-     */
-    public static function handleRawResult($result): void
-    {
-        if (is_string($result) && !(false === strpos($result, ': no such index'))) {
-            sscanf($result, '%s: no such index', $index);
-
-            throw new IndexNotFoundException((string) $index);
-        }
-        if (is_string($result) && 'unknown index name' === strtolower($result)) {
-            throw new IndexNotFoundException();
-        }
-        if (is_string($result) && 0 === strpos($result, 'Syntax error')) {
-            throw new SyntaxErrorException($result);
-        }
-    }
-
-    /**
-     * @param null|bool|float|int|string $raw
-     *
-     * @return null|bool|float|int|string
-     */
-    public static function nullOrCast($raw, string $cast)
-    {
-        if (is_scalar($raw)) {
-            settype($raw, self::getPhpType($cast, true));
-        }
-
-        return $raw;
-    }
-
-    private static function getPhpType(string $type, bool $exceptionOnUnknown = false): string
-    {
-        switch ($type) {
-            case 'int':
-            case 'integer':
-                return 'integer';
-            case 'bool':
-            case 'boolean':
-                return 'boolean';
-            case 'float':
-            case 'double':
-                return 'double';
-            case 'string':
-                return 'string';
-        }
-        if (true === $exceptionOnUnknown) {
-            throw new UnexpectedValueException(sprintf('The type "%s" is unknown', $type));
-        }
-
-        return $type;
-    }
-}
diff --git a/src/Helper/PaginatedResult.php b/src/Helper/PaginatedResult.php
deleted file mode 100644
index 5cd2265..0000000
--- a/src/Helper/PaginatedResult.php
+++ /dev/null
@@ -1,105 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Helper;
-
-use function ceil;
-use function count;
-
-/**
- * @template T
- */
-abstract class PaginatedResult
-{
-    /** @var int */
-    private $totalCount;
-
-    /** @var array<T> */
-    private $items;
-
-    /** @var int */
-    private $offset;
-
-    /** @var int */
-    private $pageSize;
-
-    /**
-     * @param array<T> $items
-     */
-    public function __construct(int $totalCount, array $items, int $offset, int $pageSize)
-    {
-        $this->totalCount = $totalCount;
-        $this->items = $items;
-        $this->offset = $offset;
-        $this->pageSize = $pageSize;
-    }
-
-    public function getTotalCount(): int
-    {
-        return $this->totalCount;
-    }
-
-    /**
-     * @return array<T>
-     */
-    public function getItems(): array
-    {
-        return $this->items;
-    }
-
-    public function getOffset(): int
-    {
-        return $this->offset;
-    }
-
-    public function getPageSize(): int
-    {
-        return $this->pageSize;
-    }
-
-    public function getPageNumber(): int
-    {
-        if (0 === $this->pageSize) {
-            return 0;
-        }
-
-        return (int) ceil($this->getOffset() / $this->pageSize) + 1;
-    }
-
-    public function havePreviousPage(): bool
-    {
-        return $this->offset > 0;
-    }
-
-    public function haveNextPage(): bool
-    {
-        return $this->offset + count($this->items) < $this->totalCount;
-    }
-
-    public function getPageCount(): int
-    {
-        if (0 === $this->pageSize) {
-            return 0;
-        }
-
-        return (int) ceil($this->totalCount / $this->pageSize);
-    }
-}
diff --git a/src/Helper/RedisHelper.php b/src/Helper/RedisHelper.php
deleted file mode 100644
index bbce26d..0000000
--- a/src/Helper/RedisHelper.php
+++ /dev/null
@@ -1,155 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Helper;
-
-use function array_chunk;
-use function array_column;
-use function array_combine;
-use function array_filter;
-use function array_keys;
-use function array_merge;
-use function array_pop;
-use function array_shift;
-use function assert;
-use function count;
-use function is_array;
-use MacFJA\RediSearch\PartialQuery;
-
-class RedisHelper
-{
-    /**
-     * [
-     *   'REDISEARCHKEYWORD' => $phpBooleanValue
-     * ].
-     *
-     * @param array<float|int|string> $query
-     * @param array<string,bool>      $data
-     *
-     * @return array<float|int|string>
-     */
-    public static function buildQueryBoolean(array $query, array $data): array
-    {
-        return array_merge($query, array_keys(array_filter($data, function (bool $item): bool {
-            return $item;
-        })));
-    }
-
-    /**
-     * [
-     *   'REDISEARCHKEYWORD' => $phpArray
-     * ].
-     *
-     * @param array<float|int|string> $query
-     * @param array<string,array>     $data
-     *
-     * @return array<float|int|string>
-     */
-    public static function buildQueryList(array $query, array $data, bool $allowEmpty = false): array
-    {
-        foreach ($data as $queryWord => $list) {
-            if (!is_array($list)) {
-                continue;
-            }
-            if (true === $allowEmpty || count($list) > 0) {
-                $query[] = $queryWord;
-                $query[] = count($list);
-                $query = array_merge($query, $list);
-            }
-        }
-
-        return $query;
-    }
-
-    /**
-     * @param array<float|int|string>   $query
-     * @param array<string, null|mixed> $data
-     *
-     * @return array<float|int|string>
-     *
-     * @phan-suppress PhanPartialTypeMismatchReturn
-     */
-    public static function buildQueryNotNull($query, array $data): array
-    {
-        $notNullValues = array_filter($data, function ($value): bool {
-            return !(null === $value);
-        });
-        foreach ($notNullValues as $queryWord => $value) {
-            $query[] = $queryWord;
-            $query[] = $value;
-        }
-
-        return $query;
-    }
-
-    /**
-     * @param array<float|int|string>  $query
-     * @param array<null|PartialQuery> $partials
-     *
-     * @return array<float|int|string>
-     */
-    public static function buildQueryPartial(array $query, array $partials): array
-    {
-        /** @var PartialQuery $partial */
-        foreach (array_filter($partials) as $partial) {
-            if (!$partial instanceof PartialQuery) {
-                continue;
-            }
-            $query = array_merge($query, $partial->getQueryParts());
-        }
-
-        return $query;
-    }
-
-    /**
-     * @param array<float|int|mixed|string> $notKeyedArray
-     *
-     * @return array<string,mixed>
-     */
-    public static function getPairs(array $notKeyedArray): array
-    {
-        if (count($notKeyedArray) % 2 > 0) {
-            array_pop($notKeyedArray);
-        }
-        $entries = array_chunk($notKeyedArray, 2);
-        $keyed = array_combine(array_column($entries, 0), array_column($entries, 1));
-        assert(is_array($keyed));
-
-        return $keyed;
-    }
-
-    /**
-     * @param array<bool|float|int|string> $notKeyedArray
-     *
-     * @return null|bool|float|int|string
-     */
-    public static function getValue(array $notKeyedArray, string $key)
-    {
-        while (count($notKeyedArray) >= 2) {
-            $shifted = array_shift($notKeyedArray);
-            if ($shifted === $key) {
-                return array_shift($notKeyedArray);
-            }
-        }
-
-        return null;
-    }
-}
diff --git a/src/Index.php b/src/Index.php
index cb99d12..c24fd31 100644
--- a/src/Index.php
+++ b/src/Index.php
@@ -21,113 +21,111 @@
 
 namespace MacFJA\RediSearch;
 
-use function array_keys;
-use function array_map;
-use function array_merge;
+use function count;
 use function is_array;
 use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Index\Builder\Field;
-use MacFJA\RediSearch\Index\InfoResult;
-use Predis\Client;
-use Throwable;
-use function trigger_error;
-use function uniqid;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\AliasAdd;
+use MacFJA\RediSearch\Redis\Command\AliasDel;
+use MacFJA\RediSearch\Redis\Command\AliasUpdate;
+use MacFJA\RediSearch\Redis\Command\Alter;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\CreateCommandFieldOption;
+use MacFJA\RediSearch\Redis\Command\DropIndex;
+use MacFJA\RediSearch\Redis\Command\Info;
+use MacFJA\RediSearch\Redis\Command\TagVals;
+use MacFJA\RediSearch\Redis\Initializer;
+use MacFJA\RediSearch\Redis\Response\InfoResponse;
 
 class Index
 {
-    /** @var string */
-    private $indexKey;
-
     /** @var Client */
-    private $redis;
+    private $client;
 
-    public function __construct(string $indexKey, Client $redis)
-    {
-        $this->indexKey = $indexKey;
-        $this->redis = $redis;
-    }
+    /** @var null|InfoResponse */
+    private $info;
 
-    /**
-     * @param array<string,float|int|string> $properties
-     *
-     * @throws Throwable
-     *
-     * @deprecated Use `addDocumentFromArray` instead
-     * @see Index::addDocumentFromArray()
-     */
-    public function addFromArray(array $properties, ?string $hash = null): string
-    {
-        trigger_error('The method '.__METHOD__.' is deprecated. Use \MacFJA\RediSearch\Index::addDocumentFromArray instead.', \E_USER_DEPRECATED);
+    /** @var string */
+    private $index;
+
+    /** @var null|string */
+    private $version;
 
-        return $this->addDocumentFromArray($properties, $hash);
+    public function __construct(string $index, Client $client, ?string $version = null)
+    {
+        $this->client = $client;
+        $this->index = $index;
+        $this->version = $version;
     }
 
     /**
      * @param array<string,float|int|string> $properties
-     *
-     * @throws Throwable
      */
     public function addDocumentFromArray(array $properties, ?string $hash = null): string
     {
-        DataHelper::assertArrayOf(array_keys($properties), 'string');
-        DataHelper::assertArrayOf($properties, 'scalar');
-        $documentId = is_string($hash) ? $hash : uniqid();
-        $query = ['HSET', $documentId];
+        $prefixes = $this->getInfo()->getIndexDefinition('prefixes');
+        $prefix = '';
+        if (is_array($prefixes) && count($prefixes) > 0) {
+            $prefix = (string) reset($prefixes);
+        }
+        $documentId = is_string($hash) ? $hash : uniqid($prefix, true);
+        $query = [$documentId];
         foreach ($properties as $name => $value) {
             $query[] = $name;
             $query[] = $value;
         }
 
-        $this->redis->executeRaw($query);
+        $this->client->executeRaw('hset', ...$query);
 
         return $documentId;
     }
 
     public function deleteDocument(string $hash): bool
     {
-        $count = $this->redis->del($hash);
+        $count = $this->client->executeRaw('del', $hash);
 
         return 1 === $count;
     }
 
-    public function addField(Field $field): bool
+    public function addField(CreateCommandFieldOption $field): bool
     {
-        $result = $this->redis->executeRaw(array_merge([
-            'FT.ALTER',
-            $this->indexKey,
-            'SCHEMA',
-            'ADD',
-        ], $field->getQueryParts()));
-
-        return 'OK' === $result;
+        $command = new Alter($this->getVersion());
+        $command
+            ->setIndex($this->index)
+            ->addField($field)
+        ;
+
+        return 'OK' === (string) $this->client->execute($command);
     }
 
     public function delete(bool $withDocuments = false): bool
     {
-        $query = ['FT.DROPINDEX', $this->indexKey];
-        if (true === $withDocuments) {
-            $query[] = 'DD';
-        }
-        $result = $this->redis->executeRaw($query);
+        $command = new DropIndex($this->getVersion());
+        $command->setIndex($this->index)
+            ->setDeleteDocument($withDocuments)
+        ;
 
-        return 'OK' === $result;
+        return 'OK' === (string) $this->client->execute($command);
     }
 
     public function addAlias(string $alias): bool
     {
-        return $this->redis->executeRaw(['FT.ALIASADD', $alias, $this->indexKey]);
+        return 'OK' === (string) $this->client->execute(
+            (new AliasAdd($this->getVersion()))
+                ->setIndex($this->index)
+                ->setAlias($alias)
+        );
     }
 
     public function updateAlias(string $alias): bool
     {
-        return $this->redis->executeRaw(['FT.ALIASUPDATE', $alias, $this->indexKey]);
+        return 'OK' === (string) $this->client->execute((new AliasUpdate($this->getVersion()))->setIndex($this->index)->setAlias($alias));
     }
 
     public function deleteAlias(string $alias): bool
     {
-        return $this->redis->executeRaw(['FT.ALIASDEL', $alias]);
+        return 'OK' === (string) $this->client->execute((new AliasDel($this->getVersion()))->setAlias($alias));
     }
 
     /**
@@ -135,48 +133,24 @@ public function deleteAlias(string $alias): bool
      */
     public function getTagValues(string $fieldName): array
     {
-        return $this->redis->executeRaw(['FT.TAGVALS', $this->indexKey, $fieldName]);
+        return $this->client->execute((new TagVals($this->getVersion()))->setIndex($this->index)->setField($fieldName));
     }
 
-    public function getStats(): InfoResult
+    public function getInfo(): InfoResponse
     {
-        $result = $this->redis->executeRaw(['FT.INFO', $this->indexKey]);
-        DataHelper::handleRawResult($result);
-
-        $stats = RedisHelper::getPairs($result);
-        $definitions = RedisHelper::getPairs($stats['index_definition'] ?? []);
-        $definitions = array_map(function ($item) {
-            if (!is_array($item)) {
-                return (string) $item;
-            }
-
-            return $item;
-        }, $definitions);
-        $definitions['prefixes'] = array_map('strval', (array) ($definitions['prefixes'] ?? []));
-
-        return new InfoResult(
-            (string) $stats['index_name'],
-            array_map('strval', (array) ($stats['index_options'] ?? [])),
-            $definitions,
-            (array) ($stats['fields'] ?? []),
-            (int) $stats['num_docs'],
-            (int) $stats['max_doc_id'],
-            (int) $stats['num_terms'],
-            (float) $stats['bytes_per_record_avg'],
-            (float) $stats['inverted_sz_mb'],
-            (int) ($stats['total_inverted_index_blocks'] ?? 0),
-            '1' === $stats['indexing'],
-            (int) ($stats['hash_indexing_failures'] ?? 0),
-            (float) ($stats['percent_indexed'] ?? 1),
-            array_map('strval', (array) ($stats['stopwords_list'] ?? []))
-        );
+        if (!($this->info instanceof InfoResponse)) {
+            $this->info = $this->client->execute((new Info($this->getVersion()))->setIndex($this->index));
+        }
+
+        return $this->info;
     }
 
-    /**
-     * @return array<string>
-     */
-    public static function getIndexes(Client $redis): array
+    private function getVersion(): string
     {
-        return $redis->executeRaw(['FT._LIST']);
+        if (!is_string($this->version)) {
+            $this->version = Initializer::getRediSearchVersion($this->client) ?? AbstractCommand::MIN_IMPLEMENTED_VERSION;
+        }
+
+        return $this->version;
     }
 }
diff --git a/src/Index/Builder.php b/src/Index/Builder.php
deleted file mode 100644
index e3e865e..0000000
--- a/src/Index/Builder.php
+++ /dev/null
@@ -1,396 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Index;
-
-use function array_filter;
-use function array_merge;
-use function assert;
-use function count;
-use function in_array;
-use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Index\Builder\Exception\MissingNameException;
-use MacFJA\RediSearch\Index\Builder\Exception\TooManyFields;
-use MacFJA\RediSearch\Index\Builder\Exception\UnsupportedLanguageException;
-use MacFJA\RediSearch\Index\Builder\Field;
-use MacFJA\RediSearch\Index\Builder\GeoField;
-use MacFJA\RediSearch\Index\Builder\NumericField;
-use MacFJA\RediSearch\Index\Builder\TagField;
-use MacFJA\RediSearch\Index\Builder\TextField;
-use Predis\Client;
-use Throwable;
-
-/**
- * @SuppressWarnings(PHPMD.TooManyFields) -- Builder Class
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) -- Builder Class
- */
-class Builder implements \MacFJA\RediSearch\Builder
-{
-    public const STRUCTURE_HASH = 'HASH';
-
-    public const MAXIMUM_ALLOWED_FIELDS = 1024;
-
-    public const MAXIMUM_ALLOWED_TEXT_FIELDS = 128;
-
-    public const SUPPORTED_LANGUAGES = [
-        'arabic', 'danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian',
-        'portuguese', 'romanian', 'russian', 'spanish', 'swedish', 'tamil', 'turkish', 'chinese',
-    ];
-
-    /** @var Client */
-    private $redis;
-
-    /** @var array<Field> */
-    private $fields = [];
-
-    /** @var null|string */
-    private $name;
-
-    /** @var null|string */
-    private $structure;
-
-    /** @var array<string> */
-    private $prefix = [];
-
-    /** @var null|string */
-    private $filter;
-
-    /** @var null|string */
-    private $language;
-
-    /** @var null|string */
-    private $languageField;
-
-    /** @var null|float */
-    private $score;
-
-    /** @var null|string */
-    private $scoreField;
-
-    /** @var null|string */
-    private $payloadField;
-
-    /** @var null|int */
-    private $maxTextFields;
-
-    /** @var bool */
-    private $noOffsets = false;
-
-    /** @var null|int */
-    private $temporary;
-
-    /** @var bool */
-    private $noHighlight = false;
-
-    /** @var bool */
-    private $noFields = false;
-
-    /** @var bool */
-    private $noFrequencies = false;
-
-    /** @var array<string> */
-    private $stopWords = [];
-
-    /** @var bool */
-    private $skipInitialScan = false;
-
-    public function __construct(Client $redis)
-    {
-        $this->redis = $redis;
-        $this->reset();
-    }
-
-    public function withName(string $name): self
-    {
-        $this->name = $name;
-
-        return $this;
-    }
-
-    public function withHashStructure(): self
-    {
-        $this->structure = self::STRUCTURE_HASH;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $prefix
-     *
-     * @throws Throwable
-     *
-     * @return $this
-     */
-    public function withPrefix(array $prefix): self
-    {
-        DataHelper::assert(count($prefix) > 0);
-        DataHelper::assertArrayOf($prefix, 'string');
-        $this->prefix = $prefix;
-
-        return $this;
-    }
-
-    public function withFilter(string $expression): self
-    {
-        $this->filter = $expression;
-
-        return $this;
-    }
-
-    public function withLanguage(string $language): self
-    {
-        DataHelper::assert(in_array($language, self::SUPPORTED_LANGUAGES, true), UnsupportedLanguageException::class);
-        $this->language = $language;
-
-        return $this;
-    }
-
-    public function withLanguageField(string $fieldName): self
-    {
-        $this->languageField = $fieldName;
-
-        return $this;
-    }
-
-    public function withDefaultScore(float $score): self
-    {
-        $this->score = $score;
-
-        return $this;
-    }
-
-    public function withScoreField(string $fieldName): self
-    {
-        $this->scoreField = $fieldName;
-
-        return $this;
-    }
-
-    public function withPayloadField(string $fieldName): self
-    {
-        $this->payloadField = $fieldName;
-
-        return $this;
-    }
-
-    public function withMaxTextFields(int $max): self
-    {
-        $this->maxTextFields = $max;
-
-        return $this;
-    }
-
-    public function withNoOffsets(bool $flag = true): self
-    {
-        $this->noOffsets = $flag;
-
-        return $this;
-    }
-
-    public function withTemporary(int $ttl): self
-    {
-        $this->temporary = $ttl;
-
-        return $this;
-    }
-
-    public function withNoHighlight(bool $flag = true): self
-    {
-        $this->noHighlight = $flag;
-
-        return $this;
-    }
-
-    public function withNoFields(bool $flag = true): self
-    {
-        $this->noFields = $flag;
-
-        return $this;
-    }
-
-    public function withNoFrequencies(bool $flag = true): self
-    {
-        $this->noFrequencies = $flag;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $words
-     *
-     * @return $this
-     */
-    public function withStopWords(array $words): self
-    {
-        DataHelper::assert(count($words) > 0);
-        DataHelper::assertArrayOf($words, 'string');
-        $this->stopWords = $words;
-
-        return $this;
-    }
-
-    public function withSkipInitialScan(bool $flag = true): self
-    {
-        $this->skipInitialScan = $flag;
-
-        return $this;
-    }
-
-    public function reset(): \MacFJA\RediSearch\Builder
-    {
-        $this->fields = [];
-        $this->name = null;
-        $this->structure = null;
-        $this->prefix = [];
-        $this->filter = null;
-        $this->language = null;
-        $this->languageField = null;
-        $this->score = null;
-        $this->scoreField = null;
-        $this->payloadField = null;
-        $this->maxTextFields = null;
-        $this->noOffsets = false;
-        $this->temporary = null;
-        $this->noHighlight = false;
-        $this->noFields = false;
-        $this->noFrequencies = false;
-        $this->stopWords = [];
-        $this->skipInitialScan = false;
-
-        return $this;
-    }
-
-    /**
-     * @deprecated Use \MacFJA\RediSearch\Index\Builder::execute()
-     */
-    public function create(): bool
-    {
-        return $this->execute();
-    }
-
-    public function execute(): bool
-    {
-        $this->validateData();
-        $query = $this->createRequest();
-        $result = $this->redis->executeRaw($query);
-
-        if ('OK' === $result) {
-            $this->reset();
-
-            return true;
-        }
-
-        return false;
-    }
-
-    public function addField(Field $field): self
-    {
-        $this->fields[] = $field;
-
-        return $this;
-    }
-
-    public function addTextField(string $name, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false): self
-    {
-        return $this->addField(
-            new TextField($name, $noStem, $weight, $phonetic, $sortable, $noIndex)
-        );
-    }
-
-    public function addGeoField(string $name, bool $noIndex = false): self
-    {
-        return $this->addField(new GeoField($name, $noIndex));
-    }
-
-    public function addNumericField(string $name, bool $sortable = false, bool $noIndex = false): self
-    {
-        return $this->addField(new NumericField($name, $sortable, $noIndex));
-    }
-
-    public function addTagField(string $name, ?string $separator, bool $sortable = false, bool $noIndex = false): self
-    {
-        return $this->addField(new TagField($name, $separator, $sortable, $noIndex));
-    }
-
-    /**
-     * @return array<float|int|string>
-     */
-    private function createRequest(): array
-    {
-        assert(is_string($this->name));
-
-        $query = ['FT.CREATE', $this->name];
-        if (is_string($this->structure)) {
-            $query[] = 'ON';
-            $query[] = $this->structure;
-        }
-        $query = RedisHelper::buildQueryList($query, [
-            'PREFIX' => $this->prefix,
-            'STOPWORDS' => $this->stopWords,
-        ]);
-        $query = RedisHelper::buildQueryBoolean($query, [
-            'NOOFFSETS' => $this->noOffsets,
-            'NOHL' => $this->noHighlight,
-            'NOFIELDS' => $this->noFields,
-            'NOFREQS' => $this->noFrequencies,
-            'SKIPINITIALSCAN' => $this->skipInitialScan,
-        ]);
-        $query = RedisHelper::buildQueryNotNull($query, [
-            'FILTER' => $this->filter,
-            'LANGUAGE' => $this->language,
-            'LANGUAGE_FIELD' => $this->languageField,
-            'SCORE' => $this->score,
-            'SCORE_FIELD' => $this->scoreField,
-            'PAYLOAD_FIELD' => $this->payloadField,
-            'MAXTEXTFIELDS' => $this->maxTextFields,
-            'TEMPORARY' => $this->temporary,
-        ]);
-        if (count($this->fields) > 0) {
-            $query[] = 'SCHEMA';
-
-            foreach ($this->fields as $field) {
-                /** @var Field $field */
-                $query = array_merge($query, $field->getQueryParts());
-            }
-        }
-
-        return $query;
-    }
-
-    private function validateData(): bool
-    {
-        if (null === $this->name) {
-            throw new MissingNameException();
-        }
-        if (count($this->fields) > self::MAXIMUM_ALLOWED_FIELDS) {
-            throw new TooManyFields('Maximum allowed fields is 1024');
-        }
-        if (count(array_filter($this->fields, function (Field $field) {
-            return Field::TYPE_TEXT === $field->getType();
-        })) > self::MAXIMUM_ALLOWED_TEXT_FIELDS) {
-            throw new TooManyFields('Maximum allowed Text field is 128');
-        }
-
-        return true;
-    }
-}
diff --git a/src/Index/Builder/AbstractField.php b/src/Index/Builder/AbstractField.php
deleted file mode 100644
index 7a01d90..0000000
--- a/src/Index/Builder/AbstractField.php
+++ /dev/null
@@ -1,111 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Index\Builder;
-
-use function array_merge;
-use function implode;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use SGH\Comparable\ComparatorException;
-
-abstract class AbstractField implements Field
-{
-    /** @var string */
-    private $name;
-
-    /** @var bool */
-    private $sortable = false;
-
-    /** @var bool */
-    private $noIndex = false;
-
-    public function __construct(string $name, bool $sortable = false, bool $noIndex = false)
-    {
-        $this->name = $name;
-        $this->sortable = $sortable;
-        $this->noIndex = $noIndex;
-    }
-
-    public function isNoStem(): bool
-    {
-        return false;
-    }
-
-    public function getPhonetic(): ?string
-    {
-        return null;
-    }
-
-    public function getWeight(): ?float
-    {
-        return null;
-    }
-
-    public function getSeparator(): ?string
-    {
-        return null;
-    }
-
-    public function isSortable(): bool
-    {
-        return $this->sortable;
-    }
-
-    public function isNoIndex(): bool
-    {
-        return $this->noIndex;
-    }
-
-    public function getName(): string
-    {
-        return $this->name;
-    }
-
-    public function getQueryParts(): array
-    {
-        $query = array_merge(
-            [$this->name, $this->getType()],
-            $this->getAdditionalQueryParts()
-        );
-
-        return RedisHelper::buildQueryBoolean($query, [
-            'SORTABLE' => $this->sortable,
-            'NOINDEX' => $this->noIndex,
-        ]);
-    }
-
-    public function compareTo($object)
-    {
-        if (!($object instanceof Field)) {
-            throw new ComparatorException();
-        }
-
-        return implode(' ', $object->getQueryParts()) <=> implode(' ', $this->getQueryParts());
-    }
-
-    /**
-     * @return array<float|int|string>
-     */
-    protected function getAdditionalQueryParts(): array
-    {
-        return [];
-    }
-}
diff --git a/src/Index/Builder/Exception/UnsupportedLanguageException.php b/src/Index/Builder/Exception/UnsupportedLanguageException.php
deleted file mode 100644
index 2feac01..0000000
--- a/src/Index/Builder/Exception/UnsupportedLanguageException.php
+++ /dev/null
@@ -1,28 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Index\Builder\Exception;
-
-use UnexpectedValueException;
-
-class UnsupportedLanguageException extends UnexpectedValueException
-{
-}
diff --git a/src/Index/Builder/Field.php b/src/Index/Builder/Field.php
deleted file mode 100644
index bbf1961..0000000
--- a/src/Index/Builder/Field.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Index\Builder;
-
-use MacFJA\RediSearch\PartialQuery;
-use SGH\Comparable\Comparable;
-
-interface Field extends PartialQuery, Comparable
-{
-    public const TYPE_TEXT = 'TEXT';
-
-    public const TYPE_NUMERIC = 'NUMERIC';
-
-    public const TYPE_GEO = 'GEO';
-
-    public const TYPE_TAG = 'TAG';
-
-    public function getName(): string;
-
-    public function getType(): string;
-
-    public function isSortable(): bool;
-
-    public function isNoStem(): bool;
-
-    public function isNoIndex(): bool;
-
-    public function getPhonetic(): ?string;
-
-    public function getWeight(): ?float;
-
-    public function getSeparator(): ?string;
-}
diff --git a/src/Index/InfoResult.php b/src/Index/InfoResult.php
deleted file mode 100644
index 3b94b3a..0000000
--- a/src/Index/InfoResult.php
+++ /dev/null
@@ -1,248 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Index;
-
-use function array_map;
-use function array_shift;
-use function assert;
-use function in_array;
-use function is_array;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Index\Builder\Field;
-use MacFJA\RediSearch\Index\Builder\GeoField;
-use MacFJA\RediSearch\Index\Builder\NumericField;
-use MacFJA\RediSearch\Index\Builder\TagField;
-use MacFJA\RediSearch\Index\Builder\TextField;
-use function sprintf;
-use UnexpectedValueException;
-
-class InfoResult
-{
-    /** @var int */
-    protected $maxDocumentId;
-
-    /** @var string */
-    private $indexName;
-
-    /** @var array<string> */
-    private $indexOptions = [];
-
-    /** @var array<array<string>|string> */
-    private $definition = [];
-
-    /** @var array<mixed> */
-    private $fields = [];
-
-    /** @var int */
-    private $documentCount;
-
-    /** @var int */
-    private $termsCount;
-
-    /** @var float */
-    private $averageRecordSize;
-
-    /** @var float */
-    private $indexSize;
-
-    /** @var int */
-    private $indexBlockCount;
-
-    /** @var bool */
-    private $indexing;
-
-    /** @var int */
-    private $indexingFailureCount;
-
-    /** @var float */
-    private $percentIndexed;
-
-    /** @var array<string> */
-    private $stopWords;
-
-    /**
-     * @param array<string>               $indexOptions
-     * @param array<array<string>|string> $definition
-     * @param array<mixed>                $fields
-     * @param array<string>               $stopWords
-     *
-     * @SuppressWarnings(PHPMD.ExcessiveParameterList)
-     */
-    public function __construct(string $indexName, array $indexOptions, array $definition, array $fields, int $documentCount, int $maxDocumentId, int $termsCount, float $averageRecordSize, float $indexSize, int $indexBlockCount, bool $indexing, int $indexingFailureCount, float $percentIndexed, array $stopWords)
-    {
-        /** @var array<string> $prefixes */
-        $prefixes = $definition['prefixes'] ?? [];
-        assert(is_array($prefixes));
-        unset($definition['prefixes']);
-        /** @var array<string> $definition */
-        DataHelper::assertArrayOf($indexOptions, 'string');
-        DataHelper::assertArrayOf($definition, 'string');
-        DataHelper::assertArrayOf($prefixes, 'string');
-        DataHelper::assertArrayOf($stopWords, 'string');
-        $definition['prefixes'] = $prefixes;
-
-        $this->indexName = $indexName;
-        $this->indexOptions = $indexOptions;
-        $this->definition = $definition;
-        $this->fields = $fields;
-        $this->documentCount = $documentCount;
-        $this->maxDocumentId = $maxDocumentId;
-        $this->termsCount = $termsCount;
-        $this->averageRecordSize = $averageRecordSize;
-        $this->indexSize = $indexSize;
-        $this->indexBlockCount = $indexBlockCount;
-        $this->indexing = $indexing;
-        $this->indexingFailureCount = $indexingFailureCount;
-        $this->percentIndexed = $percentIndexed;
-        $this->stopWords = $stopWords;
-    }
-
-    public function getIndexName(): string
-    {
-        return $this->indexName;
-    }
-
-    /**
-     * @return array<string>
-     */
-    public function getIndexOptions(): array
-    {
-        return $this->indexOptions;
-    }
-
-    /**
-     * @return array<array<string>|string>
-     */
-    public function getDefinition(): array
-    {
-        return $this->definition;
-    }
-
-    /**
-     * @return array<mixed>
-     */
-    public function getFields(): array
-    {
-        return $this->fields;
-    }
-
-    /**
-     * @return array<Field>
-     */
-    public function getFieldsAsObject(): array
-    {
-        return array_map(function (array $field): Field {
-            $name = (string) array_shift($field);
-            array_shift($field); // type
-            $type = (string) array_shift($field);
-
-            switch ($type) {
-                case 'GEO':
-                    return new GeoField(
-                        $name,
-                        in_array('NOINDEX', $field, true)
-                    );
-                case 'NUMERIC':
-                    return new NumericField(
-                        $name,
-                        in_array('SORTABLE', $field, true),
-                        in_array('NOINDEX', $field, true)
-                    );
-                case 'TAG':
-                    return new TagField(
-                        $name,
-                        /** @phan-suppress-next-line PhanPartialTypeMismatchArgument */
-                        DataHelper::nullOrCast(RedisHelper::getValue($field, 'SEPARATOR'), 'string'),
-                        in_array('SORTABLE', $field, true),
-                        in_array('NOINDEX', $field, true)
-                    );
-                case 'TEXT':
-                    return new TextField(
-                        $name,
-                        in_array('NOSTEM', $field, true),
-                        /** @phan-suppress-next-line PhanPartialTypeMismatchArgument */
-                        DataHelper::nullOrCast(RedisHelper::getValue($field, 'WEIGHT'), 'float'),
-                        /** @phan-suppress-next-line PhanPartialTypeMismatchArgument */
-                        DataHelper::nullOrCast(RedisHelper::getValue($field, 'PHONETIC'), 'string'),
-                        in_array('SORTABLE', $field, true),
-                        in_array('NOINDEX', $field, true)
-                    );
-                default:
-                    throw new UnexpectedValueException(sprintf('Unknown field type "%s" for field "%s"', $type, $name));
-            }
-        }, $this->fields);
-    }
-
-    public function getDocumentCount(): int
-    {
-        return $this->documentCount;
-    }
-
-    public function getMaxDocumentId(): int
-    {
-        return $this->maxDocumentId;
-    }
-
-    public function getTermsCount(): int
-    {
-        return $this->termsCount;
-    }
-
-    public function getAverageRecordSize(): float
-    {
-        return $this->averageRecordSize;
-    }
-
-    public function getIndexSize(): float
-    {
-        return $this->indexSize;
-    }
-
-    public function getIndexBlockCount(): int
-    {
-        return $this->indexBlockCount;
-    }
-
-    public function isIndexing(): bool
-    {
-        return $this->indexing;
-    }
-
-    public function getIndexingFailureCount(): int
-    {
-        return $this->indexingFailureCount;
-    }
-
-    public function getPercentIndexed(): float
-    {
-        return $this->percentIndexed;
-    }
-
-    /**
-     * @return array<string>
-     */
-    public function getStopWords(): array
-    {
-        return $this->stopWords;
-    }
-}
diff --git a/src/IndexBuilder.php b/src/IndexBuilder.php
new file mode 100644
index 0000000..74bb31f
--- /dev/null
+++ b/src/IndexBuilder.php
@@ -0,0 +1,380 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch;
+
+use function count;
+use function in_array;
+use function is_array;
+use function is_float;
+use function is_int;
+use function is_string;
+use function strlen;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait;
+use MacFJA\RediSearch\Redis\Command\Create;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\CreateCommandFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption;
+use RuntimeException;
+
+/**
+ * @method IndexBuilder setIndex(string $name)
+ * @method IndexBuilder withIndex(string $name)
+ * @method IndexBuilder setStructure(string $type)
+ * @method IndexBuilder withStructure(string $type)
+ * @method IndexBuilder setPrefixes(array $prefixes)
+ * @method IndexBuilder withPrefixes(array $prefixes)
+ * @method IndexBuilder addPrefixes(string ...$prefixes)
+ * @method IndexBuilder withAddedPrefixes(string ...$prefixes)
+ * @method IndexBuilder setFilter(string $filter)
+ * @method IndexBuilder withFilter(string $filter)
+ * @method IndexBuilder setDefaultLanguage(string $language)
+ * @method IndexBuilder withDefaultLanguage(string $language)
+ * @method IndexBuilder setLanguageField(string $field)
+ * @method IndexBuilder withLanguageField(string $field)
+ * @method IndexBuilder setDefaultScore(float $score)
+ * @method IndexBuilder withDefaultScore(float $score)
+ * @method IndexBuilder setScoreField(string $field)
+ * @method IndexBuilder withScoreField(string $field)
+ * @method IndexBuilder setPayloadField(string $field)
+ * @method IndexBuilder withPayloadField(string $field)
+ * @method IndexBuilder setMaxTextFields(bool $active)
+ * @method IndexBuilder withMaxTextFields(bool $active)
+ * @method IndexBuilder setTemporary(int $seconds)
+ * @method IndexBuilder withTemporary(int $seconds)
+ * @method IndexBuilder setNoOffsets(bool $active)
+ * @method IndexBuilder withNoOffsets(bool $active)
+ * @method IndexBuilder setNoHighLight(bool $active)
+ * @method IndexBuilder withNoHighLight(bool $active)
+ * @method IndexBuilder setNoFields(bool $active)
+ * @method IndexBuilder withNoFields(bool $active)
+ * @method IndexBuilder setNoFrequencies(bool $active)
+ * @method IndexBuilder withNoFrequencies(bool $active)
+ * @method IndexBuilder setSkipInitialScan(bool $active)
+ * @method IndexBuilder withSkipInitialScan(bool $active)
+ * @method IndexBuilder setStopWords(array $words)
+ * @method IndexBuilder withStopWords(array $words)
+ * @method IndexBuilder addStopWords(string ...$words)
+ * @method IndexBuilder withAddedStopWords(string ...$words)
+ * @method IndexBuilder setFields(array $fields)
+ * @method IndexBuilder withFields(array $fields)
+ * @method IndexBuilder addFields(CreateCommandFieldOption ...$fields)
+ * @method IndexBuilder withAddedFields(CreateCommandFieldOption ...$fields)
+ * @method IndexBuilder withAddedTextField(string $name, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false)
+ * @method IndexBuilder withAddedNumericField(string $name, bool $sortable = false, bool $noIndex = false)
+ * @method IndexBuilder withAddedGeoField(string $name, bool $noIndex = false)
+ * @method IndexBuilder withAddedTagField(string $name, ?string $separator = null, bool $sortable = false, bool $noIndex = false)
+ * @method IndexBuilder withAddedJSONTextField(string $path, string $attribute, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false)
+ * @method IndexBuilder withAddedJSONNumericField(string $path, string $attribute, bool $sortable = false, bool $noIndex = false)
+ * @method IndexBuilder withAddedJSONGeoField(string $path, string $attribute, bool $noIndex = false)
+ * @method IndexBuilder withAddedJSONTagField(string $path, string $attribute, ?string $separator = null, bool $sortable = false, bool $noIndex = false)
+ *
+ * @SuppressWarnings(PHPMD.TooManyFields)
+ */
+class IndexBuilder
+{
+    use AddFieldOptionTrait;
+
+    private const IS_ARRAY = ['prefixes', 'stopWords', 'fields'];
+
+    /** @var null|string */
+    private $index;
+
+    /** @var null|string */
+    private $structure;
+
+    /** @var array<string> */
+    private $prefixes = [];
+
+    /** @var null|string */
+    private $filter;
+
+    /** @var null|string */
+    private $defaultLanguage;
+
+    /** @var null|string */
+    private $languageField;
+
+    /** @var null|float */
+    private $defaultScore;
+
+    /** @var null|string */
+    private $scoreField;
+
+    /** @var null|string */
+    private $payloadField;
+
+    /** @var bool */
+    private $maxTextFields = false;
+
+    /** @var null|int */
+    private $temporary;
+
+    /** @var bool */
+    private $noOffsets = false;
+
+    /** @var bool */
+    private $noHighLight = false;
+
+    /** @var bool */
+    private $noFields = false;
+
+    /** @var bool */
+    private $noFrequencies = false;
+
+    /** @var bool */
+    private $skipInitialScan = false;
+
+    /** @var null|array<string> */
+    private $stopWords;
+
+    /** @var array<CreateCommandFieldOption> */
+    private $fields = [];
+
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return $this
+     */
+    public function __call(string $name, array $arguments): self
+    {
+        $calls = ['set' => true, 'add' => false, 'withAdded' => false, 'with' => true];
+        foreach ($calls as $type => $singleArgument) {
+            $result = $this->doCall($type, $name, true === $singleArgument ? $arguments[0] ?? null : $arguments);
+            if ($result instanceof IndexBuilder) {
+                return $result;
+            }
+        }
+
+        throw new BadMethodCallException(sprintf('Call undefined %s method', $name));
+    }
+
+    public function reset(): self
+    {
+        $this->index = null;
+        $this->structure = null;
+        $this->prefixes = [];
+        $this->filter = null;
+        $this->defaultLanguage = null;
+        $this->languageField = null;
+        $this->defaultScore = null;
+        $this->scoreField = null;
+        $this->payloadField = null;
+        $this->maxTextFields = false;
+        $this->temporary = null;
+        $this->noOffsets = false;
+        $this->noHighLight = false;
+        $this->noFields = false;
+        $this->noFrequencies = false;
+        $this->skipInitialScan = false;
+        $this->stopWords = null;
+        $this->fields = [];
+
+        return $this;
+    }
+
+    public function setNoStopWords(): self
+    {
+        $this->stopWords = [];
+
+        return $this;
+    }
+
+    public function addField(CreateCommandFieldOption $option): self
+    {
+        $this->fields[] = $option;
+
+        return $this;
+    }
+
+    public function addJSONField(string $path, CreateCommandFieldOption $option): self
+    {
+        $this->fields[$option->getFieldName()] = new JSONFieldOption($path, $option);
+
+        return $this;
+    }
+
+    /**
+     * @return mixed|string
+     */
+    public function create(Client $client, string $rediSearchVersion = AbstractCommand::MIN_IMPLEMENTED_VERSION)
+    {
+        $command = $this->getCommand($rediSearchVersion);
+
+        $this->reset();
+
+        return $client->execute($command);
+    }
+
+    /**
+     * @throws RuntimeException if the index is not defined
+     *
+     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+     * @SuppressWarnings(PHPMD.NPathComplexity)
+     */
+    public function getCommand(string $rediSearchVersion = AbstractCommand::MIN_IMPLEMENTED_VERSION): Create
+    {
+        if (!is_string($this->index)) {
+            throw new RuntimeException('The index name is missing');
+        }
+        $command = new Create($rediSearchVersion);
+        $command
+            ->setIndex($this->index)
+            ->setMaxTextFields($this->maxTextFields)
+            ->setNoOffsets($this->noOffsets)
+            ->setNoHighLight($this->noHighLight)
+            ->setNoFields($this->noFields)
+            ->setNoFrequencies($this->noFrequencies)
+            ->setSkipInitialScan($this->skipInitialScan)
+        ;
+        if (is_string($this->structure)) {
+            $command->setStructure($this->structure);
+        }
+        if (count($this->prefixes) > 0) {
+            $command->setPrefixes(...$this->prefixes);
+        }
+        if (is_string($this->filter)) {
+            $command->setFilter($this->filter);
+        }
+        if (is_string($this->defaultLanguage)) {
+            $command->setDefaultLanguage($this->defaultLanguage);
+        }
+        if (is_string($this->languageField)) {
+            $command->setLanguageField($this->languageField);
+        }
+        if (is_float($this->defaultScore)) {
+            $command->setDefaultScore($this->defaultScore);
+        }
+        if (is_string($this->scoreField)) {
+            $command->setScoreField($this->scoreField);
+        }
+        if (is_string($this->payloadField)) {
+            $command->setPayloadField($this->payloadField);
+        }
+        if (is_int($this->temporary)) {
+            $command->setTemporary($this->temporary);
+        }
+        if (is_array($this->stopWords) && count($this->stopWords) > 0) {
+            $command->setStopWords(...$this->stopWords);
+        }
+        if (is_array($this->stopWords) && 0 === count($this->stopWords)) {
+            $command->setNoStopWords();
+        }
+        foreach ($this->fields as $field) {
+            $command->addField($field);
+        }
+
+        return $command;
+    }
+
+    /**
+     * @param array<mixed>|mixed $arguments
+     *
+     * @return null|$this
+     */
+    private function doCall(string $type, string $name, $arguments): ?self
+    {
+        $typeLength = strlen($type);
+        if (0 === strpos($name, $type) && $name[$typeLength] === strtoupper($name[$typeLength])) {
+            return $this->{$type.'Call'}(substr($name, $typeLength), $arguments);
+        }
+
+        return null;
+    }
+
+    /**
+     * @param mixed $argument
+     *
+     * @return $this
+     *
+     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -- Called in doCall
+     */
+    private function withCall(string $name, $argument): self
+    {
+        $newInstance = clone $this;
+
+        return $newInstance->{'set'.$name}($argument);
+    }
+
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return $this
+     *
+     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -- Called in doCall
+     */
+    private function withAddedCall(string $name, array $arguments): self
+    {
+        $newInstance = clone $this;
+
+        return $newInstance->{'add'.$name}(...$arguments);
+    }
+
+    /**
+     * @param mixed $argument
+     *
+     * @return $this
+     *
+     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -- Called in doCall
+     */
+    private function setCall(string $name, $argument): self
+    {
+        $propertyName = lcfirst($name);
+        if (property_exists($this, $propertyName)) {
+            $this->{$propertyName} = $argument;
+
+            return $this;
+        }
+
+        throw new BadMethodCallException(sprintf('Call undefined set%s method', $name));
+    }
+
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return $this
+     *
+     * @SuppressWarnings(PHPMD.UnusedPrivateMethod) -- Called in doCall
+     */
+    private function addCall(string $name, array $arguments): self
+    {
+        $propertyName = lcfirst($name);
+
+        if (!in_array($propertyName, self::IS_ARRAY, true)) {
+            throw new BadMethodCallException(sprintf('Call undefined add%s method', $name));
+        }
+
+        if (0 === count($arguments)) {
+            throw new InvalidArgumentException(sprintf('add%s method need at least one parameter', $name));
+        }
+
+        if (null === $this->{$propertyName}) {
+            $this->{$propertyName} = [];
+        }
+        array_push($this->{$propertyName}, ...$arguments);
+
+        return $this;
+    }
+}
diff --git a/src/Pipeable.php b/src/Pipeable.php
deleted file mode 100644
index a5b13b0..0000000
--- a/src/Pipeable.php
+++ /dev/null
@@ -1,29 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch;
-
-use MacFJA\RediSearch\Helper\PipelineItem;
-
-interface Pipeable
-{
-    public function asPipelineItem(): PipelineItem;
-}
diff --git a/src/Query/Builder.php b/src/Query/Builder.php
new file mode 100644
index 0000000..2f801fb
--- /dev/null
+++ b/src/Query/Builder.php
@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query;
+
+use MacFJA\RediSearch\Query\Builder\AndGroup;
+use MacFJA\RediSearch\Query\Builder\ExactMatch;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\NumericFacet;
+use MacFJA\RediSearch\Query\Builder\QueryElement;
+use MacFJA\RediSearch\Query\Builder\QueryElementGroup;
+use MacFJA\RediSearch\Query\Builder\TagFacet;
+use MacFJA\RediSearch\Query\Builder\TextFacet;
+
+/**
+ * @SuppressWarnings(PHPMD.TooManyPublicMethods)
+ */
+class Builder implements QueryElement, QueryElementGroup
+{
+    /** @var AndGroup */
+    private $group;
+
+    public function __construct()
+    {
+        $this->reset();
+    }
+
+    public function reset(): self
+    {
+        $this->group = new AndGroup();
+
+        return $this;
+    }
+
+    public function addTextFacet(string $field, string ...$values): self
+    {
+        $this->group->addElement(
+            new TextFacet([$field], ...$values)
+        );
+
+        return $this;
+    }
+
+    public function addTagFacet(string $tagName, string ...$values): self
+    {
+        $this->group->addElement(
+            new TagFacet([$tagName], ...$values)
+        );
+
+        return $this;
+    }
+
+    public function addNumericFacet(string $fieldName, ?float $min, ?float $max): self
+    {
+        $this->group->addElement(
+            new NumericFacet([$fieldName], $min, $max)
+        );
+
+        return $this;
+    }
+
+    public function addGeoFacet(string $fieldName, float $lon, float $lat, float $radius, string $unit): self
+    {
+        $this->group->addElement(
+            new GeoFacet([$fieldName], $lon, $lat, $radius, $unit)
+        );
+
+        return $this;
+    }
+
+    public function addString(string ...$strings): self
+    {
+        foreach ($strings as $string) {
+            $this->group->addElement(new ExactMatch($string));
+        }
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function addElement(QueryElement $element): QueryElementGroup
+    {
+        $this->group->addElement($element);
+
+        return $this;
+    }
+
+    public function render(?callable $escaper = null): string
+    {
+        $rendered = $this->group->render(null);
+        $this->reset();
+
+        return $rendered;
+    }
+
+    public function priority(): int
+    {
+        return self::PRIORITY_BEFORE;
+    }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
+}
diff --git a/src/Search/QueryBuilder/OrGroup.php b/src/Query/Builder/AbstractGroup.php
similarity index 52%
rename from src/Search/QueryBuilder/OrGroup.php
rename to src/Query/Builder/AbstractGroup.php
index ec3dada..cc276b8 100644
--- a/src/Search/QueryBuilder/OrGroup.php
+++ b/src/Query/Builder/AbstractGroup.php
@@ -19,44 +19,30 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use function array_map;
-use function count;
-use function implode;
-use function sprintf;
-use function substr;
-use function usort;
-
-class OrGroup implements GroupPartialQuery
+abstract class AbstractGroup implements QueryElement, QueryElementGroup
 {
-    /** @var array<PartialQuery> */
-    private $expressions = [];
+    /**
+     * @var array<QueryElement>
+     */
+    protected $elements;
 
-    public function render(): string
+    /**
+     * @param array<QueryElement> $elements
+     */
+    final public function __construct(array $elements = [])
     {
-        usort($this->expressions, function (PartialQuery $queryA, PartialQuery $queryB) {
-            return $queryA->priority() <=> $queryB->priority();
-        });
-        $terms = array_map(function (PartialQuery $query): string {
-            if ($query->includeSpace()) {
-                return sprintf('(%s)', $query->render());
-            }
-
-            return $query->render();
-        }, $this->expressions);
-
-        return sprintf('(%s)', implode('|', $terms));
+        $this->elements = $elements;
+        $this->sortElement();
     }
 
-    public function includeSpace(): bool
+    public function addElement(QueryElement $element): QueryElementGroup
     {
-        return false;
-    }
+        $this->elements[] = $element;
+        $this->sortElement();
 
-    public function addExpression(PartialQuery $expression): void
-    {
-        $this->expressions[] = $expression;
+        return $this;
     }
 
     public function priority(): int
@@ -64,18 +50,31 @@ public function priority(): int
         return self::PRIORITY_NORMAL;
     }
 
-    public static function renderNoParentheses(PartialQuery ...$queries): string
+    public function includeSpace(): bool
     {
-        if (0 === count($queries)) {
-            return '';
+        /** @var QueryElement $element */
+        foreach ($this->elements as $element) {
+            if ($element->includeSpace()) {
+                return true;
+            }
         }
 
-        $group = new self();
-        foreach ($queries as $query) {
-            $group->addExpression($query);
-        }
-        $rendered = $group->render();
+        return false;
+    }
+
+    public static function fromStrings(string ...$words): self
+    {
+        $words = array_map(static function (string $word): ExactMatch {
+            return new ExactMatch($word);
+        }, $words);
+
+        return new static($words);
+    }
 
-        return substr($rendered, 1, -1) ?: $rendered;
+    private function sortElement(): void
+    {
+        usort($this->elements, static function (QueryElement $elementA, QueryElement $elementB) {
+            return $elementA->priority() <=> $elementB->priority();
+        });
     }
 }
diff --git a/src/Query/Builder/AndGroup.php b/src/Query/Builder/AndGroup.php
new file mode 100644
index 0000000..8a96425
--- /dev/null
+++ b/src/Query/Builder/AndGroup.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function count;
+
+class AndGroup extends AbstractGroup
+{
+    public function render(?callable $escaper = null): string
+    {
+        if (1 === count($this->elements)) {
+            return reset($this->elements)->render($escaper);
+        }
+
+        return implode(' ', array_map(static function (QueryElement $element) use ($escaper): string {
+            if ($element instanceof OrGroup) {
+                return (new EncapsulationGroup('(', ')', $element))->render($escaper);
+            }
+
+            return $element->render($escaper);
+        }, $this->elements));
+    }
+
+    public function includeSpace(): bool
+    {
+        return count($this->elements) > 1 || parent::includeSpace();
+    }
+}
diff --git a/src/Query/Builder/EncapsulationGroup.php b/src/Query/Builder/EncapsulationGroup.php
new file mode 100644
index 0000000..9c7c871
--- /dev/null
+++ b/src/Query/Builder/EncapsulationGroup.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+class EncapsulationGroup implements QueryElement, QueryElementDecorator
+{
+    /** @var string */
+    private $open;
+
+    /** @var string */
+    private $close;
+
+    /** @var bool */
+    private $omitIfNoSpace;
+
+    /** @var QueryElement */
+    private $element;
+
+    public function __construct(string $open, string $close, QueryElement $element, bool $omitIfNoSpace = false)
+    {
+        $this->open = $open;
+        $this->close = $close;
+        $this->omitIfNoSpace = $omitIfNoSpace;
+        $this->element = $element;
+    }
+
+    public function render(?callable $escaper = null): string
+    {
+        if ($this->omitIfNoSpace && !$this->element->includeSpace()) {
+            return $this->element->render($escaper);
+        }
+
+        return sprintf('%s%s%s', $this->open, $this->element->render($escaper), $this->close);
+    }
+
+    public function priority(): int
+    {
+        return self::PRIORITY_NORMAL;
+    }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
+
+    public static function simple(QueryElement $element): self
+    {
+        return new self('(', ')', $element, true);
+    }
+}
diff --git a/src/Search/QueryBuilder/ExactMatch.php b/src/Query/Builder/ExactMatch.php
similarity index 70%
rename from src/Search/QueryBuilder/ExactMatch.php
rename to src/Query/Builder/ExactMatch.php
index b6fa311..c665e65 100644
--- a/src/Search/QueryBuilder/ExactMatch.php
+++ b/src/Query/Builder/ExactMatch.php
@@ -19,38 +19,36 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use function sprintf;
-use function strpos;
+use MacFJA\RediSearch\Query\Escaper;
 
-class ExactMatch implements PartialQuery
+class ExactMatch implements QueryElement
 {
     /** @var string */
-    private $match;
+    private $word;
 
-    public function __construct(string $match)
+    public function __construct(string $word)
     {
-        $this->match = $match;
+        $this->word = $word;
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
-        if (false === strpos($this->match, ' ')) {
-            return EscapeHelper::escapeWord($this->match);
+        if (false === strpos($this->word, ' ')) {
+            return $this->word;
         }
 
-        return sprintf('"%s"', EscapeHelper::escapeExactMatch($this->match));
+        return sprintf('"%s"', Escaper::escapeExactMatch($this->word));
     }
 
-    public function includeSpace(): bool
+    public function priority(): int
     {
-        return false;
+        return self::PRIORITY_NORMAL;
     }
 
-    public function priority(): int
+    public function includeSpace(): bool
     {
-        return self::PRIORITY_BEFORE;
+        return false;
     }
 }
diff --git a/src/Search/QueryBuilder/TagFacet.php b/src/Query/Builder/FieldFacet.php
similarity index 61%
rename from src/Search/QueryBuilder/TagFacet.php
rename to src/Query/Builder/FieldFacet.php
index 57ccf15..2541a7c 100644
--- a/src/Search/QueryBuilder/TagFacet.php
+++ b/src/Query/Builder/FieldFacet.php
@@ -19,46 +19,49 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use function array_map;
 use function count;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use MacFJA\RediSearch\Search\Exception\NotEnoughTermsException;
-use function sprintf;
 
-class TagFacet implements PartialQuery
-{
-    /** @var string */
-    private $field;
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Query\Escaper;
 
+class FieldFacet implements QueryElement
+{
     /** @var array<string> */
-    private $orValues;
+    private $fields;
 
-    public function __construct(string $field, string ...$orValues)
-    {
-        DataHelper::assert(count($orValues) > 0, NotEnoughTermsException::class);
-        $this->field = $field;
-        $this->orValues = $orValues;
-    }
+    /** @var QueryElement */
+    private $element;
 
-    public function render(): string
+    /**
+     * @param array<string> $fields
+     */
+    public function __construct(array $fields, QueryElement $element)
     {
-        $terms = OrGroup::renderNoParentheses(...array_map(function (string $orValue) {
-            return new Word($orValue);
-        }, $this->orValues));
-
-        return sprintf('@%s:{%s}', EscapeHelper::escapeFieldName($this->field), $terms);
+        if (0 === count($fields)) {
+            throw new NotEnoughFieldsException();
+        }
+        $this->fields = array_map([Escaper::class, 'escapeFieldName'], $fields);
+        $this->element = $element;
     }
 
-    public function includeSpace(): bool
+    public function render(?callable $escaper = null): string
     {
-        return false;
+        return sprintf(
+            '@%s:%s',
+            OrGroup::fromStrings(...$this->fields)->render(),
+            EncapsulationGroup::simple($this->element)->render()
+        );
     }
 
     public function priority(): int
     {
         return self::PRIORITY_NORMAL;
     }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
 }
diff --git a/src/Search/QueryBuilder/FuzzyWord.php b/src/Query/Builder/Fuzzy.php
similarity index 61%
rename from src/Search/QueryBuilder/FuzzyWord.php
rename to src/Query/Builder/Fuzzy.php
index b62726e..4fbc423 100644
--- a/src/Search/QueryBuilder/FuzzyWord.php
+++ b/src/Query/Builder/Fuzzy.php
@@ -19,42 +19,42 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use MacFJA\RediSearch\Search\Exception\OutOfRangeLevenshteinDistanceException;
-use function sprintf;
-use function str_repeat;
+use MacFJA\RediSearch\Exception\OutOfRangeLevenshteinDistanceException;
+use MacFJA\RediSearch\Query\Escaper;
 
-class FuzzyWord implements PartialQuery
+class Fuzzy implements QueryElement
 {
     /** @var string */
     private $word;
 
     /** @var int */
-    private $levenshteinDistance;
+    private $distance;
 
-    public function __construct(string $word, int $levenshteinDistance = 1)
+    public function __construct(string $word, int $distance = 1)
     {
-        if ($levenshteinDistance < 1 || $levenshteinDistance > 3) {
-            throw new OutOfRangeLevenshteinDistanceException($levenshteinDistance);
+        if ($distance < 1 || $distance > 3) {
+            throw new OutOfRangeLevenshteinDistanceException($distance);
         }
-        $this->word = $word;
-        $this->levenshteinDistance = $levenshteinDistance;
+        $this->word = trim($word);
+        $this->distance = $distance;
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
-        return sprintf('%1$s%2$s%1$s', str_repeat('%', $this->levenshteinDistance), EscapeHelper::escapeFuzzy($this->word));
+        $fuzzy = str_repeat('%', $this->distance);
+
+        return $fuzzy.Escaper::escapeFuzzy($this->word).$fuzzy;
     }
 
-    public function includeSpace(): bool
+    public function priority(): int
     {
-        return false;
+        return self::PRIORITY_NORMAL;
     }
 
-    public function priority(): int
+    public function includeSpace(): bool
     {
-        return self::PRIORITY_BEFORE;
+        return strpos($this->word, ' ') > 0;
     }
 }
diff --git a/src/Query/Builder/GeoFacet.php b/src/Query/Builder/GeoFacet.php
new file mode 100644
index 0000000..8d00db1
--- /dev/null
+++ b/src/Query/Builder/GeoFacet.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function count;
+
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Exception\UnknownUnitException;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+use Respect\Validation\Rules\In;
+
+class GeoFacet extends FieldFacet
+{
+    /**
+     * @param array<string> $fields
+     */
+    public function __construct(array $fields, float $lon, float $lat, float $radius, string $unit)
+    {
+        if (0 === count($fields)) {
+            throw new NotEnoughFieldsException();
+        }
+        if (!(new In(GeoFilterOption::UNITS))->validate($unit)) {
+            throw new UnknownUnitException($unit);
+        }
+
+        $group = new EncapsulationGroup('[', ']', new RawElement(sprintf(
+            '%f %f %f %s',
+            $lon,
+            $lat,
+            $radius,
+            $unit
+        )));
+        parent::__construct($fields, $group);
+    }
+}
diff --git a/src/Search/QueryBuilder/Optional.php b/src/Query/Builder/Negation.php
similarity index 67%
rename from src/Search/QueryBuilder/Optional.php
rename to src/Query/Builder/Negation.php
index d4144a4..6397441 100644
--- a/src/Search/QueryBuilder/Optional.php
+++ b/src/Query/Builder/Negation.php
@@ -19,39 +19,32 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use function sprintf;
+use MacFJA\RediSearch\Query\Escaper;
 
-class Optional implements PartialQuery
+class Negation implements QueryElement, QueryElementDecorator
 {
-    private const WITH_SPACE_PATTERN = '~(%s)';
+    /** @var QueryElement */
+    private $element;
 
-    private const WITHOUT_SPACE_PATTERN = '~%s';
-
-    /** @var PartialQuery */
-    private $expression;
-
-    public function __construct(PartialQuery $expression)
+    public function __construct(QueryElement $element)
     {
-        $this->expression = $expression;
+        $this->element = $element;
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
-        return sprintf(
-            $this->expression->includeSpace() ? self::WITH_SPACE_PATTERN : self::WITHOUT_SPACE_PATTERN,
-            $this->expression->render()
-        );
+        return '-'.EncapsulationGroup::simple($this->element)->render([Escaper::class, 'escapeNegation']);
     }
 
-    public function includeSpace(): bool
+    public function priority(): int
     {
-        return false;
+        return self::PRIORITY_NORMAL;
     }
 
-    public function priority(): int
+    public function includeSpace(): bool
     {
-        return $this->expression->priority();
+        return false;
     }
 }
diff --git a/src/Query/Builder/NumericFacet.php b/src/Query/Builder/NumericFacet.php
new file mode 100644
index 0000000..3028424
--- /dev/null
+++ b/src/Query/Builder/NumericFacet.php
@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function is_array;
+
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\NumericRangeTrait;
+
+class NumericFacet extends FieldFacet
+{
+    use NumericRangeTrait;
+
+    /**
+     * @param array<string>|string $fields
+     */
+    public function __construct($fields, ?float $min, ?float $max, bool $isMinInclusive = true, bool $isMaxInclusive = true)
+    {
+        if (empty($fields)) {
+            throw new NotEnoughFieldsException();
+        }
+        if (!is_array($fields)) {
+            $fields = [$fields];
+        }
+
+        $bounds = self::renderBound($min, $max, $isMinInclusive, $isMaxInclusive);
+        $group = new EncapsulationGroup('[', ']', AndGroup::fromStrings(...$bounds));
+        parent::__construct($fields, $group);
+    }
+}
diff --git a/alias/Words/SpellingResult.php b/src/Query/Builder/Optional.php
similarity index 67%
rename from alias/Words/SpellingResult.php
rename to src/Query/Builder/Optional.php
index d537be2..7342e87 100644
--- a/alias/Words/SpellingResult.php
+++ b/src/Query/Builder/Optional.php
@@ -19,9 +19,32 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch\Words;
+namespace MacFJA\RediSearch\Query\Builder;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Words\SpellingResult */
-final class SpellingResult extends \MacFJA\RediSearch\Words\SpellingResult
+use MacFJA\RediSearch\Query\Escaper;
+
+class Optional implements QueryElement, QueryElementDecorator
 {
+    /** @var QueryElement */
+    private $element;
+
+    public function __construct(QueryElement $element)
+    {
+        $this->element = $element;
+    }
+
+    public function render(?callable $escaper = null): string
+    {
+        return '~'.EncapsulationGroup::simple($this->element)->render([Escaper::class, 'escapeOptional']);
+    }
+
+    public function priority(): int
+    {
+        return self::PRIORITY_NORMAL;
+    }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
 }
diff --git a/alias/Search/QueryBuilder/OrGroup.php b/src/Query/Builder/OrGroup.php
similarity index 77%
rename from alias/Search/QueryBuilder/OrGroup.php
rename to src/Query/Builder/OrGroup.php
index 802d6bd..f0933f1 100644
--- a/alias/Search/QueryBuilder/OrGroup.php
+++ b/src/Query/Builder/OrGroup.php
@@ -19,9 +19,14 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search\QueryBuilder\OrGroup */
-final class OrGroup extends \MacFJA\RediSearch\Search\QueryBuilder\OrGroup
+class OrGroup extends AbstractGroup
 {
+    public function render(?callable $escaper = null): string
+    {
+        return implode('|', array_map(static function (QueryElement $element) use ($escaper): string {
+            return $element->render($escaper);
+        }, $this->elements));
+    }
 }
diff --git a/src/Query/Builder/Prefix.php b/src/Query/Builder/Prefix.php
new file mode 100644
index 0000000..f8e5bef
--- /dev/null
+++ b/src/Query/Builder/Prefix.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use MacFJA\RediSearch\Query\Escaper;
+
+class Prefix implements QueryElement
+{
+    /** @var string */
+    private $prefix;
+
+    public function __construct(string $prefix)
+    {
+        $this->prefix = $prefix;
+    }
+
+    public function render(?callable $escaper = null): string
+    {
+        return Escaper::escapeWord($this->prefix).'*';
+    }
+
+    public function priority(): int
+    {
+        return self::PRIORITY_NORMAL;
+    }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
+}
diff --git a/src/Search/QueryBuilder/PartialQuery.php b/src/Query/Builder/QueryElement.php
similarity index 88%
rename from src/Search/QueryBuilder/PartialQuery.php
rename to src/Query/Builder/QueryElement.php
index 3c5cf91..46ded20 100644
--- a/src/Search/QueryBuilder/PartialQuery.php
+++ b/src/Query/Builder/QueryElement.php
@@ -19,17 +19,17 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-interface PartialQuery
+interface QueryElement
 {
     public const PRIORITY_BEFORE = 0;
-
     public const PRIORITY_NORMAL = 1;
+    public const PRIORITY_AFTER = 2;
 
-    public function render(): string;
-
-    public function includeSpace(): bool;
+    public function render(?callable $escaper = null): string;
 
     public function priority(): int;
+
+    public function includeSpace(): bool;
 }
diff --git a/src/Search/QueryBuilder/QueryExpressionAttribute.php b/src/Query/Builder/QueryElementAttribute.php
similarity index 72%
rename from src/Search/QueryBuilder/QueryExpressionAttribute.php
rename to src/Query/Builder/QueryElementAttribute.php
index c796655..40667d6 100644
--- a/src/Search/QueryBuilder/QueryExpressionAttribute.php
+++ b/src/Query/Builder/QueryElementAttribute.php
@@ -19,22 +19,21 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
+use function count;
 use function implode;
 use function is_bool;
 use function is_float;
 use function is_int;
 use function sprintf;
 
-class QueryExpressionAttribute implements PartialQuery
-{
-    private const WITH_SPACE_PATTERN = '(%s) => { %s }';
-
-    private const WITHOUT_SPACE_PATTERN = '%s => { %s }';
+use NumberFormatter;
 
-    /** @var PartialQuery */
-    private $expression;
+class QueryElementAttribute implements QueryElement, QueryElementDecorator
+{
+    /** @var QueryElement */
+    private $element;
 
     /** @var null|float */
     private $weight;
@@ -48,20 +47,21 @@ class QueryExpressionAttribute implements PartialQuery
     /** @var null|bool */
     private $phonetic;
 
-    public function __construct(PartialQuery $expression, ?float $weight = null, ?int $slop = null, ?bool $inOrder = null, ?bool $phonetic = null)
+    public function __construct(QueryElement $element, ?float $weight = null, ?int $slop = null, ?bool $inOrder = null, ?bool $phonetic = null)
     {
-        $this->expression = $expression;
+        $this->element = $element;
         $this->weight = $weight;
         $this->slop = $slop;
         $this->inOrder = $inOrder;
         $this->phonetic = $phonetic;
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
         $attributes = [];
         if (is_float($this->weight)) {
-            $attributes[] = sprintf('$weight: %f;', $this->weight);
+            $formatter = new NumberFormatter('en_US', NumberFormatter::PATTERN_DECIMAL, '0.0###');
+            $attributes[] = sprintf('$weight: %s;', $formatter->format($this->weight));
         }
         if (is_int($this->slop)) {
             $attributes[] = sprintf('$slop: %d;', $this->slop);
@@ -72,11 +72,16 @@ public function render(): string
         if (is_bool($this->phonetic)) {
             $attributes[] = sprintf('$phonetic: %s;', $this->phonetic ? 'true' : 'false');
         }
+
+        if (0 === count($attributes)) {
+            return $this->element->render($escaper);
+        }
+
         $attributesString = implode(' ', $attributes);
 
         return sprintf(
-            $this->expression->includeSpace() ? self::WITH_SPACE_PATTERN : self::WITHOUT_SPACE_PATTERN,
-            $this->expression->render(),
+            '%s => { %s }',
+            EncapsulationGroup::simple($this->element)->render($escaper),
             $attributesString
         );
     }
@@ -88,6 +93,6 @@ public function includeSpace(): bool
 
     public function priority(): int
     {
-        return $this->expression->priority();
+        return $this->element->priority();
     }
 }
diff --git a/src/Search/Exception/SyntaxErrorException.php b/src/Query/Builder/QueryElementDecorator.php
similarity index 89%
rename from src/Search/Exception/SyntaxErrorException.php
rename to src/Query/Builder/QueryElementDecorator.php
index 938c70c..43ea0fe 100644
--- a/src/Search/Exception/SyntaxErrorException.php
+++ b/src/Query/Builder/QueryElementDecorator.php
@@ -19,10 +19,6 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\Exception;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use RuntimeException;
-
-class SyntaxErrorException extends RuntimeException
-{
-}
+interface QueryElementDecorator extends QueryElement {}
diff --git a/alias/Index.php b/src/Query/Builder/QueryElementGroup.php
similarity index 88%
rename from alias/Index.php
rename to src/Query/Builder/QueryElementGroup.php
index b0551cc..7f8e3aa 100644
--- a/alias/Index.php
+++ b/src/Query/Builder/QueryElementGroup.php
@@ -19,9 +19,9 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch;
+namespace MacFJA\RediSearch\Query\Builder;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Index */
-final class Index extends \MacFJA\RediSearch\Index
+interface QueryElementGroup extends QueryElement
 {
+    public function addElement(QueryElement $element): self;
 }
diff --git a/src/Query/Builder/QueryElementVector.php b/src/Query/Builder/QueryElementVector.php
new file mode 100644
index 0000000..a09ff78
--- /dev/null
+++ b/src/Query/Builder/QueryElementVector.php
@@ -0,0 +1,150 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function array_key_exists;
+use function count;
+use function gettype;
+use function is_int;
+use function is_string;
+use function strlen;
+
+use InvalidArgumentException;
+
+class QueryElementVector implements QueryElementDecorator
+{
+    private const PARAMETERS = [
+        'EF_RUNTIME' => 'integer',
+    ];
+
+    /** @var QueryElement */
+    private $element;
+
+    /** @var int|string */
+    private $number;
+
+    /** @var string */
+    private $field;
+
+    /** @var string */
+    private $blob;
+
+    /** @var null|string */
+    private $scoreAlias;
+
+    /** @var array<string,int|string> */
+    private $parameters = [];
+
+    /**
+     * @param int|scalar|string $number
+     */
+    public function __construct(QueryElement $element, $number, string $field, string $blob, ?string $scoreAlias = null)
+    {
+        if (!is_string($number) && !is_int($number)) {
+            throw new InvalidArgumentException();
+        }
+        $this->element = $element;
+        $this->number = $number;
+        $this->field = $field;
+        $this->blob = $blob;
+        $this->scoreAlias = $scoreAlias;
+    }
+
+    public function render(?callable $escaper = null): string
+    {
+        return sprintf(
+            '(%s)=>[KNN %s @%s $%s%s%s]',
+            $this->element->render($escaper),
+            $this->getRenderedNumber(),
+            $this->getRenderedField(),
+            $this->getRenderedBlob(),
+            $this->getRenderedAttributes(),
+            $this->getRenderedAlias()
+        );
+    }
+
+    /**
+     * @param int|string $value
+     *
+     * @return $this
+     */
+    public function addParameter(string $name, $value): self
+    {
+        if (!array_key_exists($name, self::PARAMETERS)) {
+            throw new InvalidArgumentException();
+        }
+        if (
+            !(gettype($value) === self::PARAMETERS[$name])
+            && (is_string($value) && (strlen($value) < 2 || !(0 === strpos($value, '$'))))
+        ) {
+            throw new InvalidArgumentException();
+        }
+        $this->parameters[$name] = $value;
+
+        return $this;
+    }
+
+    public function priority(): int
+    {
+        return self::PRIORITY_BEFORE;
+    }
+
+    public function includeSpace(): bool
+    {
+        return false;
+    }
+
+    private function getRenderedNumber(): string
+    {
+        return is_string($this->number) ? ('$'.ltrim($this->number, '$')) : ((string) $this->number);
+    }
+
+    private function getRenderedField(): string
+    {
+        return ltrim($this->field, '@');
+    }
+
+    private function getRenderedBlob(): string
+    {
+        return ltrim($this->blob, '$');
+    }
+
+    private function getRenderedAlias(): string
+    {
+        if (!is_string($this->scoreAlias)) {
+            return '';
+        }
+
+        return ' AS '.$this->scoreAlias;
+    }
+
+    private function getRenderedAttributes(): string
+    {
+        if (0 === count($this->parameters)) {
+            return '';
+        }
+
+        return ' '.implode(' ', array_map(static function ($key, $value) {
+            return $key.' '.$value;
+        }, array_keys($this->parameters), array_values($this->parameters)));
+    }
+}
diff --git a/src/Search/QueryBuilder/RawExpression.php b/src/Query/Builder/RawElement.php
similarity index 90%
rename from src/Search/QueryBuilder/RawExpression.php
rename to src/Query/Builder/RawElement.php
index 895f016..e1e6d11 100644
--- a/src/Search/QueryBuilder/RawExpression.php
+++ b/src/Query/Builder/RawElement.php
@@ -19,9 +19,9 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-class RawExpression implements PartialQuery
+class RawElement implements QueryElement
 {
     /** @var string */
     private $content;
@@ -31,7 +31,7 @@ public function __construct(string $content)
         $this->content = $content;
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
         return $this->content;
     }
diff --git a/src/Query/Builder/TagFacet.php b/src/Query/Builder/TagFacet.php
new file mode 100644
index 0000000..bf28da1
--- /dev/null
+++ b/src/Query/Builder/TagFacet.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function count;
+
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Exception\NotEnoughTermsException;
+
+class TagFacet extends FieldFacet
+{
+    public function __construct(array $fields, string ...$values)
+    {
+        if (0 === count($fields)) {
+            throw new NotEnoughFieldsException();
+        }
+        if (0 === count($values)) {
+            throw new NotEnoughTermsException();
+        }
+
+        $orGroup = new OrGroup(array_map(static function (string $value): QueryElement {
+            return new Word($value);
+        }, $values));
+
+        $group = new EncapsulationGroup('{', '}', $orGroup);
+        parent::__construct($fields, $group);
+    }
+}
diff --git a/src/Query/Builder/TextFacet.php b/src/Query/Builder/TextFacet.php
new file mode 100644
index 0000000..3d549fd
--- /dev/null
+++ b/src/Query/Builder/TextFacet.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Query\Builder;
+
+use function count;
+
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Exception\NotEnoughTermsException;
+
+class TextFacet extends FieldFacet
+{
+    /**
+     * @param array<string> $fields
+     */
+    public function __construct(array $fields, string ...$values)
+    {
+        if (0 === count($fields)) {
+            throw new NotEnoughFieldsException();
+        }
+        if (0 === count($values)) {
+            throw new NotEnoughTermsException();
+        }
+
+        $orGroup = new OrGroup(array_map(static function (string $value): QueryElement {
+            return new ExactMatch($value);
+        }, $values));
+
+        $group = new EncapsulationGroup('(', ')', $orGroup, count($values) < 2);
+        parent::__construct($fields, $group);
+    }
+}
diff --git a/src/Search/QueryBuilder/Word.php b/src/Query/Builder/Word.php
similarity index 70%
rename from src/Search/QueryBuilder/Word.php
rename to src/Query/Builder/Word.php
index eb5b710..add431d 100644
--- a/src/Search/QueryBuilder/Word.php
+++ b/src/Query/Builder/Word.php
@@ -19,37 +19,37 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Query\Builder;
 
-use function count;
-use function implode;
-use MacFJA\RediSearch\Helper\EscapeHelper;
+use function call_user_func;
+use function is_callable;
 
-class Word implements PartialQuery
+class Word implements QueryElement
 {
-    /** @var array<string> */
-    private $words = [];
+    /** @var string */
+    private $word;
 
-    /**
-     * @param string ...$words
-     */
-    public function __construct(string ...$words)
+    public function __construct(string $word)
     {
-        $this->words = $words;
+        $this->word = trim($word);
     }
 
-    public function render(): string
+    public function render(?callable $escaper = null): string
     {
-        return EscapeHelper::escapeWord(implode(' ', $this->words));
+        if (is_callable($escaper)) {
+            return call_user_func($escaper, $this->word);
+        }
+
+        return $this->word;
     }
 
-    public function includeSpace(): bool
+    public function priority(): int
     {
-        return count($this->words) > 1;
+        return self::PRIORITY_NORMAL;
     }
 
-    public function priority(): int
+    public function includeSpace(): bool
     {
-        return self::PRIORITY_BEFORE;
+        return strpos($this->word, ' ') > 0;
     }
 }
diff --git a/src/Helper/EscapeHelper.php b/src/Query/Escaper.php
similarity index 90%
rename from src/Helper/EscapeHelper.php
rename to src/Query/Escaper.php
index 705de16..dc1a7cd 100644
--- a/src/Helper/EscapeHelper.php
+++ b/src/Query/Escaper.php
@@ -19,14 +19,14 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Helper;
+namespace MacFJA\RediSearch\Query;
 
 use function preg_quote;
 use function preg_replace;
 use function sprintf;
 use function str_split;
 
-class EscapeHelper
+class Escaper
 {
     private const NOT_ESCAPED_CHAR_REGEX_PATTERN = '/(?<!\\\\)((?:\\\\\\\\)*)(%s)/';
 
@@ -59,7 +59,7 @@ public static function escapeNegation(string $text): string
         $text = self::escapeCommon($text);
         $text = self::escapeCharAtWordBegin($text, '-');
 
-        for ($number = 0; $number < 10; $number++) {
+        for ($number = 0; $number < 10; ++$number) {
             $text = self::escapeCharAtWordBegin($text, (string) $number);
         }
 
@@ -71,16 +71,21 @@ public static function escapeOptional(string $text): string
         return self::escapeCommon($text);
     }
 
+    public static function escapeNothing(string $text): string
+    {
+        return $text;
+    }
+
     /**
      * @codeCoverageIgnore
      */
     private static function escapeCharAtWordBegin(string $inText, string $char): string
     {
         return preg_replace(
-                sprintf(self::CHAR_AT_WORD_BEGIN_REGEX_PATTERN, preg_quote($char, '/')),
-                '$1\\\\$2',
-                $inText
-            ) ?? $inText;
+            sprintf(self::CHAR_AT_WORD_BEGIN_REGEX_PATTERN, preg_quote($char, '/')),
+            '$1\\\\$2',
+            $inText
+        ) ?? $inText;
     }
 
     /**
diff --git a/src/Redis/Client.php b/src/Redis/Client.php
new file mode 100644
index 0000000..e1452b0
--- /dev/null
+++ b/src/Redis/Client.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis;
+
+interface Client
+{
+    /**
+     * @param mixed $redis
+     */
+    public static function make($redis): Client;
+
+    /**
+     * @ return array<mixed>|int|Response|string
+     *
+     * @return mixed
+     */
+    public function execute(Command $command);
+
+    /**
+     * @param float|int|string $args
+     *
+     * @return mixed
+     */
+    public function executeRaw(...$args);
+
+    /**
+     * @return array<mixed>
+     */
+    public function pipeline(Command ...$commands): array;
+
+    /**
+     * @param mixed $redis
+     */
+    public static function supports($redis): bool;
+}
diff --git a/src/Redis/Client/AbstractClient.php b/src/Redis/Client/AbstractClient.php
new file mode 100644
index 0000000..3f12b39
--- /dev/null
+++ b/src/Redis/Client/AbstractClient.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function count;
+use function strlen;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+
+abstract class AbstractClient implements Client
+{
+    /** @var bool */
+    public static $disableNotice = false;
+
+    public function pipeline(Command ...$commands): array
+    {
+        $results = $this->doPipeline(...$commands);
+
+        return array_map(static function ($result, $index) use ($commands) {
+            return $commands[$index]->parseResponse($result);
+        }, $results, array_keys($results));
+    }
+
+    /**
+     * @return array<mixed>
+     */
+    abstract protected function doPipeline(Command ...$commands): array;
+
+    protected static function fcqnExists(string $fcqn): bool
+    {
+        return class_exists($fcqn) || interface_exists($fcqn);
+    }
+
+    /**
+     * @param array<string,array<string>> $classesMethods
+     * @param array<string>               $functions
+     */
+    protected function getMissingMessage(string $name, bool $isExtension, array $classesMethods, array $functions = []): string
+    {
+        $classes = array_keys($classesMethods);
+        $methods = array_map(static function (string $class, array $methods) {
+            return array_map(static function ($method) use ($class) {
+                return $class.'::'.$method;
+            }, $methods);
+        }, $classes, $classesMethods);
+        $methods = array_reduce($methods, static function ($flat, $methods) {
+            return array_merge($flat, $methods);
+        }, []);
+
+        $message = 'The dependency '.$name.' is missing.'.PHP_EOL.'Install the dependency';
+        if (true === $isExtension) {
+            $message = 'The extension '.$name.' is missing.'.PHP_EOL.'Install the extension';
+        }
+        $message .= ' or use a polyfill';
+
+        $classesMessage = null;
+        if (1 === count($classes)) {
+            $classesMessage = 'the class "'.reset($classes).'"';
+        } elseif (count($classes) > 1) {
+            $classesMessage = 'the classes "'.implode('", "', $classes).'"';
+        }
+        $methodsMessage = null;
+        if (1 === count($methods)) {
+            $methodsMessage = 'the method "'.reset($methods).'"';
+        } elseif (count($methods) > 1) {
+            $methodsMessage = 'the methods "'.implode('", "', $methods).'"';
+        }
+        $functionsMessage = null;
+        if (1 === count($functions)) {
+            $functionsMessage = 'the function "'.reset($functions).'"';
+        } elseif (count($functions) > 1) {
+            $functionsMessage = 'the functions "'.implode('", "', $functions).'"';
+        }
+
+        $additional = implode(' and ', array_filter([$classesMessage, $methodsMessage, $functionsMessage]));
+        if (strlen($additional) > 0) {
+            $additional = ' that provide '.$additional;
+        }
+        $message .= $additional.'.';
+
+        return $message;
+    }
+}
diff --git a/src/Redis/Client/AmpRedisClient.php b/src/Redis/Client/AmpRedisClient.php
new file mode 100644
index 0000000..540641c
--- /dev/null
+++ b/src/Redis/Client/AmpRedisClient.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function Amp\Promise\wait;
+use function function_exists;
+
+use Amp\Promise;
+use Amp\Redis\Redis;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use RuntimeException;
+
+class AmpRedisClient extends AbstractClient
+{
+    /** @var Redis */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(Redis $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Amp\Redis', false, [
+                Redis::class => ['query'],
+            ], ['\\Amp\\Promise\\wait']));
+        }
+        $this->redis = $redis;
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function execute(Command $command)
+    {
+        /** @var Promise<mixed> $query */
+        $query = $this->redis->query($command->getId(), ...array_map('strval', $command->getArguments()));
+        $result = wait($query);
+
+        return $command->parseResponse($result);
+    }
+
+    public function executeRaw(...$args)
+    {
+        /** @var Promise<mixed> $query */
+        $query = $this->redis->query(...array_map('strval', $args));
+
+        return wait($query);
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof Redis
+            && method_exists($redis, 'query')
+            && function_exists('\\Amp\\Promise\\wait');
+    }
+
+    public function pipeline(Command ...$commands): array
+    {
+        false === static::$disableNotice
+            && trigger_error('Warning, \\Amp\\Redis\\Redis don\'t use a real Redis Pipeline', E_USER_NOTICE);
+
+        return array_map(function (Command $command) {
+            return $this->execute($command);
+        }, $commands);
+    }
+
+    /**
+     * @codeCoverageIgnore
+     */
+    protected function doPipeline(Command ...$commands): array
+    {
+        return [];
+    }
+}
diff --git a/src/Redis/Client/CheprasovRedisClient.php b/src/Redis/Client/CheprasovRedisClient.php
new file mode 100644
index 0000000..ca71cde
--- /dev/null
+++ b/src/Redis/Client/CheprasovRedisClient.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use Closure;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use RedisClient\Client\AbstractRedisClient;
+use RedisClient\Pipeline\PipelineInterface;
+use RuntimeException;
+
+class CheprasovRedisClient extends AbstractClient
+{
+    /** @var AbstractRedisClient */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(AbstractRedisClient $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Cheprasov Redis', false, [
+                AbstractRedisClient::class => ['executeRaw', 'pipeline'],
+                PipelineInterface::class => [],
+            ]));
+        }
+        $this->redis = $redis;
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function execute(Command $command)
+    {
+        $response = $this->redis->executeRaw(array_merge([$command->getId()], $command->getArguments()));
+
+        return $command->parseResponse($response);
+    }
+
+    public function executeRaw(...$args)
+    {
+        return $this->redis->executeRaw(array_map('strval', $args));
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof AbstractRedisClient
+            && static::fcqnExists(PipelineInterface::class)
+            && method_exists(AbstractRedisClient::class, 'executeRaw')
+            && method_exists(AbstractRedisClient::class, 'pipeline');
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        false === static::$disableNotice
+            && trigger_error('Warning, a workaround is used to enable custom command in pipeline for \\RedisClient\\Pipeline\\PipelineInterface', E_USER_NOTICE);
+
+        return $this->redis->pipeline(function (PipelineInterface $pipeline) use ($commands): void {
+            foreach ($commands as $command) {
+                $closure = Closure::fromCallable(function () use ($command): void {
+                    // @phpstan-ignore-next-line
+                    $this->returnCommand(array_merge([$command->getId()], $command->getArguments()), null);
+                });
+                $closure->call($pipeline);
+            }
+        });
+    }
+}
diff --git a/src/Redis/Client/ClientFacade.php b/src/Redis/Client/ClientFacade.php
new file mode 100644
index 0000000..d16a269
--- /dev/null
+++ b/src/Redis/Client/ClientFacade.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use Amp\Redis\Redis as AmpRedis;
+use Credis_Client as CredisRedis;
+use MacFJA\RediSearch\Redis\Client;
+use Predis\ClientInterface as PredisRedis;
+use Redis as PhpredisRedis;
+use RedisClient\Client\AbstractRedisClient as CheprasovRedis;
+use redisent\Redis as RedisentRedis;
+use Rediska as RediskaRedis;
+use RuntimeException;
+
+class ClientFacade
+{
+    /** @var array<string> */
+    private $factories = [
+        PredisClient::class,
+        CredisClient::class,
+        PhpredisClient::class,
+        PhpiredisClient::class,
+        CheprasovRedisClient::class,
+        RedisentClient::class,
+        RediskaClient::class,
+        AmpRedisClient::class,
+        TinyRedisClient::class,
+    ];
+
+    /**
+     * @param AmpRedis|CheprasovRedis|CredisRedis|mixed|PhpredisRedis|PredisRedis|RedisentRedis|RediskaRedis|resource $redis
+     */
+    public function getClient($redis): Client
+    {
+        /** @var Client $factory */
+        foreach ($this->factories as $factory) {
+            if ($factory::supports($redis)) {
+                return $factory::make($redis);
+            }
+        }
+
+        throw new RuntimeException('Unable to handle the Redis connection');
+    }
+
+    public function addFactory(string $classname): bool
+    {
+        if (!is_subclass_of($classname, Client::class)) {
+            return false;
+        }
+        $this->factories[] = $classname;
+
+        return true;
+    }
+}
diff --git a/src/Redis/Client/CredisClient.php b/src/Redis/Client/CredisClient.php
new file mode 100644
index 0000000..2569e09
--- /dev/null
+++ b/src/Redis/Client/CredisClient.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function is_array;
+
+use Credis_Client;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use RuntimeException;
+
+class CredisClient extends AbstractClient
+{
+    /** @var Credis_Client */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(Credis_Client $redis)
+    {
+        if (!self::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Credis', false, [
+                Credis_Client::class => ['__call'],
+            ]));
+        }
+        $this->redis = $redis;
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function execute(Command $command)
+    {
+        $result = $this->redis->__call($command->getId(), $command->getArguments());
+
+        return $command->parseResponse($this->fixFalseToNull($result));
+    }
+
+    public function executeRaw(...$args)
+    {
+        $command = array_shift($args);
+
+        return $this->fixFalseToNull($this->redis->__call($command, $args));
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof Credis_Client
+            && method_exists($redis, '__call');
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        $pipeline = $this->redis->pipeline();
+        foreach ($commands as $command) {
+            $pipeline = $pipeline->__call($command->getId(), $command->getArguments());
+        }
+
+        return $this->fixFalseToNull($pipeline->exec());
+    }
+
+    /**
+     * @param false|mixed $response
+     *
+     * @return null|mixed
+     */
+    private function fixFalseToNull($response)
+    {
+        if (false === $response) {
+            return null;
+        }
+        if (is_array($response)) {
+            foreach ($response as &$item) {
+                $item = $this->fixFalseToNull($item);
+            }
+        }
+
+        return $response;
+    }
+}
diff --git a/src/Redis/Client/PhpiredisClient.php b/src/Redis/Client/PhpiredisClient.php
new file mode 100644
index 0000000..0c30a6b
--- /dev/null
+++ b/src/Redis/Client/PhpiredisClient.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function function_exists;
+use function is_resource;
+
+use Exception;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use RuntimeException;
+
+/**
+ * @codeCoverageIgnore
+ */
+class PhpiredisClient extends AbstractClient
+{
+    /** @var resource */
+    private $redis;
+
+    /**
+     * @param resource $redis
+     *
+     * @psalm-assert function_exists('phpiredis_command_bs')
+     * @psalm-assert function_exists('phpiredis_multi_command_bs')
+     */
+    private function __construct($redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('phpiredis', true, [], ['phpiredis_command_bs', 'phpiredis_multi_command_bs']));
+        }
+        $this->redis = $redis;
+    }
+
+    public function execute(Command $command)
+    {
+        $rawResponse = phpiredis_command_bs($this->redis, array_merge([$command->getId()], $command->getArguments()));
+
+        return $command->parseResponse($rawResponse);
+    }
+
+    public static function supports($redis): bool
+    {
+        if (!is_resource($redis)
+            || !function_exists('phpiredis_command_bs')
+            || !function_exists('phpiredis_multi_command_bs')
+        ) {
+            return false;
+        }
+
+        try {
+            $response = (string) phpiredis_command_bs($redis, ['PING']);
+
+            return 'pong' === strtolower($response);
+        } catch (Exception $exception) {
+            return false;
+        }
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function executeRaw(...$args)
+    {
+        $args = array_map('strval', $args);
+
+        return phpiredis_command_bs($this->redis, $args);
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        return phpiredis_multi_command_bs($this->redis, array_map(static function (Command $command) {
+            return array_merge([$command->getId()], $command->getArguments());
+        }, $commands));
+    }
+}
diff --git a/src/Redis/Client/PhpredisClient.php b/src/Redis/Client/PhpredisClient.php
new file mode 100644
index 0000000..09bac00
--- /dev/null
+++ b/src/Redis/Client/PhpredisClient.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function count;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use Redis;
+use RuntimeException;
+
+/**
+ * @codeCoverageIgnore
+ */
+class PhpredisClient extends AbstractClient
+{
+    /** @var Redis */
+    private $redis;
+
+    private function __construct(Redis $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('phpredis', true, [
+                Redis::class => ['rawCommand', 'multi', 'exec', 'getOption', 'setOption'],
+            ]));
+        }
+        $this->redis = $redis;
+    }
+
+    public function execute(Command $command)
+    {
+        $arguments = $command->getArguments();
+
+        $originalValue = $this->redis->getOption(Redis::OPT_REPLY_LITERAL) ?? false;
+        $this->redis->setOption(Redis::OPT_REPLY_LITERAL, true);
+
+        if (0 === count($arguments)) {
+            /** @psalm-suppress TooFewArguments */
+            $rawResponse = $this->redis->rawCommand($command->getId());
+        } else {
+            $rawResponse = $this->redis->rawCommand($command->getId(), ...$arguments);
+        }
+
+        $this->redis->setOption(Redis::OPT_REPLY_LITERAL, $originalValue);
+
+        return $command->parseResponse($rawResponse);
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof Redis
+            && method_exists($redis, 'rawCommand')
+            && method_exists($redis, 'multi')
+            && method_exists($redis, 'setOption')
+            && method_exists($redis, 'getOption')
+            && method_exists($redis, 'exec');
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function executeRaw(...$args)
+    {
+        if (count($args) < 1) {
+            return null;
+        }
+
+        // @phpstan-ignore-next-line
+        return $this->redis->rawCommand(...$args);
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        $pipeline = $this->redis->multi();
+        foreach ($commands as $command) {
+            $arguments = $command->getArguments();
+            if (0 === count($arguments)) {
+                $arguments = [null];
+            }
+            $pipeline = $pipeline->rawCommand($command->getId(), ...$arguments);
+        }
+
+        return $pipeline->exec();
+    }
+}
diff --git a/src/Redis/Client/PredisClient.php b/src/Redis/Client/PredisClient.php
new file mode 100644
index 0000000..89cf769
--- /dev/null
+++ b/src/Redis/Client/PredisClient.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function assert;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use Predis\ClientContextInterface;
+use Predis\ClientInterface;
+use Predis\Command\CommandInterface;
+use Predis\Command\RawCommand;
+use Predis\Command\RawFactory;
+use RuntimeException;
+
+class PredisClient extends AbstractClient
+{
+    /** @var ClientInterface */
+    private $redis;
+
+    /** @var null|RawFactory */
+    private static $rawFactory;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(ClientInterface $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Predis', false, [
+                RawCommand::class => [],
+                ClientInterface::class => ['executeCommand', 'pipeline'],
+                ClientContextInterface::class => ['executeCommand'],
+            ]));
+        }
+        $this->redis = $redis;
+    }
+
+    public function execute(Command $command)
+    {
+        $rawResponse = $this->redis->executeCommand(self::createRawCommand(array_merge([$command->getId()], $command->getArguments())));
+
+        return $command->parseResponse($rawResponse);
+    }
+
+    public static function supports($redis): bool
+    {
+        if (
+            !static::fcqnExists(ClientInterface::class)
+            || !static::fcqnExists(RawCommand::class)
+            || !static::fcqnExists(ClientContextInterface::class)
+        ) {
+            return false;
+        }
+
+        return $redis instanceof ClientInterface
+            && method_exists($redis, 'executeCommand')
+            && method_exists($redis, 'pipeline')
+            && method_exists(ClientContextInterface::class, 'executeCommand');
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    /**
+     * @inheridoc
+     */
+    public function executeRaw(...$args)
+    {
+        return $this->redis->executeCommand(self::createRawCommand($args));
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        assert(method_exists($this->redis, 'pipeline'));
+
+        return $this->redis->pipeline(static function (ClientContextInterface $pipeline) use ($commands): void {
+            foreach ($commands as $command) {
+                $pipeline->executeCommand(self::createRawCommand(array_merge([$command->getId()], $command->getArguments())));
+            }
+        });
+    }
+
+    /**
+     * @param array<float|int|string> $args
+     *
+     * @codeCoverageIgnore
+     */
+    private static function createRawCommand(array $args): CommandInterface
+    {
+        if (class_exists(RawFactory::class)) {
+            if (null === self::$rawFactory) {
+                self::$rawFactory = new RawFactory();
+            }
+            $commandID = array_shift($args);
+
+            return self::$rawFactory->create((string) $commandID, $args);
+        }
+
+        // @phpstan-ignore-next-line
+        return new RawCommand($args);
+    }
+}
diff --git a/src/Redis/Client/RedisentClient.php b/src/Redis/Client/RedisentClient.php
new file mode 100644
index 0000000..9a048e3
--- /dev/null
+++ b/src/Redis/Client/RedisentClient.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use redisent\Redis;
+use RuntimeException;
+
+class RedisentClient extends AbstractClient
+{
+    /** @var Redis */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(Redis $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Redisent', false, [
+                Redis::class => ['__call', 'pipeline', 'uncork'],
+            ]));
+        }
+        $this->redis = $redis;
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function execute(Command $command)
+    {
+        $result = $this->redis->__call($command->getId(), $command->getArguments());
+
+        return $command->parseResponse($result);
+    }
+
+    public function executeRaw(...$args)
+    {
+        $command = array_shift($args);
+
+        return $this->redis->__call($command, $args);
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof Redis
+            && method_exists($redis, '__call')
+            && method_exists($redis, 'pipeline')
+            && method_exists($redis, 'uncork');
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        $pipeline = $this->redis->pipeline();
+        foreach ($commands as $command) {
+            $pipeline = $pipeline->__call($command->getId(), $command->getArguments());
+        }
+
+        return $pipeline->uncork();
+    }
+}
diff --git a/src/Redis/Client/Rediska/RediskaRediSearchCommand.php b/src/Redis/Client/Rediska/RediskaRediSearchCommand.php
new file mode 100644
index 0000000..08996e3
--- /dev/null
+++ b/src/Redis/Client/Rediska/RediskaRediSearchCommand.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client\Rediska;
+
+use function count;
+
+use MacFJA\RediSearch\Redis\Client\AbstractClient;
+use Rediska_Command_Abstract;
+use Rediska_Connection_Exec;
+
+/**
+ * @codeCoverageIgnore
+ */
+class RediskaRediSearchCommand extends Rediska_Command_Abstract
+{
+    public function create(): Rediska_Connection_Exec
+    {
+        $connections = $this->_rediska->getConnections();
+
+        if (false === AbstractClient::$disableNotice && count($connections) > 1) {
+            trigger_error('Warning, Multiple redis connections exists, only the first connection will be used', E_USER_NOTICE);
+        }
+        $connection = reset($connections);
+
+        $commands = $this->_arguments;
+        if (!('__redisearch' === $this->_name)) {
+            array_unshift($commands, $this->_name);
+        }
+
+        return new Rediska_Connection_Exec($connection, $commands);
+    }
+}
diff --git a/src/Redis/Client/RediskaClient.php b/src/Redis/Client/RediskaClient.php
new file mode 100644
index 0000000..6459ff1
--- /dev/null
+++ b/src/Redis/Client/RediskaClient.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use function in_array;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use MacFJA\RediSearch\Redis\Initializer;
+use Rediska;
+use Rediska_Command_Abstract;
+use Rediska_Commands;
+use Rediska_Connection_Exec;
+use RuntimeException;
+
+class RediskaClient extends AbstractClient
+{
+    /** @var Rediska */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    private function __construct(Rediska $redis)
+    {
+        if (!static::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage('Rediska', false, [
+                Rediska::class => ['__class', 'pipeline'],
+                Rediska_Command_Abstract::class => [],
+                Rediska_Connection_Exec::class => [],
+                Rediska_Commands::class => ['add', 'getList'],
+            ]));
+        }
+        Initializer::registerCommandsRediska();
+        $this->redis = $redis;
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    public function execute(Command $command)
+    {
+        $result = $this->redis->__call('__redisearch', array_merge([$command->getId()], $command->getArguments()));
+
+        return $command->parseResponse($result);
+    }
+
+    public function executeRaw(...$args)
+    {
+        $command = (string) $args[0];
+        $commands = array_keys(Rediska_Commands::getList());
+
+        if (in_array(strtolower($command), $commands, true)) {
+            array_shift($args);
+
+            return $this->redis->__call($command, $args);
+        }
+
+        return $this->redis->__call('__redisearch', $args);
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof Rediska
+            && static::fcqnExists(Rediska_Commands::class)
+            && static::fcqnExists(Rediska_Command_Abstract::class)
+            && static::fcqnExists(Rediska_Connection_Exec::class)
+            && method_exists($redis, '__call')
+            && method_exists(Rediska_Commands::class, 'add')
+            && method_exists($redis, 'pipeline');
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        $pipeline = $this->redis->pipeline();
+        foreach ($commands as $command) {
+            $pipeline = $pipeline->__call('__redisearch', array_merge([$command->getId()], $command->getArguments()));
+        }
+
+        return $pipeline->execute();
+    }
+}
diff --git a/src/Redis/Client/TinyRedisClient.php b/src/Redis/Client/TinyRedisClient.php
new file mode 100644
index 0000000..67c7a69
--- /dev/null
+++ b/src/Redis/Client/TinyRedisClient.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Client;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+use RuntimeException;
+
+class TinyRedisClient extends AbstractClient
+{
+    /** @var \TinyRedisClient */
+    private $redis;
+
+    /**
+     * @codeCoverageIgnore
+     */
+    public function __construct(\TinyRedisClient $redis)
+    {
+        if (!self::supports($redis)) {
+            throw new RuntimeException($this->getMissingMessage(
+                'TinyRedis',
+                false,
+                [\TinyRedisClient::class => ['__call']]
+            ));
+        }
+        $this->redis = $redis;
+    }
+
+    public function pipeline(Command ...$commands): array
+    {
+        false === static::$disableNotice
+            && trigger_error('Warning, \\TinyRedisClient don\'t use a real Redis Pipeline', E_USER_NOTICE);
+
+        return array_map(function (Command $command) {
+            return $this->execute($command);
+        }, $commands);
+    }
+
+    public function execute(Command $command)
+    {
+        $result = $this->redis->__call($command->getId(), $command->getArguments());
+
+        return $command->parseResponse($result);
+    }
+
+    public function executeRaw(...$args)
+    {
+        $command = array_shift($args);
+
+        return $this->redis->__call($command, $args);
+    }
+
+    public static function supports($redis): bool
+    {
+        return $redis instanceof \TinyRedisClient
+            && method_exists($redis, '__call');
+    }
+
+    public static function make($redis): Client
+    {
+        return new self($redis);
+    }
+
+    /**
+     * @codeCoverageIgnore
+     */
+    protected function doPipeline(Command ...$commands): array
+    {
+        return [];
+    }
+}
diff --git a/src/Redis/Command.php b/src/Redis/Command.php
new file mode 100644
index 0000000..3797483
--- /dev/null
+++ b/src/Redis/Command.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis;
+
+interface Command
+{
+    public function getId(): string;
+
+    public function getRediSearchVersion(): string;
+
+    public function setRediSearchVersion(string $rediSearchVersion): self;
+
+    /**
+     * @return array<mixed>
+     */
+    public function getArguments(): array;
+
+    /**
+     * @param array<mixed>|float|int|mixed|string $data
+     *
+     * @return array<mixed>|int|Response|string
+     */
+    public function parseResponse($data);
+}
diff --git a/src/Redis/Command/AbstractCommand.php b/src/Redis/Command/AbstractCommand.php
new file mode 100644
index 0000000..9acc2c4
--- /dev/null
+++ b/src/Redis/Command/AbstractCommand.php
@@ -0,0 +1,144 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function count;
+use function is_array;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command;
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+
+/**
+ * @SuppressWarnings(PHPMD.NumberOfChildren)
+ */
+abstract class AbstractCommand implements Command
+{
+    public const MAX_IMPLEMENTED_VERSION = '2.4.3';
+    public const MIN_IMPLEMENTED_VERSION = '2.0.0';
+
+    /**
+     * @var array<array<CommandOption>|CommandOption|mixed>
+     */
+    protected $options;
+
+    /** @var string */
+    private $rediSearchVersion;
+
+    /**
+     * @param array<array<CommandOption>|CommandOption|mixed> $options
+     */
+    public function __construct(array $options, string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        $this->rediSearchVersion = $rediSearchVersion;
+
+        $this->options = $options;
+    }
+
+    public function getRediSearchVersion(): string
+    {
+        return $this->rediSearchVersion;
+    }
+
+    public function setRediSearchVersion(string $rediSearchVersion): Command
+    {
+        $this->rediSearchVersion = $rediSearchVersion;
+
+        return $this;
+    }
+
+    /**
+     * @return array<float|int|string>
+     */
+    public function getArguments(): array
+    {
+        if (count($missing = $this->validateRequirements()) > 0) {
+            throw new InvalidArgumentException('Missing command option: '.implode(', ', $missing));
+        }
+
+        $options = array_reduce($this->options, static function ($flatten, $item) {
+            if (!is_array($item)) {
+                $item = [$item];
+            }
+
+            return array_merge($flatten, $item);
+        }, []);
+        $arguments = array_filter($options, function (CommandOption $option) {
+            return $option->isCompatible($this->rediSearchVersion) && $option->isValid();
+        });
+        $arguments = $this->sortArguments($arguments);
+
+        return array_reduce($arguments, function ($carry, CommandOption $option) {
+            return array_merge($carry, $option->render($this->rediSearchVersion));
+        }, []);
+    }
+
+    /**
+     * @codeCoverageIgnore
+     *
+     * @param array<mixed>|float|int|mixed|string $data
+     *
+     * @return mixed
+     */
+    public function parseResponse($data)
+    {
+        return $data;
+    }
+
+    /**
+     * @param array<CommandOption> $arguments
+     *
+     * @return array<CommandOption>
+     */
+    protected function sortArguments(array $arguments): array
+    {
+        return $arguments;
+    }
+
+    /**
+     * @return array<string>
+     */
+    abstract protected function getRequiredOptions(): array;
+
+    /**
+     * @return array<string>
+     */
+    private function validateRequirements(): array
+    {
+        $arguments = array_filter($this->options, function ($option) {
+            if ($option instanceof CommandOption) {
+                return $option->isCompatible($this->rediSearchVersion) && $option->isValid();
+            }
+            if (is_array($option)) {
+                $option = array_filter($option, function (CommandOption $option) {
+                    return $option->isCompatible($this->rediSearchVersion) && $option->isValid();
+                });
+
+                return count($option) > 0;
+            }
+
+            return false;
+        });
+
+        return array_diff($this->getRequiredOptions(), array_keys($arguments));
+    }
+}
diff --git a/src/Redis/Command/AddFieldOptionTrait.php b/src/Redis/Command/AddFieldOptionTrait.php
new file mode 100644
index 0000000..db0fadb
--- /dev/null
+++ b/src/Redis/Command/AddFieldOptionTrait.php
@@ -0,0 +1,173 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function get_class;
+
+use BadMethodCallException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\CreateCommandFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption;
+
+trait AddFieldOptionTrait
+{
+    public function addTextField(string $name, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false): self
+    {
+        return $this->addField(
+            (new TextFieldOption())
+                ->setField($name)
+                ->setNoStem($noStem)
+                ->setWeight($weight)
+                ->setPhonetic($phonetic)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addNumericField(string $name, bool $sortable = false, bool $noIndex = false): self
+    {
+        return $this->addField(
+            (new NumericFieldOption())
+                ->setField($name)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addGeoField(string $name, bool $noIndex = false): self
+    {
+        return $this->addField(
+            (new GeoFieldOption())
+                ->setField($name)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addTagField(string $name, ?string $separator = null, bool $sortable = false, bool $noIndex = false, bool $caseSensitive = false): self
+    {
+        return $this->addField(
+            (new TagFieldOption())
+                ->setField($name)
+                ->setSeparator($separator)
+                ->setCaseSensitive($caseSensitive)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addVectorField(string $name, string $algorithm, string $type, int $dimension, string $distanceMetric, bool $noIndex = false): self
+    {
+        return $this->addField(
+            (new VectorFieldOption())
+                ->setField($name)
+                ->setAlgorithm($algorithm)
+                ->addAttribute($type, $dimension, $distanceMetric)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addJSONTextField(string $path, string $attribute, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false): self
+    {
+        return $this->addJSONField(
+            $path,
+            (new TextFieldOption())
+                ->setField($attribute)
+                ->setNoStem($noStem)
+                ->setWeight($weight)
+                ->setPhonetic($phonetic)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addJSONNumericField(string $path, string $attribute, bool $sortable = false, bool $noIndex = false): self
+    {
+        return $this->addJSONField(
+            $path,
+            (new NumericFieldOption())
+                ->setField($attribute)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addJSONGeoField(string $path, string $attribute, bool $noIndex = false): self
+    {
+        return $this->addJSONField(
+            $path,
+            (new GeoFieldOption())
+                ->setField($attribute)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addJSONTagField(string $path, string $attribute, ?string $separator = null, bool $sortable = false, bool $noIndex = false, bool $caseSensitive = false): self
+    {
+        return $this->addJSONField(
+            $path,
+            (new TagFieldOption())
+                ->setField($attribute)
+                ->setSeparator($separator)
+                ->setCaseSensitive($caseSensitive)
+                ->setSortable($sortable)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addJSONVectorField(string $path, string $attribute, string $algorithm, string $type, int $dimension, string $distanceMetric, bool $noIndex = false): self
+    {
+        return $this->addJSONField(
+            $path,
+            (new VectorFieldOption())
+                ->setField($attribute)
+                ->setAlgorithm($algorithm)
+                ->addAttribute($type, $dimension, $distanceMetric)
+                ->setNoIndex($noIndex)
+        );
+    }
+
+    public function addField(CreateCommandFieldOption $option): self
+    {
+        if (!($this instanceof AbstractCommand)) {
+            throw new BadMethodCallException('This method is not callable in '.get_class($this));
+        }
+
+        $this->options['fields'][$option->getFieldName()] = $option;
+
+        return $this;
+    }
+
+    public function addJSONField(string $path, CreateCommandFieldOption $option): self
+    {
+        if (!($this instanceof AbstractCommand)) {
+            throw new BadMethodCallException('This method is not callable in '.get_class($this));
+        }
+
+        $this->options['fields'][$option->getFieldName()] = new JSONFieldOption($path, $option);
+
+        return $this;
+    }
+}
diff --git a/src/Redis/Command/Aggregate.php b/src/Redis/Command/Aggregate.php
new file mode 100644
index 0000000..1493473
--- /dev/null
+++ b/src/Redis/Command/Aggregate.php
@@ -0,0 +1,276 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function assert;
+use function count;
+use function is_array;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\ApplyOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\LimitOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\SortByOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\WithCursor;
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+use MacFJA\RediSearch\Redis\Response\AggregateResponseItem;
+use MacFJA\RediSearch\Redis\Response\ArrayResponseTrait;
+use MacFJA\RediSearch\Redis\Response\CursorResponse;
+use MacFJA\RediSearch\Redis\Response\PaginatedResponse;
+
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class Aggregate extends AbstractCommand implements PaginatedCommand
+{
+    use ArrayResponseTrait;
+
+    /** @var CommandOption */
+    private $lastAdded;
+
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'query' => new NamelessOption(null, '>=2.0.0'),
+            'verbatim' => new FlagOption('VERBATIM', false, '>= 2.0.0'),
+            'load' => new NumberedOption('LOAD', null, '>=2.0.0'),
+            'loadall' => CV::allowedValues(new NamedOption('LOAD', null, '>=2.0.13'), ['ALL']),
+            'groupby' => [],
+            'sortby' => new SortByOption(),
+            'apply' => [],
+            'limit' => new LimitOption(),
+            'filter' => [],
+            'cursor' => new WithCursor(),
+            'dialect' => CV::isNumeric(new NamedOption('DIALECT', null, '>=2.4.3')),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+        $this->lastAdded = $this->options['index'];
+
+        return $this;
+    }
+
+    public function getIndex(): string
+    {
+        return $this->options['index']->getValue();
+    }
+
+    public function setQuery(string $query): self
+    {
+        $this->options['query']->setValue($query);
+        $this->lastAdded = $this->options['query'];
+
+        return $this;
+    }
+
+    public function setVerbatim(bool $active = true): self
+    {
+        $this->options['verbatim']->setActive($active);
+        $this->lastAdded = $this->options['verbatim'];
+
+        return $this;
+    }
+
+    public function addSortBy(string $field, ?string $direction = null): self
+    {
+        $this->options['sortby']->addField($field, $direction);
+
+        return $this;
+    }
+
+    public function setSortByMax(int $max): self
+    {
+        $this->options['sortby']->setMax($max);
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function setLimit(int $offset, int $size): PaginatedCommand
+    {
+        $this->options['limit']->setOffset($offset)->setSize($size);
+
+        return $this;
+    }
+
+    public function addFilter(string $expression): self
+    {
+        $this->options['filter'][] = new NamedOption('FILTER', $expression);
+
+        return $this;
+    }
+
+    public function addApply(string $expression, string $alias): self
+    {
+        $apply = new ApplyOption();
+        $apply
+            ->setDataOfOption('expression', $expression)
+            ->setDataOfOption('alias', $alias)
+        ;
+        $apply->setParent($this->lastAdded);
+        $this->lastAdded = $apply;
+        $this->options['apply'][] = $apply;
+
+        return $this;
+    }
+
+    public function addGroupBy(GroupByOption $group): self
+    {
+        $this->options['groupby'][] = $group;
+        $this->lastAdded = $group;
+
+        return $this;
+    }
+
+    public function setLoad(string ...$field): self
+    {
+        $this->options['load']->setArguments($field);
+        $this->lastAdded = $this->options['load'];
+
+        return $this;
+    }
+
+    public function setLoadAll(): self
+    {
+        $this->options['load']->setArguments(null);
+        $this->options['loadall']->setValue('ALL');
+        $this->lastAdded = $this->options['loadall'];
+
+        return $this;
+    }
+
+    public function setDialect(int $version): self
+    {
+        $this->options['dialect']->setValue($version);
+
+        return $this;
+    }
+
+    public function setWithCursor(?int $count = null, ?int $maxIdle = null): self
+    {
+        $this->options['cursor']
+            ->setDataOfOption('enabled', true)
+            ->setDataOfOption('count', $count)
+            ->setDataOfOption('maxidle', $maxIdle)
+        ;
+
+        return $this;
+    }
+
+    public function setWithoutCursor(): self
+    {
+        $this->options['cursor']->setDataOfOption('enabled', false);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.AGGREGATE';
+    }
+
+    public function getOffset(): ?int
+    {
+        /** @var LimitOption $limit */
+        $limit = $this->options['limit'];
+
+        return $limit->getOffset();
+    }
+
+    public function getSize(): ?int
+    {
+        /** @var LimitOption $limit */
+        $limit = $this->options['limit'];
+
+        return $limit->getSize();
+    }
+
+    /**
+     * @param array|mixed|string $data
+     *
+     * @return CursorResponse|PaginatedResponse<AggregateResponseItem>
+     *
+     * @phpstan-return CursorResponse|PaginatedResponse
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        if (true === $this->options['cursor']->getDataOfOption('enabled')) {
+            return CursorRead::transformResponse(
+                $data,
+                $this->options['cursor']->getDataOfOption('count'),
+                $this->getIndex(),
+                $this->getRediSearchVersion()
+            );
+        }
+
+        $totalCount = array_shift($data);
+        assert(is_numeric($totalCount));
+        $totalCount = (int) $totalCount;
+
+        $items = array_map(static function (array $document) {
+            return new AggregateResponseItem(self::getPairs($document));
+        }, $data);
+
+        return new PaginatedResponse($this, $totalCount, $items);
+    }
+
+    protected function sortArguments(array $arguments): array
+    {
+        /** @var array<ApplyOption> $applies */
+        $applies = array_filter($arguments, static function (CommandOption $option) {
+            return $option instanceof ApplyOption;
+        });
+        $withoutApplies = array_values(array_filter($arguments, static function (CommandOption $option) {
+            return !($option instanceof ApplyOption);
+        }));
+        for ($index = count($withoutApplies); $index > 0; --$index) {
+            $item = $withoutApplies[$index - 1];
+            foreach ($applies as $apply) {
+                if ($apply->getParent() === $item || (null === $apply->getParent() && 1 === $index)) {
+                    array_splice($withoutApplies, $index, 0, [$apply]);
+                }
+            }
+        }
+
+        return $withoutApplies;
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'query'];
+    }
+}
diff --git a/src/Redis/Command/AggregateCommand/ApplyOption.php b/src/Redis/Command/AggregateCommand/ApplyOption.php
new file mode 100644
index 0000000..b60a1a4
--- /dev/null
+++ b/src/Redis/Command/AggregateCommand/ApplyOption.php
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+
+class ApplyOption extends GroupedOption
+{
+    /** @var null|CommandOption */
+    private $parent;
+
+    public function __construct()
+    {
+        parent::__construct([
+            'expression' => new NamedOption('APPLY', null, '>=2.0.0'),
+            'alias' => new NamedOption('AS', null, '>=2.0.0'),
+        ], ['expression'], [], '>=2.0.0');
+    }
+
+    public function setParent(?CommandOption $parent): void
+    {
+        $this->parent = $parent;
+    }
+
+    public function getParent(): ?CommandOption
+    {
+        return $this->parent;
+    }
+}
diff --git a/src/Redis/Command/AggregateCommand/GroupByOption.php b/src/Redis/Command/AggregateCommand/GroupByOption.php
new file mode 100644
index 0000000..c68ceec
--- /dev/null
+++ b/src/Redis/Command/AggregateCommand/GroupByOption.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
+
+use function count;
+
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+use MacFJA\RediSearch\Redis\Command\PrefixFieldName;
+
+class GroupByOption extends AbstractCommandOption
+{
+    use PrefixFieldName;
+
+    /** @var array<ReduceOption> */
+    private $reducers = [];
+
+    /** @var array<string> */
+    private $fields;
+
+    /**
+     * @param array<string>       $fields
+     * @param array<ReduceOption> $reducers
+     */
+    public function __construct(array $fields, array $reducers = [])
+    {
+        $this->reducers = $reducers;
+        $this->fields = $fields;
+        parent::__construct('>=2.0.0');
+    }
+
+    public function addReducer(ReduceOption $reducer): self
+    {
+        $this->reducers[] = $reducer;
+
+        return $this;
+    }
+
+    public function isValid(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @codeCoverageIgnore Simple getter
+     *
+     * @return array<string,mixed>
+     */
+    public function getOptionData()
+    {
+        return [
+            'fields' => $this->fields,
+            'reducers' => $this->reducers,
+        ];
+    }
+
+    protected function doRender(?string $version): array
+    {
+        $groupBy = array_merge(['GROUPBY', count($this->fields)], self::prefixFields($this->fields));
+        $reducers = array_reduce($this->reducers, static function ($flatten, ReduceOption $reduce) use ($version) {
+            return array_merge($flatten, $reduce->render($version));
+        }, []);
+
+        return array_merge($groupBy, $reducers);
+    }
+}
diff --git a/src/Index/Builder/Exception/MissingNameException.php b/src/Redis/Command/AggregateCommand/LimitOption.php
similarity index 88%
rename from src/Index/Builder/Exception/MissingNameException.php
rename to src/Redis/Command/AggregateCommand/LimitOption.php
index 014ba4b..8d22d64 100644
--- a/src/Index/Builder/Exception/MissingNameException.php
+++ b/src/Redis/Command/AggregateCommand/LimitOption.php
@@ -19,10 +19,6 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder\Exception;
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
 
-use InvalidArgumentException;
-
-class MissingNameException extends InvalidArgumentException
-{
-}
+class LimitOption extends \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption {}
diff --git a/src/Aggregate/Reducer.php b/src/Redis/Command/AggregateCommand/ReduceOption.php
similarity index 60%
rename from src/Aggregate/Reducer.php
rename to src/Redis/Command/AggregateCommand/ReduceOption.php
index 1dda170..bb2eba3 100644
--- a/src/Aggregate/Reducer.php
+++ b/src/Redis/Command/AggregateCommand/ReduceOption.php
@@ -19,20 +19,22 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate;
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
 
 use function count;
 use function is_bool;
 use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\PartialQuery;
-use Throwable;
+
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+use MacFJA\RediSearch\Redis\Command\PrefixFieldName;
 
 /**
  * @SuppressWarnings(PHPMD.TooManyPublicMethods)
  */
-class Reducer implements PartialQuery
+class ReduceOption extends AbstractCommandOption
 {
+    use PrefixFieldName;
+
     /** @var string */
     private $function;
 
@@ -44,89 +46,90 @@ class Reducer implements PartialQuery
 
     /**
      * @param array<float|int|string> $arguments
-     *
-     * @throws Throwable
      */
-    public function __construct(string $function, array $arguments = [], ?string $name = null)
+    public function __construct(string $function, array $arguments = [], ?string $name = null, ?string $versionConstraint = null)
     {
-        DataHelper::assertArrayOf($arguments, 'scalar');
         $this->function = $function;
         $this->arguments = $arguments;
         $this->name = $name;
+        parent::__construct($versionConstraint);
     }
 
-    public function getQueryParts(): array
+    public function isValid(): bool
     {
-        $query = ['REDUCE', $this->function, count($this->arguments)];
-        foreach ($this->arguments as $argument) {
-            $query[] = $argument;
-        }
-        if (is_string($this->name)) {
-            $query[] = 'AS';
-            $query[] = $this->name;
-        }
+        return true;
+    }
 
-        return $query;
+    /**
+     * @return array<string,null|array<float|int|string>|string>
+     */
+    public function getOptionData()
+    {
+        return [
+            'function' => $this->function,
+            'arguments' => $this->arguments,
+            'alias' => $this->name,
+        ];
     }
 
     public static function count(?string $name = null): self
     {
-        return new self('COUNT', [], $name);
+        return new self('COUNT', [], $name, '>=2.0.0');
     }
 
     public static function countDistinct(string $property, ?string $name = null): self
     {
-        return new self('COUNT_DISTINCT', ['@'.$property], $name);
+        return new self('COUNT_DISTINCT', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function countDistinctish(string $property, ?string $name = null): self
     {
-        return new self('COUNT_DISTINCTISH', ['@'.$property], $name);
+        return new self('COUNT_DISTINCTISH', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function sum(string $property, ?string $name = null): self
     {
-        return new self('SUM', ['@'.$property], $name);
+        return new self('SUM', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function minimum(string $property, ?string $name = null): self
     {
-        return new self('MIN', ['@'.$property], $name);
+        return new self('MIN', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function maximum(string $property, ?string $name = null): self
     {
-        return new self('MAX', ['@'.$property], $name);
+        return new self('MAX', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function average(string $property, ?string $name = null): self
     {
-        return new self('AVG', ['@'.$property], $name);
+        return new self('AVG', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function standardDeviation(string $property, ?string $name = null): self
     {
-        return new self('STDDEV', ['@'.$property], $name);
+        return new self('STDDEV', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function quantile(string $property, float $quantile, ?string $name = null): self
     {
-        return new self('QUANTILE', ['@'.$property, $quantile], $name);
+        return new self('QUANTILE', [self::prefixField($property), $quantile], $name, '>=2.0.0');
     }
 
     public static function median(string $property, ?string $name = null): self
     {
-        return new self('QUANTILE', ['@'.$property, 0.5], $name);
+        return new self('QUANTILE', [self::prefixField($property), 0.5], $name, '>=2.0.0');
     }
 
     public static function toList(string $property, ?string $name = null): self
     {
-        return new self('TOLIST', ['@'.$property], $name);
+        return new self('TOLIST', [self::prefixField($property)], $name, '>=2.0.0');
     }
 
     public static function firstValue(string $property, ?string $sortByProperty = null, ?bool $ascending = null, ?string $name = null): self
     {
-        $arguments = ['@'.$property];
+        $arguments = [self::prefixField($property)];
         if (is_string($sortByProperty)) {
             $arguments[] = 'BY';
             $arguments[] = '@'.$sortByProperty;
@@ -135,6 +138,13 @@ public static function firstValue(string $property, ?string $sortByProperty = nu
             }
         }
 
-        return new self('FIRST_VALUE', $arguments, $name);
+        return new self('FIRST_VALUE', $arguments, $name, '>=2.0.0');
+    }
+
+    protected function doRender(?string $version): array
+    {
+        $alias = is_string($this->name) ? ['AS', $this->name] : [];
+
+        return array_merge(['REDUCE', $this->function, count($this->arguments)], $this->arguments, $alias);
     }
 }
diff --git a/src/Redis/Command/AggregateCommand/SortByOption.php b/src/Redis/Command/AggregateCommand/SortByOption.php
new file mode 100644
index 0000000..7b54bc4
--- /dev/null
+++ b/src/Redis/Command/AggregateCommand/SortByOption.php
@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
+
+use function count;
+use function is_int;
+
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+use MacFJA\RediSearch\Redis\Command\PrefixFieldName;
+
+class SortByOption extends AbstractCommandOption
+{
+    use PrefixFieldName;
+
+    /** @var array<string,null|string> */
+    private $fields = [];
+
+    /** @var null|int */
+    private $max;
+
+    public function setMax(?int $max): SortByOption
+    {
+        $this->max = $max;
+
+        return $this;
+    }
+
+    public function addField(string $name, ?string $direction = null): void
+    {
+        $this->fields[self::prefixField($name)] = $direction;
+    }
+
+    public function isValid(): bool
+    {
+        return count($this->fields) > 0;
+    }
+
+    /**
+     * @codeCoverageIgnore Simple getter
+     *
+     * @return array<string,null|array<string,null|string>|int>
+     */
+    public function getOptionData()
+    {
+        return [
+            'fields' => $this->fields,
+            'max' => $this->max,
+        ];
+    }
+
+    protected function doRender(?string $version): array
+    {
+        $fields = [];
+        foreach ($this->fields as $name => $direction) {
+            $fields[] = $name;
+            if ('ASC' === $direction || 'DESC' === $direction) {
+                $fields[] = $direction;
+            }
+        }
+        $max = is_int($this->max) ? ['MAX', $this->max] : [];
+
+        return array_merge(['SORTBY', count($fields)], $fields, $max);
+    }
+}
diff --git a/src/Redis/Command/AggregateCommand/WithCursor.php b/src/Redis/Command/AggregateCommand/WithCursor.php
new file mode 100644
index 0000000..c523e53
--- /dev/null
+++ b/src/Redis/Command/AggregateCommand/WithCursor.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\AggregateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+
+class WithCursor extends GroupedOption
+{
+    public function __construct()
+    {
+        parent::__construct([
+            'enabled' => new NotEmptyOption(new FlagOption('WITHCURSOR', false, '>=2.0.0')),
+            'count' => CV::isNumeric(new NamedOption('COUNT', null, '>=2.0.0')),
+            'maxidle' => CV::isNumeric(new NamedOption('MAXIDLE', null, '>=2.0.0')),
+        ], ['enabled'], [], '>=2.0.0');
+    }
+}
diff --git a/src/Redis/Command/AliasAdd.php b/src/Redis/Command/AliasAdd.php
new file mode 100644
index 0000000..e20f63f
--- /dev/null
+++ b/src/Redis/Command/AliasAdd.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class AliasAdd extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'alias' => new NamelessOption(null, '>=2.0.0'),
+            'index' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setAlias(string $name): self
+    {
+        $this->options['alias']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.ALIASADD';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'alias'];
+    }
+}
diff --git a/src/Redis/Command/AliasDel.php b/src/Redis/Command/AliasDel.php
new file mode 100644
index 0000000..2c58044
--- /dev/null
+++ b/src/Redis/Command/AliasDel.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class AliasDel extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'alias' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setAlias(string $name): self
+    {
+        $this->options['alias']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.ALIASDEL';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['alias'];
+    }
+}
diff --git a/src/Redis/Command/AliasUpdate.php b/src/Redis/Command/AliasUpdate.php
new file mode 100644
index 0000000..e0dfdbf
--- /dev/null
+++ b/src/Redis/Command/AliasUpdate.php
@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class AliasUpdate extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'alias' => new NamelessOption(null, '>=2.0.0'),
+            'index' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setAlias(string $name): self
+    {
+        $this->options['alias']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.ALIASUPDATE';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'alias'];
+    }
+}
diff --git a/src/Redis/Command/Alter.php b/src/Redis/Command/Alter.php
new file mode 100644
index 0000000..8d65c47
--- /dev/null
+++ b/src/Redis/Command/Alter.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class Alter extends AbstractCommand
+{
+    use AddFieldOptionTrait;
+
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'schema' => new FlagOption('SCHEMA', true, '>=2.0.0'),
+            'add' => new FlagOption('ADD', true, '>=2.0.0'),
+            'fields' => [],
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.ALTER';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'schema', 'add', 'fields'];
+    }
+}
diff --git a/src/Redis/Command/Config.php b/src/Redis/Command/Config.php
new file mode 100644
index 0000000..ba449c6
--- /dev/null
+++ b/src/Redis/Command/Config.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method array<string,string>|string parseResponse(mixed $data)
+ */
+class Config extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'action' => CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.0.0'), ['GET', 'HELP']),
+            'option' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setHelpMode(?string $optionName = null): self
+    {
+        $this->options['action']->setValue('HELP');
+        $this->options['option']->setValue($optionName ?? '*');
+
+        return $this;
+    }
+
+    public function setGetMode(?string $optionName = null): self
+    {
+        $this->options['action']->setValue('GET');
+        $this->options['option']->setValue($optionName ?? '*');
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.CONFIG';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['action', 'option'];
+    }
+}
diff --git a/src/Redis/Command/ConfigSet.php b/src/Redis/Command/ConfigSet.php
new file mode 100644
index 0000000..baefb46
--- /dev/null
+++ b/src/Redis/Command/ConfigSet.php
@@ -0,0 +1,66 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class ConfigSet extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'action' => new FlagOption('SET', true, '>=2.0.0'),
+            'options' => [
+                CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.0.0 <2.0.6'), ['NOGC', 'MAXEXPANSIONS', 'TIMEOUT', 'ON_TIMEOUT', 'MIN_PHONETIC_TERM_LEN']),
+                CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.0.6 <2.4.3'), ['NOGC', 'MINPREFIX', 'MAXEXPANSIONS', 'MAXFILTEREXPANSION', 'TIMEOUT', 'ON_TIMEOUT', 'MIN_PHONETIC_TERM_LEN']),
+                CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.4.3'), ['NOGC', 'MINPREFIX', 'MAXEXPANSIONS', 'TIMEOUT', 'ON_TIMEOUT', 'MIN_PHONETIC_TERM_LEN', 'DEFAULT_DIALECT']),
+            ],
+            'value' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    /**
+     * @param mixed $value
+     *
+     * @return $this
+     */
+    public function setConfig(string $name, $value): self
+    {
+        $this->options['options'][0]->setValue($name);
+        $this->options['options'][1]->setValue($name);
+        $this->options['value']->setValue($value);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.CONFIG';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['action', 'options', 'value'];
+    }
+}
diff --git a/src/Redis/Command/Create.php b/src/Redis/Command/Create.php
new file mode 100644
index 0000000..6e840c9
--- /dev/null
+++ b/src/Redis/Command/Create.php
@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+
+class Create extends AbstractCommand
+{
+    use LanguageOptionTrait;
+    use AddFieldOptionTrait;
+
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'structure' => [
+                CV::allowedValues(new NamedOption('ON', null, '>=2.0.0 <2.2.0'), ['HASH']),
+                CV::allowedValues(new NamedOption('ON', null, '>=2.2.0'), ['HASH', 'JSON']),
+            ],
+            'prefixes' => new NotEmptyOption(new NumberedOption('PREFIX', null, '>=2.0.0')),
+            'filter' => new NamedOption('FILTER', null, '>=2.0.0'),
+            'default_lang' => $this->getLanguageOptions(),
+            'lang_field' => new NamedOption('LANGUAGE_FIELD', null, '>=2.0.0'),
+            'default_score' => CV::isNumeric(new NamedOption('SCORE', null, '>=2.0.0')),
+            'score_field' => new NamedOption('SCORE_FIELD', null, '>=2.0.0'),
+            'payload_field' => new NamedOption('PAYLOAD_FIELD', null, '>=2.0.0'),
+            'max_text_fields' => new FlagOption('MAXTEXTFIELDS', false, '>=2.0.0'),
+            'temporary' => CV::isNumeric(new NamedOption('TEMPORARY', null, '>=2.0.0')),
+            'no_offsets' => new FlagOption('NOOFFSETS', false, '>=2.0.0'),
+            'no_hl' => new FlagOption('NOHL', false, '>=2.0.0'),
+            'no_fields' => new FlagOption('NOFIELDS', false, '>=2.0.0'),
+            'no_freqs' => new FlagOption('NOFREQS', false, '>=2.0.0'),
+            'skip_initial_scan' => new FlagOption('SKIPINITIALSCAN', false, '>=2.0.0'),
+            'stopwords' => new NumberedOption('STOPWORDS', null, '>=2.0.0'),
+            'schema' => new FlagOption('SCHEMA', true, '>=2.0.0'),
+            'fields' => [],
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setStructure(string $structureType = 'HASH'): self
+    {
+        array_walk($this->options['structure'], static function (CV $option) use ($structureType): void {
+            $option->setValue($structureType);
+        });
+
+        return $this;
+    }
+
+    public function setPrefixes(string ...$prefix): self
+    {
+        $this->options['prefixes']->setArguments($prefix);
+
+        return $this;
+    }
+
+    public function setFilter(string $filter): self
+    {
+        $this->options['filter']->setValue($filter);
+
+        return $this;
+    }
+
+    public function setDefaultLanguage(string $language): self
+    {
+        array_walk($this->options['default_lang'], static function (CV $option) use ($language): void {
+            $option->setValue($language);
+        });
+
+        return $this;
+    }
+
+    public function setLanguageField(string $fieldName): self
+    {
+        $this->options['lang_field']->setValue($fieldName);
+
+        return $this;
+    }
+
+    public function setDefaultScore(float $score): self
+    {
+        $this->options['default_score']->setValue($score);
+
+        return $this;
+    }
+
+    public function setScoreField(string $fieldName): self
+    {
+        $this->options['score_field']->setValue($fieldName);
+
+        return $this;
+    }
+
+    public function setPayloadField(string $fieldName): self
+    {
+        $this->options['payload_field']->setValue($fieldName);
+
+        return $this;
+    }
+
+    public function setMaxTextFields(bool $active = true): self
+    {
+        $this->options['max_text_fields']->setActive($active);
+
+        return $this;
+    }
+
+    public function setTemporary(int $seconds): self
+    {
+        $this->options['temporary']->setValue($seconds);
+
+        return $this;
+    }
+
+    public function setNoOffsets(bool $active = true): self
+    {
+        $this->options['no_offsets']->setActive($active);
+
+        return $this;
+    }
+
+    public function setNoHighLight(bool $active = true): self
+    {
+        $this->options['no_hl']->setActive($active);
+
+        return $this;
+    }
+
+    public function setNoFields(bool $active = true): self
+    {
+        $this->options['no_fields']->setActive($active);
+
+        return $this;
+    }
+
+    public function setNoFrequencies(bool $active = true): self
+    {
+        $this->options['no_freqs']->setActive($active);
+
+        return $this;
+    }
+
+    public function setSkipInitialScan(bool $active = true): self
+    {
+        $this->options['skip_initial_scan']->setActive($active);
+
+        return $this;
+    }
+
+    public function setStopWords(string ...$stopWords): self
+    {
+        $this->options['stopwords']->setArguments($stopWords);
+
+        return $this;
+    }
+
+    public function setNoStopWords(): self
+    {
+        $this->options['stopwords']->setArguments([]);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.CREATE';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'schema', 'fields'];
+    }
+}
diff --git a/src/Redis/Command/CreateCommand/BaseCreateFieldOptionTrait.php b/src/Redis/Command/CreateCommand/BaseCreateFieldOptionTrait.php
new file mode 100644
index 0000000..916be34
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/BaseCreateFieldOptionTrait.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use Respect\Validation\Rules\Callback;
+
+trait BaseCreateFieldOptionTrait
+{
+    /**
+     * @param array<array<CommandOption>|CommandOption|mixed> $options $options
+     *
+     * @return array<array<CommandOption>|CommandOption|mixed> $options
+     */
+    private function getConstructorOptions(string $type, array $options = []): array
+    {
+        $self = $this;
+
+        return array_merge(
+            [
+                'field' => new NamelessOption(null, '>=2.0.0'),
+                'type' => new FlagOption($type, true, '>=2.0.0'),
+            ],
+            $options,
+            [
+                'sortable' => new FlagOption('SORTABLE', false, '>=2.0.0'),
+                'un_normalized_sortable' => new CustomValidatorOption(new FlagOption('UNF', false, '>=2.0.12'), new Callback(static function () use ($self) {
+                    return $self->getDataOfOption('sortable');
+                })),
+                'no_index' => new FlagOption('NOINDEX', false, '>=2.0.0'),
+            ]
+        );
+    }
+}
diff --git a/alias/Aggregate/GroupBy.php b/src/Redis/Command/CreateCommand/CreateCommandFieldOption.php
similarity index 83%
rename from alias/Aggregate/GroupBy.php
rename to src/Redis/Command/CreateCommand/CreateCommandFieldOption.php
index bba509b..cdf49af 100644
--- a/alias/Aggregate/GroupBy.php
+++ b/src/Redis/Command/CreateCommand/CreateCommandFieldOption.php
@@ -19,9 +19,11 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch\Aggregate;
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\GroupBy */
-final class GroupBy extends \MacFJA\RediSearch\Aggregate\GroupBy
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+
+interface CreateCommandFieldOption extends CommandOption
 {
+    public function getFieldName(): string;
 }
diff --git a/alias/PartialQuery.php b/src/Redis/Command/CreateCommand/CreateCommandJSONFieldOption.php
similarity index 86%
rename from alias/PartialQuery.php
rename to src/Redis/Command/CreateCommand/CreateCommandJSONFieldOption.php
index 13ba0a9..a41eb67 100644
--- a/alias/PartialQuery.php
+++ b/src/Redis/Command/CreateCommand/CreateCommandJSONFieldOption.php
@@ -19,9 +19,9 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch;
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\PartialQuery */
-interface PartialQuery extends \MacFJA\RediSearch\PartialQuery
+interface CreateCommandJSONFieldOption extends CreateCommandFieldOption
 {
+    public function getJSONPath(): string;
 }
diff --git a/src/Redis/Command/CreateCommand/GeoFieldOption.php b/src/Redis/Command/CreateCommand/GeoFieldOption.php
new file mode 100644
index 0000000..e2a50ef
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/GeoFieldOption.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+
+/**
+ * @method GeoFieldOption setField(string $name)
+ * @method GeoFieldOption setNoIndex(bool $active)
+ * @method GeoFieldOption setSortable(bool $active)
+ * @method GeoFieldOption setUnNormalizedSortable(bool $unNormalized)
+ */
+class GeoFieldOption extends GroupedOption implements CreateCommandFieldOption
+{
+    use BaseCreateFieldOptionTrait;
+    use WithPublicGroupedSetterTrait;
+
+    public function __construct()
+    {
+        parent::__construct($this->getConstructorOptions('GEO'), ['field', 'type'], ['type'], '>=2.0.0');
+    }
+
+    public function getFieldName(): string
+    {
+        return $this->getDataOfOption('field');
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function publicSetter(): array
+    {
+        return ['field', 'no_index', 'sortable', 'un_normalized_sortable'];
+    }
+}
diff --git a/src/Redis/Command/CreateCommand/JSONFieldOption.php b/src/Redis/Command/CreateCommand/JSONFieldOption.php
new file mode 100644
index 0000000..263b16c
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/JSONFieldOption.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use function count;
+
+use Flow\JSONPath\JSONPath;
+use Flow\JSONPath\JSONPathException;
+use MacFJA\RediSearch\Exception\InvalidJSONPathException;
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+
+class JSONFieldOption extends AbstractCommandOption implements CreateCommandJSONFieldOption
+{
+    /** @var CreateCommandFieldOption */
+    private $decorated;
+
+    /** @var string */
+    private $path;
+
+    public function __construct(string $path, CreateCommandFieldOption $decorated)
+    {
+        parent::__construct('>=2.2.0');
+        $this->path = $path;
+        $this->decorated = $decorated;
+        if (!self::isPathValid($path)) {
+            throw new InvalidJSONPathException($path);
+        }
+    }
+
+    public function isValid(): bool
+    {
+        return !empty($this->path) && $this->decorated->isValid();
+    }
+
+    public function getOptionData()
+    {
+        return array_merge(['path' => $this->path], $this->decorated->getOptionData());
+    }
+
+    public function getVersionConstraint(): string
+    {
+        return '>=2.2.0';
+    }
+
+    public function getFieldName(): string
+    {
+        return $this->decorated->getFieldName();
+    }
+
+    public function getJSONPath(): string
+    {
+        return $this->path;
+    }
+
+    /**
+     * @throws InvalidJSONPathException
+     */
+    public static function isPathValid(string $path): bool
+    {
+        if (0 === strpos($path, '$')) {
+            $parser = new JSONPath();
+
+            try {
+                $result = $parser->parseTokens($path);
+
+                return count($result) > 0;
+            } catch (JSONPathException $e) {
+                throw new InvalidJSONPathException($path, $e);
+            }
+        }
+
+        return false;
+    }
+
+    protected function doRender(?string $version): array
+    {
+        return array_merge([$this->path, 'AS'], $this->decorated->render($version));
+    }
+}
diff --git a/src/Configuration.php b/src/Redis/Command/CreateCommand/NumericFieldOption.php
similarity index 54%
rename from src/Configuration.php
rename to src/Redis/Command/CreateCommand/NumericFieldOption.php
index 84904df..7f957cb 100644
--- a/src/Configuration.php
+++ b/src/Redis/Command/CreateCommand/NumericFieldOption.php
@@ -19,47 +19,37 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch;
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
 
-use function is_scalar;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use Predis\Client;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
 
-class Configuration
+/**
+ * @method NumericFieldOption setField(string $name)
+ * @method NumericFieldOption setNoIndex(bool $active)
+ * @method NumericFieldOption setSortable(bool $active)
+ * @method NumericFieldOption setUnNormalizedSortable(bool $unNormalized)
+ */
+class NumericFieldOption extends GroupedOption implements CreateCommandFieldOption
 {
-    /** @var Client */
-    private $redis;
-
-    public function __construct(Client $redis)
-    {
-        $this->redis = $redis;
-    }
+    use BaseCreateFieldOptionTrait;
+    use WithPublicGroupedSetterTrait;
 
-    public function getOption(string $optionName): string
+    public function __construct()
     {
-        return $this->redis->executeRaw(['FT.CONFIG', 'GET', $optionName]);
+        parent::__construct($this->getConstructorOptions('NUMERIC'), ['field', 'type'], ['type'], '>=2.0.0');
     }
 
-    /**
-     * @return array<float|int|string>
-     */
-    public function getAll(): array
+    public function getFieldName(): string
     {
-        $rawResult = $this->redis->executeRaw(['FT.CONFIG', 'GET', '*']);
-
-        return RedisHelper::getPairs($rawResult);
+        return $this->getDataOfOption('field');
     }
 
     /**
-     * @param float|int|string $value
-     * @psalm-suppress RedundantConditionGivenDocblockType
+     * @return string[]
      */
-    public function setOption(string $optionName, $value): bool
+    protected function publicSetter(): array
     {
-        DataHelper::assert(is_scalar($value));
-        $rawResult = $this->redis->executeRaw(['FT.CONFIG', 'GET', $optionName, $value]);
-
-        return 'OK' === $rawResult;
+        return ['field', 'no_index', 'sortable', 'un_normalized_sortable'];
     }
 }
diff --git a/src/Redis/Command/CreateCommand/TagFieldOption.php b/src/Redis/Command/CreateCommand/TagFieldOption.php
new file mode 100644
index 0000000..cee3390
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/TagFieldOption.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+use Respect\Validation\Rules\Length;
+
+/**
+ * @method TagFieldOption setField(string $name)
+ * @method TagFieldOption setNoIndex(bool $active)
+ * @method TagFieldOption setSortable(bool $active)
+ * @method TagFieldOption setSeparator(?string $separator)
+ * @method TagFieldOption setCaseSensitive(bool $active)
+ * @method TagFieldOption setUnNormalizedSortable(bool $unNormalized)
+ */
+class TagFieldOption extends GroupedOption implements CreateCommandFieldOption
+{
+    use BaseCreateFieldOptionTrait;
+    use WithPublicGroupedSetterTrait;
+
+    public function __construct()
+    {
+        parent::__construct($this->getConstructorOptions('TAG', [
+            'separator' => new CustomValidatorOption(new NamedOption('SEPARATOR', null, '>=2.0.0'), new Length(1, 1)),
+            'case_sensitive' => new FlagOption('CASESENSITIVE', false, '>=2.0.11'),
+        ]), ['field', 'type'], ['type'], '>=2.0.0');
+    }
+
+    public function getFieldName(): string
+    {
+        return $this->getDataOfOption('field');
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function publicSetter(): array
+    {
+        return ['field', 'separator', 'sortable', 'no_index', 'case_sensitive', 'un_normalized_sortable'];
+    }
+}
diff --git a/src/Redis/Command/CreateCommand/TextFieldOption.php b/src/Redis/Command/CreateCommand/TextFieldOption.php
new file mode 100644
index 0000000..5efa94b
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/TextFieldOption.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+
+/**
+ * @method TextFieldOption setField(string $name)
+ * @method TextFieldOption setNoIndex(bool $active)
+ * @method TextFieldOption setSortable(bool $active)
+ * @method TextFieldOption setNoStem(bool $active)
+ * @method TextFieldOption setWeight(?float $weight)
+ * @method TextFieldOption setPhonetic(?string $phonetic)
+ * @method TextFieldOption setUnNormalizedSortable(bool $unNormalized)
+ */
+class TextFieldOption extends GroupedOption implements CreateCommandFieldOption
+{
+    use BaseCreateFieldOptionTrait;
+    use WithPublicGroupedSetterTrait;
+
+    public function __construct()
+    {
+        parent::__construct($this->getConstructorOptions('TEXT', [
+            'no_stem' => new FlagOption('NOSTEM', false, '>=2.0.0'),
+            'weight' => CustomValidatorOption::isNumeric(new NamedOption('WEIGHT', null, '>=2.0.0')),
+            'phonetic' => CustomValidatorOption::allowedValues(new NamedOption('PHONETIC', null, '>=2.0.0'), ['dm:en', 'dm:fr', 'dm:pt', 'dm:es']),
+        ]), ['field', 'type'], ['type'], '>=2.0.0');
+    }
+
+    public function getFieldName(): string
+    {
+        return $this->getDataOfOption('field');
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function publicSetter(): array
+    {
+        return ['field', 'no_stem', 'weight', 'phonetic', 'sortable', 'no_index', 'un_normalized_sortable'];
+    }
+}
diff --git a/src/Redis/Command/CreateCommand/VectorFieldOption.php b/src/Redis/Command/CreateCommand/VectorFieldOption.php
new file mode 100644
index 0000000..f43d94c
--- /dev/null
+++ b/src/Redis/Command/CreateCommand/VectorFieldOption.php
@@ -0,0 +1,175 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\CreateCommand;
+
+use function in_array;
+use function is_int;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\OptionListOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+use OutOfRangeException;
+use RuntimeException;
+
+/**
+ * @method VectorFieldOption setField(string $name)
+ * @method VectorFieldOption setNoIndex(bool $active)
+ * @method VectorFieldOption setAlgorithm(string $algorithm)
+ */
+class VectorFieldOption extends GroupedOption implements CreateCommandFieldOption
+{
+    use BaseCreateFieldOptionTrait;
+    use WithPublicGroupedSetterTrait;
+
+    public const ALGORITHM_FLAT = 'FLAT';
+    public const ALGORITHM_HNSW = 'HNSW';
+    public const TYPE_FLOAT32 = 'FLOAT32';
+    public const DISTANCE_METRIC_L2 = 'L2';
+    public const DISTANCE_METRIC_IP = 'IP';
+    public const DISTANCE_METRIC_COSINE = 'COSINE';
+    private const TYPES = [self::TYPE_FLOAT32];
+    private const DISTANCE_METRICS = [self::DISTANCE_METRIC_IP, self::DISTANCE_METRIC_COSINE, self::DISTANCE_METRIC_L2];
+
+    public function __construct()
+    {
+        parent::__construct($this->getConstructorOptions('VECTOR', [
+            'algorithm' => CustomValidatorOption::allowedValues(new NamelessOption(null, '>=2.4.0'), [self::ALGORITHM_FLAT, self::ALGORITHM_HNSW]),
+            'count' => CustomValidatorOption::isNumeric(new NamelessOption(null, '>=2.4.0')),
+            'attributes' => new OptionListOption('>=2.4.0'),
+        ]), ['field', 'algorithm', 'count', 'type'], ['type'], '>=2.4.0');
+    }
+
+    public function getFieldName(): string
+    {
+        return $this->getDataOfOption('field');
+    }
+
+    public function addFlatAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null): self
+    {
+        if (!(self::ALGORITHM_FLAT === $this->getDataOfOption('algorithm'))) {
+            throw new RuntimeException();
+        }
+
+        return $this->addAttribute($type, $dimension, $distanceMetric, $initialCapacity, $blockSize);
+    }
+
+    public function addHnswAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): self
+    {
+        if (!(self::ALGORITHM_HNSW === $this->getDataOfOption('algorithm'))) {
+            throw new RuntimeException();
+        }
+
+        return $this->addAttribute($type, $dimension, $distanceMetric, $initialCapacity, null, $maxEdge, $efConstruction, $efRuntime);
+    }
+
+    public function addAttribute(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): self
+    {
+        $algorithm = $this->getDataOfOption('algorithm');
+
+        if (self::ALGORITHM_FLAT === $algorithm) {
+            $efRuntime = null;
+            $efConstruction = null;
+            $maxEdge = null;
+        }
+        if (self::ALGORITHM_HNSW === $algorithm) {
+            $blockSize = null;
+        }
+
+        $this->validateArguments($type, $dimension, $distanceMetric, $initialCapacity, $blockSize, $maxEdge, $efConstruction, $efRuntime);
+
+        $attribute = new GroupedOption([
+            'type' => new NamedOption('TYPE', $type, '>=2.4.0'),
+            'dim' => new NamedOption('DIM', $dimension, '>=2.4.0'),
+            'distance_metric' => new NamedOption('DISTANCE_METRIC', $distanceMetric, '>=2.4.0'),
+            'initial_cap' => new NamedOption('INITIAL_CAP', $initialCapacity, '>=2.4.0'),
+            'block_size' => new NamedOption('BLOCK_SIZE', $blockSize, '>=2.4.0'),
+            'm' => new NamedOption('M', $maxEdge, '>=2.4.0'),
+            'ef_construction' => new NamedOption('EF_CONSTRUCTION', $efConstruction, '>=2.4.0'),
+            'ef_runtime' => new NamedOption('EF_RUNTIME', $efRuntime, '>=2.4.0'),
+        ], ['type', 'dim', 'distance_metric'], ['type', 'dim', 'distance_metric', 'initial_cap', 'block_size', 'm', 'ef_construction', 'ef_runtime']);
+
+        /** @var array<GroupedOption> $options */
+        $options = $this->getDataOfOption('attributes');
+        $options[] = $attribute;
+        $this->setDataOfOption('attributes', ...$options);
+
+        $countAddition = 6
+            + (is_int($initialCapacity) ? 2 : 0)
+            + (is_int($blockSize) ? 2 : 0)
+            + (is_int($maxEdge) ? 2 : 0)
+            + (is_int($efConstruction) ? 2 : 0)
+            + (is_int($efRuntime) ? 2 : 0);
+
+        /** @var int $count */
+        $count = $this->getDataOfOption('count') ?? 0;
+        $count += $countAddition;
+        $this->setDataOfOption('count', $count);
+
+        return $this;
+    }
+
+    /**
+     * @return string[]
+     */
+    protected function publicSetter(): array
+    {
+        return ['field', 'algorithm', 'no_index'];
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter) -- Dynamic variable name
+     */
+    private function validateArguments(string $type, int $dimension, string $distanceMetric, ?int $initialCapacity = null, ?int $blockSize = null, ?int $maxEdge = null, ?int $efConstruction = null, ?int $efRuntime = null): void
+    {
+        $atLeastOne = ['dimension', 'initialCapacity', 'blockSize', 'efConstruction', 'efRuntime'];
+
+        if (!in_array($type, self::TYPES, true)) {
+            throw new InvalidArgumentException(sprintf(
+                '"%s" is not a known type (expect one of %s)',
+                $type,
+                implode(', ', self::TYPES)
+            ));
+        }
+
+        foreach ($atLeastOne as $varName) {
+            if (is_int(${$varName}) && ${$varName} < 1) {
+                throw new OutOfRangeException(sprintf('$%s must be greater than 0', $varName));
+            }
+        }
+
+        if (!in_array($distanceMetric, self::DISTANCE_METRICS, true)) {
+            throw new InvalidArgumentException(sprintf(
+                '"%s" is not a known distance metric (expect one of %s)',
+                $distanceMetric,
+                implode(', ', self::DISTANCE_METRICS)
+            ));
+        }
+
+        if (is_int($maxEdge) && $maxEdge < 0) {
+            throw new OutOfRangeException('$maxEdge must be a positive number');
+        }
+    }
+}
diff --git a/src/Redis/Command/CursorDel.php b/src/Redis/Command/CursorDel.php
new file mode 100644
index 0000000..897f25b
--- /dev/null
+++ b/src/Redis/Command/CursorDel.php
@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class CursorDel extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'type' => new FlagOption('DEL', true, '>=2.0.0'),
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'cursor' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setCursorId(int $cursor): self
+    {
+        $this->options['cursor']->setValue($cursor);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.CURSOR';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['type', 'index', 'cursor'];
+    }
+}
diff --git a/src/Redis/Command/CursorRead.php b/src/Redis/Command/CursorRead.php
new file mode 100644
index 0000000..3345290
--- /dev/null
+++ b/src/Redis/Command/CursorRead.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function assert;
+use function count;
+use function is_array;
+use function is_int;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Response\AggregateResponseItem;
+use MacFJA\RediSearch\Redis\Response\ArrayResponseTrait;
+use MacFJA\RediSearch\Redis\Response\CursorResponse;
+
+class CursorRead extends AbstractCommand
+{
+    use ArrayResponseTrait;
+
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'type' => new FlagOption('READ', true, '>=2.0.0'),
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'cursor' => new NamelessOption(null, '>=2.0.0'),
+            'count' => new NamedOption('COUNT', null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setCursorId(int $cursor): self
+    {
+        $this->options['cursor']->setValue($cursor);
+
+        return $this;
+    }
+
+    public function setCount(int $count): self
+    {
+        $this->options['count']->setValue($count);
+
+        return $this;
+    }
+
+    public function setNoCount(): self
+    {
+        $this->options['count']->setValue(null);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.CURSOR';
+    }
+
+    /**
+     * @param array<array<mixed>|int> $data
+     */
+    public static function transformResponse(array $data, ?int $count, string $index, string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION): CursorResponse
+    {
+        [$rawResult, $cursorId] = $data;
+
+        assert(is_array($rawResult));
+        assert(is_int($cursorId));
+
+        $totalCount = array_shift($rawResult);
+        $items = array_map(static function (array $document) {
+            return new AggregateResponseItem(self::getPairs($document));
+        }, $rawResult);
+
+        $size = $count ?? count($items);
+
+        return new CursorResponse($cursorId, $totalCount, $items, $size, $index, $rediSearchVersion);
+    }
+
+    /**
+     * @param array|mixed $data
+     *
+     * @return CursorResponse
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        return self::transformResponse(
+            $data,
+            $this->options['count']->getValue(),
+            $this->options['index']->getValue(),
+            $this->getRediSearchVersion()
+        );
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['type', 'index', 'cursor'];
+    }
+}
diff --git a/src/Search/QueryBuilder/AndGroup.php b/src/Redis/Command/DictAdd.php
similarity index 53%
rename from src/Search/QueryBuilder/AndGroup.php
rename to src/Redis/Command/DictAdd.php
index 4727685..aa3b473 100644
--- a/src/Search/QueryBuilder/AndGroup.php
+++ b/src/Redis/Command/DictAdd.php
@@ -19,48 +19,53 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Redis\Command;
 
-use function array_map;
-use function count;
-use function implode;
-use function sprintf;
-use function usort;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
 
-class AndGroup implements GroupPartialQuery
+/**
+ * @method int parseResponse(mixed $data)
+ */
+class DictAdd extends AbstractCommand
 {
-    /** @var array<PartialQuery> */
-    private $expressions = [];
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dict' => new NamelessOption(null, '>=2.0.0'),
+            'terms' => [],
+        ], $rediSearchVersion);
+    }
 
-    public function render(): string
+    public function setDictionary(string $name): self
     {
-        usort($this->expressions, function (PartialQuery $queryA, PartialQuery $queryB) {
-            return $queryA->priority() <=> $queryB->priority();
-        });
+        $this->options['dict']->setValue($name);
 
-        $terms = array_map(function (PartialQuery $query): string {
-            if ($query->includeSpace()) {
-                return sprintf('(%s)', $query->render());
-            }
+        return $this;
+    }
 
-            return $query->render();
-        }, $this->expressions);
+    public function addTerm(string $term): self
+    {
+        $this->options['terms'][] = new NamelessOption($term);
 
-        return sprintf('%s', implode(' ', $terms));
+        return $this;
     }
 
-    public function includeSpace(): bool
+    public function addTerms(string ...$term): self
     {
-        return count($this->expressions) > 1;
+        array_walk($term, function (string $term): void {
+            $this->addTerm($term);
+        });
+
+        return $this;
     }
 
-    public function addExpression(PartialQuery $expression): void
+    public function getId(): string
     {
-        $this->expressions[] = $expression;
+        return 'FT.DICTADD';
     }
 
-    public function priority(): int
+    protected function getRequiredOptions(): array
     {
-        return self::PRIORITY_NORMAL;
+        return ['dict', 'terms'];
     }
 }
diff --git a/src/Redis/Command/DictDel.php b/src/Redis/Command/DictDel.php
new file mode 100644
index 0000000..4ae5875
--- /dev/null
+++ b/src/Redis/Command/DictDel.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method int parseResponse(mixed $data)
+ */
+class DictDel extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dict' => new NamelessOption(null, '>=2.0.0'),
+            'terms' => [],
+        ], $rediSearchVersion);
+    }
+
+    public function addTerm(string $term): self
+    {
+        $this->options['terms'][] = new NamelessOption($term);
+
+        return $this;
+    }
+
+    public function addTerms(string ...$term): self
+    {
+        array_walk($term, function (string $term): void {
+            $this->addTerm($term);
+        });
+
+        return $this;
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dict']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.DICTDEL';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dict', 'terms'];
+    }
+}
diff --git a/src/Redis/Command/DictDump.php b/src/Redis/Command/DictDump.php
new file mode 100644
index 0000000..3ad08c7
--- /dev/null
+++ b/src/Redis/Command/DictDump.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method array<string> parseResponse(mixed $data)
+ */
+class DictDump extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dict' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dict']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.DICTDUMP';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dict'];
+    }
+}
diff --git a/src/Redis/Command/DropIndex.php b/src/Redis/Command/DropIndex.php
new file mode 100644
index 0000000..37d5dbb
--- /dev/null
+++ b/src/Redis/Command/DropIndex.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class DropIndex extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'dd' => new FlagOption('DD', false, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setDeleteDocument(bool $active = true): self
+    {
+        $this->options['dd']->setActive($active);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.DROPINDEX';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index'];
+    }
+}
diff --git a/src/Redis/Command/Explain.php b/src/Redis/Command/Explain.php
new file mode 100644
index 0000000..c5059df
--- /dev/null
+++ b/src/Redis/Command/Explain.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method string parseResponse(mixed $data)
+ */
+class Explain extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'query' => new NamelessOption(null, '>=2.0.0'),
+            'dialect' => CV::isNumeric(new NamedOption('DIALECT', null, '>=2.4.3')),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setQuery(string $query): self
+    {
+        $this->options['query']->setValue($query);
+
+        return $this;
+    }
+
+    public function setDialect(int $version): self
+    {
+        $this->options['dialect']->setValue($version);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.EXPLAIN';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'query'];
+    }
+}
diff --git a/src/Index/Builder/NumericField.php b/src/Redis/Command/ExplainCli.php
similarity index 84%
rename from src/Index/Builder/NumericField.php
rename to src/Redis/Command/ExplainCli.php
index 9bb9a1b..334d5bd 100644
--- a/src/Index/Builder/NumericField.php
+++ b/src/Redis/Command/ExplainCli.php
@@ -19,12 +19,15 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\Redis\Command;
 
-class NumericField extends AbstractField
+/**
+ * @method array<string> parseResponse(mixed $data)
+ */
+class ExplainCli extends Explain
 {
-    public function getType(): string
+    public function getId(): string
     {
-        return self::TYPE_NUMERIC;
+        return 'FT.EXPLAINCLI';
     }
 }
diff --git a/src/Redis/Command/IndexList.php b/src/Redis/Command/IndexList.php
new file mode 100644
index 0000000..230c668
--- /dev/null
+++ b/src/Redis/Command/IndexList.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command;
+
+class IndexList implements Command
+{
+    /** @var string */
+    private $rediSearchVersion = AbstractCommand::MIN_IMPLEMENTED_VERSION;
+
+    public function getId(): string
+    {
+        return 'FT._LIST';
+    }
+
+    public function getRediSearchVersion(): string
+    {
+        return $this->rediSearchVersion;
+    }
+
+    /**
+     * @return IndexList
+     */
+    public function setRediSearchVersion(string $rediSearchVersion): Command
+    {
+        $this->rediSearchVersion = $rediSearchVersion;
+
+        return $this;
+    }
+
+    public function getArguments(): array
+    {
+        return [];
+    }
+
+    public function parseResponse($data)
+    {
+        return $data;
+    }
+}
diff --git a/src/Redis/Command/Info.php b/src/Redis/Command/Info.php
new file mode 100644
index 0000000..c90f741
--- /dev/null
+++ b/src/Redis/Command/Info.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function is_array;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Response\InfoResponse;
+
+class Info extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $name): self
+    {
+        $this->options['index']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.INFO';
+    }
+
+    /**
+     * @param array|mixed $data
+     *
+     * @return InfoResponse
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        return new InfoResponse($data);
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index'];
+    }
+}
diff --git a/src/Redis/Command/LanguageOptionTrait.php b/src/Redis/Command/LanguageOptionTrait.php
new file mode 100644
index 0000000..cdd11cd
--- /dev/null
+++ b/src/Redis/Command/LanguageOptionTrait.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+
+trait LanguageOptionTrait
+{
+    /**
+     * @return array<CV<NamedOption>>
+     */
+    protected function getLanguageOptions(): array
+    {
+        $base = ['arabic', 'danish', 'dutch', 'english', 'finnish', 'french', 'german', 'hungarian', 'italian', 'norwegian', 'portuguese', 'romanian', 'russian', 'spanish', 'swedish', 'tamil', 'turkish', 'chinese'];
+        $v20006 = array_merge($base, ['basque', 'catalan', 'greek', 'indonesian', 'irish', 'lithuanian', 'nepali']);
+        $c200010 = array_merge($v20006, ['armenian', 'serbian', 'yiddish']);
+
+        return [
+            CV::allowedValues(new NamedOption('LANGUAGE', null, '>=2.0.0 <2.0.6'), $base),
+            CV::allowedValues(new NamedOption('LANGUAGE', null, '>=2.0.6 <2.0.10'), $v20006),
+            CV::allowedValues(new NamedOption('LANGUAGE', null, '^2.0.10'), $c200010),
+        ];
+    }
+}
diff --git a/src/Index/Builder/TextField.php b/src/Redis/Command/Option/AbstractCommandOption.php
similarity index 51%
rename from src/Index/Builder/TextField.php
rename to src/Redis/Command/Option/AbstractCommandOption.php
index f10ac33..e2b9966 100644
--- a/src/Index/Builder/TextField.php
+++ b/src/Redis/Command/Option/AbstractCommandOption.php
@@ -19,53 +19,52 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\Redis\Command\Option;
 
-use MacFJA\RediSearch\Helper\RedisHelper;
+use function is_scalar;
 
-class TextField extends AbstractField
-{
-    /** @var bool */
-    private $noStem = false;
-
-    /** @var null|float */
-    private $weight;
+use Composer\Semver\Semver;
+use UnexpectedValueException;
 
+abstract class AbstractCommandOption implements CommandOption
+{
     /** @var null|string */
-    private $phonetic;
+    private $versionConstraint;
 
-    public function __construct(string $name, bool $noStem = false, ?float $weight = null, ?string $phonetic = null, bool $sortable = false, bool $noIndex = false)
+    public function __construct(?string $versionConstraint = null)
     {
-        parent::__construct($name, $sortable, $noIndex);
-        $this->noStem = $noStem;
-        $this->weight = $weight;
-        $this->phonetic = $phonetic;
+        $this->versionConstraint = $versionConstraint;
     }
 
-    public function isNoStem(): bool
+    public function isCompatible(?string $version): bool
     {
-        return $this->noStem;
-    }
+        if (null === $version) {
+            return true;
+        }
 
-    public function getWeight(): ?float
-    {
-        return $this->weight;
+        try {
+            return Semver::satisfies($version, $this->versionConstraint ?? '*');
+        } catch (UnexpectedValueException $exception) {
+            return false;
+        }
     }
 
-    public function getPhonetic(): ?string
+    public function getVersionConstraint(): string
     {
-        return $this->phonetic;
+        return $this->versionConstraint ?? '*';
     }
 
-    public function getType(): string
+    public function render(?string $version = null): array
     {
-        return self::TYPE_TEXT;
-    }
-
-    protected function getAdditionalQueryParts(): array
-    {
-        $query = RedisHelper::buildQueryBoolean([], ['NOSTEM' => $this->noStem]);
+        if (!$this->isCompatible($version) || !$this->isValid()) {
+            return [];
+        }
 
-        return RedisHelper::buildQueryNotNull($query, ['WEIGHT' => $this->weight, 'PHONETIC' => $this->phonetic]);
+        return array_filter($this->doRender($version), static function ($item) { return is_scalar($item); });
     }
+
+    /**
+     * @return array<null|float|int|string>
+     */
+    abstract protected function doRender(?string $version): array;
 }
diff --git a/src/PartialQuery.php b/src/Redis/Command/Option/CommandOption.php
similarity index 76%
rename from src/PartialQuery.php
rename to src/Redis/Command/Option/CommandOption.php
index 9c6d1a8..943e9fe 100644
--- a/src/PartialQuery.php
+++ b/src/Redis/Command/Option/CommandOption.php
@@ -19,12 +19,23 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch;
+namespace MacFJA\RediSearch\Redis\Command\Option;
 
-interface PartialQuery
+interface CommandOption
 {
     /**
      * @return array<float|int|string>
      */
-    public function getQueryParts(): array;
+    public function render(?string $version = null): array;
+
+    public function isValid(): bool;
+
+    /**
+     * @return mixed
+     */
+    public function getOptionData();
+
+    public function isCompatible(?string $version): bool;
+
+    public function getVersionConstraint(): string;
 }
diff --git a/src/Redis/Command/Option/CustomValidatorOption.php b/src/Redis/Command/Option/CustomValidatorOption.php
new file mode 100644
index 0000000..89abc90
--- /dev/null
+++ b/src/Redis/Command/Option/CustomValidatorOption.php
@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use Respect\Validation\Rules\In;
+use Respect\Validation\Rules\NumericVal;
+use Respect\Validation\Validatable;
+
+/**
+ * @template T of CommandOption
+ *
+ * @mixin T
+ */
+class CustomValidatorOption implements CommandOption, DecoratedCommandOption
+{
+    use DecoratedOptionTrait { render as decoratedRender; }
+
+    /** @var Validatable */
+    private $validator;
+
+    /**
+     * @phpstan-param T $decorated
+     */
+    public function __construct(CommandOption $decorated, Validatable $validator)
+    {
+        $this->validator = $validator;
+        $this->setDecoratedOption($decorated);
+    }
+
+    /**
+     * @phpstan-param T $decorated
+     *
+     * @psalm-param CommandOption $decorated
+     *
+     * @param array<float|int|string> $allowed
+     *
+     * @return CustomValidatorOption<T>
+     */
+    public static function allowedValues(CommandOption $decorated, array $allowed): self
+    {
+        return new self($decorated, new In($allowed));
+    }
+
+    /**
+     * @phpstan-param T $decorated
+     *
+     * @psalm-param CommandOption $decorated
+     *
+     * @return static<T>
+     */
+    public static function isNumeric(CommandOption $decorated): self
+    {
+        return new self($decorated, new NumericVal());
+    }
+
+    public function isValid(): bool
+    {
+        return $this->validator->validate($this->getDecoratedOption()->getOptionData());
+    }
+
+    public function getValidator(): Validatable
+    {
+        return $this->validator;
+    }
+
+    public function render(?string $version = null): array
+    {
+        if ($this->isValid()) {
+            return $this->decoratedRender($version);
+        }
+
+        return [];
+    }
+}
diff --git a/alias/Search.php b/src/Redis/Command/Option/DecoratedCommandOption.php
similarity index 87%
rename from alias/Search.php
rename to src/Redis/Command/Option/DecoratedCommandOption.php
index 8c764a3..e61863a 100644
--- a/alias/Search.php
+++ b/src/Redis/Command/Option/DecoratedCommandOption.php
@@ -19,9 +19,9 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch;
+namespace MacFJA\RediSearch\Redis\Command\Option;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Search */
-final class Search extends \MacFJA\RediSearch\Search
+interface DecoratedCommandOption extends CommandOption
 {
+    public function getDecoratedOption(): CommandOption;
 }
diff --git a/src/Redis/Command/Option/DecoratedOptionAwareTrait.php b/src/Redis/Command/Option/DecoratedOptionAwareTrait.php
new file mode 100644
index 0000000..29de241
--- /dev/null
+++ b/src/Redis/Command/Option/DecoratedOptionAwareTrait.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+trait DecoratedOptionAwareTrait
+{
+    /**
+     * @param object|string $object
+     */
+    protected function methodExists($object, string $method): bool
+    {
+        if ($object instanceof DecoratedCommandOption) {
+            return $this->methodExists($object->getDecoratedOption(), $method);
+        }
+
+        return method_exists($object, $method);
+    }
+}
diff --git a/src/Redis/Command/Option/DecoratedOptionTrait.php b/src/Redis/Command/Option/DecoratedOptionTrait.php
new file mode 100644
index 0000000..5c3323e
--- /dev/null
+++ b/src/Redis/Command/Option/DecoratedOptionTrait.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use BadMethodCallException;
+
+/**
+ * @template T of CommandOption
+ *
+ * @mixin T
+ */
+trait DecoratedOptionTrait
+{
+    /**
+     * @var CommandOption
+     *
+     * @phpstan-var T
+     */
+    private $decorated;
+
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return mixed
+     */
+    public function __call(string $name, array $arguments)
+    {
+        if (method_exists($this->decorated, $name)) {
+            return $this->decorated->{$name}(...$arguments);
+        }
+
+        throw new BadMethodCallException('Call undefined method '.$name);
+    }
+
+    public function isCompatible(?string $version): bool
+    {
+        return $this->decorated->isCompatible($version);
+    }
+
+    public function getVersionConstraint(): string
+    {
+        return $this->decorated->getVersionConstraint();
+    }
+
+    /**
+     * @return array<float|int|string>
+     */
+    public function render(?string $version = null): array
+    {
+        return $this->decorated->render($version);
+    }
+
+    public function isValid(): bool
+    {
+        return $this->decorated->isValid();
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getOptionData()
+    {
+        return $this->decorated->getOptionData();
+    }
+
+    /**
+     * @phpstan-return T
+     */
+    public function getDecoratedOption(): CommandOption
+    {
+        return $this->decorated;
+    }
+
+    /**
+     * @phpstan-param T $decorated
+     */
+    private function setDecoratedOption(CommandOption $decorated): void
+    {
+        $this->decorated = $decorated;
+    }
+}
diff --git a/src/Redis/Command/Option/FlagOption.php b/src/Redis/Command/Option/FlagOption.php
new file mode 100644
index 0000000..b565bfe
--- /dev/null
+++ b/src/Redis/Command/Option/FlagOption.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+class FlagOption extends AbstractCommandOption
+{
+    /** @var string */
+    private $name;
+
+    /** @var bool */
+    private $active;
+
+    public function __construct(string $name, bool $active = false, ?string $versionConstraint = null)
+    {
+        $this->active = $active;
+        $this->name = $name;
+
+        parent::__construct($versionConstraint);
+    }
+
+    public function setActive(bool $active): void
+    {
+        $this->active = $active;
+    }
+
+    public function isValid(): bool
+    {
+        return true;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function isActive(): bool
+    {
+        return $this->active;
+    }
+
+    /**
+     * @return bool
+     *
+     * @SuppressWarnings(PHPMD.BooleanGetMethodName)
+     */
+    public function getOptionData()
+    {
+        return $this->isActive();
+    }
+
+    protected function doRender(?string $version): array
+    {
+        if ($this->active) {
+            return [$this->name];
+        }
+
+        return [];
+    }
+}
diff --git a/src/Redis/Command/Option/GroupedOption.php b/src/Redis/Command/Option/GroupedOption.php
new file mode 100644
index 0000000..088ed7a
--- /dev/null
+++ b/src/Redis/Command/Option/GroupedOption.php
@@ -0,0 +1,138 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use function array_key_exists;
+use function in_array;
+use function is_bool;
+use function is_scalar;
+
+use BadMethodCallException;
+use OutOfRangeException;
+
+class GroupedOption extends AbstractCommandOption
+{
+    use DecoratedOptionAwareTrait;
+
+    /** @var array<string,CommandOption> */
+    private $options;
+
+    /** @var array<string> */
+    private $required;
+
+    /** @var array<string> */
+    private $locked;
+
+    /**
+     * @param array<string,CommandOption> $options
+     * @param array<string>               $required
+     * @param array<string>               $locked
+     */
+    public function __construct(array $options, array $required, array $locked = [], ?string $versionConstraint = null)
+    {
+        $this->required = $required;
+        $this->options = $options;
+        $this->locked = $locked;
+        parent::__construct($versionConstraint);
+    }
+
+    /**
+     * @param null|array<mixed>|mixed $value
+     *
+     * @return $this
+     */
+    public function setDataOfOption(string $name, $value): self
+    {
+        if (!array_key_exists($name, $this->options)) {
+            throw new OutOfRangeException('There is no option for '.$name);
+        }
+        if (in_array($name, $this->locked, true)) {
+            throw new BadMethodCallException('The option '.$name.' can\'t be modified');
+        }
+
+        if ($this->methodExists($this->options[$name], 'setValue')) {
+            $this->options[$name]->setValue($value);
+        }
+        if ($this->methodExists($this->options[$name], 'setActive')) {
+            $this->options[$name]->setActive($value);
+        }
+        if ($this->methodExists($this->options[$name], 'setArguments')) {
+            $this->options[$name]->setArguments($value);
+        }
+
+        return $this;
+    }
+
+    public function getOption(string $name): ?CommandOption
+    {
+        return $this->options[$name] ?? null;
+    }
+
+    /**
+     * @return null|array<mixed>|mixed
+     */
+    public function getDataOfOption(string $name)
+    {
+        $option = $this->getOption($name);
+
+        return null === $option ? null : $option->getOptionData();
+    }
+
+    /**
+     * @return array<string>
+     */
+    public function getOptionsName(): array
+    {
+        return array_keys($this->options);
+    }
+
+    public function isValid(): bool
+    {
+        foreach ($this->required as $requiredOption) {
+            if (!array_key_exists($requiredOption, $this->options) || !$this->options[$requiredOption]->isValid()) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * @return array<CommandOption>
+     */
+    public function getOptionData()
+    {
+        return $this->options;
+    }
+
+    protected function doRender(?string $version): array
+    {
+        $arguments = array_filter($this->options, static function (CommandOption $option) use ($version) {
+            return $option->isCompatible($version) && $option->isValid();
+        });
+        $arguments = array_reduce($arguments, static function ($carry, CommandOption $option) use ($version) {
+            return array_merge($carry, $option->render($version));
+        }, []);
+
+        return array_filter($arguments, static function ($item) { return !is_bool($item) && is_scalar($item); });
+    }
+}
diff --git a/src/Index/Exception/IndexNotFoundException.php b/src/Redis/Command/Option/NamedOption.php
similarity index 69%
rename from src/Index/Exception/IndexNotFoundException.php
rename to src/Redis/Command/Option/NamedOption.php
index a8b4b4c..fffe5da 100644
--- a/src/Index/Exception/IndexNotFoundException.php
+++ b/src/Redis/Command/Option/NamedOption.php
@@ -19,21 +19,26 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Exception;
+namespace MacFJA\RediSearch\Redis\Command\Option;
 
-use RuntimeException;
-use function sprintf;
-use Throwable;
-
-class IndexNotFoundException extends RuntimeException
+class NamedOption extends NamelessOption
 {
-    public function __construct(?string $index = null, int $code = 0, ?Throwable $previous = null)
+    /** @var string */
+    private $name;
+
+    public function __construct(string $name, $value = null, ?string $versionConstraint = null)
     {
-        if (null === $index) {
-            parent::__construct('The requested index does not exist', $code, $previous);
+        $this->name = $name;
+        parent::__construct($value, $versionConstraint);
+    }
 
-            return;
-        }
-        parent::__construct(sprintf('The index "%s" does not exist', $index), $code, $previous);
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    protected function doRender(?string $version): array
+    {
+        return [$this->name, $this->getValue()];
     }
 }
diff --git a/src/Redis/Command/Option/NamelessOption.php b/src/Redis/Command/Option/NamelessOption.php
new file mode 100644
index 0000000..5970213
--- /dev/null
+++ b/src/Redis/Command/Option/NamelessOption.php
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+class NamelessOption extends AbstractCommandOption
+{
+    /** @var null|mixed */
+    private $value;
+
+    /**
+     * @param null|mixed $value
+     */
+    public function __construct($value = null, ?string $versionConstraint = null)
+    {
+        $this->value = $value;
+        parent::__construct($versionConstraint);
+    }
+
+    /**
+     * @param null|mixed $value
+     */
+    public function setValue($value): void
+    {
+        $this->value = $value;
+    }
+
+    public function isValid(): bool
+    {
+        return !(null === $this->value);
+    }
+
+    /**
+     * @return null|mixed
+     */
+    public function getValue()
+    {
+        return $this->value;
+    }
+
+    /**
+     * @return null|mixed
+     */
+    public function getOptionData()
+    {
+        return $this->getValue();
+    }
+
+    protected function doRender(?string $version): array
+    {
+        return [$this->value];
+    }
+}
diff --git a/src/Redis/Command/Option/NotEmptyOption.php b/src/Redis/Command/Option/NotEmptyOption.php
new file mode 100644
index 0000000..8dcf4c5
--- /dev/null
+++ b/src/Redis/Command/Option/NotEmptyOption.php
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use function count;
+
+/**
+ * @template T of CommandOption
+ *
+ * @mixin T
+ */
+class NotEmptyOption implements CommandOption, DecoratedCommandOption
+{
+    use DecoratedOptionTrait {
+        isValid as decoratedIsValid;
+        render as decoratedRender;
+    }
+
+    /**
+     * @phpstan-param T $decorated
+     */
+    public function __construct(CommandOption $decorated)
+    {
+        $this->setDecoratedOption($decorated);
+    }
+
+    public function render(?string $version = null): array
+    {
+        if (!$this->isValid()) {
+            return [];
+        }
+
+        return $this->decoratedRender($version);
+    }
+
+    public function isValid(): bool
+    {
+        if (!$this->decoratedIsValid()) {
+            return false;
+        }
+
+        $option = $this->getDecoratedOption();
+
+        if ($option instanceof NamelessOption && empty($option->getValue())) {
+            return false;
+        }
+
+        if ($option instanceof NumberedOption && 0 === count($option->getArguments() ?? [])) {
+            return false;
+        }
+
+        if ($option instanceof FlagOption && !$option->isActive()) {
+            return false;
+        }
+
+        return true;
+    }
+}
diff --git a/src/Redis/Command/Option/NumberedOption.php b/src/Redis/Command/Option/NumberedOption.php
new file mode 100644
index 0000000..088e695
--- /dev/null
+++ b/src/Redis/Command/Option/NumberedOption.php
@@ -0,0 +1,86 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use function assert;
+use function count;
+use function is_array;
+
+class NumberedOption extends AbstractCommandOption
+{
+    /** @var string */
+    private $name;
+
+    /** @var null|array<float|int|string> */
+    private $arguments;
+
+    /**
+     * @param null|array<float|int|string> $arguments
+     */
+    public function __construct(string $name, ?array $arguments = null, ?string $versionConstraint = null)
+    {
+        $this->arguments = $arguments;
+        $this->name = $name;
+        parent::__construct($versionConstraint);
+    }
+
+    /**
+     * @param null|array<float|int|string> $arguments
+     */
+    public function setArguments(?array $arguments): void
+    {
+        $this->arguments = $arguments;
+    }
+
+    public function isValid(): bool
+    {
+        return is_array($this->arguments);
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * @return null|array<float|int|string>
+     */
+    public function getArguments(): ?array
+    {
+        return $this->arguments;
+    }
+
+    /**
+     * @return null|array<mixed>
+     */
+    public function getOptionData()
+    {
+        return $this->getArguments();
+    }
+
+    protected function doRender(?string $version): array
+    {
+        assert(is_array($this->arguments));
+
+        return array_merge([$this->name, count($this->arguments)], $this->arguments);
+    }
+}
diff --git a/src/Redis/Command/Option/OptionListOption.php b/src/Redis/Command/Option/OptionListOption.php
new file mode 100644
index 0000000..7a1f8c2
--- /dev/null
+++ b/src/Redis/Command/Option/OptionListOption.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use function is_bool;
+use function is_scalar;
+
+class OptionListOption extends AbstractCommandOption
+{
+    /** @var array<CommandOption> */
+    private $options = [];
+
+    public function addOption(CommandOption $option): self
+    {
+        $this->options[] = $option;
+
+        return $this;
+    }
+
+    public function setValue(CommandOption ...$options): self
+    {
+        $this->options = $options;
+
+        return $this;
+    }
+
+    public function isValid(): bool
+    {
+        return true;
+    }
+
+    public function getOptionData()
+    {
+        return $this->options;
+    }
+
+    protected function doRender(?string $version): array
+    {
+        $arguments = array_filter($this->options, static function (CommandOption $option) use ($version) {
+            return $option->isCompatible($version) && $option->isValid();
+        });
+        $arguments = array_reduce($arguments, static function ($carry, CommandOption $option) use ($version) {
+            return array_merge($carry, $option->render($version));
+        }, []);
+
+        return array_filter($arguments, static function ($item) { return !is_bool($item) && is_scalar($item); });
+    }
+}
diff --git a/src/Redis/Command/Option/WithPublicGroupedSetterTrait.php b/src/Redis/Command/Option/WithPublicGroupedSetterTrait.php
new file mode 100644
index 0000000..58d2ba3
--- /dev/null
+++ b/src/Redis/Command/Option/WithPublicGroupedSetterTrait.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\Option;
+
+use function count;
+use function get_class;
+use function in_array;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+
+trait WithPublicGroupedSetterTrait
+{
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return $this
+     */
+    public function __call(string $name, array $arguments): self
+    {
+        if (!$this instanceof GroupedOption) {
+            throw new BadMethodCallException('This method is not callable in '.get_class($this));
+        }
+        if (0 === strpos($name, 'set') && $name[3] === strtoupper($name[3])) {
+            $pascalCase = substr($name, 3);
+            $snakeCase = strtolower(
+                trim(
+                    preg_replace('/([A-Z])/', '_$1', $pascalCase) ?? $pascalCase,
+                    '_'
+                )
+            );
+
+            if (!in_array($snakeCase, $this->publicSetter(), true)) {
+                throw new BadMethodCallException(sprintf('Call undefined method %s in %s', $name, get_class($this)));
+            }
+            if (!(1 === count($arguments))) {
+                throw new InvalidArgumentException(sprintf('The method %s::%s need exactly one argument', get_class($this), $name));
+            }
+
+            return $this->setDataOfOption($snakeCase, $arguments[0]);
+        }
+
+        throw new BadMethodCallException(sprintf('Call undefined method %s in %s', $name, get_class($this)));
+    }
+
+    /**
+     * @return array<string>
+     */
+    abstract protected function publicSetter(): array;
+}
diff --git a/src/Redis/Command/PaginatedCommand.php b/src/Redis/Command/PaginatedCommand.php
new file mode 100644
index 0000000..96a2183
--- /dev/null
+++ b/src/Redis/Command/PaginatedCommand.php
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command;
+
+interface PaginatedCommand extends Command
+{
+    public function getOffset(): ?int;
+
+    public function getSize(): ?int;
+
+    public function setLimit(int $offset, int $size): PaginatedCommand;
+}
diff --git a/src/Aggregate/Exception/UnknownSortDirectionException.php b/src/Redis/Command/PrefixFieldName.php
similarity index 68%
rename from src/Aggregate/Exception/UnknownSortDirectionException.php
rename to src/Redis/Command/PrefixFieldName.php
index 052f55b..e4ffb94 100644
--- a/src/Aggregate/Exception/UnknownSortDirectionException.php
+++ b/src/Redis/Command/PrefixFieldName.php
@@ -19,21 +19,28 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate\Exception;
+namespace MacFJA\RediSearch\Redis\Command;
 
-use function implode;
-use function sprintf;
-use Throwable;
-use UnexpectedValueException;
-
-class UnknownSortDirectionException extends UnexpectedValueException
+trait PrefixFieldName
 {
     /**
-     * @param array<string> $directions
+     * @param array<string> $fields
+     *
+     * @return array<string>
      */
-    public function __construct(array $directions, int $code = 0, ?Throwable $previous = null)
+    protected static function prefixFields(array $fields): array
     {
-        $message = sprintf('Sort By direction can only be "ASC" or "DESC". Provided: %s.', implode(', ', $directions));
-        parent::__construct($message, $code, $previous);
+        return array_map(static function (string $field): string {
+            return self::prefixField($field);
+        }, $fields);
+    }
+
+    protected static function prefixField(string $field): string
+    {
+        if (0 === strpos($field, '@')) {
+            return $field;
+        }
+
+        return '@'.$field;
     }
 }
diff --git a/src/Redis/Command/Profile.php b/src/Redis/Command/Profile.php
new file mode 100644
index 0000000..5dbd44c
--- /dev/null
+++ b/src/Redis/Command/Profile.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\ProfileCommand\QueryOption;
+
+class Profile extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.2.0'),
+            'type' => CV::allowedValues(new NamelessOption(null, '>=2.2.0'), ['SEARCH', 'AGGREGATE']),
+            'limited' => new FlagOption('LIMITED', false, '>=2.2.0'),
+            'query' => new QueryOption('>=2.2.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    /**
+     * @param Aggregate|Search $query
+     */
+    public function setQuery($query): self
+    {
+        /** @var QueryOption $queryOption */
+        $queryOption = $this->options['query'];
+        $queryOption->setCommand($query);
+        $this->options['type']->setValue($queryOption->isSearch() ? 'SEARCH' : 'AGGREGATE');
+        $this->options['index']->setValue($queryOption->getIndex());
+
+        return $this;
+    }
+
+    public function setTypeAggregate(): self
+    {
+        $this->options['type']->setValue('AGGREGATE');
+
+        return $this;
+    }
+
+    public function setTypeSearch(): self
+    {
+        $this->options['type']->setValue('SEARCH');
+
+        return $this;
+    }
+
+    public function setLimited(bool $active = true): self
+    {
+        $this->options['limited']->setActive($active);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.PROFILE';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'type', 'query'];
+    }
+}
diff --git a/src/Helper/PipelineItem.php b/src/Redis/Command/ProfileCommand/QueryOption.php
similarity index 51%
rename from src/Helper/PipelineItem.php
rename to src/Redis/Command/ProfileCommand/QueryOption.php
index ba102ec..b7d1723 100644
--- a/src/Helper/PipelineItem.php
+++ b/src/Redis/Command/ProfileCommand/QueryOption.php
@@ -19,67 +19,62 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Helper;
+namespace MacFJA\RediSearch\Redis\Command\ProfileCommand;
 
-use function call_user_func;
-use Predis\Command\CommandInterface;
-use Predis\Command\RawCommand;
+use function assert;
 
-class PipelineItem
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+use MacFJA\RediSearch\Redis\Command\Search;
+
+class QueryOption extends AbstractCommandOption
 {
-    /** @var CommandInterface */
+    /**
+     * @var null|Aggregate|Search
+     */
     private $command;
 
-    /** @var array<mixed> */
-    private $context;
-
-    /** @var callable */
-    private $transformer;
-
     /**
-     * @param array<mixed> $context
+     * @param Aggregate|Search $command
      */
-    public function __construct(CommandInterface $command, callable $transformer, array $context = [])
+    public function setCommand($command): self
     {
         $this->command = $command;
-        $this->context = $context;
-        $this->transformer = $transformer;
+
+        return $this;
     }
 
-    /**
-     * @param array<mixed> $params
-     * @param array<mixed> $context
-     */
-    public static function createFromRaw(array $params, callable $transformer, array $context = []): PipelineItem
+    public function isValid(): bool
     {
-        return new self(new RawCommand($params), $transformer, $context);
+        return $this->command instanceof Search || $this->command instanceof Aggregate;
     }
 
-    public function getCommand(): CommandInterface
+    public function getOptionData()
     {
-        return $this->command;
+        return [
+            'command' => $this->command,
+        ];
     }
 
-    /**
-     * @return array<mixed>
-     */
-    public function getContext(): array
+    public function isSearch(): bool
     {
-        return $this->context;
+        return $this->command instanceof Search;
     }
 
-    public function getTransformer(): callable
+    public function getIndex(): string
     {
-        return $this->transformer;
+        assert($this->command instanceof Search || $this->command instanceof Aggregate);
+
+        return $this->command->getIndex();
     }
 
-    /**
-     * @param mixed $rawResult
-     *
-     * @return mixed
-     */
-    public function transform($rawResult)
+    protected function doRender(?string $version): array
     {
-        return call_user_func($this->transformer, $rawResult, $this->context);
+        assert($this->command instanceof Search || $this->command instanceof Aggregate);
+        $arguments = $this->command->getArguments();
+        // remove index
+        array_shift($arguments);
+
+        return array_merge(['QUERY'], $arguments);
     }
 }
diff --git a/src/Redis/Command/Search.php b/src/Redis/Command/Search.php
new file mode 100644
index 0000000..7fcfe26
--- /dev/null
+++ b/src/Redis/Command/Search.php
@@ -0,0 +1,393 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function assert;
+use function count;
+use function is_array;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\FilterOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption;
+use MacFJA\RediSearch\Redis\Response\ArrayResponseTrait;
+use MacFJA\RediSearch\Redis\Response\PaginatedResponse;
+use MacFJA\RediSearch\Redis\Response\SearchResponseItem;
+
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class Search extends AbstractCommand implements PaginatedCommand
+{
+    use LanguageOptionTrait;
+    use ArrayResponseTrait;
+
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct(
+            [
+                'index' => new NamelessOption(null, '>=2.0.0'),
+                'query' => new NamelessOption(null, '>=2.0.0'),
+                'nocontent' => new FlagOption('NOCONTENT', false, '>= 2.0.0'),
+                'verbatim' => new FlagOption('VERBATIM', false, '>= 2.0.0'),
+                'nostopwords' => new FlagOption('NOSTOPWORDS', false, '>= 2.0.0'),
+                'withscores' => new FlagOption('WITHSCORES', false, '>= 2.0.0'),
+                'withpayloads' => new FlagOption('WITHPAYLOADS', false, '>= 2.0.0'),
+                'withsortkeys' => new FlagOption('WITHSORTKEYS', false, '>= 2.0.0'),
+                'filter' => [],
+                'geofilter' => new GeoFilterOption(),
+                'inkeys' => new NumberedOption('INKEYS', null, '>=2.0.0'),
+                'infields' => new NumberedOption('INFIELDS', null, '>=2.0.0'),
+                'return' => new NumberedOption('RETURN', null, '>=2.0.0'),
+                'summarize' => new SummarizeOption(),
+                'highlight' => new HighlightOption(),
+                'slop' => CV::isNumeric(new NamedOption('SLOP', null, '>=2.0.0')),
+                'inorder' => new FlagOption('INORDER', false, '>=2.0.0'),
+                'language' => $this->getLanguageOptions(),
+                'expander' => new NamedOption('EXPANDER', null, '>=2.0.0'),
+                'scorer' => new NamedOption('SCORER', null, '>=2.0.0'),
+                'explainscore' => new FlagOption('EXPLAINSCORE', false, '>=2.0.0'),
+                'payload' => new NamedOption('PAYLOAD', null, '>=2.0.0'),
+                'params' => new NotEmptyOption(new NumberedOption('PARAMS', null, '>=2.4.0')),
+                'sortby' => new SortByOption(),
+                'limit' => new LimitOption(),
+                'dialect' => CV::isNumeric(new NamedOption('DIALECT', null, '>=2.4.3')),
+            ],
+            $rediSearchVersion
+        );
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function getIndex(): string
+    {
+        return $this->options['index']->getValue();
+    }
+
+    public function setQuery(string $query): self
+    {
+        $this->options['query']->setValue($query);
+
+        return $this;
+    }
+
+    public function setNoContent(bool $active = true): self
+    {
+        $this->options['nocontent']->setActive($active);
+
+        return $this;
+    }
+
+    public function setVerbatim(bool $active = true): self
+    {
+        $this->options['verbatim']->setActive($active);
+
+        return $this;
+    }
+
+    public function setNoStopWords(bool $active = true): self
+    {
+        $this->options['nostopwords']->setActive($active);
+
+        return $this;
+    }
+
+    public function setWithScores(bool $active = true): self
+    {
+        $this->options['withscores']->setActive($active);
+
+        return $this;
+    }
+
+    public function setWithPayloads(bool $active = true): self
+    {
+        $this->options['withpayloads']->setActive($active);
+
+        return $this;
+    }
+
+    public function setWithSortKeys(bool $active = true): self
+    {
+        $this->options['withsortkeys']->setActive($active);
+
+        return $this;
+    }
+
+    /**
+     * @param null|array<string> $fields
+     *
+     * @return $this
+     */
+    public function setSummarize(?array $fields = null, ?int $fragmentCount = null, ?int $fragmentSize = null, ?string $separator = null): self
+    {
+        $this->options['summarize']
+            ->setDataOfOption('type', true)
+            ->setDataOfOption('fields', $fields)
+            ->setDataOfOption('frags', $fragmentCount)
+            ->setDataOfOption('len', $fragmentSize)
+            ->setDataOfOption('separator', $separator)
+        ;
+
+        return $this;
+    }
+
+    /**
+     * @param null|array<string> $fields
+     *
+     * @return $this
+     */
+    public function setHighlight(?array $fields, ?string $openTag = null, ?string $closeTag = null): self
+    {
+        $this->options['highlight']
+            ->setDataOfOption('type', true)
+            ->setDataOfOption('fields', $fields)
+            ->setTags($openTag, $closeTag)
+        ;
+
+        return $this;
+    }
+
+    public function setSlop(int $slop): self
+    {
+        $this->options['slop']->setValue($slop);
+
+        return $this;
+    }
+
+    public function setInOrder(bool $active = true): self
+    {
+        $this->options['inorder']->setActive($active);
+
+        return $this;
+    }
+
+    public function setLanguage(string $language): self
+    {
+        array_walk($this->options['language'], static function (CV $option) use ($language): void {
+            $option->setValue($language);
+        });
+
+        return $this;
+    }
+
+    public function setExpander(string $function): self
+    {
+        $this->options['expander']->setValue($function);
+
+        return $this;
+    }
+
+    public function setScorer(string $function): self
+    {
+        $this->options['scorer']->setValue($function);
+
+        return $this;
+    }
+
+    public function setExplainScore(bool $active = true): self
+    {
+        $this->options['explainscore']->setActive($active);
+
+        return $this;
+    }
+
+    public function setPayload(string $payload): self
+    {
+        $this->options['payload']->setValue($payload);
+
+        return $this;
+    }
+
+    public function setSortBy(string $field, ?string $direction = null): self
+    {
+        $this->options['sortby']->setField($field)->setDirection($direction);
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function setLimit(int $offset, int $size): PaginatedCommand
+    {
+        $this->options['limit']->setOffset($offset)->setSize($size);
+
+        return $this;
+    }
+
+    public function addFilter(FilterOption $filterOption): self
+    {
+        $this->options['filter'][] = $filterOption;
+
+        return $this;
+    }
+
+    public function setGeoFilter(string $fieldName, float $longitude, float $latitude, float $radius, string $unit): self
+    {
+        $this->options['geofilter']->setDataOfOption('field', $fieldName)
+            ->setDataOfOption('lon', $longitude)
+            ->setDataOfOption('lat', $latitude)
+            ->setDataOfOption('radius', $radius)
+            ->setDataOfOption('unit', $unit)
+        ;
+
+        return $this;
+    }
+
+    public function setInKeys(string ...$key): self
+    {
+        $this->options['inkeys']->setArguments($key);
+
+        return $this;
+    }
+
+    public function setInFields(string ...$field): self
+    {
+        $this->options['infields']->setArguments($field);
+
+        return $this;
+    }
+
+    public function setReturn(string ...$field): self
+    {
+        $this->options['return']->setArguments($field);
+
+        return $this;
+    }
+
+    /**
+     * @param int|scalar|string $value
+     *
+     * @return $this
+     */
+    public function addParam(string $name, $value): self
+    {
+        $params = $this->options['params']->getArguments() ?? [];
+        $params[] = $name;
+        $params[] = (string) $value;
+        $this->options['params']->setArguments($params);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SEARCH';
+    }
+
+    public function getOffset(): ?int
+    {
+        /** @var LimitOption $limit */
+        $limit = $this->options['limit'];
+
+        return $limit->getOffset();
+    }
+
+    public function getSize(): ?int
+    {
+        /** @var LimitOption $limit */
+        $limit = $this->options['limit'];
+
+        return $limit->getSize();
+    }
+
+    public function setDialect(int $version): self
+    {
+        $this->options['dialect']->setValue($version);
+
+        return $this;
+    }
+
+    /**
+     * @param mixed $data
+     *
+     * @return PaginatedResponse
+     *
+     * @psalm-return PaginatedResponse<SearchResponseItem>
+     *
+     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
+     * @SuppressWarnings(PHPMD.NPathComplexity)
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        $totalCount = array_shift($data);
+        assert(is_numeric($totalCount));
+        $totalCount = (int) $totalCount;
+
+        $useScores = $this->options['withscores']->isActive();
+        $usePayloads = $this->options['withpayloads']->isActive();
+        $useSortKeys = $this->options['withsortkeys']->isActive();
+        $noContent = $this->options['nocontent']->isActive();
+
+        $chunkSize = 2 // Hash + fields
+            + (true === $useScores ? 1 : 0)
+            + (true === $usePayloads ? 1 : 0)
+            + (true === $useSortKeys ? 1 : 0)
+            - (true === $noContent ? 1 : 0);
+
+        $documents = array_chunk($data, $chunkSize);
+
+        $items = array_map(static function ($document) use ($data, $useSortKeys, $usePayloads, $useScores, $noContent) {
+            $hash = array_shift($document) ?? '';
+            $score = true === $useScores ? (float) array_shift($document) : null;
+            $payload = true === $usePayloads ? array_shift($document) : null;
+            $sortKey = true === $useSortKeys ? array_shift($document) : null;
+
+            $fields = [];
+            if (false === $noContent) {
+                if (!(1 === count($document))) {
+                    throw new UnexpectedServerResponseException($data, 'Incomplete response');
+                }
+                $rawData = reset($document);
+                assert(is_array($rawData));
+                $fields = self::getPairs($rawData);
+            }
+
+            return new SearchResponseItem($hash, $fields, $score, $payload, $sortKey);
+        }, $documents);
+
+        return new PaginatedResponse($this, $totalCount, $items);
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'query'];
+    }
+}
diff --git a/src/Redis/Command/SearchCommand/FilterOption.php b/src/Redis/Command/SearchCommand/FilterOption.php
new file mode 100644
index 0000000..c4c0570
--- /dev/null
+++ b/src/Redis/Command/SearchCommand/FilterOption.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+
+class FilterOption extends AbstractCommandOption
+{
+    use NumericRangeTrait;
+
+    /** @var string */
+    private $fieldName;
+
+    /** @var null|float */
+    private $min;
+
+    /** @var null|float */
+    private $max;
+
+    /** @var bool */
+    private $isMaxInclusive;
+
+    /** @var bool */
+    private $isMinInclusive;
+
+    public function __construct(string $fieldName, ?float $min, ?float $max, bool $isMinInclusive = true, bool $isMaxInclusive = true)
+    {
+        $this->fieldName = $fieldName;
+        $this->min = $min;
+        $this->max = $max;
+        $this->isMaxInclusive = $isMaxInclusive;
+        $this->isMinInclusive = $isMinInclusive;
+        parent::__construct('>=2.0.0');
+    }
+
+    public function isValid(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string,null|bool|float>
+     */
+    public function getOptionData()
+    {
+        return [
+            'min' => $this->min,
+            'max' => $this->max,
+            'isMinInclusive' => $this->isMinInclusive,
+            'isMaxInclusive' => $this->isMaxInclusive,
+        ];
+    }
+
+    protected function doRender(?string $version): array
+    {
+        return array_merge(
+            ['FILTER', $this->fieldName],
+            self::renderBound($this->min, $this->max, $this->isMinInclusive, $this->isMaxInclusive)
+        );
+    }
+}
diff --git a/src/Search/GeoFilter.php b/src/Redis/Command/SearchCommand/GeoFilterOption.php
similarity index 54%
rename from src/Search/GeoFilter.php
rename to src/Redis/Command/SearchCommand/GeoFilterOption.php
index b55e76e..a9ee284 100644
--- a/src/Search/GeoFilter.php
+++ b/src/Redis/Command/SearchCommand/GeoFilterOption.php
@@ -19,53 +19,30 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search;
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
 
-use function in_array;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\PartialQuery;
-use MacFJA\RediSearch\Search\Exception\UnknownUnitException;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
 
-class GeoFilter implements PartialQuery
+class GeoFilterOption extends GroupedOption
 {
+    public const UNITS = [self::UNIT_METERS, self::UNIT_KILOMETERS, self::UNIT_FEET, self::UNIT_MILES];
     public const UNIT_METERS = 'm';
-
     public const UNIT_KILOMETERS = 'km';
-
     public const UNIT_MILES = 'mi';
-
     public const UNIT_FEET = 'ft';
 
-    /** @var string */
-    private $fieldName;
-
-    /** @var float */
-    private $lon;
-
-    /** @var float */
-    private $lat;
-
-    /** @var int */
-    private $radius;
-
-    /** @var string */
-    private $unit;
-
-    public function __construct(string $fieldName, float $lon, float $lat, int $radius, string $unit)
-    {
-        DataHelper::assert(
-            in_array($unit, [self::UNIT_FEET, self::UNIT_KILOMETERS, self::UNIT_METERS, self::UNIT_MILES], true),
-            new UnknownUnitException($unit)
-        );
-        $this->fieldName = $fieldName;
-        $this->lon = $lon;
-        $this->lat = $lat;
-        $this->radius = $radius;
-        $this->unit = $unit;
-    }
-
-    public function getQueryParts(): array
+    public function __construct()
     {
-        return ['GEOFILTER', $this->fieldName, $this->lon, $this->lat, $this->radius, $this->unit];
+        parent::__construct([
+            'type' => new FlagOption('GEOFILTER', true, '>=2.0.0'),
+            'field' => new NamelessOption(null, '>=2.0.0'),
+            'lon' => CV::isNumeric(new NamelessOption(null, '>=2.0.0')),
+            'lat' => CV::isNumeric(new NamelessOption(null, '>=2.0.0')),
+            'radius' => CV::isNumeric(new NamelessOption(null, '>=2.0.0')),
+            'unit' => CV::allowedValues(new NamelessOption(null, '>=2.0.0'), self::UNITS),
+        ], ['type', 'field', 'lon', 'lat', 'radius', 'unit'], ['type'], '>=2.0.0');
     }
 }
diff --git a/src/Redis/Command/SearchCommand/HighlightOption.php b/src/Redis/Command/SearchCommand/HighlightOption.php
new file mode 100644
index 0000000..72f6527
--- /dev/null
+++ b/src/Redis/Command/SearchCommand/HighlightOption.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+
+class HighlightOption extends GroupedOption
+{
+    public function __construct()
+    {
+        parent::__construct([
+            'type' => new FlagOption('HIGHLIGHT', false, '>=2.0.0'),
+            'fields' => new NotEmptyOption(new NumberedOption('FIELDS', null, '>=2.0.0')),
+            'tag' => new GroupedOption([
+                'type' => new FlagOption('TAGS', true, '>=2.0.0'),
+                'open' => new NamelessOption(null, '>=2.0.0'),
+                'close' => new NamelessOption(null, '>=2.0.0'),
+            ], ['type', 'open', 'close'], ['type'], '>=2.0.0'),
+        ], ['type'], [], '>=2.0.0');
+    }
+
+    public function setTags(?string $open, ?string $close): self
+    {
+        /** @var GroupedOption $tag */
+        $tag = $this->getOption('tag');
+        $tag->setDataOfOption('open', $open)
+            ->setDataOfOption('close', $close)
+        ;
+
+        return $this;
+    }
+}
diff --git a/src/Pipeline.php b/src/Redis/Command/SearchCommand/LimitOption.php
similarity index 50%
rename from src/Pipeline.php
rename to src/Redis/Command/SearchCommand/LimitOption.php
index 70453ac..fb3cb73 100644
--- a/src/Pipeline.php
+++ b/src/Redis/Command/SearchCommand/LimitOption.php
@@ -19,61 +19,67 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch;
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
 
-use function array_keys;
-use function array_map;
-use function assert;
-use function is_array;
-use MacFJA\RediSearch\Helper\PipelineItem;
-use Predis\Client;
+use function is_int;
 
-class Pipeline
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+
+class LimitOption extends AbstractCommandOption
 {
-    /** @var array<PipelineItem> */
-    private $pipeline;
+    /** @var null|int */
+    private $offset;
 
-    /** @var Client */
-    private $redis;
+    /** @var null|int */
+    private $size;
 
-    public function __construct(Client $redis)
+    public function __construct()
     {
-        $this->redis = $redis;
-        $this->pipeline = [];
+        parent::__construct('>=2.0.0');
     }
 
-    public function addItem(PipelineItem $pipelineItem): self
+    public function setOffset(?int $offset): LimitOption
     {
-        $this->pipeline[] = $pipelineItem;
+        $this->offset = $offset;
 
         return $this;
     }
 
-    public function addPipeable(Pipeable $pipeable): self
+    public function getOffset(): ?int
+    {
+        return $this->offset;
+    }
+
+    public function getSize(): ?int
+    {
+        return $this->size;
+    }
+
+    public function setSize(?int $size): LimitOption
     {
-        $this->pipeline[] = $pipeable->asPipelineItem();
+        $this->size = $size;
 
         return $this;
     }
 
+    public function isValid(): bool
+    {
+        return is_int($this->offset) && is_int($this->size) && $this->offset >= 0 && $this->size >= 0;
+    }
+
     /**
-     * @return array<mixed>
+     * @return array<string,null|int>
      */
-    public function executePipeline(): array
+    public function getOptionData()
+    {
+        return [
+            'offset' => $this->offset,
+            'size' => $this->size,
+        ];
+    }
+
+    protected function doRender(?string $version): array
     {
-        $rawReplies = $this->redis->pipeline(function (\Predis\Pipeline\Pipeline $pipe) {
-            foreach ($this->pipeline as $item) {
-                $pipe->executeCommand($item->getCommand());
-            }
-        });
-        assert(is_array($rawReplies));
-
-        try {
-            return array_map(function ($reply, $index) {
-                return $this->pipeline[$index]->transform($reply);
-            }, $rawReplies, array_keys($rawReplies));
-        } finally {
-            $this->pipeline = [];
-        }
+        return ['LIMIT', $this->offset, $this->size];
     }
 }
diff --git a/src/Search/QueryBuilder/NumericFacet.php b/src/Redis/Command/SearchCommand/NumericRangeTrait.php
similarity index 59%
rename from src/Search/QueryBuilder/NumericFacet.php
rename to src/Redis/Command/SearchCommand/NumericRangeTrait.php
index 4927a2b..d315f22 100644
--- a/src/Search/QueryBuilder/NumericFacet.php
+++ b/src/Redis/Command/SearchCommand/NumericRangeTrait.php
@@ -19,38 +19,10 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
 
-use function is_numeric;
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use function sprintf;
-
-class NumericFacet implements PartialQuery
+trait NumericRangeTrait
 {
-    /** @var string */
-    private $fieldName;
-
-    /** @var null|float */
-    private $min;
-
-    /** @var null|float */
-    private $max;
-
-    /** @var bool */
-    private $isMaxInclusive;
-
-    /** @var bool */
-    private $isMinInclusive;
-
-    public function __construct(string $fieldName, ?float $min, ?float $max, bool $isMinInclusive = true, bool $isMaxInclusive = true)
-    {
-        $this->fieldName = $fieldName;
-        $this->min = $min;
-        $this->max = $max;
-        $this->isMaxInclusive = $isMaxInclusive;
-        $this->isMinInclusive = $isMinInclusive;
-    }
-
     public static function greaterThan(string $fieldName, float $value): self
     {
         return new self($fieldName, $value, null, false);
@@ -76,28 +48,21 @@ public static function equalsTo(string $fieldName, float $value): self
         return new self($fieldName, $value, $value, true, true);
     }
 
-    public function render(): string
+    /**
+     * @return string[]
+     */
+    private static function renderBound(?float $min, ?float $max, bool $isMinInclusive = true, bool $isMaxInclusive = true): array
     {
-        $min = '-inf';
-        $max = '+inf';
+        $minValue = '-inf';
+        $maxValue = '+inf';
 
-        if (is_numeric($this->min)) {
-            $min = ($this->isMinInclusive ? '' : '(').$this->min;
+        if (is_numeric($min)) {
+            $minValue = (true === $isMinInclusive ? '' : '(').$min;
         }
-        if (is_numeric($this->max)) {
-            $max = ($this->isMaxInclusive ? '' : '(').$this->max;
+        if (is_numeric($max)) {
+            $maxValue = (true === $isMaxInclusive ? '' : '(').$max;
         }
 
-        return sprintf('@%s:[%s %s]', EscapeHelper::escapeFieldName($this->fieldName), $min, $max);
-    }
-
-    public function includeSpace(): bool
-    {
-        return false;
-    }
-
-    public function priority(): int
-    {
-        return self::PRIORITY_NORMAL;
+        return [$minValue, $maxValue];
     }
 }
diff --git a/src/Redis/Command/SearchCommand/SortByOption.php b/src/Redis/Command/SearchCommand/SortByOption.php
new file mode 100644
index 0000000..b565b4f
--- /dev/null
+++ b/src/Redis/Command/SearchCommand/SortByOption.php
@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class SortByOption extends GroupedOption
+{
+    public function __construct()
+    {
+        parent::__construct([
+            'type' => new FlagOption('SORTBY', true, '>=2.0.0'),
+            'field' => new NamelessOption(null, '>=2.0.0'),
+            'direction' => CV::allowedValues(new NamelessOption(null, '>=2.0.0'), ['DESC', 'ASC']),
+        ], ['type', 'field'], ['type'], '>=2.0.0');
+    }
+
+    public function setField(string $name): self
+    {
+        $this->setDataOfOption('field', $name);
+
+        return $this;
+    }
+
+    public function setDirection(?string $direction): self
+    {
+        $this->setDataOfOption('direction', $direction);
+
+        return $this;
+    }
+}
diff --git a/src/Redis/Command/SearchCommand/SummarizeOption.php b/src/Redis/Command/SearchCommand/SummarizeOption.php
new file mode 100644
index 0000000..fa8cfd4
--- /dev/null
+++ b/src/Redis/Command/SearchCommand/SummarizeOption.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+
+class SummarizeOption extends GroupedOption
+{
+    public function __construct()
+    {
+        parent::__construct([
+            'type' => new FlagOption('SUMMARIZE', false, '>=2.0.0'),
+            'fields' => new NotEmptyOption(new NumberedOption('FIELDS', null, '>=2.0.0')),
+            'frags' => CV::isNumeric(new NamedOption('FRAGS', null, '>=2.0.0')),
+            'len' => CV::isNumeric(new NamedOption('LEN', null, '>=2.0.0')),
+            'separator' => new NamedOption('SEPARATOR', null, '>=2.0.0'),
+        ], ['type'], [], '>=2.0.0');
+    }
+}
diff --git a/src/Redis/Command/SpellCheck.php b/src/Redis/Command/SpellCheck.php
new file mode 100644
index 0000000..c1301d5
--- /dev/null
+++ b/src/Redis/Command/SpellCheck.php
@@ -0,0 +1,128 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function is_array;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\SpellCheckCommand\TermsOption;
+use MacFJA\RediSearch\Redis\Response\SpellCheckResponseItem;
+use Respect\Validation\Validator;
+
+class SpellCheck extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'query' => new NamelessOption(null, '>=2.0.0'),
+            'distance' => new CustomValidatorOption(
+                new NamedOption('DISTANCE', null, '>=2.0.0'),
+                Validator::intVal()->between(1, 4)
+            ),
+            'dialect' => CV::isNumeric(new NamedOption('DIALECT', null, '>=2.4.3')),
+            'terms' => [],
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setQuery(string $query): self
+    {
+        $this->options['query']->setValue($query);
+
+        return $this;
+    }
+
+    public function setDistance(int $distance): self
+    {
+        $this->options['distance']->setValue($distance);
+
+        return $this;
+    }
+
+    public function setDialect(int $version): self
+    {
+        $this->options['dialect']->setValue($version);
+
+        return $this;
+    }
+
+    public function addTerms(string $dictionary, bool $isExcluding = false): self
+    {
+        $terms = new TermsOption();
+        $terms->setIsExclusion($isExcluding);
+        $terms->setDictionary($dictionary);
+        $this->options['terms'][] = $terms;
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SPELLCHECK';
+    }
+
+    /**
+     * @param mixed $data
+     *
+     * @return SpellCheckResponseItem[]
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        $items = array_map(static function (array $group) {
+            $term = array_shift($group);
+            if (!('TERM' === $term)) {
+                return null;
+            }
+            $misspelled = array_shift($group);
+            $suggestions = reset($group);
+            $suggestions = array_column($suggestions, 0, 1);
+            $castedSuggestions = [];
+            foreach ($suggestions as $term => $score) {
+                $castedSuggestions[(string) $term] = (float) $score;
+            }
+
+            return new SpellCheckResponseItem($misspelled, $castedSuggestions);
+        }, $data);
+
+        return array_filter($items);
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'query'];
+    }
+}
diff --git a/src/Redis/Command/SpellCheckCommand/TermsOption.php b/src/Redis/Command/SpellCheckCommand/TermsOption.php
new file mode 100644
index 0000000..3dae047
--- /dev/null
+++ b/src/Redis/Command/SpellCheckCommand/TermsOption.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command\SpellCheckCommand;
+
+use function is_string;
+
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
+
+class TermsOption extends AbstractCommandOption
+{
+    /** @var bool */
+    private $isExclusion = false;
+
+    /** @var string */
+    private $dictionary;
+
+    public function __construct()
+    {
+        parent::__construct('>=2.0.0');
+    }
+
+    public function setIsExclusion(bool $isExclusion): TermsOption
+    {
+        $this->isExclusion = $isExclusion;
+
+        return $this;
+    }
+
+    public function setDictionary(string $dictionary): TermsOption
+    {
+        $this->dictionary = $dictionary;
+
+        return $this;
+    }
+
+    public function isValid(): bool
+    {
+        return is_string($this->dictionary);
+    }
+
+    /**
+     * @return string
+     */
+    public function getOptionData()
+    {
+        return $this->dictionary;
+    }
+
+    protected function doRender(?string $version): array
+    {
+        return ['TERMS', $this->isExclusion ? 'EXCLUDE' : 'INCLUDE', $this->dictionary];
+    }
+}
diff --git a/src/Redis/Command/SugAdd.php b/src/Redis/Command/SugAdd.php
new file mode 100644
index 0000000..2b8ea4c
--- /dev/null
+++ b/src/Redis/Command/SugAdd.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method int parseResponse(mixed $data)
+ */
+class SugAdd extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dictionary' => new NamelessOption(null, '>=2.0.0'),
+            'suggestion' => new NamelessOption(null, '>=2.0.0'),
+            'score' => CV::isNumeric(new NamelessOption(null, '>=2.0.0')),
+            'incr' => new FlagOption('INCR', false, '>=2.0.0'),
+            'payload' => new NamedOption('PAYLOAD', null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dictionary']->setValue($name);
+
+        return $this;
+    }
+
+    public function setSuggestion(string $value): self
+    {
+        $this->options['suggestion']->setValue($value);
+
+        return $this;
+    }
+
+    public function setScore(float $score): self
+    {
+        /** @var CV<NamelessOption>|NamelessOption $option */
+        $option = $this->options['score'];
+        $option->setValue($score);
+
+        return $this;
+    }
+
+    public function setIncrement(bool $active = true): self
+    {
+        $this->options['incr']->setActive($active);
+
+        return $this;
+    }
+
+    public function setPayload(string $payload): self
+    {
+        $this->options['payload']->setValue($payload);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SUGADD';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dictionary', 'suggestion', 'score'];
+    }
+}
diff --git a/src/Redis/Command/SugDel.php b/src/Redis/Command/SugDel.php
new file mode 100644
index 0000000..a58ee11
--- /dev/null
+++ b/src/Redis/Command/SugDel.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method int parseResponse(mixed $data)
+ */
+class SugDel extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dictionary' => new NamelessOption(null, '>=2.0.0'),
+            'suggestion' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dictionary']->setValue($name);
+
+        return $this;
+    }
+
+    public function setSuggestion(string $value): self
+    {
+        $this->options['suggestion']->setValue($value);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SUGDEL';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dictionary', 'suggestion'];
+    }
+}
diff --git a/src/Redis/Command/SugGet.php b/src/Redis/Command/SugGet.php
new file mode 100644
index 0000000..f8d20ac
--- /dev/null
+++ b/src/Redis/Command/SugGet.php
@@ -0,0 +1,135 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use function count;
+use function is_array;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption as CV;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Response\SuggestionResponseItem;
+
+class SugGet extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dictionary' => new NamelessOption(null, '>=2.0.0'),
+            'prefix' => new NamelessOption(null, '>=2.0.0'),
+            'fuzzy' => new FlagOption('FUZZY', false, '>=2.0.0'),
+            'scores' => new FlagOption('WITHSCORES', false, '>=2.0.0'),
+            'payloads' => new FlagOption('WITHPAYLOADS', false, '>=2.0.0'),
+            'max' => CV::isNumeric(new NamedOption('MAX', null, '>=2.0.0')),
+        ], $rediSearchVersion);
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dictionary']->setValue($name);
+
+        return $this;
+    }
+
+    public function setPrefix(string $value): self
+    {
+        $this->options['prefix']->setValue($value);
+
+        return $this;
+    }
+
+    public function setWithScores(bool $active = true): self
+    {
+        $this->options['scores']->setActive($active);
+
+        return $this;
+    }
+
+    public function setFuzzy(bool $active = true): self
+    {
+        $this->options['fuzzy']->setActive($active);
+
+        return $this;
+    }
+
+    public function setWithPayloads(bool $active = true): self
+    {
+        $this->options['payloads']->setActive($active);
+
+        return $this;
+    }
+
+    public function setMax(int $max): self
+    {
+        /** @var CV<NamedOption> $option */
+        $option = $this->options['max'];
+
+        $option->setValue($max);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SUGGET';
+    }
+
+    /**
+     * @param mixed $data
+     *
+     * @return SuggestionResponseItem[]
+     */
+    public function parseResponse($data)
+    {
+        if (!is_array($data)) {
+            throw new UnexpectedServerResponseException($data);
+        }
+
+        $useScores = $this->options['scores']->isActive();
+        $usePayloads = $this->options['payloads']->isActive();
+
+        $chunkSize = 1 // value
+            + (true === $useScores ? 1 : 0)
+            + (true === $usePayloads ? 1 : 0);
+
+        $documents = array_chunk($data, $chunkSize);
+
+        return array_map(static function ($document) use ($data, $usePayloads, $useScores) {
+            $payload = true === $usePayloads ? array_pop($document) : null;
+            $score = true === $useScores ? (float) array_pop($document) : null;
+
+            if (!(1 === count($document))) {
+                throw new UnexpectedServerResponseException($data, 'Incomplete response');
+            }
+            $value = reset($document);
+
+            return new SuggestionResponseItem($value, $score, $payload);
+        }, $documents);
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dictionary', 'prefix'];
+    }
+}
diff --git a/src/Redis/Command/SugLen.php b/src/Redis/Command/SugLen.php
new file mode 100644
index 0000000..9e664dc
--- /dev/null
+++ b/src/Redis/Command/SugLen.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+/**
+ * @method int parseResponse(mixed $data)
+ */
+class SugLen extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'dictionary' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setDictionary(string $name): self
+    {
+        $this->options['dictionary']->setValue($name);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SUGLEN';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['dictionary'];
+    }
+}
diff --git a/src/Redis/Command/SynDump.php b/src/Redis/Command/SynDump.php
new file mode 100644
index 0000000..87aede2
--- /dev/null
+++ b/src/Redis/Command/SynDump.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class SynDump extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SYNDUMP';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index'];
+    }
+}
diff --git a/src/Redis/Command/SynUpdate.php b/src/Redis/Command/SynUpdate.php
new file mode 100644
index 0000000..10f37f3
--- /dev/null
+++ b/src/Redis/Command/SynUpdate.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+
+class SynUpdate extends AbstractCommand
+{
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'group' => new NamelessOption(null, '>=2.0.0'),
+            'skip' => new FlagOption('SKIPINITIALSCAN', false, '>=2.0.0'),
+            'terms' => [],
+        ], $rediSearchVersion);
+    }
+
+    public function setIndex(string $index): self
+    {
+        $this->options['index']->setValue($index);
+
+        return $this;
+    }
+
+    public function setGroupId(string $name): self
+    {
+        $this->options['group']->setValue($name);
+
+        return $this;
+    }
+
+    public function setSkipInitialScan(bool $active = true): self
+    {
+        $this->options['skip']->setActive($active);
+
+        return $this;
+    }
+
+    public function setTerms(string ...$term): self
+    {
+        $this->options['terms'] = array_map(static function (string $term) {
+            return new NamelessOption($term);
+        }, $term);
+
+        return $this;
+    }
+
+    public function getId(): string
+    {
+        return 'FT.SYNUPDATE';
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return ['index', 'group', 'terms'];
+    }
+}
diff --git a/src/Search/QueryBuilder/Negation.php b/src/Redis/Command/TagVals.php
similarity index 57%
rename from src/Search/QueryBuilder/Negation.php
rename to src/Redis/Command/TagVals.php
index 0782829..86a0d56 100644
--- a/src/Search/QueryBuilder/Negation.php
+++ b/src/Redis/Command/TagVals.php
@@ -19,43 +19,44 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search\QueryBuilder;
+namespace MacFJA\RediSearch\Redis\Command;
 
-use function ctype_digit;
-use function sprintf;
-use function substr;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
 
-class Negation implements PartialQuery
+/**
+ * @method array<string> parseResponse(mixed $data)
+ */
+class TagVals extends AbstractCommand
 {
-    private const WITH_SPACE_PATTERN = '-(%s)';
-
-    private const WITHOUT_SPACE_PATTERN = '-%s';
-
-    /** @var PartialQuery */
-    private $expression;
+    public function __construct(string $rediSearchVersion = self::MIN_IMPLEMENTED_VERSION)
+    {
+        parent::__construct([
+            'index' => new NamelessOption(null, '>=2.0.0'),
+            'field' => new NamelessOption(null, '>=2.0.0'),
+        ], $rediSearchVersion);
+    }
 
-    public function __construct(PartialQuery $expression)
+    public function setIndex(string $index): self
     {
-        $this->expression = $expression;
+        $this->options['index']->setValue($index);
+
+        return $this;
     }
 
-    public function render(): string
+    public function setField(string $name): self
     {
-        $withParentheses = $this->expression->includeSpace() || ctype_digit(substr($this->expression->render(), 0, 1));
+        $this->options['field']->setValue($name);
 
-        return sprintf(
-            true === $withParentheses ? self::WITH_SPACE_PATTERN : self::WITHOUT_SPACE_PATTERN,
-            $this->expression->render()
-        );
+        return $this;
     }
 
-    public function includeSpace(): bool
+    public function getId(): string
     {
-        return false;
+        return 'FT.TAGVALS';
     }
 
-    public function priority(): int
+    protected function getRequiredOptions(): array
     {
-        return $this->expression->priority();
+        return ['index', 'field'];
     }
 }
diff --git a/src/Redis/Initializer.php b/src/Redis/Initializer.php
new file mode 100644
index 0000000..c45fcbb
--- /dev/null
+++ b/src/Redis/Initializer.php
@@ -0,0 +1,192 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis;
+
+use MacFJA\RediSearch\Redis\Client\AbstractClient;
+use MacFJA\RediSearch\Redis\Client\Rediska\RediskaRediSearchCommand;
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\AliasAdd;
+use MacFJA\RediSearch\Redis\Command\AliasDel;
+use MacFJA\RediSearch\Redis\Command\AliasUpdate;
+use MacFJA\RediSearch\Redis\Command\Alter;
+use MacFJA\RediSearch\Redis\Command\Config;
+use MacFJA\RediSearch\Redis\Command\ConfigSet;
+use MacFJA\RediSearch\Redis\Command\Create;
+use MacFJA\RediSearch\Redis\Command\CursorDel;
+use MacFJA\RediSearch\Redis\Command\CursorRead;
+use MacFJA\RediSearch\Redis\Command\DictAdd;
+use MacFJA\RediSearch\Redis\Command\DictDel;
+use MacFJA\RediSearch\Redis\Command\DictDump;
+use MacFJA\RediSearch\Redis\Command\DropIndex;
+use MacFJA\RediSearch\Redis\Command\Explain;
+use MacFJA\RediSearch\Redis\Command\ExplainCli;
+use MacFJA\RediSearch\Redis\Command\IndexList;
+use MacFJA\RediSearch\Redis\Command\Info;
+use MacFJA\RediSearch\Redis\Command\Search;
+use MacFJA\RediSearch\Redis\Command\SpellCheck;
+use MacFJA\RediSearch\Redis\Command\SugAdd;
+use MacFJA\RediSearch\Redis\Command\SugDel;
+use MacFJA\RediSearch\Redis\Command\SugGet;
+use MacFJA\RediSearch\Redis\Command\SugLen;
+use MacFJA\RediSearch\Redis\Command\SynDump;
+use MacFJA\RediSearch\Redis\Command\SynUpdate;
+use MacFJA\RediSearch\Redis\Command\TagVals;
+use Predis\Profile\RedisProfile;
+use Rediska_Commands;
+use Throwable;
+
+/**
+ * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
+ */
+class Initializer
+{
+    /**
+     * @codeCoverageIgnore
+     *
+     * @psalm-suppress UndefinedDocblockClass
+     * @psalm-suppress UndefinedClass
+     *
+     * @param RedisProfile $profile
+     */
+    public static function registerCommandsPredis($profile): void
+    {
+        if (!$profile instanceof RedisProfile) {
+            false === AbstractClient::$disableNotice
+            && trigger_error(sprintf('The parameter $profile must be an instance of %s, which is only available in Predis 1.x.', RedisProfile::class), E_USER_WARNING);
+
+            return;
+        }
+        $profile->defineCommand('ftaggregate', Aggregate::class);
+        $profile->defineCommand('ftaliasadd', AliasAdd::class);
+        $profile->defineCommand('ftaliasdel', AliasDel::class);
+        $profile->defineCommand('ftaliasupdate', AliasUpdate::class);
+        $profile->defineCommand('ftalter', Alter::class);
+        $profile->defineCommand('ftconfig', Config::class);
+        $profile->defineCommand('ftconfigset', ConfigSet::class);
+        $profile->defineCommand('ftcreate', Create::class);
+        $profile->defineCommand('ftcursordel', CursorDel::class);
+        $profile->defineCommand('ftcursorread', CursorRead::class);
+        $profile->defineCommand('ftdictadd', DictAdd::class);
+        $profile->defineCommand('ftdictdel', DictDel::class);
+        $profile->defineCommand('ftdictdump', DictDump::class);
+        $profile->defineCommand('ftdropindex', DropIndex::class);
+        $profile->defineCommand('ftexplain', Explain::class);
+        $profile->defineCommand('ftexplaincli', ExplainCli::class);
+        $profile->defineCommand('ftlist', IndexList::class);
+        $profile->defineCommand('ftinfo', Info::class);
+        $profile->defineCommand('ftsearch', Search::class);
+        $profile->defineCommand('ftspellcheck', SpellCheck::class);
+        $profile->defineCommand('ftsugadd', SugAdd::class);
+        $profile->defineCommand('ftsugdel', SugDel::class);
+        $profile->defineCommand('ftsugget', SugGet::class);
+        $profile->defineCommand('ftsuglen', SugLen::class);
+        $profile->defineCommand('ftsyndump', SynDump::class);
+        $profile->defineCommand('ftsynupdate', SynUpdate::class);
+        $profile->defineCommand('fttagvals', TagVals::class);
+    }
+
+    /**
+     * @codeCoverageIgnore
+     */
+    public static function registerCommandsRediska(): void
+    {
+        Rediska_Commands::add('ftaggregate', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftaliasadd', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftaliasdel', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftaliasupdate', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftalter', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftconfig', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftconfigset', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftcreate', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftcursordel', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftcursorread', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftdictadd', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftdictdel', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftdictdump', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftdropindex', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftexplain', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftexplaincli', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftlist', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftinfo', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsearch', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftspellcheck', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsugadd', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsugdel', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsugget', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsuglen', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsyndump', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('ftsynupdate', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('fttagvals', RediskaRediSearchCommand::class);
+        Rediska_Commands::add('__redisearch', RediskaRediSearchCommand::class);
+    }
+
+    public static function getRedisModuleVersion(Client $client, string $module): ?string
+    {
+        try {
+            $modules = $client->executeRaw('module', 'list') ?? [];
+        } catch (Throwable $exception) {
+            if (false === stripos($exception->getMessage(), 'unknown command')) {
+                throw $exception;
+            }
+
+            return null;
+        }
+
+        foreach ($modules as $moduleData) {
+            $data = array_column(
+                array_chunk(
+                    $moduleData,
+                    2
+                ),
+                1,
+                0
+            );
+
+            if (!(($data['name'] ?? '') === $module) || empty($data['ver'])) {
+                continue;
+            }
+
+            $major = floor($data['ver'] / 10000);
+            $minor = floor(($data['ver'] - $major * 10000) / 100);
+            $patch = $data['ver'] - $major * 10000 - $minor * 100;
+
+            return sprintf(
+                '%d.%d.%d',
+                $major,
+                $minor,
+                $patch
+            );
+        }
+
+        return null;
+    }
+
+    public static function getRediSearchVersion(Client $client): ?string
+    {
+        return self::getRedisModuleVersion($client, 'search');
+    }
+
+    public static function getRedisJSONVersion(Client $client): ?string
+    {
+        return self::getRedisModuleVersion($client, 'ReJSON');
+    }
+}
diff --git a/src/Index/Builder/Exception/TooManyFields.php b/src/Redis/Response.php
similarity index 89%
rename from src/Index/Builder/Exception/TooManyFields.php
rename to src/Redis/Response.php
index f9f8618..e037c7c 100644
--- a/src/Index/Builder/Exception/TooManyFields.php
+++ b/src/Redis/Response.php
@@ -19,10 +19,6 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder\Exception;
+namespace MacFJA\RediSearch\Redis;
 
-use OutOfBoundsException;
-
-class TooManyFields extends OutOfBoundsException
-{
-}
+interface Response {}
diff --git a/src/Aggregate/Result.php b/src/Redis/Response/AggregateResponseItem.php
similarity index 73%
rename from src/Aggregate/Result.php
rename to src/Redis/Response/AggregateResponseItem.php
index f6c626f..f9c365b 100644
--- a/src/Aggregate/Result.php
+++ b/src/Redis/Response/AggregateResponseItem.php
@@ -19,19 +19,18 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate;
+namespace MacFJA\RediSearch\Redis\Response;
 
-use Throwable;
-
-class Result
+/**
+ * @codeCoverageIgnore Simple value object
+ */
+class AggregateResponseItem implements ResponseItem
 {
     /** @var array<string,null|array<mixed>|float|int|string> */
     private $fields = [];
 
     /**
      * @param array<string,null|array<mixed>|float|int|string> $fields
-     *
-     * @throws Throwable
      */
     public function __construct(array $fields)
     {
@@ -50,8 +49,28 @@ public function getFields(): array
      * @param null|array<mixed>|float|int|string $default
      *
      * @return null|array<mixed>|float|int|string
+     *
+     * @deprecated Use ::getFieldValue instead
+     *
+     * @codeCoverageIgnore
+     *
+     * @psalm-suppress
+     *
+     * @SuppressWarnings
      */
     public function getValue(string $fieldName, $default = null)
+    {
+        trigger_error(sprintf(
+            'Method %s is deprecated from version 2.2.0, use %s::getFieldValue instead',
+            __METHOD__,
+            __CLASS__
+        ), E_USER_DEPRECATED);
+
+        // @phpstan-ignore-next-line
+        return $this->getFieldValue($fieldName, $default);
+    }
+
+    public function getFieldValue(string $fieldName, $default = null)
     {
         return $this->fields[$fieldName] ?? $default;
     }
diff --git a/src/Redis/Response/ArrayResponseTrait.php b/src/Redis/Response/ArrayResponseTrait.php
new file mode 100644
index 0000000..d5d1a20
--- /dev/null
+++ b/src/Redis/Response/ArrayResponseTrait.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+use function assert;
+use function count;
+use function is_array;
+
+trait ArrayResponseTrait
+{
+    /**
+     * @param array<float|int|mixed|string> $notKeyedArray
+     *
+     * @return array<string,mixed>
+     */
+    private static function getPairs(array $notKeyedArray): array
+    {
+        if (count($notKeyedArray) % 2 > 0) {
+            array_pop($notKeyedArray);
+        }
+        $entries = array_chunk($notKeyedArray, 2);
+        $keyed = array_combine(array_column($entries, 0), array_column($entries, 1));
+        assert(is_array($keyed));
+
+        return $keyed;
+    }
+
+    /**
+     * @param array<array|mixed> $notKeyedArray
+     *
+     * @return null|array<mixed>|bool|float|int|string
+     */
+    private static function getValue(array $notKeyedArray, string $key)
+    {
+        while (count($notKeyedArray) >= 2) {
+            $shifted = (string) array_shift($notKeyedArray);
+            if ($shifted === $key) {
+                return array_shift($notKeyedArray);
+            }
+            array_shift($notKeyedArray);
+        }
+
+        return null;
+    }
+
+    /**
+     * @param array<array|mixed> $notKeyedArray
+     *
+     * @return null|bool|float|int|string
+     */
+    private static function getNextValue(array $notKeyedArray, string $key)
+    {
+        while (count($notKeyedArray) >= 2) {
+            $shifted = (string) array_shift($notKeyedArray);
+            if ($shifted === $key) {
+                return array_shift($notKeyedArray);
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * @param array<float|int|mixed|string> $notKeyedArray
+     *
+     * @return array<string>
+     */
+    private static function getKeys(array $notKeyedArray): array
+    {
+        if (count($notKeyedArray) % 2 > 0) {
+            array_pop($notKeyedArray);
+        }
+        $keys = array_values(array_filter(array_values($notKeyedArray), static function (int $key): bool {
+            return 0 === $key % 2;
+        }, ARRAY_FILTER_USE_KEY));
+
+        return array_map(static function ($key): string {
+            return (string) $key;
+        }, $keys);
+    }
+}
diff --git a/src/Index/Builder/Exception/MustBeSingleCharException.php b/src/Redis/Response/ClientAware.php
similarity index 84%
rename from src/Index/Builder/Exception/MustBeSingleCharException.php
rename to src/Redis/Response/ClientAware.php
index 12ada47..d221e52 100644
--- a/src/Index/Builder/Exception/MustBeSingleCharException.php
+++ b/src/Redis/Response/ClientAware.php
@@ -19,10 +19,13 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder\Exception;
+namespace MacFJA\RediSearch\Redis\Response;
 
-use UnexpectedValueException;
+use MacFJA\RediSearch\Redis\Client;
 
-class MustBeSingleCharException extends UnexpectedValueException
+interface ClientAware
 {
+    public function setClient(Client $client): ClientAware;
+
+    public function getClient(): ?Client;
 }
diff --git a/src/Redis/Response/ClientAwareTrait.php b/src/Redis/Response/ClientAwareTrait.php
new file mode 100644
index 0000000..3bbd51d
--- /dev/null
+++ b/src/Redis/Response/ClientAwareTrait.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+use MacFJA\RediSearch\Redis\Client;
+
+trait ClientAwareTrait
+{
+    /** @var null|Client */
+    private $client;
+
+    public function getClient(): ?Client
+    {
+        return $this->client;
+    }
+
+    public function setClient(Client $client): ClientAware
+    {
+        $this->client = $client;
+
+        return $this;
+    }
+}
diff --git a/src/Redis/Response/CursorResponse.php b/src/Redis/Response/CursorResponse.php
new file mode 100644
index 0000000..4e024e1
--- /dev/null
+++ b/src/Redis/Response/CursorResponse.php
@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+use function count;
+
+use Countable;
+use Iterator;
+use MacFJA\RediSearch\Exception\MissingClientException;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\CursorRead;
+use MacFJA\RediSearch\Redis\Response;
+
+/**
+ * @implements Iterator<int,AggregateResponseItem[]>
+ */
+class CursorResponse implements Response, Iterator, Countable, ClientAware
+{
+    use ClientAwareTrait;
+
+    /** @var array<AggregateResponseItem> */
+    private $items;
+
+    /** @var int */
+    private $totalCount;
+
+    /** @var bool */
+    private $doNext = false;
+
+    /** @var int */
+    private $cursorId;
+
+    /** @var int */
+    private $size;
+
+    /** @var int */
+    private $offset = 0;
+
+    /** @var string */
+    private $index;
+
+    /** @var string */
+    private $redisVersion;
+
+    /**
+     * @param array<AggregateResponseItem> $items
+     */
+    public function __construct(int $cursorId, int $totalCount, array $items, int $size, string $index, string $redisVersion = AbstractCommand::MIN_IMPLEMENTED_VERSION)
+    {
+        $this->totalCount = $totalCount;
+        $this->items = $items;
+        $this->size = $size;
+        $this->index = $index;
+        $this->redisVersion = $redisVersion;
+        $this->cursorId = $cursorId;
+    }
+
+    /**
+     * @return array<AggregateResponseItem>
+     */
+    public function current()
+    {
+        if ($this->doNext) {
+            if (!($this->getClient() instanceof Client)) {
+                throw new MissingClientException();
+            }
+
+            $cursorRead = new CursorRead($this->redisVersion);
+            $cursorRead->setIndex($this->index);
+            $cursorRead->setCursorId($this->cursorId);
+            $cursorRead->setCount($this->getPageSize());
+
+            /** @var CursorResponse $next */
+            $next = $this->getClient()->execute($cursorRead);
+            $this->cursorId = $next->cursorId;
+            $this->offset += count($this->items);
+            $this->items = $next->items;
+            $this->doNext = false;
+            // Don't update totalCount as Cursor always return 0
+        }
+
+        return $this->items;
+    }
+
+    public function next(): void
+    {
+        $this->doNext = true;
+    }
+
+    public function getPageSize(): int
+    {
+        return $this->size;
+    }
+
+    public function getPageCount(): int
+    {
+        if (0 === $this->size) {
+            return 0;
+        }
+
+        return (int) ceil($this->totalCount / $this->size);
+    }
+
+    public function key()
+    {
+        if (0 === $this->size) {
+            return 0;
+        }
+
+        return (int) ceil($this->offset / $this->size) + 1;
+    }
+
+    public function valid()
+    {
+        return $this->offset + count($this->items) < $this->totalCount && $this->cursorId > 0;
+    }
+
+    public function rewind(): void
+    {
+        // Do nothing
+    }
+
+    public function getTotalCount(): int
+    {
+        return $this->totalCount;
+    }
+
+    public function count()
+    {
+        return $this->getPageCount();
+    }
+}
diff --git a/src/Redis/Response/InfoResponse.php b/src/Redis/Response/InfoResponse.php
new file mode 100644
index 0000000..0fd2b6e
--- /dev/null
+++ b/src/Redis/Response/InfoResponse.php
@@ -0,0 +1,245 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+use function assert;
+use function in_array;
+use function is_array;
+use function is_object;
+use function is_string;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\CreateCommandFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Response;
+use Stringable;
+
+/**
+ * @method string     getIndexName()
+ * @method array      getIndexOptions()
+ * @method array      getFields()
+ * @method int        getNumDocs()
+ * @method int        getMaxDocId()
+ * @method int        getNumTerms()
+ * @method int        getNumRecords()
+ * @method float      getInvertedSzMb()
+ * @method int        getTotalInvertedIndexBlocks()
+ * @method float      getOffsetVectorsSzMb()
+ * @method float      getDocTableSizeMb()
+ * @method float      getSortableValuesSizeMb()
+ * @method float      getKeyTableSizeMb()
+ * @method null|float getRecordsPerDocAvg()
+ * @method null|float getBytesPerRecordAvg()
+ * @method null|float getOffsetsPerTermAvg()
+ * @method null|float getOffsetBitsPerRecordAvg()
+ * @method bool       getHashIndexingFailures()
+ * @method bool       getIndexing()
+ * @method float      getPercentIndexed()
+ * @method array      getGcStats()
+ * @method array      getCursorStats()
+ * @method array      getStopwordsList()
+ */
+class InfoResponse implements Response
+{
+    use ArrayResponseTrait;
+
+    private const TO_FLOAT = ['*_sz_mb', '*_size_mb', '*_avg', 'percent_indexed'];
+    private const TO_INT = ['num_*', 'max_doc_id', 'total_inverted_index_blocks', 'hash_indexing_failures'];
+    private const TO_STRING = ['index_name'];
+    private const TO_BOOLEAN = ['indexing'];
+
+    /**
+     * @var array<array<mixed>|float|int|string>
+     */
+    private $rawLines;
+
+    /**
+     * @param array<array<mixed>|float|int|string> $rawLines
+     */
+    public function __construct(array $rawLines)
+    {
+        $this->rawLines = $rawLines;
+    }
+
+    /**
+     * @param array<mixed> $arguments
+     *
+     * @return null|float|int|mixed
+     */
+    public function __call(string $name, array $arguments)
+    {
+        if (!(0 === strpos($name, 'get'))) {
+            throw new BadMethodCallException();
+        }
+        $property = substr($name, 3);
+        if (!($property[0] === strtoupper($property[0]))) {
+            throw new BadMethodCallException();
+        }
+        $keyName = $this->pascalCaseToSnakeCase($property);
+        $keys = self::getKeys($this->rawLines);
+        if (!in_array($keyName, $keys, true)) {
+            throw new InvalidArgumentException();
+        }
+
+        $value = self::getValue($this->rawLines, $keyName);
+        if ('-nan' === $value || 'inf' === $value) {
+            return null;
+        }
+
+        return $this->cast($value, $keyName);
+    }
+
+    /**
+     * @return array<CreateCommandFieldOption>
+     */
+    public function getFieldsAsOption(): array
+    {
+        $fields = self::getValue($this->rawLines, 'fields');
+        assert(is_array($fields));
+
+        return array_map(static function (array $field): CreateCommandFieldOption {
+            $fieldName = (string) array_shift($field);
+            array_walk($field, static function (&$item) {
+                if (is_object($item) && ($item instanceof Stringable || method_exists($item, '__toString'))) {
+                    $item = (string) $item;
+                }
+
+                return $item;
+            });
+
+            switch (self::getValue($field, 'type')) {
+                case 'GEO':
+                    $object = new GeoFieldOption();
+
+                    break;
+
+                case 'NUMERIC':
+                    $object = new NumericFieldOption();
+
+                    break;
+
+                case 'TAG':
+                    $object = new TagFieldOption();
+                    $object = self::setOptionValue($object, $field, 'SEPARATOR', 'separator');
+
+                    break;
+
+                case 'TEXT':
+                default:
+                    $object = new TextFieldOption();
+                    $object = self::setOptionValue($object, $field, 'WEIGHT', 'weight');
+                    $object = self::setOptionValue($object, $field, 'PHONETIC', 'phonetic');
+                    $object = self::setOptionFlag($object, $field, 'NOSTEM', 'no_stem');
+            }
+
+            assert($object instanceof GroupedOption);
+            $object->setDataOfOption('field', $fieldName);
+            $object = self::setOptionFlag($object, $field, 'NOINDEX', 'no_index');
+
+            return self::setOptionFlag($object, $field, 'SORTABLE', 'sortable');
+        }, $fields);
+    }
+
+    /**
+     * @return null|array<string,mixed>|mixed
+     */
+    public function getIndexDefinition(?string $key = null)
+    {
+        $rawDefinitions = self::getValue($this->rawLines, 'index_definition');
+        if (!is_array($rawDefinitions)) {
+            return is_string($key) ? null : [];
+        }
+        $data = self::getPairs($rawDefinitions);
+        if (!is_string($key)) {
+            return $data;
+        }
+
+        return $data[$key] ?? null;
+    }
+
+    private function pascalCaseToSnakeCase(string $pascalCase): string
+    {
+        return strtolower(preg_replace('/([^A-Z\s])([A-Z])/', '$1_$2', $pascalCase) ?? $pascalCase);
+    }
+
+    /**
+     * @param mixed $value
+     *
+     * @return float|int|mixed
+     */
+    private function cast($value, string $key)
+    {
+        foreach (self::TO_FLOAT as $floatKey) {
+            if (fnmatch($floatKey, $key)) {
+                return (float) $value;
+            }
+        }
+        foreach (self::TO_INT as $intKey) {
+            if (fnmatch($intKey, $key)) {
+                return (int) $value;
+            }
+        }
+        foreach (self::TO_STRING as $intKey) {
+            if (fnmatch($intKey, $key)) {
+                return (string) $value;
+            }
+        }
+        foreach (self::TO_BOOLEAN as $intKey) {
+            if (fnmatch($intKey, $key)) {
+                return '1' === (string) $value;
+            }
+        }
+
+        return $value;
+    }
+
+    /**
+     * @param array<float|int|string> $fields
+     */
+    private static function setOptionValue(CreateCommandFieldOption $commandOption, array $fields, string $key, string $optionName): CreateCommandFieldOption
+    {
+        assert($commandOption instanceof TextFieldOption || $commandOption instanceof TagFieldOption);
+        if (in_array($key, $fields, true)) {
+            $commandOption->setDataOfOption($optionName, self::getNextValue($fields, $key));
+        }
+
+        return $commandOption;
+    }
+
+    /**
+     * @param array<float|int|string> $fields
+     */
+    private static function setOptionFlag(CreateCommandFieldOption $commandOption, array $fields, string $key, string $optionName): CreateCommandFieldOption
+    {
+        assert($commandOption instanceof GroupedOption);
+        if (in_array($key, $fields, true)) {
+            $commandOption->setDataOfOption($optionName, true);
+        }
+
+        return $commandOption;
+    }
+}
diff --git a/src/Redis/Response/PaginatedResponse.php b/src/Redis/Response/PaginatedResponse.php
new file mode 100644
index 0000000..0e14dec
--- /dev/null
+++ b/src/Redis/Response/PaginatedResponse.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+use function count;
+use function is_int;
+
+use Countable;
+use Iterator;
+use MacFJA\RediSearch\Exception\MissingClientException;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\PaginatedCommand;
+use MacFJA\RediSearch\Redis\Response;
+
+/**
+ * @implements Iterator<int,ResponseItem[]|AggregateResponseItem[]|SearchResponseItem[]>
+ */
+class PaginatedResponse implements Response, Iterator, Countable, ClientAware
+{
+    use ClientAwareTrait;
+
+    /** @var array<ResponseItem> */
+    private $items;
+
+    /** @var PaginatedCommand */
+    private $lastCommand;
+
+    /** @var int */
+    private $totalCount;
+
+    /** @var null|int */
+    private $requestedOffset;
+
+    /** @var null|int */
+    private $requestedSize;
+
+    /**
+     * @param ResponseItem[] $items
+     */
+    public function __construct(PaginatedCommand $command, int $totalCount, array $items)
+    {
+        $this->totalCount = $totalCount;
+        $this->items = $items;
+        $this->lastCommand = $command;
+    }
+
+    /**
+     * @return array<ResponseItem>
+     */
+    public function current(): array
+    {
+        if ($this->requestedOffset === ($this->lastCommand->getOffset() ?? 0) && ($this->requestedSize === $this->getPageSize())) {
+            $this->requestedOffset = null;
+            $this->requestedSize = null;
+        }
+        if (is_int($this->requestedOffset) && is_int($this->requestedSize)) {
+            $this->updateWithLimit($this->requestedOffset, $this->requestedSize);
+            $this->requestedOffset = null;
+            $this->requestedSize = null;
+        }
+
+        return $this->items;
+    }
+
+    public function next(): void
+    {
+        $this->requestedOffset = ($this->lastCommand->getOffset() ?? 0) + $this->getPageSize();
+        $this->requestedSize = $this->getPageSize();
+    }
+
+    public function getPageSize(): int
+    {
+        return $this->lastCommand->getSize() ?? count($this->items);
+    }
+
+    public function getPageCount(): int
+    {
+        if (0 === $this->getPageSize()) {
+            return 0;
+        }
+
+        return (int) ceil($this->totalCount / $this->getPageSize());
+    }
+
+    public function key(): int
+    {
+        if (0 === $this->getPageSize()) {
+            return 0;
+        }
+
+        return (int) floor(($this->lastCommand->getOffset() ?? $this->requestedOffset ?? 0) / $this->getPageSize());
+    }
+
+    public function valid(): bool
+    {
+        return (
+            null === $this->requestedOffset
+            && null === $this->requestedSize
+        ) || (
+            $this->requestedOffset >= 0
+            && $this->requestedSize > 0
+            && $this->requestedOffset < $this->totalCount
+        );
+    }
+
+    public function rewind(): void
+    {
+        $this->requestedOffset = 0;
+        $this->requestedSize = $this->getPageSize();
+    }
+
+    public function getTotalCount(): int
+    {
+        return $this->totalCount;
+    }
+
+    public function count(): int
+    {
+        return $this->getPageCount();
+    }
+
+    private function updateWithLimit(int $offset, int $size): void
+    {
+        if (!($this->getClient() instanceof Client)) {
+            throw new MissingClientException();
+        }
+
+        /** @var PaginatedCommand $nextCommand */
+        $nextCommand = clone $this->lastCommand;
+        $nextCommand->setLimit($offset, $size);
+
+        /** @var PaginatedResponse $paginated */
+        $paginated = $this->getClient()->execute($nextCommand);
+        $this->lastCommand = $nextCommand;
+        $this->totalCount = $paginated->totalCount;
+        $this->items = $paginated->items;
+    }
+}
diff --git a/src/Redis/Response/ResponseItem.php b/src/Redis/Response/ResponseItem.php
new file mode 100644
index 0000000..a8f234d
--- /dev/null
+++ b/src/Redis/Response/ResponseItem.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\Redis\Response;
+
+interface ResponseItem
+{
+    /**
+     * @return array<string,null|array<mixed>|float|int|string>
+     */
+    public function getFields(): array;
+
+    /**
+     * @param null|array<mixed>|bool|float|int|string $default
+     *
+     * @return null|array<mixed>|bool|float|int|string
+     */
+    public function getFieldValue(string $fieldName, $default = null);
+}
diff --git a/src/Search/Result.php b/src/Redis/Response/SearchResponseItem.php
similarity index 84%
rename from src/Search/Result.php
rename to src/Redis/Response/SearchResponseItem.php
index 962f8d5..6b1fa27 100644
--- a/src/Search/Result.php
+++ b/src/Redis/Response/SearchResponseItem.php
@@ -19,12 +19,12 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search;
+namespace MacFJA\RediSearch\Redis\Response;
 
-use function array_keys;
-use MacFJA\RediSearch\Helper\DataHelper;
-
-class Result
+/**
+ * @codeCoverageIgnore Simple value object
+ */
+class SearchResponseItem implements ResponseItem
 {
     /** @var string */
     private $hash;
@@ -46,8 +46,6 @@ class Result
      */
     public function __construct(string $hash, array $fields = [], ?float $score = null, ?string $payload = null, ?string $sortKey = null)
     {
-        DataHelper::assertArrayOf(array_keys($fields), 'string');
-        DataHelper::assertArrayOf($fields, 'scalar');
         $this->hash = $hash;
         $this->score = $score;
         $this->payload = $payload;
@@ -78,6 +76,16 @@ public function getFields(): array
         return $this->fields;
     }
 
+    /**
+     * @param null|array<mixed>|bool|float|int|string $default
+     *
+     * @return null|array<mixed>|bool|float|int|string
+     */
+    public function getFieldValue(string $fieldName, $default = null)
+    {
+        return $this->fields[$fieldName] ?? $default;
+    }
+
     public function getSortKey(): ?string
     {
         return $this->sortKey;
diff --git a/src/Words/SpellingResult.php b/src/Redis/Response/SpellCheckResponseItem.php
similarity index 53%
rename from src/Words/SpellingResult.php
rename to src/Redis/Response/SpellCheckResponseItem.php
index 7163150..316259a 100644
--- a/src/Words/SpellingResult.php
+++ b/src/Redis/Response/SpellCheckResponseItem.php
@@ -19,59 +19,67 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Words;
+namespace MacFJA\RediSearch\Redis\Response;
 
-use function array_keys;
-use function assert;
 use function count;
-use function is_string;
-use function key;
-use MacFJA\RediSearch\Helper\DataHelper;
-use function reset;
-use Throwable;
 
-class SpellingResult
+/**
+ * @codeCoverageIgnore Simple value object
+ */
+class SpellCheckResponseItem
 {
     /** @var string */
-    private $searched;
+    private $term;
 
-    /** @var array<string, float> */
-    private $suggestions = [];
+    /** @var array<string,float> */
+    private $suggestions;
 
     /**
-     * @param array<string, float> $suggestions
-     *
-     * @throws Throwable
+     * @param array<string,float> $suggestions
      */
-    public function __construct(string $searched, array $suggestions)
+    public function __construct(string $term, array $suggestions)
     {
-        DataHelper::assert(count($suggestions) > 0);
-        DataHelper::assertArrayOf(array_keys($suggestions), 'string');
-        DataHelper::assertArrayOf($suggestions, 'double');
-        $this->searched = $searched;
+        $this->term = $term;
         $this->suggestions = $suggestions;
     }
 
-    public function getSearched(): string
+    public function getMisspelled(): string
     {
-        return $this->searched;
+        return $this->term;
     }
 
     /**
      * @return array<string, float>
      */
-    public function getSuggestions(): array
+    public function getSuggestions(bool $normalized = false): array
     {
-        return $this->suggestions;
+        if (false === $normalized) {
+            return $this->suggestions;
+        }
+        if (0 === count($this->suggestions)) {
+            return [];
+        }
+        $highest = max($this->suggestions);
+
+        if ($highest <= 0) {
+            return [];
+        }
+
+        return array_map(static function (float $score) use ($highest) {
+            return $score / $highest;
+        }, $this->suggestions);
     }
 
-    public function getBestSuggestion(): string
+    public function getTopSuggestion(): ?string
     {
-        assert(count($this->suggestions) > 0);
-        reset($this->suggestions);
-        $best = key($this->suggestions);
-        assert(is_string($best));
+        if (0 === count($this->suggestions)) {
+            return null;
+        }
+
+        $local = $this->suggestions;
+        arsort($local);
+        reset($local);
 
-        return $best;
+        return key($local);
     }
 }
diff --git a/src/Suggestion/Result.php b/src/Redis/Response/SuggestionResponseItem.php
similarity index 93%
rename from src/Suggestion/Result.php
rename to src/Redis/Response/SuggestionResponseItem.php
index 24c110e..e69bf4f 100644
--- a/src/Suggestion/Result.php
+++ b/src/Redis/Response/SuggestionResponseItem.php
@@ -19,12 +19,13 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Suggestion;
+namespace MacFJA\RediSearch\Redis\Response;
 
-class Result
+/**
+ * @codeCoverageIgnore Simple value object
+ */
+class SuggestionResponseItem
 {
-    public const DEFAULT_SCORE = 1.0;
-
     /** @var string */
     private $value;
 
diff --git a/src/Search.php b/src/Search.php
deleted file mode 100644
index a6c9803..0000000
--- a/src/Search.php
+++ /dev/null
@@ -1,541 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch;
-
-use function array_chunk;
-use function array_map;
-use function array_merge;
-use function array_shift;
-use function assert;
-use function count;
-use function in_array;
-use InvalidArgumentException;
-use function is_array;
-use function is_int;
-use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\PaginatedResult;
-use MacFJA\RediSearch\Helper\PipelineItem;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Index\Builder as IndexBuilder;
-use MacFJA\RediSearch\Search\Exception\UnsupportedLanguageException;
-use MacFJA\RediSearch\Search\Filter;
-use MacFJA\RediSearch\Search\GeoFilter;
-use MacFJA\RediSearch\Search\Highlight;
-use MacFJA\RediSearch\Search\Result;
-use MacFJA\RediSearch\Search\Summarize;
-use Predis\Client;
-use function reset;
-use Throwable;
-
-/**
- * @SuppressWarnings(PHPMD.TooManyFields) -- Builder Class
- * @SuppressWarnings(PHPMD.CouplingBetweenObjects) -- Builder Class
- */
-class Search implements Builder, Pipeable
-{
-    public const SORT_ASC = 'ASC';
-
-    public const SORT_DESC = 'DESC';
-
-    private const DEFAULT_OFFSET = 0;
-
-    private const DEFAULT_LIMIT = 10;
-
-    /** @var Client */
-    private $redis;
-
-    /** @var null|string */
-    private $index;
-
-    /** @var null|string */
-    private $query;
-
-    /** @var bool */
-    private $noContent = false;
-
-    /** @var bool */
-    private $verbatim = false;
-
-    /** @var bool */
-    private $noStopWords = false;
-
-    /** @var bool */
-    private $withScores = false;
-
-    /** @var bool */
-    private $withPayloads = false;
-
-    /** @var bool */
-    private $withSortKeys = false;
-
-    /** @var array<Filter> */
-    private $filters = [];
-
-    /** @var null|GeoFilter */
-    private $geoFilter;
-
-    /** @var array<string> */
-    private $inKeys = [];
-
-    /** @var array<string> */
-    private $inFields = [];
-
-    /** @var array<string> */
-    private $returns = [];
-
-    /** @var null|Summarize */
-    private $summarize;
-
-    /** @var null|Highlight */
-    private $highlight;
-
-    /** @var null|int */
-    private $slop;
-
-    /** @var bool */
-    private $inOrder = false;
-
-    /** @var null|string */
-    private $language;
-
-    /** @var null|string */
-    private $expander;
-
-    /** @var null|string */
-    private $scorer;
-
-    /** @var bool */
-    private $explainScore = false;
-
-    /** @var null|string */
-    private $extensionPayload;
-
-    /** @var null|string */
-    private $sortBy;
-
-    /** @var null|string */
-    private $sortDirection;
-
-    /** @var null|int */
-    private $resultOffset;
-
-    /** @var null|int */
-    private $resultLimit;
-
-    public function __construct(Client $redis)
-    {
-        $this->redis = $redis;
-        $this->reset();
-    }
-
-    public function reset(): Builder
-    {
-        $this->index = null;
-        $this->query = null;
-        $this->noContent = false;
-        $this->verbatim = false;
-        $this->noStopWords = false;
-        $this->withScores = false;
-        $this->withPayloads = false;
-        $this->withSortKeys = false;
-        $this->filters = [];
-        $this->geoFilter = null;
-        $this->inKeys = [];
-        $this->inFields = [];
-        $this->returns = [];
-        $this->summarize = null;
-        $this->highlight = null;
-        $this->slop = null;
-        $this->inOrder = false;
-        $this->language = null;
-        $this->expander = null;
-        $this->scorer = null;
-        $this->explainScore = false;
-        $this->extensionPayload = null;
-        $this->sortBy = null;
-        $this->sortDirection = null;
-        $this->resultOffset = null;
-        $this->resultLimit = null;
-
-        return $this;
-    }
-
-    public function withIndex(string $index): Search
-    {
-        $this->index = $index;
-
-        return $this;
-    }
-
-    public function withQuery(string $query): Search
-    {
-        $this->query = $query;
-
-        return $this;
-    }
-
-    public function withNoContent(bool $noContent = true): Search
-    {
-        $this->noContent = $noContent;
-
-        return $this;
-    }
-
-    public function withVerbatim(bool $verbatim = true): Search
-    {
-        $this->verbatim = $verbatim;
-
-        return $this;
-    }
-
-    public function withoutStopWords(bool $noStopWords = true): Search
-    {
-        $this->noStopWords = $noStopWords;
-
-        return $this;
-    }
-
-    public function withScores(bool $withScores = true): Search
-    {
-        $this->withScores = $withScores;
-
-        return $this;
-    }
-
-    public function withPayloads(bool $withPayloads = true): Search
-    {
-        $this->withPayloads = $withPayloads;
-
-        return $this;
-    }
-
-    public function withSortKeys(bool $withSortKeys = true): Search
-    {
-        $this->withSortKeys = $withSortKeys;
-
-        return $this;
-    }
-
-    public function addFilter(string $numericField, float $min, float $max): self
-    {
-        $this->filters[] = new Filter($numericField, $min, $max);
-
-        return $this;
-    }
-
-    public function withGeoFilter(string $fieldName, float $lon, float $lat, int $radius, string $unit): Search
-    {
-        $this->geoFilter = new GeoFilter($fieldName, $lon, $lat, $radius, $unit);
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $inKeys
-     *
-     * @throws Throwable If $inKeys is not an array of strings
-     *
-     * @return $this
-     */
-    public function withInKeys(array $inKeys): Search
-    {
-        DataHelper::assertArrayOf($inKeys, 'string');
-        $this->inKeys = $inKeys;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $inFields
-     *
-     * @throws Throwable If $inFields is not an array of strings
-     *
-     * @return $this
-     */
-    public function withInFields(array $inFields): Search
-    {
-        DataHelper::assertArrayOf($inFields, 'string');
-        $this->inFields = $inFields;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $returns
-     *
-     * @throws Throwable If $returns is not an array of strings
-     *
-     * @return $this
-     */
-    public function withReturns(array $returns): Search
-    {
-        DataHelper::assertArrayOf($returns, 'string');
-        $this->returns = $returns;
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $fields
-     * @param array<string> $fragments
-     *
-     * @return $this
-     */
-    public function withSummarize(array $fields = [], array $fragments = [], ?int $length = null, ?string $separator = null): Search
-    {
-        $this->summarize = new Summarize($fields, $fragments, $length, $separator);
-
-        return $this;
-    }
-
-    /**
-     * @param array<string> $fields
-     *
-     * @return $this
-     */
-    public function withHighlight(array $fields = [], ?string $openTag = null, ?string $closeTag = null): Search
-    {
-        $this->highlight = new Highlight($fields, $openTag, $closeTag);
-
-        return $this;
-    }
-
-    public function withSlop(int $slop = 0): Search
-    {
-        $this->slop = $slop;
-
-        return $this;
-    }
-
-    public function withInOrder(bool $inOrder = true): Search
-    {
-        $this->inOrder = $inOrder;
-
-        return $this;
-    }
-
-    /**
-     * @throws UnsupportedLanguageException
-     *
-     * @return $this
-     */
-    public function withLanguage(string $language): Search
-    {
-        DataHelper::assert(in_array($language, IndexBuilder::SUPPORTED_LANGUAGES, true), UnsupportedLanguageException::class);
-        $this->language = $language;
-
-        return $this;
-    }
-
-    public function withExpander(string $expander): Search
-    {
-        $this->expander = $expander;
-
-        return $this;
-    }
-
-    public function withScorer(string $scorer): Search
-    {
-        $this->scorer = $scorer;
-
-        return $this;
-    }
-
-    public function withExplainScore(bool $explainScore = true): Search
-    {
-        $this->explainScore = $explainScore;
-
-        return $this;
-    }
-
-    public function withExtensionPayload(string $extensionPayload): Search
-    {
-        $this->extensionPayload = $extensionPayload;
-
-        return $this;
-    }
-
-    public function sortBy(string $sortBy): Search
-    {
-        $this->sortBy = $sortBy;
-
-        return $this;
-    }
-
-    public function withSortDirection(string $sortDirection): Search
-    {
-        $this->sortDirection = $sortDirection;
-
-        return $this;
-    }
-
-    public function withResultOffset(int $resultOffset): Search
-    {
-        $this->resultOffset = $resultOffset;
-
-        return $this;
-    }
-
-    public function withResultLimit(int $resultLimit): Search
-    {
-        $this->resultLimit = $resultLimit;
-
-        return $this;
-    }
-
-    /**
-     * @return array<Result>
-     *
-     * @deprecated Use \MacFJA\RediSearch\Search::execute()
-     */
-    public function search(): array
-    {
-        return $this->execute()->getItems();
-    }
-
-    /**
-     * @return PaginatedResult<Result>
-     */
-    public function execute(): PaginatedResult
-    {
-        $request = $this->asPipelineItem();
-        $raw = $this->redis->executeCommand($request->getCommand());
-
-        return $request->transform($raw);
-    }
-
-    /**
-     * @return array<string>|string
-     */
-    public function explainQuery(bool $asArray = false)
-    {
-        return $this->redis->executeRaw([true === $asArray ? 'FT.EXPLAINCLI' : 'FT.EXPLAIN', $this->index, $this->query]);
-    }
-
-    public function asPipelineItem(): PipelineItem
-    {
-        try {
-            return PipelineItem::createFromRaw(
-                $this->buildQuery(),
-                static function ($rawResult, array $context): PaginatedResult {
-                    DataHelper::handleRawResult($rawResult);
-
-                    $totalCount = array_shift($rawResult);
-                    assert(is_int($totalCount));
-
-                    $chunkSize = 2 // Hash + fields
-                        + (($context['scores'] ?? false) ? 1 : 0)
-                        + (($context['payloads'] ?? false) ? 1 : 0)
-                        + (($context['sortKeys'] ?? false) ? 1 : 0);
-
-                    $documents = array_chunk($rawResult, $chunkSize);
-
-                    $items = array_map(function ($document) use ($context) {
-                        $hash = array_shift($document) ?? '';
-                        $score = ($context['scores'] ?? false) ? (float) array_shift($document) : null;
-                        $payload = ($context['payloads'] ?? false) ? array_shift($document) : null;
-                        $sortKey = ($context['sortKeys'] ?? false) ? array_shift($document) : null;
-
-                        $fields = [];
-                        if (false === $context['noContent']) {
-                            if (!(1 === count($document))) {
-                                throw new InvalidArgumentException();
-                            }
-                            $rawData = reset($document);
-                            assert(is_array($rawData));
-                            $fields = RedisHelper::getPairs($rawData);
-                        }
-
-                        return new Result($hash, $fields, $score, $payload, $sortKey);
-                    }, $documents);
-
-                    return new class($totalCount, $items, $context['offset'] ?? self::DEFAULT_OFFSET, $context['limit'] ?? self::DEFAULT_LIMIT) extends PaginatedResult {
-                    };
-                },
-                [
-                    'offset' => $this->resultOffset,
-                    'limit' => $this->resultLimit,
-                    'scores' => $this->withScores,
-                    'payloads' => $this->withPayloads,
-                    'sortKeys' => $this->withSortKeys,
-                    'noContent' => $this->noContent,
-                ]
-            );
-        } finally {
-            $this->reset();
-        }
-    }
-
-    /**
-     * @return array<float|int|string>
-     */
-    private function buildQuery(): array
-    {
-        assert(is_string($this->index));
-        assert(is_string($this->query));
-
-        $query = ['FT.SEARCH', $this->index, $this->query];
-        $query = RedisHelper::buildQueryBoolean($query, [
-            'NOCONTENT' => $this->noContent,
-            'VERBATIM' => $this->verbatim,
-            'NOSTOPWORDS' => $this->noStopWords,
-            'WITHSCORES' => $this->withScores,
-            'WITHPAYLOADS' => $this->withPayloads,
-            'WITHSORTKEYS' => $this->withSortKeys,
-            'EXPLAINSCORE' => $this->explainScore,
-            'INORDER' => $this->inOrder,
-        ]);
-        $query = RedisHelper::buildQueryList($query, [
-            'INKEYS' => $this->inKeys,
-            'INFIELDS' => $this->inFields,
-            'RETURN' => $this->returns,
-        ]);
-        $query = RedisHelper::buildQueryNotNull($query, [
-            'LANGUAGE' => $this->language,
-            'EXPANDER' => $this->expander,
-            'SCORER' => $this->scorer,
-            'PAYLOAD' => $this->extensionPayload,
-        ]);
-        $query = RedisHelper::buildQueryPartial($query,
-            array_merge($this->filters, [$this->geoFilter, $this->summarize, $this->highlight])
-        );
-        if (is_int($this->slop)) {
-            $query[] = 'SLOP';
-            $query[] = $this->slop;
-        }
-        if (is_string($this->sortBy)) {
-            $query[] = 'SORTBY';
-            $query[] = $this->sortBy;
-            if (is_string($this->sortDirection)) {
-                $query[] = $this->sortDirection;
-            }
-        }
-        if (is_int($this->resultOffset) && is_int($this->resultLimit)) {
-            $query[] = 'LIMIT';
-            $query[] = $this->resultOffset;
-            $query[] = $this->resultLimit;
-        }
-
-        return $query;
-    }
-}
diff --git a/src/Search/Exception/NotEnoughTermsException.php b/src/Search/Exception/NotEnoughTermsException.php
deleted file mode 100644
index 056218d..0000000
--- a/src/Search/Exception/NotEnoughTermsException.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search\Exception;
-
-class NotEnoughTermsException extends \MacFJA\RediSearch\Words\Exception\NotEnoughTermsException
-{
-}
diff --git a/src/Search/Exception/UnsupportedLanguageException.php b/src/Search/Exception/UnsupportedLanguageException.php
deleted file mode 100644
index 854fb89..0000000
--- a/src/Search/Exception/UnsupportedLanguageException.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search\Exception;
-
-class UnsupportedLanguageException extends \MacFJA\RediSearch\Index\Builder\Exception\UnsupportedLanguageException
-{
-}
diff --git a/src/Search/Highlight.php b/src/Search/Highlight.php
deleted file mode 100644
index fe499f0..0000000
--- a/src/Search/Highlight.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search;
-
-use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-
-class Highlight implements \MacFJA\RediSearch\PartialQuery
-{
-    /** @var array<string> */
-    private $fields = [];
-
-    /** @var null|string */
-    private $openTag;
-
-    /** @var null|string */
-    private $closeTag;
-
-    /**
-     * @param array<string> $fields
-     */
-    public function __construct(array $fields = [], ?string $openTag = null, ?string $closeTag = null)
-    {
-        DataHelper::assertArrayOf($fields, 'string');
-        $this->fields = $fields;
-        $this->openTag = $openTag;
-        $this->closeTag = $closeTag;
-    }
-
-    public function getQueryParts(): array
-    {
-        $query = ['HIGHLIGHT'];
-        $query = RedisHelper::buildQueryList($query, ['FIELDS' => $this->fields]);
-        if (is_string($this->openTag) && is_string($this->closeTag)) {
-            $query[] = 'TAGS';
-            $query[] = $this->openTag;
-            $query[] = $this->closeTag;
-        }
-
-        return $query;
-    }
-}
diff --git a/src/Search/QueryBuilder.php b/src/Search/QueryBuilder.php
deleted file mode 100644
index bb9b5c4..0000000
--- a/src/Search/QueryBuilder.php
+++ /dev/null
@@ -1,127 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search;
-
-use function array_map;
-use function implode;
-use MacFJA\RediSearch\Search\QueryBuilder\AndGroup;
-use MacFJA\RediSearch\Search\QueryBuilder\GroupPartialQuery;
-use MacFJA\RediSearch\Search\QueryBuilder\NumericFacet;
-use MacFJA\RediSearch\Search\QueryBuilder\OrGroup;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery;
-use MacFJA\RediSearch\Search\QueryBuilder\TagFacet;
-use MacFJA\RediSearch\Search\QueryBuilder\TextFacet;
-use MacFJA\RediSearch\Search\QueryBuilder\Word;
-use function strlen;
-
-class QueryBuilder
-{
-    /** @var GroupPartialQuery */
-    private $group;
-
-    /** @var array<QueryBuilder> */
-    private $queryBuilders = [];
-
-    private function __construct(bool $isOrGroup)
-    {
-        if (true === $isOrGroup) {
-            $this->group = new OrGroup();
-
-            return;
-        }
-        $this->group = new AndGroup();
-    }
-
-    public function addTagFacet(string $tagName, string ...$values): self
-    {
-        $this->group->addExpression(
-            new TagFacet($tagName, ...$values)
-        );
-
-        return $this;
-    }
-
-    public function addTextFacet(string $fieldName, string ...$values): self
-    {
-        $this->group->addExpression(
-            new TextFacet($fieldName, ...$values)
-        );
-
-        return $this;
-    }
-
-    public function addNumericFacet(string $fieldName, ?float $min, ?float $max): self
-    {
-        $this->group->addExpression(
-            new NumericFacet($fieldName, $min, $max)
-        );
-
-        return $this;
-    }
-
-    public function addString(string ...$string): self
-    {
-        $this->group->addExpression(new Word(...$string));
-
-        return $this;
-    }
-
-    public function addQueryBuilder(QueryBuilder $queryBuilder): self
-    {
-        $this->queryBuilders[] = $queryBuilder;
-
-        return $this;
-    }
-
-    public function addExpression(PartialQuery $expression): self
-    {
-        $this->group->addExpression($expression);
-
-        return $this;
-    }
-
-    public function render(): string
-    {
-        $otherQueries = implode(' ', array_map(
-            function (QueryBuilder $queryBuilder): string {
-                return $queryBuilder->render();
-            }, $this->queryBuilders
-        ));
-
-        return $this->group->render().(strlen($otherQueries) > 0 ? ' '.$otherQueries : '');
-    }
-
-    public static function createOrGroup(): self
-    {
-        return new self(true);
-    }
-
-    public static function createAndGroup(): self
-    {
-        return new self(false);
-    }
-
-    public static function create(): self
-    {
-        return new self(false);
-    }
-}
diff --git a/src/Search/QueryBuilder/GeoFacet.php b/src/Search/QueryBuilder/GeoFacet.php
deleted file mode 100644
index 7f4a716..0000000
--- a/src/Search/QueryBuilder/GeoFacet.php
+++ /dev/null
@@ -1,75 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search\QueryBuilder;
-
-use function in_array;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use MacFJA\RediSearch\Search\Exception\UnknownUnitException;
-use MacFJA\RediSearch\Search\GeoFilter;
-use function sprintf;
-
-class GeoFacet implements PartialQuery
-{
-    /** @var string */
-    private $fieldName;
-
-    /** @var float */
-    private $lon;
-
-    /** @var float */
-    private $lat;
-
-    /** @var int */
-    private $radius;
-
-    /** @var string */
-    private $unit;
-
-    public function __construct(string $fieldName, float $lon, float $lat, int $radius, string $unit)
-    {
-        DataHelper::assert(
-            in_array($unit, [GeoFilter::UNIT_FEET, GeoFilter::UNIT_KILOMETERS, GeoFilter::UNIT_METERS, GeoFilter::UNIT_MILES], true),
-            new UnknownUnitException($unit)
-        );
-        $this->fieldName = $fieldName;
-        $this->lon = $lon;
-        $this->lat = $lat;
-        $this->radius = $radius;
-        $this->unit = $unit;
-    }
-
-    public function render(): string
-    {
-        return sprintf('@%s:[%f %f %f %s]', EscapeHelper::escapeFieldName($this->fieldName), $this->lon, $this->lat, $this->radius, $this->unit);
-    }
-
-    public function includeSpace(): bool
-    {
-        return false;
-    }
-
-    public function priority(): int
-    {
-        return self::PRIORITY_NORMAL;
-    }
-}
diff --git a/src/Search/QueryBuilder/GroupPartialQuery.php b/src/Search/QueryBuilder/GroupPartialQuery.php
deleted file mode 100644
index a79c211..0000000
--- a/src/Search/QueryBuilder/GroupPartialQuery.php
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search\QueryBuilder;
-
-interface GroupPartialQuery extends PartialQuery
-{
-    public function addExpression(PartialQuery $expression): void;
-}
diff --git a/src/Search/QueryBuilder/TextFacet.php b/src/Search/QueryBuilder/TextFacet.php
deleted file mode 100644
index 71dc2cc..0000000
--- a/src/Search/QueryBuilder/TextFacet.php
+++ /dev/null
@@ -1,79 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search\QueryBuilder;
-
-use function array_map;
-use function count;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use MacFJA\RediSearch\Search\Exception\NotEnoughTermsException;
-use function reset;
-use function sprintf;
-use function strpos;
-
-class TextFacet implements PartialQuery
-{
-    private const WITH_SPACE_PATTERN = '@%s:(%s)';
-
-    private const WITHOUT_SPACE_PATTERN = '@%s:%s';
-
-    /** @var string */
-    private $field;
-
-    /** @var array<string> */
-    private $orValues;
-
-    public function __construct(string $field, string ...$orValues)
-    {
-        DataHelper::assert(count($orValues) > 0, NotEnoughTermsException::class);
-        $this->field = $field;
-        $this->orValues = $orValues;
-    }
-
-    public function render(): string
-    {
-        if (count($this->orValues) > 1) {
-            $terms = OrGroup::renderNoParentheses(...array_map(function (string $orValue) {
-                return new ExactMatch($orValue);
-            }, $this->orValues));
-
-            return sprintf(self::WITH_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), $terms);
-        }
-
-        $value = reset($this->orValues) ?: '';
-        if (false === strpos($value, ' ')) {
-            return sprintf(self::WITHOUT_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), EscapeHelper::escapeWord($value));
-        }
-
-        return sprintf(self::WITH_SPACE_PATTERN, EscapeHelper::escapeFieldName($this->field), EscapeHelper::escapeWord($value));
-    }
-
-    public function includeSpace(): bool
-    {
-        return false;
-    }
-
-    public function priority(): int
-    {
-        return self::PRIORITY_NORMAL;
-    }
-}
diff --git a/src/Search/Summarize.php b/src/Search/Summarize.php
deleted file mode 100644
index f323150..0000000
--- a/src/Search/Summarize.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch\Search;
-
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-
-class Summarize implements \MacFJA\RediSearch\PartialQuery
-{
-    /** @var array<string> */
-    private $fields = [];
-
-    /** @var array<string> */
-    private $fragments = [];
-
-    /** @var null|int */
-    private $length;
-
-    /** @var null|string */
-    private $separator;
-
-    /**
-     * @param array<string> $fields
-     * @param array<string> $fragments
-     */
-    public function __construct(array $fields = [], array $fragments = [], ?int $length = null, ?string $separator = null)
-    {
-        DataHelper::assertArrayOf($fields, 'string');
-        DataHelper::assertArrayOf($fragments, 'string');
-        $this->fields = $fields;
-        $this->fragments = $fragments;
-        $this->length = $length;
-        $this->separator = $separator;
-    }
-
-    public function getQueryParts(): array
-    {
-        $query = ['SUMMARIZE'];
-        $query = RedisHelper::buildQueryList($query, [
-            'FIELDS' => $this->fields,
-            'FRAGS' => $this->fragments,
-        ]);
-
-        return RedisHelper::buildQueryNotNull($query, [
-            'LEN' => $this->length,
-            'SEPARATOR' => $this->separator,
-        ]);
-    }
-}
diff --git a/src/Suggestions.php b/src/Suggestions.php
index 3c232b7..af2fa2d 100644
--- a/src/Suggestions.php
+++ b/src/Suggestions.php
@@ -21,106 +21,90 @@
 
 namespace MacFJA\RediSearch;
 
-use function array_chunk;
-use function array_map;
-use function array_shift;
-use function assert;
-use function count;
-use function in_array;
-use InvalidArgumentException;
+use function is_int;
 use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\PipelineItem;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Suggestion\Result;
-use Predis\Client;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\SugAdd;
+use MacFJA\RediSearch\Redis\Command\SugDel;
+use MacFJA\RediSearch\Redis\Command\SugGet;
+use MacFJA\RediSearch\Redis\Command\SugLen;
+use MacFJA\RediSearch\Redis\Initializer;
+use MacFJA\RediSearch\Redis\Response\SuggestionResponseItem;
 
 /**
- * @property-read int $length
+ * @codeCoverageIgnore
  */
 class Suggestions
 {
     /** @var string */
-    private $dictionaryKey;
+    private $dictionary;
+
+    /** @var Client */
+    private $client;
 
-    /** @var ?int */
+    /** @var int */
     private $length;
 
-    /** @var Client */
-    private $redis;
+    /** @var string */
+    private $version;
 
-    public function __construct(string $dictionaryKey, Client $redis)
+    public function __construct(string $dictionary, Client $client)
     {
-        $this->dictionaryKey = $dictionaryKey;
-        $this->redis = $redis;
+        $this->dictionary = $dictionary;
+        $this->client = $client;
+        $this->length = (int) $client->execute((new SugLen())->setDictionary($dictionary));
+        $this->version = Initializer::getRediSearchVersion($client) ?? AbstractCommand::MIN_IMPLEMENTED_VERSION;
     }
 
-    /**
-     * @return int|void
-     */
-    public function __get(string $name)
+    public function add(string $suggestion, float $score, bool $increment = false, ?string $payload = null): void
     {
-        if ('length' === $name) {
-            return $this->length ?? $this->length();
+        $command = new SugAdd($this->version);
+        $command
+            ->setDictionary($this->dictionary)
+            ->setScore($score)
+            ->setSuggestion($suggestion)
+            ->setIncrement($increment)
+        ;
+        if (is_string($payload)) {
+            $command->setPayload($payload);
         }
 
-        return;
-    }
-
-    public function __isset(string $name)
-    {
-        return in_array($name, ['length'], true);
-    }
+        $response = $this->client->execute($command);
 
-    public function add(string $suggestion, float $score, bool $increment = false, ?string $payload = null): void
-    {
-        $command = ['FT.SUGADD', $this->dictionaryKey, $suggestion, $score];
-        $command = RedisHelper::buildQueryBoolean($command, ['INCR' => $increment]);
-        $command = RedisHelper::buildQueryNotNull($command, ['PAYLOAD' => $payload]);
-        /** @phan-suppress-next-line PhanAccessReadOnlyMagicProperty */
-        $this->length = $this->redis->executeRaw($command);
+        if (is_int($response)) {
+            $this->length = $response;
+        }
     }
 
     /**
-     * @return array<Result>
+     * @return array<SuggestionResponseItem>
      */
     public function get(string $prefix, bool $fuzzy = false, bool $withScores = false, bool $withPayloads = false, ?int $max = null): array
     {
-        $request = $this->pipeableGet($prefix, $fuzzy, $withScores, $withPayloads, $max);
-        $result = $this->redis->executeCommand($request->getCommand());
+        $command = new SugGet($this->version);
+        $command->setDictionary($this->dictionary)
+            ->setWithPayloads($withPayloads)
+            ->setWithScores($withScores)
+            ->setPrefix($prefix)
+            ->setFuzzy($fuzzy)
+        ;
+        if (is_int($max)) {
+            $command->setMax($max);
+        }
 
-        return $request->transform($result);
-    }
-
-    public function pipeableGet(string $prefix, bool $fuzzy = false, bool $withScores = false, bool $withPayloads = false, ?int $max = null): PipelineItem
-    {
-        $command = ['FT.SUGGET', $this->dictionaryKey, $prefix];
-        $command = RedisHelper::buildQueryBoolean($command, [
-            'FUZZY' => $fuzzy,
-            'WITHSCORES' => $withScores,
-            'WITHPAYLOADS' => $withPayloads,
-        ]);
-        $command = RedisHelper::buildQueryNotNull($command, [
-            'MAX' => $max,
-        ]);
-
-        return PipelineItem::createFromRaw(
-            $command,
-            function ($rawResult, array $context) {
-                if (null === $rawResult) {
-                    return [];
-                }
-
-                return self::listFromRawRedis($rawResult, $context['payloads'], $context['scores']);
-            },
-            ['payloads' => $withPayloads, 'scores' => $withScores]
-        );
+        return $this->client->execute($command) ?? [];
     }
 
     public function delete(string $suggestion): bool
     {
-        $removed = (int) $this->redis->executeRaw(['FT.SUGDEL', $this->dictionaryKey, $suggestion]);
-        /** @phan-suppress-next-line PhanAccessReadOnlyMagicProperty */
+        $command = new SugDel($this->version);
+        $command
+            ->setDictionary($this->dictionary)
+            ->setSuggestion($suggestion)
+        ;
+        $removed = (int) $this->client->execute($command);
         $this->length -= $removed;
 
         return 1 === $removed;
@@ -128,57 +112,6 @@ public function delete(string $suggestion): bool
 
     public function length(): int
     {
-        /** @phan-suppress-next-line PhanAccessReadOnlyMagicProperty */
-        $this->length = (int) $this->redis->executeRaw(['FT.SUGLEN', $this->dictionaryKey]);
-
         return $this->length;
     }
-
-    /**
-     * @param array<float|string> $data
-     *
-     * @return array<Result>
-     */
-    private static function listFromRawRedis(array $data, bool $withPayload = true, bool $withScore = false): array
-    {
-        $size = 1;
-        if (true === $withPayload) {
-            $size++;
-        }
-        if (true === $withScore) {
-            $size++;
-        }
-        $suggestions = array_chunk($data, $size, false);
-
-        return array_map(function (array $grouped) use ($withPayload, $withScore) {
-            return self::fromRawRedis($grouped, $withPayload, $withScore);
-        }, $suggestions);
-    }
-
-    /**
-     * @param array<float|string> $data
-     */
-    private static function fromRawRedis(array $data, bool $withPayload = true, bool $withScore = false): Result
-    {
-        $expectedSize = 1 + (true === $withPayload ? 1 : 0) + (true === $withScore ? 1 : 0);
-        if (!(count($data) === $expectedSize)) {
-            throw new InvalidArgumentException();
-        }
-
-        $value = array_shift($data);
-        $score = null;
-        $payload = null;
-
-        if (true === $withScore) {
-            $score = (float) array_shift($data);
-        }
-        if (true === $withPayload) {
-            $payload = array_shift($data);
-        }
-
-        DataHelper::assert(is_string($payload) || null === $payload);
-        assert(is_string($payload) || null === $payload);
-
-        return new Result((string) $value, $score, $payload);
-    }
 }
diff --git a/src/Words.php b/src/Words.php
deleted file mode 100644
index 76f2004..0000000
--- a/src/Words.php
+++ /dev/null
@@ -1,206 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace MacFJA\RediSearch;
-
-use function array_chunk;
-use function array_column;
-use function array_combine;
-use function array_map;
-use function array_merge;
-use function assert;
-use function count;
-use function is_array;
-use function is_bool;
-use function is_string;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\PipelineItem;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Words\Exception\NotEnoughTermsException;
-use MacFJA\RediSearch\Words\SpellingResult;
-use MacFJA\RediSearch\Words\SynonymResult;
-use Predis\Client;
-use Throwable;
-
-class Words
-{
-    /** @var Client */
-    private $redis;
-
-    public function __construct(Client $redis)
-    {
-        $this->redis = $redis;
-    }
-
-    /**
-     * @param array<string> $synonyms
-     */
-    public function addSynonym(string $index, string $for, array $synonyms, bool $skipInitialScan = false): bool
-    {
-        DataHelper::assert(count($synonyms) > 0);
-        DataHelper::assertArrayOf($synonyms, 'string');
-        $query = ['FT.SYNUPDATE', $index, $for];
-        $query = RedisHelper::buildQueryBoolean($query, ['SKIPINITIALSCAN' => $skipInitialScan]);
-        $query = array_merge($query, $synonyms);
-
-        return 'OK' === $this->redis->executeRaw($query);
-    }
-
-    /**
-     * @return array<SynonymResult>
-     */
-    public function dumpSynonyms(string $index): array
-    {
-        $result = $this->redis->executeRaw(['FT.SYNDUMP', $index]);
-        DataHelper::handleRawResult($result);
-
-        return array_map(function (array $item): SynonymResult {
-            return new SynonymResult($item[0], $item[1]);
-        }, array_chunk($result, 2));
-    }
-
-    /**
-     * @throws Throwable
-     *
-     * @return array<SpellingResult>
-     */
-    public function getSpellCheck(string $index, string $query, ?int $distance = null): array
-    {
-        return $this->internalSpellCheck($index, $query, $distance);
-    }
-
-    public function getPipeableSpellCheck(string $index, string $query, ?int $distance = null): PipelineItem
-    {
-        $redisQuery = ['FT.SPELLCHECK', $index, $query];
-        $redisQuery = RedisHelper::buildQueryNotNull($redisQuery, ['DISTANCE' => $distance]);
-
-        return PipelineItem::createFromRaw(
-            $redisQuery,
-            function ($result) {
-                return array_map(function (array $term): SpellingResult {
-                    $searched = $term[1];
-                    $suggestions = array_combine(
-                        array_column($term[2], 1),
-                        array_column($term[2], 0)
-                    );
-                    assert(is_array($suggestions));
-
-                    return new SpellingResult($searched, $suggestions);
-                }, $result);
-            }
-        );
-    }
-
-    /**
-     * @param array<string> $terms
-     *
-     * @throws Throwable
-     *
-     * @return array<SpellingResult>
-     */
-    public function getSpellCheckInclude(string $index, string $query, string $dictionary, array $terms = [], ?int $distance = null): array
-    {
-        DataHelper::assertArrayOf($terms, 'string');
-
-        return $this->internalSpellCheck($index, $query, $distance, true, $dictionary, $terms);
-    }
-
-    /**
-     * @param array<string> $terms
-     *
-     * @throws Throwable
-     *
-     * @return array<SpellingResult>
-     */
-    public function getSpellCheckExclude(string $index, string $query, string $dictionary, array $terms = [], ?int $distance = null): array
-    {
-        DataHelper::assertArrayOf($terms, 'string');
-
-        return $this->internalSpellCheck($index, $query, $distance, false, $dictionary, $terms);
-    }
-
-    /**
-     * @param array<string> $terms
-     *
-     * @throws NotEnoughTermsException
-     */
-    public function addToDictionary(string $dictionary, array $terms): int
-    {
-        DataHelper::assert(count($terms) < 1, NotEnoughTermsException::class);
-        $query = array_merge(['FT.DICTADD', $dictionary], $terms);
-
-        return $this->redis->executeRaw($query);
-    }
-
-    /**
-     * @param array<string> $terms
-     *
-     * @throws NotEnoughTermsException
-     */
-    public function removeFromDictionary(string $dictionary, array $terms): int
-    {
-        DataHelper::assert(count($terms) < 1, NotEnoughTermsException::class);
-        DataHelper::assertArrayOf($terms, 'string');
-        $query = array_merge(['FT.DICTDEL', $dictionary], $terms);
-
-        return $this->redis->executeRaw($query);
-    }
-
-    /**
-     * @return array<string>
-     */
-    public function dumpDictionary(string $dictionary): array
-    {
-        return $this->redis->executeRaw(['FT.DICTDUMP', $dictionary]);
-    }
-
-    /**
-     * @param array<string> $terms
-     *
-     * @throws Throwable
-     *
-     * @return array<SpellingResult>
-     */
-    private function internalSpellCheck(string $index, string $query, ?int $distance = null, ?bool $withInclude = null, ?string $dictionary = null, array $terms = []): array
-    {
-        $redisQuery = ['FT.SPELLCHECK', $index, $query];
-        $redisQuery = RedisHelper::buildQueryNotNull($redisQuery, ['DISTANCE' => $distance]);
-        if (is_bool($withInclude) && is_string($dictionary)) {
-            $redisQuery[] = 'TERMS';
-            $redisQuery[] = true === $withInclude ? 'INCLUDE' : 'EXCLUDE';
-            $redisQuery[] = $dictionary;
-            $redisQuery = array_merge($redisQuery, $terms);
-        }
-
-        $result = $this->redis->executeRaw($redisQuery);
-
-        return array_map(function (array $term): SpellingResult {
-            $searched = $term[1];
-            $suggestions = array_combine(
-                array_column($term[2], 1),
-                array_column($term[2], 0)
-            );
-            assert(is_array($suggestions));
-
-            return new SpellingResult($searched, $suggestions);
-        }, $result);
-    }
-}
diff --git a/tests/Aggregate/GroupByTest.php b/tests/Aggregate/GroupByTest.php
deleted file mode 100644
index 57ec052..0000000
--- a/tests/Aggregate/GroupByTest.php
+++ /dev/null
@@ -1,92 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Aggregate;
-
-use MacFJA\RediSearch\Aggregate\GroupBy;
-use MacFJA\RediSearch\Aggregate\Reducer;
-use PHPUnit\Framework\TestCase;
-use RuntimeException;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Aggregate\GroupBy
- * @covers ::__construct
- * @covers ::getQueryParts
- *
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
- * @uses \MacFJA\RediSearch\Aggregate\Reducer
- *
- * @see https://oss.redislabs.com/redisearch/Aggregations/#aggregate_request_format
- */
-class GroupByTest extends TestCase
-{
-    use Assertion;
-
-    public function testNominal(): void
-    {
-        self::assertSameQuery(
-            'GROUPBY 1 @name REDUCE COUNT 0',
-            new GroupBy(['name'], [Reducer::count()])
-        );
-        self::assertSameQuery(
-            'GROUPBY 2 @name @age REDUCE COUNT 0 AS _count REDUCE AVG 1 @age AS _average',
-            new GroupBy(['name', 'age'], [Reducer::count('_count'), Reducer::average('age', '_average')])
-        );
-    }
-
-    public function testNoProperties(): void
-    {
-        self::assertSameQuery(
-            'GROUPBY 0 REDUCE COUNT 0',
-            new GroupBy([], [Reducer::count()])
-        );
-        self::assertSameQuery(
-            'GROUPBY 0 REDUCE COUNT 0 AS _count REDUCE AVG 1 @age AS _average',
-            new GroupBy([], [Reducer::count('_count'), Reducer::average('age', '_average')])
-        );
-    }
-
-    public function testNoReducer(): void
-    {
-        self::assertSameQuery(
-            'GROUPBY 1 @name',
-            new GroupBy(['name'], [])
-        );
-        self::assertSameQuery(
-            'GROUPBY 2 @name @age',
-            new GroupBy(['name', 'age'], [])
-        );
-    }
-
-    public function testInvalidPropertiesDataType(): void
-    {
-        $this->expectException(RuntimeException::class);
-        new GroupBy([1, false], []);
-    }
-
-    public function testInvalidReducerDataType(): void
-    {
-        $this->expectException(RuntimeException::class);
-        new GroupBy([], ['foo', 1, false]);
-    }
-}
diff --git a/tests/Aggregate/ReducerTest.php b/tests/Aggregate/ReducerTest.php
deleted file mode 100644
index 1f7c074..0000000
--- a/tests/Aggregate/ReducerTest.php
+++ /dev/null
@@ -1,155 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Aggregate;
-
-use MacFJA\RediSearch\Aggregate\Reducer;
-use PHPUnit\Framework\TestCase;
-use RuntimeException;
-use stdClass;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Aggregate\Reducer
- * @covers ::__construct
- * @covers ::getQueryParts
- *
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- *
- * @see https://oss.redislabs.com/redisearch/Aggregations/#supported_groupby_reducers
- */
-class ReducerTest extends TestCase
-{
-    use Assertion;
-
-    public function testInvalidArgumentsDataType(): void
-    {
-        $this->expectException(RuntimeException::class);
-        new Reducer('foo', [new stdClass()]);
-    }
-
-    /**
-     * @covers ::count
-     */
-    public function testCount(): void
-    {
-        self::assertSameQuery('REDUCE COUNT 0', Reducer::count());
-        self::assertSameQuery('REDUCE COUNT 0 AS countReducer', Reducer::count('countReducer'));
-    }
-
-    /**
-     * @covers ::countDistinct
-     */
-    public function testCountDistinct(): void
-    {
-        self::assertSameQuery('REDUCE COUNT_DISTINCT 1 @foobar', Reducer::countDistinct('foobar'));
-        self::assertSameQuery('REDUCE COUNT_DISTINCT 1 @foobar AS countReducer', Reducer::countDistinct('foobar', 'countReducer'));
-    }
-
-    /**
-     * @covers ::countDistinctish
-     */
-    public function testCountDistinctish(): void
-    {
-        self::assertSameQuery('REDUCE COUNT_DISTINCTISH 1 @foobar', Reducer::countDistinctish('foobar'));
-        self::assertSameQuery('REDUCE COUNT_DISTINCTISH 1 @foobar AS countReducer', Reducer::countDistinctish('foobar', 'countReducer'));
-    }
-
-    /**
-     * @covers ::toList
-     */
-    public function testToList(): void
-    {
-        self::assertSameQuery('REDUCE TOLIST 1 @foobar', Reducer::toList('foobar'));
-        self::assertSameQuery('REDUCE TOLIST 1 @foobar AS list', Reducer::toList('foobar', 'list'));
-    }
-
-    /**
-     * @covers ::median
-     * @covers ::quantile
-     */
-    public function testQuantile(): void
-    {
-        self::assertSameQuery('REDUCE QUANTILE 2 @foobar 0.25', Reducer::quantile('foobar', 0.25));
-        self::assertSameQuery('REDUCE QUANTILE 2 @foobar 0.5 AS median', Reducer::quantile('foobar', 0.5, 'median'));
-        self::assertSameQuery('REDUCE QUANTILE 2 @foobar 0.5 AS median', Reducer::median('foobar', 'median'));
-    }
-
-    /**
-     * @covers ::average
-     */
-    public function testAverage(): void
-    {
-        self::assertSameQuery('REDUCE AVG 1 @foobar', Reducer::average('foobar'));
-        self::assertSameQuery('REDUCE AVG 1 @foobar AS average', Reducer::average('foobar', 'average'));
-    }
-
-    /**
-     * @covers ::maximum
-     */
-    public function testMaximum(): void
-    {
-        self::assertSameQuery('REDUCE MAX 1 @foobar', Reducer::maximum('foobar'));
-        self::assertSameQuery('REDUCE MAX 1 @foobar AS maximum', Reducer::maximum('foobar', 'maximum'));
-    }
-
-    /**
-     * @covers ::minimum
-     */
-    public function testMinimum(): void
-    {
-        self::assertSameQuery('REDUCE MIN 1 @foobar', Reducer::minimum('foobar'));
-        self::assertSameQuery('REDUCE MIN 1 @foobar AS minimum', Reducer::minimum('foobar', 'minimum'));
-    }
-
-    /**
-     * @covers ::sum
-     */
-    public function testSum(): void
-    {
-        self::assertSameQuery('REDUCE SUM 1 @foobar', Reducer::sum('foobar'));
-        self::assertSameQuery('REDUCE SUM 1 @foobar AS sum', Reducer::sum('foobar', 'sum'));
-    }
-
-    /**
-     * @covers ::firstValue
-     */
-    public function testFirstValue(): void
-    {
-        self::assertSameQuery('REDUCE FIRST_VALUE 1 @foobar', Reducer::firstValue('foobar'));
-        self::assertSameQuery('REDUCE FIRST_VALUE 1 @foobar AS first', Reducer::firstValue('foobar', null, null, 'first'));
-        self::assertSameQuery('REDUCE FIRST_VALUE 1 @foobar AS first', Reducer::firstValue('foobar', null, true, 'first'));
-        self::assertSameQuery('REDUCE FIRST_VALUE 3 @foo BY @bar', Reducer::firstValue('foo', 'bar'));
-        self::assertSameQuery('REDUCE FIRST_VALUE 4 @foo BY @bar ASC', Reducer::firstValue('foo', 'bar', true));
-        self::assertSameQuery('REDUCE FIRST_VALUE 4 @foo BY @bar DESC', Reducer::firstValue('foo', 'bar', false));
-        self::assertSameQuery('REDUCE FIRST_VALUE 4 @foo BY @bar DESC AS first', Reducer::firstValue('foo', 'bar', false, 'first'));
-        self::assertSameQuery('REDUCE FIRST_VALUE 3 @foo BY @bar AS first', Reducer::firstValue('foo', 'bar', null, 'first'));
-    }
-
-    /**
-     * @covers ::standardDeviation
-     */
-    public function testStandardDeviation(): void
-    {
-        self::assertSameQuery('REDUCE STDDEV 1 @foobar', Reducer::standardDeviation('foobar'));
-        self::assertSameQuery('REDUCE STDDEV 1 @foobar AS deviation', Reducer::standardDeviation('foobar', 'deviation'));
-    }
-}
diff --git a/tests/Aggregate/SortByTest.php b/tests/Aggregate/SortByTest.php
deleted file mode 100644
index b8a618c..0000000
--- a/tests/Aggregate/SortByTest.php
+++ /dev/null
@@ -1,86 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Aggregate;
-
-use MacFJA\RediSearch\Aggregate\Exception\UnknownSortDirectionException;
-use MacFJA\RediSearch\Aggregate\SortBy;
-use OutOfRangeException;
-use PHPUnit\Framework\TestCase;
-use RuntimeException;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Aggregate\SortBy
- * @covers ::__construct
- * @covers ::getQueryParts
- * @covers \MacFJA\RediSearch\Aggregate\Exception\UnknownSortDirectionException
- *
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
- *
- * @see https://oss.redislabs.com/redisearch/Aggregations/#aggregate_request_format
- */
-class SortByTest extends TestCase
-{
-    use Assertion;
-
-    public function testNominal(): void
-    {
-        self::assertSameQuery(
-            'SORTBY 4 @name ASC @age DESC MAX 20',
-            new SortBy(['name' => SortBy::SORT_ASC, 'age' => SortBy::SORT_DESC], 20)
-        );
-        self::assertSameQuery(
-            'SORTBY 4 @name ASC @age DESC MAX 20',
-            new SortBy(['name' => SortBy::SORT_ASC, '@age' => SortBy::SORT_DESC], 20)
-        );
-        self::assertSameQuery(
-            'SORTBY 2 @name ASC', new SortBy(['name' => SortBy::SORT_ASC]));
-    }
-
-    public function testNoProperties(): void
-    {
-        self::assertSameQuery('SORTBY 0', new SortBy([]));
-        self::assertSameQuery('SORTBY 0 MAX 2', new SortBy([], 2));
-        self::assertSameQuery('SORTBY 0 MAX 0', new SortBy([], 0));
-    }
-
-    public function testInvalidMax(): void
-    {
-        $this->expectException(OutOfRangeException::class);
-        $this->expectExceptionMessage('MAX such by greater or equals to 0');
-        new SortBy([], -1);
-    }
-
-    public function testInvalidSort(): void
-    {
-        $this->expectException(UnknownSortDirectionException::class);
-        $this->expectExceptionMessage('Sort By direction can only be "ASC" or "DESC". Provided: UP, DOWN.');
-        new SortBy(['foo' => 'UP', 'bar' => 'DOWN']);
-    }
-
-    public function testInvalidSortProperties(): void
-    {
-        $this->expectException(RuntimeException::class);
-        new SortBy([1 => 'ASC', false => 'DESC']);
-    }
-}
diff --git a/tests/Helper/DataHelperTest.php b/tests/Helper/DataHelperTest.php
deleted file mode 100644
index 08e9826..0000000
--- a/tests/Helper/DataHelperTest.php
+++ /dev/null
@@ -1,112 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Helper;
-
-use InvalidArgumentException;
-use MacFJA\RediSearch\Helper\DataHelper;
-use PHPUnit\Framework\TestCase;
-use RuntimeException;
-use stdClass;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Helper\DataHelper
- */
-class DataHelperTest extends TestCase
-{
-    /**
-     * @covers ::assert
-     */
-    public function testAssertTrue(): void
-    {
-        DataHelper::assert(true);
-        $this->expectNotToPerformAssertions();
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertFalse(): void
-    {
-        $this->expectException(RuntimeException::class);
-        $this->expectExceptionMessage('The assertion of Tests\MacFJA\RediSearch\Helper\DataHelperTest::testAssertFalse at line ');
-
-        DataHelper::assert(false);
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertFalseCustomException1(): void
-    {
-        $this->expectException(InvalidArgumentException::class);
-
-        DataHelper::assert(false, InvalidArgumentException::class);
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertFalseCustomException2(): void
-    {
-        $exception = new InvalidArgumentException('not valid');
-        $this->expectException(InvalidArgumentException::class);
-        $this->expectExceptionMessage('not valid');
-
-        DataHelper::assert(false, $exception);
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertFalseWrongCustomException(): void
-    {
-        $this->expectException(RuntimeException::class);
-
-        DataHelper::assert(false, stdClass::class);
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertEmbed1(): void
-    {
-        $runner = function () {
-            $this->expectException(RuntimeException::class);
-            $this->expectExceptionMessage('The assertion of Tests\MacFJA\RediSearch\Helper\DataHelperTest::Tests\MacFJA\RediSearch\Helper\{closure} at line ');
-            DataHelper::assert(false);
-        };
-        $runner();
-    }
-
-    /**
-     * @covers ::assert
-     */
-    public function testAssertEmbed2(): void
-    {
-        $runner = function () {
-            $this->expectException(RuntimeException::class);
-            $this->expectExceptionMessage('The assertion of Tests\MacFJA\RediSearch\Helper\DataHelperTest::testAssertEmbed2 at line ');
-            DataHelper::assert(false, null, 2);
-        };
-        $runner();
-    }
-}
diff --git a/tests/Helper/EscaperHelperTest.php b/tests/Helper/EscaperHelperTest.php
deleted file mode 100644
index 8637937..0000000
--- a/tests/Helper/EscaperHelperTest.php
+++ /dev/null
@@ -1,175 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Helper;
-
-use function array_combine;
-use function array_map;
-use function fclose;
-use function fgetcsv;
-use function fopen;
-use MacFJA\RediSearch\Helper\EscapeHelper;
-use PHPUnit\Framework\TestCase;
-
-class EscaperHelperTest extends TestCase
-{
-    private $fixtures;
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeFieldName
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCharAtWordBegin()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     * @dataProvider dataProvider
-     */
-    public function testEscapeFieldName(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeFieldName($input));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeWord
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     * @dataProvider dataProvider
-     */
-    public function testEscapeWord(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeWord($input));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeFuzzy
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     *
-     * @dataProvider dataProvider
-     */
-    public function testEscapeFuzzy(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeFuzzy($input));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeExactMatch
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     *
-     * @dataProvider dataProvider
-     */
-    public function testEscapeExactMatch(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeExactMatch($input));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeNegation
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCharAtWordBegin()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     *
-     * @dataProvider dataProvider
-     */
-    public function testEscapeNegation(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeNegation($input));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Helper\EscapeHelper::escapeOptional
-     *
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeChar()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::escapeCommon()
-     * @uses \MacFJA\RediSearch\Helper\EscapeHelper::unescapeNumber()
-     *
-     * @dataProvider dataProvider
-     */
-    public function testEscapeOptional(string $input, string $output): void
-    {
-        if (empty($output)) {
-            self::markTestSkipped('not transformation for '.$input);
-        }
-        self::assertSame($output, EscapeHelper::escapeOptional($input));
-    }
-
-    public function dataProvider(string $testName): array
-    {
-        switch ($testName) {
-            case 'testEscapeFieldName':
-                return $this->getFixture('field name');
-            case 'testEscapeWord':
-                return $this->getFixture('word');
-            case 'testEscapeFuzzy':
-                return $this->getFixture('fuzzy');
-            case 'testEscapeExactMatch':
-                return $this->getFixture('exact match');
-            case 'testEscapeNegation':
-                return $this->getFixture('negation');
-            case 'testEscapeOptional':
-                return $this->getFixture('optional');
-        }
-
-        return [];
-    }
-
-    private function getFixture(string $columnName): array
-    {
-        if (null === $this->fixtures) {
-            $handle = fopen(__DIR__.'/../fixtures/Helper/EscaperHelperFixture.csv', 'rb');
-            $header = fgetcsv($handle);
-            fgetcsv($handle); // Description
-            $this->fixtures = [];
-            while (($row = fgetcsv($handle, 0, ',', '"', '"')) !== false) {
-                $this->fixtures[] = array_combine($header, $row);
-            }
-            fclose($handle);
-        }
-
-        return array_map(function (array $row) use ($columnName) {
-            return [$row['input'], $row[$columnName]];
-        }, $this->fixtures);
-    }
-}
diff --git a/tests/Helper/RedisHelperTest.php b/tests/Helper/RedisHelperTest.php
deleted file mode 100644
index 77e7163..0000000
--- a/tests/Helper/RedisHelperTest.php
+++ /dev/null
@@ -1,92 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Helper;
-
-use MacFJA\RediSearch\Helper\RedisHelper;
-use PHPUnit\Framework\TestCase;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Helper\RedisHelper
- */
-class RedisHelperTest extends TestCase
-{
-    /**
-     * @covers ::buildQueryBoolean
-     */
-    public function testBuildQueryBoolean(): void
-    {
-        self::assertSame([], RedisHelper::buildQueryBoolean([], []));
-        self::assertSame([], RedisHelper::buildQueryBoolean([], ['foobar' => false]));
-        self::assertSame(['foobar'], RedisHelper::buildQueryBoolean([], ['foobar' => true]));
-        self::assertSame(['foobar', 'foo'], RedisHelper::buildQueryBoolean([], ['foobar' => true, 'foo' => true, 'bar' => false]));
-        self::assertSame(['foo', 'bar'], RedisHelper::buildQueryBoolean(['foo'], ['bar' => true]));
-    }
-
-    /**
-     * @covers ::buildQueryList
-     */
-    public function testBuildQueryList(): void
-    {
-        self::assertSame([], RedisHelper::buildQueryList([], []));
-        self::assertSame([], RedisHelper::buildQueryList([], ['foobar' => []]));
-        self::assertSame(['foobar', 2, 'foo', 'bar'], RedisHelper::buildQueryList([], ['foobar' => ['foo', 'bar']]));
-        self::assertSame(['foobar', 0], RedisHelper::buildQueryList([], ['foobar' => []], true));
-        self::assertSame(['foo', 2, 'foo', 'bar', 'bar', 1, 'foobar'], RedisHelper::buildQueryList([], ['foo' => ['foo', 'bar'], 'bar' => ['foobar']]));
-        self::assertSame(['foobar', 2, 2, 'bar'], RedisHelper::buildQueryList([], ['foobar' => [2, 'bar']]));
-        self::assertSame(['foo', 'foobar', 2, 2, 'bar'], RedisHelper::buildQueryList(['foo'], ['foobar' => [2, 'bar']]));
-    }
-
-    /**
-     * @covers ::buildQueryNotNull
-     */
-    public function testBuildQueryNotNull(): void
-    {
-        self::assertSame([], RedisHelper::buildQueryNotNull([], []));
-        self::assertSame([], RedisHelper::buildQueryNotNull([], ['foobar' => null]));
-        self::assertSame(['foobar', 2], RedisHelper::buildQueryNotNull([], ['foobar' => 2]));
-        self::assertSame(['foo', 'bar'], RedisHelper::buildQueryNotNull([], ['foo' => 'bar']));
-        self::assertSame(['foo', 'bar', 'hello', 'world'], RedisHelper::buildQueryNotNull([], ['foo' => 'bar', 'hello' => 'world']));
-        self::assertSame(['foobar', 'foo', 'bar'], RedisHelper::buildQueryNotNull(['foobar'], ['foo' => 'bar']));
-    }
-
-    /**
-     * @covers ::getPairs
-     */
-    public function testGetPairs(): void
-    {
-        self::assertSame(['key1' => 'value1', 'key2' => 'value2'], RedisHelper::getPairs(['key1', 'value1', 'key2', 'value2']));
-        self::assertSame(['key1' => 'value1'], RedisHelper::getPairs(['key1', 'value1', 'key2']));
-    }
-
-    /**
-     * @covers ::getValue
-     */
-    public function testGetValue(): void
-    {
-        self::assertSame('value1', RedisHelper::getValue(['key1', 'value1', 'key2', 'value2'], 'key1'));
-        self::assertSame('value2', RedisHelper::getValue(['key1', 'value1', 'key2', 'value2'], 'key2'));
-        self::assertNull(RedisHelper::getValue(['key1', 'value1', 'key2', 'value2'], 'key3'));
-        self::assertSame('value1', RedisHelper::getValue(['key1', 'value1', 'key2'], 'key1'));
-        self::assertNull(RedisHelper::getValue(['key1', 'value1', 'key2'], 'key2'));
-        self::assertSame('value', RedisHelper::getValue(['foo', 'bar', 'foobar', 'key', 'value'], 'key'));
-    }
-}
diff --git a/tests/Index/Builder/TagFieldTest.php b/tests/Index/Builder/TagFieldTest.php
deleted file mode 100644
index c81e231..0000000
--- a/tests/Index/Builder/TagFieldTest.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Index\Builder;
-
-use MacFJA\RediSearch\Index\Builder\Exception\MustBeSingleCharException;
-use MacFJA\RediSearch\Index\Builder\TagField;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @covers \MacFJA\RediSearch\Index\Builder\TagField
- *
- * @uses \MacFJA\RediSearch\Index\Builder\AbstractField
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- */
-class TagFieldTest extends TestCase
-{
-    use Assertion;
-
-    public function testNominal(): void
-    {
-        self::assertSameQuery(
-            'genres TAG SORTABLE',
-            new TagField('genres', null, true)
-        );
-        self::assertSameQuery(
-            'keywords TAG SEPARATOR +',
-            new TagField('keywords', '+')
-        );
-        self::assertSameQuery(
-            'ean TAG SEPARATOR % NOINDEX',
-            new TagField('ean', '%', false, true)
-        );
-    }
-
-    public function testGetType(): void
-    {
-        self::assertSame('TAG', (new TagField('foobar'))->getType());
-    }
-
-    public function testWrongSeparator(): void
-    {
-        $this->expectException(MustBeSingleCharException::class);
-        $this->expectExceptionMessage('Separator must be a single char');
-
-        new TagField('foobar', '||');
-    }
-
-    public function testGetSeparator(): void
-    {
-        $tag = new TagField('foobar', '|');
-        self::assertSame('|', $tag->getSeparator());
-
-        $tag = new TagField('foobar');
-        self::assertNull($tag->getSeparator());
-    }
-}
diff --git a/tests/Index/Builder/TextFieldTest.php b/tests/Index/Builder/TextFieldTest.php
deleted file mode 100644
index 31a502f..0000000
--- a/tests/Index/Builder/TextFieldTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Index\Builder;
-
-use MacFJA\RediSearch\Index\Builder\TextField;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @covers \MacFJA\RediSearch\Index\Builder\TextField
- *
- * @uses \MacFJA\RediSearch\Index\Builder\AbstractField
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
- */
-class TextFieldTest extends TestCase
-{
-    use Assertion;
-
-    public function testNominal(): void
-    {
-        self::assertSameQuery(
-            'title TEXT WEIGHT 2 PHONETIC dm:en SORTABLE',
-            new TextField('title', false, 2, 'dm:en', true, false)
-        );
-        self::assertSameQuery(
-            'name TEXT NOSTEM NOINDEX',
-            new TextField('name', true, null, null, false, true)
-        );
-        self::assertSameQuery(
-            'code TEXT NOSTEM WEIGHT 4.5',
-            new TextField('code', true, 4.5)
-        );
-    }
-
-    public function testGetType(): void
-    {
-        self::assertSame('TEXT', (new TextField('foobar'))->getType());
-    }
-
-    public function testIsNoStem(): void
-    {
-        $text = new TextField('foobar', false);
-        self::assertFalse($text->isNoStem());
-
-        $text = new TextField('foobar');
-        self::assertFalse($text->isNoStem());
-
-        $text = new TextField('foobar', true);
-        self::assertTrue($text->isNoStem());
-    }
-
-    public function testGetWeight(): void
-    {
-        $text = new TextField('foobar', false, 1);
-        self::assertSame(1.0, $text->getWeight());
-
-        $text = new TextField('foobar', false, null);
-        self::assertNull($text->getWeight());
-
-        $text = new TextField('foobar');
-        self::assertNull($text->getWeight());
-    }
-
-    public function testGetPhonetic(): void
-    {
-        $text = new TextField('foobar', false, null, 'dm:en');
-        self::assertSame('dm:en', $text->getPhonetic());
-
-        $text = new TextField('foobar', false, null, null);
-        self::assertNull($text->getPhonetic());
-
-        $text = new TextField('foobar');
-        self::assertNull($text->getPhonetic());
-    }
-}
diff --git a/tests/IndexBuilderTest.php b/tests/IndexBuilderTest.php
new file mode 100644
index 0000000..11adf69
--- /dev/null
+++ b/tests/IndexBuilderTest.php
@@ -0,0 +1,405 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use MacFJA\RediSearch\IndexBuilder;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\Create;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+/**
+ * @covers \MacFJA\RediSearch\IndexBuilder
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Create
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\OptionListOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\BaseCreateFieldOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait
+ *
+ * @group default
+ *
+ * @internal
+ */
+class IndexBuilderTest extends TestCase
+{
+    /**
+     * @return array<Create|IndexBuilder>
+     *
+     * @medium
+     */
+    public function testAllSetter(): array
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        static::assertNotEquals($createCommand, $builder->getCommand());
+        $createCommand->setIndex('city');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $resultInstance = $builder->setPrefixes(['city-', 'c-']);
+        $createCommand->setPrefixes('city-', 'c-');
+        static::assertSame($builder, $resultInstance);
+
+        $builder->setStopWords(['hello', 'world']);
+        $createCommand->setStopWords('hello', 'world');
+
+        $builder->setSkipInitialScan(true);
+        $createCommand->setSkipInitialScan(true);
+
+        $builder->setNoFrequencies(true);
+        $createCommand->setNoFrequencies(true);
+
+        $builder->setNoFields(true);
+        $createCommand->setNoFields(true);
+
+        $builder->setNoHighLight(true);
+        $createCommand->setNoHighLight(true);
+
+        $builder->setTemporary(20);
+        $createCommand->setTemporary(20);
+
+        $builder->setNoOffsets(true);
+        $createCommand->setNoOffsets(true);
+
+        $builder->setDefaultLanguage('en');
+        $createCommand->setDefaultLanguage('en');
+
+        $builder->setDefaultScore(1.3);
+        $createCommand->setDefaultScore(1.3);
+
+        $builder->setMaxTextFields(true);
+        $createCommand->setMaxTextFields(true);
+
+        $builder->setPayloadField('payload');
+        $createCommand->setPayloadField('payload');
+
+        $builder->setScoreField('score');
+        $createCommand->setScoreField('score');
+
+        $builder->setLanguageField('lang');
+        $createCommand->setLanguageField('lang');
+
+        $builder->setFilter('startswith(@name, "G")');
+        $createCommand->setFilter('startswith(@name, "G")');
+
+        $builder->setStructure('JSON');
+        $createCommand->setStructure('JSON');
+
+        $builder->setFields([(new TextFieldOption())->setField('name')]);
+        $createCommand->addTextField('name');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        return [$builder, $createCommand];
+    }
+
+    /**
+     * @medium
+     */
+    public function testAllWith(): void
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        $createCommand->setIndex('city');
+
+        $oldBuilder = $builder;
+        $builder = $builder->withPrefixes(['city-', 'c-']);
+        static::assertNotEquals($createCommand, $builder->getCommand());
+        $createCommand->setPrefixes('city-', 'c-');
+        static::assertEquals($createCommand, $builder->getCommand());
+        static::assertNotSame($oldBuilder, $builder);
+
+        $builder = $builder->withStopWords(['hello', 'world']);
+        $createCommand->setStopWords('hello', 'world');
+
+        $builder = $builder->withSkipInitialScan(true);
+        $createCommand->setSkipInitialScan(true);
+
+        $builder = $builder->withNoFrequencies(true);
+        $createCommand->setNoFrequencies(true);
+
+        $builder = $builder->withNoFields(true);
+        $createCommand->setNoFields(true);
+
+        $builder = $builder->withNoHighLight(true);
+        $createCommand->setNoHighLight(true);
+
+        $builder = $builder->withTemporary(20);
+        $createCommand->setTemporary(20);
+
+        $builder = $builder->withNoOffsets(true);
+        $createCommand->setNoOffsets(true);
+
+        $builder = $builder->withDefaultLanguage('en');
+        $createCommand->setDefaultLanguage('en');
+
+        $builder = $builder->withDefaultScore(1.3);
+        $createCommand->setDefaultScore(1.3);
+
+        $builder = $builder->withMaxTextFields(true);
+        $createCommand->setMaxTextFields(true);
+
+        $builder = $builder->withPayloadField('payload');
+        $createCommand->setPayloadField('payload');
+
+        $builder = $builder->withScoreField('score');
+        $createCommand->setScoreField('score');
+
+        $builder = $builder->withLanguageField('lang');
+        $createCommand->setLanguageField('lang');
+
+        $builder = $builder->withFilter('startswith(@name, "G")');
+        $createCommand->setFilter('startswith(@name, "G")');
+
+        $builder = $builder->withStructure('JSON');
+        $createCommand->setStructure('JSON');
+
+        $builder = $builder->withFields([(new TextFieldOption())->setField('name')]);
+        $createCommand->addTextField('name');
+        static::assertEquals($createCommand, $builder->getCommand());
+    }
+
+    public function testNoStopWords(): void
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        $createCommand->setIndex('city');
+
+        $builder->setNoStopWords();
+        $createCommand->setNoStopWords();
+
+        static::assertEquals($createCommand, $builder->getCommand());
+    }
+
+    public function testAllAdd(): void
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        $createCommand->setIndex('city');
+
+        $oldBuilder = $builder;
+        $builder = $builder->addPrefixes('city-', 'c-');
+        static::assertNotEquals($createCommand, $builder->getCommand());
+        $createCommand->setPrefixes('city-', 'c-');
+        static::assertEquals($createCommand, $builder->getCommand());
+        static::assertSame($oldBuilder, $builder);
+
+        $builder->addStopWords('hello', 'world');
+        $createCommand->setStopWords('hello', 'world');
+
+        $builder->addFields((new TextFieldOption())->setField('name'));
+        $createCommand->addTextField('name');
+
+        $builder->addField((new TextFieldOption())->setField('country'));
+        $createCommand->addTextField('country');
+
+        $builder->addTextField('continent');
+        $createCommand->addTextField('continent');
+
+        $builder->addNumericField('population');
+        $createCommand->addNumericField('population');
+
+        $builder->addGeoField('gps');
+        $createCommand->addGeoField('gps');
+
+        $builder->addTagField('languages');
+        $createCommand->addTagField('languages');
+        static::assertEquals($createCommand, $builder->getCommand());
+    }
+
+    public function testAllJsonAdd(): void
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        $createCommand->setIndex('city');
+
+        $oldBuilder = $builder;
+        $builder = $builder->addPrefixes('city-', 'c-');
+        static::assertNotEquals($createCommand, $builder->getCommand());
+        $createCommand->setPrefixes('city-', 'c-');
+        static::assertEquals($createCommand, $builder->getCommand());
+        static::assertSame($oldBuilder, $builder);
+
+        $builder->addStopWords('hello', 'world');
+        $createCommand->setStopWords('hello', 'world');
+
+        $builder->addJSONField('$.city.name', (new TextFieldOption())->setField('name'));
+        $createCommand->addJSONTextField('$.city.name', 'name');
+
+        $builder->addJSONField('$.city.country', (new TextFieldOption())->setField('country'));
+        $createCommand->addJSONTextField('$.city.country', 'country');
+
+        $builder->addJSONTextField('$.city.continent', 'continent');
+        $createCommand->addJSONTextField('$.city.continent', 'continent');
+
+        $builder->addJSONNumericField('$.city.population', 'population');
+        $createCommand->addJSONNumericField('$.city.population', 'population');
+
+        $builder->addJSONGeoField('$.city.gps', 'gps');
+        $createCommand->addJSONGeoField('$.city.gps', 'gps');
+
+        $builder->addJSONTagField('$.city.languages', 'languages');
+        $createCommand->addJSONTagField('$.city.languages', 'languages');
+
+        $builder->addJSONVectorField('$.city.vec', 'vec', VectorFieldOption::ALGORITHM_FLAT, VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_L2);
+        $createCommand->addJSONVectorField('$.city.vec', 'vec', VectorFieldOption::ALGORITHM_FLAT, VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_L2);
+        static::assertEquals($createCommand, $builder->getCommand());
+    }
+
+    /**
+     * @medium
+     */
+    public function testAllWithAdded(): void
+    {
+        $createCommand = new Create();
+
+        $builder = new IndexBuilder();
+
+        $builder->setIndex('city');
+        $createCommand->setIndex('city');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $oldBuilder = $builder;
+        $builder = $builder->withAddedPrefixes('city-', 'c-');
+        $createCommand->setPrefixes('city-', 'c-');
+        static::assertEquals($createCommand, $builder->getCommand());
+        static::assertNotSame($oldBuilder, $builder);
+
+        $builder = $builder->withAddedStopWords('hello', 'world');
+        $createCommand->setStopWords('hello', 'world');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $builder = $builder->withAddedFields((new TextFieldOption())->setField('name'));
+        $createCommand->addTextField('name');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $builder = $builder->withAddedTextField('continent');
+        $createCommand->addTextField('continent');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $builder = $builder->withAddedNumericField('population');
+        $createCommand->addNumericField('population');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $builder = $builder->withAddedGeoField('gps');
+        $createCommand->addGeoField('gps');
+        static::assertEquals($createCommand, $builder->getCommand());
+
+        $builder = $builder->withAddedTagField('languages');
+        $createCommand->addTagField('languages');
+        static::assertEquals($createCommand, $builder->getCommand());
+    }
+
+    public function testUnexistingMethod(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined foobar method');
+
+        $builder = new IndexBuilder();
+        // @phpstan-ignore-next-line
+        $builder->foobar();
+    }
+
+    public function testUnexistingMethod2(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined setHelloWorld method');
+
+        $builder = new IndexBuilder();
+        // @phpstan-ignore-next-line
+        $builder->setHelloWorld();
+    }
+
+    public function testUnexistingMethodAdd(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined addHelloWorld method');
+
+        $builder = new IndexBuilder();
+        // @phpstan-ignore-next-line
+        $builder->addHelloWorld();
+    }
+
+    public function testInvalidAdd(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('addFields method need at least one parameter');
+
+        $builder = new IndexBuilder();
+        $builder->addFields();
+    }
+
+    public function testMissingIndex(): void
+    {
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('The index name is missing');
+
+        $builder = new IndexBuilder();
+        $builder->getCommand();
+    }
+
+    public function testCreate(): void
+    {
+        /**
+         * @var IndexBuilder $builder
+         * @var Create       $createCommand
+         */
+        [$builder, $createCommand] = $this->testAllSetter();
+        $client = $this->createMock(Client::class);
+        $client->expects(static::once())->method('execute')->with($createCommand);
+        $builder->create($client);
+    }
+}
diff --git a/tests/IndexTest.php b/tests/IndexTest.php
new file mode 100644
index 0000000..6e7c47a
--- /dev/null
+++ b/tests/IndexTest.php
@@ -0,0 +1,270 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests;
+
+use MacFJA\RediSearch\Index;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\AliasAdd;
+use MacFJA\RediSearch\Redis\Command\AliasDel;
+use MacFJA\RediSearch\Redis\Command\AliasUpdate;
+use MacFJA\RediSearch\Redis\Command\Alter;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\DropIndex;
+use MacFJA\RediSearch\Redis\Command\TagVals;
+use MacFJA\RediSearch\Redis\Response\InfoResponse;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Index
+ *
+ * @uses \MacFJA\RediSearch\Redis\Response\ArrayResponseTrait
+ * @uses \MacFJA\RediSearch\Redis\Response\InfoResponse
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Alter
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\BaseCreateFieldOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\DropIndex
+ * @uses \MacFJA\RediSearch\Redis\Command\AliasAdd
+ * @uses \MacFJA\RediSearch\Redis\Command\AliasUpdate
+ * @uses \MacFJA\RediSearch\Redis\Command\AliasDel
+ * @uses \MacFJA\RediSearch\Redis\Command\TagVals
+ *
+ * @internal
+ */
+class IndexTest extends TestCase
+{
+    public function testAddDocumentFromArray(): void
+    {
+        $expectedKey = 'manualKey';
+        $client = $this->createMock(Client::class);
+        $client->method('executeRaw')->withConsecutive(
+            ['hset', static::stringStartsWith('doc:'), 'f1', 'foo', 'f2', 'bar'],
+            ['hset', $expectedKey, 'f1', 'foo', 'f2', 'bar']
+        );
+
+        $index = $this->getMockBuilder(Index::class)
+            ->setConstructorArgs(['idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION])
+            ->onlyMethods(['getInfo'])
+            ->getMock()
+        ;
+        $normalInfoResponse = new InfoResponse([
+            0 => 'index_name',
+            1 => 'idxt',
+            2 => 'index_options',
+            3 => [],
+            4 => 'index_definition',
+            5 => [
+                0 => 'key_type',
+                1 => 'HASH',
+                2 => 'prefixes',
+                3 => [0 => 'doc:'],
+                4 => 'language_field',
+                5 => '__language',
+                6 => 'default_score',
+                7 => '1',
+                8 => 'score_field',
+                9 => '__score',
+                10 => 'payload_field',
+                11 => '__payload',
+            ],
+            6 => 'fields',
+            7 => [
+                0 => [
+                    0 => 'f1',
+                    1 => 'type',
+                    2 => 'TEXT',
+                    3 => 'WEIGHT',
+                    4 => '1',
+                ],
+                1 => [
+                    0 => 'f2',
+                    1 => 'type',
+                    2 => 'TEXT',
+                ],
+            ],
+            8 => 'num_docs',
+            9 => '4',
+            10 => 'max_doc_id',
+            11 => '12',
+            12 => 'num_terms',
+            13 => '4',
+            14 => 'num_records',
+            15 => '30',
+            16 => 'inverted_sz_mb',
+            17 => '0.000171661376953125',
+            18 => 'total_inverted_index_blocks',
+            19 => '1801',
+            20 => 'offset_vectors_sz_mb',
+            21 => '2.86102294921875e-05',
+            22 => 'doc_table_size_mb',
+            23 => '0.000995635986328125',
+            24 => 'sortable_values_size_mb',
+            25 => '0',
+            26 => 'key_table_size_mb',
+            27 => '0.00012493133544921875',
+            28 => 'records_per_doc_avg',
+            29 => '7.5',
+            30 => 'bytes_per_record_avg',
+            31 => '6',
+            32 => 'offsets_per_term_avg',
+            33 => '1',
+            34 => 'offset_bits_per_record_avg',
+            35 => '8',
+            36 => 'hash_indexing_failures',
+            37 => '0',
+            38 => 'indexing',
+            39 => '0',
+            40 => 'percent_indexed',
+            41 => '1',
+            42 => 'gc_stats',
+            43 => [
+                0 => 'bytes_collected',
+                1 => '0',
+                2 => 'total_ms_run',
+                3 => '0',
+                4 => 'total_cycles',
+                5 => '0',
+                6 => 'average_cycle_time_ms',
+                7 => '-nan',
+                8 => 'last_run_time_ms',
+                9 => '0',
+                10 => 'gc_numeric_trees_missed',
+                11 => '0',
+                12 => 'gc_blocks_denied',
+                13 => '0',
+            ],
+            44 => 'cursor_stats',
+            45 => [
+                0 => 'global_idle',
+                1 => 63,
+                2 => 'global_total',
+                3 => 63,
+                4 => 'index_capacity',
+                5 => 128,
+                6 => 'index_total',
+                7 => 63,
+            ],
+        ]);
+        $emptyInfoResponse = new InfoResponse([]);
+        $index->method('getInfo')->willReturnOnConsecutiveCalls($normalInfoResponse, $normalInfoResponse, $emptyInfoResponse, $emptyInfoResponse);
+
+        $generatedHash = $index->addDocumentFromArray(['f1' => 'foo', 'f2' => 'bar']);
+        static::assertStringStartsWith('doc:', $generatedHash);
+
+        $insertedKey = $index->addDocumentFromArray(['f1' => 'foo', 'f2' => 'bar'], $expectedKey);
+        static::assertSame($expectedKey, $insertedKey);
+
+        $insertedKey = $index->addDocumentFromArray(['f1' => 'foo', 'f2' => 'bar'], $expectedKey);
+        static::assertSame($expectedKey, $insertedKey);
+
+        $insertedKey = $index->addDocumentFromArray(['f1' => 'foo', 'f2' => 'bar']);
+        static::assertStringStartsNotWith('doc:', $insertedKey);
+    }
+
+    public function testDeleteDocument(): void
+    {
+        $client = $this->createMock(Client::class);
+        $client->expects(static::exactly(2))->method('executeRaw')->with('del', 'foobar')->willReturnOnConsecutiveCalls(1, 0);
+
+        $index = new Index('idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION);
+        static::assertTrue($index->deleteDocument('foobar'));
+        static::assertFalse($index->deleteDocument('foobar'));
+    }
+
+    public function testAddField(): void
+    {
+        $client = $this->createMock(Client::class);
+        $expected = (new Alter(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx')->addTextField('f3');
+        $client->expects(static::exactly(2))->method('execute')->with($expected)->willReturnOnConsecutiveCalls('OK', 0);
+        $index = new Index('idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION);
+
+        static::assertTrue($index->addField((new TextFieldOption())->setField('f3')));
+        static::assertFalse($index->addField((new TextFieldOption())->setField('f3')));
+    }
+
+    public function testDelete(): void
+    {
+        $client = $this->createMock(Client::class);
+        $normalExpectation = (new DropIndex(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx');
+        $withDocumentExpectation = (new DropIndex(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx')->setDeleteDocument();
+        $client
+            ->expects(static::exactly(3))
+            ->method('execute')
+            ->withConsecutive([$normalExpectation], [$withDocumentExpectation], [$normalExpectation])
+            ->willReturnOnConsecutiveCalls('OK', 'OK', 0)
+        ;
+        $index = new Index('idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION);
+
+        static::assertTrue($index->delete());
+        static::assertTrue($index->delete(true));
+        static::assertFalse($index->delete());
+    }
+
+    public function testAliases(): void
+    {
+        $client = $this->createMock(Client::class);
+        $addExpectation = (new AliasAdd(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx')->setAlias('foo');
+        $updateExpectation = (new AliasUpdate(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx')->setAlias('foo');
+        $deleteExpectation = (new AliasDel(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setAlias('foo');
+        $client
+            ->expects(static::exactly(6))
+            ->method('execute')
+            ->withConsecutive([$addExpectation], [$addExpectation], [$updateExpectation], [$updateExpectation], [$deleteExpectation], [$deleteExpectation])
+            ->willReturnOnConsecutiveCalls('OK', 0, 'OK', 0, 'OK', 0)
+        ;
+        $index = new Index('idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION);
+
+        static::assertTrue($index->addAlias('foo'));
+        static::assertFalse($index->addAlias('foo'));
+        static::assertTrue($index->updateAlias('foo'));
+        static::assertFalse($index->updateAlias('foo'));
+        static::assertTrue($index->deleteAlias('foo'));
+        static::assertFalse($index->deleteAlias('foo'));
+    }
+
+    public function testGetTagValues(): void
+    {
+        $client = $this->createMock(Client::class);
+        $expectation = (new TagVals(AbstractCommand::MIN_IMPLEMENTED_VERSION))->setIndex('idx')->setField('f1');
+        $expectedTags = ['foo', 'bar'];
+        $client
+            ->expects(static::once())
+            ->method('execute')
+            ->with($expectation)
+            ->willReturn($expectedTags)
+        ;
+        $index = new Index('idx', $client, AbstractCommand::MIN_IMPLEMENTED_VERSION);
+
+        static::assertSame($expectedTags, $index->getTagValues('f1'));
+    }
+}
diff --git a/src/Aggregate/GroupBy.php b/tests/Query/Builder/FuzzyTest.php
similarity index 52%
rename from src/Aggregate/GroupBy.php
rename to tests/Query/Builder/FuzzyTest.php
index 0fe1a5a..7d50899 100644
--- a/src/Aggregate/GroupBy.php
+++ b/tests/Query/Builder/FuzzyTest.php
@@ -19,44 +19,40 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate;
+namespace MacFJA\RediSearch\tests\Query\Builder;
 
-use function array_map;
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\PartialQuery;
-use Throwable;
+use MacFJA\RediSearch\Exception\OutOfRangeLevenshteinDistanceException;
+use MacFJA\RediSearch\Query\Builder\Fuzzy;
+use PHPUnit\Framework\TestCase;
 
-class GroupBy implements PartialQuery
+/**
+ * @covers \MacFJA\RediSearch\Exception\OutOfRangeLevenshteinDistanceException
+ * @covers \MacFJA\RediSearch\Query\Builder\Fuzzy
+ *
+ * @internal
+ */
+class FuzzyTest extends TestCase
 {
-    /** @var array<string> */
-    private $properties = [];
-
-    /** @var array<Reducer> */
-    private $reducers = [];
-
-    /**
-     * @param array<string>  $properties
-     * @param array<Reducer> $reducers
-     *
-     * @throws Throwable
-     */
-    public function __construct(array $properties, array $reducers)
+    public function testTooLow(): void
     {
-        DataHelper::assertArrayOf($properties, 'string');
-        DataHelper::assertArrayOf($reducers, Reducer::class);
+        $this->expectException(OutOfRangeLevenshteinDistanceException::class);
+        $this->expectExceptionMessage('The Levenshtein distance should be between 1 and 3 (0 provided)');
+        new Fuzzy('hello', 0);
+    }
 
-        $this->properties = $properties;
-        $this->reducers = $reducers;
+    public function testTooHigh(): void
+    {
+        $this->expectException(OutOfRangeLevenshteinDistanceException::class);
+        $this->expectExceptionMessage('The Levenshtein distance should be between 1 and 3 (4 provided)');
+        new Fuzzy('hello', 4);
     }
 
-    public function getQueryParts(): array
+    public function testSpace(): void
     {
-        $atProperties = array_map(static function (string $property) {
-            return '@'.$property;
-        }, $this->properties);
-        $query = RedisHelper::buildQueryList([], ['GROUPBY' => $atProperties], true);
+        $element = new Fuzzy('hello');
+        static::assertFalse($element->includeSpace());
 
-        return RedisHelper::buildQueryPartial($query, $this->reducers);
+        $element = new Fuzzy('hello world');
+        static::assertTrue($element->includeSpace());
     }
 }
diff --git a/tests/Query/Builder/GeoFacetTest.php b/tests/Query/Builder/GeoFacetTest.php
new file mode 100644
index 0000000..d590160
--- /dev/null
+++ b/tests/Query/Builder/GeoFacetTest.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use MacFJA\RediSearch\Exception\UnknownUnitException;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\UnknownUnitException
+ * @covers \MacFJA\RediSearch\Query\Builder\GeoFacet
+ *
+ * @internal
+ */
+class GeoFacetTest extends TestCase
+{
+    public function testWrongUnit(): void
+    {
+        $this->expectException(UnknownUnitException::class);
+        $this->expectExceptionMessage('The unit "foo" is not valid.');
+
+        new GeoFacet(['foo'], 1.0, 1.0, 1.0, 'foo');
+    }
+}
diff --git a/tests/Query/Builder/IncludeSpaceTest.php b/tests/Query/Builder/IncludeSpaceTest.php
new file mode 100644
index 0000000..07fafb5
--- /dev/null
+++ b/tests/Query/Builder/IncludeSpaceTest.php
@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Query\Builder\AndGroup;
+use MacFJA\RediSearch\Query\Builder\EncapsulationGroup;
+use MacFJA\RediSearch\Query\Builder\ExactMatch;
+use MacFJA\RediSearch\Query\Builder\FieldFacet;
+use MacFJA\RediSearch\Query\Builder\Fuzzy;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\Negation;
+use MacFJA\RediSearch\Query\Builder\NumericFacet;
+use MacFJA\RediSearch\Query\Builder\Optional;
+use MacFJA\RediSearch\Query\Builder\OrGroup;
+use MacFJA\RediSearch\Query\Builder\Prefix;
+use MacFJA\RediSearch\Query\Builder\QueryElementAttribute;
+use MacFJA\RediSearch\Query\Builder\RawElement;
+use MacFJA\RediSearch\Query\Builder\TagFacet;
+use MacFJA\RediSearch\Query\Builder\TextFacet;
+use MacFJA\RediSearch\Query\Builder\Word;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Builder
+ * @covers \MacFJA\RediSearch\Query\Builder\AbstractGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\AndGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\EncapsulationGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\ExactMatch
+ * @covers \MacFJA\RediSearch\Query\Builder\FieldFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Fuzzy
+ * @covers \MacFJA\RediSearch\Query\Builder\GeoFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Negation
+ * @covers \MacFJA\RediSearch\Query\Builder\NumericFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Optional
+ * @covers \MacFJA\RediSearch\Query\Builder\OrGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\Prefix
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElement::priority
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementAttribute
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementVector
+ * @covers \MacFJA\RediSearch\Query\Builder\RawElement
+ * @covers \MacFJA\RediSearch\Query\Builder\TagFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\TextFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Word
+ *
+ * @uses \MacFJA\RediSearch\Query\Escaper
+ *
+ * @internal
+ */
+class IncludeSpaceTest extends TestCase
+{
+    public function testGroupIncludeSpace(): void
+    {
+        $andGroup = new AndGroup();
+        $andGroup2 = new AndGroup([new Word('foo bar')]);
+        $andGroup3 = new AndGroup([new Word('foo bar'), new Word('baz')]);
+        $encapsulationGroup = new EncapsulationGroup('(', ')', new RawElement(''));
+        $encapsulationGroup2 = new EncapsulationGroup('(', ')', new Word('foo'), true);
+        $encapsulationGroup3 = new EncapsulationGroup('(', ')', new Word('foo bar'), true);
+        $orGroup = new OrGroup();
+        $orGroup2 = new OrGroup([new Word('foo bar')]);
+        $orGroup3 = new OrGroup([new Word('foo bar'), new Word('baz')]);
+        $orGroup4 = new OrGroup([new Word('foo')]);
+        $builderGroup = new Builder();
+
+        static::assertFalse($andGroup->includeSpace());
+        static::assertTrue($andGroup2->includeSpace());
+        static::assertTrue($andGroup3->includeSpace());
+        static::assertFalse($encapsulationGroup->includeSpace());
+        static::assertFalse($encapsulationGroup2->includeSpace());
+        static::assertFalse($encapsulationGroup3->includeSpace());
+        static::assertFalse($orGroup->includeSpace());
+        static::assertTrue($orGroup2->includeSpace());
+        static::assertTrue($orGroup3->includeSpace());
+        static::assertFalse($orGroup4->includeSpace());
+        static::assertFalse($builderGroup->includeSpace());
+    }
+
+    public function testFacetIncludeSpace(): void
+    {
+        $field = new FieldFacet(['foo'], new RawElement(''));
+        $geo = new GeoFacet(['foo'], 0, 0, 0, GeoFilterOption::UNIT_METERS);
+        $numeric = new NumericFacet(['foo'], 0, 0);
+        $tag = new TagFacet(['foo'], '');
+        $text = new TextFacet(['foo'], '');
+        $text2 = new TextFacet(['foo'], 'bar baz');
+        $text3 = new TextFacet(['foo'], 'bar', 'baz');
+
+        static::assertFalse($field->includeSpace());
+        static::assertFalse($geo->includeSpace());
+        static::assertFalse($numeric->includeSpace());
+        static::assertFalse($tag->includeSpace());
+        static::assertFalse($text->includeSpace());
+        static::assertFalse($text2->includeSpace());
+        static::assertFalse($text3->includeSpace());
+    }
+
+    public function testModifierIncludeSpace(): void
+    {
+        $exactMatch = new ExactMatch('foo bar');
+        $fuzzy = new Fuzzy('foo bar');
+        $negation = new Negation(new RawElement('foo bar'));
+        $optional = new Optional(new RawElement('foo bar'));
+        $prefix = new Prefix('foo bar');
+        $attribute = new QueryElementAttribute(new RawElement('foo bar'));
+        $vector = new Builder\QueryElementVector(new RawElement('foo bar'), 10, 'vec', 'BLOB');
+        $raw = new RawElement('foo bar');
+        $word = new Word('foo bar');
+
+        static::assertFalse($exactMatch->includeSpace());
+        static::assertTrue($fuzzy->includeSpace());
+        static::assertFalse($negation->includeSpace());
+        static::assertFalse($optional->includeSpace());
+        static::assertFalse($prefix->includeSpace());
+        static::assertTrue($attribute->includeSpace());
+        static::assertFalse($vector->includeSpace());
+        static::assertTrue($raw->includeSpace());
+        static::assertTrue($word->includeSpace());
+    }
+}
diff --git a/tests/Query/Builder/IncompleteFacetTest.php b/tests/Query/Builder/IncompleteFacetTest.php
new file mode 100644
index 0000000..ad07d6b
--- /dev/null
+++ b/tests/Query/Builder/IncompleteFacetTest.php
@@ -0,0 +1,105 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use MacFJA\RediSearch\Exception\NotEnoughFieldsException;
+use MacFJA\RediSearch\Exception\NotEnoughTermsException;
+use MacFJA\RediSearch\Query\Builder\FieldFacet;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\NumericFacet;
+use MacFJA\RediSearch\Query\Builder\RawElement;
+use MacFJA\RediSearch\Query\Builder\TagFacet;
+use MacFJA\RediSearch\Query\Builder\TextFacet;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\NotEnoughFieldsException
+ * @covers \MacFJA\RediSearch\Exception\NotEnoughTermsException
+ * @covers \MacFJA\RediSearch\Query\Builder\FieldFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\GeoFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\NumericFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\TagFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\TextFacet
+ *
+ * @uses  \MacFJA\RediSearch\Query\Builder\RawElement
+ *
+ * @internal
+ */
+class IncompleteFacetTest extends TestCase
+{
+    public function testTagFacetMissingTerms(): void
+    {
+        $this->expectException(NotEnoughTermsException::class);
+        $this->expectExceptionMessage('You must have at least one term');
+
+        new TagFacet(['text']);
+    }
+
+    public function testTagFacetMissingFields(): void
+    {
+        $this->expectException(NotEnoughFieldsException::class);
+        $this->expectExceptionMessage('You must have at least one field');
+
+        new TagFacet([], 'foo');
+    }
+
+    public function testTextFacetMissingTerms(): void
+    {
+        $this->expectException(NotEnoughTermsException::class);
+        $this->expectExceptionMessage('You must have at least one term');
+
+        new TextFacet(['text']);
+    }
+
+    public function testTextFacetMissingFields(): void
+    {
+        $this->expectException(NotEnoughFieldsException::class);
+        $this->expectExceptionMessage('You must have at least one field');
+
+        new TextFacet([], 'foo');
+    }
+
+    public function testNumericFacetMissingFields(): void
+    {
+        $this->expectException(NotEnoughFieldsException::class);
+        $this->expectExceptionMessage('You must have at least one field');
+
+        new NumericFacet([], 0, null);
+    }
+
+    public function testGeoFacetMissingFields(): void
+    {
+        $this->expectException(NotEnoughFieldsException::class);
+        $this->expectExceptionMessage('You must have at least one field');
+
+        new GeoFacet([], 0, 0, 5, GeoFilterOption::UNIT_METERS);
+    }
+
+    public function testFieldFacetMissingFields(): void
+    {
+        $this->expectException(NotEnoughFieldsException::class);
+        $this->expectExceptionMessage('You must have at least one field');
+
+        new FieldFacet([], new RawElement(''));
+    }
+}
diff --git a/tests/Query/Builder/PriorityTest.php b/tests/Query/Builder/PriorityTest.php
new file mode 100644
index 0000000..43c2ae4
--- /dev/null
+++ b/tests/Query/Builder/PriorityTest.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Query\Builder\AndGroup;
+use MacFJA\RediSearch\Query\Builder\EncapsulationGroup;
+use MacFJA\RediSearch\Query\Builder\ExactMatch;
+use MacFJA\RediSearch\Query\Builder\FieldFacet;
+use MacFJA\RediSearch\Query\Builder\Fuzzy;
+use MacFJA\RediSearch\Query\Builder\GeoFacet;
+use MacFJA\RediSearch\Query\Builder\Negation;
+use MacFJA\RediSearch\Query\Builder\NumericFacet;
+use MacFJA\RediSearch\Query\Builder\Optional;
+use MacFJA\RediSearch\Query\Builder\OrGroup;
+use MacFJA\RediSearch\Query\Builder\Prefix;
+use MacFJA\RediSearch\Query\Builder\QueryElement;
+use MacFJA\RediSearch\Query\Builder\QueryElementAttribute;
+use MacFJA\RediSearch\Query\Builder\RawElement;
+use MacFJA\RediSearch\Query\Builder\TagFacet;
+use MacFJA\RediSearch\Query\Builder\TextFacet;
+use MacFJA\RediSearch\Query\Builder\Word;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Builder
+ * @covers \MacFJA\RediSearch\Query\Builder\AbstractGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\AndGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\EncapsulationGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\ExactMatch
+ * @covers \MacFJA\RediSearch\Query\Builder\FieldFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Fuzzy
+ * @covers \MacFJA\RediSearch\Query\Builder\GeoFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Negation
+ * @covers \MacFJA\RediSearch\Query\Builder\NumericFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Optional
+ * @covers \MacFJA\RediSearch\Query\Builder\OrGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\Prefix
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElement::priority
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementAttribute
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementVector
+ * @covers \MacFJA\RediSearch\Query\Builder\RawElement
+ * @covers \MacFJA\RediSearch\Query\Builder\TagFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\TextFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Word
+ *
+ * @uses \MacFJA\RediSearch\Query\Escaper
+ *
+ * @internal
+ */
+class PriorityTest extends TestCase
+{
+    public function testGroupPriority(): void
+    {
+        $andGroup = new AndGroup();
+        $encapsulationGroup = new EncapsulationGroup('(', ')', new RawElement(''));
+        $orGroup = new OrGroup();
+        $builderGroup = new Builder();
+
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $andGroup->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $encapsulationGroup->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $orGroup->priority());
+        static::assertSame(QueryElement::PRIORITY_BEFORE, $builderGroup->priority());
+    }
+
+    public function testFacetPriority(): void
+    {
+        $field = new FieldFacet(['foo'], new RawElement(''));
+        $geo = new GeoFacet(['foo'], 0, 0, 0, GeoFilterOption::UNIT_METERS);
+        $numeric = new NumericFacet(['foo'], 0, 0);
+        $tag = new TagFacet(['foo'], '');
+        $text = new TextFacet(['foo'], '');
+
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $field->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $geo->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $numeric->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $tag->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $text->priority());
+    }
+
+    public function testModifierPriority(): void
+    {
+        $exactMatch = new ExactMatch('foo');
+        $fuzzy = new Fuzzy('foo');
+        $negation = new Negation(new RawElement('foo'));
+        $optional = new Optional(new RawElement('foo'));
+        $prefix = new Prefix('foo');
+        $attribute = new QueryElementAttribute(new RawElement('foo'));
+        $vector = new Builder\QueryElementVector(new RawElement('foo'), 10, 'vec', 'BLOB');
+        $raw = new RawElement('foo');
+        $word = new Word('foo');
+
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $exactMatch->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $fuzzy->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $negation->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $optional->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $prefix->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $attribute->priority());
+        static::assertSame(QueryElement::PRIORITY_BEFORE, $vector->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $raw->priority());
+        static::assertSame(QueryElement::PRIORITY_NORMAL, $word->priority());
+    }
+}
diff --git a/tests/Query/Builder/QueryElementAttributeTest.php b/tests/Query/Builder/QueryElementAttributeTest.php
new file mode 100644
index 0000000..0baf2a5
--- /dev/null
+++ b/tests/Query/Builder/QueryElementAttributeTest.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use MacFJA\RediSearch\Query\Builder\QueryElementAttribute;
+use MacFJA\RediSearch\Query\Builder\RawElement;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementAttribute
+ *
+ * @uses \MacFJA\RediSearch\Query\Builder\RawElement
+ * @uses \MacFJA\RediSearch\Query\Builder\EncapsulationGroup
+ *
+ * @internal
+ */
+class QueryElementAttributeTest extends TestCase
+{
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testOptions(string $expected, ?float $weight, ?int $slop, ?bool $inOrder, ?bool $phonetic): void
+    {
+        $baseElement = new RawElement('foo bar');
+        $attribute = new QueryElementAttribute($baseElement, $weight, $slop, $inOrder, $phonetic);
+        static::assertSame($expected, $attribute->render());
+    }
+
+    /**
+     * @return array<array<null|bool|float|int|string>>
+     */
+    public function dataProvider(): array
+    {
+        return [
+            ['foo bar', null, null, null, null],
+            ['(foo bar) => { $weight: 0.5; }', 0.5, null, null, null],
+            ['(foo bar) => { $weight: 2.0; }', 2.0, null, null, null],
+            ['(foo bar) => { $slop: 2; }', null, 2, null, null],
+            ['(foo bar) => { $inorder: true; }', null, null, true, null],
+            ['(foo bar) => { $inorder: false; }', null, null, false, null],
+            ['(foo bar) => { $phonetic: true; }', null, null, null, true],
+            ['(foo bar) => { $phonetic: false; }', null, null, null, false],
+            ['(foo bar) => { $weight: 1.3; $slop: 4; $inorder: true; $phonetic: false; }', 1.3, 4, true, false],
+        ];
+    }
+}
diff --git a/tests/Query/Builder/QueryElementVectorTest.php b/tests/Query/Builder/QueryElementVectorTest.php
new file mode 100644
index 0000000..25c5d9d
--- /dev/null
+++ b/tests/Query/Builder/QueryElementVectorTest.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query\Builder;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Redis\Command\Search;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementVector
+ *
+ * @uses \MacFJA\RediSearch\Query\Builder
+ * @uses \MacFJA\RediSearch\Query\Builder\AbstractGroup
+ * @uses \MacFJA\RediSearch\Query\Builder\AndGroup
+ * @uses \MacFJA\RediSearch\Query\Builder\EncapsulationGroup
+ * @uses \MacFJA\RediSearch\Query\Builder\ExactMatch
+ * @uses \MacFJA\RediSearch\Query\Builder\FieldFacet
+ * @uses \MacFJA\RediSearch\Query\Builder\Optional
+ * @uses \MacFJA\RediSearch\Query\Builder\OrGroup
+ * @uses \MacFJA\RediSearch\Query\Builder\RawElement
+ * @uses \MacFJA\RediSearch\Query\Builder\TagFacet
+ * @uses \MacFJA\RediSearch\Query\Builder\Word
+ * @uses \MacFJA\RediSearch\Query\Escaper
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\LanguageOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Search
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ *
+ * @internal
+ */
+class QueryElementVectorTest extends TestCase
+{
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample(): void
+    {
+        // $expected = 'idx "(@type:{shirt} ~@color:{blue})=>[KNN $K @vec $BLOB AS my_scores]" PARAMS 4 BLOB "\12\a9\f5\6c" K 10 SORTBY my_scores';
+        // Data escaping is partially handled by the Redis lib
+        $expected = 'idx (@type:{shirt} ~@color:{blue})=>[KNN $K @vec $BLOB AS my_scores] PARAMS 4 BLOB \12\a9\f5\6c K 10 SORTBY my_scores';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $group = new Builder\AndGroup([
+            new Builder\TagFacet(['type'], 'shirt'),
+            new Builder\Optional(new Builder\TagFacet(['color'], 'blue')),
+        ]);
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector($group, 'K', 'vec', 'BLOB', 'my_scores')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->setSortBy('my_scores')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    public function testInvalidType(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $element = new Builder\RawElement('*');
+        new Builder\QueryElementVector($element, false, 'vec', 'BLOB');
+    }
+
+    public function testInvalidParameterName(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $element = new Builder\RawElement('*');
+        $vector = new Builder\QueryElementVector($element, 10, 'vec', 'BLOB');
+        $vector->addParameter('foo', 'bar');
+    }
+
+    public function testInvalidParameterValue(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $element = new Builder\RawElement('*');
+        $vector = new Builder\QueryElementVector($element, 10, 'vec', 'BLOB');
+        $vector->addParameter('EF_RUNTIME', 'bar');
+    }
+}
diff --git a/tests/Query/BuilderTest.php b/tests/Query/BuilderTest.php
new file mode 100644
index 0000000..6436fb4
--- /dev/null
+++ b/tests/Query/BuilderTest.php
@@ -0,0 +1,771 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Redis\Command\Search;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Builder
+ * @covers \MacFJA\RediSearch\Query\Builder\AbstractGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\AndGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\EncapsulationGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\ExactMatch
+ * @covers \MacFJA\RediSearch\Query\Builder\FieldFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Fuzzy
+ * @covers \MacFJA\RediSearch\Query\Builder\GeoFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Negation
+ * @covers \MacFJA\RediSearch\Query\Builder\NumericFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Optional
+ * @covers \MacFJA\RediSearch\Query\Builder\OrGroup
+ * @covers \MacFJA\RediSearch\Query\Builder\Prefix
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementAttribute
+ * @covers \MacFJA\RediSearch\Query\Builder\QueryElementVector
+ * @covers \MacFJA\RediSearch\Query\Builder\RawElement
+ * @covers \MacFJA\RediSearch\Query\Builder\TagFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\TextFacet
+ * @covers \MacFJA\RediSearch\Query\Builder\Word
+ * @covers \MacFJA\RediSearch\Query\Escaper
+ * @covers \MacFJA\RediSearch\Redis\Command\Search::addParam
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\LanguageOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Search
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ *
+ * @internal
+ */
+class BuilderTest extends TestCase
+{
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#numeric_filters_in_query
+     */
+    public function testDocNumericExample1(): void
+    {
+        $expected = '@price:[100 200]';
+
+        $builder = new Builder();
+
+        $builder->addNumericFacet('price', 100, 200);
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#numeric_filters_in_query
+     */
+    public function testDocNumericExample2(): void
+    {
+        $expected = '@title:foo -@price:[100 100]';
+
+        $builder = new Builder();
+
+        $builder
+            ->addTextFacet('title', 'foo')
+            ->addElement(
+                new Builder\Negation(
+                    Builder\NumericFacet::equalsTo('price', 100)
+                )
+            )
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * https://oss.redislabs.com/redisearch/Query_Syntax/#pure_negative_queries.
+     */
+    public function testDocNegativeExample1(): void
+    {
+        $expected = '-hello';
+        $builder = new Builder();
+
+        $builder->addElement(new Builder\Negation(new Builder\Word('hello')));
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#field_modifiers
+     */
+    public function testDocModifierExample1(): void
+    {
+        $expected = '@country:korea @engine:(diesel|hybrid) @class:suv';
+        $builder = new Builder();
+
+        $builder->addTextFacet('country', 'korea')
+            ->addTextFacet('engine', 'diesel', 'hybrid')
+            ->addTextFacet('class', 'suv')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#field_modifiers
+     */
+    public function testDocModifierExample2(): void
+    {
+        $expected = '@title|body:(hello world) @url|image:mydomain';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(
+                new Builder\FieldFacet(
+                    ['title', 'body'],
+                    Builder\EncapsulationGroup::simple(Builder\AndGroup::fromStrings('hello', 'world'))
+                )
+            )
+            ->addElement(new Builder\TextFacet(
+                ['url', 'image'],
+                'mydomain'
+            ))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#tag_filters
+     */
+    public function testDocTagExample1(): void
+    {
+        $expected = '@cities:{ New York | Los Angeles | Barcelona }';
+        $expected = str_replace(['{ ', ' | ', ' }'], ['{', '|', '}'], $expected);
+        $builder = new Builder();
+
+        $builder
+            ->addTagFacet('cities', 'New York', 'Los Angeles', 'Barcelona')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#tag_filters
+     */
+    public function testDocTagExample2(): void
+    {
+        $expected = '@cities:{ New York } @cities:{Los Angeles} @cities:{ Barcelona }';
+        $expected = str_replace(['{ ', ' | ', ' }'], ['{', '|', '}'], $expected);
+        $builder = new Builder();
+
+        $builder
+            ->addTagFacet('cities', 'New York')
+            ->addTagFacet('cities', 'Los Angeles')
+            ->addTagFacet('cities', 'Barcelona')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#geo_filters_in_query
+     */
+    public function testDocGeoExample1(): void
+    {
+        $expected = 'chinese restaurant @location:[-122.41 37.77 5 km]';
+        $builder = new Builder();
+
+        $builder
+            ->addString('chinese')
+            ->addString('restaurant')
+            ->addGeoFacet('location', -122.41, 37.77, 5, 'km')
+        ;
+
+        static::assertSame($expected, str_replace(
+            ['.000000 ', '0000 '],
+            [' ', ' '],
+            $builder->render()
+        ));
+    }
+
+    /**
+     * @see https://oss.redis.com/redisearch/Query_Syntax/#prefix_matching
+     */
+    public function testDocPrefixExample1(): void
+    {
+        $expected = 'hel* world';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(new Builder\Prefix('hel'))
+            ->addString('world')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#fuzzy_matching
+     */
+    public function testDocFuzzyExample1(): void
+    {
+        $expected = '%hello% world';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(new Builder\Fuzzy('hello'))
+            ->addString('world')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#fuzzy_matching
+     */
+    public function testDocFuzzyExample2(): void
+    {
+        $expected = '%%hello%%';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(new Builder\Fuzzy('hello', 2))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#query_attributes
+     */
+    public function testDocAttributeExample1(): void
+    {
+        $expected = '(foo bar) => { $weight: 2.0; $slop: 1; $inorder: true; }';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementAttribute(
+                    Builder\AndGroup::fromStrings('foo', 'bar'),
+                    2.0,
+                    1,
+                    true
+                )
+            )
+        ;
+
+        static::assertSame($expected, str_replace(
+            [' })', '(('],
+            [' }', '('],
+            $builder->render()
+        ));
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#query_attributes
+     */
+    public function testDocAttributeExample2(): void
+    {
+        $expected = '~(bar baz) => { $weight: 0.5; }';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementAttribute(
+                    new Builder\Optional(Builder\AndGroup::fromStrings('bar', 'baz')),
+                    0.5
+                )
+            )
+        ;
+
+        static::assertSame($expected, str_replace(
+            [' })', '(~('],
+            [' }', '~('],
+            $builder->render()
+        ));
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#query_attributes
+     */
+    public function testDocAttributeExample2bis(): void
+    {
+        $expected = '~(bar baz) => { $weight: 0.5; }';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(
+                new Builder\Optional(
+                    new Builder\QueryElementAttribute(
+                        Builder\AndGroup::fromStrings('foo', 'bar'),
+                        0.5
+                    )
+                )
+            )
+        ;
+
+        static::assertNotSame($expected, str_replace(
+            [' })', '(~('],
+            [' }', '~('],
+            $builder->render()
+        ));
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample1(): void
+    {
+        $expected = 'hello world';
+        $builder = new Builder();
+
+        $builder
+            ->addString('hello')
+            ->addString('world')
+        ;
+
+        static::assertSame($expected, $builder->render());
+
+        $builder = new Builder();
+
+        $builder
+            ->addElement(Builder\AndGroup::fromStrings('hello', 'world'))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample2(): void
+    {
+        $expected = '"hello world"';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(new Builder\ExactMatch('hello world'))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample3(): void
+    {
+        $expected = 'hello|world';
+        $builder = new Builder();
+
+        $builder->addElement(Builder\OrGroup::fromStrings('hello', 'world'));
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample4(): void
+    {
+        $expected = 'hello -world';
+        $builder = new Builder();
+
+        $builder
+            ->addString('hello')
+            ->addElement(new Builder\Negation(new Builder\Word('world')))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample5(): void
+    {
+        $expected = '(hello|halo) (world|werld)';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(Builder\OrGroup::fromStrings('hello', 'halo'))
+            ->addElement(Builder\OrGroup::fromStrings('world', 'werld'))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample6(): void
+    {
+        $expected = 'hello -(world|werld)';
+        $builder = new Builder();
+
+        $builder
+            ->addString('hello')
+            ->addElement(
+                new Builder\Negation(
+                    new Builder\EncapsulationGroup(
+                        '(',
+                        ')',
+                        Builder\OrGroup::fromStrings('world', 'werld')
+                    )
+                )
+            )
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample7(): void
+    {
+        $expected = '(barack|barrack) obama';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(Builder\OrGroup::fromStrings('barack', 'barrack'))
+            ->addString('obama')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample8(): void
+    {
+        $expected = 'obama ~barack ~michelle';
+        $builder = new Builder();
+
+        $builder
+            ->addString('obama')
+            ->addElement(new Builder\Optional(new Builder\Word('barack')))
+            ->addElement(new Builder\Optional(new Builder\Word('michelle')))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample9(): void
+    {
+        $expected = '@title:"barack obama" @job:president';
+        $builder = new Builder();
+
+        $builder
+            ->addTextFacet('title', 'barack obama')
+            ->addTextFacet('job', 'president')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample10(): void
+    {
+        $expected = '@title:hello world @body:(foo bar) @category:(articles|biographies)';
+        $builder = new Builder();
+
+        $builder
+            ->addTextFacet('title', 'hello')
+            ->addString('world')
+            ->addElement(new Builder\FieldFacet(['body'], Builder\AndGroup::fromStrings('foo', 'bar')))
+            ->addTextFacet('category', 'articles', 'biographies')
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample12(): void
+    {
+        $expected = '@name:tv @price:[200 500]';
+        $builder = new Builder();
+
+        $builder
+            ->addTextFacet('name', 'tv')
+            ->addNumericFacet('price', 200, 500)
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://oss.redislabs.com/redisearch/Query_Syntax/#a_few_query_examples
+     */
+    public function testDocExample13(): void
+    {
+        $expected = '@age:[(18 +inf]';
+        $builder = new Builder();
+
+        $builder
+            ->addElement(Builder\NumericFacet::greaterThan('age', 18))
+        ;
+
+        static::assertSame($expected, $builder->render());
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample1(): void
+    {
+        // $expected = 'idx "*=>[KNN 100 @vec $BLOB]" PARAMS 2 BLOB "\12\a9\f5\6c"';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN 100 @vec $BLOB] PARAMS 2 BLOB \12\a9\f5\6c';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector(new Builder\RawElement('*'), 100, 'vec', 'BLOB')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample12(): void
+    {
+        // $expected = 'idx "*=>[KNN 100 @vec $BLOB]" PARAMS 2 BLOB "\12\a9\f5\6c"';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN 100 @vec $BLOB] PARAMS 2 BLOB \12\a9\f5\6c';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector(new Builder\RawElement('*'), 100, '@vec', '$BLOB')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample2(): void
+    {
+        // $expected = 'idx "*=>[KNN 100 @vec $BLOB]" PARAMS 2 BLOB "\12\a9\f5\6c" SORTBY __vec_score';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN 100 @vec $BLOB] PARAMS 2 BLOB \12\a9\f5\6c SORTBY __vec_score';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector(new Builder\RawElement('*'), 100, 'vec', 'BLOB')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->setSortBy('__vec_score')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample3(): void
+    {
+        // $expected = 'idx "*=>[KNN $K @vec $BLOB EF_RUNTIME $EF]" PARAMS 6 BLOB "\12\a9\f5\6c" K 10 EF 150';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN $K @vec $BLOB EF_RUNTIME $EF] PARAMS 6 BLOB \12\a9\f5\6c K 10 EF 150';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $vector = new Builder\QueryElementVector(new Builder\RawElement('*'), 'K', 'vec', 'BLOB');
+        $vector->addParameter('EF_RUNTIME', '$EF');
+        $builder->addElement($vector);
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->addParam('EF', 150)
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample32(): void
+    {
+        // $expected = 'idx "*=>[KNN $K @vec $BLOB EF_RUNTIME $EF]" PARAMS 6 BLOB "\12\a9\f5\6c" K 10 EF 150';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN $K @vec $BLOB EF_RUNTIME $EF] PARAMS 6 BLOB \12\a9\f5\6c K 10 EF 150';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $vector = new Builder\QueryElementVector(new Builder\RawElement('*'), '$K', '@vec', '$BLOB');
+        $vector->addParameter('EF_RUNTIME', '$EF');
+        $builder->addElement($vector);
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->addParam('EF', 150)
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample33Error(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+
+        $vector = new Builder\QueryElementVector(new Builder\RawElement('*'), '$K', '@vec', '$BLOB');
+        $vector->addParameter('EF_RUNTIME', 'EF');
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample4(): void
+    {
+        // $expected = 'idx "*=>[KNN $K @vec $BLOB AS my_scores]" PARAMS 4 BLOB "\12\a9\f5\6c" K 10 SORTBY my_scores';
+        // Data escaping is partially handled by the Redis lib, + this lib put extra safety ()
+        $expected = 'idx (*)=>[KNN $K @vec $BLOB AS my_scores] PARAMS 4 BLOB \12\a9\f5\6c K 10 SORTBY my_scores';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector(new Builder\RawElement('*'), 'K', 'vec', 'BLOB', 'my_scores')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->setSortBy('my_scores')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample5(): void
+    {
+        // $expected = 'idx "(@title:Dune @num:[2020 2022])=>[KNN $K @vec $BLOB AS my_scores]" PARAMS 4 BLOB "\12\a9\f5\6c" K 10 SORTBY my_scores';
+        // Data escaping is partially handled by the Redis lib
+        $expected = 'idx (@title:Dune @num:[2020 2022])=>[KNN $K @vec $BLOB AS my_scores] PARAMS 4 BLOB \12\a9\f5\6c K 10 SORTBY my_scores';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $group = new Builder\AndGroup([
+            new Builder\TextFacet(['title'], 'Dune'),
+            new Builder\NumericFacet(['num'], 2020, 2022),
+        ]);
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector($group, 'K', 'vec', 'BLOB', 'my_scores')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->setSortBy('my_scores')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+
+    /**
+     * @see https://redis.io/docs/stack/search/reference/vectors/#examples-for-querying-vector-fields
+     */
+    public function testVectorDocExample6(): void
+    {
+        // $expected = 'idx "(@type:{shirt} ~@color:{blue})=>[KNN $K @vec $BLOB AS my_scores]" PARAMS 4 BLOB "\12\a9\f5\6c" K 10 SORTBY my_scores';
+        // Data escaping is partially handled by the Redis lib
+        $expected = 'idx (@type:{shirt} ~@color:{blue})=>[KNN $K @vec $BLOB AS my_scores] PARAMS 4 BLOB \12\a9\f5\6c K 10 SORTBY my_scores';
+        $builder = new Builder();
+        $search = new Search('2.4.0');
+
+        $group = new Builder\AndGroup([
+            new Builder\TagFacet(['type'], 'shirt'),
+            new Builder\Optional(new Builder\TagFacet(['color'], 'blue')),
+        ]);
+        $builder
+            ->addElement(
+                new Builder\QueryElementVector($group, 'K', 'vec', 'BLOB', 'my_scores')
+            )
+        ;
+        $search
+            ->setIndex('idx')
+            ->setQuery($builder->render())
+            ->addParam('BLOB', '\12\a9\f5\6c')
+            ->addParam('K', 10)
+            ->setSortBy('my_scores')
+        ;
+
+        static::assertSame($expected, implode(' ', $search->getArguments()));
+    }
+}
diff --git a/tests/Query/EscaperTest.php b/tests/Query/EscaperTest.php
new file mode 100644
index 0000000..11fdc0d
--- /dev/null
+++ b/tests/Query/EscaperTest.php
@@ -0,0 +1,147 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Query;
+
+use function count;
+
+use MacFJA\RediSearch\Query\Escaper;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Query\Escaper
+ *
+ * @internal
+ */
+class EscaperTest extends TestCase
+{
+    private const TEST_TO_FIXTURE = [
+        'testEscapeFieldName' => 'field name',
+        'testEscapeWord' => 'word',
+        'testEscapeExactMatch' => 'exact match',
+        'testEscapeFuzzy' => 'fuzzy',
+        'testEscapeNegation' => 'negation',
+        'testEscapeOptional' => 'optional',
+        'testEscapeNothing' => 'input',
+    ];
+
+    /**
+     * @var array<array<string>>
+     */
+    private $dataset = [];
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeNothing(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeNothing($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeFieldName(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeFieldName($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeWord(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeWord($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeExactMatch(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeExactMatch($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeFuzzy(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeFuzzy($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeNegation(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeNegation($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testEscapeOptional(string $input, string $escaped): void
+    {
+        static::assertSame($escaped, Escaper::escapeOptional($input));
+    }
+
+    /**
+     * @return array<array<string>>
+     */
+    public function dataProvider(string $testName): array
+    {
+        $this->buildDataset();
+
+        return array_map(static function ($row) use ($testName) {
+            return [$row['input'], $row[self::TEST_TO_FIXTURE[$testName]]];
+        }, $this->dataset);
+    }
+
+    private function buildDataset(): void
+    {
+        if (count($this->dataset) > 0) {
+            return;
+        }
+
+        $datasetPath = __DIR__.'/../fixtures/Query/EscaperFixture.csv';
+
+        if (!file_exists($datasetPath)) {
+            static::markTestSkipped('Unable to find fixture data');
+        }
+        $handle = fopen($datasetPath, 'rb');
+
+        if (false === $handle) {
+            static::markTestSkipped('Unable to load fixture data');
+        }
+
+        $header = fgetcsv($handle) ?: [];
+        fgetcsv($handle); // Description
+        while (($row = fgetcsv($handle, 0, ',', '"', '"')) !== false) {
+            $this->dataset[] = array_combine($header, $row ?: []) ?: [];
+        }
+        fclose($handle);
+
+        if (0 === count($this->dataset)) {
+            static::markTestSkipped('Unable to load fixture data');
+        }
+    }
+}
diff --git a/tests/Redis/Client/AbstractClientTest.php b/tests/Redis/Client/AbstractClientTest.php
new file mode 100644
index 0000000..c4989df
--- /dev/null
+++ b/tests/Redis/Client/AbstractClientTest.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Client;
+
+use MacFJA\RediSearch\tests\fixtures\Redis\Client\FakeAbstractClient;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Client\AbstractClient
+ *
+ * @internal
+ */
+class AbstractClientTest extends TestCase
+{
+    public function testGetMissingMessage(): void
+    {
+        $fake = new FakeAbstractClient();
+
+        static::assertSame(
+            'The dependency foo is missing.'.PHP_EOL.'Install the dependency or use a polyfill.',
+            $fake->exposeGetMissingMessage('foo', false, [])
+        );
+
+        static::assertSame(
+            'The dependency bar is missing.'.PHP_EOL.'Install the dependency or use a polyfill that provide the class "baz" and the method "baz::hello".',
+            $fake->exposeGetMissingMessage('bar', false, ['baz' => ['hello']])
+        );
+
+        static::assertSame(
+            'The dependency baz is missing.'.PHP_EOL.'Install the dependency or use a polyfill that provide the function "foobar".',
+            $fake->exposeGetMissingMessage('baz', false, [], ['foobar'])
+        );
+
+        static::assertSame(
+            'The dependency DEP is missing.'.PHP_EOL.'Install the dependency or use a polyfill that provide the classes "C", "CL" and the methods "C::m", "C::e", "CL::t", "CL::h" and the functions "f", "u".',
+            $fake->exposeGetMissingMessage('DEP', false, ['C' => ['m', 'e'], 'CL' => ['t', 'h']], ['f', 'u'])
+        );
+
+        static::assertSame(
+            'The extension foobar is missing.'.PHP_EOL.'Install the extension or use a polyfill.',
+            $fake->exposeGetMissingMessage('foobar', true, [])
+        );
+    }
+}
diff --git a/tests/Redis/Client/ClientFacadeTest.php b/tests/Redis/Client/ClientFacadeTest.php
new file mode 100644
index 0000000..ae5e1cd
--- /dev/null
+++ b/tests/Redis/Client/ClientFacadeTest.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Client;
+
+use function get_class;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Client\ClientFacade;
+use MacFJA\RediSearch\Redis\Command;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+use stdClass;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Client\ClientFacade
+ *
+ * @uses \MacFJA\RediSearch\Redis\Client\AbstractClient
+ * @uses \MacFJA\RediSearch\Redis\Client\AmpRedisClient
+ * @uses \MacFJA\RediSearch\Redis\Client\CheprasovRedisClient
+ * @uses \MacFJA\RediSearch\Redis\Client\PredisClient
+ * @uses \MacFJA\RediSearch\Redis\Client\RedisentClient
+ * @uses \MacFJA\RediSearch\Redis\Client\RediskaClient
+ * @uses \MacFJA\RediSearch\Redis\Client\CredisClient
+ * @uses \MacFJA\RediSearch\Redis\Client\TinyRedisClient
+ *
+ * @internal
+ */
+class ClientFacadeTest extends TestCase
+{
+    public function testGetClientError(): void
+    {
+        $this->expectException(RuntimeException::class);
+        $this->expectExceptionMessage('Unable to handle the Redis connection');
+
+        $facade = new ClientFacade();
+        $facade->getClient(new stdClass());
+    }
+
+    public function testAddFactory(): void
+    {
+        $facade = new ClientFacade();
+        static::assertFalse($facade->addFactory('Foobar'));
+
+        static::assertTrue($facade->addFactory(get_class(new class() implements Client {
+            public static function make($redis): Client
+            {
+                return new self();
+            }
+
+            public function execute(Command $command): void
+            {
+                // Do nothing
+            }
+
+            public function executeRaw(...$args): void
+            {
+                // Do nothing
+            }
+
+            public function pipeline(Command ...$commands): array
+            {
+                return [];
+            }
+
+            public static function supports($redis): bool
+            {
+                return false;
+            }
+        })));
+    }
+}
diff --git a/tests/Redis/Command/AbstractCommandTest.php b/tests/Redis/Command/AbstractCommandTest.php
new file mode 100644
index 0000000..85da6bf
--- /dev/null
+++ b/tests/Redis/Command/AbstractCommandTest.php
@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\FakeAbstractCommand;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class AbstractCommandTest extends TestCase
+{
+    public function testGetRediSearchVersion(): void
+    {
+        $command = new FakeAbstractCommand([]);
+
+        static::assertSame('2.0.0', $command->getRediSearchVersion());
+        $command->setRediSearchVersion('2.2.0');
+        static::assertSame('2.2.0', $command->getRediSearchVersion());
+    }
+
+    public function testMissingRequirements(): void
+    {
+        $command = new FakeAbstractCommand(['req' => new NamelessOption()]);
+        $command->setRequiredOptions(['req']);
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Missing command option');
+
+        $command->getArguments();
+    }
+
+    public function testWrongOptionType(): void
+    {
+        $command = new FakeAbstractCommand(['req' => 10]);
+        $command->setRequiredOptions(['req']);
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Missing command option');
+
+        $command->getArguments();
+    }
+}
diff --git a/tests/Redis/Command/AddFieldOptionTraitTest.php b/tests/Redis/Command/AddFieldOptionTraitTest.php
new file mode 100644
index 0000000..118d576
--- /dev/null
+++ b/tests/Redis/Command/AddFieldOptionTraitTest.php
@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use BadMethodCallException;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\FakeAddFieldOptionTraitClass1;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\FakeAddFieldOptionTraitClass2;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class AddFieldOptionTraitTest extends TestCase
+{
+    public function testWrongParent(): void
+    {
+        $command = new FakeAddFieldOptionTraitClass1();
+
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('This method is not callable in '.FakeAddFieldOptionTraitClass1::class);
+
+        $command->addNumericField('foo');
+    }
+
+    public function testWrongParent2(): void
+    {
+        $command = new FakeAddFieldOptionTraitClass1();
+
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('This method is not callable in '.FakeAddFieldOptionTraitClass1::class);
+
+        $command->addJSONNumericField('$.foo', 'foo');
+    }
+
+    public function testValidParent(): void
+    {
+        $command = new FakeAddFieldOptionTraitClass2([]);
+
+        $command->addNumericField('foo');
+
+        static::assertTrue(true);
+    }
+}
diff --git a/tests/Redis/Command/AggregateTest.php b/tests/Redis/Command/AggregateTest.php
new file mode 100644
index 0000000..45a840f
--- /dev/null
+++ b/tests/Redis/Command/AggregateTest.php
@@ -0,0 +1,240 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Aggregate
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\ApplyOption
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\LimitOption
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\SortByOption
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\WithCursor
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ *
+ * @internal
+ */
+class AggregateTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Aggregate();
+        static::assertSame('FT.AGGREGATE', $command->getId());
+    }
+
+    public function testGetIndex(): void
+    {
+        $command = new Aggregate();
+        $command->setIndex('idx');
+        static::assertSame('idx', $command->getIndex());
+    }
+
+    public function testMultiReduce(): void
+    {
+        $group = new GroupByOption(['@text1']);
+        $group
+            ->addReducer(ReduceOption::countDistinct('user_id'))
+            ->addReducer(ReduceOption::average('num1', 'average'))
+        ;
+        $command = new Aggregate();
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->addGroupBy($group)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'GROUPBY', 1, '@text1', 'REDUCE', 'COUNT_DISTINCT', 1, '@user_id', 'REDUCE', 'AVG', 1, '@num1', 'AS', 'average',
+        ], $command->getArguments());
+    }
+
+    public function testWithCursor(): void
+    {
+        $command = new Aggregate();
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setWithCursor(20, 30)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'WITHCURSOR', 'COUNT', 20, 'MAXIDLE', 30,
+        ], $command->getArguments());
+
+        $command->setWithoutCursor();
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+        ], $command->getArguments());
+    }
+
+    public function testSizeOptions(): void
+    {
+        $command = new Aggregate();
+        static::assertNull($command->getSize());
+        static::assertNull($command->getOffset());
+
+        $command->setLimit(0, 10);
+        static::assertSame(0, $command->getOffset());
+        static::assertSame(10, $command->getSize());
+    }
+
+    public function testLoadAll(): void
+    {
+        $command = new Aggregate('2.0.12');
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setLoadAll()
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+        ], $command->getArguments());
+
+        $command = new Aggregate('2.0.13');
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setLoadAll()
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'LOAD', 'ALL',
+        ], $command->getArguments());
+
+        $command = new Aggregate('2.0.13');
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setLoad('foo', 'bar')
+            ->setLoadAll()
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'LOAD', 'ALL',
+        ], $command->getArguments());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Aggregate(Aggregate::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setVerbatim()
+            ->setLoad('@geo1', '@tag1')
+            ->addGroupBy(new GroupByOption(['@text1'], [ReduceOption::countDistinct('user_id'), ReduceOption::average('num1', 'average')]))
+            ->addFilter("@name=='foo' && @age < 20")
+            ->addSortBy('@text1')
+            ->addSortBy('@num1', 'DESC')
+            ->setSortByMax(10)
+            ->setLimit(12, 35)
+            ->addApply('@timestamp - (@timestamp % 86400)', 'day')
+            ->setWithCursor(20, 30)
+            ->setDialect(2)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'VERBATIM',
+            'LOAD', 2, '@geo1', '@tag1',
+            'GROUPBY', 1, '@text1', 'REDUCE', 'COUNT_DISTINCT', 1, '@user_id', 'REDUCE', 'AVG', 1, '@num1', 'AS', 'average',
+            'APPLY', '@timestamp - (@timestamp % 86400)', 'AS', 'day',
+            'SORTBY', 3, '@text1', '@num1', 'DESC', 'MAX', 10,
+            'LIMIT', 12, 35,
+            'FILTER', "@name=='foo' && @age < 20",
+            'WITHCURSOR', 'COUNT', 20, 'MAXIDLE', 30,
+            'DIALECT', 2,
+        ], $command->getArguments());
+    }
+
+    public function testApplyOrder(): void
+    {
+        $command = new Aggregate();
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setVerbatim()
+            ->setLoad('@geo1', '@tag1')
+            ->addApply('split(@tag1)', 'tag2')
+            ->addGroupBy(new GroupByOption(['@text1'], [ReduceOption::countDistinct('user_id'), ReduceOption::average('num1', 'average')]))
+            ->addFilter("@name=='foo' && @age < 20")
+            ->addApply('@age - 1', 'age')
+            ->addSortBy('@text1')
+            ->addSortBy('@num1', 'DESC')
+            ->setSortByMax(10)
+            ->setLimit(12, 35)
+            ->setWithCursor(20, 30)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'VERBATIM',
+            'LOAD', 2, '@geo1', '@tag1',
+            'APPLY', 'split(@tag1)', 'AS', 'tag2',
+            'GROUPBY', 1, '@text1', 'REDUCE', 'COUNT_DISTINCT', 1, '@user_id', 'REDUCE', 'AVG', 1, '@num1', 'AS', 'average',
+            'APPLY', '@age - 1', 'AS', 'age',
+            'SORTBY', 3, '@text1', '@num1', 'DESC', 'MAX', 10,
+            'LIMIT', 12, 35,
+            'FILTER', "@name=='foo' && @age < 20",
+            'WITHCURSOR', 'COUNT', 20, 'MAXIDLE', 30,
+        ], $command->getArguments());
+    }
+
+    public function testResponseError(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $command = new Aggregate();
+        $command->parseResponse(false);
+    }
+}
diff --git a/tests/Redis/Command/AggregationCommand/ReduceOptionTest.php b/tests/Redis/Command/AggregationCommand/ReduceOptionTest.php
new file mode 100644
index 0000000..56f5ae6
--- /dev/null
+++ b/tests/Redis/Command/AggregationCommand/ReduceOptionTest.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\AggregationCommand;
+
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class ReduceOptionTest extends TestCase
+{
+    public function testShorthands(): void
+    {
+        static::assertSame(['REDUCE', 'COUNT', 0, 'AS', 'count'], ReduceOption::count('count')->render());
+        static::assertSame(['REDUCE', 'COUNT_DISTINCT', 1, '@name', 'AS', 'distinct'], ReduceOption::countDistinct('name', 'distinct')->render());
+        static::assertSame(['REDUCE', 'COUNT_DISTINCTISH', 1, '@city', 'AS', 'cities_count'], ReduceOption::countDistinctish('city', 'cities_count')->render());
+        static::assertSame(['REDUCE', 'SUM', 1, '@delta', 'AS', 'total'], ReduceOption::sum('delta', 'total')->render());
+        static::assertSame(['REDUCE', 'MIN', 1, '@distance', 'AS', 'shortest'], ReduceOption::minimum('distance', 'shortest')->render());
+        static::assertSame(['REDUCE', 'MAX', 1, '@distance', 'AS', 'longest'], ReduceOption::maximum('distance', 'longest')->render());
+        static::assertSame(['REDUCE', 'AVG', 1, '@age', 'AS', 'avg'], ReduceOption::average('age', 'avg')->render());
+        static::assertSame(['REDUCE', 'STDDEV', 1, '@fare', 'AS', 'deviation'], ReduceOption::standardDeviation('fare', 'deviation')->render());
+        static::assertSame(['REDUCE', 'QUANTILE', 2, '@num', 0.25, 'AS', 'quarter'], ReduceOption::quantile('num', 0.25, 'quarter')->render());
+        static::assertSame(['REDUCE', 'QUANTILE', 2, '@num', 0.5, 'AS', 'median'], ReduceOption::median('num', 'median')->render());
+        static::assertSame(['REDUCE', 'TOLIST', 1, '@year', 'AS', 'years'], ReduceOption::toList('year', 'years')->render());
+        static::assertSame(['REDUCE', 'FIRST_VALUE', 4, '@name', 'BY', '@age', 'DESC', 'AS', 'n'], ReduceOption::firstValue('name', 'age', false, 'n')->render());
+    }
+
+    public function testGetOptionData(): void
+    {
+        static::assertSame([
+            'function' => 'FIRST_VALUE',
+            'arguments' => ['@name', 'BY', '@age', 'DESC'],
+            'alias' => 'n',
+        ], ReduceOption::firstValue('name', 'age', false, 'n')->getOptionData());
+    }
+}
diff --git a/tests/Index/Builder/NumericFieldTest.php b/tests/Redis/Command/AliasAddTest.php
similarity index 59%
rename from tests/Index/Builder/NumericFieldTest.php
rename to tests/Redis/Command/AliasAddTest.php
index 42cce84..e557619 100644
--- a/tests/Index/Builder/NumericFieldTest.php
+++ b/tests/Redis/Command/AliasAddTest.php
@@ -19,31 +19,38 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace Tests\MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\tests\Redis\Command;
 
-use MacFJA\RediSearch\Index\Builder\NumericField;
+use MacFJA\RediSearch\Redis\Command\AliasAdd;
 use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
 
 /**
- * @covers \MacFJA\RediSearch\Index\Builder\NumericField
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\AliasAdd
  *
- * @uses \MacFJA\RediSearch\Index\Builder\AbstractField
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
  */
-class NumericFieldTest extends TestCase
+class AliasAddTest extends TestCase
 {
-    use Assertion;
-
-    public function testNominal(): void
+    public function testGetId(): void
     {
-        self::assertSameQuery('age NUMERIC SORTABLE', new NumericField('age', true));
-        self::assertSameQuery('weight NUMERIC NOINDEX', new NumericField('weight', false, true));
-        self::assertSameQuery('speed NUMERIC SORTABLE NOINDEX', new NumericField('speed', true, true));
+        $command = new AliasAdd();
+        static::assertSame('FT.ALIASADD', $command->getId());
     }
 
-    public function testGetType(): void
+    public function testFullOption(): void
     {
-        self::assertSame('NUMERIC', (new NumericField('foobar'))->getType());
+        $command = new AliasAdd();
+        $command
+            ->setIndex('idx')
+            ->setAlias('current')
+        ;
+
+        static::assertSame([
+            'current', 'idx',
+        ], $command->getArguments());
     }
 }
diff --git a/tests/Redis/Command/AliasDelTest.php b/tests/Redis/Command/AliasDelTest.php
new file mode 100644
index 0000000..c181d0b
--- /dev/null
+++ b/tests/Redis/Command/AliasDelTest.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\AliasDel;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\AliasDel
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class AliasDelTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new AliasDel();
+        static::assertSame('FT.ALIASDEL', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new AliasDel();
+        $command
+            ->setAlias('current')
+        ;
+
+        static::assertSame([
+            'current',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/AliasUpdateTest.php b/tests/Redis/Command/AliasUpdateTest.php
new file mode 100644
index 0000000..025ed6a
--- /dev/null
+++ b/tests/Redis/Command/AliasUpdateTest.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\AliasUpdate;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\AliasUpdate
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class AliasUpdateTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new AliasUpdate();
+        static::assertSame('FT.ALIASUPDATE', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new AliasUpdate();
+        $command
+            ->setIndex('idx')
+            ->setAlias('current')
+        ;
+
+        static::assertSame([
+            'current', 'idx',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/AlterTest.php b/tests/Redis/Command/AlterTest.php
new file mode 100644
index 0000000..78e01f3
--- /dev/null
+++ b/tests/Redis/Command/AlterTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Alter;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Alter
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class AlterTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Alter();
+        static::assertSame('FT.ALTER', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Alter();
+        $command
+            ->setIndex('idx')
+            ->addNumericField('id2', true, false)
+        ;
+
+        static::assertSame([
+            'idx', 'SCHEMA', 'ADD', 'id2', 'NUMERIC', 'SORTABLE',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/ConfigSetTest.php b/tests/Redis/Command/ConfigSetTest.php
new file mode 100644
index 0000000..c5d4143
--- /dev/null
+++ b/tests/Redis/Command/ConfigSetTest.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\ConfigSet;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\ConfigSet
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class ConfigSetTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new ConfigSet();
+        static::assertSame('FT.CONFIG', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new ConfigSet();
+        $command->setConfig('TIMEOUT', 3);
+        static::assertSame(['SET', 'TIMEOUT', 3], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/ConfigTest.php b/tests/Redis/Command/ConfigTest.php
new file mode 100644
index 0000000..89baf75
--- /dev/null
+++ b/tests/Redis/Command/ConfigTest.php
@@ -0,0 +1,72 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Config;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Config
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class ConfigTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Config();
+        static::assertSame('FT.CONFIG', $command->getId());
+    }
+
+    public function testGetAll(): void
+    {
+        $command = new Config();
+        $command->setGetMode();
+        static::assertSame(['GET', '*'], $command->getArguments());
+    }
+
+    public function testGetOne(): void
+    {
+        $command = new Config();
+        $command->setGetMode('foo');
+        static::assertSame(['GET', 'foo'], $command->getArguments());
+    }
+
+    public function testHelpAll(): void
+    {
+        $command = new Config();
+        $command->setHelpMode();
+        static::assertSame(['HELP', '*'], $command->getArguments());
+    }
+
+    public function testHelpOne(): void
+    {
+        $command = new Config();
+        $command->setHelpMode('foo');
+        static::assertSame(['HELP', 'foo'], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/CreateCommand/JSONFieldOptionTest.php b/tests/Redis/Command/CreateCommand/JSONFieldOptionTest.php
new file mode 100644
index 0000000..e78a54f
--- /dev/null
+++ b/tests/Redis/Command/CreateCommand/JSONFieldOptionTest.php
@@ -0,0 +1,74 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\CreateCommand;
+
+use MacFJA\RediSearch\Exception\InvalidJSONPathException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @internal
+ *
+ * @covers \MacFJA\RediSearch\Exception\InvalidJSONPathException
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption
+ */
+class JSONFieldOptionTest extends TestCase
+{
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testIsPathValid(string $input, bool $expected): void
+    {
+        static::assertEquals($expected, JSONFieldOption::isPathValid($input));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testIsPathValidException(string $input, bool $expected): void
+    {
+        $this->expectException(InvalidJSONPathException::class);
+        static::assertEquals($expected, JSONFieldOption::isPathValid($input));
+    }
+
+    /**
+     * @return array<string, array<bool|string>>
+     */
+    public function dataProvider(string $testName): array
+    {
+        if ('testIsPathValid' === $testName) {
+            return [
+                '2nd element of root' => ['$.[1]', true],
+                '2nd element' => ['$[1]', true],
+                'multilevel' => ['$.basic.dict.new_child_1', true],
+                'empty 2' => [' ', false],
+                'empty' => ['', false],
+                'not close 2' => ['$[', false],
+                'missing dollar' => ['.basic', false],
+            ];
+        }
+
+        return [
+            'not close 1' => ['$(', false],
+        ];
+    }
+}
diff --git a/tests/Redis/Command/CreateCommand/VectorFieldOptionTest.php b/tests/Redis/Command/CreateCommand/VectorFieldOptionTest.php
new file mode 100644
index 0000000..258f6f3
--- /dev/null
+++ b/tests/Redis/Command/CreateCommand/VectorFieldOptionTest.php
@@ -0,0 +1,176 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\CreateCommand;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption;
+use OutOfRangeException;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\OptionListOption
+ *
+ * @internal
+ */
+class VectorFieldOptionTest extends TestCase
+{
+    public function testGetFieldName(): void
+    {
+        $option = new VectorFieldOption();
+        $option->setField('foo');
+
+        static::assertSame('foo', $option->getFieldName());
+    }
+
+    public function testAddFlatAttributeWrongAlgorithm(): void
+    {
+        $this->expectException(RuntimeException::class);
+        $option = new VectorFieldOption();
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_HNSW);
+        $option->addFlatAttribute(VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_IP);
+    }
+
+    public function testAddFlatAttribute(): void
+    {
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_FLAT);
+        $option->addFlatAttribute(VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_IP);
+        static::assertSame(['vec', 'VECTOR', 'FLAT', 6, 'TYPE', 'FLOAT32', 'DIM', 10, 'DISTANCE_METRIC', 'IP'], $option->render('2.4.0'));
+
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_FLAT);
+        $option->addFlatAttribute(VectorFieldOption::TYPE_FLOAT32, 128, VectorFieldOption::DISTANCE_METRIC_COSINE, 1000);
+        static::assertSame(['vec', 'VECTOR', 'FLAT', 8, 'TYPE', 'FLOAT32', 'DIM', 128, 'DISTANCE_METRIC', 'COSINE', 'INITIAL_CAP', 1000], $option->render('2.4.0'));
+
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_FLAT);
+        $option->addFlatAttribute(VectorFieldOption::TYPE_FLOAT32, 128, VectorFieldOption::DISTANCE_METRIC_COSINE, 1000, 10);
+        static::assertSame(['vec', 'VECTOR', 'FLAT', 10, 'TYPE', 'FLOAT32', 'DIM', 128, 'DISTANCE_METRIC', 'COSINE', 'INITIAL_CAP', 1000, 'BLOCK_SIZE', 10], $option->render('2.4.0'));
+    }
+
+    public function testAddHnswAttributeWrongAlgorithm(): void
+    {
+        $this->expectException(RuntimeException::class);
+        $option = new VectorFieldOption();
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_FLAT);
+        $option->addHnswAttribute(VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_L2);
+    }
+
+    public function testAddHnswAttribute(): void
+    {
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_HNSW);
+        $option->addHnswAttribute(VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_IP);
+        static::assertSame(['vec', 'VECTOR', 'HNSW', 6, 'TYPE', 'FLOAT32', 'DIM', 10, 'DISTANCE_METRIC', 'IP'], $option->render('2.4.0'));
+
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm(VectorFieldOption::ALGORITHM_HNSW);
+        $option->addHnswAttribute(
+            VectorFieldOption::TYPE_FLOAT32,
+            10,
+            VectorFieldOption::DISTANCE_METRIC_IP,
+            1000,
+            10,
+            500,
+            50
+        );
+        static::assertSame(['vec', 'VECTOR', 'HNSW', 14, 'TYPE', 'FLOAT32', 'DIM', 10, 'DISTANCE_METRIC', 'IP', 'INITIAL_CAP', 1000, 'M', 10, 'EF_CONSTRUCTION', 500, 'EF_RUNTIME', 50], $option->render('2.4.0'));
+    }
+
+    /**
+     * @phpstan-param class-string<\Throwable> $expectedErrorClass
+     *
+     * @dataProvider validateArgumentDataProvider
+     */
+    public function testValidateArguments(string $expectedErrorClass, string $algo, string $type, int $dimension, string $distanceMetric, ?int $initialCapacity, ?int $blockSize, ?int $maxEdge, ?int $efConstruction, ?int $efRuntime): void
+    {
+        $this->expectException($expectedErrorClass);
+        $option = new VectorFieldOption();
+        $option->setField('vec');
+        $option->setAlgorithm($algo);
+        $option->addAttribute($type, $dimension, $distanceMetric, $initialCapacity, $blockSize, $maxEdge, $efConstruction, $efRuntime);
+    }
+
+    /**
+     * @return array<array<int|string>>
+     */
+    public function validateArgumentDataProvider(): array
+    {
+        $rangeDefault = [
+            0 => OutOfRangeException::class, // $expectedErrorClass
+            1 => VectorFieldOption::ALGORITHM_HNSW, // $algo
+            2 => VectorFieldOption::TYPE_FLOAT32, // $type
+            3 => 10, // $dimension
+            4 => VectorFieldOption::DISTANCE_METRIC_L2, // $distanceMetric
+            5 => 100, // $initialCapacity
+            6 => 10, // $blockSize
+            7 => 20, // $maxEdge
+            8 => 200, // $efConstruction
+            9 => 100, // $efRuntime
+        ];
+        $invalidDefault = [
+            0 => InvalidArgumentException::class, // $expectedErrorClass
+            1 => VectorFieldOption::ALGORITHM_HNSW, // $algo
+            2 => VectorFieldOption::TYPE_FLOAT32, // $type
+            3 => 10, // $dimension
+            4 => VectorFieldOption::DISTANCE_METRIC_L2, // $distanceMetric
+            5 => 100, // $initialCapacity
+            6 => 10, // $blockSize
+            7 => 20, // $maxEdge
+            8 => 200, // $efConstruction
+            9 => 100, // $efRuntime
+        ];
+
+        $sort = static function (array $source): array {
+            ksort($source);
+
+            return $source;
+        };
+
+        return [
+            $sort([2 => 'foo'] + $invalidDefault),
+            $sort([3 => 0] + $rangeDefault),
+            $sort([4 => 'bar'] + $invalidDefault),
+            $sort([5 => 0] + $rangeDefault),
+            $sort([6 => 0, 1 => VectorFieldOption::ALGORITHM_FLAT] + $rangeDefault),
+            $sort([7 => -1] + $rangeDefault),
+            $sort([8 => 0] + $rangeDefault),
+            $sort([9 => 0] + $rangeDefault),
+        ];
+    }
+}
diff --git a/tests/Redis/Command/CreateTest.php b/tests/Redis/Command/CreateTest.php
new file mode 100644
index 0000000..92cda82
--- /dev/null
+++ b/tests/Redis/Command/CreateTest.php
@@ -0,0 +1,272 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\Create;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Create
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\VectorFieldOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\OptionListOption
+ *
+ * @internal
+ */
+class CreateTest extends TestCase
+{
+    public function testGetArguments(): void
+    {
+        $command = new Create();
+        $command->setDefaultLanguage('french')
+            ->setIndex('idx')
+            ->setPrefixes('document_', 'doc_')
+            ->addTextField('title', false, null, null, true)
+            ->addTextField('subtitle', true, null, null, false, true)
+            ->addGeoField('city')
+            ->addNumericField('views', true)
+        ;
+
+        static::assertSame([
+            'idx',
+            'PREFIX', 2, 'document_', 'doc_',
+            'LANGUAGE', 'french',
+            'SCHEMA',
+            'title', 'TEXT', 'SORTABLE',
+            'subtitle', 'TEXT', 'NOSTEM', 'NOINDEX',
+            'city', 'GEO',
+            'views', 'NUMERIC', 'SORTABLE',
+        ], $command->getArguments());
+    }
+
+    public function testGetId(): void
+    {
+        $command = new Create();
+        static::assertSame('FT.CREATE', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Create(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->addNumericField('num1')
+            ->addNumericField('num2', false, true)
+            ->addNumericField('num3', true, false)
+            ->addNumericField('num4', true, true)
+            ->addGeoField('geo1')
+            ->addGeoField('geo2', true)
+            ->addTextField('text1')
+            ->addTextField('text2', true)
+            ->addTextField('text3', false, 1.0)
+            ->addTextField('text4', false, null, 'dm:en')
+            ->addTextField('text5', false, null, null, true)
+            ->addTextField('text6', false, null, null, false, true)
+            ->addTextField('text7', true, 1.0, 'dm:fr', true, true)
+            ->addTagField('tag1')
+            ->addTagField('tag2', '|')
+            ->addTagField('tag3', null, true)
+            ->addTagField('tag4', null, false, true)
+            ->addTagField('tag5', null, false, false, true)
+            ->addTagField('tag6', '#', true, true, true)
+            ->addVectorField('vec', VectorFieldOption::ALGORITHM_FLAT, VectorFieldOption::TYPE_FLOAT32, 10, VectorFieldOption::DISTANCE_METRIC_L2)
+            ->addField(
+                (new VectorFieldOption())
+                    ->setField('vector_field')
+                    ->setAlgorithm(VectorFieldOption::ALGORITHM_HNSW)
+                    ->addHnswAttribute(VectorFieldOption::TYPE_FLOAT32, 128, VectorFieldOption::DISTANCE_METRIC_L2, 1000000, 40, 250, 20)
+            )
+            ->setPrefixes('doc_', 'document_')
+            ->setIndex('idx')
+            ->setDefaultLanguage('french')
+            ->setDefaultScore(1.5)
+            ->setFilter('startswith(@text1, "G")')
+            ->setLanguageField('text6')
+            ->setMaxTextFields(true)
+            ->setNoFields(true)
+            ->setNoFrequencies(true)
+            ->setNoHighLight(true)
+            ->setNoOffsets(true)
+            ->setPayloadField('text4')
+            ->setScoreField('num1')
+            ->setSkipInitialScan(true)
+            ->setStopWords('the', 'a')
+            ->setTemporary(3600)
+            ->setStructure('HASH')
+        ;
+
+        static::assertSame([
+            'idx',
+            'ON', 'HASH',
+            'PREFIX', 2, 'doc_', 'document_',
+            'FILTER', 'startswith(@text1, "G")',
+            'LANGUAGE', 'french',
+            'LANGUAGE_FIELD', 'text6',
+            'SCORE', 1.5,
+            'SCORE_FIELD', 'num1',
+            'PAYLOAD_FIELD', 'text4',
+            'MAXTEXTFIELDS', 'TEMPORARY', 3600, 'NOOFFSETS', 'NOHL', 'NOFIELDS', 'NOFREQS', 'SKIPINITIALSCAN',
+            'STOPWORDS', 2, 'the', 'a',
+            'SCHEMA',
+            'num1', 'NUMERIC',
+            'num2', 'NUMERIC', 'NOINDEX',
+            'num3', 'NUMERIC', 'SORTABLE',
+            'num4', 'NUMERIC', 'SORTABLE', 'NOINDEX',
+            'geo1', 'GEO',
+            'geo2', 'GEO', 'NOINDEX',
+            'text1', 'TEXT',
+            'text2', 'TEXT', 'NOSTEM',
+            'text3', 'TEXT', 'WEIGHT', 1.0,
+            'text4', 'TEXT', 'PHONETIC', 'dm:en',
+            'text5', 'TEXT', 'SORTABLE',
+            'text6', 'TEXT', 'NOINDEX',
+            'text7', 'TEXT', 'NOSTEM', 'WEIGHT', 1.0, 'PHONETIC', 'dm:fr', 'SORTABLE', 'NOINDEX',
+            'tag1', 'TAG',
+            'tag2', 'TAG', 'SEPARATOR', '|',
+            'tag3', 'TAG', 'SORTABLE',
+            'tag4', 'TAG', 'NOINDEX',
+            'tag5', 'TAG', 'CASESENSITIVE',
+            'tag6', 'TAG', 'SEPARATOR', '#', 'CASESENSITIVE', 'SORTABLE', 'NOINDEX',
+            'vec', 'VECTOR', 'FLAT', 6, 'TYPE', 'FLOAT32', 'DIM', 10, 'DISTANCE_METRIC', 'L2',
+            'vector_field', 'VECTOR', 'HNSW', 14, 'TYPE', 'FLOAT32', 'DIM', 128, 'DISTANCE_METRIC', 'L2', 'INITIAL_CAP', 1000000, 'M', 40, 'EF_CONSTRUCTION', 250, 'EF_RUNTIME', 20,
+        ], $command->getArguments());
+    }
+
+    public function testNoStopWords(): void
+    {
+        $command = new Create();
+        $command->setIndex('idx')
+            ->addTextField('foo')
+            ->setNoStopWords()
+        ;
+
+        static::assertSame(['idx', 'STOPWORDS', 0, 'SCHEMA', 'foo', 'TEXT'], $command->getArguments());
+    }
+
+    public function testUnNormalizedForm(): void
+    {
+        $command = new Create('2.0.0');
+        $command->setIndex('idx');
+
+        $textField = new TextFieldOption();
+        $textField->setField('foo')
+            ->setSortable(true)
+            ->setUnNormalizedSortable(true)
+        ;
+
+        $command->addField($textField);
+
+        static::assertSame(['idx', 'SCHEMA', 'foo', 'TEXT', 'SORTABLE'], $command->getArguments());
+
+        // Change version
+        $command->setRediSearchVersion('2.0.12');
+        static::assertSame(['idx', 'SCHEMA', 'foo', 'TEXT', 'SORTABLE', 'UNF'], $command->getArguments());
+
+        // Remove sorting
+        $textField->setSortable(false);
+        static::assertSame(['idx', 'SCHEMA', 'foo', 'TEXT'], $command->getArguments());
+    }
+
+    public function testJSONStructure(): void
+    {
+        $expected = [
+            'userIdx', 'ON', 'JSON', 'SCHEMA', '$.user.name', 'AS', 'name', 'TEXT', '$.user.tag', 'AS', 'country', 'TAG',
+        ];
+        $command = new Create('2.2.0');
+        $command
+            ->setIndex('userIdx')
+            ->setStructure('JSON')
+            ->addField(new JSONFieldOption('$.user.name', (new TextFieldOption())->setField('name')))
+            ->addField(new JSONFieldOption('$.user.tag', (new TagFieldOption())->setField('country')))
+        ;
+
+        static::assertSame($expected, $command->getArguments());
+    }
+
+    public function testJSONStructureShortHand(): void
+    {
+        $expected = [
+            'userIdx', 'ON', 'JSON', 'SCHEMA', '$.user.name', 'AS', 'name', 'TEXT', '$.user.tag', 'AS', 'country', 'TAG',
+        ];
+        $command = new Create('2.2.0');
+        $command
+            ->setIndex('userIdx')
+            ->setStructure('JSON')
+            ->addJSONField('$.user.name', (new TextFieldOption())->setField('name'))
+            ->addJSONField('$.user.tag', (new TagFieldOption())->setField('country'))
+        ;
+
+        static::assertSame($expected, $command->getArguments());
+    }
+
+    public function testJSONStructureShortHand2(): void
+    {
+        $expected = [
+            'userIdx', 'ON', 'JSON', 'SCHEMA', '$.user.name', 'AS', 'name', 'TEXT', '$.user.tag', 'AS', 'country', 'TAG',
+        ];
+        $command = new Create('2.2.0');
+        $command
+            ->setIndex('userIdx')
+            ->setStructure('JSON')
+            ->addJSONTextField('$.user.name', 'name')
+            ->addJSONTagField('$.user.tag', 'country')
+        ;
+
+        static::assertSame($expected, $command->getArguments());
+    }
+
+    public function testJSONStructureWrongVersion(): void
+    {
+        $command = new Create('2.0.0');
+        $command
+            ->setIndex('userIdx')
+            ->setStructure('JSON')
+            ->addJSONTextField('$.user.name', 'name')
+            ->addJSONTagField('$.user.tag', 'country')
+        ;
+
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('Missing command option: fields');
+        $command->getArguments();
+    }
+}
diff --git a/tests/Redis/Command/CursorDelTest.php b/tests/Redis/Command/CursorDelTest.php
new file mode 100644
index 0000000..bc9ab46
--- /dev/null
+++ b/tests/Redis/Command/CursorDelTest.php
@@ -0,0 +1,52 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\CursorDel;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\CursorDel
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ *
+ * @internal
+ */
+class CursorDelTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new CursorDel();
+        static::assertSame('FT.CURSOR', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new CursorDel();
+        $command->setIndex('idx');
+        $command->setCursorId(398);
+        static::assertSame(['DEL', 'idx', 398], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/CursorReadTest.php b/tests/Redis/Command/CursorReadTest.php
new file mode 100644
index 0000000..b2b1e18
--- /dev/null
+++ b/tests/Redis/Command/CursorReadTest.php
@@ -0,0 +1,103 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\CursorRead;
+use MacFJA\RediSearch\Redis\Response\CursorResponse;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\CursorRead
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Response\CursorResponse
+ * @uses \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ *
+ * @internal
+ */
+class CursorReadTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new CursorRead();
+        static::assertSame('FT.CURSOR', $command->getId());
+    }
+
+    public function testCountOption(): void
+    {
+        $command = new CursorRead();
+        $command->setIndex('idx');
+        $command->setCursorId(398);
+        $command->setCount(30);
+        static::assertSame(['READ', 'idx', 398, 'COUNT', 30], $command->getArguments());
+
+        $command->setNoCount();
+        static::assertSame(['READ', 'idx', 398], $command->getArguments());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new CursorRead();
+        $command->setIndex('idx');
+        $command->setCursorId(398);
+        $command->setCount(30);
+        static::assertSame(['READ', 'idx', 398, 'COUNT', 30], $command->getArguments());
+    }
+
+    public function testParseResponseWithError(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+
+        $command = new CursorRead();
+        $command->parseResponse('');
+    }
+
+    public function testParseResponse(): void
+    {
+        $rawResponse = [
+            0 => [
+                0 => 0,
+                1 => [
+                    0 => 'f1',
+                    1 => 'bar',
+                ],
+                2 => [
+                    0 => 'f1',
+                    1 => 'baz',
+                ],
+            ],
+            1 => 976015094,
+        ];
+        $command = new CursorRead();
+        $command->setIndex('idx')
+            ->setCursorId(976015094)
+        ;
+        $response = $command->parseResponse($rawResponse);
+
+        static::assertInstanceOf(CursorResponse::class, $response);
+    }
+}
diff --git a/tests/Redis/Command/DictAddTest.php b/tests/Redis/Command/DictAddTest.php
new file mode 100644
index 0000000..79aaefc
--- /dev/null
+++ b/tests/Redis/Command/DictAddTest.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\DictAdd;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\DictAdd
+ **
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class DictAddTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new DictAdd();
+        static::assertSame('FT.DICTADD', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new DictAdd();
+        $command
+            ->setDictionary('cities')
+            ->addTerms('paris', 'new york', 'london')
+        ;
+
+        static::assertSame([
+            'cities',
+            'paris', 'new york', 'london',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/DictDelTest.php b/tests/Redis/Command/DictDelTest.php
new file mode 100644
index 0000000..20b5a76
--- /dev/null
+++ b/tests/Redis/Command/DictDelTest.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\DictDel;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\DictDel
+ **
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class DictDelTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new DictDel();
+        static::assertSame('FT.DICTDEL', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new DictDel();
+        $command
+            ->setDictionary('cities')
+            ->addTerms('paris', 'new york', 'london')
+        ;
+
+        static::assertSame([
+            'cities',
+            'paris', 'new york', 'london',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/support/Assertion.php b/tests/Redis/Command/DictDumpTest.php
similarity index 58%
rename from tests/support/Assertion.php
rename to tests/Redis/Command/DictDumpTest.php
index 5f9cc61..1fee15d 100644
--- a/tests/support/Assertion.php
+++ b/tests/Redis/Command/DictDumpTest.php
@@ -19,30 +19,37 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace Tests\MacFJA\RediSearch\support;
+namespace MacFJA\RediSearch\tests\Redis\Command;
 
-use function implode;
-use function is_subclass_of;
-use MacFJA\RediSearch\PartialQuery;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery as RenderablePartialQuery;
+use MacFJA\RediSearch\Redis\Command\DictDump;
 use PHPUnit\Framework\TestCase;
-use RuntimeException;
 
-trait Assertion
+/**
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\DictDump
+ **
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class DictDumpTest extends TestCase
 {
-    private static function assertSameQuery(string $expected, PartialQuery $partialQuery): void
+    public function testGetId(): void
     {
-        if (!is_subclass_of(self::class, TestCase::class)) {
-            throw new RuntimeException();
-        }
-        self::assertSame($expected, implode(' ', $partialQuery->getQueryParts()));
+        $command = new DictDump();
+        static::assertSame('FT.DICTDUMP', $command->getId());
     }
 
-    private static function assertSameRender(string $expected, RenderablePartialQuery $partialQuery): void
+    public function testFullOption(): void
     {
-        if (!is_subclass_of(self::class, TestCase::class)) {
-            throw new RuntimeException();
-        }
-        self::assertSame($expected, $partialQuery->render());
+        $command = new DictDump();
+        $command
+            ->setDictionary('cities')
+        ;
+
+        static::assertSame([
+            'cities',
+        ], $command->getArguments());
     }
 }
diff --git a/tests/Redis/Command/DropIndexTest.php b/tests/Redis/Command/DropIndexTest.php
new file mode 100644
index 0000000..6e68e3f
--- /dev/null
+++ b/tests/Redis/Command/DropIndexTest.php
@@ -0,0 +1,57 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\DropIndex;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\DropIndex
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ *
+ * @internal
+ */
+class DropIndexTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new DropIndex();
+        static::assertSame('FT.DROPINDEX', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new DropIndex();
+        $command
+            ->setIndex('idx')
+            ->setDeleteDocument(true)
+        ;
+
+        static::assertSame([
+            'idx', 'DD',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/ExplainCliTest.php b/tests/Redis/Command/ExplainCliTest.php
new file mode 100644
index 0000000..f4cfb2f
--- /dev/null
+++ b/tests/Redis/Command/ExplainCliTest.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\ExplainCli;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Explain
+ * @covers \MacFJA\RediSearch\Redis\Command\ExplainCli
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class ExplainCliTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new ExplainCli();
+        static::assertSame('FT.EXPLAINCLI', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new ExplainCli(ExplainCli::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setDialect(2)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'DIALECT', 2,
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/ExplainTest.php b/tests/Redis/Command/ExplainTest.php
new file mode 100644
index 0000000..74fe20f
--- /dev/null
+++ b/tests/Redis/Command/ExplainTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Explain;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Explain
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class ExplainTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Explain();
+        static::assertSame('FT.EXPLAIN', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Explain(Explain::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setDialect(2)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'DIALECT', 2,
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/IndexListTest.php b/tests/Redis/Command/IndexListTest.php
new file mode 100644
index 0000000..74c82da
--- /dev/null
+++ b/tests/Redis/Command/IndexListTest.php
@@ -0,0 +1,48 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\IndexList;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\IndexList
+ *
+ * @internal
+ */
+class IndexListTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new IndexList();
+        static::assertSame('FT._LIST', $command->getId());
+    }
+
+    public function testGetSetRediSearchVersion(): void
+    {
+        $command = new IndexList();
+
+        static::assertSame('2.0.0', $command->getRediSearchVersion());
+        $command->setRediSearchVersion('2.2.0');
+        static::assertSame('2.2.0', $command->getRediSearchVersion());
+    }
+}
diff --git a/tests/Redis/Command/InfoTest.php b/tests/Redis/Command/InfoTest.php
new file mode 100644
index 0000000..713e0aa
--- /dev/null
+++ b/tests/Redis/Command/InfoTest.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\Info;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Info
+ **
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class InfoTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Info();
+        static::assertSame('FT.INFO', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Info();
+        $command
+            ->setIndex('idx')
+        ;
+
+        static::assertSame([
+            'idx',
+        ], $command->getArguments());
+    }
+
+    public function testParseResponseInvalid(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $command = new Info();
+        $command->parseResponse('foo');
+    }
+}
diff --git a/tests/Redis/Command/Option/AbstractCommandOptionTest.php b/tests/Redis/Command/Option/AbstractCommandOptionTest.php
new file mode 100644
index 0000000..46be7b0
--- /dev/null
+++ b/tests/Redis/Command/Option/AbstractCommandOptionTest.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\Option\FakeAbstractCommandOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class AbstractCommandOptionTest extends TestCase
+{
+    public function testIsCompatible(): void
+    {
+        $noConstraint = new FakeAbstractCommandOption();
+        static::assertTrue($noConstraint->isCompatible(null));
+        static::assertFalse($noConstraint->isCompatible('foo'));
+        static::assertTrue($noConstraint->isCompatible('1.0.0'));
+        static::assertTrue($noConstraint->isCompatible('1.5.0'));
+        static::assertTrue($noConstraint->isCompatible('2.0.0'));
+        static::assertTrue($noConstraint->isCompatible('2.5.0'));
+
+        $fixConstraint = new FakeAbstractCommandOption('1.5.0');
+        static::assertTrue($fixConstraint->isCompatible(null));
+        static::assertFalse($fixConstraint->isCompatible('foo'));
+        static::assertFalse($fixConstraint->isCompatible('1.0.0'));
+        static::assertTrue($fixConstraint->isCompatible('1.5.0'));
+        static::assertFalse($fixConstraint->isCompatible('2.0.0'));
+        static::assertFalse($fixConstraint->isCompatible('2.5.0'));
+
+        $simpleInterval = new FakeAbstractCommandOption('^1.1.0');
+        static::assertTrue($simpleInterval->isCompatible(null));
+        static::assertFalse($simpleInterval->isCompatible('foo'));
+        static::assertFalse($simpleInterval->isCompatible('1.0.0'));
+        static::assertTrue($simpleInterval->isCompatible('1.5.0'));
+        static::assertFalse($simpleInterval->isCompatible('2.0.0'));
+        static::assertFalse($simpleInterval->isCompatible('2.5.0'));
+
+        $simpleInterval2 = new FakeAbstractCommandOption('>=1.1.0');
+        static::assertTrue($simpleInterval2->isCompatible(null));
+        static::assertFalse($simpleInterval2->isCompatible('foo'));
+        static::assertFalse($simpleInterval2->isCompatible('1.0.0'));
+        static::assertTrue($simpleInterval2->isCompatible('1.5.0'));
+        static::assertTrue($simpleInterval2->isCompatible('2.0.0'));
+        static::assertTrue($simpleInterval2->isCompatible('2.5.0'));
+
+        $multiInterval = new FakeAbstractCommandOption('>=1.1.0 <=2.4');
+        static::assertTrue($multiInterval->isCompatible(null));
+        static::assertFalse($multiInterval->isCompatible('foo'));
+        static::assertFalse($multiInterval->isCompatible('1.0.0'));
+        static::assertTrue($multiInterval->isCompatible('1.5.0'));
+        static::assertTrue($multiInterval->isCompatible('2.0.0'));
+        static::assertFalse($multiInterval->isCompatible('2.5.0'));
+
+        /** @var FakeAbstractCommandOption $invalidConstraint */
+        $invalidConstraint = new FakeAbstractCommandOption('bar');
+        static::assertTrue($invalidConstraint->isCompatible(null));
+        static::assertFalse($invalidConstraint->isCompatible('foo'));
+        static::assertFalse($invalidConstraint->isCompatible('1.0.0'));
+        static::assertFalse($invalidConstraint->isCompatible('1.5.0'));
+        static::assertFalse($invalidConstraint->isCompatible('2.0.0'));
+        static::assertFalse($invalidConstraint->isCompatible('2.5.0'));
+    }
+
+    public function testGetVersionConstraint(): void
+    {
+        $noConstraint = new FakeAbstractCommandOption();
+        static::assertSame('*', $noConstraint->getVersionConstraint());
+
+        $fixConstraint = new FakeAbstractCommandOption('1.5.0');
+        static::assertSame('1.5.0', $fixConstraint->getVersionConstraint());
+
+        $simpleInterval = new FakeAbstractCommandOption('^1.1.0');
+        static::assertSame('^1.1.0', $simpleInterval->getVersionConstraint());
+    }
+}
diff --git a/tests/Redis/Command/Option/BaseOptionTestTrait.php b/tests/Redis/Command/Option/BaseOptionTestTrait.php
new file mode 100644
index 0000000..f030899
--- /dev/null
+++ b/tests/Redis/Command/Option/BaseOptionTestTrait.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+
+trait BaseOptionTestTrait
+{
+    /**
+     * @dataProvider dataProvider
+     *
+     * @param array<float|int|string> $expected
+     */
+    public function testRender(CommandOption $inputOption, array $expected): void
+    {
+        self::assertEquals($expected, $inputOption->render());
+    }
+
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testIsValid(CommandOption $inputOption, bool $expected): void
+    {
+        self::assertEquals($expected, $inputOption->isValid());
+    }
+
+    /**
+     * @dataProvider dataProvider
+     *
+     * @param mixed $expected
+     */
+    public function testGetOptionData(CommandOption $inputOption, $expected): void
+    {
+        self::assertEquals($expected, $inputOption->getOptionData());
+    }
+
+    abstract public function dataProvider(string $testName): Generator;
+}
diff --git a/tests/Redis/Command/Option/CustomValidatorOptionTest.php b/tests/Redis/Command/Option/CustomValidatorOptionTest.php
new file mode 100644
index 0000000..c1b985a
--- /dev/null
+++ b/tests/Redis/Command/Option/CustomValidatorOptionTest.php
@@ -0,0 +1,157 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use BadMethodCallException;
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use PHPUnit\Framework\TestCase;
+use Respect\Validation\Rules\Length;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class CustomValidatorOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+    use TestIsCompatibleTrait;
+
+    public function testGetValidator(): void
+    {
+        $validator = new Length(4);
+        $option = new CustomValidatorOption(new NamelessOption(), $validator);
+
+        static::assertSame($validator, $option->getValidator());
+    }
+
+    public function testShorthands(): void
+    {
+        $validNumeric = CustomValidatorOption::isNumeric(new NamelessOption('10'));
+        $invalidNumeric = CustomValidatorOption::isNumeric(new NamelessOption('foo'));
+        $validList = CustomValidatorOption::allowedValues(new NamelessOption('foo'), ['foo', 'bar']);
+        $invalidList = CustomValidatorOption::allowedValues(new NamelessOption('foobar'), ['foo', 'bar']);
+
+        static::assertTrue($validNumeric->isValid());
+        static::assertTrue($validList->isValid());
+        static::assertFalse($invalidNumeric->isValid());
+        static::assertFalse($invalidList->isValid());
+    }
+
+    public function testInvalidMethodName(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined method invalidName');
+
+        $decorator = new CustomValidatorOption(new NamelessOption('foo'), new Length(4));
+        // @phpstan-ignore-next-line
+        $decorator->invalidName();
+    }
+
+    public function testVersionConstraint(): void
+    {
+        $decorator = new CustomValidatorOption(new NamelessOption('foo'), new Length(4));
+        static::assertSame('*', $decorator->getVersionConstraint());
+        $decorator = new CustomValidatorOption(new NamelessOption('foo', '>=1.0.0'), new Length(4));
+        static::assertSame('>=1.0.0', $decorator->getVersionConstraint());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $invalidMinLengthOption = new CustomValidatorOption(new NamelessOption('foo'), new Length(4));
+        $emptyMinLengthOption = new CustomValidatorOption(new NamelessOption(), new Length(4));
+        $validMinLengthOption = new CustomValidatorOption(new NamelessOption('foobar'), new Length(4));
+        $lowValidMinLengthOption = new CustomValidatorOption(new NamelessOption('foobar', '>=1.0.0'), new Length(4));
+        $highValidMinLengthOption = new CustomValidatorOption(new NamelessOption('foobar', '>=2.0.0'), new Length(4));
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$invalidMinLengthOption, 'foo'];
+
+                yield [$validMinLengthOption, 'foobar'];
+
+                yield [$emptyMinLengthOption, null];
+
+                break;
+
+            case 'testIsValid':
+                yield [$invalidMinLengthOption, false];
+
+                yield [$validMinLengthOption, true];
+
+                yield [$emptyMinLengthOption, false];
+
+                break;
+
+            case 'testRender':
+                yield [$invalidMinLengthOption, []];
+
+                yield [$validMinLengthOption, ['foobar']];
+
+                yield [$emptyMinLengthOption, []];
+
+                break;
+
+            case 'testIsCompatible':
+                yield [$validMinLengthOption, '1.0.0', true];
+
+                yield [$validMinLengthOption, '1.5.0', true];
+
+                yield [$validMinLengthOption, '2.0.0', true];
+
+                yield [$validMinLengthOption, '2.5.0', true];
+
+                yield [$validMinLengthOption, 'foo', false];
+
+                yield [$lowValidMinLengthOption, '1.0.0', true];
+
+                yield [$lowValidMinLengthOption, '1.5.0', true];
+
+                yield [$lowValidMinLengthOption, '2.0.0', true];
+
+                yield [$lowValidMinLengthOption, '2.5.0', true];
+
+                yield [$lowValidMinLengthOption, 'foo', false];
+
+                yield [$highValidMinLengthOption, '1.0.0', false];
+
+                yield [$highValidMinLengthOption, '1.5.0', false];
+
+                yield [$highValidMinLengthOption, '2.0.0', true];
+
+                yield [$highValidMinLengthOption, '2.5.0', true];
+
+                yield [$highValidMinLengthOption, 'foo', false];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/FlagOptionTest.php b/tests/Redis/Command/Option/FlagOptionTest.php
new file mode 100644
index 0000000..1b0c551
--- /dev/null
+++ b/tests/Redis/Command/Option/FlagOptionTest.php
@@ -0,0 +1,99 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class FlagOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+
+    public function testGetSetActive(): void
+    {
+        $option = new FlagOption('FOO', true);
+        static::assertTrue($option->isActive());
+        $option->setActive(true);
+        static::assertTrue($option->isActive());
+        $option->setActive(false);
+        static::assertFalse($option->isActive());
+        $option = new FlagOption('BAR', false);
+        static::assertFalse($option->isActive());
+        $option = new FlagOption('FOOBAR');
+        static::assertFalse($option->isActive());
+    }
+
+    public function testGetName(): void
+    {
+        $option = new FlagOption('FOO');
+        static::assertEquals('FOO', $option->getName());
+        $option = new FlagOption('BAR');
+        static::assertEquals('BAR', $option->getName());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $defaultValueFlag = new FlagOption('FOO');
+        $notActiveFlag = new FlagOption('FOO', false);
+        $activeFlag = new FlagOption('BAR', true);
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$defaultValueFlag, false];
+
+                yield [$notActiveFlag, false];
+
+                yield [$activeFlag, true];
+
+                break;
+
+            case 'testIsValid':
+                yield [$defaultValueFlag, true];
+
+                yield [$notActiveFlag, true];
+
+                yield [$activeFlag, true];
+
+                break;
+
+            case 'testRender':
+                yield [$defaultValueFlag, []];
+
+                yield [$notActiveFlag, []];
+
+                yield [$activeFlag, ['BAR']];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/GroupedOptionTest.php b/tests/Redis/Command/Option/GroupedOptionTest.php
new file mode 100644
index 0000000..f05ca4c
--- /dev/null
+++ b/tests/Redis/Command/Option/GroupedOptionTest.php
@@ -0,0 +1,220 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use BadMethodCallException;
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+use OutOfRangeException;
+use PHPUnit\Framework\TestCase;
+use Respect\Validation\Rules\StringVal;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class GroupedOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+    use TestIsCompatibleTrait;
+
+    public function testGetSetDataOfOption(): void
+    {
+        $option = new GroupedOption([
+            'flag' => new FlagOption('FOO'),
+            'nameless' => new NamelessOption(),
+            'decorated' => new CustomValidatorOption(new NamelessOption(), new StringVal()),
+            'named' => new NamedOption('BAR'),
+            'list' => new NumberedOption('list'),
+        ], []);
+
+        static::assertEquals(false, $option->getDataOfOption('flag'));
+        static::assertEquals(null, $option->getDataOfOption('nameless'));
+        static::assertEquals(null, $option->getDataOfOption('decorated'));
+        static::assertEquals(null, $option->getDataOfOption('named'));
+        static::assertEquals(null, $option->getDataOfOption('list'));
+
+        $option->setDataOfOption('flag', true);
+        static::assertEquals(true, $option->getDataOfOption('flag'));
+
+        $option->setDataOfOption('nameless', 20);
+        static::assertEquals(20, $option->getDataOfOption('nameless'));
+
+        $option->setDataOfOption('decorated', 'foo');
+        static::assertEquals('foo', $option->getDataOfOption('decorated'));
+
+        $option->setDataOfOption('list', ['foo', 'bar']);
+        static::assertEquals(['foo', 'bar'], $option->getDataOfOption('list'));
+    }
+
+    public function testGetOption(): void
+    {
+        $option = new GroupedOption([
+            'flag' => new FlagOption('FOO'),
+            'nameless' => new NamelessOption(),
+            'named' => new NamedOption('BAR'),
+        ], []);
+
+        static::assertEquals(new FlagOption('FOO'), $option->getOption('flag'));
+        static::assertEquals(new NamelessOption(), $option->getOption('nameless'));
+        static::assertEquals(new NamedOption('BAR'), $option->getOption('named'));
+    }
+
+    public function testGetOptionsName(): void
+    {
+        $option = new GroupedOption([
+            'flag' => new FlagOption('FOO'),
+            'nameless' => new NamelessOption(),
+            'named' => new NamedOption('BAR'),
+        ], []);
+        static::assertEquals(['flag', 'nameless', 'named'], $option->getOptionsName());
+    }
+
+    public function testSetUnknownOptionData(): void
+    {
+        $this->expectException(OutOfRangeException::class);
+        $this->expectExceptionMessage('There is no option for foobar');
+
+        $option = new GroupedOption([], []);
+        $option->setDataOfOption('foobar', 'bar');
+    }
+
+    public function testSetLockOptionData(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('The option lock can\'t be modified');
+
+        $option = new GroupedOption(['lock' => new NamelessOption('foo')], [], ['lock']);
+        $option->setDataOfOption('lock', 'bar');
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $emptyGroup = new GroupedOption([], []);
+        $noRequiredGroup = new GroupedOption(['foo' => new NamelessOption()], []);
+        $emptyRequiredGroup = new GroupedOption([], ['foo']);
+        $noValueRequireGroup = new GroupedOption(['foo' => new NamelessOption()], ['foo']);
+        $valueRequireGroup = new GroupedOption(['foo' => new NamelessOption('FOO')], ['foo']);
+        $valueRequireGroup2 = new GroupedOption(['foo' => new NamelessOption(0)], ['foo']);
+        $valueLockGroup = new GroupedOption(['foo' => new NamelessOption('FOO')], [], ['foo']);
+        $lowVersionGroup = new GroupedOption([], [], [], '>=1.0.0');
+        $highVersionGroup = new GroupedOption([], [], [], '>=2.0.0');
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$emptyGroup, []];
+
+                yield [$noRequiredGroup, ['foo' => new NamelessOption()]];
+
+                yield [$emptyRequiredGroup, []];
+
+                yield [$noValueRequireGroup, ['foo' => new NamelessOption()]];
+
+                yield [$valueRequireGroup, ['foo' => new NamelessOption('FOO')]];
+
+                yield [$valueRequireGroup2, ['foo' => new NamelessOption(0)]];
+
+                yield [$valueLockGroup, ['foo' => new NamelessOption('FOO')]];
+
+                break;
+
+            case 'testIsValid':
+                yield [$emptyGroup, true];
+
+                yield [$noRequiredGroup, true];
+
+                yield [$emptyRequiredGroup, false];
+
+                yield [$noValueRequireGroup, false];
+
+                yield [$valueRequireGroup, true];
+
+                yield [$valueRequireGroup2, true];
+
+                yield [$valueLockGroup, true];
+
+                break;
+
+            case 'testRender':
+                yield [$emptyGroup, []];
+
+                yield [$noRequiredGroup, []];
+
+                yield [$emptyRequiredGroup, []];
+
+                yield [$noValueRequireGroup, []];
+
+                yield [$valueRequireGroup, ['FOO']];
+
+                yield [$valueRequireGroup2, [0]];
+
+                yield [$valueLockGroup, ['FOO']];
+
+                break;
+
+            case 'testIsCompatible':
+                yield [$emptyGroup, '1.0.0', true];
+
+                yield [$emptyGroup, '1.5.0', true];
+
+                yield [$emptyGroup, '2.0.0', true];
+
+                yield [$emptyGroup, '2.5.0', true];
+
+                yield [$lowVersionGroup, '1.0.0', true];
+
+                yield [$lowVersionGroup, '1.5.0', true];
+
+                yield [$lowVersionGroup, '2.0.0', true];
+
+                yield [$lowVersionGroup, '2.5.0', true];
+
+                yield [$highVersionGroup, '1.0.0', false];
+
+                yield [$highVersionGroup, '1.5.0', false];
+
+                yield [$highVersionGroup, '2.0.0', true];
+
+                yield [$highVersionGroup, '2.5.0', true];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/JSONFieldOptionTest.php b/tests/Redis/Command/Option/JSONFieldOptionTest.php
new file mode 100644
index 0000000..ddaf873
--- /dev/null
+++ b/tests/Redis/Command/Option/JSONFieldOptionTest.php
@@ -0,0 +1,115 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use MacFJA\RediSearch\Exception\InvalidJSONPathException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\CreateCommand\JSONFieldOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Exception\InvalidJSONPathException
+ *
+ * @internal
+ */
+class JSONFieldOptionTest extends TestCase
+{
+    public function testIsValid(): void
+    {
+        $option = new TextFieldOption();
+        $json = new JSONFieldOption('$.data', $option);
+
+        static::assertFalse($option->isValid());
+        static::assertFalse($json->isValid());
+
+        $option->setField('data');
+        static::assertTrue($option->isValid());
+
+        try {
+            new JSONFieldOption('', $option);
+            static::fail();
+        } catch (InvalidJSONPathException $e) {
+            // Do nothing
+        }
+
+        $json = new JSONFieldOption('$.data', $option);
+        static::assertTrue($option->isValid());
+        static::assertTrue($json->isValid());
+    }
+
+    public function testGetOptionData(): void
+    {
+        $option = new TextFieldOption();
+        $option->setField('data');
+        $json = new JSONFieldOption('$.data', $option);
+
+        $expected = [
+            'path' => '$.data',
+        ] + $option->getOptionData();
+
+        static::assertSame($expected, $json->getOptionData());
+    }
+
+    public function testGetVersionConstraint(): void
+    {
+        $option = new TextFieldOption();
+        $json = new JSONFieldOption('$.data', $option);
+
+        static::assertSame('>=2.2.0', $json->getVersionConstraint());
+    }
+
+    public function testGetFieldName(): void
+    {
+        $option = new TextFieldOption();
+        $option->setField('data');
+        $json = new JSONFieldOption('$.data', $option);
+
+        static::assertSame('data', $json->getFieldName());
+    }
+
+    public function testGetJSONPath(): void
+    {
+        $option = new TextFieldOption();
+        $json = new JSONFieldOption('$.data', $option);
+
+        static::assertSame('$.data', $json->getJSONPath());
+    }
+
+    public function testDoRender(): void
+    {
+        $option = new TextFieldOption();
+        $option->setField('data');
+        $json = new JSONFieldOption('$.data', $option);
+
+        static::assertSame(['$.data', 'AS', 'data', 'TEXT'], $json->render());
+    }
+}
diff --git a/tests/Redis/Command/Option/NamedOptionTest.php b/tests/Redis/Command/Option/NamedOptionTest.php
new file mode 100644
index 0000000..d7bce60
--- /dev/null
+++ b/tests/Redis/Command/Option/NamedOptionTest.php
@@ -0,0 +1,91 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class NamedOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+
+    public function testGetSetValue(): void
+    {
+        $option = new NamedOption('FOO');
+        static::assertEquals(null, $option->getValue());
+        $option->setValue('FOO');
+        static::assertEquals('FOO', $option->getValue());
+        $option->setValue('bar');
+        static::assertEquals('bar', $option->getValue());
+        $option->setValue(10);
+        static::assertEquals(10, $option->getValue());
+    }
+
+    public function testGetName(): void
+    {
+        $option = new NamedOption('FOO');
+        static::assertEquals('FOO', $option->getName());
+        $option = new NamedOption('BAR');
+        static::assertEquals('BAR', $option->getName());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $defaultValueOption = new NamedOption('FOO');
+        $withValueOption = new NamedOption('FOO', 'bar');
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$defaultValueOption, null];
+
+                yield [$withValueOption, 'bar'];
+
+                break;
+
+            case 'testIsValid':
+                yield [$defaultValueOption, false];
+
+                yield [$withValueOption, true];
+
+                break;
+
+            case 'testRender':
+                yield [$defaultValueOption, []];
+
+                yield [$withValueOption, ['FOO', 'bar']];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/NamelessOptionTest.php b/tests/Redis/Command/Option/NamelessOptionTest.php
new file mode 100644
index 0000000..c6f8166
--- /dev/null
+++ b/tests/Redis/Command/Option/NamelessOptionTest.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class NamelessOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+
+    public function testGetSetValue(): void
+    {
+        $option = new NamelessOption('FOO');
+        static::assertEquals('FOO', $option->getValue());
+        $option->setValue('FOO');
+        static::assertEquals('FOO', $option->getValue());
+        $option->setValue('bar');
+        static::assertEquals('bar', $option->getValue());
+        $option->setValue(10);
+        static::assertEquals(10, $option->getValue());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $defaultValueOption = new NamelessOption();
+        $withValueOption = new NamelessOption('FOO');
+        $emptyValueOption = new NamelessOption('');
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$defaultValueOption, null];
+
+                yield [$withValueOption, 'FOO'];
+
+                yield [$emptyValueOption, ''];
+
+                break;
+
+            case 'testIsValid':
+                yield [$defaultValueOption, false];
+
+                yield [$withValueOption, true];
+
+                yield [$emptyValueOption, true];
+
+                break;
+
+            case 'testRender':
+                yield [$defaultValueOption, []];
+
+                yield [$withValueOption, ['FOO']];
+
+                yield [$emptyValueOption, ['']];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/NotEmptyOptionTest.php b/tests/Redis/Command/Option/NotEmptyOptionTest.php
new file mode 100644
index 0000000..f4243be
--- /dev/null
+++ b/tests/Redis/Command/Option/NotEmptyOptionTest.php
@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\FlagOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ *
+ * @internal
+ */
+class NotEmptyOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+    use TestIsCompatibleTrait;
+
+    public function testIsValid2(): void
+    {
+        $option = new NotEmptyOption(new NamedOption('FOO'));
+        static::assertEquals(null, $option->getValue());
+        static::assertFalse($option->isValid());
+        static::assertSame([], $option->render());
+        $option->setValue('bar');
+        static::assertEquals('bar', $option->getValue());
+        static::assertTrue($option->isValid());
+        static::assertSame(['FOO', 'bar'], $option->render());
+
+        $option->setValue('');
+        static::assertEquals('', $option->getValue());
+        static::assertFalse($option->isValid());
+        static::assertSame([], $option->render());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $namelessOption = new NotEmptyOption(new NamelessOption('foo'));
+        $namedOption = new NotEmptyOption(new NamedOption('FOO', 'bar'));
+        $numberedOption = new NotEmptyOption(new NumberedOption('FOO', ['bar', 'foobar']));
+        $emptyNamelessOption = new NotEmptyOption(new NamelessOption());
+        $emptyNamedOption = new NotEmptyOption(new NamedOption('FOO'));
+        $emptyNumberedOption = new NotEmptyOption(new NumberedOption('FOO', []));
+        $nullNumberedOption = new NotEmptyOption(new NumberedOption('FOO'));
+        $trueFlagOption = new NotEmptyOption(new FlagOption('FOO', true));
+        $falseFlagOption = new NotEmptyOption(new FlagOption('FOO'));
+        $lowOptionOption = new NotEmptyOption(new NamelessOption('bar', '>=1.0.0'));
+        $highOptionOption = new NotEmptyOption(new NamelessOption('bar', '>=2.0.0'));
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$namelessOption, 'foo'];
+
+                yield [$namedOption, 'bar'];
+
+                yield [$numberedOption, ['bar', 'foobar']];
+
+                yield [$emptyNamelessOption, null];
+
+                yield [$emptyNamedOption, null];
+
+                yield [$emptyNumberedOption, []];
+
+                yield [$nullNumberedOption, null];
+
+                yield [$trueFlagOption, true];
+
+                yield [$falseFlagOption, false];
+
+                break;
+
+            case 'testIsValid':
+                yield [$namelessOption, true];
+
+                yield [$namedOption, true];
+
+                yield [$numberedOption, true];
+
+                yield [$emptyNamelessOption, false];
+
+                yield [$emptyNamedOption, false];
+
+                yield [$emptyNumberedOption, false];
+
+                yield [$trueFlagOption, true];
+
+                yield [$falseFlagOption, false];
+
+                break;
+
+            case 'testRender':
+                yield [$namelessOption, ['foo']];
+
+                yield [$namedOption, ['FOO', 'bar']];
+
+                yield [$numberedOption, ['FOO', 2, 'bar', 'foobar']];
+
+                yield [$emptyNamelessOption, []];
+
+                yield [$emptyNamedOption, []];
+
+                yield [$emptyNumberedOption, []];
+
+                yield [$trueFlagOption, ['FOO']];
+
+                yield [$falseFlagOption, []];
+
+                break;
+
+            case 'testIsCompatible':
+                yield [$namelessOption, '1.0.0', true];
+
+                yield [$namelessOption, '1.5.0', true];
+
+                yield [$namelessOption, '2.0.0', true];
+
+                yield [$namelessOption, '2.5.0', true];
+
+                yield [$namelessOption, 'foo', false];
+
+                yield [$lowOptionOption, '1.0.0', true];
+
+                yield [$lowOptionOption, '1.5.0', true];
+
+                yield [$lowOptionOption, '2.0.0', true];
+
+                yield [$lowOptionOption, '2.5.0', true];
+
+                yield [$lowOptionOption, 'foo', false];
+
+                yield [$highOptionOption, '1.0.0', false];
+
+                yield [$highOptionOption, '1.5.0', false];
+
+                yield [$highOptionOption, '2.0.0', true];
+
+                yield [$highOptionOption, '2.5.0', true];
+
+                yield [$highOptionOption, 'foo', false];
+
+                break;
+
+            default:
+                return;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/NumberedOptionTest.php b/tests/Redis/Command/Option/NumberedOptionTest.php
new file mode 100644
index 0000000..58abe71
--- /dev/null
+++ b/tests/Redis/Command/Option/NumberedOptionTest.php
@@ -0,0 +1,111 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\NumberedOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class NumberedOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+
+    public function testGetSetArguments(): void
+    {
+        $option = new NumberedOption('FOO');
+        static::assertNull($option->getArguments());
+        $option->setArguments([]);
+        static::assertIsArray($option->getArguments());
+        static::assertEquals([], $option->getArguments());
+        $option->setArguments(['foo', 'bar', 'foobar']);
+        static::assertIsArray($option->getArguments());
+        static::assertEquals(['foo', 'bar', 'foobar'], $option->getArguments());
+
+        $option = new NumberedOption('FOO', ['foo']);
+        static::assertIsArray($option->getArguments());
+        static::assertEquals(['foo'], $option->getArguments());
+
+        $option = new NumberedOption('FOO', ['foo']);
+        $option->setArguments(null);
+        static::assertNull($option->getArguments());
+        static::assertFalse($option->isValid());
+        static::assertSame([], $option->render());
+    }
+
+    public function testGetName(): void
+    {
+        $option = new NumberedOption('FOO');
+        static::assertEquals('FOO', $option->getName());
+        $option = new NumberedOption('BAR');
+        static::assertEquals('BAR', $option->getName());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $noArgumentsOption = new NumberedOption('FOO');
+        $emptyArgumentsOption = new NumberedOption('FOO', []);
+        $withOneArgumentOption = new NumberedOption('FOO', ['bar']);
+        $withMultipleArgumentOption = new NumberedOption('FOO', ['bar', 'foobar']);
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$noArgumentsOption, null];
+
+                yield [$emptyArgumentsOption, []];
+
+                yield [$withOneArgumentOption, ['bar']];
+
+                yield [$withMultipleArgumentOption, ['bar', 'foobar']];
+
+                break;
+
+            case 'testIsValid':
+                yield [$noArgumentsOption, false];
+
+                yield [$emptyArgumentsOption, true];
+
+                yield [$withOneArgumentOption, true];
+
+                yield [$withMultipleArgumentOption, true];
+
+                break;
+
+            case 'testRender':
+                yield [$noArgumentsOption, []];
+
+                yield [$emptyArgumentsOption, ['FOO', 0]];
+
+                yield [$withOneArgumentOption, ['FOO', 1, 'bar']];
+
+                yield [$withMultipleArgumentOption, ['FOO', 2, 'bar', 'foobar']];
+
+                break;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/OptionListOptionTest.php b/tests/Redis/Command/Option/OptionListOptionTest.php
new file mode 100644
index 0000000..3664562
--- /dev/null
+++ b/tests/Redis/Command/Option/OptionListOptionTest.php
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Command\Option\NamedOption;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\Redis\Command\Option\OptionListOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\OptionListOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class OptionListOptionTest extends TestCase
+{
+    use BaseOptionTestTrait;
+
+    public function testAddOption(): void
+    {
+        $emptyArgumentsOption = new OptionListOption();
+        $emptyArgumentsOption->addOption(new NamelessOption());
+        static::assertEquals([new NamelessOption()], $emptyArgumentsOption->getOptionData());
+
+        $withOneArgumentOption = new OptionListOption();
+        $withOneArgumentOption->addOption(new NamelessOption('foo'));
+        static::assertEquals([new NamelessOption('foo')], $withOneArgumentOption->getOptionData());
+
+        $withMultipleArgumentOption = new OptionListOption();
+        $withMultipleArgumentOption->addOption(new NamelessOption('foo'));
+        static::assertEquals([new NamelessOption('foo')], $withMultipleArgumentOption->getOptionData());
+        $withMultipleArgumentOption->addOption(new NamelessOption('bar'));
+        static::assertEquals([new NamelessOption('foo'), new NamelessOption('bar')], $withMultipleArgumentOption->getOptionData());
+        $withMultipleArgumentOption->addOption(new NamedOption('foobar', 'baz'));
+        static::assertEquals([new NamelessOption('foo'), new NamelessOption('bar'), new NamedOption('foobar', 'baz')], $withMultipleArgumentOption->getOptionData());
+    }
+
+    public function testSetValue(): void
+    {
+        $emptyArgumentsOption = new OptionListOption();
+        $emptyArgumentsOption->setValue(new NamelessOption());
+        static::assertEquals([new NamelessOption()], $emptyArgumentsOption->getOptionData());
+
+        $withOneArgumentOption = new OptionListOption();
+        $withOneArgumentOption->setValue(new NamelessOption('foo'));
+        static::assertEquals([new NamelessOption('foo')], $withOneArgumentOption->getOptionData());
+        $withOneArgumentOption->setValue(new NamelessOption('bar'));
+        static::assertEquals([new NamelessOption('bar')], $withOneArgumentOption->getOptionData());
+
+        $withMultipleArgumentOption = new OptionListOption();
+        $withMultipleArgumentOption->setValue(new NamelessOption('foo'), new NamelessOption('bar'), new NamedOption('foobar', 'baz'));
+        static::assertEquals([new NamelessOption('foo'), new NamelessOption('bar'), new NamedOption('foobar', 'baz')], $withMultipleArgumentOption->getOptionData());
+    }
+
+    public function dataProvider(string $testName): Generator
+    {
+        $noArgumentsOption = new OptionListOption();
+        $emptyArgumentsOption = new OptionListOption();
+        $emptyArgumentsOption->addOption(new NamelessOption());
+        $withOneArgumentOption = new OptionListOption();
+        $withOneArgumentOption->addOption(new NamelessOption('foo'));
+        $withMultipleArgumentOption = new OptionListOption();
+        $withMultipleArgumentOption->addOption(new NamelessOption('foo'));
+        $withMultipleArgumentOption->addOption(new NamelessOption('bar'));
+        $withMultipleArgumentOption->addOption(new NamedOption('foobar', 'baz'));
+
+        switch ($testName) {
+            case 'testGetOptionData':
+                yield [$noArgumentsOption, []];
+
+                yield [$emptyArgumentsOption, [new NamelessOption()]];
+
+                yield [$withOneArgumentOption, [new NamelessOption('foo')]];
+
+                yield [$withMultipleArgumentOption, [new NamelessOption('foo'), new NamelessOption('bar'), new NamedOption('foobar', 'baz')]];
+
+                break;
+
+            case 'testIsValid':
+                yield [$noArgumentsOption, true];
+
+                yield [$emptyArgumentsOption, true];
+
+                yield [$withOneArgumentOption, true];
+
+                yield [$withMultipleArgumentOption, true];
+
+                break;
+
+            case 'testRender':
+                yield [$noArgumentsOption, []];
+
+                yield [$emptyArgumentsOption, []];
+
+                yield [$withOneArgumentOption, ['foo']];
+
+                yield [$withMultipleArgumentOption, ['foo', 'bar', 'foobar', 'baz']];
+
+                break;
+        }
+    }
+}
diff --git a/tests/Redis/Command/Option/TestIsCompatibleTrait.php b/tests/Redis/Command/Option/TestIsCompatibleTrait.php
new file mode 100644
index 0000000..8baf947
--- /dev/null
+++ b/tests/Redis/Command/Option/TestIsCompatibleTrait.php
@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use MacFJA\RediSearch\Redis\Command\Option\CommandOption;
+
+trait TestIsCompatibleTrait
+{
+    /**
+     * @dataProvider dataProvider
+     */
+    public function testIsCompatible(CommandOption $inputOption, string $inputVersion, bool $expected): void
+    {
+        $computed = $inputOption->isCompatible($inputVersion);
+        self::assertEquals($expected, $computed);
+    }
+}
diff --git a/tests/Redis/Command/Option/WithPublicGroupedSetterTraitTest.php b/tests/Redis/Command/Option/WithPublicGroupedSetterTraitTest.php
new file mode 100644
index 0000000..12abf01
--- /dev/null
+++ b/tests/Redis/Command/Option/WithPublicGroupedSetterTraitTest.php
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\Option;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\Option\NamelessOption;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\Option\FakeWithPublicGroupedSetter;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\Option\FakeWithPublicGroupedSetterInvalidSelf;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class WithPublicGroupedSetterTraitTest extends TestCase
+{
+    public function testInvalidSelf(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('This method is not callable in '.FakeWithPublicGroupedSetterInvalidSelf::class);
+        $class = new FakeWithPublicGroupedSetterInvalidSelf();
+        $class->setName('John');
+    }
+
+    public function testInvalidSetterName(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined method setAge in '.FakeWithPublicGroupedSetter::class);
+        $class = new FakeWithPublicGroupedSetter([], []);
+        $class->setAge(50);
+    }
+
+    public function testInvalidSetterArgCount(): void
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('The method '.FakeWithPublicGroupedSetter::class.'::setName need exactly one argument');
+        $class = new FakeWithPublicGroupedSetter([], []);
+        $class->setName('John', 'Doe');
+    }
+
+    public function testUndefinedMethod(): void
+    {
+        $this->expectException(BadMethodCallException::class);
+        $this->expectExceptionMessage('Call undefined method foo in '.FakeWithPublicGroupedSetter::class);
+        $class = new FakeWithPublicGroupedSetter([], []);
+        $class->foo();
+    }
+
+    public function testSetter(): void
+    {
+        $class = new FakeWithPublicGroupedSetter(['name' => new NamelessOption()], []);
+        $class->setName('John');
+        static::assertSame('John', $class->getDataOfOption('name'));
+    }
+}
diff --git a/tests/Redis/Command/ProfileCommand/QueryOptionTest.php b/tests/Redis/Command/ProfileCommand/QueryOptionTest.php
new file mode 100644
index 0000000..32c93fa
--- /dev/null
+++ b/tests/Redis/Command/ProfileCommand/QueryOptionTest.php
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\ProfileCommand;
+
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\ProfileCommand\QueryOption;
+use MacFJA\RediSearch\Redis\Command\Search;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\ProfileCommand\QueryOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Search
+ * @uses \MacFJA\RediSearch\Redis\Command\Aggregate
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\WithCursor
+ *
+ * @internal
+ */
+class QueryOptionTest extends TestCase
+{
+    public function testGetOptionData(): void
+    {
+        $command = (new Search())->setIndex('idx')->setQuery('*');
+        static::assertSame([
+            'command' => $command,
+        ], (new QueryOption())->setCommand($command)->getOptionData());
+    }
+
+    public function testIsSearch(): void
+    {
+        $search = (new Search())->setIndex('idx')->setQuery('*');
+        $aggregate = (new Aggregate())->setIndex('idx')->setQuery('*');
+        static::assertTrue((new QueryOption())->setCommand($search)->isSearch());
+        static::assertFalse((new QueryOption())->setCommand($aggregate)->isSearch());
+    }
+}
diff --git a/tests/Redis/Command/ProfileTest.php b/tests/Redis/Command/ProfileTest.php
new file mode 100644
index 0000000..ae8f4f5
--- /dev/null
+++ b/tests/Redis/Command/ProfileTest.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\Profile;
+use MacFJA\RediSearch\Redis\Command\Search;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Profile
+ * @covers \MacFJA\RediSearch\Redis\Command\ProfileCommand\QueryOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Search
+ * @uses \MacFJA\RediSearch\Redis\Command\Aggregate
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\WithCursor
+ *
+ * @internal
+ */
+class ProfileTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Profile();
+        static::assertSame('FT.PROFILE', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $aggregate = new Aggregate(Profile::MAX_IMPLEMENTED_VERSION);
+        $aggregate->setQuery('@foo:bar')->setIndex('idx');
+        $command = new Profile(Profile::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setTypeAggregate()
+            ->setLimited()
+            ->setQuery($aggregate)
+        ;
+
+        static::assertSame([
+            'idx', 'AGGREGATE', 'LIMITED', 'QUERY', '@foo:bar',
+        ], $command->getArguments());
+    }
+
+    public function testSearch(): void
+    {
+        $search = new Search('2.2.0');
+        $search->setIndex('idx')->setQuery('@foo:bar');
+        $command = new Profile('2.2.0');
+        $command
+            ->setIndex('idx')
+            ->setTypeSearch()
+            ->setQuery($search)
+        ;
+
+        static::assertSame([
+            'idx', 'SEARCH', 'QUERY', '@foo:bar',
+        ], $command->getArguments());
+    }
+
+    public function testAutoFill(): void
+    {
+        $search = new Search('2.2.0');
+        $search->setIndex('idx')->setQuery('@foo:bar');
+        $command = new Profile('2.2.0');
+        $command
+            ->setQuery($search)
+        ;
+
+        static::assertSame([
+            'idx', 'SEARCH', 'QUERY', '@foo:bar',
+        ], $command->getArguments());
+    }
+
+    public function testOverride(): void
+    {
+        $search = new Search('2.2.0');
+        $search->setIndex('idx')->setQuery('@foo:bar');
+        $command = new Profile('2.2.0');
+        $command
+            ->setQuery($search)
+            ->setIndex('idx2')
+            ->setTypeAggregate()
+        ;
+
+        static::assertSame([
+            'idx2', 'AGGREGATE', 'QUERY', '@foo:bar',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/SearchCommand/FilterOptionTest.php b/tests/Redis/Command/SearchCommand/FilterOptionTest.php
new file mode 100644
index 0000000..35ed9d7
--- /dev/null
+++ b/tests/Redis/Command/SearchCommand/FilterOptionTest.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\SearchCommand\FilterOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\FilterOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class FilterOptionTest extends TestCase
+{
+    public function testShorthands(): void
+    {
+        static::assertSame(['FILTER', 'num1', '(100', '+inf'], FilterOption::greaterThan('num1', 100)->render());
+        static::assertSame(['FILTER', 'num1', '100', '100'], FilterOption::equalsTo('num1', 100)->render());
+        static::assertSame(['FILTER', 'num1', '100', '+inf'], FilterOption::greaterThanOrEquals('num1', 100)->render());
+        static::assertSame(['FILTER', 'num1', '-inf', '(100'], FilterOption::lessThan('num1', 100)->render());
+        static::assertSame(['FILTER', 'num1', '-inf', '100'], FilterOption::lessThanOrEquals('num1', 100)->render());
+    }
+
+    public function testGetOptionData(): void
+    {
+        static::assertSame([
+            'min' => 100.0,
+            'max' => null,
+            'isMinInclusive' => false,
+            'isMaxInclusive' => true,
+        ], FilterOption::greaterThan('num1', 100)->getOptionData());
+    }
+}
diff --git a/tests/Redis/Command/SearchCommand/LimitOptionTest.php b/tests/Redis/Command/SearchCommand/LimitOptionTest.php
new file mode 100644
index 0000000..65312de
--- /dev/null
+++ b/tests/Redis/Command/SearchCommand/LimitOptionTest.php
@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\SearchCommand;
+
+use MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ *
+ * @internal
+ */
+class LimitOptionTest extends TestCase
+{
+    public function testSetOffset(): void
+    {
+        $option = new LimitOption();
+        $option->setSize(1);
+
+        $option->setOffset(0);
+        static::assertSame(['LIMIT', 0, 1], $option->render());
+
+        $option->setOffset(10);
+        static::assertSame(['LIMIT', 10, 1], $option->render());
+
+        $option->setOffset(null);
+        static::assertSame([], $option->render());
+    }
+
+    public function testSetSize(): void
+    {
+        $option = new LimitOption();
+        $option->setOffset(0);
+
+        $option->setSize(0);
+        static::assertSame(['LIMIT', 0, 0], $option->render());
+
+        $option->setSize(10);
+        static::assertSame(['LIMIT', 0, 10], $option->render());
+
+        $option->setSize(null);
+        static::assertSame([], $option->render());
+    }
+
+    public function testIsValid(): void
+    {
+        $option = new LimitOption();
+        static::assertFalse($option->isValid());
+
+        $option->setOffset(0);
+        static::assertFalse($option->isValid());
+
+        $option->setSize(0);
+        static::assertTrue($option->isValid());
+
+        $option->setSize(-1);
+        static::assertFalse($option->isValid());
+
+        $option->setSize(10);
+        $option->setOffset(-10);
+        static::assertFalse($option->isValid());
+    }
+
+    public function testGetOptionData(): void
+    {
+        $option = new LimitOption();
+        static::assertSame(['offset' => null, 'size' => null], $option->getOptionData());
+
+        $option = new LimitOption();
+        $option->setOffset(0);
+        static::assertSame(['offset' => 0, 'size' => null], $option->getOptionData());
+
+        $option = new LimitOption();
+        $option->setSize(0);
+        static::assertSame(['offset' => null, 'size' => 0], $option->getOptionData());
+
+        $option = new LimitOption();
+        $option->setOffset(10);
+        $option->setSize(10);
+        static::assertSame(['offset' => 10, 'size' => 10], $option->getOptionData());
+    }
+}
diff --git a/tests/Redis/Command/SearchTest.php b/tests/Redis/Command/SearchTest.php
new file mode 100644
index 0000000..f1f6179
--- /dev/null
+++ b/tests/Redis/Command/SearchTest.php
@@ -0,0 +1,267 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\AbstractCommand;
+use MacFJA\RediSearch\Redis\Command\Search;
+use MacFJA\RediSearch\Redis\Command\SearchCommand\FilterOption;
+use MacFJA\RediSearch\Redis\Response\PaginatedResponse;
+use MacFJA\RediSearch\Redis\Response\SearchResponseItem;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\Search
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\FilterOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @covers \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ * @covers \MacFJA\RediSearch\Redis\Response\PaginatedResponse
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ *
+ * @internal
+ */
+class SearchTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new Search();
+        static::assertSame('FT.SEARCH', $command->getId());
+    }
+
+    public function testGetIndex(): void
+    {
+        $command = new Search();
+        $command->setIndex('idx');
+        static::assertSame('idx', $command->getIndex());
+    }
+
+    public function testSizeOptions(): void
+    {
+        $command = new Search();
+        static::assertNull($command->getSize());
+        static::assertNull($command->getOffset());
+
+        $command->setLimit(0, 10);
+        static::assertSame(0, $command->getOffset());
+        static::assertSame(10, $command->getSize());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setNoContent()
+            ->setNoStopWords()
+            ->setVerbatim()
+            ->setWithSortKeys()
+            ->setWithPayloads()
+            ->setWithScores()
+            ->addFilter(new FilterOption('@num1', 10, 20))
+            ->setGeoFilter('@geo1', 37.37767452939122, -122.06451753037919, 50, 'm')
+            ->setInKeys('@text1', '@text2')
+            ->setInFields('@text4')
+            ->setReturn('@text3', '@text4', '@text5')
+            ->setSummarize(['@text1', '@text3'], 2, 4, ' ... ')
+            ->setHighlight(['@text4'], '<em>', '</em>')
+            ->setSlop(2)
+            ->setInOrder()
+            ->setLanguage('english')
+            ->setExpander('my_expander')
+            ->setScorer('my_scorer')
+            ->setExplainScore()
+            ->setPayload('foobar')
+            ->setSortBy('@num1', 'ASC')
+            ->setLimit(12, 10)
+            ->setDialect(2)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'NOCONTENT',
+            'VERBATIM',
+            'NOSTOPWORDS',
+            'WITHSCORES',
+            'WITHPAYLOADS',
+            'WITHSORTKEYS',
+            'FILTER', '@num1', '10', '20',
+            'GEOFILTER', '@geo1', 37.37767452939122, -122.06451753037919, 50.0, 'm',
+            'INKEYS', 2, '@text1', '@text2',
+            'INFIELDS', 1, '@text4',
+            'RETURN', 3, '@text3', '@text4', '@text5',
+            'SUMMARIZE', 'FIELDS', 2, '@text1', '@text3', 'FRAGS', 2, 'LEN', 4, 'SEPARATOR', ' ... ',
+            'HIGHLIGHT', 'FIELDS', 1, '@text4', 'TAGS', '<em>', '</em>',
+            'SLOP', 2, 'INORDER',
+            'LANGUAGE', 'english',
+            'EXPANDER', 'my_expander',
+            'SCORER', 'my_scorer',
+            'EXPLAINSCORE',
+            'PAYLOAD', 'foobar',
+            'SORTBY', '@num1', 'ASC',
+            'LIMIT', 12, 10,
+            'DIALECT', 2,
+        ], $command->getArguments());
+    }
+
+    public function testNoSummarizeAndNoHighlight(): void
+    {
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setLimit(12, 10)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'LIMIT', 12, 10,
+        ], $command->getArguments());
+    }
+
+    public function testParsingResponseNoContent(): void
+    {
+        $redisResponse = [
+            3,
+            'hash_1',
+            'hash_2',
+            'hash_3',
+        ];
+
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command->setQuery('*');
+        $command->setNoContent();
+
+        $expectedResponse = new PaginatedResponse($command, 3, [
+            new SearchResponseItem('hash_1'),
+            new SearchResponseItem('hash_2'),
+            new SearchResponseItem('hash_3'),
+        ]);
+
+        $actualResponse = $command->parseResponse($redisResponse);
+
+        static::assertEquals($expectedResponse, $actualResponse);
+    }
+
+    public function testParsingResponseScore(): void
+    {
+        $redisResponse = [
+            3,
+            'hash_1',
+            '0.741',
+            ['field', '1'],
+            'hash_2',
+            '0.654',
+            ['field', '2'],
+            'hash_3',
+            '0.258',
+            ['field', '3'],
+        ];
+
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command->setQuery('*');
+        $command->setWithScores();
+
+        $expectedResponse = new PaginatedResponse($command, 3, [
+            new SearchResponseItem('hash_1', ['field' => '1'], 0.741),
+            new SearchResponseItem('hash_2', ['field' => '2'], 0.654),
+            new SearchResponseItem('hash_3', ['field' => '3'], 0.258),
+        ]);
+
+        $actualResponse = $command->parseResponse($redisResponse);
+
+        static::assertEquals($expectedResponse, $actualResponse);
+    }
+
+    public function testParsingResponseScoreAndPayload(): void
+    {
+        $redisResponse = [
+            3,
+            'hash_1',
+            '0.741',
+            null,
+            ['field', '1'],
+            'hash_2',
+            '0.654',
+            null,
+            ['field', '2', 'other', 'field'],
+            'hash_3',
+            '0.258',
+            'third',
+            ['field', '3'],
+        ];
+
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command->setQuery('*');
+        $command->setWithScores();
+        $command->setWithPayloads();
+
+        $expectedResponse = new PaginatedResponse($command, 3, [
+            new SearchResponseItem('hash_1', ['field' => '1'], 0.741),
+            new SearchResponseItem('hash_2', ['field' => '2', 'other' => 'field'], 0.654),
+            new SearchResponseItem('hash_3', ['field' => '3'], 0.258, 'third'),
+        ]);
+
+        $actualResponse = $command->parseResponse($redisResponse);
+
+        static::assertEquals($expectedResponse, $actualResponse);
+    }
+
+    public function testParsingInvalidServerResponse(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command->parseResponse('foobar');
+    }
+
+    public function testParsingInvalidServerResponseContent(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+
+        $redisResponse = [
+            1,
+            'hash_1',
+        ];
+
+        $command = new Search(AbstractCommand::MAX_IMPLEMENTED_VERSION);
+        $command->setQuery('*');
+
+        $command->parseResponse($redisResponse);
+    }
+}
diff --git a/tests/Redis/Command/SpellCheckCommand/TermsOptionTest.php b/tests/Redis/Command/SpellCheckCommand/TermsOptionTest.php
new file mode 100644
index 0000000..7cdf8b1
--- /dev/null
+++ b/tests/Redis/Command/SpellCheckCommand/TermsOptionTest.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command\SpellCheckCommand;
+
+use MacFJA\RediSearch\Redis\Command\SpellCheckCommand\TermsOption;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\SpellCheckCommand\TermsOption
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ *
+ * @internal
+ */
+class TermsOptionTest extends TestCase
+{
+    public function testFullOptions(): void
+    {
+        $option = new TermsOption();
+        $option->setDictionary('country');
+        $option->setIsExclusion(true);
+        static::assertSame(['TERMS', 'EXCLUDE', 'country'], $option->render());
+
+        $option = new TermsOption();
+        $option->setDictionary('city');
+        $option->setIsExclusion(false);
+        static::assertSame(['TERMS', 'INCLUDE', 'city'], $option->render());
+    }
+
+    public function testGetData(): void
+    {
+        $option = new TermsOption();
+        $option->setDictionary('country');
+        $option->setIsExclusion(true);
+        static::assertSame('country', $option->getOptionData());
+
+        $option = new TermsOption();
+        $option->setDictionary('city');
+        $option->setIsExclusion(false);
+        static::assertSame('city', $option->getOptionData());
+    }
+}
diff --git a/tests/Redis/Command/SpellCheckTest.php b/tests/Redis/Command/SpellCheckTest.php
new file mode 100644
index 0000000..8852f60
--- /dev/null
+++ b/tests/Redis/Command/SpellCheckTest.php
@@ -0,0 +1,113 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\SpellCheck;
+use MacFJA\RediSearch\Redis\Response\SpellCheckResponseItem;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers  \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ * @covers  \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SpellCheck
+ * @covers \MacFJA\RediSearch\Redis\Command\SpellCheckCommand\TermsOption
+ **
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ *
+ * @internal
+ */
+class SpellCheckTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new SpellCheck();
+        static::assertSame('FT.SPELLCHECK', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new SpellCheck(SpellCheck::MAX_IMPLEMENTED_VERSION);
+        $command
+            ->setIndex('idx')
+            ->setQuery('@text1:"hello world"')
+            ->setDistance(2)
+            ->addTerms('badword', true)
+            ->addTerms('cities', false)
+            ->setDialect(2)
+        ;
+
+        static::assertSame([
+            'idx',
+            '@text1:"hello world"',
+            'DISTANCE', 2,
+            'DIALECT', 2,
+            'TERMS', 'EXCLUDE', 'badword',
+            'TERMS', 'INCLUDE', 'cities',
+        ], $command->getArguments());
+    }
+
+    public function testParseResponse(): void
+    {
+        $command = new SpellCheck();
+        $expected = [
+            new SpellCheckResponseItem('hell', ['hello' => 0.7, 'hola' => 0.4]),
+            new SpellCheckResponseItem('worl', ['world' => 0.9, 'work' => 0.56]),
+        ];
+        $rawResponse = [
+            ['TERM', 'hell', [[0.7, 'hello'], [0.4, 'hola']]],
+            ['TERM', 'worl', [[0.9, 'world'], [0.56, 'work']]],
+        ];
+
+        $actual = $command->parseResponse($rawResponse);
+
+        static::assertEquals($expected, $actual);
+    }
+
+    public function testParseResponseInvalid(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $command = new SpellCheck();
+
+        $command->parseResponse('foobar');
+    }
+
+    public function testParseResponsePartialInvalid(): void
+    {
+        $command = new SpellCheck();
+        $expected = [
+            new SpellCheckResponseItem('hell', ['hello' => 0.7, 'hola' => 0.4]),
+        ];
+        $rawResponse = [
+            ['TERM', 'hell', [[0.7, 'hello'], [0.4, 'hola']]],
+            ['TERMbar', 'worl', [[0.9, 'world'], [0.56, 'work']]],
+        ];
+
+        $actual = $command->parseResponse($rawResponse);
+
+        static::assertEquals($expected, $actual);
+    }
+}
diff --git a/tests/Redis/Command/SugAddTest.php b/tests/Redis/Command/SugAddTest.php
new file mode 100644
index 0000000..f1d0521
--- /dev/null
+++ b/tests/Redis/Command/SugAddTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\SugAdd;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SugAdd
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class SugAddTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new SugAdd();
+        static::assertSame('FT.SUGADD', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new SugAdd();
+        $command
+            ->setDictionary('ac')
+            ->setSuggestion('hello world')
+            ->setScore(1)
+            ->setIncrement()
+            ->setPayload('greeting')
+        ;
+
+        static::assertSame([
+            'ac', 'hello world', 1.0, 'INCR', 'PAYLOAD', 'greeting',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/SugDelTest.php b/tests/Redis/Command/SugDelTest.php
new file mode 100644
index 0000000..a7ef274
--- /dev/null
+++ b/tests/Redis/Command/SugDelTest.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\SugDel;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SugDel
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class SugDelTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new SugDel();
+        static::assertSame('FT.SUGDEL', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new SugDel();
+        $command
+            ->setDictionary('ac')
+            ->setSuggestion('hello world')
+        ;
+
+        static::assertSame([
+            'ac', 'hello world',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/Command/SugGetTest.php b/tests/Redis/Command/SugGetTest.php
new file mode 100644
index 0000000..01c32f8
--- /dev/null
+++ b/tests/Redis/Command/SugGetTest.php
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Exception\UnexpectedServerResponseException;
+use MacFJA\RediSearch\Redis\Command\SugGet;
+use MacFJA\RediSearch\Redis\Response\SuggestionResponseItem;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\UnexpectedServerResponseException
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SugGet
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ *
+ * @internal
+ */
+class SugGetTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new SugGet();
+        static::assertSame('FT.SUGGET', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new SugGet();
+        $command
+            ->setDictionary('ac')
+            ->setPrefix('hell')
+            ->setFuzzy()
+            ->setWithScores()
+            ->setWithPayloads()
+            ->setMax(10)
+        ;
+
+        static::assertSame([
+            'ac', 'hell', 'FUZZY', 'WITHSCORES', 'WITHPAYLOADS', 'MAX', 10,
+        ], $command->getArguments());
+    }
+
+    public function testParseResponse(): void
+    {
+        $expected = [
+            new SuggestionResponseItem('hello', 0.4024922251701355, 'greeting'),
+            new SuggestionResponseItem('hola', 0.40000000596046448, 'greeting'),
+        ];
+
+        $rawResult = [
+            'hello', '0.4024922251701355', 'greeting',
+            'hola', '0.40000000596046448', 'greeting',
+        ];
+
+        $command = new SugGet();
+        $command->setWithPayloads()->setWithScores();
+
+        static::assertEquals($expected, $command->parseResponse($rawResult));
+    }
+
+    public function testInvalidServerResponse1(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $command = new SugGet();
+        $command->parseResponse('foobar');
+    }
+
+    public function testInvalidServerResponse2(): void
+    {
+        $this->expectException(UnexpectedServerResponseException::class);
+        $this->expectExceptionMessage('Unexpected response from the Redis server: Incomplete response');
+        $command = new SugGet();
+        $command->setWithPayloads();
+        $command->parseResponse(['foobar']);
+    }
+}
diff --git a/tests/Index/Builder/GeoFieldTest.php b/tests/Redis/Command/SugLenTest.php
similarity index 61%
rename from tests/Index/Builder/GeoFieldTest.php
rename to tests/Redis/Command/SugLenTest.php
index 1c54c41..bb93185 100644
--- a/tests/Index/Builder/GeoFieldTest.php
+++ b/tests/Redis/Command/SugLenTest.php
@@ -19,31 +19,37 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace Tests\MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\tests\Redis\Command;
 
-use MacFJA\RediSearch\Index\Builder\GeoField;
+use MacFJA\RediSearch\Redis\Command\SugLen;
 use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
 
 /**
- * @covers \MacFJA\RediSearch\Index\Builder\GeoField
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SugLen
  *
- * @uses \MacFJA\RediSearch\Index\Builder\AbstractField
- * @uses \MacFJA\RediSearch\Helper\RedisHelper
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
  */
-class GeoFieldTest extends TestCase
+class SugLenTest extends TestCase
 {
-    use Assertion;
-
-    public function testNominal(): void
+    public function testGetId(): void
     {
-        self::assertSameQuery('address GEO NOINDEX', new GeoField('address', true));
-        self::assertSameQuery('position GEO', new GeoField('position', false));
-        self::assertSameQuery('coordinate GEO', new GeoField('coordinate'));
+        $command = new SugLen();
+        static::assertSame('FT.SUGLEN', $command->getId());
     }
 
-    public function testGetType(): void
+    public function testFullOption(): void
     {
-        self::assertSame('GEO', (new GeoField('foobar'))->getType());
+        $command = new SugLen();
+        $command
+            ->setDictionary('ac')
+        ;
+
+        static::assertSame([
+            'ac',
+        ], $command->getArguments());
     }
 }
diff --git a/src/Search/Filter.php b/tests/Redis/Command/SynDumpTest.php
similarity index 59%
rename from src/Search/Filter.php
rename to tests/Redis/Command/SynDumpTest.php
index 76cf707..88a6d21 100644
--- a/src/Search/Filter.php
+++ b/tests/Redis/Command/SynDumpTest.php
@@ -19,33 +19,37 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Search;
+namespace MacFJA\RediSearch\tests\Redis\Command;
 
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\PartialQuery;
-use RangeException;
+use MacFJA\RediSearch\Redis\Command\SynDump;
+use PHPUnit\Framework\TestCase;
 
-class Filter implements PartialQuery
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SynDump
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class SynDumpTest extends TestCase
 {
-    /** @var string */
-    private $fieldName;
-
-    /** @var float */
-    private $min;
-
-    /** @var float */
-    private $max;
-
-    public function __construct(string $fieldName, float $min, float $max)
+    public function testGetId(): void
     {
-        DataHelper::assert($min < $max, new RangeException('Maximum can\'t be inferior to minimum'));
-        $this->fieldName = $fieldName;
-        $this->min = $min;
-        $this->max = $max;
+        $command = new SynDump();
+        static::assertSame('FT.SYNDUMP', $command->getId());
     }
 
-    public function getQueryParts(): array
+    public function testFullOption(): void
     {
-        return ['FILTER', $this->fieldName, $this->min, $this->max];
+        $command = new SynDump();
+        $command
+            ->setIndex('idx')
+        ;
+
+        static::assertSame([
+            'idx',
+        ], $command->getArguments());
     }
 }
diff --git a/src/Index/Builder/TagField.php b/tests/Redis/Command/SynUpdateTest.php
similarity index 52%
rename from src/Index/Builder/TagField.php
rename to tests/Redis/Command/SynUpdateTest.php
index 93a1d33..837bdff 100644
--- a/src/Index/Builder/TagField.php
+++ b/tests/Redis/Command/SynUpdateTest.php
@@ -19,42 +19,41 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Index\Builder;
+namespace MacFJA\RediSearch\tests\Redis\Command;
 
-use MacFJA\RediSearch\Helper\DataHelper;
-use MacFJA\RediSearch\Helper\RedisHelper;
-use MacFJA\RediSearch\Index\Builder\Exception\MustBeSingleCharException;
-use function strlen;
+use MacFJA\RediSearch\Redis\Command\SynUpdate;
+use PHPUnit\Framework\TestCase;
 
-class TagField extends AbstractField
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\SynUpdate
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ *
+ * @internal
+ */
+class SynUpdateTest extends TestCase
 {
-    /** @var null|string */
-    private $separator;
-
-    public function __construct(string $name, ?string $separator = null, bool $sortable = false, bool $noIndex = false)
-    {
-        DataHelper::assert(
-            null === $separator || 1 === strlen($separator),
-            new MustBeSingleCharException('Separator must be a single char')
-        );
-        parent::__construct($name, $sortable, $noIndex);
-        $this->separator = $separator;
-    }
-
-    public function getType(): string
-    {
-        return self::TYPE_TAG;
-    }
-
-    public function getSeparator(): ?string
+    public function testGetId(): void
     {
-        return $this->separator;
+        $command = new SynUpdate();
+        static::assertSame('FT.SYNUPDATE', $command->getId());
     }
 
-    protected function getAdditionalQueryParts(): array
+    public function testFullOption(): void
     {
-        return RedisHelper::buildQueryNotNull([], [
-            'SEPARATOR' => $this->separator,
-        ]);
+        $command = new SynUpdate();
+        $command
+            ->setIndex('idx')
+            ->setGroupId('blue')
+            ->setSkipInitialScan()
+            ->setTerms('blue', 'cyan', 'navy', 'teal')
+        ;
+
+        static::assertSame([
+            'idx', 'blue', 'SKIPINITIALSCAN', 'blue', 'cyan', 'navy', 'teal',
+        ], $command->getArguments());
     }
 }
diff --git a/tests/Redis/Command/TagValsTest.php b/tests/Redis/Command/TagValsTest.php
new file mode 100644
index 0000000..a2f650e
--- /dev/null
+++ b/tests/Redis/Command/TagValsTest.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command\TagVals;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @covers \MacFJA\RediSearch\Redis\Command\TagVals
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ *
+ * @internal
+ */
+class TagValsTest extends TestCase
+{
+    public function testGetId(): void
+    {
+        $command = new TagVals();
+        static::assertSame('FT.TAGVALS', $command->getId());
+    }
+
+    public function testFullOption(): void
+    {
+        $command = new TagVals();
+        $command
+            ->setIndex('idx')
+            ->setField('myTag')
+        ;
+
+        static::assertSame([
+            'idx', 'myTag',
+        ], $command->getArguments());
+    }
+}
diff --git a/tests/Redis/InitializerTest.php b/tests/Redis/InitializerTest.php
new file mode 100644
index 0000000..700d94d
--- /dev/null
+++ b/tests/Redis/InitializerTest.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis;
+
+use Generator;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Initializer;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Initializer
+ *
+ * @internal
+ */
+class InitializerTest extends TestCase
+{
+    /**
+     * @param array<string> $input
+     *
+     * @dataProvider dataProvider
+     */
+    public function testRediSearchVersion(array $input, ?string $expected): void
+    {
+        $client = $this->createMock(Client::class);
+        $client->method('executeRaw')->with('module', 'list')->willReturn($input);
+
+        static::assertSame($expected, Initializer::getRediSearchVersion($client));
+    }
+
+    /**
+     * @param array<string> $input
+     *
+     * @dataProvider dataProvider
+     */
+    public function testRedisJSONVersion(array $input, ?string $expected): void
+    {
+        $client = $this->createMock(Client::class);
+        $client->method('executeRaw')->with('module', 'list')->willReturn($input);
+
+        static::assertSame($expected, Initializer::getRedisJSONVersion($client));
+    }
+
+    /**
+     * @return Generator<array>
+     */
+    public function dataProvider(string $testName): Generator
+    {
+        if ('testRediSearchVersion' === $testName) {
+            yield [[['name', 'search', 'ver', '20005']], '2.0.5'];
+
+            yield [[['name', 'search', 'ver', '20000']], '2.0.0'];
+
+            yield [[['name', 'search', 'ver', '26512']], '2.65.12'];
+
+            yield [[['name', 'search', 'ver', '120569']], '12.5.69'];
+
+            yield [[['name', 'search', 'ver', '99999']], '9.99.99'];
+
+            yield [[['name', 'json', 'ver', '99999']], null];
+
+            yield [[['name', 'json', 'ver', '99999'], ['name', 'search', 'ver', '20005']], '2.0.5'];
+        }
+        if ('testRedisJSONVersion' === $testName) {
+            yield [[['name', 'ReJSON', 'ver', '20005']], '2.0.5'];
+
+            yield [[['name', 'ReJSON', 'ver', '20000']], '2.0.0'];
+
+            yield [[['name', 'ReJSON', 'ver', '26512']], '2.65.12'];
+
+            yield [[['name', 'ReJSON', 'ver', '120569']], '12.5.69'];
+
+            yield [[['name', 'ReJSON', 'ver', '99999']], '9.99.99'];
+
+            yield [[['name', 'json', 'ver', '99999']], null];
+
+            yield [[['name', 'json', 'ver', '99999'], ['name', 'ReJSON', 'ver', '20005']], '2.0.5'];
+        }
+    }
+}
diff --git a/tests/Redis/Response/ArrayResponseTraitTest.php b/tests/Redis/Response/ArrayResponseTraitTest.php
new file mode 100644
index 0000000..19bd0df
--- /dev/null
+++ b/tests/Redis/Response/ArrayResponseTraitTest.php
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Response;
+
+use MacFJA\RediSearch\tests\fixtures\Redis\Response\ArrayResponseTraitTester;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Response\ArrayResponseTrait
+ *
+ * @internal
+ */
+class ArrayResponseTraitTest extends TestCase
+{
+    public function testOddGetPairs(): void
+    {
+        $data = [1, 2, 3];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(['1' => 2], $inlineArrayResponse->publicGetPairs($data));
+    }
+
+    public function testEvenGetPairs(): void
+    {
+        $data = [1, 2, 3, 4];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(['1' => 2, '3' => 4], $inlineArrayResponse->publicGetPairs($data));
+    }
+
+    public function testOddGetKeys(): void
+    {
+        $data = [1, 2, 3];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(['1'], $inlineArrayResponse->publicGetKeys($data));
+    }
+
+    public function testEvenGetKeys(): void
+    {
+        $data = [1, 2, 3, 4];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(['1', '3'], $inlineArrayResponse->publicGetKeys($data));
+    }
+
+    public function testOddGetValue(): void
+    {
+        $data = [1, 2, 3];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(2, $inlineArrayResponse->publicGetValue($data, '1'));
+        static::assertNull($inlineArrayResponse->publicGetValue($data, '3'));
+    }
+
+    public function testEvenGetValue(): void
+    {
+        $data = [1, 2, 3, 4];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(2, $inlineArrayResponse->publicGetValue($data, '1'));
+        static::assertSame(4, $inlineArrayResponse->publicGetValue($data, '3'));
+        static::assertNull($inlineArrayResponse->publicGetValue($data, '6'));
+    }
+
+    public function testOddGetNextValue(): void
+    {
+        $data = [1, 2, 3];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(2, $inlineArrayResponse->publicGetNextValue($data, '1'));
+        static::assertSame(3, $inlineArrayResponse->publicGetNextValue($data, '2'));
+        static::assertNull($inlineArrayResponse->publicGetNextValue($data, '3'));
+    }
+
+    public function testEvenGetNextValue(): void
+    {
+        $data = [1, 2, 3, 4];
+
+        $inlineArrayResponse = $this->getInlineArrayResponse();
+
+        static::assertSame(2, $inlineArrayResponse->publicGetNextValue($data, '1'));
+        static::assertSame(3, $inlineArrayResponse->publicGetNextValue($data, '2'));
+        static::assertSame(4, $inlineArrayResponse->publicGetNextValue($data, '3'));
+        static::assertNull($inlineArrayResponse->publicGetNextValue($data, '6'));
+    }
+
+    private function getInlineArrayResponse(): ArrayResponseTraitTester
+    {
+        return new ArrayResponseTraitTester();
+    }
+}
diff --git a/tests/Redis/Response/InfoResponseTest.php b/tests/Redis/Response/InfoResponseTest.php
new file mode 100644
index 0000000..04df0cf
--- /dev/null
+++ b/tests/Redis/Response/InfoResponseTest.php
@@ -0,0 +1,297 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Response;
+
+use BadMethodCallException;
+use InvalidArgumentException;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption;
+use MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption;
+use MacFJA\RediSearch\Redis\Command\Info;
+use MacFJA\RediSearch\Redis\Response\InfoResponse;
+use PHPUnit\Framework\TestCase;
+use Predis\Response\Status;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Command\AbstractCommand::parseResponse
+ * @covers \MacFJA\RediSearch\Redis\Command\Info
+ * @covers \MacFJA\RediSearch\Redis\Response\InfoResponse
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand::__construct
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\GeoFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TagFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionAwareTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ *
+ * @internal
+ */
+class InfoResponseTest extends TestCase
+{
+    public function testResponse(): void
+    {
+        $rawResponse = [
+            0 => new Status('index_name'),
+            1 => new Status('idxt'),
+            2 => new Status('index_options'),
+            3 => [],
+            4 => new Status('index_definition'),
+            5 => [
+                0 => new Status('key_type'),
+                1 => new Status('HASH'),
+                2 => new Status('prefixes'),
+                3 => [0 => new Status('test')],
+                4 => new Status('language_field'),
+                5 => new Status('__language'),
+                6 => new Status('default_score'),
+                7 => '1',
+                8 => new Status('score_field'),
+                9 => new Status('__score'),
+                10 => new Status('payload_field'),
+                11 => new Status('__payload'),
+            ],
+            6 => new Status('fields'),
+            7 => [
+                0 => [
+                    0 => new Status('text'),
+                    1 => new Status('type'),
+                    2 => new Status('TEXT'),
+                    3 => new Status('WEIGHT'),
+                    4 => '1',
+                ],
+                1 => [
+                    0 => new Status('labels'),
+                    1 => new Status('type'),
+                    2 => new Status('TAG'),
+                ],
+                2 => [
+                    0 => new Status('place'),
+                    1 => new Status('type'),
+                    2 => new Status('GEO'),
+                ],
+                3 => [
+                    0 => new Status('count'),
+                    1 => new Status('type'),
+                    2 => new Status('NUMERIC'),
+                    3 => new Status('NOINDEX'),
+                ],
+            ],
+            8 => new Status('num_docs'),
+            9 => '4',
+            10 => new Status('max_doc_id'),
+            11 => '12',
+            12 => new Status('num_terms'),
+            13 => '4',
+            14 => new Status('num_records'),
+            15 => '30',
+            16 => new Status('inverted_sz_mb'),
+            17 => '0.000171661376953125',
+            18 => new Status('total_inverted_index_blocks'),
+            19 => '1801',
+            20 => new Status('offset_vectors_sz_mb'),
+            21 => '2.86102294921875e-05',
+            22 => new Status('doc_table_size_mb'),
+            23 => '0.000995635986328125',
+            24 => new Status('sortable_values_size_mb'),
+            25 => '0',
+            26 => new Status('key_table_size_mb'),
+            27 => '0.00012493133544921875',
+            28 => new Status('records_per_doc_avg'),
+            29 => '7.5',
+            30 => new Status('bytes_per_record_avg'),
+            31 => '6',
+            32 => new Status('offsets_per_term_avg'),
+            33 => '1',
+            34 => new Status('offset_bits_per_record_avg'),
+            35 => '8',
+            36 => new Status('hash_indexing_failures'),
+            37 => '0',
+            38 => new Status('indexing'),
+            39 => '0',
+            40 => new Status('percent_indexed'),
+            41 => '1',
+            42 => new Status('gc_stats'),
+            43 => [
+                0 => new Status('bytes_collected'),
+                1 => '0',
+                2 => new Status('total_ms_run'),
+                3 => '0',
+                4 => new Status('total_cycles'),
+                5 => '0',
+                6 => new Status('average_cycle_time_ms'),
+                7 => '-nan',
+                8 => new Status('last_run_time_ms'),
+                9 => '0',
+                10 => new Status('gc_numeric_trees_missed'),
+                11 => '0',
+                12 => new Status('gc_blocks_denied'),
+                13 => '0',
+            ],
+            44 => new Status('cursor_stats'),
+            45 => [
+                0 => new Status('global_idle'),
+                1 => 63,
+                2 => new Status('global_total'),
+                3 => 63,
+                4 => new Status('index_capacity'),
+                5 => 128,
+                6 => new Status('index_total'),
+                7 => 63,
+            ],
+        ];
+        $command = new Info();
+
+        /** @var InfoResponse $parsed */
+        $parsed = $command->parseResponse($rawResponse);
+
+        static::assertSame('idxt', $parsed->getIndexName());
+        static::assertSame(4, $parsed->getNumDocs());
+        static::assertSame(12, $parsed->getMaxDocId());
+        static::assertSame(4, $parsed->getNumTerms());
+        static::assertSame(30, $parsed->getNumRecords());
+        static::assertSame(0.000171661376953125, $parsed->getInvertedSzMb());
+        static::assertSame(1801, $parsed->getTotalInvertedIndexBlocks());
+        static::assertSame(2.86102294921875e-05, $parsed->getOffsetVectorsSzMb());
+        static::assertSame(0.000995635986328125, $parsed->getDocTableSizeMb());
+        static::assertSame(0.0, $parsed->getSortableValuesSizeMb());
+        static::assertSame(0.00012493133544921875, $parsed->getKeyTableSizeMb());
+        static::assertSame(7.5, $parsed->getRecordsPerDocAvg());
+        static::assertSame(6.0, $parsed->getBytesPerRecordAvg());
+        static::assertSame(1.0, $parsed->getOffsetsPerTermAvg());
+        static::assertSame(8.0, $parsed->getOffsetBitsPerRecordAvg());
+        static::assertSame(0, $parsed->getHashIndexingFailures());
+        static::assertFalse($parsed->getIndexing());
+        static::assertSame(1.0, $parsed->getPercentIndexed());
+
+        $this->doTestGetFieldsAsOption($parsed);
+        $this->doTestGetIndexDefinition($parsed);
+    }
+
+    public function doTestGetFieldsAsOption(InfoResponse $parsed): void
+    {
+        $response = $parsed->getFieldsAsOption();
+
+        static::assertSame(['text', 'TEXT', 'WEIGHT', '1'], $response[0]->render());
+        static::assertInstanceOf(TextFieldOption::class, $response[0]);
+        static::assertSame(['labels', 'TAG'], $response[1]->render());
+        static::assertInstanceOf(TagFieldOption::class, $response[1]);
+        static::assertSame(['place', 'GEO'], $response[2]->render());
+        static::assertInstanceOf(GeoFieldOption::class, $response[2]);
+        static::assertSame(['count', 'NUMERIC', 'NOINDEX'], $response[3]->render());
+        static::assertInstanceOf(NumericFieldOption::class, $response[3]);
+    }
+
+    public function doTestGetIndexDefinition(InfoResponse $parsed): void
+    {
+        static::assertEquals(
+            [
+                'key_type' => new Status('HASH'),
+                'prefixes' => [0 => new Status('test')],
+                'language_field' => new Status('__language'),
+                'default_score' => '1',
+                'score_field' => new Status('__score'),
+                'payload_field' => new Status('__payload'),
+            ],
+            $parsed->getIndexDefinition()
+        );
+
+        static::assertSame(
+            'HASH',
+            (string) $parsed->getIndexDefinition('key_type')
+        );
+        static::assertSame(
+            1.0,
+            (float) $parsed->getIndexDefinition('default_score')
+        );
+    }
+
+    public function testUnknownKey(): void
+    {
+        $response = new InfoResponse([]);
+        $this->expectException(InvalidArgumentException::class);
+
+        $response->getHelloWorld(); // @phpstan-ignore-line
+    }
+
+    public function testUnknownBadNaming1(): void
+    {
+        $response = new InfoResponse([]);
+        $this->expectException(BadMethodCallException::class);
+
+        $response->gethelloWorld(); // @phpstan-ignore-line
+    }
+
+    public function testUnknownBadNaming2(): void
+    {
+        $response = new InfoResponse([]);
+        $this->expectException(BadMethodCallException::class);
+
+        $response->gotHelloWorld(); // @phpstan-ignore-line
+    }
+
+    public function testNotANumber(): void
+    {
+        $response = new InfoResponse(['num_docs', '-nan']);
+
+        static::assertNull($response->getNumDocs());
+    }
+
+    public function testDataCasting(): void
+    {
+        $response = new InfoResponse([
+            'records_per_doc_avg', '1.5',
+            'max_doc_id', '50',
+            'index_name', 'idx',
+            'indexing', '1',
+            'non_existent_string', 'hello',
+            'non_existent_float', 1.2,
+            'non_existent_int', 1,
+        ]);
+
+        static::assertSame(1.5, $response->getRecordsPerDocAvg());
+        static::assertSame(50, $response->getMaxDocId());
+        static::assertSame('idx', $response->getIndexName());
+        static::assertTrue($response->getIndexing());
+        // @phpstan-ignore-next-line
+        static::assertSame('hello', $response->getNonExistentString());
+        // @phpstan-ignore-next-line
+        static::assertSame(1.2, $response->getNonExistentFloat());
+        // @phpstan-ignore-next-line
+        static::assertSame(1, $response->getNonExistentInt());
+    }
+
+    public function testMissingIndexDefinition(): void
+    {
+        $response = new InfoResponse([]);
+        static::assertNull($response->getIndexDefinition('foobar'));
+        static::assertSame([], $response->getIndexDefinition());
+        static::assertEmpty($response->getIndexDefinition());
+    }
+}
diff --git a/tests/Redis/Response/PaginatedResponseTest.php b/tests/Redis/Response/PaginatedResponseTest.php
new file mode 100644
index 0000000..445a760
--- /dev/null
+++ b/tests/Redis/Response/PaginatedResponseTest.php
@@ -0,0 +1,191 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\Redis\Response;
+
+use function array_slice;
+
+use MacFJA\RediSearch\Exception\MissingClientException;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command\PaginatedCommand;
+use MacFJA\RediSearch\Redis\Response\PaginatedResponse;
+use MacFJA\RediSearch\Redis\Response\SearchResponseItem;
+use MacFJA\RediSearch\tests\fixtures\Redis\Command\FakePaginatedCommand;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Exception\MissingClientException
+ * @covers \MacFJA\RediSearch\Redis\Response\PaginatedResponse
+ *
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ *
+ * @internal
+ */
+class PaginatedResponseTest extends TestCase
+{
+    public function testMultiplePages(): void
+    {
+        [$client, $command] = $this->getClientAndCommand();
+
+        /** @var PaginatedResponse $response */
+        $response = $client->execute($command);
+
+        static::assertInstanceOf(PaginatedResponse::class, $response);
+
+        static::assertTrue($response->valid());
+        static::assertSame('foo', $response->current()[0]->getFieldValue('f1'));
+        static::assertCount(4, $response);
+        static::assertEquals(2, $response->getPageSize());
+        static::assertEquals(4, $response->getPageCount());
+        static::assertEquals(7, $response->getTotalCount());
+        static::assertEquals(0, $response->key());
+
+        $response->setClient($client);
+
+        $response->next();
+
+        static::assertTrue($response->valid());
+        static::assertSame('bob', $response->current()[0]->getFieldValue('f1'));
+        static::assertCount(4, $response);
+        static::assertEquals(2, $response->getPageSize());
+        static::assertEquals(4, $response->getPageCount());
+        static::assertEquals(7, $response->getTotalCount());
+        static::assertEquals(1, $response->key());
+
+        $response->next();
+
+        static::assertTrue($response->valid());
+        static::assertSame('lorem', $response->current()[0]->getFieldValue('f1'));
+        static::assertCount(4, $response);
+        static::assertEquals(2, $response->getPageSize());
+        static::assertEquals(4, $response->getPageCount());
+        static::assertEquals(7, $response->getTotalCount());
+        static::assertEquals(2, $response->key());
+
+        $response->next();
+
+        static::assertTrue($response->valid());
+        static::assertSame('odd', $response->current()[0]->getFieldValue('f1'));
+        static::assertCount(4, $response);
+        static::assertEquals(2, $response->getPageSize());
+        static::assertEquals(4, $response->getPageCount());
+        static::assertEquals(7, $response->getTotalCount());
+        static::assertEquals(3, $response->key());
+
+        $response->next();
+
+        static::assertFalse($response->valid());
+    }
+
+    public function testMultiplePagesIterator(): void
+    {
+        [$client, $command] = $this->getClientAndCommand();
+
+        /** @var PaginatedResponse $response */
+        $response = $client->execute($command);
+
+        static::assertInstanceOf(PaginatedResponse::class, $response);
+        $response->setClient($client);
+
+        foreach ($response as $index => $page) {
+            if (0 === $index) {
+                static::assertSame('foo', $response->current()[0]->getFieldValue('f1'));
+            }
+            if (1 === $index) {
+                static::assertSame('bob', $response->current()[0]->getFieldValue('f1'));
+            }
+            if (2 === $index) {
+                static::assertSame('lorem', $response->current()[0]->getFieldValue('f1'));
+            }
+            if (3 === $index) {
+                static::assertSame('odd', $response->current()[0]->getFieldValue('f1'));
+            }
+
+            static::assertEquals(2, $response->getPageSize());
+            static::assertEquals(4, $response->getPageCount());
+            static::assertEquals(7, $response->getTotalCount());
+        }
+    }
+
+    public function testMissingClient(): void
+    {
+        $this->expectException(MissingClientException::class);
+        [$client, $command] = $this->getClientAndCommand();
+
+        /** @var PaginatedResponse $response */
+        $response = $client->execute($command);
+
+        static::assertInstanceOf(PaginatedResponse::class, $response);
+
+        static::assertTrue($response->valid());
+
+        $response->next();
+        $response->current();
+    }
+
+    public function testPageSizeSetToZero(): void
+    {
+        $command = new FakePaginatedCommand([]);
+        $command->setLimit(0, 0);
+        $response = new PaginatedResponse($command, 2, []);
+
+        static::assertSame(0, $response->key());
+        static::assertSame(0, $response->getPageCount());
+        static::assertCount(0, $response);
+    }
+
+    /**
+     * @return array{0:Client,1:FakePaginatedCommand}
+     */
+    private function getClientAndCommand(): array
+    {
+        $command = new FakePaginatedCommand([]);
+
+        $client = $this->createMock(Client::class);
+        $client->method('execute')->willReturnOnConsecutiveCalls(
+            self::getResponses($command, 0, 2),
+            self::getResponses($command, 2, 4),
+            self::getResponses($command, 4, 6),
+            self::getResponses($command, 6, 7)
+        );
+
+        return [$client, $command];
+    }
+
+    private static function getResponses(PaginatedCommand $command, int $from, int $to): PaginatedResponse
+    {
+        $response = [
+            new SearchResponseItem('foobar1', ['f1' => 'foo', 'f2' => 'bar']),
+            new SearchResponseItem('foobar2', ['f1' => 'baz', 'f2' => 'alice']),
+            new SearchResponseItem('foobar3', ['f1' => 'bob', 'f2' => 'charlie']),
+            new SearchResponseItem('foobar4', ['f1' => 'john', 'f2' => 'doe']),
+            new SearchResponseItem('foobar5', ['f1' => 'lorem', 'f2' => 'ipsum']),
+            new SearchResponseItem('foobar6', ['f1' => 'foobar', 'f2' => 'foobaz']),
+            new SearchResponseItem('foobar7', ['f1' => 'odd', 'f2' => 'page']),
+        ];
+
+        return new PaginatedResponse(
+            $command,
+            7,
+            array_slice($response, $from, $to - $from, false)
+        );
+    }
+}
diff --git a/tests/Search/QueryBuilder/GeoFacetTest.php b/tests/Search/QueryBuilder/GeoFacetTest.php
deleted file mode 100644
index 23d836c..0000000
--- a/tests/Search/QueryBuilder/GeoFacetTest.php
+++ /dev/null
@@ -1,90 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Search\QueryBuilder;
-
-use MacFJA\RediSearch\Search\Exception\UnknownUnitException;
-use MacFJA\RediSearch\Search\GeoFilter;
-use MacFJA\RediSearch\Search\QueryBuilder\GeoFacet;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery;
-use function PHPUnit\Framework\assertFalse;
-use function PHPUnit\Framework\assertSame;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Search\QueryBuilder\GeoFacet
- * @covers ::__construct
- *
- * @uses \MacFJA\RediSearch\Helper\EscapeHelper
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\OrGroup
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\Word
- * @uses \MacFJA\RediSearch\Search\Exception\UnknownUnitException
- */
-class GeoFacetTest extends TestCase
-{
-    use Assertion;
-
-    /**
-     * @covers ::render
-     */
-    public function testNominal(): void
-    {
-        self::assertSameRender('@foobar:[-74.044502 40.689247 40.000000 km]', new GeoFacet('foobar', -74.044502, 40.689247, 40, GeoFilter::UNIT_KILOMETERS));
-    }
-
-    public function testValidUnits(): void
-    {
-        $this->expectNotToPerformAssertions();
-        new GeoFacet('foobar', -74.044502, 40.689247, 40, GeoFilter::UNIT_KILOMETERS);
-        new GeoFacet('foobar', -74.044502, 40.689247, 40, GeoFilter::UNIT_FEET);
-        new GeoFacet('foobar', -74.044502, 40.689247, 40, GeoFilter::UNIT_METERS);
-        new GeoFacet('foobar', -74.044502, 40.689247, 40, GeoFilter::UNIT_MILES);
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Search\Exception\UnknownUnitException
-     */
-    public function testUnknownUnit(): void
-    {
-        $this->expectException(UnknownUnitException::class);
-        $this->expectExceptionMessage('The unit "bar" is not valid.');
-
-        new GeoFacet('foo', 0, 0, 0, 'bar');
-    }
-
-    /**
-     * @covers ::includeSpace
-     */
-    public function testIncludeSpace(): void
-    {
-        assertFalse((new GeoFacet('foo', 0, 0, 0, 'm'))->includeSpace());
-    }
-
-    /**
-     * @covers ::priority
-     */
-    public function testPriority(): void
-    {
-        assertSame(PartialQuery::PRIORITY_NORMAL, (new GeoFacet('foo', 0, 0, 0, 'm'))->priority());
-    }
-}
diff --git a/tests/Search/QueryBuilder/NumericFacetTest.php b/tests/Search/QueryBuilder/NumericFacetTest.php
deleted file mode 100644
index 01ae0ca..0000000
--- a/tests/Search/QueryBuilder/NumericFacetTest.php
+++ /dev/null
@@ -1,117 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Search\QueryBuilder;
-
-use MacFJA\RediSearch\Search\QueryBuilder\NumericFacet;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Search\QueryBuilder\NumericFacet
- * @covers ::__construct
- * @covers ::render
- *
- * @uses \MacFJA\RediSearch\Helper\EscapeHelper
- *
- * @see: https://oss.redislabs.com/redisearch/Query_Syntax/#mapping_common_sql_predicates_to_redisearch
- * WHERE num BETWEEN 10 AND 20	    @num:[10 20]
- * WHERE num >= 10	                @num:[10 +inf]
- * WHERE num > 10	                @num:[(10 +inf]
- * WHERE num < 10	                @num:[-inf (10]
- * WHERE num <= 10	                @num:[-inf 10]
- * WHERE num < 10 OR num > 20	    @num:[-inf (10] | @num:[(20 +inf]
- */
-class NumericFacetTest extends TestCase
-{
-    use Assertion;
-
-    public function testNominal(): void
-    {
-        self::assertSameRender('@foo:[0 10]', new NumericFacet('foo', 0, 10));
-        self::assertSameRender('@foo:[0 10]', new NumericFacet('foo', 0, 10, true, true));
-        self::assertSameRender('@foo:[(0 10]', new NumericFacet('foo', 0, 10, false));
-        self::assertSameRender('@foo:[0 (10]', new NumericFacet('foo', 0, 10, true, false));
-        self::assertSameRender('@foo:[(0 (10]', new NumericFacet('foo', 0, 10, false, false));
-    }
-
-    /**
-     * @covers ::greaterThan
-     */
-    public function testGreaterThan()
-    {
-        $facet = NumericFacet::greaterThan('num', 10);
-        self::assertSameRender('@num:[(10 +inf]', $facet);
-    }
-
-    /**
-     * @covers ::greaterThanOrEquals
-     */
-    public function testGreaterThanOrEquals()
-    {
-        $facet = NumericFacet::greaterThanOrEquals('num', 10);
-        self::assertSameRender('@num:[10 +inf]', $facet);
-    }
-
-    /**
-     * @covers ::lessThan
-     */
-    public function testLessThan()
-    {
-        $facet = NumericFacet::lessThan('num', 10);
-        self::assertSameRender('@num:[-inf (10]', $facet);
-    }
-
-    /**
-     * @covers ::lessThanOrEquals
-     */
-    public function testLessThanOrEquals()
-    {
-        $facet = NumericFacet::lessThanOrEquals('num', 10);
-        self::assertSameRender('@num:[-inf 10]', $facet);
-    }
-
-    /**
-     * @covers ::equalsTo
-     */
-    public function testEqualsTo()
-    {
-        $facet = NumericFacet::equalsTo('num', 10);
-        self::assertSameRender('@num:[10 10]', $facet);
-    }
-
-    /**
-     * @covers ::includeSpace
-     */
-    public function testIncludeSpace(): void
-    {
-        self::assertFalse((new NumericFacet('foobar', null, null))->includeSpace());
-    }
-
-    /**
-     * @covers ::priority
-     */
-    public function testPriority(): void
-    {
-        self::assertSame(PartialQuery::PRIORITY_NORMAL, (new NumericFacet('foobar', null, null))->priority());
-    }
-}
diff --git a/tests/Search/QueryBuilder/TagFacetTest.php b/tests/Search/QueryBuilder/TagFacetTest.php
deleted file mode 100644
index 91cecf8..0000000
--- a/tests/Search/QueryBuilder/TagFacetTest.php
+++ /dev/null
@@ -1,84 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Search\QueryBuilder;
-
-use MacFJA\RediSearch\Search\Exception\NotEnoughTermsException;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery;
-use MacFJA\RediSearch\Search\QueryBuilder\TagFacet;
-use function PHPUnit\Framework\assertFalse;
-use function PHPUnit\Framework\assertSame;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Search\QueryBuilder\TagFacet
- * @covers ::__construct
- *
- * @uses \MacFJA\RediSearch\Helper\EscapeHelper
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\OrGroup
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\Word
- */
-class TagFacetTest extends TestCase
-{
-    use Assertion;
-
-    /**
-     * @covers ::render
-     */
-    public function testNominal(): void
-    {
-        self::assertSameRender('@foobar:{hello}', new TagFacet('foobar', 'hello'));
-        self::assertSameRender('@foobar:{hello|world}', new TagFacet('foobar', 'hello', 'world'));
-        self::assertSameRender('@foobar:{hello world}', new TagFacet('foobar', 'hello world'));
-        self::assertSameRender('@foobar:{hello\|world}', new TagFacet('foobar', 'hello|world'));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Search\Exception\NotEnoughTermsException
-     *
-     * @uses \MacFJA\RediSearch\Words\Exception\NotEnoughTermsException
-     */
-    public function testNotEnoughValues(): void
-    {
-        $this->expectException(NotEnoughTermsException::class);
-        $this->expectExceptionMessage('You must have at least one term');
-
-        new TagFacet('foobar');
-    }
-
-    /**
-     * @covers ::includeSpace
-     */
-    public function testIncludeSpace(): void
-    {
-        assertFalse((new TagFacet('foo', 'bar'))->includeSpace());
-    }
-
-    /**
-     * @covers ::priority
-     */
-    public function testPriority(): void
-    {
-        assertSame(PartialQuery::PRIORITY_NORMAL, (new TagFacet('foo', 'bar'))->priority());
-    }
-}
diff --git a/tests/Search/QueryBuilder/TextFacetTest.php b/tests/Search/QueryBuilder/TextFacetTest.php
deleted file mode 100644
index ffcc3d5..0000000
--- a/tests/Search/QueryBuilder/TextFacetTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-/*
- * Copyright MacFJA
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
- * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
- * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
- * permit persons to whom the Software is furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
- * Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
- * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
- * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
- * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
- */
-
-namespace Tests\MacFJA\RediSearch\Search\QueryBuilder;
-
-use MacFJA\RediSearch\Search\Exception\NotEnoughTermsException;
-use MacFJA\RediSearch\Search\QueryBuilder\PartialQuery;
-use MacFJA\RediSearch\Search\QueryBuilder\TextFacet;
-use function PHPUnit\Framework\assertFalse;
-use function PHPUnit\Framework\assertSame;
-use PHPUnit\Framework\TestCase;
-use Tests\MacFJA\RediSearch\support\Assertion;
-
-/**
- * @coversDefaultClass \MacFJA\RediSearch\Search\QueryBuilder\TextFacet
- * @covers ::__construct
- *
- * @uses \MacFJA\RediSearch\Helper\EscapeHelper
- * @uses \MacFJA\RediSearch\Helper\DataHelper
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\ExactMatch
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\OrGroup
- * @uses \MacFJA\RediSearch\Search\QueryBuilder\Word
- */
-class TextFacetTest extends TestCase
-{
-    use Assertion;
-
-    /**
-     * @covers ::render
-     */
-    public function testNominal(): void
-    {
-        self::assertSameRender('@foobar:hello', new TextFacet('foobar', 'hello'));
-        self::assertSameRender('@foobar:(hello|world)', new TextFacet('foobar', 'hello', 'world'));
-        self::assertSameRender('@foobar:(hello world)', new TextFacet('foobar', 'hello world'));
-        self::assertSameRender('@foobar:hello\|world', new TextFacet('foobar', 'hello|world'));
-    }
-
-    /**
-     * @covers \MacFJA\RediSearch\Search\Exception\NotEnoughTermsException
-     *
-     * @uses \MacFJA\RediSearch\Words\Exception\NotEnoughTermsException
-     */
-    public function testNotEnoughValues(): void
-    {
-        $this->expectException(NotEnoughTermsException::class);
-        $this->expectExceptionMessage('You must have at least one term');
-
-        new TextFacet('foobar');
-    }
-
-    /**
-     * @covers ::includeSpace
-     */
-    public function testIncludeSpace(): void
-    {
-        assertFalse((new TextFacet('foo', 'bar'))->includeSpace());
-    }
-
-    /**
-     * @covers ::priority
-     */
-    public function testPriority(): void
-    {
-        assertSame(PartialQuery::PRIORITY_NORMAL, (new TextFacet('foo', 'bar'))->priority());
-    }
-}
diff --git a/tests/fixes/PR42Test.php b/tests/fixes/PR42Test.php
new file mode 100644
index 0000000..07063cf
--- /dev/null
+++ b/tests/fixes/PR42Test.php
@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixes;
+
+use Exception;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Initializer;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @covers \MacFJA\RediSearch\Redis\Initializer
+ *
+ * @internal
+ */
+class PR42Test extends TestCase
+{
+    public function testUnavailableCommand(): void
+    {
+        $client = $this->createMock(Client::class);
+        $client->method('executeRaw')->willThrowException(new Exception('ERR unknown command `module`, with args beginning with: `list`'));
+
+        $response = Initializer::getRediSearchVersion($client);
+
+        static::assertNull($response);
+    }
+
+    public function testUnknownError(): void
+    {
+        $this->expectException(Exception::class);
+
+        $client = $this->createMock(Client::class);
+        $client->method('executeRaw')->willThrowException(new Exception('ERR unknown server error'));
+
+        Initializer::getRediSearchVersion($client);
+    }
+}
diff --git a/tests/fixtures/Helper/EscaperHelperFixture.csv b/tests/fixtures/Query/EscaperFixture.csv
similarity index 100%
rename from tests/fixtures/Helper/EscaperHelperFixture.csv
rename to tests/fixtures/Query/EscaperFixture.csv
diff --git a/tests/fixtures/Redis/Client/FakeAbstractClient.php b/tests/fixtures/Redis/Client/FakeAbstractClient.php
new file mode 100644
index 0000000..179229d
--- /dev/null
+++ b/tests/fixtures/Redis/Client/FakeAbstractClient.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Client;
+
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Command;
+
+class FakeAbstractClient extends \MacFJA\RediSearch\Redis\Client\AbstractClient
+{
+    public static function make($redis): Client
+    {
+        return new self();
+    }
+
+    public function execute(Command $command): void
+    {
+        // Do nothing
+    }
+
+    public function executeRaw(...$args): void
+    {
+        // Do nothing
+    }
+
+    public static function supports($redis): bool
+    {
+        return false;
+    }
+
+    /**
+     * @param array<string,array<string>> $classesMethods
+     * @param array<string>               $functions
+     */
+    public function exposeGetMissingMessage(string $name, bool $isExtension, array $classesMethods, array $functions = []): string
+    {
+        return $this->getMissingMessage($name, $isExtension, $classesMethods, $functions);
+    }
+
+    protected function doPipeline(Command ...$commands): array
+    {
+        return [];
+    }
+}
diff --git a/src/Words/SynonymResult.php b/tests/fixtures/Redis/Command/FakeAbstractCommand.php
similarity index 70%
rename from src/Words/SynonymResult.php
rename to tests/fixtures/Redis/Command/FakeAbstractCommand.php
index a22fff0..9dbe16f 100644
--- a/src/Words/SynonymResult.php
+++ b/tests/fixtures/Redis/Command/FakeAbstractCommand.php
@@ -19,38 +19,34 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Words;
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command;
 
-use MacFJA\RediSearch\Helper\DataHelper;
-
-class SynonymResult
+/**
+ * @internal
+ */
+class FakeAbstractCommand extends \MacFJA\RediSearch\Redis\Command\AbstractCommand
 {
-    /** @var string */
-    private $for;
-
     /** @var array<string> */
-    private $synonyms = [];
+    private $requiredOptions;
 
-    /**
-     * @param array<string> $synonyms
-     */
-    public function __construct(string $for, array $synonyms)
+    public function getId(): string
     {
-        DataHelper::assertArrayOf($synonyms, 'string');
-        $this->for = $for;
-        $this->synonyms = $synonyms;
+        return '';
     }
 
-    public function getFor(): string
+    /**
+     * @param array<string> $requiredOptions
+     */
+    public function setRequiredOptions(array $requiredOptions): void
     {
-        return $this->for;
+        $this->requiredOptions = $requiredOptions;
     }
 
     /**
      * @return array<string>
      */
-    public function getSynonyms(): array
+    protected function getRequiredOptions(): array
     {
-        return $this->synonyms;
+        return $this->requiredOptions;
     }
 }
diff --git a/src/Builder.php b/tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass1.php
similarity index 86%
rename from src/Builder.php
rename to tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass1.php
index 3428760..9bb4b97 100644
--- a/src/Builder.php
+++ b/tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass1.php
@@ -19,14 +19,11 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch;
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command;
 
-interface Builder
-{
-    public function reset(): self;
+use MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait;
 
-    /**
-     * @return mixed
-     */
-    public function execute();
+class FakeAddFieldOptionTraitClass1
+{
+    use AddFieldOptionTrait;
 }
diff --git a/alias/Aggregate/Reducer.php b/tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass2.php
similarity index 84%
rename from alias/Aggregate/Reducer.php
rename to tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass2.php
index 2f97d60..c4d8082 100644
--- a/alias/Aggregate/Reducer.php
+++ b/tests/fixtures/Redis/Command/FakeAddFieldOptionTraitClass2.php
@@ -19,9 +19,11 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RedisSearch\Aggregate;
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command;
 
-/** @deprecated Bad namespace naming, use \MacFJA\RediSearch\Aggregate\Reducer */
-final class Reducer extends \MacFJA\RediSearch\Aggregate\Reducer
+use MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait;
+
+class FakeAddFieldOptionTraitClass2 extends FakeAbstractCommand
 {
+    use AddFieldOptionTrait;
 }
diff --git a/tests/fixtures/Redis/Command/FakePaginatedCommand.php b/tests/fixtures/Redis/Command/FakePaginatedCommand.php
new file mode 100644
index 0000000..607ac74
--- /dev/null
+++ b/tests/fixtures/Redis/Command/FakePaginatedCommand.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command;
+
+use MacFJA\RediSearch\Redis\Command;
+use MacFJA\RediSearch\Redis\Command\PaginatedCommand;
+
+class FakePaginatedCommand extends Command\AbstractCommand implements PaginatedCommand
+{
+    /** @var null|int */
+    private $size;
+
+    /** @var null|int */
+    private $offset;
+
+    public function getId(): string
+    {
+        return 'fake';
+    }
+
+    public function getOffset(): ?int
+    {
+        return $this->offset;
+    }
+
+    public function getSize(): ?int
+    {
+        return $this->size;
+    }
+
+    public function setLimit(int $offset, int $size): PaginatedCommand
+    {
+        $this->offset = $offset;
+        $this->size = $size;
+
+        return $this;
+    }
+
+    protected function getRequiredOptions(): array
+    {
+        return [];
+    }
+}
diff --git a/src/Aggregate/Exception/NotEnoughPropertiesException.php b/tests/fixtures/Redis/Command/Option/FakeAbstractCommandOption.php
similarity index 71%
rename from src/Aggregate/Exception/NotEnoughPropertiesException.php
rename to tests/fixtures/Redis/Command/Option/FakeAbstractCommandOption.php
index 6e40ef2..c769db2 100644
--- a/src/Aggregate/Exception/NotEnoughPropertiesException.php
+++ b/tests/fixtures/Redis/Command/Option/FakeAbstractCommandOption.php
@@ -19,20 +19,30 @@
  * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
  */
 
-namespace MacFJA\RediSearch\Aggregate\Exception;
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command\Option;
 
-use OutOfRangeException;
-use Throwable;
+use MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption;
 
 /**
- * @codeCoverageIgnore
- *
- * @deprecated This exception is never used.
+ * @internal
  */
-class NotEnoughPropertiesException extends OutOfRangeException
+class FakeAbstractCommandOption extends AbstractCommandOption
 {
-    public function __construct(string $message = 'You must have at least one property', int $code = 0, ?Throwable $previous = null)
+    public function isValid(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @phpstan-return null
+     */
+    public function getOptionData()
+    {
+        return null;
+    }
+
+    protected function doRender(?string $version): array
     {
-        parent::__construct($message, $code, $previous);
+        return [];
     }
 }
diff --git a/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetter.php b/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetter.php
new file mode 100644
index 0000000..6dc7073
--- /dev/null
+++ b/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetter.php
@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command\Option;
+
+use MacFJA\RediSearch\Redis\Command\Option\GroupedOption;
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+
+/**
+ * @method void setName(string $name, $arg = null)
+ * @method void setAge(int $age)                   Fake setter to please PHPStan
+ * @method void foo()                              Fake method to please PHPStan
+ */
+class FakeWithPublicGroupedSetter extends GroupedOption
+{
+    use WithPublicGroupedSetterTrait;
+
+    /**
+     * @return string[]
+     */
+    protected function publicSetter(): array
+    {
+        return ['name'];
+    }
+}
diff --git a/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetterInvalidSelf.php b/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetterInvalidSelf.php
new file mode 100644
index 0000000..51e6dd7
--- /dev/null
+++ b/tests/fixtures/Redis/Command/Option/FakeWithPublicGroupedSetterInvalidSelf.php
@@ -0,0 +1,40 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Command\Option;
+
+use MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait;
+
+/**
+ * @method void setName(string $name)
+ */
+class FakeWithPublicGroupedSetterInvalidSelf
+{
+    use WithPublicGroupedSetterTrait;
+
+    /**
+     * @return array<string>
+     */
+    protected function publicSetter(): array
+    {
+        return [];
+    }
+}
diff --git a/tests/fixtures/Redis/Response/ArrayResponseTraitTester.php b/tests/fixtures/Redis/Response/ArrayResponseTraitTester.php
new file mode 100644
index 0000000..e6a9708
--- /dev/null
+++ b/tests/fixtures/Redis/Response/ArrayResponseTraitTester.php
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\fixtures\Redis\Response;
+
+use MacFJA\RediSearch\Redis\Response\ArrayResponseTrait;
+
+class ArrayResponseTraitTester
+{
+    use ArrayResponseTrait;
+
+    /**
+     * @param array<float|int|mixed|string> $notKeyedArray
+     *
+     * @return array<string,mixed>
+     */
+    public function publicGetPairs($notKeyedArray)
+    {
+        return self::getPairs($notKeyedArray);
+    }
+
+    /**
+     * @param array<float|int|mixed|string> $notKeyedArray
+     *
+     * @return array<string>
+     */
+    public function publicGetKeys($notKeyedArray)
+    {
+        return self::getKeys($notKeyedArray);
+    }
+
+    /**
+     * @param array<array|mixed> $notKeyedArray
+     *
+     * @return null|array<mixed>|bool|float|int|string
+     */
+    public function publicGetValue(array $notKeyedArray, string $key)
+    {
+        return self::getValue($notKeyedArray, $key);
+    }
+
+    /**
+     * @param array<array|mixed> $notKeyedArray
+     *
+     * @return null|bool|float|int|string
+     */
+    public function publicGetNextValue(array $notKeyedArray, string $key)
+    {
+        return self::getNextValue($notKeyedArray, $key);
+    }
+}
diff --git a/tests/integration/DockerTest.php b/tests/integration/DockerTest.php
new file mode 100644
index 0000000..fb4dc9c
--- /dev/null
+++ b/tests/integration/DockerTest.php
@@ -0,0 +1,361 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+namespace MacFJA\RediSearch\tests\integration;
+
+use function get_class;
+use function in_array;
+use function is_string;
+
+use Amp\Redis\Config;
+use Amp\Redis\RemoteExecutor;
+use Closure;
+use Credis_Client;
+use MacFJA\RediSearch\Index;
+use MacFJA\RediSearch\IndexBuilder;
+use MacFJA\RediSearch\Query\Builder;
+use MacFJA\RediSearch\Query\Builder\Fuzzy;
+use MacFJA\RediSearch\Redis\Client;
+use MacFJA\RediSearch\Redis\Client\ClientFacade;
+use MacFJA\RediSearch\Redis\Command\Aggregate;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption;
+use MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption;
+use MacFJA\RediSearch\Redis\Command\DropIndex;
+use MacFJA\RediSearch\Redis\Command\IndexList;
+use MacFJA\RediSearch\Redis\Command\Search;
+use MacFJA\RediSearch\Redis\Command\SynDump;
+use MacFJA\RediSearch\Redis\Command\SynUpdate;
+use MacFJA\RediSearch\Redis\Response\PaginatedResponse;
+use MacFJA\RediSearch\Redis\Response\SearchResponseItem;
+use PHPUnit\Framework\TestCase;
+use Rediska;
+use TinyRedisClient;
+
+/**
+ * @covers \MacFJA\RediSearch\Index
+ * @covers \MacFJA\RediSearch\IndexBuilder
+ * @covers \MacFJA\RediSearch\Query\Builder
+ * @covers \MacFJA\RediSearch\Redis\Client\AbstractClient
+ * @covers \MacFJA\RediSearch\Redis\Client\AmpRedisClient
+ * @covers \MacFJA\RediSearch\Redis\Client\CheprasovRedisClient
+ * @covers \MacFJA\RediSearch\Redis\Client\ClientFacade
+ * @covers \MacFJA\RediSearch\Redis\Client\CredisClient
+ * @covers \MacFJA\RediSearch\Redis\Client\PredisClient
+ * @covers \MacFJA\RediSearch\Redis\Client\RedisentClient
+ * @covers \MacFJA\RediSearch\Redis\Client\RediskaClient
+ * @covers \MacFJA\RediSearch\Redis\Client\TinyRedisClient
+ * @covers \MacFJA\RediSearch\Redis\Command\Aggregate
+ * @covers \MacFJA\RediSearch\Redis\Command\DropIndex
+ * @covers \MacFJA\RediSearch\Redis\Command\IndexList
+ * @covers \MacFJA\RediSearch\Redis\Command\Info
+ * @covers \MacFJA\RediSearch\Redis\Command\Search
+ * @covers \MacFJA\RediSearch\Redis\Command\SynDump
+ * @covers \MacFJA\RediSearch\Redis\Command\SynUpdate
+ * @covers \MacFJA\RediSearch\Redis\Initializer
+ * @covers \MacFJA\RediSearch\Redis\Response\InfoResponse
+ * @covers \MacFJA\RediSearch\Redis\Response\PaginatedResponse
+ *
+ * @uses \MacFJA\RediSearch\Query\Builder\AbstractGroup
+ * @uses \MacFJA\RediSearch\Query\Builder\AndGroup
+ * @uses \MacFJA\RediSearch\Redis\Command\AbstractCommand
+ * @uses \MacFJA\RediSearch\Redis\Command\AddFieldOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\GroupByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\ReduceOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\WithCursor
+ * @uses \MacFJA\RediSearch\Redis\Command\Create
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\BaseCreateFieldOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\NumericFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\CreateCommand\TextFieldOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\AbstractCommandOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\CustomValidatorOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\DecoratedOptionTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\FlagOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\GroupedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NamelessOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NotEmptyOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\NumberedOption
+ * @uses \MacFJA\RediSearch\Redis\Command\Option\WithPublicGroupedSetterTrait
+ * @uses \MacFJA\RediSearch\Redis\Command\PrefixFieldName
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\GeoFilterOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\HighlightOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\LimitOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Redis\Command\SearchCommand\SummarizeOption
+ * @uses \MacFJA\RediSearch\Redis\Command\AggregateCommand\SortByOption
+ * @uses \MacFJA\RediSearch\Query\Builder\Fuzzy
+ * @uses \MacFJA\RediSearch\Query\Escaper
+ *
+ * @group integration
+ *
+ * @internal
+ */
+class DockerTest extends TestCase
+{
+    /** @var bool */
+    private static $skip = false;
+
+    /** @var string */
+    private static $containerCommand = 'podman';
+
+    /** @var null|string */
+    private static $containerId;
+
+    public static function setUpBeforeClass(): void
+    {
+        parent::setUpBeforeClass();
+        exec('which podman', $output, $code);
+        if ($code > 0) {
+            exec('which docker', $output, $code);
+            self::$containerCommand = 'docker';
+        }
+        if ($code > 0) {
+            self::$skip = true;
+
+            return;
+        }
+        $output = [];
+        exec(self::$containerCommand.' run --rm -p 16379:6379 -d redislabs/redisearch:latest', $output, $code);
+        self::$containerId = reset($output) ?: null;
+        Client\AbstractClient::$disableNotice = true;
+    }
+
+    public static function tearDownAfterClass(): void
+    {
+        parent::tearDownAfterClass();
+        if (self::$skip || !is_string(self::$containerId)) {
+            return;
+        }
+        exec(self::$containerCommand.' stop '.escapeshellarg(self::$containerId), $output);
+        Client\AbstractClient::$disableNotice = false;
+    }
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        if (self::$skip) {
+            static::markTestSkipped('Podman/Docker is missing');
+        }
+    }
+
+    /**
+     * @param mixed $redisBuilder
+     *
+     * @medium
+     *
+     * @dataProvider dataProvider
+     */
+    public function testGetClient(string $expectedClass, $redisBuilder): void
+    {
+        $facade = new ClientFacade();
+        $client = $facade->getClient($redisBuilder());
+
+        static::assertSame($expectedClass, get_class($client));
+    }
+
+    /**
+     * @dataProvider dataProvider
+     *
+     * @large
+     *
+     * @param mixed $clientBuilder
+     */
+    public function testIntegration($clientBuilder): void
+    {
+        $client = $clientBuilder();
+
+        $list = $client->execute(new IndexList());
+        static::assertEmpty($list);
+        $builder = new IndexBuilder();
+        $builder
+            ->withIndex('testDoc')
+            ->addTextField('lastname', false, null, null, true)
+            ->addTextField('firstname')
+            ->addNumericField('age')
+            ->create($client)
+        ;
+        static::assertEquals(['testDoc'], $client->execute(new IndexList()));
+
+        $index = new Index('testDoc', $client);
+        $docToRemove = [];
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'lastname' => 'Doe', 'age' => 30]);
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'age' => 30]);
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'age' => 30, 'fullname' => 'eeeee']);
+
+        $search = new Search();
+
+        $query = (new Builder())
+            ->addElement(new Fuzzy('Joo'))
+        ;
+
+        $search
+            ->setIndex('testDoc')
+            ->setQuery($query->render())
+            ->setWithPayloads()
+            ->setHighlight(['lastname'], '<b>', '</b>')
+            ->setWithScores()
+        ;
+
+        /** @var PaginatedResponse $result */
+        $result = $client->execute($search);
+        static::assertCount(1, $result);
+        static::assertSame(1, $result->getPageCount());
+        static::assertSame(3, $result->getTotalCount());
+
+        /** @var array<SearchResponseItem> $page */
+        foreach ($result as $page) {
+            foreach ($page as $item) {
+                static::assertSame(30, (int) $item->getFieldValue('age'));
+            }
+        }
+
+        $client->execute((new SynUpdate())->setIndex('testDoc')->setGroupId('Joe')->setTerms('John'));
+        $syns = $client->execute((new SynDump())->setIndex('testDoc'));
+        static::assertEquals([
+            'john', ['Joe'],
+        ], $syns);
+
+        $result = $client->pipeline(
+            (new Search())
+                ->setIndex('testDoc')
+                ->setQuery('%%Joe%%')
+                ->setWithPayloads()
+                ->setWithScores()
+                ->setHighlight(['lastname'], '<b>', '</b>'),
+            (new Aggregate())
+                ->setIndex('testDoc')
+                ->setQuery('*')
+                ->addGroupBy(new GroupByOption(['lastname'], [ReduceOption::count('count')])),
+            (new Aggregate())
+                ->setIndex('testDoc')
+                ->setQuery('*')
+                ->addGroupBy(new GroupByOption(['firstname'], [ReduceOption::count('count')])),
+            (new Aggregate())
+                ->setIndex('testDoc')
+                ->setQuery('*')
+                ->addGroupBy(new GroupByOption(['age'], [ReduceOption::count('count')])),
+            (new Aggregate())
+                ->setIndex('testDoc')
+                ->setQuery('*')
+                ->addGroupBy(new GroupByOption([], [ReduceOption::toList('age', 'list')]))
+        );
+
+        static::assertCount(1, $result[0]);
+        static::assertSame(3, $result[0]->getTotalCount());
+        static::assertEquals('1', current($result[1])[0]->getFieldValue('count'));
+        static::assertEquals('2', current($result[1])[1]->getFieldValue('count'));
+        static::assertEquals('3', current($result[2])[0]->getFieldValue('count'));
+        static::assertEquals('3', current($result[3])[0]->getFieldValue('count'));
+        static::assertEquals(['30'], current($result[4])[0]->getFieldValue('list'));
+
+        $client->execute((new DropIndex())->setIndex('testDoc')->setDeleteDocument());
+        $client->executeRaw('del', ...$docToRemove);
+    }
+
+    /**
+     * @return array<array<Closure|string>>
+     */
+    public function dataProvider(string $testName): array
+    {
+        if (in_array($testName, ['testIntegration', 'testAddDocument'])) {
+            return [
+                [static function () { return Client\PredisClient::make(new \Predis\Client(['scheme' => 'tcp', 'host' => 'localhost', 'port' => '16379', 'db' => 0])); }],
+                [static function () { return Client\RediskaClient::make(new Rediska(['servers' => [['host' => 'localhost', 'port' => '16379', 'db' => 0]]])); }],
+                [static function () { return Client\RedisentClient::make(new \redisent\Redis('redis://localhost:16379')); }],
+                [static function () { return Client\CheprasovRedisClient::make(new \RedisClient\Client\Version\RedisClient6x0(['server' => 'localhost:16379', 'database' => 0])); }],
+                [static function () { return Client\AmpRedisClient::make(new \Amp\Redis\Redis(new RemoteExecutor(Config::fromUri('redis://localhost:16379')))); }],
+                [static function () { return Client\TinyRedisClient::make(new TinyRedisClient('localhost:16379')); }],
+                [static function () { return Client\CredisClient::make(new Credis_Client('localhost', 16379, null, '', 0)); }],
+            ];
+        }
+
+        return [
+            [Client\PredisClient::class, static function () { return new \Predis\Client(['scheme' => 'tcp', 'host' => 'localhost', 'port' => '16379', 'db' => 0]); }],
+            [Client\RediskaClient::class, static function () { return new Rediska(['servers' => [['host' => 'localhost', 'port' => '16379', 'db' => 0]]]); }],
+            [Client\RedisentClient::class, static function () { return new \redisent\Redis('redis://localhost:16379'); }],
+            [Client\CheprasovRedisClient::class, static function () { return new \RedisClient\Client\Version\RedisClient6x0(['server' => 'localhost:16379', 'database' => 0]); }],
+            [Client\AmpRedisClient::class, static function () { return new \Amp\Redis\Redis(new RemoteExecutor(Config::fromUri('redis://localhost:16379'))); }],
+            [Client\TinyRedisClient::class, static function () { return new TinyRedisClient('localhost:16379'); }],
+            [Client\CredisClient::class, static function () { return new Credis_Client('localhost', 16379, null, '', 0); }],
+        ];
+    }
+
+    /**
+     * @dataProvider dataProvider
+     *
+     * @large
+     *
+     * @param mixed $clientBuilder
+     */
+    public function testAddDocument($clientBuilder): void
+    {
+        $client = $clientBuilder();
+
+        $list = $client->execute(new IndexList());
+        static::assertEmpty($list);
+        $builder = new IndexBuilder();
+        $builder
+            ->withIndex('testDoc')
+            ->addPrefixes('person:')
+            ->addTextField('lastname', false, null, null, true)
+            ->addTextField('firstname')
+            ->addNumericField('age')
+            ->create($client)
+        ;
+        static::assertEquals(['testDoc'], $client->execute(new IndexList()));
+
+        $index = new Index('testDoc', $client);
+        $docToRemove = [];
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'lastname' => 'Doe', 'age' => 30], 'person:1');
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'age' => 30], 'person:2');
+        $docToRemove[] = $index->addDocumentFromArray(['firstname' => 'Joe', 'age' => 30, 'fullname' => 'eeeee'], 'person:3');
+
+        $search = new Search();
+
+        $query = (new Builder())
+            ->addElement(new Fuzzy('Joo'))
+        ;
+
+        $search
+            ->setIndex('testDoc')
+            ->setQuery($query->render())
+            ->setWithPayloads()
+            ->setHighlight(['lastname'], '<b>', '</b>')
+            ->setWithScores()
+        ;
+
+        /** @var PaginatedResponse $result */
+        $result = $client->execute($search);
+        static::assertCount(1, $result);
+        static::assertSame(1, $result->getPageCount());
+        static::assertSame(3, $result->getTotalCount());
+
+        /** @var array<SearchResponseItem> $page */
+        foreach ($result as $page) {
+            foreach ($page as $item) {
+                static::assertSame(30, (int) $item->getFieldValue('age'));
+            }
+        }
+
+        $client->execute((new DropIndex())->setIndex('testDoc')->setDeleteDocument());
+        $client->executeRaw('del', ...$docToRemove);
+    }
+}
diff --git a/tests/psalm/stubs/phpiredis.php b/tests/psalm/stubs/phpiredis.php
new file mode 100644
index 0000000..8bd01f2
--- /dev/null
+++ b/tests/psalm/stubs/phpiredis.php
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * Copyright MacFJA
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
+ * documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
+ * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+ * permit persons to whom the Software is furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
+ * Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
+ * WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+ * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+const PHPIREDIS_READER_STATE_COMPLETE = 1;
+const PHPIREDIS_READER_STATE_INCOMPLETE = 2;
+const PHPIREDIS_READER_STATE_ERROR = 3;
+
+const PHPIREDIS_REPLY_STRING = 1;
+const PHPIREDIS_REPLY_ARRAY = 2;
+const PHPIREDIS_REPLY_INTEGER = 3;
+const PHPIREDIS_REPLY_NIL = 4;
+const PHPIREDIS_REPLY_STATUS = 5;
+const PHPIREDIS_REPLY_ERROR = 6;
+
+function phpiredis_connect($host, $port = 6379): void {}
+function phpiredis_pconnect($host, $port = 6379): void {}
+function phpiredis_disconnect(): void {}
+
+/**
+ * @param resource $redis
+ * @param string[] $command
+ *
+ * @return bool|string
+ */
+function phpiredis_command_bs($redis, array $command) {}
+
+/**
+ * @param resource $redis
+ *
+ * @return bool|string
+ */
+function phpiredis_command($redis, string $command) {}
+
+/**
+ * @param resource      $redis
+ * @param array<string> $commands
+ *
+ * @return array<bool|string>
+ */
+function phpiredis_multi_command($redis, array $commands) {}
+
+/**
+ * @param resource             $redis
+ * @param array<array<string>> $commands
+ *
+ * @return array<bool|string>
+ */
+function phpiredis_multi_command_bs($redis, array $commands) {}
+
+/**
+ * @param array<float|int|string> $args
+ */
+function phpiredis_format_command(array $args): string {}
+
+/**
+ * @return resource
+ */
+function phpiredis_reader_create() {}
+
+/**
+ * @param resource $reader
+ */
+function phpiredis_reader_reset($reader): void {}
+
+/**
+ * @param resource $reader
+ */
+function phpiredis_reader_feed($reader, string $response): void {}
+
+/**
+ * @param resource $reader
+ *
+ * @return bool|int
+ */
+function phpiredis_reader_get_state($reader) {}
+
+/**
+ * @param resource $reader
+ *
+ * @return bool|string
+ */
+function phpiredis_reader_get_error($reader) {}
+
+/**
+ * @param resource $reader
+ *
+ * @return array<array|bool|int|string>|bool|int|string
+ */
+function phpiredis_reader_get_reply($reader, int &$type) {}
+
+/**
+ * @param resource $redis
+ */
+function phpiredis_reader_destroy($redis): void {}
+
+/**
+ * @param resource $reader
+ */
+function phpiredis_reader_set_error_handler($reader, ?callable $handler): void {}
+
+/**
+ * @param resource $reader
+ */
+function phpiredis_reader_set_status_handler($reader, ?callable $handler): void {}
+function phpiredis_utils_crc16(string $data): int {}
