Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Apache Solr #73

Merged
merged 22 commits into from
Feb 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1ab3987
Add support for Apache Solr
alexander-schranz Jan 15, 2023
62851bc
Switch Docker File to Cloud Mode
alexander-schranz Jan 20, 2023
7c14518
Activate Docker Compose for CI Task
alexander-schranz Jan 20, 2023
a04c67d
Create Collections via SolrSchemaManager
alexander-schranz Jan 20, 2023
760909e
Add basic query and document with ID for Documents test
alexander-schranz Jan 20, 2023
1321682
Add basic schema for flat fields
alexander-schranz Jan 24, 2023
b21441c
Create own configset for every collection
alexander-schranz Jan 24, 2023
2afad8a
Create object and typed fields based on FlattenMarshaller
alexander-schranz Jan 31, 2023
b299064
Remove unneeded FlattenMarshaller methods
alexander-schranz Jan 31, 2023
8be8c4d
Add Basic Find, Delete and Add Document implementation
alexander-schranz Jan 31, 2023
8124f06
Rename _rawDocument to _source to match elasticsearch behaviour
alexander-schranz Jan 31, 2023
0d9eaab
Remove not longer required todos
alexander-schranz Jan 31, 2023
ad27512
Fix core tests
alexander-schranz Jan 31, 2023
33b2a37
Fix that tests are correcty failing
alexander-schranz Feb 1, 2023
1382fcd
Add LessThan, LessThanEqual, GreaterThan, GreaterThanEqual Conditions…
alexander-schranz Feb 1, 2023
cf32175
Add sorting for Solr
alexander-schranz Feb 1, 2023
a47e3ea
Escape query fields in Solr
alexander-schranz Feb 1, 2023
5ca2700
Add handling of .raw text fields
alexander-schranz Feb 1, 2023
b410979
Add handling for filters on text .raw fields for Solr
alexander-schranz Feb 1, 2023
4a3e881
Add SearchCondition handling
alexander-schranz Feb 1, 2023
c5e523d
Add hint for todo multi search support
alexander-schranz Feb 1, 2023
5c75377
Update README for Apache Solr Support
alexander-schranz Feb 1, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ jobs:
directory: 'packages/seal-algolia-adapter'
docker: false
secrets: inherit

