Create own Adapter#

In this part of the documentation describes how to create an own adapter. Before you start with it let us know via an issue if it maybe an Adapter which make sense to add to the SEAL core and we can work together to get it in it.

Install dependencies#

To create your own Adapter you need atleast the SEAL composer package:

composer require schranz-search/seal

The project already ships a test suite based on PHPUnit to use it you need to install PHPUnit:

composer require phpunit/phpunit:”^9.6”

Create Basic Classes#

An own Adapter depends on the following classes which are responsible for all different operations:

  • Adapter
    • SchemaManager (create and drop indexes)

    • Indexer (save and delete documents)

    • Searcher (search documents)

  • AdapterFactory

Create Adapter#

The Adapter is the main entry point for the own Adapter and provides access to the SchemaManager, Indexer and Searcher.

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Schranz\Search\SEAL\Adapter\AdapterInterface;
use Schranz\Search\SEAL\Adapter\IndexerInterface;
use Schranz\Search\SEAL\Adapter\SchemaManagerInterface;
use Schranz\Search\SEAL\Adapter\SearcherInterface;

final class MyAdapter implements AdapterInterface
{
    private readonly SchemaManagerInterface $schemaManager;

    private readonly IndexerInterface $indexer;

    private readonly SearcherInterface $searcher;

    public function __construct(
        Client $client,
        ?SchemaManagerInterface $schemaManager = null,
        ?IndexerInterface $indexer = null,
        ?SearcherInterface $searcher = null,
    ) {
        $this->schemaManager = $schemaManager ?? new MySchemaManager($client);
        $this->indexer = $indexer ?? new MyIndexer($client);
        $this->searcher = $searcher ?? new MySearcher($client);
    }

    public function getSchemaManager(): SchemaManagerInterface
    {
        return $this->schemaManager;
    }

    public function getIndexer(): IndexerInterface
    {
        return $this->indexer;
    }

    public function getSearcher(): SearcherInterface
    {
        return $this->searcher;
    }
}

Create SchemaManager#

The SchemaManager is responsible for creating and dropping indexes.

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Schranz\Search\SEAL\Adapter\SchemaManagerInterface;
use Schranz\Search\SEAL\Schema\Index;
use Schranz\Search\SEAL\Task\AsyncTask;
use Schranz\Search\SEAL\Task\TaskInterface;

final class MySchemaManager implements SchemaManagerInterface
{
    public function __construct(
        private readonly Client $client,
    ) {
    }

    public function existIndex(Index $index): bool
    {
        // TODO we will tackle this later
    }

    public function dropIndex(Index $index, array $options = []): ?TaskInterface
    {
        // TODO we will tackle this later
    }

    public function createIndex(Index $index, array $options = []): ?TaskInterface
    {
        // TODO we will tackle this later
    }
}

Create Indexer#

The Indexer is responsible for saving and deleting documents.

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Schranz\Search\SEAL\Adapter\IndexerInterface;
use Schranz\Search\SEAL\Marshaller\Marshaller;
use Schranz\Search\SEAL\Schema\Index;
use Schranz\Search\SEAL\Task\AsyncTask;
use Schranz\Search\SEAL\Task\TaskInterface;

final class MyIndexer implements IndexerInterface
{
    private readonly Marshaller $marshaller;

    public function __construct(
        private readonly Client $client,
    ) {
        $this->marshaller = new Marshaller();
    }

    public function save(Index $index, array $document, array $options = []): ?TaskInterface
    {
        // TODO we will tackle this later
    }

    public function delete(Index $index, string $identifier, array $options = []): ?TaskInterface
    {
        // TODO we will tackle this later
    }
}

The Marshaller is responsible for converting the document into an easier Format to index documents. There exists 2 Marshaller``the ``Marshaller which keeps nested objects and the FlattenMarshaller which flatten nested objects to the root by using . as divider.

Create Searcher#

The Searcher is responsible for searching documents.

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Schranz\Search\SEAL\Adapter\SearcherInterface;
use Schranz\Search\SEAL\Marshaller\Marshaller;
use Schranz\Search\SEAL\Schema\Index;
use Schranz\Search\SEAL\Search\Condition;
use Schranz\Search\SEAL\Search\Result;
use Schranz\Search\SEAL\Search\Search;

