diff --git a/contrib/search_api_views/includes/query.inc b/contrib/search_api_views/includes/query.inc
index 82588a6..ee551bb 100644
--- a/contrib/search_api_views/includes/query.inc
+++ b/contrib/search_api_views/includes/query.inc
@@ -494,31 +494,31 @@ class SearchApiViewsQuery extends views_plugin_query {
    * query backend.
    */
   public function get_result_wrappers($results, $relationship = NULL, $field = NULL) {
-    $entity_type = $this->index->getEntityType();
+    $type = $this->index->item_type;
     $wrappers = array();
-    $load_entities = array();
+    $load_items = array();
     foreach ($results as $row_index => $row) {
-      if ($entity_type && isset($row->entity)) {
+      if (isset($row->entity)) {
         // If this entity isn't load, register it for pre-loading.
         if (!is_object($row->entity)) {
-          $load_entities[$row->entity] = $row_index;
+          $load_items[$row->entity] = $row_index;
+        }
+        else {
+          $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
         }
-
-        $wrappers[$row_index] = $this->index->entityWrapper($row->entity);
       }
     }
 
     // If the results are entities, we pre-load them to make use of a multiple
     // load. (Otherwise, each result would be loaded individually.)
-    if (!empty($load_entities)) {
-      $entities = entity_load($entity_type, array_keys($load_entities));
-      foreach ($entities as $entity_id => $entity) {
-        $wrappers[$load_entities[$entity_id]] = $this->index->entityWrapper($entity);
+    if (!empty($load_items)) {
+      $items = $this->index->loadItems(array_keys($load_items));
+      foreach ($items as $id => $item) {
+        $wrappers[$load_items[$id]] = $this->index->entityWrapper($item);
       }
     }
 
     // Apply the relationship, if necessary.
-    $type = $entity_type ? $entity_type : $this->index->item_type;
     $selector_suffix = '';
     if ($field && ($pos = strrpos($field, ':'))) {
       $selector_suffix = substr($field, 0, $pos);
diff --git a/includes/callback_bundle_filter.inc b/includes/callback_bundle_filter.inc
index 08fcba4..6a36e6e 100644
--- a/includes/callback_bundle_filter.inc
+++ b/includes/callback_bundle_filter.inc
@@ -14,24 +14,47 @@ class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
    * {@inheritdoc}
    */
   public function supportsIndex(SearchApiIndex $index) {
-    $support = $index->getEntityType() && ($info = entity_get_info($index->getEntityType())) && self::hasBundles($info);
-    $support &= empty($index->options['datasource']['bundles']) || count($index->options['datasource']['bundles']) > 1;
-    return $support;
+    if ($this->isMultiEntityIndex($index)) {
+      $info = entity_get_info();
+      foreach ($index->options['datasource']['types'] as $type) {
+        if (isset($info[$type]) && self::hasBundles($info[$type])) {
+          return TRUE;
+        }
+      }
+      return FALSE;
+    }
+    return $index->getEntityType() && ($info = entity_get_info($index->getEntityType())) && self::hasBundles($info);
   }
 
   /**
    * {@inheritdoc}
    */
   public function alterItems(array &$items) {
-    $info = entity_get_info($this->index->getEntityType());
-    if (self::hasBundles($info) && isset($this->options['bundles'])) {
-      $bundles = array_flip($this->options['bundles']);
-      $default = (bool) $this->options['default'];
-      $bundle_prop = $info['entity keys']['bundle'];
-      foreach ($items as $id => $item) {
-        if (isset($bundles[$item->$bundle_prop]) == $default) {
-          unset($items[$id]);
+    if (!$this->supportsIndex($this->index) || !isset($this->options['bundles'])) {
+      return;
+    }
+
+    $entity_type = $this->isMultiEntityIndex() ? NULL : $this->index->getEntityType();
+    // If the index contains multiple entity type, $info will contain the infos
+    // of all types, otherwise it's just the info of the relevant type.
+    $info = entity_get_info($entity_type);
+    $bundle_prop = $this->isMultiEntityIndex() ? NULL : $info['entity keys']['bundle'];
+
+    $bundles = array_flip($this->options['bundles']);
+    $default = (bool) $this->options['default'];
+
+    foreach ($items as $id => $item) {
+      if ($entity_type) {
+        $bundle = $item->$bundle_prop;
+      }
+      else {
+        if (!isset($info[$item->_type]['entity keys']['bundle'])) {
+          continue;
         }
+        $bundle = $item->_type . ':' . $item->{$item->_type}->{$info[$item->_type]['entity keys']['bundle']};
+      }
+      if (isset($bundles[$bundle]) == $default) {
+        unset($items[$id]);
       }
     }
   }
@@ -40,20 +63,35 @@ class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
    * {@inheritdoc}
    */
   public function configurationForm() {
-    $info = entity_get_info($this->index->getEntityType());
-    if (self::hasBundles($info)) {
+    if ($this->supportsIndex($this->index)) {
       $options = array();
-      foreach ($info['bundles'] as $bundle => $bundle_info) {
-        $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+      if ($this->isMultiEntityIndex()) {
+        $info = entity_get_info();
+        $unsupported_types = array();
+        foreach ($this->index->options['datasource']['types'] as $type) {
+          if (isset($info[$type]) && self::hasBundles($info[$type])) {
+            foreach ($info[$type]['bundles'] as $bundle => $bundle_info) {
+              $options["$type:$bundle"] = $info[$type]['label'] . ' » ' . $bundle_info['label'];
+            }
+          }
+          else {
+            $unsupported_types[] = isset($info[$type]['label']) ? $info[$type]['label'] : $type;
+          }
+        }
+        if ($unsupported_types) {
+          $form['unsupported_types']['#markup'] = '<p>' . t('The following entity types do not contain any bundles: @types. All items of those types will therefore be included in the index.', array('@types' => implode(', ', $unsupported_types))) . '</p>';
+        }
+      }
+      else {
+        $info = entity_get_info($this->index->getEntityType());
+        foreach ($info['bundles'] as $bundle => $bundle_info) {
+          $options[$bundle] = isset($bundle_info['label']) ? $bundle_info['label'] : $bundle;
+        }
       }
       if (!empty($this->index->options['datasource']['bundles'])) {
         $form['message']['#markup'] = '<p>' . t("<strong>Note:</strong> This index is already restricted to certain bundles. If you use this data alteration, those will be reduced further. However, the index setting is better supported in the user interface and should therefore be prefered. For example, using this data alteration will not reduce the displayed total number of items to index (even though some of them will not be indexed). Consider creating a new index with appropriate bundle settings instead.") . '</p>';
-        $included_bundles = drupal_map_assoc($this->index->options['datasource']['bundles']);
-        foreach ($options as $bundle => $label) {
-          if (!isset($included_bundles[$bundle])) {
-            unset($options[$bundle]);
-          }
-        }
+        $included_bundles = array_flip($this->index->options['datasource']['bundles']);
+        $options = array_intersect_key($options, $included_bundles);
       }
       $form['default'] = array(
         '#type' => 'radios',
@@ -90,10 +128,25 @@ class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
    *   The entity type's entity_get_info() array.
    *
    * @return bool
-   *   TRUE if the entity type has bundles, FASLE otherwise.
+   *   TRUE if the entity type has bundles, FALSE otherwise.
    */
   protected static function hasBundles(array $entity_info) {
     return !empty($entity_info['entity keys']['bundle']) && !empty($entity_info['bundles']);
   }
 
+  /**
+   * Determines whether the given index contains multiple types of entities.
+   *
+   * @param SearchApiIndex|null $index
+   *   (optional) The index to examine. Defaults to the index set for this
+   *   plugin.
+   *
+   * @return bool
+   *   TRUE if the index is a multi-entity index, FALSE otherwise.
+   */
+  protected function isMultiEntityIndex(SearchApiIndex $index = NULL) {
+    $index = $index ? $index : $this->index;
+    return $index->datasource() instanceof SearchApiCombinedEntityDataSourceController;
+  }
+
 }
diff --git a/includes/datasource_multiple.inc b/includes/datasource_multiple.inc
new file mode 100644
index 0000000..2c3f900
--- /dev/null
+++ b/includes/datasource_multiple.inc
@@ -0,0 +1,328 @@
+<?php
+
+/**
+ * @file
+ * Contains SearchApiCombinedEntityDataSourceController.
+ */
+
+/**
+ * Provides a datasource for indexing multiple types of entities.
+ */
+class SearchApiCombinedEntityDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $table = 'search_api_item_string_id';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getIdFieldInfo() {
+    return array(
+      'key' => '_id',
+      'type' => 'string',
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function loadItems(array $ids) {
+    $ids_by_type = array();
+    foreach ($ids as $id) {
+      list($type, $type_id) = explode('/', $id);
+      $ids_by_type[$type][$type_id] = $id;
+    }
+
+    $items = array();
+    foreach ($ids_by_type as $type => $type_ids) {
+      foreach (entity_load($type, array_keys($type_ids)) as $type_id => $item) {
+        $id = $type_ids[$type_id];
+        $item = (object) array($type => $item);
+        $item->_id = $id;
+        $item->_type = $type;
+        $items[$id] = $item;
+        unset($type_ids[$type_id]);
+      }
+      if ($type_ids) {
+        search_api_track_item_delete($type, array_keys($type_ids));
+      }
+    }
+
+    return $items;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPropertyInfo() {
+    $info = array(
+      '_id' => array(
+        'label' => t('ID'),
+        'description' => t('The datasource-specific ID of the item.'),
+        'type' => 'text',
+      ),
+      '_type' => array(
+        'label' => t('Entity type'),
+        'description' => t('The entity type of the item.'),
+        'type' => 'token',
+        'options list' => 'search_api_entity_type_options_list',
+      ),
+    );
+
+    foreach ($this->getSelectedEntityTypeOptions() as $type => $label) {
+      $info[$type] = array(
+        'label' => $label,
+        'description' => t('The indexed entity, if it is of type %type.', array('%type' => $label)),
+        'type' => $type,
+      );
+    }
+
+    return array('property info' => $info);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemId($item) {
+    return isset($item->_id) ? $item->_id : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemLabel($item) {
+    $label = entity_label($item->_type, $item->{$item->_type});
+    return $label ? $label : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getItemUrl($item) {
+    if ($item->_type == 'file') {
+      return array(
+        'path' => file_create_url($item->file->uri),
+        'options' => array(
+          'entity_type' => 'file',
+          'entity' => $item,
+        ),
+      );
+    }
+    $url = entity_uri($item->_type, $item->{$item->_type});
+    return $url ? $url : NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function startTracking(array $indexes) {
+    if (!$this->table) {
+      return;
+    }
+    // We first clear the tracking table for all indexes, so we can just insert
+    // all items again without any key conflicts.
+    $this->stopTracking($indexes);
+
+    foreach ($indexes as $index) {
+      $types = $this->getEntityTypes($index);
+
+      // Wherever possible, use a sub-select instead of the much slower
+      // entity_load().
+      foreach ($types as $type) {
+        $entity_info = entity_get_info($type);
+
+        if (!empty($entity_info['base table'])) {
+          // Assumes that all entities use the "base table" property and the
+          // "entity keys[id]" in the same way as the default controller.
+          $id_field = $entity_info['entity keys']['id'];
+          $table = $entity_info['base table'];
+
+          // Select all entity ids.
+          $query = db_select($table, 't');
+          $query->addExpression("CONCAT(:prefix, t.$id_field)", 'item_id', array(':prefix' => $type . '/'));
+          $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+          $query->addExpression('1', 'changed');
+
+          // INSERT ... SELECT ...
+          db_insert($this->table)
+            ->from($query)
+            ->execute();
+
+          unset($types[$type]);
+        }
+      }
+
+      // In the absence of a "base table", use the slow entity_load().
+      if ($types) {
+        foreach ($types as $type) {
+          $query = new EntityFieldQuery();
+          $query->entityCondition('entity_type', $type);
+          $result = $query->execute();
+          $ids = !empty($result[$type]) ? array_keys($result[$type]) : array();
+          if ($ids) {
+            foreach ($ids as $i => $id) {
+              $ids[$i] = $type . '/' . $id;
+            }
+            $this->trackItemInsert($ids, array($index), TRUE);
+          }
+        }
+      }
+    }
+  }
+
+  /**
+   * Starts tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of new items to track.
+   * @param SearchApiIndex[] $indexes
+   *   The indexes for which items should be tracked.
+   * @param bool $skip_type_check
+   *   (optional) If TRUE, don't check whether the type matches the index's
+   *   datasource configuration. Internal use only.
+   *
+   * @return SearchApiIndex[]|null
+   *   All indexes for which any items were added; or NULL if items were added
+   *   for all of them.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any error state was encountered.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes, $skip_type_check = FALSE) {
+    $ret = array();
+
+    foreach ($indexes as $index_id => $index) {
+      $ids = drupal_map_assoc($item_ids);
+
+      if (!$skip_type_check) {
+        $types = $this->getEntityTypes($index);
+        foreach ($ids as $id) {
+          list($type) = explode('/', $id);
+          if (!isset($types[$type])) {
+            unset($ids[$id]);
+          }
+        }
+      }
+
+      if ($ids) {
+        parent::trackItemInsert($ids, array($index));
+        $ret[$index_id] = $index;
+      }
+    }
+
+    return $ret;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationForm(array $form, array &$form_state) {
+    $form['types'] = array(
+      '#type' => 'checkboxes',
+      '#title' => t('Entity types'),
+      '#description' => t('Select the entity types which should be included in this index.'),
+      '#options' => search_api_entity_type_options_list(),
+      '#attributes' => array('class' => array('search-api-checkboxes-list')),
+      '#disabled' => !empty($form_state['index']),
+      '#required' => TRUE,
+    );
+    if (!empty($form_state['index']->options['datasource']['types'])) {
+      $form['types']['#default_value'] = $this->getEntityTypes($form_state['index']);
+    }
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function configurationFormSubmit(array $form, array &$values, array &$form_state) {
+    if (!empty($values['types'])) {
+      $values['types'] = array_keys(array_filter($values['types']));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfigurationSummary(SearchApiIndex $index) {
+    if ($type_labels = $this->getSelectedEntityTypeOptions($index)) {
+      $args['!types'] = implode(', ', $type_labels);
+      return format_plural(count($type_labels), 'Indexed entity types: !types.', 'Indexed entity types: !types.', $args);
+    }
+    return NULL;
+  }
+
+  /**
+   * Retrieves the index for which the current method was called.
+   *
+   * Very ugly method which uses the stack trace to find the right object.
+   *
+   * @return SearchApiIndex
+   *   The active index.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getCallingIndex() {
+    foreach (debug_backtrace() as $trace) {
+      if (isset($trace['object']) && $trace['object'] instanceof SearchApiIndex) {
+        return $trace['object'];
+      }
+    }
+    // If there's only a single index on the site, it's also easy.
+    $indexes = search_api_index_load_multiple(FALSE);
+    if (count($indexes) === 1) {
+      return reset($indexes);
+    }
+    throw new SearchApiException('Could not determine the active index of the datasource.');
+  }
+
+  /**
+   * Returns the entity types for which this datasource is configured.
+   *
+   * Depends on the index from which this method is (indirectly) called.
+   *
+   * @param SearchApiIndex $index
+   *   (optional) The index for which to get the enabled entity types. If not
+   *   given, will be determined automatically.
+   *
+   * @return string[]
+   *   The machine names of the datasource's enabled entity types, as both keys
+   *   and values.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getEntityTypes(SearchApiIndex $index = NULL) {
+    if (!$index) {
+      $index = $this->getCallingIndex();
+    }
+    if (isset($index->options['datasource']['types'])) {
+      return drupal_map_assoc($index->options['datasource']['types']);
+    }
+    return array();
+  }
+
+  /**
+   * Returns the selected entity type options for this datasource.
+   *
+   * Depends on the index from which this method is (indirectly) called.
+   *
+   * @param SearchApiIndex $index
+   *   (optional) The index for which to get the enabled entity types. If not
+   *   given, will be determined automatically.
+   *
+   * @return string[]
+   *   An associative array, mapping the machine names of the enabled entity
+   *   types to their labels.
+   *
+   * @throws SearchApiException
+   *   Thrown if the active index could not be determined.
+   */
+  protected function getSelectedEntityTypeOptions(SearchApiIndex $index = NULL) {
+    return array_intersect_key(search_api_entity_type_options_list(), $this->getEntityTypes($index));
+  }
+
+}
diff --git a/search_api.info b/search_api.info
index 5b65f83..d0d3bb5 100644
--- a/search_api.info
+++ b/search_api.info
@@ -19,6 +19,7 @@ files[] = includes/callback_role_filter.inc
 files[] = includes/datasource.inc
 files[] = includes/datasource_entity.inc
 files[] = includes/datasource_external.inc
+files[] = includes/datasource_multiple.inc
 files[] = includes/exception.inc
 files[] = includes/index_entity.inc
 files[] = includes/processor.inc
diff --git a/search_api.install b/search_api.install
index 850b828..3650a03 100644
--- a/search_api.install
+++ b/search_api.install
@@ -191,6 +191,35 @@ function search_api_schema() {
     'primary key' => array('item_id', 'index_id'),
   );
 
+  $schema['search_api_item_string_id'] = array(
+    'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+    'fields' => array(
+      'item_id' => array(
+        'description' => "The item's ID.",
+        'type' => 'varchar',
+        'length' => 64,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.id this item belongs to.',
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'changed' => array(
+        'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+        'type' => 'int',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+    ),
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+
   $schema['search_api_task'] = array(
     'description' => 'Stores pending tasks for servers.',
     'fields' => array(
@@ -1001,3 +1030,38 @@ function search_api_update_7117() {
       ->execute();
   }
 }
+
+/**
+ * Adds the {search_api_item_string_id} table for items with string IDs.
+ */
+function search_api_update_7118() {
+  $table = array(
+    'description' => 'Stores the items which should be indexed for each index, and their status. Used only for items with string IDs.',
+    'fields' => array(
+      'item_id' => array(
+        'description' => "The item's ID.",
+        'type' => 'varchar',
+        'length' => 64,
+        'not null' => TRUE,
+      ),
+      'index_id' => array(
+        'description' => 'The {search_api_index}.id this item belongs to.',
+        'type' => 'int',
+        'unsigned' => TRUE,
+        'not null' => TRUE,
+      ),
+      'changed' => array(
+        'description' => 'Either a flag or a timestamp to indicate if or when the item was changed since it was last indexed.',
+        'type' => 'int',
+        'size' => 'big',
+        'not null' => TRUE,
+        'default' => 1,
+      ),
+    ),
+    'indexes' => array(
+      'indexing' => array('index_id', 'changed'),
+    ),
+    'primary key' => array('item_id', 'index_id'),
+  );
+  db_create_table('search_api_item_string_id', $table);
+}
diff --git a/search_api.module b/search_api.module
index 6253221..c510a0d 100644
--- a/search_api.module
+++ b/search_api.module
@@ -647,8 +647,9 @@ function search_api_search_api_index_insert(SearchApiIndex $index) {
  * Implements hook_search_api_index_update().
  */
 function search_api_search_api_index_update(SearchApiIndex $index) {
-  // Call the datasource update function with the table this module provides.
+  // Call the datasource update function with the tables this module provides.
   search_api_index_update_datasource($index, 'search_api_item');
+  search_api_index_update_datasource($index, 'search_api_item_string_id');
 
   // If the server was changed, we have to call the appropriate service class
   // hook methods.
@@ -857,6 +858,8 @@ function search_api_entity_insert($entity, $type) {
   list($id) = entity_extract_ids($type, $entity);
   if (isset($id)) {
     search_api_track_item_insert($type, array($id));
+    $combined_id = $type . '/' . $id;
+    search_api_track_item_insert('multiple', array($combined_id));
   }
 }
 
@@ -889,6 +892,8 @@ function search_api_entity_update($entity, $type) {
 
   if (isset($id)) {
     search_api_track_item_change($type, array($id));
+    $combined_id = $type . '/' . $id;
+    search_api_track_item_change('multiple', array($combined_id));
   }
 }
 
@@ -910,6 +915,8 @@ function search_api_entity_delete($entity, $type) {
   list($id) = entity_extract_ids($type, $entity);
   if (isset($id)) {
     search_api_track_item_delete($type, array($id));
+    $combined_id = $type . '/' . $id;
+    search_api_track_item_delete('multiple', array($combined_id));
   }
 }
 
@@ -967,16 +974,19 @@ function search_api_flush_caches() {
 function search_api_search_api_item_type_info() {
   $types = array();
 
-  foreach (entity_get_property_info() as $type => $property_info) {
-    if ($info = entity_get_info($type)) {
-      $types[$type] = array(
-        'name' => $info['label'],
-        'datasource controller' => 'SearchApiEntityDataSourceController',
-        'entity_type' => $type,
-      );
-    }
+  foreach (search_api_entity_type_options_list() as $type => $label) {
+    $types[$type] = array(
+      'name' => $label,
+      'datasource controller' => 'SearchApiEntityDataSourceController',
+      'entity_type' => $type,
+    );
   }
 
+  $types['multiple'] = array(
+    'name' => t('Multiple types'),
+    'datasource controller' => 'SearchApiCombinedEntityDataSourceController',
+  );
+
   return $types;
 }
 
@@ -2825,6 +2835,26 @@ function search_api_index_options_list() {
 }
 
 /**
+ * Options list callback for entity types.
+ *
+ * Will only include entity types which specify entity property information.
+ *
+ * @return string[]
+ *   An array of entity type machine names mapped to their human-readable
+ *   names.
+ */
+function search_api_entity_type_options_list() {
+  $types = array();
+  foreach (array_keys(entity_get_property_info()) as $type) {
+    $info = entity_get_info($type);
+    if ($info) {
+      $types[$type] = $info['label'];
+    }
+  }
+  return $types;
+}
+
+/**
  * Shutdown function which indexes all queued items, if any.
  */
 function _search_api_index_queued_items() {