solr-adapter:
name: Solr Adapter
uses: ./.github/workflows/callable-test.yml
with:
directory: 'packages/seal-solr-adapter'
docker: true
secrets: inherit
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ At current state collect here different search engines which are around and coul
- [Opensearch](#opensearch) - [schranz-search/seal-opensearch-adapter](packages/seal-opensearch-adapter)
- [Meilisearch](#meilisearch) - [schranz-search/seal-meilisearch-adapter](packages/seal-meilisearch-adapter)
- [Algolia](#algolia) - [schranz-search/seal-algolia-adapter](packages/seal-algolia-adapter)
- [Solr](#solr) (help wanted [#73](https://github.com/schranz-search/schranz-search/pull/73))
- [Solr](#solr) - [schranz-search/seal-solr-adapter](packages/seal-solr-adapter)
- [Typesense](#typesense) (work in progress [#76](https://github.com/schranz-search/schranz-search/pull/76))
- [Zinc Labs](#zinc-labs) (work in progress [#79](https://github.com/schranz-search/schranz-search/pull/79))
- [RediSearch](#redisearch)
Expand Down Expand Up @@ -80,7 +80,7 @@ Fork of Elasticsearch also written in Java.
- Server: [Opensearch Server](https://github.com/opensearch-project/OpenSearch)
- PHP Client: [Opensearch PHP](https://github.com/opensearch-project/opensearch-php)

Implementation: [schranz-search/seal-elasticsearch-adapter](packages/seal-opensearch-adapter)
Implementation: [schranz-search/seal-opensearch-adapter](packages/seal-opensearch-adapter)

### Meilisearch

Expand All @@ -107,7 +107,7 @@ A search engine under the Apache Project based on Lucene written in Java:
- Server: [Solr Server](https://github.com/apache/solr)
- PHP Client: [Solarium PHP](https://github.com/solariumphp/solarium) seems to be a well maintained Client

Implementation: help wanted [#73](https://github.com/schranz-search/schranz-search/pull/73)
Implementation: [schranz-search/seal-solr-adapter](packages/seal-solr-adapter)

### Typesense

Expand Down
5 changes: 5 additions & 0 deletions config.subsplit-publish.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
"directory": "packages/seal-opensearch-adapter",
"target": "[email protected]:schranz-search/seal-opensearch-adapter.git"
},
{
"name": "SEALSolrAdapter",
"directory": "packages/seal-solr-adapter",
"target": "[email protected]:schranz-search/seal-solr-adapter.git"
},
{
"name": "SEALMeilisearchAdapter",
"directory": "packages/seal-meilisearch-adapter",
Expand Down
4 changes: 4 additions & 0 deletions packages/seal-solr-adapter/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.gitattributes export-ignore
.gitignore export-ignore
composer.lock export-ignore
/Tests export-ignore
1 change: 1 addition & 0 deletions packages/seal-solr-adapter/.github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: [alexander-schranz]
6 changes: 6 additions & 0 deletions packages/seal-solr-adapter/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/vendor/
/composer.phar
/phpunit.xml
/.phpunit.result.cache
/Tests/var
/docker-compose.override.yml
21 changes: 21 additions & 0 deletions packages/seal-solr-adapter/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2023 Alexander Schranz

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.
56 changes: 56 additions & 0 deletions packages/seal-solr-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<div align="center">
<img alt="Schranz Search Logo with a Seal on it with a magnifying glass" src="https://avatars.githubusercontent.com/u/120221538?s=400&v=5" width="200" height="200">
</div>

<h1 align="center">Schranz Search SEAL <br /> Solr Adapter</h1>

<br />
<br />

The `SolrAdapter` write the documents into a [Apache Solr](https://github.com/apache/solr) server instance. The Apache Solr server is running in the [`cloud mode`](https://solr.apache.org/guide/solr/latest/getting-started/tutorial-solrcloud.html) as we require to use collections for indexes.

> **Note**:
> This is part of the `schranz-search/schranz-search` project create issues in the [main repository](https://github.com/schranz-search/schranz-search).

> **Warning**:
> This project is heavily under development and not ready for production.

## Installation

Use [composer](https://getcomposer.org/) for install the package:

```bash
composer require schranz-search/seal schranz-search/seal-solr-adapter
```

## Usage.

The following code shows how to create an Engine using this Adapter:

```php
<?php

use Solr\Client;
use Solarium\Core\Client\Adapter\Curl;
use Schranz\Search\SEAL\Adapter\Solr\SolrAdapter;
use Schranz\Search\SEAL\Engine;
use Symfony\Component\EventDispatcher\EventDispatcher;

$client = new Client(new Curl(), new EventDispatcher(), [
'endpoint' => [
'localhost' => [
'host' => '127.0.0.1',
'port' => '8983',
// authenticated required for configset api https://solr.apache.org/guide/8_9/configsets-api.html
// alternative set solr.disableConfigSetsCreateAuthChecks=true in your server setup
'username' => 'solr',
'password' => 'SolrRocks',
],
]
]);

$engine = new Engine(
new SolrAdapter($client),
$schema,
);
```
34 changes: 34 additions & 0 deletions packages/seal-solr-adapter/SolrAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace Schranz\Search\SEAL\Adapter\Solr;

use Solarium\Client;
use Schranz\Search\SEAL\Adapter\AdapterInterface;
use Schranz\Search\SEAL\Adapter\ConnectionInterface;
use Schranz\Search\SEAL\Adapter\SchemaManagerInterface;

final class SolrAdapter implements AdapterInterface
{
private readonly ConnectionInterface $connection;

private readonly SchemaManagerInterface $schemaManager;

public function __construct(
private readonly Client $client,
?ConnectionInterface $connection = null,
?SchemaManagerInterface $schemaManager = null,
) {
$this->connection = $connection ?? new SolrConnection($client);
$this->schemaManager = $schemaManager ?? new SolrSchemaManager($client);
}

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

public function getConnection(): ConnectionInterface
{
return $this->connection;
}
}
207 changes: 207 additions & 0 deletions packages/seal-solr-adapter/SolrConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
<?php

namespace Schranz\Search\SEAL\Adapter\Solr;

use Schranz\Search\SEAL\Marshaller\FlattenMarshaller;
use Schranz\Search\SEAL\Schema\Exception\FieldByPathNotFoundException;
use Schranz\Search\SEAL\Task\SyncTask;
use Solarium\Client;
use Schranz\Search\SEAL\Adapter\ConnectionInterface;
use Schranz\Search\SEAL\Schema\Field;
use Schranz\Search\SEAL\Schema\Index;
use Schranz\Search\SEAL\Search\Condition;
use Schranz\Search\SEAL\Search\Result;
use Schranz\Search\SEAL\Search\Search;
use Schranz\Search\SEAL\Task\TaskInterface;

final class SolrConnection implements ConnectionInterface
{
private FlattenMarshaller $marshaller;

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

public function save(Index $index, array $document, array $options = []): ?TaskInterface
{
$identifierField = $index->getIdentifierField();

/** @var string|null $identifier */
$identifier = ((string) $document[$identifierField->name]) ?? null;

$marshalledDocument = $this->marshaller->marshall($index->fields, $document);
$marshalledDocument['id'] = $identifier; // Solr currently does not support set another identifier then id: https://github.com/schranz-search/schranz-search/issues/87

$update = $this->client->createUpdate();
$indexDocument = $update->createDocument($marshalledDocument);

$update->addDocuments([$indexDocument]);
$update->addCommit();

$this->client->getEndpoint()
->setCollection($index->name);

$this->client->update($update);

if (true !== ($options['return_slow_promise_result'] ?? false)) {
return null;
}

return new SyncTask(null);
}

public function delete(Index $index, string $identifier, array $options = []): ?TaskInterface
{
$update = $this->client->createUpdate();
$update->addDeleteById($identifier);
$update->addCommit();

$this->client->getEndpoint()
->setCollection($index->name);

$this->client->update($update);

if (true !== ($options['return_slow_promise_result'] ?? false)) {
return null;
}

return new SyncTask(null);
}

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

$query = $this->client->createRealtimeGet();
$query->addId($search->filters[0]->identifier);
$result = $this->client->realtimeGet($query);

if (!$result->getNumFound()) {
return new Result(
$this->hitsToDocuments($search->indexes, []),
0
);
}

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

if (count($search->indexes) !== 1) {
throw new \RuntimeException('Solr does not yet support search multiple indexes: https://github.com/schranz-search/schranz-search/issues/86');
}

$index = $search->indexes[\array_key_first($search->indexes)];
$this->client->getEndpoint()
->setCollection($index->name);

$query = $this->client->createSelect();
$helper = $query->getHelper();

$queryText = null;

$filters = [];
foreach ($search->filters as $filter) {
match (true) {
$filter instanceof Condition\SearchCondition => $queryText = $filter->query,
$filter instanceof Condition\IdentifierCondition => $filters[] = $index->getIdentifierField()->name . ':' . $helper->escapeTerm($filter->identifier),
$filter instanceof Condition\EqualCondition => $filters[] = $this->getFilterField($search->indexes, $filter->field) . ':' . $helper->escapeTerm($filter->value),
$filter instanceof Condition\NotEqualCondition => $filters[] = '-' . $this->getFilterField($search->indexes, $filter->field) . ':' . $helper->escapeTerm($helper->escapeTerm($filter->value)),
$filter instanceof Condition\GreaterThanCondition => $filters[] = $this->getFilterField($search->indexes, $filter->field) . ':{' . $helper->escapeTerm($filter->value) . ' TO *}',
$filter instanceof Condition\GreaterThanEqualCondition => $filters[] = $this->getFilterField($search->indexes, $filter->field) . ':[' . $helper->escapeTerm($filter->value) . ' TO *]',
$filter instanceof Condition\LessThanCondition => $filters[] = $this->getFilterField($search->indexes, $filter->field) . ':{* TO ' . $helper->escapeTerm($filter->value) . '}',
$filter instanceof Condition\LessThanEqualCondition => $filters[] = $this->getFilterField($search->indexes, $filter->field) . ':[* TO ' . $helper->escapeTerm($filter->value) . ']',
default => throw new \LogicException($filter::class . ' filter not implemented.'),
};
}

if ($queryText !== null) {
$dismax = $query->getDisMax();
$dismax->setQueryFields(implode(' ', $index->searchableFields));

$query->setQuery($queryText);
}

foreach ($filters as $key => $filter) {
$query->createFilterQuery('filter_' . $key)->setQuery($filter);
}

if ($search->offset) {
$query->setStart($search->offset);
}

if ($search->limit) {
$query->setRows($search->limit);
}

foreach ($search->sortBys as $field => $direction) {
$query->addSort($field, $direction);
}

$result = $this->client->select($query);

return new Result(
$this->hitsToDocuments($search->indexes, $result->getDocuments()),
$result->getNumFound()
);
}

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

foreach ($hits as $hit) {
$hit = $hit->getFields();

unset($hit['_version_']);

if ($index->getIdentifierField()->name !== 'id') {
// Solr currently does not support set another identifier then id: https://github.com/schranz-search/schranz-search/issues/87
$id = $hit['id'];
unset($hit['id']);

$hit[$index->getIdentifierField()->name] = $id;
}

yield $this->marshaller->unmarshall($index->fields, $hit);
}
}

private function getFilterField(array $indexes, string $name): string
{
foreach ($indexes as $index) {
try {
$field = $index->getFieldByPath($name);

if ($field instanceof Field\TextField) {
return $name . '.raw';
}

return $name;
} catch (FieldByPathNotFoundException $e) {
// ignore when field is not found and use go to next index instead
}
}

return $name;
}
}
Loading