final class MySearcher implements SearcherInterface
{
    private readonly Marshaller $marshaller;

    public function __construct(
        private readonly Client $client,
    ) {
        $this->marshaller = new Marshaller();
    }

    public function search(Search $search): Result
    {
        // TODO we will tackle this later
    }
}

The Searcher requires the same Marshaller as the Indexer to convert the document back to the original format.

Create AdapterFactory#

The AdapterFactory is responsible for creating the Adapter mostly used by integrations into Frameworks Dependency Injection container and constructing the Adapter via a DSN string.

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Psr\Container\ContainerInterface;
use Schranz\Search\SEAL\Adapter\AdapterFactoryInterface;
use Schranz\Search\SEAL\Adapter\AdapterInterface;

/**
 * @experimental
 */
final class MyAdapterFactory implements AdapterFactoryInterface
{
    public function __construct(
        private readonly ?ContainerInterface $container = null,
    ) {
    }

    public function createAdapter(array $dsn): AdapterInterface
    {
        $client = $this->createClient($dsn);

        return new MyAdapter($client);
    }

    /**
     * @internal
     *
     * @param array{
     *     host: string,
     *     port?: int,
     *     user?: string,
     *     pass?: string,
     * } $dsn
     */
    public function createClient(array $dsn): SearchClient
    {
        if ('' === $dsn['host']) {
            $client = $this->container?->get(Client::class);

            return $client;
        }

        $client = new Client(
            $dsn['host'] . ':' . ($dsn['port'] ?? 9200),+
            $dsn['user'] ?? '',
            $pass = $dsn['pass'] ?? '',
        );

        return $client;
    }

    public static function getName(): string
    {
        return 'my';
    }
}

Creating Tests#

The easiest way to create an own Adapter is following TDD (Test Driven Development) and use the shipped TestSuite.

For this we will create the following new files:

  • tests/MySchemaManagerTest.php

  • tests/MyAdapterTest.php

  • tests/MyIndexerTest.php

  • tests/MySearcherTest.php

For most adapters they require a Third Party client to make constructing of that Client easier we will create a ClientHelper class in our new test suite.

<?php

declare(strict_types=1);

namespace My\Own\Adapter\Tests;

use Some\Third\Party\Client;

final class ClientHelper
{
    private static ?Client $client = null;

    public static function getClient(): Client
    {
        if (!self::$client instanceof Client) {
            self::$client = new Client($_ENV['MY_OWN_HOST'] ?? '127.0.0.1:7700');
        }

        return self::$client;
    }
}

SchemaManagerTest#

<?php

declare(strict_types=1);

namespace My\Own\Adapter\Tests;

use My\Own\Adapter\MySchemaManager;
use Schranz\Search\SEAL\Testing\AbstractSchemaManagerTestCase;
use Schranz\Search\SEAL\Testing\TestingHelper;

class MySchemaManagerTest extends AbstractSchemaManagerTestCase
{
    private static Client $client;

    public static function setUpBeforeClass(): void
    {
        self::$client = ClientHelper::getClient();
        self::$schemaManager = new MySchemaManager(self::$client);

        parent::setUpBeforeClass();
    }
}

MyAdapterTest#

<?php

declare(strict_types=1);

namespace My\Own\Adapter\Tests;

use My\Own\Adapter\MyAdapter;
use Schranz\Search\SEAL\Testing\AbstractAdapterTestCase;

class MyAdapterTest extends AbstractAdapterTestCase
{
    public static function setUpBeforeClass(): void
    {
        $client = ClientHelper::getClient();
        self::$adapter = new MyAdapter($client);

        parent::setUpBeforeClass();
    }
}

MyIndexerTest#

<?php

declare(strict_types=1);

namespace My\Own\Adapter\Tests;

use My\Own\Adapter\MyAdapter;
use Schranz\Search\SEAL\Testing\AbstractIndexerTestCase;

class MyIndexerTest extends AbstractIndexerTestCase
{
    public static function setUpBeforeClass(): void
    {
        $client = ClientHelper::getClient();
        self::$adapter = new MyAdapter($client);

        parent::setUpBeforeClass();
    }
}

MySearcherTest#

<?php

declare(strict_types=1);

namespace My\Own\Adapter\Tests;

use My\Own\Adapter\MyAdapter;
use Schranz\Search\SEAL\Testing\AbstractSearcherTestCase;

