diff --git a/composer.json b/composer.json index 2b70d75..428787a 100644 --- a/composer.json +++ b/composer.json @@ -8,9 +8,12 @@ "makinacorpus/php-lucene": "^1.1" }, "require-dev": { + "drupal/better_exposed_filters": "^7.0", "drupal/coder": "^8.3", "drupal/devel": "^4.0|^5.0", + "drupal/facets": "^3.0", "drupal/geofield": "^1", + "drupal/key": "^1.20", "drush/drush": "^12.0 || ^13", "phayes/geophp": "^1.2" }, @@ -19,5 +22,11 @@ "drupal/search_api_autocomplete": "Provides auto complete for search boxes.", "drupal/search_api_location": "Provides location searches.", "drupal/search_api_spellcheck": "Provides spell checking and 'Did You Mean?'." + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.yml b/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.yml new file mode 100644 index 0000000..7427555 --- /dev/null +++ b/config/schema/elasticsearch_connector.connector.elastic_cloud_endpoint.yml @@ -0,0 +1,13 @@ +plugin.plugin_configuration.elasticsearch_connector.elastic_cloud_endpoint: + type: config_object + label: 'Elasticsearch Elastic Cloud connector settings' + mapping: + api_key_id: + type: string + label: 'Elastic Cloud API key ID' + url: + type: string + label: 'Elasticsearch endpoint' + enable_debug_logging: + type: boolean + label: 'Enable debugging mode: log ElasticSearch network traffic' diff --git a/config/schema/plugin.plugin_configuration.search_api_datasource.elasticsearch_document.yml b/config/schema/plugin.plugin_configuration.search_api_datasource.elasticsearch_document.yml new file mode 100644 index 0000000..7b8dbf8 --- /dev/null +++ b/config/schema/plugin.plugin_configuration.search_api_datasource.elasticsearch_document.yml @@ -0,0 +1,16 @@ +plugin.plugin_configuration.search_api_datasource.elasticsearch_document:*: + type: mapping + label: "Elasticsearch document datasource configuration" + mapping: + real_elasticcloud_index_id: + type: string + label: "Real ElasticCloud Index ID" + label_field: + type: string + label: "Label field" + url_field: + type: string + label: "URL field" + combined_id: + type: boolean + label: "Combined ID" diff --git a/elasticsearch_connector.services.yml b/elasticsearch_connector.services.yml index af7c4e2..c978492 100644 --- a/elasticsearch_connector.services.yml +++ b/elasticsearch_connector.services.yml @@ -84,3 +84,13 @@ services: class: Drupal\elasticsearch_connector\Event\SynonymsSubscriber tags: - { name: 'event_subscriber' } + + elasticsearch_connector.field_manager: + class: Drupal\elasticsearch_connector\ElasticSearchFieldManager + arguments: ['@cache.discovery'] + + elasticsearch_connector.event_subscriber: + class: Drupal\elasticsearch_connector\EventSubscriber\ElasticsearchConnectorSubscriber + arguments: ['@module_handler', '@plugin.manager.views.field'] + tags: + - { name: event_subscriber } diff --git a/src/ElasticSearchFieldManager.php b/src/ElasticSearchFieldManager.php new file mode 100644 index 0000000..eab856b --- /dev/null +++ b/src/ElasticSearchFieldManager.php @@ -0,0 +1,218 @@ +cacheBackend = $cache_backend; + } + + /** + * {@inheritdoc} + * + * @throws \Drupal\search_api\SearchApiException + */ + public function getFieldDefinitions($index, $cluster_name, $index_name) { + // We need to prevent the use of the field definition cache when we are + // about to save changes, or the property check in Index::presave will work + // with stale cached data and remove newly added property definitions. + // We take the presence of $index->original as indicator that the config + // entity is being saved. + if (!empty($index->original)) { + return $this->buildFieldDefinitions($cluster_name, $index_name); + } + + $index_id = $index->id(); + if (!isset($this->fieldDefinitions[$index_id])) { + // Not prepared, try to load from cache. + $cid = 'elasticsearch_field_definitions:' . $index_id; + if ($cache = $this->cacheGet($cid)) { + $field_definitions = $cache->data; + } + else { + $field_definitions = $this->buildFieldDefinitions($cluster_name, $index_name); + $this->cacheSet($cid, $field_definitions, Cache::PERMANENT, $index->getCacheTagsToInvalidate()); + } + + $this->fieldDefinitions[$index_id] = $field_definitions; + } + + return $this->fieldDefinitions[$index_id]; + } + + /** + * Builds the field definitions for an Elasticsearch server. + * + * @param string $cluster_name + * The cluster name. + * @param string $index_name + * The index name. + * + * @return \Drupal\Core\TypedData\DataDefinitionInterface[] + * The array of field definitions for the server, keyed by field name. + * + * @throws \InvalidArgumentException + */ + protected function buildFieldDefinitions($cluster_name, $index_name) { + $fields = []; + + try { + // Get the list of the fields in index directly from Elasticsearch. + //****TODO: Get clusters and clients for clusters. + $server = Server::load($cluster_name); + + if ($server) { + $backend = $server->getBackend(); + + $connector = $backend->getConnector(); + + if ($connector instanceof ElasticCloudEndpointConnector) { + $index_mapping = $connector->getElasticSearchFields(); + $fields = $this->buildFieldDefinitionsFromIndexMapping($index_mapping[$index_name]['mappings']['properties']); + $fields['_id'] = DataDefinition::create('string') + ->setLabel('Elasticsearch ID'); + } + + } + } + catch (Exception $e) { + // Log the exception. + $context = [ + '@index' => $index_name, + '@exception' => get_class($e), + '@message' => $e->getMessage() ?: $this->t('- No message -'), + ]; + $this->getLogger('elasticsearch_connector') + ->error('Unable to build field definitions for index @index. Elasticsearch exception @exception (@message)', $context); + } + + return $fields; + } + + /** + * Build field definition from index mapping. + * + * @param array $index_mapping + * The index mapping. + * @param \Drupal\elasticsearch_connector\TypedData\ElasticSearchMapDefinition|null $parent_map_definition + * (optional) The parent map definition the found properties belong to. + * + * @return array + */ + protected function buildFieldDefinitionsFromIndexMapping(array $index_mapping, ElasticSearchMapDefinition $parent_map_definition = NULL) { + $field_definitions = []; + foreach ($index_mapping as $field_name => $field_info) { + $field_definition = NULL; + + if (empty($field_info['type'])) { + // Map. + if (isset($field_info['properties'])) { + $map_definition = ElasticSearchMapDefinition::create(); + $this->buildFieldDefinitionsFromIndexMapping($field_info['properties'], $map_definition); + $field_definition = ListDataDefinition::create(ElasticSearchMapDefinitionInterface::DATA_TYPE_ID); + $field_definition->setItemDefinition($map_definition); + } + } + else { + // Scalar. + switch ($field_info['type']) { + case 'date': + $field_definition = ListDataDefinition::create(ElasticSearchDateDefinitionInterface::DATA_TYPE_ID); + break; + + case 'boolean': + $field_definition = ListDataDefinition::create('boolean'); + break; + + case 'text': + $field_definition = ListDataDefinition::create('search_api_text'); + break; + + case 'keyword': + $field_definition = ListDataDefinition::create('string'); + break; + + // Handle numeric filter type. + case 'short': + case 'byte': + case 'integer': + case 'long': + $field_definition = ListDataDefinition::create('integer'); + break; + + case 'float': + case 'double': + case 'half_float': + case 'scaled_float': + $field_definition = ListDataDefinition::create('float'); + break; + } + } + + if ($field_definition) { + $label = Unicode::ucfirst(trim(str_replace('_', ' ', $field_name))); + $field_definition->setLabel($label); + + if (isset($field_info['fields'])) { + // Multi-field. Mapped as property map, with the field value as the + // main property. + $map_definition = ElasticSearchMapDefinition::create(); + // The main value is not a list. + $map_definition->setPropertyDefinition('_value', $field_definition->getItemDefinition()); + $map_definition->setMainPropertyName('_value'); + + // Build the rest of sub-fields as map properties. + $this->buildFieldDefinitionsFromIndexMapping($field_info['fields'], $map_definition); + $field_definition = ListDataDefinition::create(ElasticSearchMapDefinitionInterface::DATA_TYPE_ID); + $field_definition->setItemDefinition($map_definition); + $field_definition->setLabel($label); + } + + if ($parent_map_definition) { + $parent_map_definition->setPropertyDefinition($field_name, $field_definition); + } + + $field_definitions[$field_name] = $field_definition; + } + } + + return $field_definitions; + } + +} diff --git a/src/ElasticSearchFieldManagerInterface.php b/src/ElasticSearchFieldManagerInterface.php new file mode 100644 index 0000000..2e7e57f --- /dev/null +++ b/src/ElasticSearchFieldManagerInterface.php @@ -0,0 +1,25 @@ +moduleHandler = $module_handler; + $this->viewsFieldPluginManager = $views_field_plugin_manager; + } + + /** + * Search API mapping views handlers event subscriber. + * + * @param \Drupal\search_api\Event\MappingViewsFieldHandlersEvent $event + * Search API views field handlers mapping event. + */ + public function onSearchApiMappingViewsHandlers(MappingViewsFieldHandlersEvent $event) { + $mappings = &$event->getFieldHandlerMapping(); + + // Add mapping for custom data types. + $mappings[ElasticSearchDateDefinitionInterface::DATA_TYPE_ID] = [ + 'id' => 'search_api_date', + ]; + $mappings[ElasticSearchMapDefinitionInterface::DATA_TYPE_ID] = [ + // @todo Remove conditional assigment when search_api_text were added to + // Search API, @see https://www.drupal.org/node/2874641 + 'id' => $this->viewsFieldPluginManager->hasDefinition('search_api_text') ? 'search_api_text' : 'search_api', + ]; + } + + /** + * Search API field type mapping event subscriber. + * + * @param \Drupal\search_api\Event\MappingFieldTypesEvent $event + * Search API field type mapping event. + */ + public function onSearchApiMappingFieldTypes(MappingFieldTypesEvent $event) { + $mappings = &$event->getFieldTypeMapping(); + + // Add mapping for custom data types. + $mappings[ElasticSearchDateDefinitionInterface::DATA_TYPE_ID] = 'date'; + $mappings[ElasticSearchMapDefinitionInterface::DATA_TYPE_ID] = 'object'; + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events = []; + if (class_exists('\Drupal\search_api\Event\SearchApiEvents')) { + $events[SearchApiEvents::MAPPING_VIEWS_FIELD_HANDLERS][] = ['onSearchApiMappingViewsHandlers']; + $events[SearchApiEvents::MAPPING_FIELD_TYPES][] = ['onSearchApiMappingFieldTypes']; + } + + return $events; + } + +} diff --git a/src/Plugin/DataType/Deriver/ElasticSearchDocumentDeriver.php b/src/Plugin/DataType/Deriver/ElasticSearchDocumentDeriver.php new file mode 100644 index 0000000..bb5be32 --- /dev/null +++ b/src/Plugin/DataType/Deriver/ElasticSearchDocumentDeriver.php @@ -0,0 +1,96 @@ +get('entity_type.manager') + ); + } + + /** + * ElasticsearchDocumentDeriver constructor. + * + * @param string $base_plugin_id + * Base plugin ID. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * Entity type manager. + */ + public function __construct($base_plugin_id, EntityTypeManagerInterface $entity_type_manager) { + $this->basePluginId = $base_plugin_id; + $this->entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * Thrown if the entity type doesn't exist. + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * Thrown if the storage handler couldn't be loaded. + * @throws \Drupal\search_api\SearchApiException + */ + public function getDerivativeDefinitions($base_plugin_definition) { + // Keep the 'elasticsearch_document' defined without any index. + $this->derivatives[''] = $base_plugin_definition; + + // Load all indexes and filter out elasticsearch_connector-based. + if ($this->entityTypeManager->hasDefinition('search_api_index')) { + $indexes = $this->entityTypeManager->getStorage('search_api_index') + ->loadMultiple(); + /* @var \Drupal\search_api\IndexInterface $index */ + foreach ($indexes as $index_id => $entity) { + $server = $entity->getServerInstance(); + if ( + $server && + $server->getBackend() instanceof ElasticsearchBackend + ) { + $this->derivatives[$index_id] = [ + 'label' => $base_plugin_definition['label'] . ':' . $entity->label(), + ] + + $base_plugin_definition; + } + } + } + + return $this->derivatives; + } + +} diff --git a/src/Plugin/DataType/ElasticSearchDate.php b/src/Plugin/DataType/ElasticSearchDate.php new file mode 100644 index 0000000..04ed6de --- /dev/null +++ b/src/Plugin/DataType/ElasticSearchDate.php @@ -0,0 +1,31 @@ +getIndex()->id()); + $instance = new static($definition); + $instance->setValue($item); + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getValue() { + return $this->item; + } + + /** + * {@inheritdoc} + */ + public function setValue($item, $notify = TRUE) { + $this->item = $item; + } + + /** + * {@inheritdoc} + * + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\TypedData\Exception\ReadOnlyException + */ + public function get($property_name) { + if (!isset($this->item)) { + throw new MissingDataException("Unable to get Elasticsearch field $property_name as no item has been provided."); + } + + // First, verify that this field actually exists in the Elasticsearch + // server. If we can't get a definition for it, it doesn't exist. + $field_manager = \Drupal::getContainer()->get('elasticsearch_connector.field_manager'); + + $index = $this->item->getIndex(); + /** @var \Drupal\elasticsearch_connector\Plugin\search_api\datasource\ElasticSearchDocument $datasource */ + $datasource = $index->getDatasource($this->item->getDatasourceId()); + $field_definitions = $field_manager->getFieldDefinitions($this->item->getIndex(), $datasource->getClusterName(), $datasource->getIndexName()); + if (empty($field_definitions[$property_name])) { + throw new \InvalidArgumentException("The Elasticsearch field $property_name could not be found on the server."); + } + + // Find its values. + $property_value = NULL; + // Look at the field values contained in the result item. + $fields = $this->item->getFields(FALSE); + if (isset($fields[$property_name]) + && $fields[$property_name]->getPropertyPath() === $property_name) { + $property_value = $fields[$property_name]->getValues(); + } + else { + foreach ($fields as $field) { + if ( + $field->getDatasourceId() === 'elasticsearch_document' && + $field->getPropertyPath() === $property_name + ) { + $property_value = $field->getValues(); + break; + } + } + } + + if ($property_value === NULL) { + // Try to get the field from the raw Elasticsearch result. + $result = $this->item->getExtraData('elasticsearch_result'); + if (isset($result[$property_name])) { + $property_value = $result[$property_name]; + } + } + + // Create a new typed data object from the item's field data. + $property = $this->getTypedDataManager()->create( + $field_definitions[$property_name], + $property_value, + $property_name, + $this + ); + + return $property; + } + + /** + * {@inheritdoc} + */ + public function set($property_name, $value, $notify = TRUE) { + // Do nothing because we treat Elasticsearch documents as read-only. + return $this; + } + + /** + * {@inheritdoc} + */ + public function getProperties($include_computed = FALSE) { + // @todo Implement this. + } + + /** + * {@inheritdoc} + */ + public function toArray() { + // @todo Implement this. + } + + /** + * {@inheritdoc} + */ + public function isEmpty() { + return !isset($this->item); + } + + /** + * {@inheritdoc} + */ + public function onChange($name) { + // Do nothing. Unlike content entities, we don't need to be notified of + // changes. + } + + /** + * {@inheritdoc} + */ + public function getIterator(): \Traversable { + return isset($this->item) ? $this->item->getIterator() : new \ArrayIterator([]); + } + +} diff --git a/src/Plugin/DataType/ElasticSearchMap.php b/src/Plugin/DataType/ElasticSearchMap.php new file mode 100644 index 0000000..b1f7151 --- /dev/null +++ b/src/Plugin/DataType/ElasticSearchMap.php @@ -0,0 +1,48 @@ +values = []; + + // Remove existing properties value if not set in the new values. + $property_keys = array_keys($this->properties) + array_keys($values); + foreach ($property_keys as $property_key) { + $this->writePropertyValue($property_key, isset($values[$property_key]) ? $values[$property_key] : NULL); + } + + // Notify the parent of any changes. + if ($notify && isset($this->parent)) { + $this->parent->onChange($this->name); + } + } + +} diff --git a/src/Plugin/ElasticSearch/Connector/BasicAuthConnector.php b/src/Plugin/ElasticSearch/Connector/BasicAuthConnector.php index feb2cd7..773ad53 100644 --- a/src/Plugin/ElasticSearch/Connector/BasicAuthConnector.php +++ b/src/Plugin/ElasticSearch/Connector/BasicAuthConnector.php @@ -56,7 +56,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#description' => $this->t('If this field is left blank and the HTTP username is filled out, the current password will not be changed.'), ]; - $form_state->set('previous_password', $this->configuration['password']); + $form_state->set('previous_password', $this->configuration['password'] ?? ''); return $form; } diff --git a/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php b/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php new file mode 100644 index 0000000..9fed25f --- /dev/null +++ b/src/Plugin/ElasticSearch/Connector/ElasticCloudEndpointConnector.php @@ -0,0 +1,237 @@ +logger = $container->get('logger.channel.elasticsearch_connector_client'); + + // If the key module is installed, then a 'key.repository' service will be + // available: if so, set that. + if ($container->has('key.repository')) { + $instance->keyRepository = $container->get('key.repository'); + } + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getLabel(): string { + return (string) $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function getDescription(): string { + return (string) $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getUrl(): string { + return $this->configuration['url']; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration + $this->defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function getClient(): Client { + $clientBuilder = ClientBuilder::create() + ->setHosts([$this->configuration['url']]); + + if ($this->keyRepositoryIsValid()) { + $apiKey = $this->keyRepository->getKey($this->configuration['api_key_id']) + ?->getKeyValue(); + $clientBuilder->setApiKey($apiKey); + } + + if ($this->configuration['enable_debug_logging']) { + $clientBuilder->setLogger($this->logger); + } + + return $clientBuilder->build(); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'real_elastic_search_id' => '', + 'api_key_id' => '', + 'url' => '', + 'enable_debug_logging' => FALSE, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $form['url'] = [ + '#type' => 'url', + '#title' => $this->t('Elasticsearch endpoint'), + '#description' => $this->t("Your Hosted deployment's Elasticsearch endpoint, which looks like a URL."), + '#default_value' => $this->configuration['url'] ?? '', + '#required' => TRUE, + ]; + + $form['real_elastic_search_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Elasticsearch id'), + '#description' => $this->t("Elasticsearch id for this endpoint."), + '#default_value' => $this->configuration['real_elastic_search_id'] ?? '', + ]; + + // If the key repository is valid, then we can assume the key module is + // installed, and present a key-selection widget. + if ($this->keyRepositoryIsValid()) { + $form['api_key_id'] = [ + '#type' => 'key_select', + '#title' => $this->t('Elastic Cloud API key'), + '#default_value' => $this->configuration['api_key_id'] ?? '', + '#required' => TRUE, + '#description' => $this->t('At minimum, this API key needs the following security privileges...
@minimum_security_privileges
...where @role_name is a unique role name; and @index_1_name, and @index_2_name are the machine names of the Indices you create on the server.', [ + '@minimum_security_privileges' => '{"role-a": {"indices": [{"names": ["INDEX_1", "INDEX_2"], "privileges": ["all", "create_index", "delete_index", "maintenance"], "allow_restricted_indices": false}]}}', + '@role_name' => 'role-a', + '@index_1_name' => 'INDEX_1', + '@index_2_name' => 'INDEX_2', + ]), + ]; + } + // If the key repository is not valid, then the key module is probably not + // installed. This plugin won't work without it, so display an error + // message. + else { + $form['no_key_module_message'] = [ + '#theme' => 'status_messages', + '#status_headings' => [ + MessengerInterface::TYPE_ERROR => $this->t('Key module missing'), + ], + '#message_list' => [ + MessengerInterface::TYPE_ERROR => [ + $this->t("You must install Drupal's Key module to use this authentication type! Please ensure that the Key module is downloaded and enabled.", [ + '@key_module_url' => 'https://www.drupal.org/project/key', + ]), + ], + ], + ]; + } + + $form['enable_debug_logging'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable debugging mode: log ElasticSearch network traffic'), + '#description' => $this->t("This will write requests, responses, and response-time information to Drupal's log, which may help you diagnose problems with Drupal's connection to ElasticSearch.

Warning: This setting will result in poor performance and may log a user’s personally identifiable information. This setting is only intended for temporary use and should be disabled when you finish debugging. Logs written while this mode is active will remain in the log until you clear them or the logs are rotated.

"), + '#default_value' => $this->configuration['enable_debug_logging'] ?? FALSE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['real_elastic_search_id'] = \trim($form_state->getValue('real_elastic_search_id') ?? ''); + $this->configuration['api_key_id'] = \trim($form_state->getValue('api_key_id') ?? ''); + $this->configuration['url'] = \trim($form_state->getValue('url') ?? ''); + $this->configuration['enable_debug_logging'] = (bool) $form_state->getValue('enable_debug_logging') ?? FALSE; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $url = $form_state->getValue('url'); + if (!UrlHelper::isValid($url)) { + $form_state->setErrorByName('url', $this->t("Invalid Elasticsearch endpoint")); + } + } + + /** + * Determine if the key repository is valid, implying key module is installed. + * + * @return bool + * TRUE if the keyRepository service injected into this plugin is of type + * \Drupal\key\KeyRepositoryInterface; FALSE otherwise. + */ + protected function keyRepositoryIsValid(): bool { + // Note that we are intentionally NOT using KeyRepositoryInterface::class + // here, because doing so would introduce a required dependency on the Key + // module. However, we want an OPTIONAL dependency on the Key module. + return \is_a($this->keyRepository, 'Drupal\key\KeyRepositoryInterface', TRUE); + } + + public function getElasticSearchFields() { + try { + $response = $this->getClient()->indices()->getMapping(); + return $response->asArray(); + } catch (ClientException $e) { + $this->logger->error($e->getMessage()); + } + + return []; + } +} diff --git a/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php b/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php new file mode 100644 index 0000000..d019556 --- /dev/null +++ b/src/Plugin/ElasticSearch/Connector/ElasticCloudIdConnector.php @@ -0,0 +1,232 @@ +logger = $container->get('logger.channel.elasticsearch_connector_client'); + + // If the key module is installed, then a 'key.repository' service will be + // available: if so, set that. + if ($container->has('key.repository')) { + $instance->keyRepository = $container->get('key.repository'); + } + + return $instance; + } + + /** + * {@inheritdoc} + */ + public function getLabel(): string { + return (string) $this->pluginDefinition['label']; + } + + /** + * {@inheritdoc} + */ + public function getDescription(): string { + return (string) $this->pluginDefinition['description']; + } + + /** + * {@inheritdoc} + */ + public function getUrl(): string { + // If we can, build a client, and ask it for the URI. + if ($this->configuration['elastic_cloud_id'] && $this->configuration['api_key_id']) { + $client = $this->getClient(); + return (string) $client->getTransport()->getNodePool()->nextNode()->getUri(); + } + + // If we're missing the data needed to find the URL, then return an empty + // string. + return ''; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration + $this->defaultConfiguration(); + } + + /** + * {@inheritdoc} + */ + public function getClient(): Client { + $clientBuilder = ClientBuilder::create() + ->setElasticCloudId($this->configuration['elastic_cloud_id']); + + if ($this->keyRepositoryIsValid()) { + $apiKey = $this->keyRepository->getKey($this->configuration['api_key_id'])?->getKeyValue(); + $clientBuilder->setApiKey($apiKey); + } + + if ($this->configuration['enable_debug_logging']) { + $clientBuilder->setLogger($this->logger); + } + + return $clientBuilder->build(); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration(): array { + return [ + 'api_key_id' => '', + 'elastic_cloud_id' => '', + 'enable_debug_logging' => FALSE, + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $form['elastic_cloud_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Elastic Cloud ID'), + '#description' => $this->t("Your Hosted deployment's Cloud ID, in the format <label>:<cloud-id>, where <label>: is an optional human-readable name, and <cloud-id> is a base64-encoded text value of about 120 characters, made up of upper and lower case letters and numbers. Your Cloud ID is displayed at the top of the Hosted deployment's Kibana instance's Overview page. You can also find it by clicking on the deployment name from the Elastic Cloud Hosted deployments list page", [ + '@elastic_cloud_deployments' => 'https://cloud.elastic.co/deployments/', + ]), + '#default_value' => $this->configuration['elastic_cloud_id'] ?? '', + '#required' => TRUE, + '#maxlength' => 500, + ]; + + // If the key repository is valid, then we can assume the key module is + // installed, and present a key-selection widget. + if ($this->keyRepositoryIsValid()) { + $form['api_key_id'] = [ + '#type' => 'key_select', + '#title' => $this->t('Elastic Cloud API key'), + '#default_value' => $this->configuration['api_key_id'] ?? '', + '#required' => TRUE, + '#description' => $this->t('At minimum, this API key needs the following security privileges...
@minimum_security_privileges
...where @role_name is a unique role name; and @index_1_name, and @index_2_name are the machine names of the Indices you create on the server.', [ + '@minimum_security_privileges' => '{"role-a": {"indices": [{"names": ["INDEX_1", "INDEX_2"], "privileges": ["all", "create_index", "delete_index", "maintenance"], "allow_restricted_indices": false}]}}', + '@role_name' => 'role-a', + '@index_1_name' => 'INDEX_1', + '@index_2_name' => 'INDEX_2', + ]), + ]; + } + // If the key repository is not valid, then the key module is probably not + // installed. This plugin won't work without it, so display an error + // message. + else { + $form['no_key_module_message'] = [ + '#theme' => 'status_messages', + '#status_headings' => [ + MessengerInterface::TYPE_ERROR => $this->t('Key module missing'), + ], + '#message_list' => [ + MessengerInterface::TYPE_ERROR => [ + $this->t("You must install Drupal's Key module to use this authentication type! Please ensure that the Key module is downloaded and enabled.", [ + '@key_module_url' => 'https://www.drupal.org/project/key', + ]), + ], + ], + ]; + } + + $form['enable_debug_logging'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable debugging mode: log ElasticSearch network traffic'), + '#description' => $this->t("This will write requests, responses, and response-time information to Drupal's log, which may help you diagnose problems with Drupal's connection to ElasticSearch.

Warning: This setting will result in poor performance and may log a user’s personally identifiable information. This setting is only intended for temporary use and should be disabled when you finish debugging. Logs written while this mode is active will remain in the log until you clear them or the logs are rotated.

"), + '#default_value' => $this->configuration['enable_debug_logging'] ?? FALSE, + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['api_key_id'] = \trim($form_state->getValue('api_key_id')); + $this->configuration['elastic_cloud_id'] = \trim($form_state->getValue('elastic_cloud_id')); + $this->configuration['enable_debug_logging'] = (bool) $form_state->getValue('enable_debug_logging'); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // Check that the Elastic Cloud ID can be parsed. + $elasticCloudId = $form_state->getValue('elastic_cloud_id') ?? ''; + try { + TransportBuilder::create()->setCloudId($elasticCloudId); + } + catch (CloudIdParseException) { + $form_state->setErrorByName('elastic_cloud_id', 'The Elastic Cloud ID that you entered could not be parsed by the official HTTP transport for Elastic PHP clients.'); + } + } + + /** + * Determine if the key repository is valid, implying key module is installed. + * + * @return bool + * TRUE if the keyRepository service injected into this plugin is of type + * \Drupal\key\KeyRepositoryInterface; FALSE otherwise. + */ + protected function keyRepositoryIsValid(): bool { + // Note that we are intentionally NOT using KeyRepositoryInterface::class + // here, because doing so would introduce a required dependency on the Key + // module. However, we want an OPTIONAL dependency on the Key module. + return \is_a($this->keyRepository, 'Drupal\key\KeyRepositoryInterface', TRUE); + } + +} diff --git a/src/Plugin/search_api/backend/ElasticSearchBackend.php b/src/Plugin/search_api/backend/ElasticSearchBackend.php index 91424da..82486ff 100644 --- a/src/Plugin/search_api/backend/ElasticSearchBackend.php +++ b/src/Plugin/search_api/backend/ElasticSearchBackend.php @@ -150,7 +150,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta '#ajax' => [ 'callback' => [$this, 'buildAjaxConnectorConfigForm'], 'wrapper' => 'elasticsearch-connector-config-form', - 'method' => 'replace', + 'method' => 'replaceWith', 'effect' => 'fade', ], ]; diff --git a/src/Plugin/search_api/data_type/FullDateDataType.php b/src/Plugin/search_api/data_type/FullDateDataType.php new file mode 100644 index 0000000..c0cf27c --- /dev/null +++ b/src/Plugin/search_api/data_type/FullDateDataType.php @@ -0,0 +1,18 @@ +setFieldManager($container->get('elasticsearch_connector.field_manager'), $index); + return $plugin; + } + + /** + * Sets the elastic search connector field manager. + * + * @param \Drupal\elasticsearch_connector\ElasticsearchFieldManagerInterface $field_manager + * The elastic search connector field manager. + * + * @return $this + */ + public function setFieldManager(ElasticSearchFieldManagerInterface $field_manager, $index) { + $this->index = $index; + $this->fieldManager = $field_manager; + return $this; + } + + /** + * Retrieves the elastic search connector field manager. + * + * @return \Drupal\elasticsearch_connector\ElasticSearchFieldManagerInterface|mixed + * The elastic search connector field manager. + */ + public function getFieldManager() { + if (!$this->fieldManager) { + $this->fieldManager = \Drupal::service('elasticsearch_connector.field_manager'); + } + + return $this->fieldManager; + } + + /** + * Retrieves the Elasticsearch cluster name. + * + * @return string + * The Elasticsearch cluster name this backend connects to. + */ + public function getClusterName() { + return $this->index->getServerId(); + } + + /** + * Retrieves the Elasticsearch index name. + * + * @return string + * The Elasticsearch index name this backend connects to. + */ + public function getIndexName() { + return !empty($this->configuration['real_elasticcloud_index_id']) ? $this->configuration['real_elasticcloud_index_id'] : $this->index->id(); + } + + /** + * {@inheritdoc} + */ + public function getItemId(ComplexDataInterface $item) { + return $item->get('_id')->getValue(); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + $config = parent::defaultConfiguration(); + $config['real_elasticcloud_index_id'] = ''; + $config['label_field'] = ''; + $config['url_field'] = ''; + $config['combined_id'] = FALSE; + return $config; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + // Get the available fields from the server (if a server has already been + // set). + $fields = []; + foreach ($this->getPropertyDefinitions() as $name => $property) { + $fields[$name] = $property->getLabel(); + } + + $form['advanced']['real_elasticcloud_index_id'] = [ + '#type' => 'textfield', + '#title' => $this->t('Real ElasticCloud Index ID'), + '#description' => $this->t('Enter the real index id for your instance.'), + '#default_value' => $this->configuration['real_elasticcloud_index_id'], + ]; + $form['advanced']['label_field'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label field'), + '#description' => $this->t('Enter the name of the field from your Elasticsearch schema that should be considered the label (if any).'), + '#default_value' => $this->configuration['label_field'], + ]; + $form['advanced']['url_field'] = [ + '#type' => 'textfield', + '#title' => $this->t('URL field'), + '#description' => $this->t('Enter the name of the field from your Elasticsearch schema that should be considered the URL (if any).'), + '#default_value' => $this->configuration['url_field'], + ]; + + // If there is already a valid server, we can transform the text fields into + // select boxes. + if (!empty($fields)) { + foreach ($form['advanced'] as $key => &$field) { + if ($key === 'real_elasticcloud_index_id') { + continue; + } + $field['#type'] = 'select'; + $field['#options'] = $fields; + $field['#empty_option'] = $this->t('- None -'); + } + $form['advanced']['label_field']['#description'] = $this->t('Select the Elasticsearch index field that should be considered the label (if any).'); + $form['advanced']['url_field']['#description'] = $this->t("Select the Elasticsearch index field that contains the document's URL (if any)."); + } + $form['advanced']['combined_id'] = [ + '#type' => 'checkbox', + '#title' => $this->t('ID already contains datasource separator ("/")'), + '#description' => $this->t('In case datasource uses index created by search_api module, the id already contains Drupal datasource separator. You need to enable this checkbox in this case.'), + '#default_value' => $this->configuration['combined_id'], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + // We want the form fields displayed inside an "Advanced configuration" + // fieldset, but we don't want them to be actually stored inside a nested + // "advanced" key. (This could also be done via "#parents", but that's + // pretty tricky to get right in a subform.) + $values = &$form_state->getValues(); + $values += $values['advanced']; + unset($values['advanced']); + } + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions() { + return $this->getFieldManager()->getFieldDefinitions($this->index, $this->getClusterName(), $this->getIndexName()); + } + + /** + * {@inheritdoc} + */ + public function loadMultiple(array $ids) { + $documents = []; + try { + // Query the index for the Elasticsearch documents. + $results = $this->index->query() + ->addCondition('_id', $ids, 'IN') + ->range(0, count($ids)) + ->execute() + ->getResultItems(); + + $datatype_plugin = \Drupal::typedDataManager() + ->getDefinition(ElasticSearchDocumentDefinitionInterface::DATA_TYPE_ID)['class']; + foreach ($results as $result) { + /** @var \Drupal\elasticsearch_connector\Plugin\DataType\ElasticSearchDocument $document */ + $document = $datatype_plugin::createFromItem($result); + $documents[$result->getExtraData('elasticsearch_result')['_id']] = $document; + } + } + + catch (SearchApiException $e) { + // Couldn't load items from server, return an empty array. + $this->logger->error($e->getMessage()); + } + + return $documents; + } + +} diff --git a/src/SearchAPI/BackendClient.php b/src/SearchAPI/BackendClient.php index d3b4ccd..8472a07 100644 --- a/src/SearchAPI/BackendClient.php +++ b/src/SearchAPI/BackendClient.php @@ -11,7 +11,6 @@ use Drupal\elasticsearch_connector\Event\IndexCreatedEvent; use Drupal\elasticsearch_connector\SearchAPI\Query\QueryParamBuilder; use Drupal\elasticsearch_connector\SearchAPI\Query\QueryResultParser; -use Drupal\search_api\Entity\Index; use Drupal\search_api\IndexInterface; use Drupal\search_api\Query\QueryInterface; use Drupal\search_api\Query\ResultSetInterface; @@ -154,6 +153,11 @@ public function search(QueryInterface $query): ResultSetInterface { $resultSet = $query->getResults(); $index = $query->getIndex(); $indexId = $this->getIndexId($index); + if ($index->isValidDatasource('elasticsearch_document')) { + $realElasticCloudIndexId = $index->getDatasource('elasticsearch_document')->getIndexName(); + $indexId = !empty($realElasticCloudIndexId) ? $realElasticCloudIndexId : $indexId; + }; + $params = [ 'index' => $indexId, ]; @@ -184,6 +188,7 @@ public function search(QueryInterface $query): ResultSetInterface { return $resultSet; } catch (ElasticSearchException | TransportException $e) { + $this->logger->error($e->getMessage()); throw new SearchApiException(sprintf('Error querying index %s', $indexId), 0, $e); } } @@ -211,7 +216,7 @@ public function removeIndex($index): void { */ public function addIndex(IndexInterface $index): void { $indexId = $this->getIndexId($index); - if ($this->indexExists($index)) { + if ($this->indexExists($index) || $index->isReadOnly()) { return; } @@ -261,12 +266,16 @@ public function updateIndex(IndexInterface $index): void { public function updateFieldMapping(IndexInterface $index): void { $indexId = $this->getIndexId($index); try { + $this->client->indices()->close(['index' => $indexId]); $params = $this->fieldParamsBuilder->mapFieldParams($indexId, $index); $this->client->indices()->putMapping($params); } catch (ElasticSearchException | TransportException $e) { throw new SearchApiException(sprintf('An error occurred updating field mappings for index %s.', $indexId), 0, $e); } + finally { + $this->client->indices()->open(['index' => $indexId]); + } } /** diff --git a/src/SearchAPI/FieldMapper.php b/src/SearchAPI/FieldMapper.php index ecca395..9e66ad2 100644 --- a/src/SearchAPI/FieldMapper.php +++ b/src/SearchAPI/FieldMapper.php @@ -141,6 +141,10 @@ protected function mapFieldProperty(FieldInterface $field): array { 'type' => 'date_range', 'format' => 'strict_date_optional_time||epoch_second', ], + 'elasticsearch_connector_full_date' => [ + 'type' => 'date', + 'format' => 'strict_date_optional_time||epoch_second', + ], default => [], }; diff --git a/src/SearchAPI/Query/FacetResultParser.php b/src/SearchAPI/Query/FacetResultParser.php index e58ebfa..784cc41 100644 --- a/src/SearchAPI/Query/FacetResultParser.php +++ b/src/SearchAPI/Query/FacetResultParser.php @@ -2,6 +2,8 @@ namespace Drupal\elasticsearch_connector\SearchAPI\Query; +use Drupal\elasticsearch_connector\Plugin\search_api\datasource\ElasticSearchDocument; +use Drupal\elasticsearch_connector\TypedData\ElasticSearchDateDefinition; use Drupal\search_api\Query\QueryInterface; use Psr\Log\LoggerInterface; @@ -44,6 +46,13 @@ public function parseFacetResult(QueryInterface $query, array $response): array ? ($response['aggregations'][$filtered_facet_id][$facet_id]['buckets'] ?? []) : ($response['aggregations'][$facet_id]['buckets'] ?? []); + // Coming from ElasticCloud, our times are in milliseconds. + if ($this->isElasticCloudDateTimeMilliseconds($query, $response, $facet, $buckets)) { + foreach ($buckets as &$bucket) { + $bucket['key'] /= 1000; + } + } + $facetData[$facet_id] = \array_map(function (array $value): array { return [ 'count' => $value['doc_count'] ?? 0, @@ -55,4 +64,46 @@ public function parseFacetResult(QueryInterface $query, array $response): array return $facetData; } + /** + * Check if a facet is from ElasticCloud and is a date/time field that is using milliseconds. + * + * @param QueryInterface $query + * @param array $response + * @param array $facet + * @param array $buckets + * @return bool + */ + protected function isElasticCloudDateTimeMilliseconds(QueryInterface $query, array $response, array $facet, array $buckets): bool { + $index = $query->getIndex(); + + $datasource = $index->isValidDatasource('elasticsearch_document') + ? $index->getDatasource('elasticsearch_document') + : NULL; + + if (is_null($datasource) || !$datasource instanceof ElasticSearchDocument) { + return FALSE; + } + + $fieldManager = $datasource->getFieldManager(); + $index = $datasource->getIndex(); + $fieldDefinitions = $fieldManager->getFieldDefinitions($index, $index->getServerId(), $index->id()); + $fieldName = $facet['field']; + + if (empty($fieldDefinitions[$fieldName])) { + return FALSE; + } + + $itemDefinition = $fieldDefinitions[$fieldName]->getItemDefinition(); + if (!$itemDefinition instanceof ElasticSearchDateDefinition) { + return FALSE; + } + + if (empty($buckets)) { + return FALSE; + } + + //****TODO: Is there a way to see if the timestamp is in milliseconds. + return TRUE; + } + } diff --git a/src/SearchAPI/Query/FilterBuilder.php b/src/SearchAPI/Query/FilterBuilder.php index f75b28b..52c8998 100644 --- a/src/SearchAPI/Query/FilterBuilder.php +++ b/src/SearchAPI/Query/FilterBuilder.php @@ -201,48 +201,42 @@ protected function getRangeFilter(Condition $condition, array $index_fields) : a $field_type = $field?->getType() ?? ''; - $rangeOption = [ - 'from' => NULL, - 'to' => NULL, - 'include_lower' => FALSE, - 'include_upper' => FALSE, - ]; + $rangeOption = []; + + $value = $condition->getValue(); $isNeg = FALSE; switch ($condition->getOperator()) { case '>=': - $rangeOption["include_lower"] = TRUE; + $rangeOption["gte"] = $value; + break; + case '>': - $rangeOption["from"] = $condition->getValue() ?? NULL; + $rangeOption["gt"] = $value; break; case '<=': - $rangeOption["include_upper"] = TRUE; + $rangeOption["lte"] = $value; + break; + case '<': - $rangeOption["to"] = $condition->getValue() ?? NULL; + $rangeOption["lt"] = $value; break; case 'NOT BETWEEN': $isNeg = TRUE; - $rangeOption["from"] = $condition->getValue()[0] ?? NULL; - $rangeOption["to"] = $condition->getValue()[1] ?? NULL; - - break; - case 'BETWEEN': - $rangeOption["from"] = $condition->getValue()[0] ?? NULL; - $rangeOption["to"] = $condition->getValue()[1] ?? NULL; - - $rangeOption["include_lower"] = TRUE; - $rangeOption["include_upper"] = TRUE; + $rangeOption["gte"] = $value[0] ?? NULL; + $rangeOption["lte"] = $value[1] ?? NULL; break; } - $allInt = (isset($rangeOption["from"]) ? is_int($rangeOption["from"]) : TRUE) - && (isset($rangeOption["to"]) ? is_int($rangeOption["to"]) : TRUE); + $allInt = is_array($value) + && (isset($value[0]) && is_int($value[0])) + && (isset($value[1]) && is_int($value[1])); - if ($field_type == "date" && $allInt) { + if ($field_type == "date" && ($allInt || (intval($value) == $value))) { $rangeOption["format"] = "epoch_second"; } diff --git a/src/SearchAPI/Query/QueryParamBuilder.php b/src/SearchAPI/Query/QueryParamBuilder.php index 4dcd2d7..6052675 100644 --- a/src/SearchAPI/Query/QueryParamBuilder.php +++ b/src/SearchAPI/Query/QueryParamBuilder.php @@ -78,6 +78,12 @@ public function __construct( */ public function buildQueryParams(string $indexId, QueryInterface $query, array $settings): array { $index = $query->getIndex(); + $indexId = $index->id(); + if ($index->isValidDatasource('elasticsearch_document')) { + $realElasticCloudIndexId = $index->getDatasource('elasticsearch_document')->getIndexName(); + $indexId = !empty($realElasticCloudIndexId) ? $realElasticCloudIndexId : $indexId; + }; + $params = [ 'index' => $indexId, ]; diff --git a/src/SearchAPI/Query/QueryResultParser.php b/src/SearchAPI/Query/QueryResultParser.php index 246c8fd..1c1094f 100644 --- a/src/SearchAPI/Query/QueryResultParser.php +++ b/src/SearchAPI/Query/QueryResultParser.php @@ -2,9 +2,15 @@ namespace Drupal\elasticsearch_connector\SearchAPI\Query; +use Drupal\Core\TypedData\ComplexDataDefinitionInterface; +use Drupal\Core\TypedData\DataDefinitionInterface; +use Drupal\Core\TypedData\ListDataDefinition; +use Drupal\elasticsearch_connector\Plugin\search_api\datasource\ElasticSearchDocument; use Drupal\search_api\Query\QueryInterface; use Drupal\search_api\Query\ResultSetInterface; use Drupal\search_api\Utility\FieldsHelperInterface; +use Drupal\search_api\Utility\Utility; +use Throwable; /** * Provides a result set parser. @@ -46,21 +52,48 @@ public function parseResult(QueryInterface $query, array $response): ResultSetIn $results = $query->getResults(); $results->setExtraData('elasticsearch_response', $response); $results->setResultCount($response['hits']['total']['value']); + /** @var @var \Drupal\search_api\Utility\FieldsHelper $fields_helper */ + $fields_helper = \Drupal::getContainer()->get('search_api.fields_helper'); + + $datasources = $index->getDatasources(); + $datasource = reset($datasources); + + $is_combined_id = FALSE; + $field_definitions = []; + if ($datasource instanceof ElasticSearchDocument) { + /** @var \Drupal\elasticsearch_connector\ElasticSearchFieldManagerInterface $field_manager */ + $field_manager = \Drupal::service('elasticsearch_connector.field_manager'); + /** @var \Drupal\elasticsearch_connector\Plugin\search_api\datasource\ElasticSearchDocument $datasource */ + $field_definitions = $field_manager->getFieldDefinitions($index, $datasource->getClusterName(), $datasource->getIndexName()); + $is_combined_id = $datasource->getConfiguration()['combined_id']; + } + // Add each search result to the results array. if (!empty($response['hits']['hits'])) { foreach ($response['hits']['hits'] as $result) { - $result_item = $this->fieldsHelper->createItem($index, $result['_id']); + if (Utility::splitCombinedId($result['_id'])[0] && !$is_combined_id) { + $item_id = $result['_id']; + } + else { + $item_id = Utility::createCombinedId($datasource->getPluginId(), $result['_id']); + } + + $result_item = $this->fieldsHelper->createItem($index, $item_id); $result_item->setScore($result['_score']); + $result_item->setExtraData('elasticsearch_result', $result); // Set each item in _source as a field in Search API. - foreach ($result['_source'] as $id => $values) { - // Make everything a multi-field. - if (!is_array($values)) { - $values = [$values]; + foreach ($result['_source'] as $source_id => $source_value) { + $field = $fields_helper->createField($index, $source_id); + $field->setPropertyPath($source_id); + + $field_definition = $field_definitions ? $field_definitions[$source_id] : NULL; + if ($field_definition) { + $field->setDataDefinition($field_definition); } - $field = $this->fieldsHelper->createField($index, $id, ['property_path' => $id]); - $field->setValues($values); - $result_item->setField($id, $field); + + $field->setValues(static::parseResultValue($source_value, $field_definition)); + $result_item->setField($source_id, $field); } // If we see that ElasticSearch highlighted matching excerpts in the @@ -90,4 +123,52 @@ public function parseResult(QueryInterface $query, array $response): ResultSetIn return $results; } + /** + * Parse result values to match with the field data definition. + * + * @param $source_value + * The result value from an Elasticsearch query response. + * @param \Drupal\Core\TypedData\DataDefinitionInterface $field_definition + * (optional) The field definition if available. + * + * @return array + * The parsed value. + */ + public static function parseResultValue($source_value, DataDefinitionInterface $field_definition = NULL) { + // All fields are multi-value (item list). Wrap scalar and associative + // arrays into sequential arrays. + if (!is_array($source_value) + || (count($source_value) && !array_key_exists('0', $source_value))) { + $source_value = [$source_value]; + } + + $parsed_value = NULL; + + if ($field_definition) { + // Parse map properties values recursively. + $field_item_definition = $field_definition instanceof ListDataDefinition + ? $field_definition->getItemDefinition() + : $field_definition; + if ($field_item_definition instanceof ComplexDataDefinitionInterface) { + foreach ($source_value as $delta => $item_value) { + if (!is_array($item_value)) { + if ($main_property_name = $field_item_definition->getMainPropertyName()) { + $parsed_value[$delta][$main_property_name] = [$item_value]; + } + if (is_string($item_value) && $field_item_definition->getPropertyDefinition('keyword')) { + $parsed_value[$delta]['keyword'] = [$item_value]; + } + } + else { + foreach ($item_value as $property_id => $property_value) { + $parsed_value[$delta][$property_id] = static::parseResultValue($property_value, $field_item_definition->getPropertyDefinition($property_id)); + } + } + } + } + } + + return $parsed_value ? $parsed_value : $source_value; + } + } diff --git a/src/SearchAPI/Query/QuerySortBuilder.php b/src/SearchAPI/Query/QuerySortBuilder.php index bedf9d3..e778fcd 100644 --- a/src/SearchAPI/Query/QuerySortBuilder.php +++ b/src/SearchAPI/Query/QuerySortBuilder.php @@ -45,9 +45,6 @@ public function getSortSearchQuery(QueryInterface $query): array { elseif ($field_id === 'search_api_id') { $sort['id'] = $direction; } - elseif ($field_id === '_id') { - $sort['_id'] = $direction; - } elseif (isset($index_fields[$field_id])) { if (in_array($field_id, $query_full_text_fields)) { // Set the field that has not been analyzed for sorting. diff --git a/src/TypedData/ElasticSearchDateDefinition.php b/src/TypedData/ElasticSearchDateDefinition.php new file mode 100644 index 0000000..6ee2a58 --- /dev/null +++ b/src/TypedData/ElasticSearchDateDefinition.php @@ -0,0 +1,14 @@ +setIndexId($index_id); + } + return $document_definition; + } + + /** + * {@inheritdoc} + */ + public static function createFromDataType($data_type) { + // The data type should be in the form of "elasticsearch_document:INDEX_ID". + $parts = explode(':', $data_type, 2); + if ($parts[0] != ElasticSearchDocumentDefinitionInterface::DATA_TYPE_ID) { + throw new \InvalidArgumentException('Data type must be in the form of "elasticsearch_document:INDEX_ID".'); + } + + return self::create($parts[1]); + } + + /** + * {@inheritdoc} + */ + public function getIndexId() { + return isset($this->definition['constraints']['Index']) ? $this->definition['constraints']['Index'] : NULL; + } + + /** + * {@inheritdoc} + */ + public function setIndexId(string $index_id) { + return $this->addConstraint('Index', $index_id); + } + + /** + * {@inheritdoc} + */ + public function getPropertyDefinitions() { + if (!isset($this->propertyDefinitions)) { + $this->propertyDefinitions = []; + if (!empty($this->getIndexId())) { + $index = Index::load($this->getIndexId()); + /** @var \Drupal\elasticsearch_connector\ElasticsearchFieldManagerInterface $field_manager */ + $field_manager = \Drupal::getContainer()->get('elasticsearch_connector.field_manager'); + $this->propertyDefinitions = $field_manager->getFieldDefinitions($index); + } + } + + return $this->propertyDefinitions; + } + +} diff --git a/src/TypedData/ElasticSearchDocumentDefinitionInterface.php b/src/TypedData/ElasticSearchDocumentDefinitionInterface.php new file mode 100644 index 0000000..c670345 --- /dev/null +++ b/src/TypedData/ElasticSearchDocumentDefinitionInterface.php @@ -0,0 +1,32 @@ +