diff --git a/src/DependencyInjection/EzSystemsEzPlatformGraphQLExtension.php b/src/DependencyInjection/EzSystemsEzPlatformGraphQLExtension.php index 4ba6156b..db59505d 100644 --- a/src/DependencyInjection/EzSystemsEzPlatformGraphQLExtension.php +++ b/src/DependencyInjection/EzSystemsEzPlatformGraphQLExtension.php @@ -39,6 +39,7 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('services/mutations.yml'); $loader->load('services/resolvers.yml'); $loader->load('services/schema.yml'); + $loader->load('services/search.yml'); $loader->load('services/services.yml'); $loader->load('default_settings.yml'); } diff --git a/src/DependencyInjection/Factory/SearchFeaturesFactory.php b/src/DependencyInjection/Factory/SearchFeaturesFactory.php new file mode 100644 index 00000000..f0924434 --- /dev/null +++ b/src/DependencyInjection/Factory/SearchFeaturesFactory.php @@ -0,0 +1,39 @@ +configurationProvider = $configurationProvider; + $this->searchFeatures = $searchFeatures; + } + + public function build() + { + $searchEngine = $this->configurationProvider->getRepositoryConfig()['search']['engine']; + + if (isset($this->searchFeatures[$searchEngine])) { + return $this->searchFeatures[$searchEngine]; + } else { + throw new \InvalidArgumentException('Search engine not found'); + } + } +} diff --git a/src/GraphQL/DataLoader/CachedContentTypeLoader.php b/src/GraphQL/DataLoader/CachedContentTypeLoader.php index d31ae707..81d9ea6e 100644 --- a/src/GraphQL/DataLoader/CachedContentTypeLoader.php +++ b/src/GraphQL/DataLoader/CachedContentTypeLoader.php @@ -23,6 +23,16 @@ class CachedContentTypeLoader implements ContentTypeLoader */ private $loadedItems = []; + /** + * @var array + */ + private $identifierToIdMap = []; + + /** + * @var ContentType[] + */ + private $loadedItemsByIdentifier = []; + public function __construct(ContentTypeLoader $innerLoader) { $this->innerLoader = $innerLoader; @@ -31,7 +41,9 @@ public function __construct(ContentTypeLoader $innerLoader) public function load($contentTypeId): ContentType { if (!isset($this->loadedItems[$contentTypeId])) { - $this->loadedItems[$contentTypeId] = $this->innerLoader->load($contentTypeId); + $contentType = $this->innerLoader->load($contentTypeId); + $this->loadedItems[$contentTypeId] = $contentType; + $this->identifierToIdMap[$contentType->identifier] = $contentTypeId; } return $this->loadedItems[$contentTypeId]; @@ -39,12 +51,13 @@ public function load($contentTypeId): ContentType public function loadByIdentifier($identifier): ContentType { - $contentType = $this->innerLoader->loadByIdentifier($identifier); - - if (!isset($this->innerLoader[$contentType->id])) { - $this->innerLoader[$contentType->id] = $contentType; + if (!isset($this->identifierToIdMap[$identifier])) { + $contentType = $this->innerLoader->loadByIdentifier($identifier); + $this->loadedItems[$contentType->id] = $contentType; + $this->identifierToIdMap[$identifier] = $contentType->id; } - return $contentType; + + return $this->loadedItems[$this->identifierToIdMap[$identifier]]; } } diff --git a/src/GraphQL/InputMapper/FieldsQueryMapper.php b/src/GraphQL/InputMapper/FieldsQueryMapper.php new file mode 100644 index 00000000..1cf24cd1 --- /dev/null +++ b/src/GraphQL/InputMapper/FieldsQueryMapper.php @@ -0,0 +1,103 @@ +innerMapper = $innerMapper; + $this->contentTypeLoader = $contentTypeLoader; + } + + /** + * @param array $inputArray + * + * @return \eZ\Publish\API\Repository\Values\Content\Query + */ + public function mapInputToQuery(array $inputArray) + { + if (isset($inputArray['ContentTypeIdentifier']) && isset($inputArray['fieldsFilters'])) { + $contentType = $this->contentTypeLoader->loadByIdentifier($inputArray['ContentTypeIdentifier']); + $fieldsArgument = []; + + foreach ($inputArray['fieldsFilters'] as $fieldDefinitionIdentifier => $value) { + if (($fieldDefinition = $contentType->getFieldDefinition($fieldDefinitionIdentifier)) === null) { + continue; + } + + if (!$fieldDefinition->isSearchable) { + continue; + } + + $fieldFilter = $this->buildFieldFilter($fieldDefinitionIdentifier, $value); + if ($fieldFilter !== null) { + $fieldsArgument[] = $fieldFilter; + } + } + + $inputArray['Fields'] = $fieldsArgument; + } + + return $this->innerMapper->mapInputToQuery($inputArray); + } + + private function buildFieldFilter($fieldDefinitionIdentifier, $value) + { + if (is_array($value) && count($value) === 1) { + $value = $value[0]; + } + $operator = 'eq'; + + // @todo if 3 items, and first item is 'between', use next two items as value + if (is_array($value)) { + $operator = 'in'; + } elseif (is_string($value)) { + if ($value[0] === '~') { + $operator = 'like'; + $value = substr($value, 1); + if (strpos($value, '%') === false) { + $value = "%$value%"; + } + } elseif ($value[0] === '<') { + $value = substr($value, 1); + if ($value[0] === '=') { + $operator = 'lte'; + $value = substr($value, 2); + } else { + $operator = 'lt'; + $value = substr($value, 1); + } + } elseif ($value[0] === '<') { + $value = substr($value, 1); + if ($value[0] === '=') { + $operator = 'gte'; + $value = substr($value, 2); + } else { + $operator = 'gt'; + $value = substr($value, 1); + } + } + } + + return ['target' => $fieldDefinitionIdentifier, $operator => trim($value)]; + } +} diff --git a/src/GraphQL/InputMapper/QueryMapper.php b/src/GraphQL/InputMapper/QueryMapper.php new file mode 100644 index 00000000..20c5760b --- /dev/null +++ b/src/GraphQL/InputMapper/QueryMapper.php @@ -0,0 +1,17 @@ +mapInputToFieldCriterion($inputArray['Field']); - } else { - $criteria = array_merge( - $criteria, - array_map( - function ($input) { - return $this->mapInputToFieldCriterion($input); - }, - $inputArray['Field'] - ) - ); - } + $inputArray['Fields'] = [$inputArray['Field']]; + } + + if (isset($inputArray['Fields'])) { + $criteria = array_merge( + $criteria, + array_map( + function ($input) { + return $this->mapInputToFieldCriterion($input); + }, + $inputArray['Fields'] + ) + ); } if (isset($inputArray['ParentLocationId'])) { diff --git a/src/GraphQL/Resolver/DomainContentResolver.php b/src/GraphQL/Resolver/DomainContentResolver.php index 619ba406..8d65fdf0 100644 --- a/src/GraphQL/Resolver/DomainContentResolver.php +++ b/src/GraphQL/Resolver/DomainContentResolver.php @@ -8,7 +8,7 @@ use EzSystems\EzPlatformGraphQL\GraphQL\DataLoader\ContentLoader; use EzSystems\EzPlatformGraphQL\GraphQL\DataLoader\ContentTypeLoader; -use EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper; +use EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\QueryMapper; use eZ\Publish\API\Repository\Repository; use eZ\Publish\Core\FieldType; use eZ\Publish\API\Repository\Values\Content\Content; @@ -31,7 +31,7 @@ class DomainContentResolver private $typeResolver; /** - * @var SearchQueryMapper + * @var \EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\QueryMapper */ private $queryMapper; @@ -53,7 +53,7 @@ class DomainContentResolver public function __construct( Repository $repository, TypeResolver $typeResolver, - SearchQueryMapper $queryMapper, + QueryMapper $queryMapper, ContentLoader $contentLoader, ContentTypeLoader $contentTypeLoader) { diff --git a/src/GraphQL/Resolver/SearchResolver.php b/src/GraphQL/Resolver/SearchResolver.php index 1a3b967d..df9a0994 100644 --- a/src/GraphQL/Resolver/SearchResolver.php +++ b/src/GraphQL/Resolver/SearchResolver.php @@ -7,7 +7,7 @@ namespace EzSystems\EzPlatformGraphQL\GraphQL\Resolver; use EzSystems\EzPlatformGraphQL\GraphQL\DataLoader\ContentLoader; -use EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper; +use EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\QueryMapper; use eZ\Publish\API\Repository\SearchService; use Overblog\GraphQLBundle\Relay\Connection\Paginator; @@ -22,7 +22,7 @@ class SearchResolver private $searchService; /** - * @var SearchQueryMapper + * @var QueryMapper */ private $queryMapper; @@ -31,7 +31,7 @@ class SearchResolver */ private $contentLoader; - public function __construct(ContentLoader $contentLoader, SearchService $searchService, SearchQueryMapper $queryMapper) + public function __construct(ContentLoader $contentLoader, SearchService $searchService, QueryMapper $queryMapper) { $this->contentLoader = $contentLoader; $this->searchService = $searchService; @@ -48,6 +48,7 @@ public function searchContent($args) public function searchContentOfTypeAsConnection($contentTypeIdentifier, $args) { $query = $args['query'] ?: []; + $query['fieldsFilters'] = $args['filter'] ?: []; $query['ContentTypeIdentifier'] = $contentTypeIdentifier; $query['sortBy'] = $args['sortBy']; $query = $this->queryMapper->mapInputToQuery($query); diff --git a/src/Resources/config/services/search.yml b/src/Resources/config/services/search.yml new file mode 100644 index 00000000..5c006f8e --- /dev/null +++ b/src/Resources/config/services/search.yml @@ -0,0 +1,31 @@ +services: + _defaults: + autoconfigure: true + autowire: true + public: false + + EzSystems\EzPlatformGraphQL\DependencyInjection\Factory\SearchFeaturesFactory: + arguments: + $configurationProvider: '@ezpublish.api.repository_configuration_provider' + $searchFeatures: + solr: '@EzSystems\EzPlatformGraphQL\Search\SolrSearchFeatures' + legacy: '@EzSystems\EzPlatformGraphQL\Search\LegacySearchFeatures' + + + EzSystems\EzPlatformGraphQL\Search\SearchFeatures: + factory: ['@EzSystems\EzPlatformGraphQL\DependencyInjection\Factory\SearchFeaturesFactory', build] + + EzSystems\EzPlatformGraphQL\Search\SolrSearchFeatures: ~ + + EzSystems\EzPlatformGraphQL\Search\LegacySearchFeatures: + arguments: + $converterRegistry: '@ezpublish.persistence.legacy.field_value_converter.registry' + + EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\QueryMapper: '@EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper' + + EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper: ~ + + EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\FieldsQueryMapper: + decorates: EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper + arguments: + $innerMapper: '@EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\FieldsQueryMapper.inner' \ No newline at end of file diff --git a/src/Resources/config/services/services.yml b/src/Resources/config/services/services.yml index 32c7ed11..7c1fe1d9 100644 --- a/src/Resources/config/services/services.yml +++ b/src/Resources/config/services/services.yml @@ -11,5 +11,3 @@ services: - { name: console.command } EzSystems\EzPlatformGraphQL\GraphQL\TypeDefinition\ContentTypeMapper: ~ - - EzSystems\EzPlatformGraphQL\GraphQL\InputMapper\SearchQueryMapper: ~ diff --git a/src/Schema/Domain/Content/NameHelper.php b/src/Schema/Domain/Content/NameHelper.php index e91d650f..551fceb0 100644 --- a/src/Schema/Domain/Content/NameHelper.php +++ b/src/Schema/Domain/Content/NameHelper.php @@ -93,6 +93,11 @@ public function fieldDefinitionField(FieldDefinition $fieldDefinition) return lcfirst($this->toCamelCase($fieldDefinition->identifier)); } + public function filterType(ContentType $contentType) + { + return $this->domainContentName($contentType) . 'Filter'; + } + private function toCamelCase($string) { return $this->caseConverter->denormalize($string); diff --git a/src/Schema/Domain/Content/Worker/ContentType/DefineDomainContentFilter.php b/src/Schema/Domain/Content/Worker/ContentType/DefineDomainContentFilter.php new file mode 100644 index 00000000..9b306bd9 --- /dev/null +++ b/src/Schema/Domain/Content/Worker/ContentType/DefineDomainContentFilter.php @@ -0,0 +1,49 @@ +addType(new Input\Type($this->filterType($args), 'input-object')); + $schema->addArgToField( + $this->groupType($args), + $this->connectionField($args), + new Input\Arg('filter', $this->filterType($args)) + ); + } + + public function canWork(Builder $schema, array $args) + { + return isset($args['ContentTypeGroup']) && $args['ContentTypeGroup'] instanceof ContentTypeGroup + && isset($args['ContentType']) && $args['ContentType'] instanceof ContentType + && !$schema->hasType($this->filterType($args)); + } + + protected function filterType(array $args): string + { + return $this->getNameHelper()->filterType($args['ContentType']); + } + + protected function groupType(array $args): string + { + return $this->getNameHelper()->domainGroupName($args['ContentTypeGroup']); + } + + protected function connectionField(array $args): string + { + return $this->getNameHelper()->domainContentCollectionField($args['ContentType']); + } +} diff --git a/src/Schema/Domain/Content/Worker/FieldDefinition/AddFieldDefinitionToCollectionFilters.php b/src/Schema/Domain/Content/Worker/FieldDefinition/AddFieldDefinitionToCollectionFilters.php new file mode 100644 index 00000000..d7b96f37 --- /dev/null +++ b/src/Schema/Domain/Content/Worker/FieldDefinition/AddFieldDefinitionToCollectionFilters.php @@ -0,0 +1,84 @@ +searchFeatures = $searchFeatures; + } + + public function work(Builder $schema, array $args) + { + $schema->addFieldToType( + $this->filterType($args), + new Builder\Input\Field( + $this->fieldDefinitionField($args), + $this->getFilterType($args['FieldDefinition']), + ['description' => 'Filter content based on the ' . $args['FieldDefinition']->identifier . ' field'] + ) + ); + } + + public function canWork(Builder $schema, array $args) + { + return + isset($args['FieldDefinition']) + && $args['FieldDefinition'] instanceof FieldDefinition + & isset($args['ContentType']) + && $args['ContentType'] instanceof ContentType + && $this->searchFeatures->supportsFieldCriterion($args['FieldDefinition']); + } + + /** + * @param array $args + * + * @return string + */ + protected function filterType(array $args): string + { + return $this->getNameHelper()->filterType($args['ContentType']); + } + + private function isSearchable(FieldDefinition $fieldDefinition): bool + { + return $fieldDefinition->isSearchable + // should only be verified if legacy is the current search engine + && $this->converterRegistry->getConverter($fieldDefinition->fieldTypeIdentifier)->getIndexColumn() !== false; + } + + private function getFilterType(FieldDefinition $fieldDefinition): string + { + switch ($fieldDefinition->fieldTypeIdentifier) { + case 'ezboolean': + return 'Boolean'; + default: + return 'String'; + } + } + + private function fieldDefinitionField(array $args): string + { + return $this->getNameHelper()->fieldDefinitionField($args['FieldDefinition']); + } +} diff --git a/src/Search/LegacySearchFeatures.php b/src/Search/LegacySearchFeatures.php new file mode 100644 index 00000000..0e61b2f4 --- /dev/null +++ b/src/Search/LegacySearchFeatures.php @@ -0,0 +1,28 @@ +converterRegistry = $converterRegistry; + } + + public function supportsFieldCriterion(FieldDefinition $fieldDefinition) + { + return $this->converterRegistry->getConverter($fieldDefinition->fieldTypeIdentifier)->getIndexColumn() !== false; + } +} diff --git a/src/Search/SearchFeatures.php b/src/Search/SearchFeatures.php new file mode 100644 index 00000000..bd69cfe1 --- /dev/null +++ b/src/Search/SearchFeatures.php @@ -0,0 +1,21 @@ +