class MySearcherTest extends AbstractSearcherTestCase
{
    public static function setUpBeforeClass(): void
    {
        $client = ClientHelper::getClient();
        self::$adapter = new MyAdapter($client);

        parent::setUpBeforeClass();
    }

    /**
     * @doesNotPerformAssertions
     */
    public function testFindMultipleIndexes(): void
    {
        $this->markTestSkipped('Not supported by MyOwnSearchEngine: https://github.com/.../.../issues/28');
    }
}

Implementing Logic#

Now we can begin to implement the logic for our own Adapter.

Implementing SchemaManager#

The SchemaManager is the required way to start to implement as all other Services depending on it that it works.

The SchemaManager is responsible for create and drop indexes and configure the Index fields correctly based on their type and defined options. How this can be achieved is different from Search Engine to Search Engine.

Read the Schema documentation to get an overview of the different field types which exists.

vendor/bin/phpunit --filter="SchemaManagerTest"

Now you can step by step implementing the SchemaManager methods.

Examples for different SchemaManager can be found in the official Repository:

Implementing the Indexer#

After the SchemaManager works like expected we will continue with the Indexer. This is responsible to save and delete documents from the Search Engine. How this can be achieved is different from Search Engine to Search Engine.

Note

The IndexerTest requires a basic Searcher implementation to work. See next Implementing the Searcher section.

Examples for different Indexer can be found in the official Repository:

Implementing the Searcher#

A Basic Searcher implementation is required that we can test the Indexer as we need a way to load a document by its identifier. How this can be achieved is different from Search Engine to Search Engine. A common way is the following example:

<?php

declare(strict_types=1);

namespace My\Own\Adapter;

use Some\Third\Party\Client;
use Schranz\Search\SEAL\Adapter\SearcherInterface;
use Schranz\Search\SEAL\Marshaller\Marshaller;
use Schranz\Search\SEAL\Schema\Index;
use Schranz\Search\SEAL\Search\Condition;
use Schranz\Search\SEAL\Search\Result;
use Schranz\Search\SEAL\Search\Search;

final class MySearcher implements SearcherInterface
{
    private readonly Marshaller $marshaller;

    public function __construct(
        private readonly Client $client,
    ) {
        $this->marshaller = new Marshaller();
    }

    public function search(Search $search): Result
    {
        // optimized single document query
        if (
            1 === \count($search->indexes)
            && 1 === \count($search->filters)
            && $search->filters[0] instanceof Condition\IdentifierCondition
            && 0 === $search->offset
            && 1 === $search->limit
        ) {
            $singleDocumentIndexName = $search->indexes[\array_key_first($search->indexes)]->name;
            $singleDocumentIdentifier = $search->filters[0]->identifier;

            try {
                $data = $this->client->index($singleDocumentIndexName)->getDocument($singleDocumentIdentifier);
            } catch (ApiException $e) {
                if (404 !== $e->httpStatus) {
                    throw $e;
                }

                return new Result(
                    $this->hitsToDocuments($search->indexes, []),
                    0,
                );
            }

            return new Result(
                $this->hitsToDocuments($search->indexes, [$data]),
                1,
            );
        }

        // TODO
    }

    /**
     * @param Index[] $indexes
     * @param iterable<array<string, mixed>> $hits
     *
     * @return \Generator<int, array<string, mixed>>
     */
    private function hitsToDocuments(array $indexes, iterable $hits): \Generator
    {
        $index = $indexes[\array_key_first($indexes)];

        foreach ($hits as $hit) {
            yield $this->marshaller->unmarshall($index->fields, $hit);
        }
    }
}
vendor/bin/phpunit --filter="IndexerTest"

If that works like expected we can continue with the SearcherTest:

vendor/bin/phpunit --filter="SearcherTest"

This is the most difficult part to implement all different conditions. How this can be achieved is different from Search Engine to Search Engine.

Read the Search & Filter Conditions documentation to get an overview of the different searches and filters which exists.

Examples for different Searcher can be found in the official Repository:

Conclusion#

If all tests are green you can be sure that your implementation works like expected. You can publish your own adapter also as a composer package if you want to make it public available.

Tag the packagist package with seal-adapter and your use the Github Topic seal-php-adapter.

This way also other can easily find your own created adapter.