diff --git a/config/schema/search_api.views.schema.yml b/config/schema/search_api.views.schema.yml
index 93a2cdf..f2472ac 100644
--- a/config/schema/search_api.views.schema.yml
+++ b/config/schema/search_api.views.schema.yml
@@ -5,9 +5,6 @@ views.query.search_api_query:
     search_api_bypass_access:
       type: boolean
       label: If the underlying search index has access checks enabled, this option allows you to disable them for this view.
-    entity_access:
-      type: boolean
-      label: Execute an access check for all result entities.
     parse_mode:
       type: string
       label: Chooses how the search keys will be parsed.
diff --git a/search_api.api.php b/search_api.api.php
index 55eaee4..696be2b 100644
--- a/search_api.api.php
+++ b/search_api.api.php
@@ -100,21 +100,23 @@ function hook_search_api_field_type_mapping_alter(array &$mapping) {
 /**
  * Alter the mapping of Search API data types to their default Views handlers.
  *
+ * Field handlers are not determined by these simplified (Search API) types, but
+ * by their actual property data types. For altering that mapping, see
+ * hook_search_api_views_field_handler_mapping_alter().
+ *
  * @param array $mapping
- *   An associative array with data types as the keys and Views field data
- *   definitions as the values. In addition to all normally defined data types,
- *   keys can also be "options" for any field with an options list, "entity" for
- *   general entity-typed fields or "entity:ENTITY_TYPE" (with "ENTITY_TYPE"
- *   being the machine name of an entity type) for entities of that type.
+ *   An associative array with data types as the keys and Views table data
+ *   definition items as the values. In addition to all normally defined Search
+ *   API data types, keys can also be "options" for any field with an options
+ *   list, "entity" for general entity-typed fields or "entity:ENTITY_TYPE"
+ *   (with "ENTITY_TYPE" being the machine name of an entity type) for entities
+ *   of that type.
  */
 function hook_search_api_views_handler_mapping_alter(array &$mapping) {
   $mapping['entity:my_entity_type'] = array(
     'argument' => array(
       'id' => 'my_entity_type',
     ),
-    'field' => array(
-      'id' => 'my_entity_type',
-    ),
     'filter' => array(
       'id' => 'my_entity_type',
     ),
@@ -126,6 +128,37 @@ function hook_search_api_views_handler_mapping_alter(array &$mapping) {
 }
 
 /**
+ * Alter the mapping of property types to their default Views field handlers.
+ *
+ * This is used in the Search API Views integration to create Search
+ * API-specific field handlers for all properties of datasources and some entity
+ * types.
+ *
+ * In addition to the definition returned here, for Field API fields, the
+ * "field_name" will be set to the field's machine name.
+ *
+ * @param array $mapping
+ *   An associative array with property data types as the keys and Views field
+ *   handler definitions as the values (i.e., just the inner "field" portion of
+ *   Views data definition items). In some cases the value might also be NULL
+ *   instead, to indicate that properties of this type shouldn't have field
+ *   handlers. The data types in the keys might also contain asterisks (*) as
+ *   wildcard characters. Data types with wildcards will be matched only if no
+ *   specific type exists, and longer type patterns will be tried before shorter
+ *   ones. The "*" mapping therefore is the default if no other match could be
+ *   found.
+ */
+function hook_search_api_views_field_handler_mapping_alter(array &$mapping) {
+  $mapping['field_item:string_long'] = array(
+    'id' => 'example_field',
+  );
+  $mapping['example_property_type'] = array(
+    'id' => 'example_field',
+    'some_option' => 'foo',
+  );
+}
+
+/**
  * Allows you to log or alter the items that are indexed.
  *
  * Please be aware that generally preventing the indexing of certain items is
diff --git a/search_api.views.inc b/search_api.views.inc
index fa4a810..7dd35b1 100644
--- a/search_api.views.inc
+++ b/search_api.views.inc
@@ -5,7 +5,11 @@
  * Views hook implementations for the Search API module.
  */
 
-use Drupal\Core\Field\FieldDefinitionInterface;
+use Drupal\Core\Entity\FieldableEntityInterface;
+use Drupal\Core\Field\TypedData\FieldItemDataDefinition;
+use Drupal\Core\TypedData\DataDefinitionInterface;
+use Drupal\Core\TypedData\DataReferenceDefinitionInterface;
+use Drupal\search_api\Datasource\DatasourceInterface;
 use Drupal\search_api\Entity\Index;
 use Drupal\search_api\Item\FieldInterface;
 use Drupal\search_api\SearchApiException;
@@ -13,31 +17,57 @@ use Drupal\search_api\Utility;
 
 /**
  * Implements hook_views_data().
+ *
+ * For each search index, we provide the following tables:
+ * - One base table, with key "search_api_index_INDEX", which contains field,
+ *   filter, argument and sort handlers for all indexed fields. (Field handlers,
+ *   too, to allow things like click-sorting.)
+ * - Tables for each datasource, by default with key
+ *   "search_api_datasource_INDEX_DATASOURCE", with field and (where applicable)
+ *   relationship handlers for each property of the datasource. Those will be
+ *   joined to the index base table by default.
+ *
+ * Also, for each entity type that appears as the target entity type of an
+ * entity reference property in any datasource (or in any entity type thus
+ * reached), a table with field/relationship handlers for all of the entity
+ * type's properties. Those tables will use the key "search_api_entity_ENTITY".
  */
 function search_api_views_data() {
   $data = array();
-  try {
-    /** @var \Drupal\search_api\IndexInterface $index */
-    foreach (Index::loadMultiple() as $index) {
+
+  /** @var \Drupal\search_api\IndexInterface $index */
+  foreach (Index::loadMultiple() as $index) {
+    try {
       // Fill in base data.
       $key = 'search_api_index_' . $index->id();
       $table = &$data[$key];
-      $table['table']['group'] = t('Index @name', array('@name' => $index->label()));
+      $index_label = $index->label();
+      $table['table']['group'] = t('Index @name', array('@name' => $index_label));
       $table['table']['base'] = array(
         'field' => 'search_api_id',
         'index' => $index->id(),
-        'title' => t('Index @name', array('@name' => $index->label())),
-        'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->label())),
+        'title' => t('Index @name', array('@name' => $index_label)),
+        'help' => t('Use the @name search index for filtering and retrieving data.', array('@name' => $index_label)),
         'query_id' => 'search_api_query',
       );
 
-      /** @var \Drupal\search_api\Item\FieldInterface $field */
+      // Add suitable handlers for all indexed fields.
       foreach ($index->getFields() as $field_id => $field) {
         $field_alias = _search_api_views_find_field_alias($field_id, $table);
         $field_definition = _search_api_views_get_handlers($field);
+        // The field handler has to be extra, since it is a) determined by the
+        // field's underlying property and b) needs a different "real field"
+        // set.
+        $field_handler = _search_api_views_get_field_handler_for_property($field->getDataDefinition(), $field->getPropertyPath());
+        if ($field_handler) {
+          $field_definition['field'] = $field_handler;
+          $field_definition['field']['real field'] = $field->getCombinedPropertyPath();
+          $field_definition['field']['click sortable'] = TRUE;
+        }
         if ($field_definition) {
+          $field_label = $field->getLabel();
           $field_definition += array(
-            'title' => $field->getLabel(),
+            'title' => $field_label,
             'help' => $field->getDescription(),
           );
           if ($datasource = $field->getDatasource()) {
@@ -49,58 +79,36 @@ function search_api_views_data() {
           if ($field_id != $field_alias) {
             $field_definition['real field'] = $field_id;
           }
+          if (isset($field_definition['field'])) {
+            $field_definition['field']['title'] = t('@field (indexed)', array('@field' => $field_label));
+          }
           $table[$field_alias] = $field_definition;
         }
       }
 
-      if (isset($table['search_api_language']['filter']['id'])) {
-        $table['search_api_language']['filter']['id'] = 'search_api_language';
-        $table['search_api_language']['filter']['allow empty'] = FALSE;
-      }
+      // Add special fields.
+      _search_api_views_data_special_fields($table);
 
-      // Add handlers for special fields.
-      $table['search_api_id']['title'] = t('Entity ID');
-      $table['search_api_id']['help'] = t("The entity's ID");
-      $table['search_api_id']['field']['id'] = 'numeric';
-      $table['search_api_id']['sort']['id'] = 'search_api';
-
-      $table['search_api_datasource']['title'] = t('Datasource');
-      $table['search_api_datasource']['help'] = t("The data source ID");
-      $table['search_api_datasource']['field']['id'] = 'standard';
-      $table['search_api_datasource']['filter']['id'] = 'search_api_datasource';
-      $table['search_api_datasource']['sort']['id'] = 'search_api';
-
-      $table['search_api_relevance']['group'] = t('Search');
-      $table['search_api_relevance']['title'] = t('Relevance');
-      $table['search_api_relevance']['help'] = t('The relevance of this search result with respect to the query');
-      $table['search_api_relevance']['field']['type'] = 'decimal';
-      $table['search_api_relevance']['field']['id'] = 'numeric';
-      $table['search_api_relevance']['field']['click sortable'] = TRUE;
-      $table['search_api_relevance']['sort']['id'] = 'search_api';
-
-      $table['search_api_excerpt']['group'] = t('Search');
-      $table['search_api_excerpt']['title'] = t('Excerpt');
-      $table['search_api_excerpt']['help'] = t('The search result excerpted to show found search terms');
-      $table['search_api_excerpt']['field']['id'] = 'search_api_excerpt';
-
-      $table['search_api_fulltext']['group'] = t('Search');
-      $table['search_api_fulltext']['title'] = t('Fulltext search');
-      $table['search_api_fulltext']['help'] = t('Search several or all fulltext fields at once.');
-      $table['search_api_fulltext']['filter']['id'] = 'search_api_fulltext';
-      $table['search_api_fulltext']['argument']['id'] = 'search_api_fulltext';
-
-      $table['search_api_more_like_this']['group'] = t('Search');
-      $table['search_api_more_like_this']['title'] = t('More like this');
-      $table['search_api_more_like_this']['help'] = t('Find similar content.');
-      $table['search_api_more_like_this']['argument']['id'] = 'search_api_more_like_this';
-
-      // @todo Add an "All taxonomy terms" contextual filter (if applicable).
+      // Add relationships for field data of all datasources.
+      $datasource_tables_prefix = 'search_api_datasource_' . $index->id() . '_';
+      foreach ($index->getDatasources() as $datasource_id => $datasource) {
+        $table_key = _search_api_views_find_field_alias($datasource_tables_prefix . $datasource_id, $data);
+        $data[$table_key] = _search_api_views_datasource_table($datasource, $data);
+        // Automatically join this table for views of this index.
+        $data[$table_key]['table']['join'][$key] = array(
+          'join_id' => 'search_api',
+        );
+      }
+    }
+    catch (\Exception $e) {
+      $args = array(
+        '%index' => $index->label(),
+      );
+      watchdog_exception('search_api', $e, '%type while computing Views data for index %index: @message in %function (line %line of %file).', $args);
     }
   }
-  catch (Exception $e) {
-    watchdog_exception('search_api', $e);
-  }
-  return $data;
+
+  return array_filter($data);
 }
 
 /**
@@ -163,16 +171,13 @@ function _search_api_views_get_handlers(FieldInterface $field) {
 
   try {
     $types = array();
-    $definition = $field->getDataDefinition();
-
-    $definition = Utility::getInnerProperty($definition);
 
-    if ($definition instanceof FieldDefinitionInterface) {
-      if ($definition->getType() == 'entity_reference') {
-        $types[] = 'entity:' . $definition->getSetting('target_type');
-        $types[] = 'entity';
-      }
+    $definition = $field->getDataDefinition();
+    if ($definition->getSetting('target_type')) {
+      $types[] = 'entity:' . $definition->getSetting('target_type');
+      $types[] = 'entity';
     }
+
     // @todo Detect fields with fixed lists of options, add type "options".
     $types[] = $field->getType();
     /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type */
@@ -191,40 +196,13 @@ function _search_api_views_get_handlers(FieldInterface $field) {
   catch (SearchApiException $e) {
     $vars['%index'] = $field->getIndex()->label();
     $vars['%field'] = $field->getPrefixedLabel();
-    watchdog_exception('search_api', $e, '%type while Views handlers for field %field on index %index: @message in %function (line %line of %file).', $vars);
+    watchdog_exception('search_api', $e, '%type while adding Views handlers for field %field on index %index: @message in %function (line %line of %file).', $vars);
   }
 
   return array();
 }
 
 /**
- * Makes necessary, field-specific adjustments to Views handler definitions.
- *
- * @param string $type
- *   The type of field, as defined in _search_api_views_handler_mapping().
- * @param \Drupal\search_api\Item\FieldInterface $field
- *   The field whose handler definitions are being created.
- * @param array $definitions
- *   The handler definitions for the field, as a reference.
- */
-function _search_api_views_handler_adjustments($type, FieldInterface $field, array &$definitions) {
-  $data_definition = $field->getDataDefinition();
-  if ($type == 'entity:taxonomy_term') {
-    if (isset($data_definition->getSettings()['handler_settings']['target_bundles'])) {
-      $target_bundles = $data_definition->getSettings()['handler_settings']['target_bundles'];
-      if (count($target_bundles) == 1) {
-        $definitions['filter']['vocabulary'] = reset($target_bundles);
-      }
-    }
-  }
-  // By default, all fields can be empty (or at least have to be treated that
-  // way by the Search API).
-  if (!isset($definitions['filter']['allow empty'])) {
-    $definitions['filter']['allow empty'] = TRUE;
-  }
-}
-
-/**
  * Determines the mapping of Search API data types to their Views handlers.
  *
  * @return array
@@ -321,3 +299,416 @@ function _search_api_views_handler_mapping() {
 
   return $mapping;
 }
+
+/**
+ * Makes necessary, field-specific adjustments to Views handler definitions.
+ *
+ * @param string $type
+ *   The type of field, as defined in _search_api_views_handler_mapping().
+ * @param \Drupal\search_api\Item\FieldInterface $field
+ *   The field whose handler definitions are being created.
+ * @param array $definitions
+ *   The handler definitions for the field, as a reference.
+ */
+function _search_api_views_handler_adjustments($type, FieldInterface $field, array &$definitions) {
+  // By default, all fields can be empty (or at least have to be treated that
+  // way by the Search API).
+  if (!isset($definitions['filter']['allow empty'])) {
+    $definitions['filter']['allow empty'] = TRUE;
+  }
+
+  // For taxonomy term references, set the referenced vocabulary.
+  $data_definition = $field->getDataDefinition();
+  if ($type == 'entity:taxonomy_term') {
+    if (isset($data_definition->getSettings()['handler_settings']['target_bundles'])) {
+      $target_bundles = $data_definition->getSettings()['handler_settings']['target_bundles'];
+      if (count($target_bundles) == 1) {
+        $definitions['filter']['vocabulary'] = reset($target_bundles);
+      }
+    }
+  }
+
+  // Special case for our own language field.
+  if ($field->getCombinedPropertyPath() === 'search_api_language') {
+    $definitions['filter']['id'] = 'search_api_language';
+    $definitions['filter']['allow empty'] = FALSE;
+  }
+}
+
+/**
+ * Adds definitions for our special fields to a Views data table definition.
+ *
+ * @param array $table
+ *   The existing Views data table definition.
+ */
+function _search_api_views_data_special_fields(array &$table) {
+  $id_field = _search_api_views_find_field_alias('search_api_id', $table);
+  $table[$id_field]['title'] = t('Entity ID');
+  $table[$id_field]['help'] = t("The entity's ID");
+  $table[$id_field]['field']['id'] = 'numeric';
+  $table[$id_field]['sort']['id'] = 'search_api';
+  if ($id_field != 'search_api_id') {
+    $table[$id_field]['real field'] = 'search_api_id';
+  }
+
+  $datasource_field = _search_api_views_find_field_alias('search_api_datasource', $table);
+  $table[$datasource_field]['title'] = t('Datasource');
+  $table[$datasource_field]['help'] = t("The data source ID");
+  $table[$datasource_field]['field']['id'] = 'standard';
+  $table[$datasource_field]['filter']['id'] = 'search_api_datasource';
+  $table[$datasource_field]['sort']['id'] = 'search_api';
+  if ($datasource_field != 'search_api_datasource') {
+    $table[$datasource_field]['real field'] = 'search_api_datasource';
+  }
+
+  $relevance_field = _search_api_views_find_field_alias('search_api_relevance', $table);
+  $table[$relevance_field]['group'] = t('Search');
+  $table[$relevance_field]['title'] = t('Relevance');
+  $table[$relevance_field]['help'] = t('The relevance of this search result with respect to the query');
+  $table[$relevance_field]['field']['type'] = 'decimal';
+  $table[$relevance_field]['field']['id'] = 'numeric';
+  $table[$relevance_field]['field']['click sortable'] = TRUE;
+  $table[$relevance_field]['sort']['id'] = 'search_api';
+  if ($relevance_field != 'search_api_relevance') {
+    $table[$relevance_field]['real field'] = 'search_api_relevance';
+  }
+
+  $excerpt_field = _search_api_views_find_field_alias('search_api_excerpt', $table);
+  $table[$excerpt_field]['group'] = t('Search');
+  $table[$excerpt_field]['title'] = t('Excerpt');
+  $table[$excerpt_field]['help'] = t('The search result excerpted to show found search terms');
+  $table[$excerpt_field]['field']['id'] = 'search_api';
+  $table[$excerpt_field]['field']['html'] = TRUE;
+  if ($excerpt_field != 'search_api_excerpt') {
+    $table[$excerpt_field]['real field'] = 'search_api_excerpt';
+  }
+
+  $fulltext_field = _search_api_views_find_field_alias('search_api_fulltext', $table);
+  $table[$fulltext_field]['group'] = t('Search');
+  $table[$fulltext_field]['title'] = t('Fulltext search');
+  $table[$fulltext_field]['help'] = t('Search several or all fulltext fields at once.');
+  $table[$fulltext_field]['filter']['id'] = 'search_api_fulltext';
+  $table[$fulltext_field]['argument']['id'] = 'search_api_fulltext';
+  if ($fulltext_field != 'search_api_fulltext') {
+    $table[$fulltext_field]['real field'] = 'search_api_fulltext';
+  }
+
+  $mlt_field = _search_api_views_find_field_alias('search_api_more_like_this', $table);
+  $table[$mlt_field]['group'] = t('Search');
+  $table[$mlt_field]['title'] = t('More like this');
+  $table[$mlt_field]['help'] = t('Find similar content.');
+  $table[$mlt_field]['argument']['id'] = 'search_api_more_like_this';
+  if ($mlt_field != 'search_api_more_like_this') {
+    $table[$mlt_field]['real field'] = 'search_api_more_like_this';
+  }
+
+  // @todo Add an "All taxonomy terms" contextual filter (if applicable).
+}
+
+/**
+ * Creates a Views table definition for one datasource of an index.
+ *
+ * @param \Drupal\search_api\Datasource\DatasourceInterface $datasource
+ *   The datasource for which to create a table definition.
+ * @param array $data
+ *   The existing Views data definitions. Passed by reference so additionally
+ *   needed tables can be inserted.
+ *
+ * @return array
+ *   A Views table definition for the given datasource.
+ */
+function _search_api_views_datasource_table(DatasourceInterface $datasource, array &$data) {
+  $datasource_id = $datasource->getPluginId();
+  $table = array(
+    'table' => array(
+      'group' => $datasource->label(),
+      'index' => $datasource->getIndex()->id(),
+      'datasource' => $datasource_id,
+    ),
+  );
+  $entity_type_id = $datasource->getEntityTypeId();
+  if ($entity_type_id) {
+    $table['table']['entity type'] = $entity_type_id;
+    $table['table']['entity revision'] = FALSE;
+  }
+
+  _search_api_views_add_handlers_for_properties($datasource->getPropertyDefinitions(), $table, $data);
+
+  // Prefix the "real field" of each entry with the datasource ID.
+  foreach ($table as $key => $definition) {
+    if ($key == 'table') {
+      continue;
+    }
+
+    $real_field = isset($definition['real field']) ? $definition['real field'] : $key;
+    $table[$key]['real field'] = Utility::createCombinedId($datasource_id, $real_field);
+
+    // Relationships sometimes have different real fields set, since they might
+    // also include the nested property that contains the actual reference. So,
+    // if a "real field" is set for that, we need to adapt it as well.
+    if (isset($definition['relationship']['real field'])) {
+      $real_field = $definition['relationship']['real field'];
+      $table[$key]['relationship']['real field'] = Utility::createCombinedId($datasource_id, $real_field);
+    }
+  }
+
+  return $table;
+}
+
+/**
+ * Creates a Views table definition for an entity type.
+ *
+ * @param string $entity_type_id
+ *   The ID of the entity type.
+ * @param array $data
+ *   The existing Views data definitions, passed by reference.
+ *
+ * @return array
+ *   A Views table definition for the given entity type. Or an empty array if
+ *   the entity type could not be found.
+ */
+function _search_api_views_entity_type_table($entity_type_id, array &$data) {
+  $entity_type = \Drupal::entityTypeManager()->getDefinition($entity_type_id);
+  if (!$entity_type || !$entity_type->isSubclassOf(FieldableEntityInterface::class)) {
+    return array();
+  }
+
+  $table = array(
+    'table' => array(
+      'group' => $entity_type->getLabel(),
+      'entity type' => $entity_type_id,
+      'entity revision' => FALSE,
+    ),
+  );
+
+  $entity_field_manager = \Drupal::getContainer()->get('entity_field.manager');
+  $bundle_info = \Drupal::getContainer()->get('entity_type.bundle.info');
+  $properties = $entity_field_manager->getBaseFieldDefinitions($entity_type_id);
+  foreach (array_keys($bundle_info->getBundleInfo($entity_type_id)) as $bundle_id) {
+    $additional = $entity_field_manager->getFieldDefinitions($entity_type_id, $bundle_id);
+    $properties += $additional;
+  }
+  _search_api_views_add_handlers_for_properties($properties, $table, $data);
+
+  return $table;
+}
+
+/**
+ * Adds field and relationship handlers for the given properties.
+ *
+ * @param \Drupal\Core\TypedData\DataDefinitionInterface[] $properties
+ *   The properties for which handlers should be added.
+ * @param array $table
+ *   The existing Views data table definition, passed by reference.
+ * @param array $data
+ *   The existing Views data definitions, passed by reference.
+ */
+function _search_api_views_add_handlers_for_properties(array $properties, array &$table, array &$data) {
+  $entity_reference_types = array_flip(array(
+    'field_item:entity_reference',
+    'field_item:image',
+    'field_item:file',
+  ));
+
+  foreach ($properties as $property_path => $property) {
+    $key = _search_api_views_find_field_alias($property_path, $table);
+    $original_property = $property;
+    $property = Utility::getInnerProperty($property);
+
+    // Add a field handler, if applicable.
+    $definition = _search_api_views_get_field_handler_for_property($property, $property_path);
+    if ($definition) {
+      $table[$key]['field'] = $definition;
+    }
+
+    // For entity-typed properties, also add a relationship to the entity type
+    // table.
+    if ($property instanceof FieldItemDataDefinition && isset($entity_reference_types[$property->getDataType()])) {
+      $entity_type_id = $property->getSetting('target_type');
+      if ($entity_type_id) {
+        $entity_type_table_key = 'search_api_entity_' . $entity_type_id;
+        if (!isset($data[$entity_type_table_key])) {
+          // Initialize the table definition before calling
+          // _search_api_views_entity_type_table() to avoid an infinite
+          // recursion.
+          $data[$entity_type_table_key] = array();
+          $data[$entity_type_table_key] = _search_api_views_entity_type_table($entity_type_id, $data);
+        }
+        // Add the relationship only if we have a non-empty table definition.
+        if ($data[$entity_type_table_key]) {
+          // Get the entity type to determine the label for the relationship.
+          $entity_type = \Drupal::entityTypeManager()
+            ->getDefinition($entity_type_id);
+          $entity_type_label = $entity_type ? $entity_type->getLabel() : $entity_type_id;
+          $args = array(
+            '@label' => $entity_type_label,
+            '@field_name' => $original_property->getLabel(),
+          );
+          // Look through the child properties to find the data reference
+          // property that should be the "real field" for the relationship.
+          // (For Core entity references, this will usually be ":entity".)
+          $suffix = '';
+          foreach ($property->getPropertyDefinitions() as $name => $nested_property) {
+            if ($nested_property instanceof DataReferenceDefinitionInterface) {
+              $suffix = ":$name";
+              break;
+            }
+          }
+          $table[$key]['relationship'] = array(
+            'title' => t('@label referenced from @field_name', $args),
+            'label' => t('@field_name: @label', $args),
+            'help' => $property->getDescription(),
+            'id' => 'search_api',
+            'base' => $entity_type_table_key,
+            'entity type' => $entity_type_id,
+            'entity revision' => FALSE,
+            'real field' => $property_path . $suffix,
+          );
+        }
+      }
+    }
+
+    if (!empty($table[$key]) && empty($table[$key]['title'])) {
+      $table[$key]['title'] = $original_property->getLabel();
+      $table[$key]['help'] = $original_property->getDescription();
+      if ($key != $property_path) {
+        $table[$key]['real field'] = $property_path;
+      }
+    }
+  }
+}
+
+/**
+ * Computes a handler definition for the given property.
+ *
+ * @param \Drupal\Core\TypedData\DataDefinitionInterface $property
+ *   The property definition.
+ * @param string|null $property_path
+ *   (optional) The property path of the property. If set, it will be used for
+ *   Field API fields to set the "field_name" property of the definition.
+ *
+ * @return array|null
+ *   Either a Views field handler definition for this property, or NULL if the
+ *   property shouldn't have one.
+ *
+ * @see hook_search_api_views_field_handler_mapping_alter()
+ */
+function _search_api_views_get_field_handler_for_property(DataDefinitionInterface $property, $property_path = NULL) {
+  $mappings = &drupal_static(__FUNCTION__);
+
+  if (!isset($mappings)) {
+    // First create a plain mapping and pass it to the alter hook.
+    $plain_mapping = array();
+
+    $plain_mapping['*'] = array(
+      'id' => 'search_api',
+    );
+
+//    $mapping['field_item:text_long'] =
+//    $mapping['field_item:text_with_summary'] = array(
+//      'id' => 'search_api',
+//      'html' => TRUE,
+//    );
+//
+//    $mapping['field_item:string_long'] =
+//    $mapping['field_item:string'] =
+//    $mapping['field_item:path'] =
+//    $mapping['email'] =
+//    $mapping['uri'] =
+//    $mapping['filter_format'] =
+//    $mapping['duration_iso8601'] = array(
+//      'id' => 'search_api',
+//    );
+//
+//    $mapping['integer'] =
+//    $mapping['timespan'] = array(
+//      'id' => 'search_api_numeric',
+//    );
+//
+//    $mapping['decimal'] =
+//    $mapping['float'] = array(
+//      'id' => 'search_api_numeric',
+//      'float' => TRUE,
+//    );
+
+    $plain_mapping['field_item:created'] =
+    $plain_mapping['field_item:changed'] =
+    $plain_mapping['datetime_iso8601'] =
+    $plain_mapping['timestamp'] = array(
+      'id' => 'search_api_date',
+    );
+
+//    $mapping['boolean'] =
+//    $mapping['field_item:boolean'] = array(
+//      'id' => 'search_api_boolean',
+//    );
+//
+//    $mapping['field_item:entity_reference'] =
+//    $mapping['field_item:comment'] = array(
+//      'id' => 'search_api_entity',
+//    );
+
+    $alter_id = 'search_api_views_field_handler_mapping';
+    \Drupal::moduleHandler()->alter($alter_id, $plain_mapping);
+
+    // Then create a new, more practical structure, with the mappings grouped by
+    // mapping type.
+    $mappings = array(
+      'simple' => array(),
+      'regex' => array(),
+      'default' => NULL,
+    );
+    foreach ($plain_mapping as $type => $definition) {
+      if ($type == '*') {
+        $mappings['default'] = $definition;
+      }
+      elseif (strpos($type, '*') === FALSE) {
+        $mappings['simple'][$type] = $definition;
+      }
+      else {
+        // Transform the type into a PCRE regular expression, taking care to
+        // quote everything except for the wildcards.
+        $parts = explode('*', $type);
+        // Passing the second parameter to preg_quote() is a bit tricky with
+        // array_map(), we need to construct an array of slashes.
+        $slashes = array_fill(0, count($parts), '/');
+        $parts = array_map('preg_quote', $parts, $slashes);
+        // Use the "S" modifier for closer analysis of the pattern, since it
+        // might be executed a lot. (The docs say this won't get us anything in
+        // our case, but this might change, or be different, e.g., in HHVM.)
+        $regex = '/^' . implode('.*', $parts) . '$/S';
+        $mappings['regex'][$regex] = $definition;
+      }
+    }
+    // Finally, order the regular expressions descending by their lengths.
+    $compare = function ($a, $b) {
+      return strlen($b) - strlen($a);
+    };
+    uksort($mappings['regex'], $compare);
+  }
+
+  // First, look for an exact match.
+  $data_type = $property->getDataType();
+  if (array_key_exists($data_type, $mappings['simple'])) {
+    $definition = $mappings['simple'][$data_type];
+  }
+  else {
+    // Then check all the patterns defined by regular expressions, defaulting to
+    // the "default" definition.
+    $definition = $mappings['default'];
+    foreach (array_keys($mappings['regex']) as $regex) {
+      if (preg_match($regex, $data_type)) {
+        $definition = $mappings['regex'][$regex];
+      }
+    }
+  }
+
+  // If there is a definition and the property represents a Field API field, add
+  // the "field_name" key.
+  if (isset($definition) && $property instanceof FieldItemDataDefinition) {
+    list(, $field_name) = Utility::splitPropertyPath($property_path, TRUE);
+    $definition['field_name'] = $field_name;
+  }
+
+  return $definition;
+}
diff --git a/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml b/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml
index 9404bd0..14154b7 100644
--- a/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml
+++ b/search_api_db/search_api_db_defaults/config/optional/views.view.search_content.yml
@@ -33,7 +33,6 @@ display:
         type: search_api_query
         options:
           search_api_bypass_access: false
-          entity_access: false
           parse_mode: terms
       exposed_form:
         type: basic
diff --git a/src/Entity/Index.php b/src/Entity/Index.php
index c797c82..c466fc1 100644
--- a/src/Entity/Index.php
+++ b/src/Entity/Index.php
@@ -1106,8 +1106,6 @@ class Index extends ConfigEntityBase implements IndexInterface {
     try {
       // Fake an original for inserts to make code cleaner.
       $original = $update ? $this->original : static::create(array('status' => FALSE));
-      $index_task_manager = \Drupal::getContainer()
-        ->get('search_api.index_task_manager');
 
       if ($this->status() && $original->status()) {
         // React on possible changes that would require re-indexing, etc.
@@ -1118,7 +1116,7 @@ class Index extends ConfigEntityBase implements IndexInterface {
       }
       elseif (!$this->status() && $original->status()) {
         if ($this->hasValidTracker()) {
-          $index_task_manager->stopTracking($this);
+          \Drupal::getContainer()->get('search_api.index_task_manager')->stopTracking($this);
         }
         if ($original->isServerEnabled()) {
           $original->getServer()->removeIndex($this);
@@ -1127,10 +1125,11 @@ class Index extends ConfigEntityBase implements IndexInterface {
       elseif ($this->status() && !$original->status()) {
         $this->getServer()->addIndex($this);
         if ($this->hasValidTracker()) {
-          $index_task_manager->startTracking($this);
+          \Drupal::getContainer()->get('search_api.index_task_manager')->startTracking($this);
         }
       }
 
+      $index_task_manager = \Drupal::getContainer()->get('search_api.index_task_manager');
       if (!$index_task_manager->isTrackingComplete($this)) {
         // Give tests and site admins the possibility to disable the use of a
         // batch for tracking items. Also, do not use a batch if running in the
@@ -1168,10 +1167,6 @@ class Index extends ConfigEntityBase implements IndexInterface {
    *   The previous version of the index.
    */
   protected function reactToServerSwitch(IndexInterface $original) {
-    // Asserts that the index was enabled before saving and will still be
-    // enabled afterwards. Otherwise, this method should not be called.
-    assert('$this->status() && $original->status()', '::reactToServerSwitch should only be called when the index is enabled');
-
     if ($this->getServerId() != $original->getServerId()) {
       if ($original->isServerEnabled()) {
         $original->getServer()->removeIndex($this);
@@ -1198,10 +1193,6 @@ class Index extends ConfigEntityBase implements IndexInterface {
    *   The previous version of the index.
    */
   protected function reactToDatasourceSwitch(IndexInterface $original) {
-    // Asserts that the index was enabled before saving and will still be
-    // enabled afterwards. Otherwise, this method should not be called.
-    assert('$this->status() && $original->status()', '::reactToDatasourceSwitch should only be called when the index is enabled');
-
     $new_datasource_ids = $this->getDatasourceIds();
     $original_datasource_ids = $original->getDatasourceIds();
     if ($new_datasource_ids != $original_datasource_ids) {
@@ -1224,10 +1215,6 @@ class Index extends ConfigEntityBase implements IndexInterface {
    *   The previous version of the index.
    */
   protected function reactToTrackerSwitch(IndexInterface $original) {
-    // Asserts that the index was enabled before saving and will still be
-    // enabled afterwards. Otherwise, this method should not be called.
-    assert('$this->status() && $original->status()', '::reactToTrackerSwitch should only be called when the index is enabled');
-
     if ($this->tracker != $original->getTrackerId()) {
       $index_task_manager = \Drupal::getContainer()->get('search_api.index_task_manager');
       if ($original->hasValidTracker()) {
diff --git a/src/Plugin/search_api/datasource/ContentEntity.php b/src/Plugin/search_api/datasource/ContentEntity.php
index de13802..8bb2378 100644
--- a/src/Plugin/search_api/datasource/ContentEntity.php
+++ b/src/Plugin/search_api/datasource/ContentEntity.php
@@ -721,7 +721,7 @@ class ContentEntity extends DatasourcePluginBase {
     $select = \Drupal::entityQuery($this->getEntityTypeId());
     // If there are bundles to filter on, and they don't include all available
     // bundles, add the appropriate condition.
-    if ($bundles && $this->hasBundles()) {
+    if ($bundles && $this->getEntityType()->hasKey('bundle')) {
       if (count($bundles) != count($this->getEntityBundles())) {
         $select->condition($this->getEntityType()->getKey('bundle'), $bundles, 'IN');
       }
diff --git a/src/Plugin/views/SearchApiHandlerTrait.php b/src/Plugin/views/SearchApiHandlerTrait.php
new file mode 100644
index 0000000..4293cc5
--- /dev/null
+++ b/src/Plugin/views/SearchApiHandlerTrait.php
@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\search_api\Plugin\views\SearchApiHandlerTrait.
+ */
+
+namespace Drupal\search_api\Plugin\views;
+
+use Drupal\search_api\Plugin\views\query\SearchApiQuery;
+
+/**
+ * Provides a trait to use for Search API Views handlers.
+ */
+trait SearchApiHandlerTrait {
+
+  /**
+   * Overrides the Views handlers' ensureMyTable() method.
+   *
+   * This is done since adding a table to a Search API query is neither
+   * necessary nor possible, but we still want to stay as compatible as possible
+   * to the default SQL query plugin.
+   */
+  public function ensureMyTable() {}
+
+  /**
+   * Returns the active search index.
+   *
+   * @return \Drupal\search_api\IndexInterface|null
+   *   The search index to use with this filter, or NULL if none could be
+   *   loaded.
+   */
+  protected function getIndex() {
+    if ($this->getQuery()) {
+      return $this->getQuery()->getIndex();
+    }
+    $base_table = $this->view->storage->get('base_table');
+    return SearchApiQuery::getIndexFromTable($base_table);
+  }
+
+  /**
+   * Retrieves the query plugin.
+   *
+   * @return \Drupal\search_api\Plugin\views\query\SearchApiQuery|null
+   *   The query plugin, or NULL if there is no query or it is no Search API query.
+   */
+  public function getQuery() {
+    if (empty($this->query) || !($this->query instanceof SearchApiQuery)) {
+      return NULL;
+    }
+    return $this->query;
+  }
+
+}
diff --git a/src/Plugin/views/field/SearchApiDate.php b/src/Plugin/views/field/SearchApiDate.php
new file mode 100644
index 0000000..62923d4
--- /dev/null
+++ b/src/Plugin/views/field/SearchApiDate.php
@@ -0,0 +1,24 @@
+<?php
+
+/**
+ * @file
+ * Contains Drupal\search_api\Plugin\views\field\SearchApiDate.
+ */
+
+namespace Drupal\search_api\Plugin\views\field;
+
+use Drupal\views\Plugin\views\field\Date;
+use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface;
+
+/**
+ * Handles the display of date fields in Search API Views.
+ *
+ * @ingroup views_field_handlers
+ *
+ * @ViewsField("search_api_date")
+ */
+class SearchApiDate extends Date implements MultiItemsFieldHandlerInterface {
+
+  use SearchApiFieldTrait;
+
+}
diff --git a/src/Plugin/views/field/SearchApiExcerpt.php b/src/Plugin/views/field/SearchApiExcerpt.php
deleted file mode 100644
index 83528dc..0000000
--- a/src/Plugin/views/field/SearchApiExcerpt.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-/**
- * @file
- * Contains \Drupal\search_api\Plugin\views\field\SearchApiExcerpt.
- */
-
-namespace Drupal\search_api\Plugin\views\field;
-
-use Drupal\views\Plugin\views\field\FieldPluginBase;
-use Drupal\views\ResultRow;
-
-/**
- * Defines a field displaying a search result's excerpt, if available.
- *
- * @ingroup views_field_handlers
- *
- * @ViewsField("search_api_excerpt")
- */
-class SearchApiExcerpt extends FieldPluginBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function render(ResultRow $values) {
-    $value = $this->getValue($values);
-    return $this->sanitizeValue($value, 'xss');
-  }
-
-}
diff --git a/src/Plugin/views/field/SearchApiField.php b/src/Plugin/views/field/SearchApiField.php
new file mode 100644
index 0000000..b3f2af5
--- /dev/null
+++ b/src/Plugin/views/field/SearchApiField.php
@@ -0,0 +1,31 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\search_api\Plugin\views\field\SearchApiField.
+ */
+
+namespace Drupal\search_api\Plugin\views\field;
+
+use Drupal\views\Plugin\views\field\PrerenderList;
+
+/**
+ * Provides a default handler for fields in Search API Views.
+ *
+ * @ingroup views_field_handlers
+ *
+ * @ViewsField("search_api")
+ */
+class SearchApiField extends PrerenderList {
+
+  use SearchApiFieldTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render_item($count, $item) {
+    $type = !empty($this->definition['filter_type']) ? $this->definition['filter_type'] : 'plain';
+    return $this->sanitizeValue($item['value'], $type);
+  }
+
+}
diff --git a/src/Plugin/views/field/SearchApiFieldTrait.php b/src/Plugin/views/field/SearchApiFieldTrait.php
new file mode 100644
index 0000000..596ec68
--- /dev/null
+++ b/src/Plugin/views/field/SearchApiFieldTrait.php
@@ -0,0 +1,530 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\search_api\Plugin\views\field\SearchApiFieldTrait.
+ */
+
+namespace Drupal\search_api\Plugin\views\field;
+
+use Drupal\Core\Entity\EntityInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\TypedData\ComplexDataInterface;
+use Drupal\Core\TypedData\DataReferenceInterface;
+use Drupal\Core\TypedData\ListInterface;
+use Drupal\search_api\Plugin\views\SearchApiHandlerTrait;
+use Drupal\search_api\Utility;
+use Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface;
+use Drupal\views\ResultRow;
+
+/**
+ * Provides a trait to use for Search API Views field handlers.
+ *
+ * Multi-valued field handling is taken from
+ * \Drupal\views\Plugin\views\field\PrerenderList.
+ */
+trait SearchApiFieldTrait {
+
+  use SearchApiHandlerTrait, StringTranslationTrait;
+
+  /**
+   * Contains the properties needed by this field handler.
+   *
+   * The array is keyed by datasource ID (which might be NULL) and property
+   * path, the values are the combined property paths.
+   *
+   * @var string[][]
+   */
+  protected $retrievedProperties = array();
+
+  /**
+   * The combined property path of this field.
+   *
+   * @var string|null
+   */
+  protected $combinedPropertyPath;
+
+  /**
+   * The datasource ID of this field, if any.
+   *
+   * @var string|null
+   */
+  protected $datasourceId;
+
+  /**
+   * Contains overridden values to be returned on the next getValue() call.
+   *
+   * @var array
+   *
+   * @see SearchApiFieldTrait::getValue()
+   */
+  protected $overriddenValues = array();
+
+  /**
+   * Information about options for all kinds of purposes will be held here.
+   *
+   * @code
+   * 'option_name' => array(
+   *  - 'default' => default value,
+   *  - 'contains' => (optional) array of items this contains, with its own
+   *      defaults, etc. If contains is set, the default will be ignored and
+   *      assumed to be array().
+   *  ),
+   * @endcode
+   *
+   * @return array
+   *   Returns the options of this handler/plugin.
+   *
+   * @see \Drupal\views\Plugin\views\PluginBase::defineOptions()
+   */
+  protected function defineOptions() {
+    $options = parent::defineOptions();
+
+    $options['link_to_item'] = array('default' => FALSE);
+
+    if ($this instanceof MultiItemsFieldHandlerInterface) {
+      $options['type'] = array('default' => 'separator');
+      $options['separator'] = array('default' => ', ');
+    }
+
+    return $options;
+  }
+
+  /**
+   * Provide a form to edit options for this plugin.
+   *
+   * @param array $form
+   *   The existing form structure, passed by reference.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current form state.
+   *
+   * @see \Drupal\views\Plugin\views\ViewsPluginInterface::buildOptionsForm()
+   */
+  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
+    parent::buildOptionsForm($form, $form_state);
+
+    $form['link_to_item'] = array(
+      '#type' => 'checkbox',
+      '#title' => $this->t('Link this field to its item'),
+      '#description' => $this->t('Display this field as a link to its original entity or item.'),
+      '#default_value' => $this->options['link_to_item'],
+    );
+
+    if ($this instanceof MultiItemsFieldHandlerInterface) {
+      $form['multi_value_settings'] = array(
+        '#type' => 'details',
+        '#title' => $this->t('Multiple values handling'),
+        '#description' => $this->t('If this field contains multiple values for an item, these settings will determine how they are handled.'),
+        '#weight' => 80,
+      );
+
+      $form['type'] = array(
+        '#type' => 'radios',
+        '#title' => $this->t('Display type'),
+        '#options' => array(
+          'ul' => $this->t('Unordered list'),
+          'ol' => $this->t('Ordered list'),
+          'separator' => $this->t('Simple separator'),
+        ),
+        '#default_value' => $this->options['type'],
+        '#fieldset' => 'multi_value_settings',
+      );
+      $form['separator'] = array(
+        '#type' => 'textfield',
+        '#title' => $this->t('Separator'),
+        '#default_value' => $this->options['separator'],
+        '#states' => array(
+          'visible' => array(
+            ':input[name="options[type]"]' => array('value' => 'separator'),
+          ),
+        ),
+        '#fieldset' => 'multi_value_settings',
+      );
+    }
+
+    // @todo Field API field handling.
+  }
+
+  /**
+   * Determines if this field is click sortable.
+   *
+   * @return bool
+   *   The value of "click sortable" from the plugin definition, this defaults
+   *   to FALSE if not set.
+   *
+   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::clickSortable()
+   */
+  public function clickSortable() {
+    // Almost the same logic as in the parent class, but we want to default to
+    // FALSE.
+    return !empty($this->definition['click sortable']);
+  }
+
+  /**
+   * Add anything to the query that we might need to.
+   *
+   * @see \Drupal\views\Plugin\views\ViewsPluginInterface::query()
+   */
+  public function query() {
+    $combined_property_path = $this->getCombinedPropertyPath();
+    $this->addRetrievedProperty($combined_property_path);
+    if ($this->options['link_to_item']) {
+      $this->addRetrievedProperty("$combined_property_path:_object");
+    }
+  }
+
+  /**
+   * Adds a property to be retrieved.
+   *
+   * @param string $combined_property_path
+   *   The combined property path of the property that should be retrieved.
+   *   "_object" can be used as a property name to indicate the loaded object is
+   *   required.
+   *
+   * @return $this
+   */
+  protected function addRetrievedProperty($combined_property_path) {
+    $this->getQuery()->addRetrievedProperty($combined_property_path);
+
+    list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path);
+    $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path;
+    return $this;
+  }
+
+  /**
+   * Gets the value that's supposed to be rendered.
+   *
+   * This API exists so that other modules can easily set the values of the
+   * field without having the need to change the render method as well.
+   *
+   * Overridden here to provide an easy way to let this method return arbitrary
+   * values, without actually touching the $values array.
+   *
+   * @param \Drupal\views\ResultRow $values
+   *   An object containing all retrieved values.
+   * @param string $field
+   *   Optional name of the field where the value is stored.
+   *
+   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::getValue()
+   */
+  public function getValue(ResultRow $values, $field = NULL) {
+    if (isset($this->overriddenValues[$field])) {
+      return $this->overriddenValues[$field];
+    }
+
+    return parent::getValue($values, $field);
+  }
+
+  /**
+   * Runs before any fields are rendered.
+   *
+   * This gives the handlers some time to set up before any handler has
+   * been rendered.
+   *
+   * @param \Drupal\views\ResultRow[] $values
+   *   An array of all ResultRow objects returned from the query.
+   *
+   * @see \Drupal\views\Plugin\views\field\FieldHandlerInterface::preRender()
+   */
+  public function preRender(&$values) {
+    // @todo This works quite well, but will load each item/entity individually.
+    //   Instead, we should exploit the workflow of proceeding by each property
+    //   on its own to multi-load as much as possible (maybe even entities of
+    //   the same type from different properties).
+    // @todo Also, this will unnecessarily load items/entities even if all
+    //   required fields are provided in the results. However, to solve this,
+    //   expandRequiredProperties() would have to
+    $required_properties = $this->expandRequiredProperties();
+    /** @var \Drupal\Core\TypedData\ComplexDataDefinitionInterface[] $data_definitions */
+    $data_definitions = array();
+
+    foreach ($required_properties as $datasource_id => $properties) {
+      foreach ($properties as $property_path => $combined_property_path) {
+        list($parent_path, $name) = Utility::splitPropertyPath($property_path);
+        if ($name == '_object') {
+          continue;
+        }
+        $combined_parent_path = Utility::createCombinedId($datasource_id, $parent_path);
+        $to_load = array();
+
+//        if ($parent_path) {
+//          if (empty($data_definitions[$parent_path])) {
+//            continue;
+//          }
+//          $parent_definitions = $data_definitions[$parent_path]->getPropertyDefinitions();
+//        }
+//        else {
+//          $parent_definitions = $this->getIndex()->getPropertyDefinitions($datasource_id, FALSE);
+//        }
+//        if (!isset($parent_definitions[$name])) {
+//          continue;
+//        }
+//        $definition = Utility::getInnerProperty($parent_definitions[$name]);
+//        $data_definitions[$property_path] = $definition;
+
+        foreach ($values as $i => $row) {
+          if ($datasource_id != $row->search_api_datasource || !$this->isActiveForRow($row)) {
+            continue;
+          }
+          if (isset($row->$combined_property_path)) {
+            continue;
+          }
+          if (empty($row->_relationship_objects[$parent_path])) {
+            if ($parent_path) {
+              continue;
+            }
+            else {
+              $row->_relationship_objects[$parent_path] = array($row->_item->getOriginalObject());
+            }
+          }
+
+          $row->$combined_property_path = array();
+          foreach ($row->_relationship_objects[$parent_path] as $parent) {
+            while ($parent instanceof DataReferenceInterface) {
+              $parent = $parent->getTarget();
+            }
+            if (!($parent instanceof ComplexDataInterface)) {
+              continue;
+            }
+            $typed_data = $parent->get($name);
+            if (isset($this->retrievedProperties[$datasource_id][$property_path])) {
+              $row->$combined_property_path[] = Utility::extractFieldValues($typed_data);
+            }
+            if ($typed_data instanceof ListInterface) {
+              foreach ($typed_data as $item) {
+                $row->_relationship_objects[$property_path][] = $item;
+              }
+            }
+            else {
+              $row->_relationship_objects[$property_path][] = $typed_data;
+            }
+          }
+          if ($row->$combined_property_path) {
+            $row->$combined_property_path = call_user_func_array('array_merge', $row->$combined_property_path);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Expands the properties to retrieve for this field.
+   *
+   * The properties are taken from this object's $retrievedProperties property,
+   * with all their ancestors also added to the array, with the ancestor
+   * properties always ordered before their descendants.
+   *
+   * This will ensure, when dealing with these properties sequentially, that
+   * the parent object necessary to load the "child" property is always already
+   * loaded.
+   *
+   * @return string[][]
+   *   The combined property paths to retrieve, keyed by their datasource ID and
+   *   property path.
+   */
+  protected function expandRequiredProperties() {
+    $required_properties = array();
+    foreach ($this->retrievedProperties as $datasource_id => $properties) {
+      foreach (array_keys($properties) as $property_path) {
+        $path_to_add = '';
+        foreach (explode(':', $property_path) as $component) {
+          $path_to_add .= ($path_to_add ? ':' : '') . $component;
+          if (!isset($required_properties[$path_to_add])) {
+            $required_properties[$datasource_id][$path_to_add] = Utility::createCombinedId($datasource_id, $path_to_add);
+          }
+        }
+      }
+    }
+    return $required_properties;
+  }
+
+  /**
+   * Determines whether this field is active for the given row.
+   *
+   * This is usually determined by the row's datasource.
+   *
+   * @param \Drupal\views\ResultRow $row
+   *   The result row.
+   *
+   * @return bool
+   *   TRUE if this field handler might produce output for the given row, FALSE
+   *   otherwise.
+   */
+  protected function isActiveForRow(ResultRow $row) {
+    $datasource_id = $this->getDatasourceId();
+    return !$datasource_id || $row->search_api_datasource === $datasource_id;
+  }
+
+  /**
+   * Retrieves the combined property path of this field.
+   *
+   * @return string
+   *   The combined property path.
+   */
+  protected function getCombinedPropertyPath() {
+    if (!isset($this->combinedPropertyPath)) {
+      // Add the property path of any relationships used to arrive at this
+      // field.
+      $path = $this->realField;
+      $relationships = $this->view->relationship;
+      $relationship = $this;
+      while (!empty($relationship->options['relationship'])) {
+        if (empty($relationships[$relationship->options['relationship']])) {
+          break;
+        }
+        $relationship = $relationships[$relationship->options['relationship']];
+        $path = $relationship->realField . ':' . $path;
+      }
+      $this->combinedPropertyPath = $path;
+    }
+    return $this->combinedPropertyPath;
+  }
+
+  /**
+   * Retrieves the ID of the datasource to which this field belongs.
+   *
+   * @return string|null
+   *   The datasource ID of this field, or NULL if it doesn't belong to a
+   *   specific datasource.
+   */
+  protected function getDatasourceId() {
+    if (!isset($this->datasourceId)) {
+      list($this->datasourceId) = Utility::splitCombinedId($this->getCombinedPropertyPath());
+    }
+    return $this->datasourceId;
+  }
+
+  /**
+   * Renders a single item of a row.
+   *
+   * @param int $count
+   *   The index of the item inside the row.
+   * @param mixed $item
+   *   The item for the field to render.
+   *
+   * @return string
+   *   The rendered output.
+   *
+   * @see \Drupal\views\Plugin\views\field\MultiItemsFieldHandlerInterface::render_item()
+   */
+  public function render_item($count, $item) {
+    $this->overriddenValues[NULL] = $item['value'];
+    $render = $this->render(new ResultRow());
+    $this->overriddenValues = array();
+    return $render;
+  }
+
+  /**
+   * Gets an array of items for the field.
+   *
+   * Items should be associative arrays with, if possible, "value" as the actual
+   * displayable value of the item, plus any items that might be found in the
+   * "alter" options array for creating links, etc., such as "path", "fragment",
+   * "query", etc. Additionally, items that might be turned into tokens should
+   * also be in this array.
+   *
+   * @param \Drupal\views\ResultRow $values
+   *   The result row object containing the values.
+   *
+   * @return array[]
+   *   An array of items for the field, with each item being an array itself.
+   *
+   * @see \Drupal\views\Plugin\views\field\PrerenderList::getItems()
+   */
+  public function getItems(ResultRow $values) {
+    $property_path = $this->getCombinedPropertyPath();
+    if (!empty($values->{$property_path})) {
+      // Although it's undocumented, the field handler base class assumes items
+      // will always be arrays.
+      $items = array();
+      foreach ((array) $values->{$property_path} as $i => $value) {
+        $item = array(
+          'value' => $value,
+        );
+
+        if ($this->options['link_to_item']) {
+          $item['make_link'] = TRUE;
+          $item['url'] = $this->getItemUrl($values, $i);
+        }
+
+        $items[] = $item;
+      }
+      return $items;
+    }
+    return array();
+  }
+
+  /**
+   * Renders all items in this field together.
+   *
+   * @param array $items
+   *   The items provided by getItems() for a single row.
+   *
+   * @return string
+   *   The rendered items.
+   *
+   * @see \Drupal\views\Plugin\views\field\PrerenderList::renderItems()
+   */
+  public function renderItems($items) {
+    if (!empty($items)) {
+      if ($this->options['type'] == 'separator') {
+        $render = array(
+          '#type' => 'inline_template',
+          '#template' => '{{ items|safe_join(separator) }}',
+          '#context' => array(
+            'items' => $items,
+            'separator' => $this->sanitizeValue($this->options['separator'], 'xss_admin'),
+          ),
+        );
+      }
+      else {
+        $render = array(
+          '#theme' => 'item_list',
+          '#items' => $items,
+          '#title' => NULL,
+          '#list_type' => $this->options['type'],
+        );
+      }
+      return $this->getRenderer()->render($render);
+    }
+    return '';
+  }
+
+  /**
+   * Retrieves an alter options array for linking the given value to its item.
+   *
+   * @param \Drupal\views\ResultRow $row
+   *   The Views result row object.
+   * @param int $i
+   *   The index in this field's values for which the item link should be
+   *   retrieved.
+   *
+   * @return \Drupal\Core\Url|null
+   *   The URL for the specified item, or NULL if it couldn't be found.
+   */
+  protected function getItemUrl(ResultRow $row, $i) {
+    list($datasource_id, $property_path) = Utility::splitCombinedId($this->getCombinedPropertyPath());
+
+    // @todo This will work in most cases, but might fail when linking a multi-
+    //   valued non-entity field's values to its containing entity (since then
+    //   $i will be off one level up). Rare use case, though, probably.
+    while (!empty($row->_relationship_objects[$property_path][$i])) {
+      /** @var \Drupal\Core\TypedData\TypedDataInterface $object */
+      $object = $row->_relationship_objects[$property_path][$i];
+      if (!$property_path) {
+        return $this->getIndex()
+          ->getDatasource($datasource_id)
+          ->getItemUrl($object);
+      }
+      $value = $object->getValue();
+      if ($value instanceof EntityInterface) {
+        return $value->toUrl('canonical');
+      }
+      list($property_path) = Utility::splitPropertyPath($property_path);
+    }
+
+    return NULL;
+  }
+
+}
diff --git a/src/Plugin/views/filter/SearchApiFilterTrait.php b/src/Plugin/views/filter/SearchApiFilterTrait.php
index 9b12f26..2065593 100644
--- a/src/Plugin/views/filter/SearchApiFilterTrait.php
+++ b/src/Plugin/views/filter/SearchApiFilterTrait.php
@@ -8,21 +8,14 @@
 namespace Drupal\search_api\Plugin\views\filter;
 
 use Drupal\Core\Form\FormStateInterface;
-use Drupal\search_api\Plugin\views\query\SearchApiQuery;
+use Drupal\search_api\Plugin\views\SearchApiHandlerTrait;
 
 /**
  * Provides a trait to use for Search API Views filters.
  */
 trait SearchApiFilterTrait {
 
-  /**
-   * Overrides the Views handlers' ensureMyTable() method.
-   *
-   * This is done since adding a table to a Search API query is neither
-   * necessary nor possible, but we still want to stay as compatible as possible
-   * to the default SQL query plugin.
-   */
-  public function ensureMyTable() {}
+  use SearchApiHandlerTrait;
 
   /**
    * Adds a form for entering the value or values for the filter.
@@ -43,21 +36,6 @@ trait SearchApiFilterTrait {
   }
 
   /**
-   * Returns the active search index.
-   *
-   * @return \Drupal\search_api\IndexInterface|null
-   *   The search index to use with this filter, or NULL if none could be
-   *   loaded.
-   */
-  protected function getIndex() {
-    if ($this->getQuery()) {
-      return $this->getQuery()->getIndex();
-    }
-    $base_table = $this->view->storage->get('base_table');
-    return SearchApiQuery::getIndexFromTable($base_table);
-  }
-
-  /**
    * Adds a filter to the search query.
    *
    * Overridden to avoid errors because of SQL-specific functionality being used
@@ -79,14 +57,4 @@ trait SearchApiFilterTrait {
     $this->getQuery()->addConditionGroup($filter, $this->options['group']);
   }
 
-  /**
-   * Retrieves the query plugin.
-   *
-   * @return \Drupal\search_api\Plugin\views\query\SearchApiQuery
-   *   The query plugin.
-   */
-  public function getQuery() {
-    return $this->query;
-  }
-
 }
diff --git a/src/Plugin/views/join/SearchApiJoin.php b/src/Plugin/views/join/SearchApiJoin.php
new file mode 100644
index 0000000..aea83f0
--- /dev/null
+++ b/src/Plugin/views/join/SearchApiJoin.php
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @file
+ * Contains Drupal\search_api\Plugin\views\join\SearchApiJoin.
+ */
+
+namespace Drupal\search_api\Plugin\views\join;
+
+use Drupal\views\Plugin\views\join\JoinPluginBase;
+
+/**
+ * Represents a join in the Search API Views tables.
+ *
+ * Since the concept of joins doesn't exist in the Search API, this handler does
+ * nothing except override the default behavior and thus enable the joining of
+ * Views data tables in Search API views.
+ *
+ * @ingroup views_join_handlers
+ *
+ * @ViewsJoin("search_api")
+ */
+class SearchApiJoin extends JoinPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildJoin($select_query, $table, $view_query) {}
+
+}
diff --git a/src/Plugin/views/query/SearchApiQuery.php b/src/Plugin/views/query/SearchApiQuery.php
index da6e85f..d42cc94 100644
--- a/src/Plugin/views/query/SearchApiQuery.php
+++ b/src/Plugin/views/query/SearchApiQuery.php
@@ -90,10 +90,10 @@ class SearchApiQuery extends QueryPluginBase {
   /**
    * The properties that should be retrieved from result items.
    *
-   * The properties are represented by their combined property paths as the
-   * array keys, to more easily keep them unique.
+   * The array is keyed by datasource ID (which might be NULL) and property
+   * path, the values are the associated combined property paths.
    *
-   * @var array
+   * @var string[][]
    */
   protected $retrievedProperties = array();
 
@@ -142,11 +142,12 @@ class SearchApiQuery extends QueryPluginBase {
   public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) {
     try {
       parent::init($view, $display, $options);
-      $this->retrievedProperties = array();
-      $this->index = self::getIndexFromTable($view->storage->get('base_table'));
+      $this->index = static::getIndexFromTable($view->storage->get('base_table'));
       if (!$this->index) {
         $this->abort(new FormattableMarkup('View %view is not based on Search API but tries to use its query plugin.', array('%view' => $view->storage->label())));
       }
+      $this->retrievedProperties = array_fill_keys($this->index->getDatasourceIds(), array());
+      $this->retrievedProperties[NULL] = array();
       $this->query = $this->index->query();
       $this->query->setParseMode($this->options['parse_mode']);
       $this->query->addTag('views');
@@ -162,17 +163,50 @@ class SearchApiQuery extends QueryPluginBase {
   /**
    * Adds a property to be retrieved.
    *
+   * Currently doesn't serve any purpose, but might be added to the search query
+   * in the future to help backends that support returning fields determine
+   * which of the fields should actually be returned.
+   *
    * @param string $combined_property_path
    *   The combined property path of the property that should be retrieved.
    *
    * @return $this
    */
-  public function addField($combined_property_path) {
-    $this->retrievedProperties[$combined_property_path] = TRUE;
+  public function addRetrievedProperty($combined_property_path) {
+    list($datasource_id, $property_path) = Utility::splitCombinedId($combined_property_path);
+    $this->retrievedProperties[$datasource_id][$property_path] = $combined_property_path;
     return $this;
   }
 
   /**
+   * Adds a field to the table.
+   *
+   * This replicates the interface of Views' default SQL backend to simplify
+   * the Views integration of the Search API. If you are writing Search
+   * API-specific Views code, you should better use the addRetrievedProperty()
+   * method.
+   *
+   * @param string|null $table
+   *   Ignored.
+   * @param string $field
+   *   The combined property path of the property that should be retrieved.
+   * @param string $alias
+   *   (optional) Ignored.
+   * @param array $params
+   *   (optional) Ignored.
+   *
+   * @return string
+   *   The name that this field can be referred to as (always $field).
+   *
+   * @see \Drupal\views\Plugin\views\query\Sql::addField()
+   * @see \Drupal\search_api\Plugin\views\query\SearchApiQuery::addField()
+   */
+  public function addField($table, $field, $alias = '', $params = array()) {
+    $this->addRetrievedProperty($field);
+    return $field;
+  }
+
+  /**
    * {@inheritdoc}
    */
   public function defineOptions() {
@@ -441,89 +475,35 @@ class SearchApiQuery extends QueryPluginBase {
    *   The executed view.
    */
   protected function addResults(array $results, ViewExecutable $view) {
-    /** @var \Drupal\views\ResultRow[] $rows */
-    $rows = array();
-    $missing = array();
-
-    if (!empty($this->configuration['entity_access'])) {
-      $items = $this->index->loadItemsMultiple(array_keys($results));
-      $results = array_intersect_key($results, $items);
-      /** @var \Drupal\Core\Entity\Plugin\DataType\EntityAdapter $item */
-      foreach ($items as $item_id => $item) {
-        if (!$item->getValue()->access('view')) {
-          unset($results[$item_id]);
-        }
-      }
-    }
+    // Views \Drupal\views\Plugin\views\style\StylePluginBase::renderFields()
+    // uses a numeric results index to key the rendered results.
+    // The ResultRow::index property is the key then used to retrieve these.
+    $count = 0;
 
-    // First off, we try to gather as much property values as possible without
-    // loading any items.
     foreach ($results as $item_id => $result) {
-      $datasource_id = $result->getDatasourceId();
-
-      // @todo Find a more elegant way of passing metadata here.
+      $values = array();
+      $values['_item'] = $result;
+      $object = $result->getOriginalObject(FALSE);
+      if ($object) {
+        $values['_object'] = $object;
+        $values['_relationship_objects'][NULL] = array($object);
+      }
       $values['search_api_id'] = $item_id;
-      $values['search_api_datasource'] = $datasource_id;
-
-      // Include the loaded item for this result row, if present, or the item
-      // ID.
-      $values['_item'] = $result->getOriginalObject(FALSE) ?: $item_id;
-
+      $values['search_api_datasource'] = $result->getDatasourceId();
       $values['search_api_relevance'] = $result->getScore();
       $values['search_api_excerpt'] = $result->getExcerpt() ?: '';
 
-      // Gather any fields from the search results.
-      foreach ($result->getFields(FALSE) as $field) {
+      // Gather any properties from the search results.
+      foreach ($result->getFields(FALSE) as $field_id => $field) {
         if ($field->getValues()) {
-          $combined_id = Utility::createCombinedId($field->getDatasourceId(), $field->getPropertyPath());
-          $values[$combined_id] = $field->getValues();
-        }
-      }
-
-      // Check whether we need to extract any properties from the result item.
-      $missing_fields = array_diff_key($this->retrievedProperties, $values);
-      if ($missing_fields) {
-        $missing[$item_id] = array_keys($missing_fields);
-        if (!is_object($values['_item'])) {
-          $item_ids[] = $item_id;
+          $values[$field->getCombinedPropertyPath()] = $field->getValues();
         }
       }
 
-      // Save the row values for adding them to the Views result afterwards.
-      // @todo Use a custom sub-class here to also pass the result item object,
-      //   or other information?
-      $rows[$item_id] = new ResultRow($values);
-    }
-
-    // Load items of those rows which haven't got all field values, yet.
-    if (!empty($item_ids)) {
-      foreach ($this->index->loadItemsMultiple($item_ids) as $item_id => $object) {
-        $results[$item_id]->setOriginalObject($object);
-        $rows[$item_id]->_item = $object;
-      }
-    }
+      $values['index'] = $count++;
 
-    foreach ($missing as $item_id => $missing_fields) {
-      /** @var \Drupal\search_api\Item\FieldInterface[] $fields_to_extract */
-      $fields_to_extract = array();
-      foreach ($missing_fields as $combined_id) {
-        list($datasource_id, $property_path) = Utility::splitCombinedId($combined_id);
-        if ($datasource_id == $results[$item_id]->getDatasourceId()) {
-          $fields_to_extract[$property_path] = Utility::createField($this->index, $combined_id);
-        }
-      }
-      Utility::extractFields($results[$item_id]->getOriginalObject(), $fields_to_extract);
-      foreach ($fields_to_extract as $field) {
-        $combined_id = $field->getFieldIdentifier();
-        $rows[$item_id]->$combined_id = $field->getValues();
-      }
+      $view->result[] = new ResultRow($values);
     }
-
-    // Finally, add all rows to the Views result set.
-    $view->result = array_values($rows);
-    array_walk($view->result, function (ResultRow $row, $index) {
-      $row->index = $index;
-    });
   }
 
   /**
@@ -1119,6 +1099,7 @@ class SearchApiQuery extends QueryPluginBase {
    * concept of "tables", this method implementation does nothing. If you are
    * writing Search API-specific Views code, there is therefore no reason at all
    * to call this method.
+   * See https://www.drupal.org/node/2484565 for more information.
    *
    * @return string
    *   An empty string.
diff --git a/src/Plugin/views/relationship/SearchApiRelationship.php b/src/Plugin/views/relationship/SearchApiRelationship.php
new file mode 100644
index 0000000..df71e6b
--- /dev/null
+++ b/src/Plugin/views/relationship/SearchApiRelationship.php
@@ -0,0 +1,52 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\views\Plugin\views\relationship\SearchApiRelationship.
+ */
+
+namespace Drupal\search_api\Plugin\views\relationship;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\views\Plugin\views\relationship\RelationshipPluginBase;
+
+/**
+ * Views relationship plugin for datasources.
+ *
+ * @ingroup views_relationship_handlers
+ *
+ * @ViewsRelationship("search_api")
+ */
+class SearchApiRelationship extends RelationshipPluginBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
+    parent::buildOptionsForm($form, $form_state);
+
+    $form['required']['#access'] = FALSE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function query() {}
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    $dependencies = array();
+
+    if (!empty($this->definition['entity type'])) {
+      $entity_type = \Drupal::entityTypeManager()->getDefinition($this->definition['entity type']);
+      if ($entity_type) {
+        $dependencies['module'][] = $entity_type->getProvider();
+      }
+    }
+
+    return $dependencies;
+  }
+
+}
diff --git a/src/Plugin/views/row/SearchApiRow.php b/src/Plugin/views/row/SearchApiRow.php
index b0ba304..a7e881d 100644
--- a/src/Plugin/views/row/SearchApiRow.php
+++ b/src/Plugin/views/row/SearchApiRow.php
@@ -24,7 +24,7 @@ use Symfony\Component\DependencyInjection\ContainerInterface;
  *
  * @ViewsRow(
  *   id = "search_api",
- *   title = @Translation("Rendered Search API item"),
+ *   title = @Translation("Rendered entity"),
  *   help = @Translation("Displays entity of the matching search API item"),
  * )
  */
@@ -118,7 +118,6 @@ class SearchApiRow extends RowPluginBase {
    */
   public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL) {
     parent::init($view, $display, $options);
-
     $base_table = $view->storage->get('base_table');
     $this->index = SearchApiQuery::getIndexFromTable($base_table, $this->getEntityTypeManager());
     if (!$this->index) {
@@ -205,10 +204,31 @@ class SearchApiRow extends RowPluginBase {
   /**
    * {@inheritdoc}
    */
+  public function preRender($result) {
+    // Load all result objects at once, before rendering.
+    $items_to_load = array();
+    foreach ($result as $i => $row) {
+      if (empty($row->_object)) {
+        $items_to_load[$i] = $row->search_api_id;
+      }
+    }
+
+    $items = $this->index->loadItemsMultiple($items_to_load);
+    foreach ($items_to_load as $i => $item_id) {
+      if (isset($items[$item_id])) {
+        $result[$i]->_object = $items[$item_id];
+        $result[$i]->_item->setOriginalObject($items[$item_id]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
   public function render($row) {
     $datasource_id = $row->search_api_datasource;
 
-    if (!($row->_item instanceof ComplexDataInterface)) {
+    if (!($row->_object instanceof ComplexDataInterface)) {
       $context = array(
         '%item_id' => $row->search_api_id,
         '%view' => $this->view->storage->label(),
@@ -228,13 +248,13 @@ class SearchApiRow extends RowPluginBase {
     // Always use the default view mode if it was not set explicitly in the
     // options.
     $view_mode = 'default';
-    $bundle = $this->index->getDatasource($datasource_id)->getItemBundle($row->_item);
+    $bundle = $this->index->getDatasource($datasource_id)->getItemBundle($row->_object);
     if (isset($this->options['view_modes'][$datasource_id][$bundle])) {
       $view_mode = $this->options['view_modes'][$datasource_id][$bundle];
     }
 
     try {
-      return $this->index->getDatasource($datasource_id)->viewItem($row->_item, $view_mode);
+      return $this->index->getDatasource($datasource_id)->viewItem($row->_object, $view_mode);
     }
     catch (SearchApiException $e) {
       watchdog_exception('search_api', $e);
@@ -245,10 +265,6 @@ class SearchApiRow extends RowPluginBase {
   /**
    * {@inheritdoc}
    */
-  public function query() {
-    parent::query();
-    // @todo Find a better way to ensure that the item is loaded.
-    $this->view->query->addField('_magic');
-  }
+  public function query() {}
 
 }
diff --git a/src/Tests/LanguageIntegrationTest.php b/src/Tests/LanguageIntegrationTest.php
index 92c88df..5dfec22 100644
--- a/src/Tests/LanguageIntegrationTest.php
+++ b/src/Tests/LanguageIntegrationTest.php
@@ -18,6 +18,13 @@ use Drupal\search_api\Entity\Index;
 class LanguageIntegrationTest extends WebTestBase {
 
   /**
+   * The ID of the search index used for this test.
+   *
+   * @var string
+   */
+  protected $indexId;
+
+  /**
    * {@inheritdoc}
    */
   public static $modules = array('node', 'search_api', 'search_api_test_backend', 'language');
@@ -39,7 +46,8 @@ class LanguageIntegrationTest extends WebTestBase {
 
     // Create an index and server to work with.
     $this->getTestServer();
-    $this->getTestIndex();
+    $index = $this->getTestIndex();
+    $this->indexId = $index->id();
 
     // Log in, so we can test all the things.
     $this->drupalLogin($this->adminUser);
@@ -122,4 +130,22 @@ class LanguageIntegrationTest extends WebTestBase {
     $index = Index::load($this->indexId);
     return $index->getTracker()->getTotalItemsCount();
   }
+
+  /**
+   * Returns the system path for the test index.
+   *
+   * @param string|null $tab
+   *   (optional) If set, the path suffix for a specific index tab.
+   *
+   * @return string
+   *   A system path.
+   */
+  protected function getIndexPath($tab = NULL) {
+    $path = 'admin/config/search/search-api/index/' . $this->indexId;
+    if ($tab) {
+      $path .= "/$tab";
+    }
+    return $path;
+  }
+
 }
diff --git a/src/Tests/ViewsTest.php b/src/Tests/ViewsTest.php
index e89c56c..40befe4 100644
--- a/src/Tests/ViewsTest.php
+++ b/src/Tests/ViewsTest.php
@@ -24,7 +24,7 @@ class ViewsTest extends WebTestBase {
    *
    * @var string[]
    */
-  public static $modules = array('search_api_test_views');
+  public static $modules = array('search_api_test_views', 'views_ui');
 
   /**
    * A search index ID.
@@ -182,4 +182,19 @@ class ViewsTest extends WebTestBase {
     }
   }
 
+  /**
+   * Test views admin.
+   */
+  public function testViewsAdmin() {
+    $admin_user = $this->drupalCreateUser(array(
+      'administer search_api',
+      'access administration pages',
+      'administer views',
+    ));
+    $this->drupalLogin($admin_user);
+    $this->insertExampleContent();
+
+    $this->drupalGet('admin/structure/views/view/search_api_test_views_fulltext');
+  }
+
 }
diff --git a/src/Utility.php b/src/Utility.php
index 7035ba8..26a9bfe 100644
--- a/src/Utility.php
+++ b/src/Utility.php
@@ -26,6 +26,7 @@ use Drupal\search_api\Item\Item;
 use Drupal\search_api\Query\Query;
 use Drupal\search_api\Query\QueryInterface;
 use Drupal\search_api\Query\ResultSet;
+use Symfony\Component\DependencyInjection\Container;
 
 /**
  * Contains utility methods for the Search API.
@@ -99,7 +100,6 @@ class Utility {
           'uri',
           'filter_format',
           'duration_iso8601',
-          'field_item:path',
         ),
         'integer' => array(
           'integer',
@@ -243,33 +243,53 @@ class Utility {
    *   The field into which to put the extracted data.
    */
   public static function extractField(TypedDataInterface $data, FieldInterface $field) {
+    $values = static::extractFieldValues($data);
+
+    // If the data type of the field is a custom one, then the value can be
+    // altered by the data type plugin.
+    $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type');
+    if ($data_type_manager->hasDefinition($field->getType())) {
+      /** @var \Drupal\search_api\DataType\DataTypeInterface $data_type_plugin */
+      $data_type_plugin = $data_type_manager->createInstance($field->getType());
+      foreach ($values as $i => $value) {
+        $values[$i] = $data_type_plugin->getValue($value);
+      }
+    }
+
+    $field->setValues($values);
+    $field->setOriginalType($data->getDataDefinition()->getDataType());
+  }
+
+  /**
+   * Extracts field values from a typed data object.
+   *
+   * @param \Drupal\Core\TypedData\TypedDataInterface $data
+   *   The typed data object.
+   *
+   * @return array
+   *   An array of values.
+   */
+  public static function extractFieldValues(TypedDataInterface $data) {
     if ($data->getDataDefinition()->isList()) {
+      $values = array();
       foreach ($data as $piece) {
-        self::extractField($piece, $field);
+        $values[] = self::extractFieldValues($piece);
       }
-      return;
+      return $values ? call_user_func_array('array_merge', $values) : array();
     }
+
     $value = $data->getValue();
     $definition = $data->getDataDefinition();
     if ($definition instanceof ComplexDataDefinitionInterface) {
       $property = $definition->getMainPropertyName();
       if (isset($value[$property])) {
-        $value = $value[$property];
+        return array($value[$property]);
       }
     }
     elseif (is_array($value)) {
-      $value = reset($value);
-    }
-
-    // If the data type of the field is a custom one, then the value can be
-    // altered by the data type plugin.
-    $data_type_manager = \Drupal::service('plugin.manager.search_api.data_type');
-    if ($data_type_manager->hasDefinition($field->getType())) {
-      $value = $data_type_manager->createInstance($field->getType())->getValue($value);
+      return array_values($value);
     }
-
-    $field->addValue($value);
-    $field->setOriginalType($definition->getDataType());
+    return array($value);
   }
 
   /**
@@ -326,6 +346,41 @@ class Utility {
   }
 
   /**
+   * Splits a property path into two parts along a path separator (:).
+   *
+   * The path is split into one part with a single property name, and one part
+   * with the complete rest of the property path (which might be empty).
+   * Depending on $separate_last the returned single property key will be the
+   * first (FALSE) or last (TRUE) property of the path.
+   *
+   * @param string $property_path
+   *   The property path to split.
+   * @param bool $separate_last
+   *   (optional) If TRUE, separate the last (leaf) property of the path. By
+   *   default, the first property is separated from the rest.
+   * @param string $separator
+   *   (optional) The separator to use.
+   *
+   * @return string[]
+   *   A string with indexes 0 and 1, 0 containing the first part of the
+   *   property path and 1 the second. If $separate_last is FALSE, index 0 will
+   *   always contain a single property name (without any colons) and index 1
+   *   might be NULL. If $separate_last is TRUE it's the exact other way round.
+   */
+  public static function splitPropertyPath($property_path, $separate_last = TRUE, $separator = ':') {
+    $function = $separate_last ? 'strrpos' : 'strpos';
+    $pos = $function($property_path, $separator);
+    if ($pos !== FALSE) {
+      return array(
+        substr($property_path, 0, $pos),
+        substr($property_path, $pos + 1),
+      );
+    }
+
+    return $separate_last ? array(NULL, $property_path) : array($property_path, NULL);
+  }
+
+  /**
    * Determines whether a field ID is reserved for special use.
    *
    * This is the case for the "magic" pseudo-fields documented in
@@ -504,11 +559,7 @@ class Utility {
     $field = new Field($index, $field_identifier);
 
     foreach ($field_info as $key => $value) {
-      // Unfortunately, the $delimiters parameter for ucwords() wasn't
-      // introduced until PHP 5.5.16 and thus cannot be depended upon.
-      $method = str_replace('_', ' ', $key);
-      $method = ucwords($method);
-      $method = 'set' . str_replace(' ', '', $method);
+      $method = 'set' . Container::camelize($key);
       if (method_exists($field, $method)) {
         $field->$method($value);
       }
diff --git a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml
index 0031593..a397f64 100644
--- a/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml
+++ b/tests/search_api_test_views/config/install/views.view.search_api_test_views_fulltext.yml
@@ -20,7 +20,6 @@ display:
         type: search_api_query
         options:
           search_api_bypass_access: false
-          entity_access: false
           parse_mode: terms
       exposed_form:
         type: basic
