diff --git a/README.txt b/README.txt
index 3b6ed02..63fe32b 100644
--- a/README.txt
+++ b/README.txt
@@ -35,11 +35,15 @@ Terms as used in this module.
   e.g. be some tables in a database, a connection to a Solr server or other
   external services, etc.
 - Index:
-  One set of data for searching a specific entity. What and how data is
-  indexed is determined by its settings. Also keeps track of which items still
-  need to be indexed (or re-indexed, if they were updated). Needs to lie on a
-  server in order to be really used (although configuration is independent of a
-  server).
+  A configuration object for indexing data of a specific type. What and how data
+  is indexed is determined by its settings. Also keeps track of which items
+  still need to be indexed (or re-indexed, if they were updated). Needs to lie
+  on a server in order to be really used (although configuration is independent
+  of a server).
+- Item type:
+  A type of data which can be indexed (i.e., for which indexes can be created).
+  Most entity types (like Content, User, Taxonomy term, etc.) are available, but
+  possibly also other types provided by contrib modules.
 - Entity:
   One object of data, usually stored in the database. Might for example
   be a node, a user or a file.
@@ -178,6 +182,7 @@ Information for developers
  | searchable with the Search API, your module will need to implement
  | hook_entity_property_info() in addition to the normal hook_entity_info().
  | hook_entity_property_info() is documented in the entity module.
+ | For making certain non-entities searchable, see "Item type" below.
  | For custom field types to be available for indexing, provide a
  | "property_type" key in hook_field_info(), and optionally a callback at the
  | "property_callbacks" key.
@@ -229,6 +234,25 @@ For the query class to become available (other than through manual creation),
 you need a custom service class where you override the query() method to return
 an instance of your query class.
 
+- Item type
+  Interface: SearchApiDataSourceControllerInterface
+  Base class: SearchApiAbstractDataSourceController
+  Hook: hook_search_api_item_type_info()
+
+If you want to index some data which is not defined as an entity, you can
+specify it as a new item type here. For defining a new item type, you have to
+create a data source controller for the type and track new, changed and deleted
+items of the type by calling the search_api_track_item_*() functions.
+An instance of the data source controller class will then be used by indexes
+when handling items of your newly-defined type.
+
+If you want to make external data that is indexed on some search server
+available to the Search API, there is a handy base class for your data source
+controller (SearchApiExternalDataSourceController in
+includes/datasource_external.inc) which you can extend. For a minimal use case,
+you will then only have to define the available fields that can be retrieved by
+the server.
+
 - Data-alter callbacks
   Interface: SearchApiAlterCallbackInterface
   Base class: SearchApiAbstractAlterCallback
diff --git a/contrib/search_api_db/search_api_db.test b/contrib/search_api_db/search_api_db.test
index 1ffbc4d..b8dc0f4 100644
--- a/contrib/search_api_db/search_api_db.test
+++ b/contrib/search_api_db/search_api_db.test
@@ -118,11 +118,11 @@ class SearchApiDbTest extends DrupalWebTestCase {
     $values = array(
       'name' => 'Test index',
       'machine_name' => 'test_index',
-      'entity_type' => 'search_api_test',
+      'item_type' => 'search_api_test',
       'enabled' => 1,
       'description' => 'An index used for testing.',
       'server' => $this->server_id,
-      'cron_limit' => 5,
+      'options[cron_limit]' => 5,
     );
     $this->drupalPost('admin/config/search/search_api/add_index', $values, t('Create index'));
 
diff --git a/contrib/search_api_db/service.inc b/contrib/search_api_db/service.inc
index 039eb13..a09946a 100644
--- a/contrib/search_api_db/service.inc
+++ b/contrib/search_api_db/service.inc
@@ -108,7 +108,7 @@ class SearchApiDbService extends SearchApiAbstractService {
         continue;
       }
       $table = $this->findFreeTable($prefix, $name);
-      $this->createFieldTable($field, $table);
+      $this->createFieldTable($index, $field, $table);
       $indexes[$index->machine_name][$name]['table'] = $table;
       $indexes[$index->machine_name][$name]['type']  = $field['type'];
       $indexes[$index->machine_name][$name]['boost'] = $field['boost'];
@@ -137,19 +137,25 @@ class SearchApiDbService extends SearchApiAbstractService {
   /**
    * Helper method for creating the table for a field.
    */
-  protected function createFieldTable($field, $name) {
+  protected function createFieldTable(SearchApiIndex $index, $field, $name) {
     $table = array(
       'name' => $name,
       'module' => 'search_api_db',
       'fields' => array(
         'item_id' => array(
-          'description' => 'The primary identifier of the entity.',
-          'type' => 'int',
-          'unsigned' => TRUE,
+          'description' => 'The primary identifier of the item.',
           'not null' => TRUE,
         ),
       ),
     );
+    // The type of the item_id field depends on the ID field's type.
+    $id_field = $index->datasource()->getIdFieldInfo();
+    $table['fields']['item_id'] += $this->sqlType($id_field['type'] == 'text' ? 'string' : $id_field['type']);
+    if (isset($table['fields']['item_id']['length'])) {
+      // A length of 255 is overkill for IDs. 50 should be more than enough.
+      $table['fields']['item_id']['length'] = 50;
+    }
+
     $type = search_api_extract_inner_type($field['type']);
     if ($type == 'text') {
       $table['fields']['word'] = array(
@@ -238,7 +244,7 @@ class SearchApiDbService extends SearchApiAbstractService {
             $this->deleteItems('all', $index);
           }
           db_drop_table($field['table']);
-          $this->createFieldTable($new_fields[$name], $field['table']);
+          $this->createFieldTable($index, $new_fields[$name], $field['table']);
         }
         elseif ($this->sqlType($old_type) != $this->sqlType($new_type)) {
           // There is a change in SQL type. We don't have to clear the index, since types can be converted.
@@ -263,7 +269,7 @@ class SearchApiDbService extends SearchApiAbstractService {
     foreach ($new_fields as $name => $field) {
       $reindex = TRUE;
       $table = $this->findFreeTable($prefix, $name);
-      $this->createFieldTable($field, $table);
+      $this->createFieldTable($index, $field, $table);
       $fields[$name]['table'] = $table;
       $fields[$name]['type']  = $field['type'];
       $fields[$name]['boost'] = $field['boost'];
@@ -331,9 +337,11 @@ class SearchApiDbService extends SearchApiAbstractService {
         if (search_api_is_text_type($type, array('text', 'tokens'))) {
           $words = array();
           foreach ($value as $token) {
-            // Taken from core search to reflect less importance of words later in the text.
-            // Focus is a decaying value in terms of the amount of unique words up to this point.
-            // From 100 words and more, it decays, to e.g. 0.5 at 500 words and 0.3 at 1000 words.
+            // Taken from core search to reflect less importance of words later
+            // in the text.
+            // Focus is a decaying value in terms of the amount of unique words
+            // up to this point. From 100 words and more, it decays, to e.g. 0.5
+            // at 500 words and 0.3 at 1000 words.
             $focus = min(1, .01 + 3.5 / (2 + count($words) * .015));
 
             $value = &$token['value'];
@@ -807,7 +815,7 @@ class SearchApiDbService extends SearchApiAbstractService {
     $negated = array();
     $db_query = NULL;
     $mul_words = FALSE;
-    $not_nested = FALSE; // If the query will nest UNIONed subqueries or just leave them that way.
+    $not_nested = FALSE;
 
     foreach ($keys as $i => $key) {
       if (!element_child($i)) {
@@ -818,7 +826,9 @@ class SearchApiDbService extends SearchApiAbstractService {
       }
       elseif (empty($key['#negation'])) {
         if ($neg) {
-          $key['#negation'] = TRUE; // If this query is negated, we also only need item_ids from subqueries.
+          // If this query is negated, we also only need item_ids from
+          // subqueries.
+          $key['#negation'] = TRUE;
         }
         $nested[] = $key;
       }
diff --git a/contrib/search_api_facets/search_api_facets.module b/contrib/search_api_facets/search_api_facets.module
index 5b827aa..733dcf6 100644
--- a/contrib/search_api_facets/search_api_facets.module
+++ b/contrib/search_api_facets/search_api_facets.module
@@ -399,7 +399,7 @@ function search_api_facets_block_view($delta = '') {
   }
 
   $theme_suffix  = '';
-  $theme_suffix .= '__' . preg_replace('/\W+/', '_', $query->getIndex()->entity_type);
+  $theme_suffix .= '__' . preg_replace('/\W+/', '_', $query->getIndex()->item_type);
   $theme_suffix .= '__' . preg_replace('/\W+/', '_', $facet->field);
   $theme_suffix .= '__' . preg_replace('/\W+/', '_', $facet->delta);
   $theme = array(
@@ -596,7 +596,7 @@ function search_api_facets_block_current_search_view() {
       }
 
       $theme_suffix  = '';
-      $theme_suffix .= '__' . preg_replace('/\W+/', '_', $index->entity_type);
+      $theme_suffix .= '__' . preg_replace('/\W+/', '_', $index->item_type);
       $theme_suffix .= '__' . preg_replace('/\W+/', '_', $field);
       $theme_suffix .= '__' . preg_replace('/\W+/', '_', $facet->delta);
       foreach ($field_filters as $i => $v) {
diff --git a/contrib/search_api_page/search_api_page.admin.inc b/contrib/search_api_page/search_api_page.admin.inc
index a76f782..99dc25b 100644
--- a/contrib/search_api_page/search_api_page.admin.inc
+++ b/contrib/search_api_page/search_api_page.admin.inc
@@ -170,12 +170,14 @@ function search_api_page_admin_add(array $form, array &$form_state) {
     '#default_value' => 10,
   );
 
-  $entity_info = entity_get_info($index->entity_type);
   $view_modes = array(
     'search_api_page_result' => t('Themed as search results'),
   );
-  foreach ($entity_info['view modes'] as $mode => $mode_info) {
-    $view_modes[$mode] = $mode_info['label'];
+  // For entities, we also add all entity view modes.
+  if ($entity_info = entity_get_info($index->item_type)) {
+    foreach ($entity_info['view modes'] as $mode => $mode_info) {
+      $view_modes[$mode] = $mode_info['label'];
+    }
   }
   if (count($view_modes) > 1) {
     $form['view_mode'] = array(
@@ -343,12 +345,14 @@ function search_api_page_admin_edit(array $form, array &$form_state, stdClass $p
     '#default_value' => $page->options['per_page'],
   );
 
-  $entity_info = entity_get_info($index->entity_type);
   $view_modes = array(
     'search_api_page_result' => t('Themed as search results'),
   );
-  foreach ($entity_info['view modes'] as $mode => $mode_info) {
-    $view_modes[$mode] = $mode_info['label'];
+  // For entities, we also add all entity view modes.
+  if ($entity_info = entity_get_info($index->item_type)) {
+    foreach ($entity_info['view modes'] as $mode => $mode_info) {
+      $view_modes[$mode] = $mode_info['label'];
+    }
   }
   if (count($view_modes) > 1) {
     $form['options']['view_mode'] = array(
diff --git a/contrib/search_api_page/search_api_page.module b/contrib/search_api_page/search_api_page.module
index 3ed6d05..acdf8ce 100644
--- a/contrib/search_api_page/search_api_page.module
+++ b/contrib/search_api_page/search_api_page.module
@@ -57,7 +57,7 @@ function search_api_page_theme() {
     'variables' => array(
       'index' => NULL,
       'results' => array('result count' => 0),
-      'entities' => array(),
+      'items' => array(),
       'view_mode' => 'search_api_page_result',
       'keys' => '',
     ),
@@ -67,7 +67,7 @@ function search_api_page_theme() {
     'variables' => array(
       'index' => NULL,
       'result' => NULL,
-      'entity' => NULL,
+      'item' => NULL,
       'keys' => '',
     ),
     'file' => 'search_api_page.pages.inc',
@@ -153,11 +153,14 @@ function search_api_page_entity_property_info() {
       'label' => t('ID'),
       'type' => 'integer',
       'description' => t('The primary identifier for a search page.'),
+      'schema field' => 'id',
+      'validation callback' => 'entity_metadata_validate_integer_positive',
     ),
     'index_id' => array(
       'label' => t('Index ID'),
       'type' => 'token',
-      'description' => t('The ID of the index this search page uses.'),
+      'description' => t('The machine name of the index this search page uses.'),
+      'schema field' => 'index_id',
     ),
     'index' => array(
       'label' => t('Index'),
@@ -169,18 +172,21 @@ function search_api_page_entity_property_info() {
       'label' => t('Name'),
       'type' => 'text',
       'description' => t('The displayed name for a search page.'),
+      'schema field' => 'name',
       'required' => TRUE,
     ),
     'description' => array(
       'label' => t('Description'),
       'type' => 'text',
       'description' => t('The displayed description for a search page.'),
+      'schema field' => 'description',
       'sanitize' => 'filter_xss',
     ),
     'enabled' => array(
       'label' => t('Enabled'),
       'type' => 'boolean',
       'description' => t('A flag indicating whether the search page is enabled.'),
+      'schema field' => 'enabled',
     ),
   );
 
diff --git a/contrib/search_api_page/search_api_page.pages.inc b/contrib/search_api_page/search_api_page.pages.inc
index b040439..ee24f36 100644
--- a/contrib/search_api_page/search_api_page.pages.inc
+++ b/contrib/search_api_page/search_api_page.pages.inc
@@ -100,7 +100,7 @@ function search_api_page_search_execute(stdClass $page, $keys) {
  */
 function template_preprocess_search_api_page_results(array &$variables) {
   if (!empty($variables['results']['results'])) {
-    $variables['entities'] = entity_load($variables['index']->entity_type, array_keys($variables['results']['results']));
+    $variables['items'] = $variables['index']->loadItems(array_keys($variables['results']['results']));
   }
 }
 
@@ -112,8 +112,8 @@ function template_preprocess_search_api_page_results(array &$variables) {
  *   - index: The index this search was executed on.
  *   - results: An array of search results, as returned by
  *     SearchApiQueryInterface::execute().
- *   - entities: The loaded entities for all results, in an array keyed by ID.
- *   - "view_mode": The view mode to use for displaying the individual results,
+ *   - items: The loaded items for all results, in an array keyed by ID.
+ *   - view_mode: The view mode to use for displaying the individual results,
  *     or the special mode "search_api_page_result" to use the theme function
  *     of the same name.
  *   - keys: The keywords of the executed search.
@@ -123,7 +123,7 @@ function theme_search_api_page_results(array $variables) {
 
   $index = $variables['index'];
   $results = $variables['results'];
-  $entities = $variables['entities'];
+  $items = $variables['items'];
   $keys = $variables['keys'];
 
   $output = '<p class="search-performance">' . format_plural($results['result count'],
@@ -139,16 +139,16 @@ function theme_search_api_page_results(array $variables) {
   $output .= "\n<h2>" . t('Search results') . "</h2>\n";
 
   if ($variables['view_mode'] == 'search_api_page_result') {
-    entity_prepare_view($index->entity_type, $entities);
     $output .= '<ol class="search-results">';
     foreach ($results['results'] as $item) {
-      $output .= '<li class="search-result">' . theme('search_api_page_result', array('index' => $index, 'result' => $item, 'entity' => isset($entities[$item['id']]) ? $entities[$item['id']] : NULL, 'keys' => $keys)) . '</li>';
+      $output .= '<li class="search-result">' . theme('search_api_page_result', array('index' => $index, 'result' => $item, 'item' => isset($items[$item['id']]) ? $items[$item['id']] : NULL, 'keys' => $keys)) . '</li>';
     }
     $output .= '</ol>';
   }
   else {
+    // This option can only be set when the items are entities.
     $output .= '<div class="search-results">';
-    $render = entity_view($index->entity_type, $entities, $variables['view_mode']);
+    $render = entity_view($index->item_type, $items, $variables['view_mode']);
     $output .= render($render);
     $output .= '</div>';
   }
@@ -164,25 +164,19 @@ function theme_search_api_page_results(array $variables) {
  *   - index: The index this search was executed on.
  *   - result: One item of the search results, an array containing the keys
  *     'id' and 'score'.
- *   - entity: The loaded entity corresponding to the result.
+ *   - item: The loaded item corresponding to the result.
  *   - keys: The keywords of the executed search.
  */
 function theme_search_api_page_result(array $variables) {
   $index = $variables['index'];
   $id = $variables['result']['id'];
-  $entity = $variables['entity'];
+  $item = $variables['item'];
 
-  $wrapper = entity_metadata_wrapper($index->entity_type, $entity);
+  $wrapper = $index->entityWrapper($item, FALSE);
+  $index = new SearchApiIndex();
 
-  $url = entity_uri($index->entity_type, $entity);
-  $name = entity_label($index->entity_type, $entity);
-
-  if ($index->entity_type == 'file') {
-    $url = array(
-      'path' => file_create_url($url),
-      'options' => array(),
-    );
-  }
+  $url = $index->datasource()->getItemUrl($item);
+  $name = $index->datasource()->getItemLabel($item);
 
   if (!empty($variables['result']['excerpt'])) {
     $text = $variables['result']['excerpt'];
diff --git a/contrib/search_api_views/includes/display_facet_block.inc b/contrib/search_api_views/includes/display_facet_block.inc
index 1dc74b4..cecd95f 100644
--- a/contrib/search_api_views/includes/display_facet_block.inc
+++ b/contrib/search_api_views/includes/display_facet_block.inc
@@ -200,7 +200,7 @@ class SearchApiViewsFacetsBlockDisplay extends views_plugin_display_block {
     }
 
     $theme_suffix  = '';
-    $theme_suffix .= '__' . preg_replace('/\W+/', '_', $this->view->query->getIndex()->entity_type);
+    $theme_suffix .= '__' . preg_replace('/\W+/', '_', $this->view->query->getIndex()->item_type);
     $theme_suffix .= '__' . preg_replace('/\W+/', '_', $facet_field);
     $theme_suffix .= '__search_api_views_facets_block';
     $info['content']['facets'] = array(
diff --git a/contrib/search_api_views/includes/handler_argument_more_like_this.inc b/contrib/search_api_views/includes/handler_argument_more_like_this.inc
index 9a8eeb6..be73ab0 100644
--- a/contrib/search_api_views/includes/handler_argument_more_like_this.inc
+++ b/contrib/search_api_views/includes/handler_argument_more_like_this.inc
@@ -56,7 +56,7 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
    */
   public function query($group_by = FALSE) {
     $server = $this->query->getIndex()->server();
-    if (!$server->supportsFeature("search_api_mlt")) {
+    if (!$server->supportsFeature('search_api_mlt')) {
       $class = search_api_get_service_info($server->class);
       throw new SearchApiException(t('The search service "!class" does not offer "More like this" functionality.',
           array('!class' => $class['name'])));
@@ -74,6 +74,6 @@ class SearchApiViewsHandlerArgumentMoreLikeThis extends SearchApiViewsHandlerArg
       'id' => $this->argument,
       'fields' => $fields,
     );
-    $this->query->getSearchApiQuery()->setOption("search_api_mlt", $mlt);
+    $this->query->getSearchApiQuery()->setOption('search_api_mlt', $mlt);
   }
 }
diff --git a/contrib/search_api_views/includes/handler_field.inc b/contrib/search_api_views/includes/handler_field.inc
index 9627ed2..d3c0771 100644
--- a/contrib/search_api_views/includes/handler_field.inc
+++ b/contrib/search_api_views/includes/handler_field.inc
@@ -73,7 +73,7 @@ class SearchApiViewsHandlerField extends views_handler_field {
     }
     $form['link_to_entity'] = array(
       '#type' => 'checkbox',
-      '#title' => t('Link this field to its entity'),
+      '#title' => t('Link this field to the result item'),
       '#description' => t('This will override any other link you have set.'),
       '#default_value' => $this->options['link_to_entity'],
     );
@@ -169,16 +169,16 @@ class SearchApiViewsHandlerField extends views_handler_field {
     if (!$this->options['link_to_entity']) {
       return $render;
     }
-    $type = isset($values->search_api_views_entity_type) ? $values->search_api_views_entity_type : $this->query->getIndex()->entity_type;
-    $entity = $values->entity;
+    $index = $this->query->getIndex();
+    $item = $values->entity;
     // $values->entity can either be the fully loaded entity, or just its ID.
-    if (!is_object($entity)) {
-      $entity = entity_load($type, array($entity));
-      if ($entity) {
-        $entity = reset($entity);
+    if (!is_object($item)) {
+      $items = $index->loadItems(array($item));
+      if ($items) {
+        $item = reset($items);
       }
     }
-    if (is_object($entity) && ($url = entity_uri($type, $entity))) {
+    if (is_object($item) && ($url = $index->datasource()->getItemUrl($item))) {
       return l($render, $url['path'], array('html' => TRUE) + $url['options']);
     }
     return $render;
diff --git a/contrib/search_api_views/includes/handler_field_entity.inc b/contrib/search_api_views/includes/handler_field_entity.inc
index 6cc11f4..a600dad 100644
--- a/contrib/search_api_views/includes/handler_field_entity.inc
+++ b/contrib/search_api_views/includes/handler_field_entity.inc
@@ -33,6 +33,7 @@ class SearchApiViewsHandlerFieldEntity extends SearchApiViewsHandlerField {
    */
   public function options_form(&$form, &$form_state) {
     parent::options_form($form, $form_state);
+    $form['link_to_entity']['#description'] = t('Link this field to its entity');
 
     $form['format_name'] = array(
       '#title' => t('Display label instead of ID'),
diff --git a/contrib/search_api_views/includes/query.inc b/contrib/search_api_views/includes/query.inc
index 4ec65d2..46a6e31 100644
--- a/contrib/search_api_views/includes/query.inc
+++ b/contrib/search_api_views/includes/query.inc
@@ -157,16 +157,15 @@ class SearchApiViewsQuery extends views_plugin_query {
   protected function addResults(array $results, $view) {
     $rows = array();
     $missing = array();
-    $entities = array();
-    $entity_type = $this->index->entity_type;
+    $items = array();
 
     // First off, we try to gather as much field values as possible without
-    // loading any entities.
+    // loading any items.
     foreach ($results as $id => $result) {
-      $row = array('search_api_views_entity_type' => $entity_type);
+      $row = array();
 
-      // Include the loaded entity for this result row, if present, or the
-      // entity ID.
+      // Include the loaded item for this result row, if present, or the item
+      // ID.
       if (!empty($result['entity'])) {
         $row['entity'] = $result['entity'];
       }
@@ -184,12 +183,12 @@ class SearchApiViewsQuery extends views_plugin_query {
         $row += $result['fields'];
       }
 
-      // Check whether we need to extract any properties from the Drupal entity.
+      // Check whether we need to extract any properties from the result item.
       $missing_fields = array_diff_key($this->fields, $row);
-      if (!empty($missing_fields)) {
+      if ($missing_fields) {
         $missing[$id] = $missing_fields;
         if (is_object($row['entity'])) {
-          $entities[$id] = $row['entity'];
+          $items[$id] = $row['entity'];
         }
         else {
           $ids[] = $id;
@@ -200,16 +199,16 @@ class SearchApiViewsQuery extends views_plugin_query {
       $rows[$id] = $row;
     }
 
-    // Load entities of those rows which haven't got all field values, yet.
+    // Load items of those rows which haven't got all field values, yet.
     if (!empty($ids)) {
-      $entities += entity_load($entity_type, $ids);
-      // $entities now includes loaded entities, and those already passed in the
+      $items += $this->index->loadItems($ids);
+      // $items now includes loaded items, and those already passed in the
       // search results.
-      foreach ($entities as $id => $entity) {
-        // Extract entity properties.
-        $wrapper = entity_metadata_wrapper($entity_type, $entity);
+      foreach ($items as $id => $item) {
+        // Extract item properties.
+        $wrapper = $this->index->entityWrapper($item, FALSE);
         $rows[$id] += $this->extractFields($wrapper, $missing[$id]);
-        $rows[$id]['entity'] = $entity;
+        $rows[$id]['entity'] = $item;
       }
     }
 
@@ -220,7 +219,7 @@ class SearchApiViewsQuery extends views_plugin_query {
   }
 
   /**
-   * Helper function for extracting all necessary fields from an entity.
+   * Helper function for extracting all necessary fields from a result item.
    */
   // @todo Optimize
   protected function extractFields(EntityMetadataWrapper $wrapper, array $all_fields) {
diff --git a/contrib/search_api_views/search_api_views.views.inc b/contrib/search_api_views/search_api_views.views.inc
index a591c34..7c6799d 100644
--- a/contrib/search_api_views/search_api_views.views.inc
+++ b/contrib/search_api_views/search_api_views.views.inc
@@ -10,29 +10,34 @@ function search_api_views_views_data() {
     // Base data
     $key = 'search_api_index_' . $index->machine_name;
     $table = &$data[$key];
-    $entity_info = entity_get_info($index->entity_type);
-    $table['table']['group'] = $entity_info['label'];
+    $type_info = search_api_get_item_type_info($index->item_type);
+    $table['table']['group'] = $type_info['name'];
     $table['table']['base'] = array(
       'field' => 'search_api_id',
       'index' => $index->machine_name,
       'title' => $index->name,
       'help' => t('Use the %name search index for filtering and retrieving data.', array('%name' => $index->name)),
       'query class' => 'search_api_views_query',
-      'entity type' => $index->entity_type,
-      'skip entity load' => TRUE,
     );
+    if (isset($entity_types[$index->item_type])) {
+      $table['table']['base'] += array(
+        'entity type' => $index->item_type,
+        'skip entity load' => TRUE,
+      );
+    }
 
     // Add all available fields
     // This is largely copied from _search_api_admin_get_fields().
     $max_depth = variable_get('search_api_views_max_fields_depth', 2);
-    $orig_wrapper = $index->entityWrapper();
+    $orig_wrapper = $index->entityWrapper(NULL, FALSE);
     $fields = empty($index->options['fields']) ? array() : $index->options['fields'];
 
-    // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the user wrapper
+    // A wrapper for a specific field name prefix, e.g. 'user:' mapped to the
+    // user wrapper.
     $wrappers = array('' => $orig_wrapper);
-    // Display names for the prefixes
+    // Display names for the prefixes.
     $prefix_names = array('' => '');
-    // The list nesting level for entities with a certain prefix
+    // The list nesting level for structures with a certain prefix.
     $nesting_levels = array('' => 0);
 
     $types = search_api_field_types();
@@ -41,7 +46,7 @@ function search_api_views_views_data() {
       foreach ($wrappers as $prefix => $wrapper) {
         $prefix_name = $prefix_names[$prefix];
         $depth = substr_count($prefix, ':');
-        // Deal with lists of entities.
+        // Deal with lists of items.
         $nesting_level = $nesting_levels[$prefix];
         $type_prefix = str_repeat('list<', $nesting_level);
         $type_suffix = str_repeat('>', $nesting_level);
@@ -240,7 +245,7 @@ function _search_api_views_add_handlers($field, $wrapper) {
 
   $ret = array();
 
-  if (empty($field['entity_type']) && $options = $wrapper->optionsList('view')) {
+  if ($options = $wrapper->optionsList('view')) {
     $ret['filter']['handler'] = 'SearchApiViewsHandlerFilterOptions';
     $ret['filter']['options'] = $options;
   }
diff --git a/includes/callback_add_url.inc b/includes/callback_add_url.inc
index 281858a..097fd41 100644
--- a/includes/callback_add_url.inc
+++ b/includes/callback_add_url.inc
@@ -6,17 +6,8 @@
 class SearchApiAlterAddUrl extends SearchApiAbstractAlterCallback {
 
   public function alterItems(array &$items) {
-    $type = $this->index->entity_type;
     foreach ($items as $id => &$item) {
-      if ($type == 'file') {
-        $url = array(
-          'path' => file_create_url($url),
-          'options' => array(),
-        );
-      }
-      else {
-        $url = entity_uri($type, $item);
-      }
+      $url = $this->index->datasource()->getItemUrl($item);
       if (!$url) {
         $item->search_api_url = NULL;
         continue;
diff --git a/includes/callback_add_viewed_entity.inc b/includes/callback_add_viewed_entity.inc
index 78731cb..bd0f8d8 100644
--- a/includes/callback_add_viewed_entity.inc
+++ b/includes/callback_add_viewed_entity.inc
@@ -5,8 +5,17 @@
  */
 class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
 
+  /**
+   * Only support indexes containing entities.
+   *
+   * @see SearchApiAlterCallbackInterface::supportsIndex()
+   */
+  public function supportsIndex(SearchApiIndex $index) {
+    return (bool) entity_get_info($index->item_type);
+  }
+
   public function configurationForm() {
-    $info = entity_get_info($this->index->entity_type);
+    $info = entity_get_info($this->index->item_type);
     $view_modes = array();
     foreach ($info['view modes'] as $key => $mode) {
       $view_modes[$key] = $mode['label'];
@@ -51,7 +60,7 @@ class SearchApiAlterAddViewedEntity extends SearchApiAbstractAlterCallback {
     $original_user = $GLOBALS['user'];
     $GLOBALS['user'] = drupal_anonymous_user();
 
-    $type = $this->index->entity_type;
+    $type = $this->index->item_type;
     $mode = empty($this->options['mode']) ? 'full' : $this->options['mode'];
     foreach ($items as $id => &$item) {
       // Since we can't really know what happens in entity_view() and render(),
diff --git a/includes/callback_bundle_filter.inc b/includes/callback_bundle_filter.inc
index f19deb4..8e39200 100644
--- a/includes/callback_bundle_filter.inc
+++ b/includes/callback_bundle_filter.inc
@@ -7,7 +7,7 @@
 class SearchApiAlterBundleFilter extends SearchApiAbstractAlterCallback {
 
   public function supportsIndex(SearchApiIndex $index) {
-    return self::hasBundles(entity_get_info($index->entity_type));
+    return self::hasBundles(entity_get_info($index->item_type));
   }
 
   public function alterItems(array &$items) {
diff --git a/includes/datasource.inc b/includes/datasource.inc
new file mode 100644
index 0000000..3b702ea
--- /dev/null
+++ b/includes/datasource.inc
@@ -0,0 +1,649 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiDataSourceControllerInterface as well as a default base class.
+ */
+
+/**
+ * Interface for all data source controllers for Search API indexes.
+ *
+ * Data source controllers encapsulate all operations specific to an item type.
+ * They are used for loading items, extracting item data, keeping track of the
+ * item status, etc.
+ *
+ * All methods of the data source may throw exceptions of type
+ * SearchApiDataSourceException if any exception or error state is encountered.
+ */
+interface SearchApiDataSourceControllerInterface {
+
+  /**
+   * Constructor for a data source controller.
+   *
+   * @param $type
+   *   The item type for which this controller is created.
+   */
+  public function __construct($type);
+
+  /**
+   * Return information on the ID field for this controller's type.
+   *
+   * @return array
+   *   An associative array containing the following keys:
+   *   - key: The property key for the ID field, as used in the item wrapper.
+   *   - type: The type of the ID field. Has to be one of the types from
+   *     search_api_field_types(). List types ("list<*>") are not allowed.
+   */
+  public function getIdFieldInfo();
+
+  /**
+   * Load items of the type of this data source controller.
+   *
+   * @param array $ids
+   *   The IDs of the items to laod.
+   *
+   * @return array
+   *   The loaded items, keyed by ID.
+   */
+  public function loadItems(array $ids);
+
+  /**
+   * Get a metadata wrapper for the item type of this data source controller.
+   *
+   * @param $item
+   *   Unless NULL, an item of the item type for this controller to be wrapped.
+   * @param array $info
+   *   Optionally, additional information that should be used for creating the
+   *   wrapper. Uses the same format as entity_metadata_wrapper().
+   *
+   * @return EntityMetadataWrapper
+   *   A wrapper for the item type of this data source controller, according to
+   *   the info array, and optionally loaded with the given data.
+   *
+   * @see entity_metadata_wrapper()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array());
+
+  /**
+   * Get the unique ID of an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either the unique ID of the item, or NULL if none is available.
+   */
+  public function getItemId($item);
+
+  /**
+   * Get a human-readable label for an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either a human-readable label for the item, or NULL if none is available.
+   */
+  public function getItemLabel($item);
+
+  /**
+   * Get a URL at which the item can be viewed on the web.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either an array containing the 'path' and 'options' keys used to build
+   *   the URL of the item, and matching the signature of url(), or NULL if the
+   *   item has no URL of its own.
+   */
+  public function getItemUrl($item);
+
+  /**
+   * Initialize tracking of the index status of items for the given indexes.
+   *
+   * All currently known items of this data source's type should be inserted
+   * into the tracking table for the given indexes, with status "changed". If
+   * items were already present, these should also be set to "changed" and not
+   * be inserted again.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be initialized.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function startTracking(array $indexes);
+
+  /**
+   * Stop tracking of the index status of items for the given indexes.
+   *
+   * The tracking tables of the given indexes should be completely cleared.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be stopped.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function stopTracking(array $indexes);
+
+  /**
+   * Start tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of new items to track.
+   * @param array $indexes
+   *   The indexes for which items should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes);
+
+  /**
+   * Set the tracking status of the given items to "changed"/"dirty".
+   *
+   * @param $item_ids
+   *   Either an array with the IDs of the changed items. Or FALSE to mark all
+   *   items as changed for the given indexes.
+   * @param array $indexes
+   *   The indexes for which the change should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemChange($item_ids, array $indexes);
+
+  /**
+   * Set the tracking status of the given items to "indexed".
+   *
+   * @param array $item_ids
+   *   The IDs of the indexed items.
+   * @param SearchApiIndex $indexes
+   *   The index on which the items were indexed.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index);
+
+  /**
+   * Stop tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of the removed items.
+   * @param array $indexes
+   *   The indexes for which the deletions should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes);
+
+  /**
+   * Get a list of items that need to be indexed.
+   *
+   * If possible, completely unindexed items should be returned before items
+   * that were indexed but later changed. Also, items that were changed longer
+   * ago should be favored.
+   *
+   * @param SearchApiIndex $index
+   *   The index for which changed items should be returned.
+   * @param $limit
+   *   The maximum number of items to return. Negative values mean "unlimited".
+   *
+   * @return array
+   *   The IDs of items that need to be indexed for the given index.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1);
+
+  /**
+   * Get information on how many items have been indexed for a certain index.
+   *
+   * @param SearchApiIndex $index
+   *   The index whose index status should be returned.
+   *
+   * @return array
+   *   An associative array containing two keys (in this order):
+   *   - indexed: The number of items already indexed in their latest version.
+   *   - total: The total number of items that have to be indexed for this
+   *     index.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function getIndexStatus(SearchApiIndex $index);
+
+}
+
+/**
+ * Default base class for the SearchApiDataSourceControllerInterface.
+ *
+ * Contains default implementations for a number of methods which will be
+ * similar for most data sources. Concrete data sources can decide to extend
+ * this base class to save time, but can also implement the interface directly.
+ *
+ * A subclass will still have to provide implementations for the following
+ * methods:
+ * - getIdFieldInfo()
+ * - loadItems()
+ * - getMetadataWrapper() or getPropertyInfo()
+ * - startTracking() or getAllItemIds()
+ *
+ * The table used by default for tracking the index status of items is
+ * {search_api_item}. This can easily be changed, for example when an item type
+ * has non-integer IDs, by changing the $table property.
+ */
+abstract class SearchApiAbstractDataSourceController implements SearchApiDataSourceControllerInterface {
+
+  /**
+   * The item type for this controller instance.
+   */
+  protected $type;
+
+  /**
+   * The info array for the item type, as specified via
+   * hook_search_api_item_type_info().
+   *
+   * @var array
+   */
+  protected $info;
+
+  /**
+   * The table used for tracking items. Set to NULL on subclasses to disable
+   * the default tracking for an item type, or change the property to use a
+   * different table for tracking.
+   *
+   * @var string
+   */
+  protected $table = 'search_api_item';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the item ID.
+   *
+   * @var string
+   */
+  protected $itemIdColumn = 'item_id';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the index ID.
+   *
+   * @var string
+   */
+  protected $indexIdColumn = 'index_id';
+
+  /**
+   * When using the default tracking mechanism: the name of the column on
+   * $this->table containing the indexing status.
+   *
+   * @var string
+   */
+  protected $changedColumn = 'changed';
+
+  /**
+   * Constructor for a data source controller.
+   *
+   * @param $type
+   *   The item type for which this controller is created.
+   */
+  public function __construct($type) {
+    $this->type = $type;
+    $this->info = search_api_get_item_type_info($type);
+  }
+
+  /**
+   * Get a metadata wrapper for the item type of this data source controller.
+   *
+   * @param $item
+   *   Unless NULL, an item of the item type for this controller to be wrapped.
+   * @param array $info
+   *   Optionally, additional information that should be used for creating the
+   *   wrapper. Uses the same format as entity_metadata_wrapper().
+   *
+   * @return EntityMetadataWrapper
+   *   A wrapper for the item type of this data source controller, according to
+   *   the info array, and optionally loaded with the given data.
+   *
+   * @see entity_metadata_wrapper()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array()) {
+    $info += $this->getPropertyInfo();
+    return entity_metadata_wrapper($this->type, $item, $info);
+  }
+
+  /**
+   * Helper method that can be used by subclasses to specify the property
+   * information to use when creating a metadata wrapper.
+   *
+   * @return array
+   *   Property information as specified by hook_entity_property_info().
+   *
+   * @see hook_entity_property_info()
+   */
+  protected function getPropertyInfo() {
+    throw new SearchApiDataSourceException(t('No known property information for type !type.', array('!type' => $this->type)));
+  }
+
+  /**
+   * Get the unique ID of an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either the unique ID of the item, or NULL if none is available.
+   */
+  public function getItemId($item) {
+    $id_info = $this->getIdFieldInfo();
+    $field = $id_info['key'];
+    $wrapper = $this->getMetadataWrapper($item);
+    if (!isset($wrapper->$field)) {
+      return NULL;
+    }
+    $id = $wrapper->$field->value();
+    return $id ? $id : NULL;
+  }
+
+  /**
+   * Get a human-readable label for an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either a human-readable label for the item, or NULL if none is available.
+   */
+  public function getItemLabel($item) {
+    $label = $this->getMetadataWrapper($item)->label();
+    return $label ? $label : NULL;
+  }
+
+  /**
+   * Get a URL at which the item can be viewed on the web.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either an array containing the 'path' and 'options' keys used to build
+   *   the URL of the item, and matching the signature of url(), or NULL if the
+   *   item has no URL of its own.
+   */
+  public function getItemUrl($item) {
+    return NULL;
+  }
+
+  /**
+   * Initialize tracking of the index status of items for the given indexes.
+   *
+   * All currently known items of this data source's type should be inserted
+   * into the tracking table for the given indexes, with status "changed". If
+   * items were already present, these should also be set to "changed" and not
+   * be inserted again.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be initialized.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function startTracking(array $indexes) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    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);
+    // Insert all items as new.
+    $this->trackItemInsert($this->getAllItemIds(), $indexes);
+  }
+
+  /**
+   * Helper method that can be used by subclasses instead of implementing startTracking().
+   *
+   * Returns the IDs of all items that are known for this controller's type.
+   *
+   * @return array
+   *   An array containing all item IDs for this type.
+   */
+  protected function getAllItemIds() {
+    throw new SearchApiDataSourceException(t('Items not known for type !type.', array('!type' => $this->type)));
+  }
+
+  /**
+   * Stop tracking of the index status of items for the given indexes.
+   *
+   * The tracking tables of the given indexes should be completely cleared.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be stopped.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function stopTracking(array $indexes) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    if (!$this->table) {
+      return;
+    }
+    // We could also use a single query with "IN" operator, but this method
+    // will mostly be called with only one index.
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+      $query = db_delete($this->table)
+        ->condition($this->indexIdColumn, $index->id)
+        ->execute();
+    }
+  }
+
+  /**
+   * Start tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of new items to track.
+   * @param array $indexes
+   *   The indexes for which items should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    if (!$this->table) {
+      return;
+    }
+    $insert = db_insert($this->table)
+      ->fields(array($this->itemIdColumn, $this->indexIdColumn, $this->changedColumn));
+    foreach ($item_ids as $item_id) {
+      foreach ($indexes as $index) {
+        $this->checkIndex($index);
+        $insert->values(array(
+          $this->itemIdColumn => $item_id,
+          $this->indexIdColumn => $index->id,
+          $this->changedColumn => 1,
+        ));
+      }
+    }
+    $insert->execute();
+  }
+
+  /**
+   * Set the tracking status of the given items to "changed"/"dirty".
+   *
+   * @param $item_ids
+   *   Either an array with the IDs of the changed items. Or FALSE to mark all
+   *   items as changed for the given indexes.
+   * @param array $indexes
+   *   The indexes for which the change should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemChange($item_ids, array $indexes) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    if (!$this->table) {
+      return;
+    }
+    $index_ids = array();
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+      $index_ids[] = $index->id;
+    }
+    $update = db_update($this->table)
+      ->fields(array(
+        $this->changedColumn => REQUEST_TIME,
+      ))
+      ->condition($this->indexIdColumn, $index_ids, 'IN')
+      ->condition($this->changedColumn, 0);
+    if (is_array($item_ids)) {
+      $update->condition($this->itemIdColumn, $item_ids, 'IN');
+    }
+    $update->execute();
+  }
+
+  /**
+   * Set the tracking status of the given items to "indexed".
+   *
+   * @param array $item_ids
+   *   The IDs of the indexed items.
+   * @param SearchApiIndex $indexes
+   *   The index on which the items were indexed.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
+    if (!$this->table) {
+      return;
+    }
+    $this->checkIndex($index);
+    db_update($this->table)
+      ->fields(array(
+        $this->changedColumn => 0,
+      ))
+      ->condition($this->itemIdColumn, $item_ids, 'IN')
+      ->condition($this->indexIdColumn, $index->id)
+      ->execute();
+  }
+
+  /**
+   * Stop tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of the removed items.
+   * @param array $indexes
+   *   The indexes for which the deletions should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    if (!$this->table) {
+      return;
+    }
+    $index_ids = array();
+    foreach ($indexes as $index) {
+      $this->checkIndex($index);
+      $index_ids[] = $index->id;
+    }
+    db_delete($this->table)
+      ->condition($this->itemIdColumn, $item_ids, 'IN')
+      ->condition($this->indexIdColumn, $index_ids, 'IN')
+      ->condition($this->changedColumn, 0)
+      ->execute();
+  }
+
+  /**
+   * Get a list of items that need to be indexed.
+   *
+   * If possible, completely unindexed items should be returned before items
+   * that were indexed but later changed. Also, items that were changed longer
+   * ago should be favored.
+   *
+   * @param SearchApiIndex $index
+   *   The index for which changed items should be returned.
+   * @param $limit
+   *   The maximum number of items to return. Negative values mean "unlimited".
+   *
+   * @return array
+   *   The IDs of items that need to be indexed for the given index.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1) {
+    if ($limit == 0) {
+      return array();
+    }
+    $this->checkIndex($index);
+    $select = db_select($this->table, 't');
+    $select->addField('t', 'item_id');
+    $select->condition($this->indexIdColumn, $index->id);
+    $select->condition($this->changedColumn, 0, '<>');
+    $select->orderBy($this->itemIdColumn, 'ASC');
+    if ($limit > 0) {
+      $select->range(0, $limit);
+    }
+    return $select->execute()->fetchCol();
+  }
+
+  /**
+   * Get information on how many items have been indexed for a certain index.
+   *
+   * @param SearchApiIndex $index
+   *   The index whose index status should be returned.
+   *
+   * @return array
+   *   An associative array containing two keys (in this order):
+   *   - indexed: The number of items already indexed in their latest version.
+   *   - total: The total number of items that have to be indexed for this
+   *     index.
+   */
+  public function getIndexStatus(SearchApiIndex $index) {
+    // Types that set "track index status" to FALSE should override this method
+    // in their data source controller and provide their own logic, if possible.
+    if (!$this->table) {
+      return array('indexed' => 0, 'total' => 0);
+    }
+    $this->checkIndex($index);
+    $indexed = db_select($this->table, 'i')
+      ->condition($this->indexIdColumn, $index->id)
+      ->condition($this->changedColumn, 0)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    $total = db_select($this->table, 'i')
+      ->condition($this->indexIdColumn, $index->id)
+      ->countQuery()
+      ->execute()
+      ->fetchField();
+    return array('indexed' => $indexed, 'total' => $total);
+  }
+
+  /**
+   * Helper method for ensuring that an index uses the same item type as this controller.
+   *
+   * @param SearchApiIndex $index
+   *   The index to check.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same type as this controller.
+   */
+  protected function checkIndex(SearchApiIndex $index) {
+    if ($index->item_type != $this->type) {
+      $index_type = search_api_get_item_type_info($index->item_type);
+      $index_type = empty($index_type['name']) ? $index->item_type : $index_type['name'];
+      $msg = t('Invalid index !index of type !index_type passed to data source controller for type !this_type.',
+          array('!index' => $index->name, '!index_type' => $index_type, '!this_type' => $this->info['name']));
+      throw new SearchApiDataSourceException($msg);
+    }
+  }
+
+}
diff --git a/includes/datasource_entity.inc b/includes/datasource_entity.inc
new file mode 100644
index 0000000..c3203dd
--- /dev/null
+++ b/includes/datasource_entity.inc
@@ -0,0 +1,200 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiEntityDataSourceController class.
+ */
+
+/**
+ * Data source for all entities known to the Entity API.
+ */
+class SearchApiEntityDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * Return information on the ID field for this controller's type.
+   *
+   * @return array
+   *   An associative array containing the following keys:
+   *   - key: The property key for the ID field, as used in the item wrapper.
+   *   - type: The type of the ID field. Has to be one of the types from
+   *     search_api_field_types(). List types ("list<*>") are not allowed.
+   */
+  public function getIdFieldInfo() {
+    $info = entity_get_info($this->type);
+    $properties = entity_get_property_info($this->type);
+    if (empty($info['entity keys']['id'])) {
+      throw new SearchApiDataSourceException(t("Entity type !type doesn't specify an ID key.",
+          array('!type' => $info['label'])));
+    }
+    $field = $info['entity keys']['id'];
+    if (empty($properties['properties'][$field]['type'])) {
+      throw new SearchApiDataSourceException(t("Entity type !type doesn't specify a type for the !prop property.",
+          array('!type' => $info['label'], '!prop' => $field)));
+    }
+    $type = $properties['properties'][$field]['type'];
+    if (search_api_is_list_type($type)) {
+      throw new SearchApiDataSourceException(t("Entity type !type uses list field !prop as its ID.",
+          array('!type' => $info['label'], '!prop' => $field)));
+    }
+    if ($type == 'token') {
+      $type = 'string';
+    }
+    return array(
+      'key' => $field,
+      'type' => $type,
+    );
+  }
+
+  /**
+   * Load items of the type of this data source controller.
+   *
+   * @param array $ids
+   *   The IDs of the items to laod.
+   *
+   * @return array
+   *   The loaded items, keyed by ID.
+   */
+  public function loadItems(array $ids) {
+    return entity_load($this->type, $ids);
+  }
+
+  /**
+   * Get a metadata wrapper for the item type of this data source controller.
+   *
+   * @param $item
+   *   Unless NULL, an item of the item type for this controller to be wrapped.
+   * @param array $info
+   *   Optionally, additional information that should be used for creating the
+   *   wrapper. Uses the same format as entity_metadata_wrapper().
+   *
+   * @return EntityMetadataWrapper
+   *   A wrapper for the item type of this data source controller, according to
+   *   the info array, and optionally loaded with the given data.
+   *
+   * @see entity_metadata_wrapper()
+   */
+  public function getMetadataWrapper($item = NULL, array $info = array()) {
+    return entity_metadata_wrapper($this->type, $item, $info);
+  }
+
+  /**
+   * Get the unique ID of an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either the unique ID of the item, or NULL if none is available.
+   */
+  public function getItemId($item) {
+    $id = entity_id($this->type, $item);
+    return $id ? $id : NULL;
+  }
+
+  /**
+   * Get a human-readable label for an item.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either a human-readable label for the item, or NULL if none is available.
+   */
+  public function getItemLabel($item) {
+    $label = entity_label($this->type, $item);
+    return $label ? $label : NULL;
+  }
+
+  /**
+   * Get a URL at which the item can be viewed on the web.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either an array containing the 'path' and 'options' keys used to build
+   *   the URL of the item, and matching the signature of url(), or NULL if the
+   *   item has no URL of its own.
+   */
+  public function getItemUrl($item) {
+    if ($this->type == 'file') {
+      return array(
+        'path' => file_create_url($item->uri),
+        'options' => array(
+          'entity_type' => 'file',
+          'entity' => $item,
+        ),
+      );
+    }
+    $url = entity_uri($this->type, $item);
+    return $url ? $url : NULL;
+  }
+
+  /**
+   * Initialize tracking of the index status of items for the given indexes.
+   *
+   * All currently known items of this data source's type should be inserted
+   * into the tracking table for the given indexes, with status "changed". If
+   * items were already present, these should also be set to "changed" and not
+   * be inserted again.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be initialized.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  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);
+
+    $entity_info = entity_get_info($this->type);
+
+    if (!empty($entity_info['base table'])) {
+      // Use a subselect, which will probably be much faster than entity_load().
+
+      // 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'];
+
+      // We could also use a single insert (with a JOIN in the nested query),
+      // but this method will be mostly called with a single index, anyways.
+      foreach ($indexes as $index) {
+        // Select all entity ids.
+        $query = db_select($table, 't');
+        $query->addField('t', $id_field, 'item_id');
+        $query->addExpression(':index_id', 'index_id', array(':index_id' => $index->id));
+        $query->addExpression('1', 'changed');
+
+        // INSERT ... SELECT ...
+        db_insert($this->table)
+          ->from($query)
+          ->execute();
+      }
+    }
+    else {
+      // In the absence of a 'base table', use the slow entity_load().
+      parent::startTracking($indexes);
+    }
+  }
+
+  /**
+   * Helper method that can be used by subclasses instead of implementing startTracking().
+   *
+   * Returns the IDs of all items that are known for this controller's type.
+   *
+   * Will be used when the entity type doesn't specify a "base table".
+   *
+   * @return array
+   *   An array containing all item IDs for this type.
+   */
+  protected function getAllItemIds() {
+    return array_keys(entity_load($this->type));
+  }
+
+}
diff --git a/includes/datasource_external.inc b/includes/datasource_external.inc
new file mode 100644
index 0000000..480466d
--- /dev/null
+++ b/includes/datasource_external.inc
@@ -0,0 +1,266 @@
+<?php
+
+/**
+ * @file
+ * Contains the SearchApiExternalDataSourceController class.
+ */
+
+/**
+ * Base class for data source controllers for external data sources.
+ *
+ * This data source controller is a base implementation for item types that
+ * represent external data, not directly accessible in Drupal. You can use this
+ * controller as a base class when you don't want to index items of the type via
+ * Drupal, but only want the search capabilities of the Search API. In addition
+ * you most probably also have to create a fitting service class for executing
+ * the actual searches.
+ *
+ * To use most of the functionality of the Search API and related modules, you
+ * will only have to specify some property information in getPropertyInfo(). If
+ * you have a custom service class which already returns the extracted fields
+ * with the search results, you will only have to provide a label and a type for
+ * each field. To make this use case easier, there is also a
+ * getFieldInformation() method which you can implement instead of directly
+ * implementing getPropertyInfo().
+ */
+class SearchApiExternalDataSourceController extends SearchApiAbstractDataSourceController {
+
+  /**
+   * Return information on the ID field for this controller's type.
+   *
+   * This implementation will return a field named "id" of type "string". This
+   * can also be used if the item type in question has no IDs.
+   *
+   * @return array
+   *   An associative array containing the following keys:
+   *   - key: The property key for the ID field, as used in the item wrapper.
+   *   - type: The type of the ID field. Has to be one of the types from
+   *     search_api_field_types(). List types ("list<*>") are not allowed.
+   */
+  public function getIdFieldInfo() {
+    return array(
+      'key' => 'id',
+      'type' => 'string',
+    );
+  }
+
+  /**
+   * Load items of the type of this data source controller.
+   *
+   * Always returns an empty array. If you want the items of your type to be
+   * loadable, specify a function here.
+   *
+   * @param array $ids
+   *   The IDs of the items to laod.
+   *
+   * @return array
+   *   The loaded items, keyed by ID.
+   */
+  public function loadItems(array $ids) {
+    return array();
+  }
+
+  /**
+   * Helper method that can be used by subclasses to specify the property
+   * information to use when creating a metadata wrapper.
+   *
+   * For most use cases, you will have to override this method to provide the
+   * real property information for your item type.
+   *
+   * @return array
+   *   Property information as specified by hook_entity_property_info().
+   *
+   * @see hook_entity_property_info()
+   */
+  protected function getPropertyInfo() {
+    $info['properties']['id'] = array(
+      'label' => t('ID'),
+      'type' => 'string',
+    );
+
+    return $info;
+  }
+
+  /**
+   * Get the unique ID of an item.
+   *
+   * Always returns 1.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either the unique ID of the item, or NULL if none is available.
+   */
+  public function getItemId($item) {
+    return 1;
+  }
+
+  /**
+   * Get a human-readable label for an item.
+   *
+   * Always returns NULL.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either a human-readable label for the item, or NULL if none is available.
+   */
+  public function getItemLabel($item) {
+    return NULL;
+  }
+
+  /**
+   * Get a URL at which the item can be viewed on the web.
+   *
+   * Always returns NULL.
+   *
+   * @param $item
+   *   An item of this controller's type.
+   *
+   * @return
+   *   Either an array containing the 'path' and 'options' keys used to build
+   *   the URL of the item, and matching the signature of url(), or NULL if the
+   *   item has no URL of its own.
+   */
+  public function getItemUrl($item) {
+    return NULL;
+  }
+
+  /**
+   * Initialize tracking of the index status of items for the given indexes.
+   *
+   * All currently known items of this data source's type should be inserted
+   * into the tracking table for the given indexes, with status "changed". If
+   * items were already present, these should also be set to "changed" and not
+   * be inserted again.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be initialized.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function startTracking(array $indexes) {
+    return;
+  }
+
+  /**
+   * Stop tracking of the index status of items for the given indexes.
+   *
+   * The tracking tables of the given indexes should be completely cleared.
+   *
+   * @param array $indexes
+   *   The SearchApiIndex objects for which item tracking should be stopped.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function stopTracking(array $indexes) {
+    return;
+  }
+
+  /**
+   * Start tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of new items to track.
+   * @param array $indexes
+   *   The indexes for which items should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemInsert(array $item_ids, array $indexes) {
+    return;
+  }
+
+  /**
+   * Set the tracking status of the given items to "changed"/"dirty".
+   *
+   * @param $item_ids
+   *   Either an array with the IDs of the changed items. Or FALSE to mark all
+   *   items as changed for the given indexes.
+   * @param array $indexes
+   *   The indexes for which the change should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemChange($item_ids, array $indexes) {
+    return;
+  }
+
+  /**
+   * Set the tracking status of the given items to "indexed".
+   *
+   * @param array $item_ids
+   *   The IDs of the indexed items.
+   * @param SearchApiIndex $indexes
+   *   The index on which the items were indexed.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function trackItemIndexed(array $item_ids, SearchApiIndex $index) {
+    return;
+  }
+
+  /**
+   * Stop tracking the index status for the given items on the given indexes.
+   *
+   * @param array $item_ids
+   *   The IDs of the removed items.
+   * @param array $indexes
+   *   The indexes for which the deletions should be tracked.
+   *
+   * @throws SearchApiDataSourceException
+   *   If any of the indexes doesn't use the same item type as this controller.
+   */
+  public function trackItemDelete(array $item_ids, array $indexes) {
+    return;
+  }
+
+  /**
+   * Get a list of items that need to be indexed.
+   *
+   * If possible, completely unindexed items should be returned before items
+   * that were indexed but later changed. Also, items that were changed longer
+   * ago should be favored.
+   *
+   * @param SearchApiIndex $index
+   *   The index for which changed items should be returned.
+   * @param $limit
+   *   The maximum number of items to return. Negative values mean "unlimited".
+   *
+   * @return array
+   *   The IDs of items that need to be indexed for the given index.
+   */
+  public function getChangedItems(SearchApiIndex $index, $limit = -1) {
+    return array();
+  }
+
+  /**
+   * Get information on how many items have been indexed for a certain index.
+   *
+   * @param SearchApiIndex $index
+   *   The index whose index status should be returned.
+   *
+   * @return array
+   *   An associative array containing two keys (in this order):
+   *   - indexed: The number of items already indexed in their latest version.
+   *   - total: The total number of items that have to be indexed for this
+   *     index.
+   *
+   * @throws SearchApiDataSourceException
+   *   If the index doesn't use the same item type as this controller.
+   */
+  public function getIndexStatus(SearchApiIndex $index) {
+    return array(
+      'indexed' => 0,
+      'total' => 0,
+    );
+  }
+
+}
diff --git a/includes/exception.inc b/includes/exception.inc
index 80e5c7e..4e7a0c8 100644
--- a/includes/exception.inc
+++ b/includes/exception.inc
@@ -20,3 +20,10 @@ class SearchApiException extends Exception {
   }
 
 }
+
+/**
+ * Represents an exception that occurred in a data source controller.
+ */
+class SearchApiDataSourceException extends SearchApiException {
+
+}
diff --git a/includes/index_entity.inc b/includes/index_entity.inc
index fd9ded5..08630ea 100644
--- a/includes/index_entity.inc
+++ b/includes/index_entity.inc
@@ -5,6 +5,16 @@
  */
 class SearchApiIndex extends Entity {
 
+  // Cache values, set when the corresponding methods are called for the first
+  // time.
+
+  /**
+   * Cached return value of datasource().
+   *
+   * @var SearchApiDataSourceControllerInterface
+   */
+  protected $datasource = NULL;
+
   /**
    * Cached return value of server().
    *
@@ -13,56 +23,82 @@ class SearchApiIndex extends Entity {
   protected $server_object = NULL;
 
   /**
+   * All enabled data alterations for this index.
+   *
    * @var array
    */
   protected $callbacks = NULL;
 
   /**
+   * All enabled processors for this index.
+   *
    * @var array
    */
   protected $processors = NULL;
 
   /**
+   * The properties added by data alterations on this index.
+   *
    * @var array
    */
   protected $added_properties = NULL;
 
   /**
+   * An array containing two arrays.
+   *
+   * At index 0, all fulltext fields of this index. At index 1, all indexed
+   * fulltext fields of this index.
+   *
    * @var array
    */
   protected $fulltext_fields = array();
 
-  // Database values that will be set when object is loaded
+  // Database values that will be set when object is loaded.
 
   /**
+   * An integer identifying the index.
+   * Immutable.
+   *
    * @var integer
    */
   public $id;
 
   /**
+   * A name to be displayed for the index.
+   *
    * @var string
    */
   public $name;
 
   /**
+   * The machine name of the index.
+   * Immutable.
+   *
    * @var string
    */
   public $machine_name;
 
   /**
+   * A string describing the index' use to users.
+   *
    * @var string
    */
   public $description;
 
   /**
+   * The machine_name of the server with which data should be indexed.
+   *
    * @var string
    */
   public $server;
 
   /**
+   * The type of items stored in this index.
+   * Immutable.
+   *
    * @var string
    */
-  public $entity_type;
+  public $item_type;
 
   /**
    * An array of options for configuring this index. The layout is as follows:
@@ -100,17 +136,21 @@ class SearchApiIndex extends Entity {
    *
    * @var array
    */
-  public $options;
+  public $options = array();
 
   /**
+   * A flag indicating whether this index is enabled.
+   *
    * @var integer
    */
-  public $enabled;
+  public $enabled = 1;
 
   /**
+   * A flag indicating whether to write to this index.
+   *
    * @var integer
    */
-  public $read_only;
+  public $read_only = 0;
 
   /**
    * Constructor as a helper to the parent constructor.
@@ -141,8 +181,7 @@ class SearchApiIndex extends Entity {
   }
 
   /**
-   * Execute necessary tasks when index is either deleted from the database or
-   * not defined in code anymore.
+   * Execute necessary tasks when the index is removed from the database.
    */
   public function postDelete() {
     if ($server = $this->server()) {
@@ -164,50 +203,8 @@ class SearchApiIndex extends Entity {
    * Record entities to index.
    */
   public function queueItems() {
-    $this->dequeueItems();
-
     if (!$this->read_only) {
-      $entity_info = entity_get_info($this->entity_type);
-
-      if (!empty($entity_info['base table'])) {
-        // Use a subselect, which will probably be much faster than entity_load().
-
-        // 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->addField('t', $id_field, 'item_id');
-        $query->addExpression(':index_id', 'index_id', array(':index_id' => $this->id));
-        $query->addExpression('1', 'changed');
-
-        // INSERT ... SELECT ...
-        db_insert('search_api_item')
-          ->from($query)
-          ->execute();
-      }
-      else {
-        // In the absence of a 'base table', use the slow entity_load().
-
-        // Get an array of all entities using entity_load().
-        $entities = entity_load($this->entity_type, FALSE);
-
-        $query = db_insert('search_api_item')
-          ->fields(array('item_id', 'index_id', 'changed'));
-
-        // Add each entity to the query.
-        foreach ($entities as $item_id => $entity) {
-          $query->values(array(
-            'item_id' => $item_id,
-            'index_id' => $this->id,
-            'changed' => 1,
-          ));
-        }
-
-        $query->execute();
-      }
+      $this->datasource()->startTracking(array($this));
     }
   }
 
@@ -215,9 +212,7 @@ class SearchApiIndex extends Entity {
    * Remove all records of entities to index.
    */
   public function dequeueItems() {
-    $query = db_delete('search_api_item')
-      ->condition('index_id', $this->id)
-      ->execute();
+    $this->datasource()->stopTracking(array($this));
   }
 
   /**
@@ -285,10 +280,8 @@ class SearchApiIndex extends Entity {
     if (!$this->server || $this->read_only) {
       return TRUE;
     }
-    $ret = _search_api_index_reindex($this->id);
-    if($ret) {
-      module_invoke_all('search_api_index_reindex', $this, FALSE);
-    }
+    _search_api_index_reindex($this);
+    module_invoke_all('search_api_index_reindex', $this, FALSE);
     return TRUE;
   }
 
@@ -310,19 +303,16 @@ class SearchApiIndex extends Entity {
     else {
       $tasks = variable_get('search_api_tasks', array());
       // If the index was cleared or newly added since the server was last enabled, we don't need to do anything.
-      if (!isset($tasks[$server->machine_name][$this->id])
-          || (array_search('add', $tasks[$server->machine_name][$this->id]) === FALSE
-              && array_search('clear', $tasks[$server->machine_name][$this->id]) === FALSE)) {
-        $tasks[$server->machine_name][$this->id][] = 'clear';
+      if (!isset($tasks[$server->machine_name][$this->machine_name])
+          || (array_search('add', $tasks[$server->machine_name][$this->machine_name]) === FALSE
+              && array_search('clear', $tasks[$server->machine_name][$this->machine_name]) === FALSE)) {
+        $tasks[$server->machine_name][$this->machine_name][] = 'clear';
         variable_set('search_api_tasks', $tasks);
       }
     }
 
-    $ret = _search_api_index_reindex($this->id);
-    if($ret) {
-      module_invoke_all('search_api_index_reindex', $this, TRUE);
-    }
-
+    _search_api_index_reindex($this);
+    module_invoke_all('search_api_index_reindex', $this, TRUE);
     return TRUE;
   }
 
@@ -336,11 +326,27 @@ class SearchApiIndex extends Entity {
    */
   public function __sleep() {
     $ret = get_object_vars($this);
-    unset($ret['server_object'], $ret['processors'], $ret['added_properties'], $ret['fulltext_fields']);
+    unset($ret['server_object'], $ret['datasource'], $ret['processors'], $ret['added_properties'], $ret['fulltext_fields']);
     return array_keys($ret);
   }
 
   /**
+   * Get the controller object of the data source used by this index.
+   *
+   * @throws SearchApiException
+   *   If the specified item type or data source doesn't exist or is invalid.
+   *
+   * @return SearchApiDataSourceControllerInterface
+   *   The data source controller for this index.
+   */
+  public function datasource() {
+    if (!isset($this->datasource)) {
+      $this->datasource = search_api_get_datasource_controller($this->item_type);
+    }
+    return $this->datasource;
+  }
+
+  /**
    * Get the server this index lies on.
    *
    * @param $reset
@@ -388,11 +394,11 @@ class SearchApiIndex extends Entity {
 
   /**
    * Indexes items on this index. Will return an array of IDs of items that
-   * should be marked as indexed – i.e. items that were either rejected by a
+   * should be marked as indexed – i.e., items that were either rejected by a
    * data-alter callback or were successfully indexed.
    *
    * @param array $items
-   *   An array of entities to index.
+   *   An array of items to index.
    *
    * @return array
    *   An array of the IDs of all items that should be marked as indexed.
@@ -687,15 +693,37 @@ class SearchApiIndex extends Entity {
    * Helper function for creating an entity metadata wrapper appropriate for
    * this index.
    *
+   * @param $item
+   *   Unless NULL, an item of this index's item type which should be wrapped.
+   * @param $alter
+   *   Whether to apply the index's active data alterations on the property
+   *   information used. To also apply the data alteration to the wrapped item,
+   *   execute SearchApiIndex::dataAlter() on it before calling this method.
+   *
    * @return EntityMetadataWrapper
-   *   A wrapper for the entity type of this index, optionally loaded with the
-   *   given data, and having fields according to the data alterations of this
-   *   index.
+   *   A wrapper for the item type of this index, optionally loaded with the
+   *   given data and having additional fields according to the data alterations
+   *   of this index.
    */
-  public function entityWrapper($item = NULL) {
-    $info['property info alter'] = array($this, 'propertyInfoAlter');
+  public function entityWrapper($item = NULL, $alter = TRUE) {
+    $info['property info alter'] = $alter ? array($this, 'propertyInfoAlter') : '_search_api_wrapper_add_all_properties';
     $info['property defaults']['property info alter'] = '_search_api_wrapper_add_all_properties';
-    return entity_metadata_wrapper($this->entity_type, $item, $info);
+    return $this->datasource()->getMetadataWrapper($item, $info);
+  }
+
+  /**
+   * Helper method to load items from the type lying on this index.
+   *
+   * @param array $ids
+   *   The IDs of the items to load.
+   *
+   * @return array
+   *   The requested items, as loaded by the data source.
+   *
+   * @see SearchApiDataSourceControllerInterface::loadItems()
+   */
+  public function loadItems(array $ids) {
+    return $this->datasource()->loadItems($ids);
   }
 
 }
diff --git a/includes/processor.inc b/includes/processor.inc
index 3b4985f..288e8f5 100644
--- a/includes/processor.inc
+++ b/includes/processor.inc
@@ -29,7 +29,7 @@ interface SearchApiProcessorInterface {
    *
    * This can be used for hiding the processor on the index's "Workflow" tab. To
    * avoid confusion, you should only use criteria that are immutable, such as
-   * the index's entity type. Also, since this is only used for UI purposes, you
+   * the index's item type. Also, since this is only used for UI purposes, you
    * should not completely rely on this to ensure certain index configurations
    * and at least throw an exception with a descriptive error message if this is
    * violated on runtime.
diff --git a/includes/query.inc b/includes/query.inc
index 0c3f9aa..958f303 100644
--- a/includes/query.inc
+++ b/includes/query.inc
@@ -127,7 +127,7 @@ interface SearchApiQueryInterface {
    *
    * @param $field
    *   The field to sort by. The special fields 'search_api_relevance' (sort by
-   *   relevance) and 'search_api_id' (sort by entity id) may be used.
+   *   relevance) and 'search_api_id' (sort by item id) may be used.
    * @param $order
    *   The order to sort items in - either 'ASC' or 'DESC'.
    *
@@ -170,10 +170,10 @@ interface SearchApiQueryInterface {
    *       already ready-to-use. This allows search engines (or postprocessors)
    *       to store extracted fields so other modules don't have to extract them
    *       again. This fields should always be checked by modules that want to
-   *       use field contents of the result entities.
-   *     - entity: (optional) If set, the fully loaded entity. This field should
-   *       always be used by modules using search results, to avoid duplicate
-   *       entity loads.
+   *       use field contents of the result items.
+   *     - entity: (optional) If set, the fully loaded result item. This field
+   *       should always be used by modules using search results, to avoid
+   *       duplicate item loads.
    *     - excerpt: (optional) If set, an HTML text containing highlighted
    *       portions of the fulltext that match the query.
    *   - warnings: A numeric array of translated warning messages that may be
@@ -571,7 +571,7 @@ class SearchApiQuery implements SearchApiQueryInterface {
    *
    * @param $field
    *   The field to sort by. The special fields 'search_api_relevance' (sort by
-   *   relevance) and 'search_api_id' (sort by entity id) may be used.
+   *   relevance) and 'search_api_id' (sort by item id) may be used.
    * @param $order
    *   The order to sort items in - either 'ASC' or 'DESC'.
    *
@@ -635,10 +635,10 @@ class SearchApiQuery implements SearchApiQueryInterface {
    *       already ready-to-use. This allows search engines (or postprocessors)
    *       to store extracted fields so other modules don't have to extract them
    *       again. This fields should always be checked by modules that want to
-   *       use field contents of the result entities.
-   *     - entity: (optional) If set, the fully loaded entity. This field should
-   *       always be used by modules using search results, to avoid duplicate
-   *       entity loads.
+   *       use field contents of the result items.
+   *     - entity: (optional) If set, the fully loaded result item. This field
+   *       should always be used by modules using search results, to avoid
+   *       duplicate item loads.
    *     - excerpt: (optional) If set, an HTML text containing highlighted
    *       portions of the fulltext that match the query.
    *   - warnings: A numeric array of translated warning messages that may be
diff --git a/includes/server_entity.inc b/includes/server_entity.inc
index d52c8b5..6db4229 100644
--- a/includes/server_entity.inc
+++ b/includes/server_entity.inc
@@ -11,36 +11,50 @@ class SearchApiServer extends Entity {
   /* Database values that will be set when object is loaded: */
 
   /**
+   * The primary identifier for a server.
+   *
    * @var integer
    */
   public $id = 0;
 
   /**
+   * The displayed name for a server.
+   *
    * @var string
    */
   public $name = '';
 
   /**
+   * The machine name for a server.
+   *
    * @var string
    */
   public $machine_name = '';
 
   /**
+   * The displayed description for a server.
+   *
    * @var string
    */
   public $description = '';
 
   /**
+   * The id of the service class to use for this server.
+   *
    * @var string
    */
   public $class = '';
 
   /**
+   * The options used to configure the service object.
+   *
    * @var array
    */
   public $options = array();
 
   /**
+   * A flag indicating whether the server is enabled.
+   *
    * @var integer
    */
   public $enabled = 1;
diff --git a/includes/service.inc b/includes/service.inc
index ae7d494..9736d07 100644
--- a/includes/service.inc
+++ b/includes/service.inc
@@ -155,8 +155,8 @@ interface SearchApiServiceInterface {
    *   array with the following keys:
    *   - type: One of the data types recognized by the Search API, or the
    *     special type "tokens" for fulltext fields.
-   *   - original_type: The original type of the property as defined through a
-   *     hook_entity_property_info().
+   *   - original_type: The original type of the property, as defined by the
+   *     datasource controller for the index's item type.
    *   - value: The value to index.
    *
    *   The special field "search_api_language" contains the item's language and
diff --git a/search_api.admin.inc b/search_api.admin.inc
index 0d298a2..14bb0ca 100644
--- a/search_api.admin.inc
+++ b/search_api.admin.inc
@@ -508,6 +508,7 @@ function search_api_admin_add_index(array $form, array &$form_state) {
   drupal_set_title(t('Add index'));
 
   $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+  $form['#tree'] = TRUE;
   $form['name'] = array(
     '#type' => 'textfield',
     '#title' => t('Index name'),
@@ -523,18 +524,16 @@ function search_api_admin_add_index(array $form, array &$form_state) {
     ),
   );
 
-  $form['entity_type'] = array(
+  $form['item_type'] = array(
     '#type' => 'select',
-    '#title' => t('Entity type'),
-    '#description' => t('Select the type of entity that will be indexed in this index. ' .
+    '#title' => t('Item type'),
+    '#description' => t('Select the type of items that will be indexed in this index. ' .
         'This setting cannot be changed afterwards.'),
     '#options' => array(),
     '#required' => TRUE,
   );
-  foreach (entity_get_info() as $name => $entity) {
-    if (entity_get_property_info($name)) {
-      $form['entity_type']['#options'][$name] = $entity['label'];
-    }
+  foreach (search_api_get_item_type_info() as $type => $info) {
+    $form['item_type']['#options'][$type] = $info['name'];
   }
   $form['enabled'] = array(
     '#type' => 'checkbox',
@@ -565,14 +564,14 @@ function search_api_admin_add_index(array $form, array &$form_state) {
       $form['server']['#options'][$server->machine_name] = t('@server_name (disabled)', array('@server_name' => $server->name));
     }
   }
-  $form['index_directly'] = array(
+  $form['options']['index_directly'] = array(
     '#type' => 'checkbox',
     '#title' => t('Index items immediately'),
     '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
         'This might have serious performance drawbacks and is generally not advised for larger sites.'),
     '#default_value' => FALSE,
   );
-  $form['cron_limit'] = array(
+  $form['options']['cron_limit'] = array(
     '#type' => 'textfield',
     '#title' => t('Cron limit'),
     '#description' => t('Set how many items will be indexed at most during each run of cron. ' .
@@ -599,10 +598,10 @@ function search_api_admin_add_index_validate(array $form, array &$form_state) {
     form_set_error('machine_name', t('The machine name must not be a pure number.'));
   }
 
-  $cron_limit = $form_state['values']['cron_limit'];
+  $cron_limit = $form_state['values']['options']['cron_limit'];
   if ($cron_limit != '' . ((int) $cron_limit)) {
     // We don't enforce stricter rules and treat all negative values as -1.
-    form_set_error('cron_limit', t('The cron limit must be an integer.'));
+    form_set_error('options[cron_limit]', t('The cron limit must be an integer.'));
   }
 }
 
@@ -613,14 +612,10 @@ function search_api_admin_add_index_submit(array $form, array &$form_state) {
   form_state_values_clean($form_state);
 
   $values = $form_state['values'];
-  $values['options'] = array(
-    'cron_limit' => $values['cron_limit'],
-    'index_directly' => $values['index_directly'],
-  );
-  unset($values['cron_limit']);
 
-  // Trying to create an enabled index on a disabled server is handled elsewhere
-  $id = search_api_index_insert($values);
+  // Validation of whether the server of an enabled index is also enabled is
+  // done in the *_insert() function.
+  search_api_index_insert($values);
 
   drupal_set_message(t('The index was successfully created. Please set up its indexed fields now.'));
   $form_state['redirect'] = 'admin/config/search/search_api/index/' . $values['machine_name'] . '/fields';
@@ -661,7 +656,7 @@ function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NUL
     '#name' => $index->name,
     '#machine_name' => $index->machine_name,
     '#description' => $index->description,
-    '#entity_type' => $index->entity_type,
+    '#item_type' => $index->item_type,
     '#enabled' => $index->enabled,
     '#server' => $index->server(),
     '#options' => $index->options,
@@ -681,7 +676,7 @@ function search_api_admin_index_view(SearchApiIndex $index = NULL, $action = NUL
  *   - name: The index' name.
  *   - machine_name: The index' machine name.
  *   - description: The index' description.
- *   - entity_type: The type of entities stored in this index.
+ *   - item_type: The type of items stored in this index.
  *   - enabled: Boolean indicating whether the index is enabled.
  *   - server: The server this index currently rests on, if any.
  *   - options: The index' options, like cron limit.
@@ -717,9 +712,9 @@ function theme_search_api_index(array $variables) {
   $output .= '<dt>' . t('Machine name') . '</dt>' . "\n";
   $output .= '<dd>' . check_plain($machine_name) . '</dd>' . "\n";
 
-  $output .= '<dt>' . t('Entity type') . '</dt>' . "\n";
-  $type = entity_get_info($entity_type);
-  $type = $type['label'];
+  $output .= '<dt>' . t('Item type') . '</dt>' . "\n";
+  $type = search_api_get_item_type_info($item_type);
+  $type = $type['name'];
   $output .= '<dd>' . check_plain($type) . '</dd>' . "\n";
 
   if (!empty($description)) {
@@ -989,6 +984,7 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
   $form_state['index'] = $index;
 
   $form['#attached']['css'][] = drupal_get_path('module', 'search_api') . '/search_api.admin.css';
+  $form['#tree'] = TRUE;
   $form['name'] = array(
     '#type' => 'textfield',
     '#title' => t('Index name'),
@@ -1033,7 +1029,7 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
     '#description' => t('Do not write to this index or track ids of entities in this index.'),
     '#default_value' => $index->read_only,
   );
-  $form['index_directly'] = array(
+  $form['options']['index_directly'] = array(
     '#type' => 'checkbox',
     '#title' => t('Index items immediately'),
     '#description' => t('Immediately index new or updated items instead of waiting for the next cron run. ' .
@@ -1043,7 +1039,7 @@ function search_api_admin_index_edit(array $form, array &$form_state, SearchApiI
       'invisible' => array(':input[name="read_only"]' => array('checked' => TRUE)),
     ),
   );
-  $form['cron_limit'] = array(
+  $form['options']['cron_limit'] = array(
     '#type' => 'textfield',
     '#title' => t('Cron limit'),
     '#description' => t('Set how many items will be indexed at most during each run of cron. ' .
@@ -1073,11 +1069,7 @@ function search_api_admin_index_edit_submit(array $form, array &$form_state) {
 
   $values = $form_state['values'];
   $index = $form_state['index'];
-  $values['options'] = $index->options;
-  $values['options']['cron_limit'] = $values['cron_limit'];
-  unset($values['cron_limit']);
-  $values['options']['index_directly'] = $values['index_directly'];
-  unset($values['index_directly']);
+  $values['options'] += $index->options;
 
   $ret = $index->update($values);
   $form_state['redirect'] = 'admin/config/search/search_api/index/' . $index->machine_name;
@@ -1467,7 +1459,7 @@ function search_api_admin_index_fields(array $form, array &$form_state, SearchAp
   $form['description'] = array(
     '#type' => 'item',
     '#title' => t('Select fields to index'),
-    '#description' => t('<p>The datatype of fields determines, how they can be searched or filtered upon. ' .
+    '#description' => t('<p>The datatype of a field determines how it can be used for searching and filtering. ' .
         'The boost is used to give additional weight to certain fields, e.g. titles or tags. It only takes effect for fulltext fields.</p>' .
         '<p>Whether detailled field types are supported depends on the type of server this index resides on. ' .
         'In any case, fields of type "Fulltext" will always be fulltext-searchable.</p>'),
diff --git a/search_api.api.php b/search_api.api.php
index 9cd3608..7339c57 100644
--- a/search_api.api.php
+++ b/search_api.api.php
@@ -65,6 +65,79 @@ function hook_search_api_service_info_alter(array &$service_info) {
 }
 
 /**
+ * Define new types of items that can be searched.
+ *
+ * This hook allows modules to define their own item types, for which indexes
+ * can then be created. (Note that the Search API natively provides support for
+ * all entity types that specify property information, so they should not be
+ * added here. You should therefore also not use an existing entity type as the
+ * identifier of a new item type.)
+ *
+ * The main part of defining a new item type is implementing its data source
+ * controller class, which is responsible for loading items, providing metadata
+ * and tracking existing items. The module defining a certain item type is also
+ * responsible for observing creations, updates and deletions of items of that
+ * type and notifying the Search API of them by calling
+ * search_api_track_item_insert(), search_api_track_item_change() and
+ * search_api_track_item_delete(), as appropriate.
+ * The only other restriction for item types is that they have to have a single
+ * item ID field, with a scalar value. This is, e.g., used to track indexed
+ * items.
+ *
+ * Note, however, that you can also define item types where some of these
+ * conditions are not met, as long as you are aware that some functionality of
+ * the Search API and related modules might then not be available for that type.
+ *
+ * @return array
+ *   An associative array keyed by item type identifier, and containing type
+ *   information arrays with at least the following keys:
+ *   - name: A human-readable name for the type.
+ *   - datasource controller: A class implementing the
+ *     SearchApiDataSourceControllerInterface interface which will be used as
+ *     the data source controller for this type.
+ *   Other, datasource-specific settings might also be placed here. These should
+ *   be specified with the data source controller in question.
+ *
+ * @see hook_search_api_item_type_info_alter()
+ */
+function hook_search_api_item_type_info() {
+  // Copied from 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',
+      );
+    }
+  }
+
+  return $types;
+}
+
+/**
+ * Alter the item type info.
+ *
+ * Modules may implement this hook to alter the information that defines an
+ * item type. All properties that are available in
+ * hook_search_api_item_type_info() can be altered here.
+ *
+ * @param array $infos
+ *   The item type info array, keyed by type identifier.
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function hook_search_api_item_type_info_alter(array &$infos) {
+  hook_entity_info_alter();
+  // Adds a boolean value is_entity to all type options telling whether the item
+  // type represents an entity type.
+  foreach ($infos as $type => $info) {
+    $info['is_entity'] = (bool) entity_get_info($type);
+  }
+}
+
+/**
  * Registers one or more callbacks that can be called at index time to add
  * additional data to the indexed items (e.g. comments or attachments to nodes),
  * alter the data in other forms or remove items from the array.
diff --git a/search_api.drush.inc b/search_api.drush.inc
index b6d9858..dfc5304 100644
--- a/search_api.drush.inc
+++ b/search_api.drush.inc
@@ -103,17 +103,21 @@ function drush_search_api_list() {
     dt('Index'),
     dt('Server'),
     dt('Type'),
-    dt('Enabled'),
+    dt('Status'),
     dt('Limit'),
   );
   foreach ($indexes as $index) {
+    $type = search_api_get_item_type_info($index->item_type);
+    $type = isset($type['name']) ? $type['name'] : $index->item_type;
+    $server = $index->server();
+    $server = $server ? $server->name : '(' . t('none') . ')';
     $row = array(
       $index->id,
       $index->name,
       $index->machine_name,
-      $index->server,
-      $index->entity_type,
-      $index->enabled,
+      $server,
+      $type,
+      $index->enabled ? t('enabled') : t('disabled'),
       $index->options['cron_limit'],
     );
     $rows[] = $row;
diff --git a/search_api.info b/search_api.info
index 440825b..dd5d70b 100644
--- a/search_api.info
+++ b/search_api.info
@@ -11,6 +11,9 @@ files[] = includes/callback_add_aggregation.inc
 files[] = includes/callback_add_url.inc
 files[] = includes/callback_add_viewed_entity.inc
 files[] = includes/callback_bundle_filter.inc
+files[] = includes/datasource.inc
+files[] = includes/datasource_entity.inc
+files[] = includes/datasource_external.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 e4ad0f2..285bd7f 100644
--- a/search_api.install
+++ b/search_api.install
@@ -109,8 +109,8 @@ function search_api_schema() {
         'length' => 50,
         'not null' => FALSE,
       ),
-      'entity_type' => array(
-        'description' => 'The entity type of items stored in this index.',
+      'item_type' => array(
+        'description' => 'The type of items stored in this index.',
         'type' => 'varchar',
         'length' => 50,
         'not null' => TRUE,
@@ -150,7 +150,7 @@ function search_api_schema() {
       ),
     ),
     'indexes' => array(
-      'entity_type' => array('entity_type'),
+      'item_type' => array('item_type'),
       'server' => array('server'),
       'enabled' => array('enabled'),
     ),
@@ -210,7 +210,7 @@ function search_api_install() {
     'machine_name' => preg_replace('/[^a-z0-9]+/', '_', drupal_strtolower($name)),
     'description' => t('An automatically created search index for indexing node data. Might be configured to specific needs.'),
     'server' => NULL,
-    'entity_type' => 'node',
+    'item_type' => 'node',
     'options' => array(
       'cron_limit' => '50',
       'data_alter_callbacks' => array(
@@ -816,3 +816,24 @@ function search_api_update_7108() {
 function search_api_update_7109() {
   cache_clear_all('entity_info:', 'cache', TRUE);
 }
+
+/**
+ * Rename the "entity_type" field to "item_type" in the {search_api_index} table.
+ */
+function search_api_update_7110() {
+  $table = 'search_api_index';
+  // This index isn't used anymore.
+  db_drop_index($table, 'entity_type');
+  // Rename the "item_type" field (and change the description).
+  $item_type = array(
+    'description' => 'The type of items stored in this index.',
+    'type' => 'varchar',
+    'length' => 50,
+    'not null' => TRUE,
+  );
+  // Also add the new "item_type" index, while we're at it.
+  $keys_new['indexes']['item_type'] = array('item_type');
+  db_change_field($table, 'entity_type', 'item_type', $item_type, $keys_new);
+  // Clear entity info caches.
+  cache_clear_all('*', 'cache', TRUE);
+}
diff --git a/search_api.module b/search_api.module
index 4dbbdcc..102fc99 100644
--- a/search_api.module
+++ b/search_api.module
@@ -177,7 +177,7 @@ function search_api_theme() {
       'name' => '',
       'machine_name' => '',
       'description' => NULL,
-      'entity_type' => NULL,
+      'item_type' => NULL,
       'enabled' => NULL,
       'server' => NULL,
       'options' => array(),
@@ -283,35 +283,42 @@ function search_api_entity_property_info() {
       'label' => t('ID'),
       'type' => 'integer',
       'description' => t('The primary identifier for a server.'),
+      'schema field' => 'id',
+      'validation callback' => 'entity_metadata_validate_integer_positive',
     ),
     'name' => array(
       'label' => t('Name'),
       'type' => 'text',
       'description' => t('The displayed name for a server.'),
+      'schema field' => 'name',
       'required' => TRUE,
     ),
     'machine_name' => array(
       'label' => t('Machine name'),
       'type' => 'token',
       'description' => t('The internally used machine name for a server.'),
+      'schema field' => 'machine_name',
       'required' => TRUE,
     ),
     'description' => array(
       'label' => t('Description'),
       'type' => 'text',
       'description' => t('The displayed description for a server.'),
+      'schema field' => 'description',
       'sanitize' => 'filter_xss',
     ),
     'class' => array(
       'label' => t('Service class'),
       'type' => 'text',
       'description' => t('The ID of the service class to use for this server.'),
+      'schema field' => 'class',
       'required' => TRUE,
     ),
     'enabled' => array(
       'label' => t('Enabled'),
       'type' => 'boolean',
       'description' => t('A flag indicating whether the server is enabled.'),
+      'schema field' => 'enabled',
     ),
   );
   $info['search_api_index']['properties'] = array(
@@ -319,29 +326,35 @@ function search_api_entity_property_info() {
       'label' => t('ID'),
       'type' => 'integer',
       'description' => t('An integer identifying the index.'),
+      'schema field' => 'id',
+      'validation callback' => 'entity_metadata_validate_integer_positive',
     ),
     'name' => array(
       'label' => t('Name'),
       'type' => 'text',
       'description' => t('A name to be displayed for the index.'),
+      'schema field' => 'name',
       'required' => TRUE,
     ),
     'machine_name' => array(
       'label' => t('Machine name'),
       'type' => 'token',
       'description' => t('The internally used machine name for an index.'),
+      'schema field' => 'machine_name',
       'required' => TRUE,
     ),
     'description' => array(
       'label' => t('Description'),
       'type' => 'text',
       'description' => t("A string describing the index' use to users."),
+      'schema field' => 'description',
       'sanitize' => 'filter_xss',
     ),
     'server' => array(
       'label' => t('Server ID'),
       'type' => 'token',
       'description' => t('The machine name of the search_api_server with which data should be indexed.'),
+      'schema field' => 'server',
     ),
     'server_entity' => array(
       'label' => t('Server'),
@@ -349,21 +362,24 @@ function search_api_entity_property_info() {
       'description' => t('The search_api_server with which data should be indexed.'),
       'getter callback' => 'search_api_index_get_server',
     ),
-    'entity_type' => array(
-      'label' => t('Entity type'),
-      'type' => 'text',
-      'description' => t('The entity type of items stored in this index.'),
+    'item_type' => array(
+      'label' => t('Item type'),
+      'type' => 'token',
+      'description' => t('The type of items stored in this index.'),
+      'schema field' => 'item_type',
       'required' => TRUE,
     ),
     'enabled' => array(
       'label' => t('Enabled'),
       'type' => 'boolean',
       'description' => t('A flag indicating whether the index is enabled.'),
+      'schema field' => 'enabled',
     ),
     'read_only' => array(
       'label' => t('Read only'),
       'type' => 'boolean',
       'description' => t('A flag indicating whether the index is read-only.'),
+      'schema field' => 'read_only',
     ),
   );
 
@@ -412,7 +428,7 @@ function search_api_search_api_server_update(SearchApiServer $server) {
                 break;
               case 'fields':
                 if ($server->fieldsUpdated($index)) {
-                  _search_api_index_reindex($index->id);
+                  _search_api_index_reindex($index);
                 }
                 break;
               case 'remove':
@@ -509,7 +525,7 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
     }
 
     // We also have to re-index all content
-    _search_api_index_reindex($index->id);
+    _search_api_index_reindex($index);
   }
 
   $old_fields = $index->original->options + array('fields' => array());
@@ -518,12 +534,12 @@ function search_api_search_api_index_update(SearchApiIndex $index) {
   $new_fields = $new_fields['fields'];
   if ($old_fields != $new_fields) {
     if ($index->server && $index->server()->fieldsUpdated($index)) {
-      _search_api_index_reindex($index->id);
+      _search_api_index_reindex($index);
     }
   }
 
   // If the index's enabled or read-only status is being changed, queue or
-  // dequeue entities for indexing.
+  // dequeue items for indexing.
   if (!$index->read_only && $index->enabled != $index->original->enabled) {
     if ($index->enabled) {
       $index->queueItems();
@@ -563,31 +579,15 @@ function search_api_search_api_index_delete(SearchApiIndex $index) {
  *   The entity's type.
  */
 function search_api_entity_insert($entity, $type) {
-  if ($type != 'search_api_index') {
-    // When inserting a new search index, the new index was already inserted into search_api_item.
-    // This would lead to a duplicate-key issue, if we would continue.
-    $info = entity_get_info($type);
-    $id = $info['entity keys']['id'];
-    $id = $entity->$id;
-
-    $query = db_select('search_api_index', 'i')
-      ->condition('entity_type', $type)
-      ->condition('enabled', 1)
-      ->condition('read_only', 0);
-    $query->addField('i', 'id', 'index_id');
-    $query->addExpression(':item_id', 'item_id', array(':item_id' => $id));
-    $query->addExpression(':changed', 'changed', array(':changed' => 1));
-
-    db_insert('search_api_item')
-      ->from($query)
-      ->execute();
+  // When inserting a new search index, the new index was already inserted into
+  // search_api_item. This would lead to a duplicate-key issue, if we would
+  // continue.
+  if ($type == 'search_api_index') {
+    return;
   }
-
-  foreach (search_api_index_load_multiple(FALSE, array('enabled' => 1, 'entity_type' => $type, 'read_only' => 0)) as $index) {
-    if (!empty($index->options['index_directly'])) {
-      $item = clone $entity;
-      search_api_index_specific_items($index, array($id => $item));
-    }
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_insert($type, array($id));
   }
 }
 
@@ -602,17 +602,16 @@ function search_api_entity_insert($entity, $type) {
  *   The entity's type.
  */
 function search_api_entity_update($entity, $type) {
-  $info = entity_get_info($type);
-  $id = $info['entity keys']['id'];
-  $id = $entity->$id;
-
-  search_api_mark_dirty($type, array($id));
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_change($type, array($id));
+  }
 }
 
 /**
  * Implements hook_entity_delete().
  *
- * Removes the item from {search_api_item} and deletes it from all indexes.
+ * Removes the item from the tracking table and deletes it from all indexes.
  *
  * @param $entity
  *   The updated entity.
@@ -620,26 +619,30 @@ function search_api_entity_update($entity, $type) {
  *   The entity's type.
  */
 function search_api_entity_delete($entity, $type) {
-  $info = entity_get_info($type);
-  $id_field = $info['entity keys']['id'];
-  $id = $entity->$id_field;
-  foreach (search_api_index_load_multiple(FALSE, array('entity_type' => $type, 'read_only' => 0)) as $index) {
-    db_delete('search_api_item')
-      ->condition('item_id', $id)
-      ->condition('index_id', $index->id)
-      ->execute();
-    if ($index->server) {
-      $server = $index->server();
-      if ($server->enabled) {
-        $server->deleteItems(array($id), $index);
-      }
-      else {
-        $tasks = variable_get('search_api_tasks', array());
-        $tasks[$server->machine_name][$index->machine_name][] = 'delete-' . $id;
-        variable_set('search_api_tasks', $tasks);
-      }
+  list($id) = entity_extract_ids($type, $entity);
+  if (isset($id)) {
+    search_api_track_item_delete($type, array($id));
+  }
+}
+
+/**
+ * Implements hook_search_api_item_type_info().
+ *
+ * Adds item types for all entity types with property information.
+ */
+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',
+      );
     }
   }
+
+  return $types;
 }
 
 /**
@@ -707,49 +710,108 @@ function search_api_search_api_processor_info() {
 }
 
 /**
- * Mark the entities with the specified IDs as "dirty", i.e., as needing to be reindexed.
+ * Inserts new unindexed items for all indexes on the specified type.
+ *
+ * @param $type
+ *   The item type of the new items.
+ * @param array $item_id
+ *   The IDs of the new items.
+ */
+function search_api_track_item_insert($type, array $item_ids) {
+  $datasource = search_api_get_datasource_controller($type);
+
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  $indexes = search_api_index_load_multiple(FALSE, $conditions);
+  if (!$indexes) {
+    return;
+  }
+  $datasource->trackItemInsert($item_ids, $indexes);
+  foreach ($indexes as $index) {
+    if (!empty($index->options['index_directly'])) {
+      $indexed = search_api_index_specific_items($index, $item_ids);
+    }
+  }
+}
+
+/**
+ * Mark the items with the specified IDs as "dirty", i.e., as needing to be reindexed.
  *
  * For indexes for which items should be indexed immediately, the items are
  * indexed directly, instead.
  *
- * @param $entity_type
- *   The type of entity, e.g., 'node'.
- * @param array $ids
- *   The entity IDs of the entities to be marked dirty.
+ * @param $type
+ *   The type of items, specific to the data source.
+ * @param array $item_ids
+ *   The IDs of the items to be marked dirty.
  */
-function search_api_mark_dirty($entity_type, array $ids) {
-  $index_ids = array();
-  foreach (search_api_index_load_multiple(FALSE, array('enabled' => 1, 'entity_type' => $entity_type, 'read_only' => 0)) as $index) {
+function search_api_track_item_change($type, array $item_ids) {
+  $indexes = array();
+  $datasource = search_api_get_datasource_controller($type);
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  foreach (search_api_index_load_multiple(FALSE, $conditions) as $index) {
     if (empty($index->options['index_directly'])) {
-      $index_ids[] = $index->id;
+      $indexes[] = $index;
     }
     else {
-      // For indexes with the index_directly set, index the items right away.
-      $items = entity_load($entity_type, $ids, array(), TRUE);
-      $indexed = search_api_index_specific_items($index, $items);
-      if (count($indexed) < count($ids)) {
+      // For indexes with the index_directly option set, index the items right
+      // away.
+      $indexed = array();
+      try {
+        $indexed = search_api_index_specific_items($index, $item_ids);
+      }
+      catch (SearchApiException $e) {
+        watchdog('search_api', $e->getMessage(), NULL, WATCHDOG_ERROR);
+      }
+      if (count($indexed) < count($item_ids)) {
         // If indexing failed for some items, mark those as dirty.
-        $diff = array_diff($ids, $indexed);
-        db_update('search_api_item')
-          ->fields(array(
-            'changed' => REQUEST_TIME,
-          ))
-          ->condition('item_id', $ids, 'IN')
-          ->condition('index_id', $index->id)
-          ->condition('changed', 0)
-          ->execute();
+        $diff = array_diff($item_ids, $indexed);
+        $datasource->trackItemChange($diff, array($index));
       }
     }
   }
-  if ($index_ids) {
-    db_update('search_api_item')
-      ->fields(array(
-        'changed' => REQUEST_TIME,
-      ))
-      ->condition('item_id', $ids, 'IN')
-      ->condition('index_id', $index_ids, 'IN')
-      ->condition('changed', 0)
-      ->execute();
+  if ($indexes) {
+    $datasource->trackItemChange($item_ids, $indexes);
+  }
+}
+
+/**
+ * Marks items as successfully indexed for the specified index.
+ *
+ * @param SearchApiIndex $index
+ *   The index on which items were indexed.
+ * @param array $item_ids
+ *   The ids of the indexed items.
+ */
+function search_api_track_item_indexed(SearchApiIndex $index, array $item_ids) {
+  $index->datasource()->trackItemIndexed($item_ids, $index);
+}
+
+/**
+ * Removes items from all indexes.
+ *
+ * @param $type
+ *   The type of the items.
+ * @param array $item_ids
+ *   The IDs of the deleted items.
+ */
+function search_api_track_item_delete($type, array $item_ids) {
+  $datasource = search_api_get_datasource_controller($type);
+  $conditions = array(
+    'enabled' => 1,
+    'item_type' => $type,
+    'read_only' => 0,
+  );
+  $indexes = search_api_index_load_multiple(FALSE, $conditions);
+  if ($indexes) {
+    $datasource->trackItemDelete($item_ids, $indexes);
   }
 }
 
@@ -763,60 +825,52 @@ function search_api_mark_dirty($entity_type, array $ids) {
  *   The number of items which should be indexed at most. -1 means no limit.
  *
  * @throws SearchApiException
- *   If the index' entity type is unknown or another error occurs during
- *   indexing.
+ *   If any error occurs during indexing.
  *
  * @return
  *   Number of successfully indexed items.
  */
 function search_api_index_items(SearchApiIndex $index, $limit = -1) {
-  // Safety check if entity type is known (prevent failing of whole cron run/page request)
-  if (!entity_get_info($index->entity_type)) {
-    throw new SearchApiException(t("Couldn't index values for '!name' index (unknown entity type '!type')", array('!name' => $index->name, '!type' => $index->entity_type)));
-  }
-
   // Don't try to index read-only indexes.
   if ($index->read_only) {
     return 0;
   }
 
-  $items = search_api_get_items_to_index($index, $limit);
-  if (!$items) {
+  $ids = search_api_get_items_to_index($index, $limit);
+  if (!$ids) {
     return 0;
   }
 
-  return count(search_api_index_specific_items($index, $items));
+  return count(search_api_index_specific_items($index, $ids));
 }
 
 /**
- * Indexes the given items on the specified index.
+ * Indexes the specified items on the given index.
  *
  * Items which were successfully indexed are marked as such afterwards.
  *
  * @param SearchApiIndex $index
  *   The index on which items should be indexed.
- * @param array $items
- *   The items which should be indexed. Have to be entities of the appropriate
- *   type.
+ * @param array $ids
+ *   The IDs of the items which should be indexed.
  *
  * @throws SearchApiException
- *   If the index' entity type is unknown or another error occurs during
- *   indexing.
+ *   If any error occurs during indexing.
  *
  * @return
  *   The IDs of all successfully indexed items.
  */
-function search_api_index_specific_items(SearchApiIndex $index, array $items) {
+function search_api_index_specific_items(SearchApiIndex $index, array $ids) {
+  $items = $index->loadItems($ids);
   $indexed = $index->index($items);
-  if (!empty($indexed)) {
-    search_api_set_items_indexed($index, $indexed);
+  if ($indexed) {
+    search_api_track_item_indexed($index, $indexed);
   }
   return $indexed;
 }
 
 /**
- * Returns a list of at most $limit items that need to be indexed for the
- * specified index.
+ * Returns a list of items that need to be indexed for the specified index.
  *
  * @param SearchApiIndex $index
  *   The index for which items should be retrieved.
@@ -824,44 +878,13 @@ function search_api_index_specific_items(SearchApiIndex $index, array $items) {
  *   The maximum number of items to retrieve. -1 means no limit.
  *
  * @return array
- *   An array of items (entities) that need to be indexed.
+ *   An array of IDs of items that need to be indexed.
  */
 function search_api_get_items_to_index(SearchApiIndex $index, $limit = -1) {
   if ($limit == 0) {
     return array();
   }
-  $select = db_select('search_api_item', 'i');
-  $select->addField('i', 'item_id');
-  $select->condition('index_id', $index->id);
-  $select->condition('changed', 0, '<>');
-  $select->orderBy('changed', 'ASC');
-  if ($limit > 0) {
-    $select->range(0, $limit);
-  }
-
-  $ids = $select->execute()->fetchCol();
-  return entity_load($index->entity_type, $ids, array(), TRUE);
-}
-
-/**
- * Marks the items as successfully indexed for the specified index.
- *
- * @param SearchApiIndex $index
- *   The index on which items were indexed.
- * @param array $ids
- *   The ids of the indexed items.
- *
- * @return
- *   The number of index entries changed.
- */
-function search_api_set_items_indexed(SearchApiIndex $index, array $ids) {
-  return db_update('search_api_item')
-    ->fields(array(
-      'changed' => 0,
-    ))
-    ->condition('index_id', $index->id)
-    ->condition('item_id', $ids, 'IN')
-    ->execute();
+  return $index->datasource()->getChangedItems($index, $limit);
 }
 
 /**
@@ -990,6 +1013,63 @@ function search_api_get_service_info($id = NULL) {
 }
 
 /**
+ * Returns information for either all item types, or a specific one.
+ *
+ * @param $type
+ *   If set, the item type whose information should be returned.
+ *
+ * @return
+ *   If $type is given, either an array containing the information of that item
+ *   type, or NULL if it is unknown. Otherwise, an array keyed by type IDs
+ *   containing the information for all item types. Item type information is
+ *   formatted as specified by hook_search_api_item_type_info(), and has all
+ *   optional fields filled with the defaults.
+ *
+ * @see hook_search_api_item_type_info()
+ */
+function search_api_get_item_type_info($type = NULL) {
+  $types = &drupal_static(__FUNCTION__);
+
+  if (!isset($types)) {
+    $types = module_invoke_all('search_api_item_type_info');
+    drupal_alter('search_api_item_type_info', $types);
+  }
+
+  if (isset($type)) {
+    return isset($types[$type]) ? $types[$type] : NULL;
+  }
+  return $types;
+}
+
+/**
+ * Get a data source controller object for the specified type.
+ *
+ * @param $type
+ *   The type whose data source controller should be returned.
+ *
+ * @return SearchApiDataSourceControllerInterface
+ *   The type's data source controller.
+ *
+ * @throws SearchApiException
+ *   If the type is unknown or specifies an invalid data source controller.
+ */
+function search_api_get_datasource_controller($type) {
+  $datasources = &drupal_static(__FUNCTION__, array());
+  if (empty($datasources[$type])) {
+    $info = search_api_get_item_type_info($type);
+    if (isset($info['datasource controller']) && class_exists($info['datasource controller'])) {
+      $datasources[$type] = new $info['datasource controller']($type);
+    }
+    if (empty($datasources[$type]) || !($datasources[$type] instanceof SearchApiDataSourceControllerInterface)) {
+      unset($datasources[$type]);
+      throw new SearchApiException(t('Unknown or invalid item type !type.',
+          array('!type' => $type)));
+    }
+  }
+  return $datasources[$type];
+}
+
+/**
  * Returns a list of all available data alter callbacks.
  *
  * @see hook_search_api_alter_callback_info()
@@ -1007,8 +1087,6 @@ function search_api_get_alter_callbacks() {
     foreach ($callbacks as $id => $callback) {
       $callbacks[$id] += array('enabled' => TRUE, 'weight' => 0);
     }
-
-    // @todo drupal_alter('search_api_alter_callback_info', $callbacks)?
   }
 
   return $callbacks;
@@ -1032,8 +1110,6 @@ function search_api_get_processors() {
     foreach ($processors as $id => $processor) {
       $processors[$id] += array('enabled pre' => TRUE, 'enabled post' => TRUE, 'weight' => 0);
     }
-
-    // @todo drupal_alter('search_api_processor_info', $processors)?
   }
 
   return $processors;
@@ -1160,7 +1236,7 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
     unset($info);
     try {
       foreach ($wrapper as $i => $w) {
-        $nested_fields = search_api_extract_fields($w, $fields);
+        $nested_fields = search_api_extract_fields($w, $fields, $value_options);
         foreach ($nested_fields as $field => $info) {
           if (isset($info['value'])) {
             $fields[$field]['value'][] = $info['value'];
@@ -1214,7 +1290,7 @@ function search_api_extract_fields(EntityMetadataWrapper $wrapper, array $fields
 
   foreach ($nested as $prefix => $nested_fields) {
     if (isset($wrapper->$prefix)) {
-      $nested_fields = search_api_extract_fields($wrapper->$prefix, $nested_fields);
+      $nested_fields = search_api_extract_fields($wrapper->$prefix, $nested_fields, $value_options);
       foreach ($nested_fields as $field => $info) {
         $fields["$prefix:$field"] = $info;
       }
@@ -1270,50 +1346,6 @@ function _search_api_extract_entity_value(EntityMetadataWrapper $wrapper, $fullt
 }
 
 /**
- * Returns a list of all (enabled) search servers.
- *
- * @deprecated
- *   This function doesn't take exported entities in code into account. Use
- *   entity_load() instead and sort manually, if you must.
- *
- * @param $only_enabled
- *   Whether to retrieve only enabled servers.
- * @param $header
- *   A table header to sort by.
- *
- * @return array
- *   An array of objects representing all (or, if $enabled is TRUE, only
- *   enabled) search servers.
- */
-// @todo Remove when changing the API.
-function search_api_list_servers($only_enabled = TRUE, $header = NULL) {
-  $servers = &drupal_static(__FUNCTION__);
-
-  $enabled = (int) $only_enabled;
-  if (!isset($servers[$enabled])) {
-    $select = db_select('search_api_server', 's', array('fetch' => 'SearchApiServer'))->fields('s');
-    if ($enabled) {
-      $select->condition('s.enabled', 1);
-    }
-    if (!empty($header)) {
-      $select = $select->extend('TableSort')->orderByHeader($header);
-    }
-
-    $results = $select->execute();
-    $servers[$enabled] = array();
-    foreach ($results as $row) {
-      $row->options = unserialize($row->options);
-      $servers[$enabled][$row->machine_name] = $row;
-    }
-
-    module_invoke_all('search_api_server_load', $servers[$enabled]);
-    module_invoke_all('entity_load', $servers[$enabled], 'search_api_server');
-  }
-
-  return $servers[$enabled];
-}
-
-/**
  * Load the search server with the specified id.
  *
  * @param $id
@@ -1470,57 +1502,6 @@ function search_api_server_delete($id) {
 }
 
 /**
- * Returns a list of search indexes.
- *
- * @deprecated
- *   This function doesn't take exported entities in code into account. Use
- *   entity_load() instead and sort manually, if you must.
- *
- * @param $options
- *   An associative array of conditions on the returned indexes.
- *   - enabled: When set to TRUE, only enabled indexes will be returned.
- *   - server: Return only indexes on the server with the specified id.
- *   - entity_type: Return only indexes on the specified entity type.
- * @param $header
- *   A table header to sort by.
- *
- * @return array
- *   An array of objects representing the search indexes that meet the
- *   specified criteria.
- */
-// @todo Remove when changing the API.
-function search_api_list_indexes(array $options = array(), $header = NULL) {
-  $server = empty($options['server']) ? NULL : $options['server'];
-  $type = empty($options['entity_type']) ? NULL : $options['entity_type'];
-
-  $select = db_select('search_api_index', 'i', array('fetch' => 'SearchApiIndex'))->fields('i');
-  if (!empty($options['enabled'])) {
-    $select->condition('i.enabled', 1);
-  }
-  if (!empty($server)) {
-    $select->condition('i.server', $server);
-  }
-  if (!empty($type)) {
-    $select->condition('i.entity_type', $type);
-  }
-  if (!empty($header)) {
-    $select = $select->extend('TableSort')->orderByHeader($header);
-  }
-
-  $results = $select->execute();
-  $indexes = array();
-  foreach ($results as $row) {
-    $row->options = unserialize($row->options);
-    $indexes[$row->machine_name] = $row;
-  }
-
-  module_invoke_all('search_api_index_load', $indexes);
-  module_invoke_all('entity_load', $indexes, 'search_api_index');
-
-  return $indexes;
-}
-
-/**
  * Loads the Search API index with the specified id.
  *
  * @param $id
@@ -1563,28 +1544,16 @@ function search_api_index_load_multiple($ids = array(), $conditions = array(), $
 /**
  * Determines a search index' indexing status.
  *
- * @param $index
- *   Either an index object, or the index' machine name.
+ * @param SearchApiIndex $index
+ *   The index whose indexing status should be determined.
  *
  * @return array
  *   An associative array containing two keys (in this order):
  *   - indexed: The number of items already indexed in their latest version.
  *   - total: The total number of items that have to be indexed for this index.
  */
-function search_api_index_status($index) {
-  $id = is_object($index) ? $index->id : $index;
-  $indexed = db_select('search_api_item', 'i')
-    ->condition('index_id', $id)
-    ->condition('changed', 0)
-    ->countQuery()
-    ->execute()
-    ->fetchField();
-  $total = db_select('search_api_item', 'i')
-    ->condition('index_id', $id)
-    ->countQuery()
-    ->execute()
-    ->fetchField();
-  return array('indexed' => $indexed, 'total' => $total);
+function search_api_index_status(SearchApiIndex $index) {
+  return $index->datasource()->getIndexStatus($index);
 }
 
 /**
@@ -1712,18 +1681,11 @@ function search_api_index_reindex($id) {
 /**
  * Helper method for marking all items on an index as needing re-indexing.
  *
- * @param $id
- *   The numeric ID of the index to re-index.
- *
- * @return
- *   The number of items affected.
- */
-function _search_api_index_reindex($id) {
-  return db_update('search_api_item')
-    ->fields(array('changed' => REQUEST_TIME))
-    ->condition('changed', 0)
-    ->condition('index_id', $id)
-    ->execute();
+ * @param SearchApiIndex $index
+ *   The index whose items should be re-indexed.
+ */
+function _search_api_index_reindex(SearchApiIndex $index) {
+  $index->datasource()->trackItemChange(FALSE, array($index));
 }
 
 /**
diff --git a/search_api.test b/search_api.test
index 46adf78..8f82bb3 100644
--- a/search_api.test
+++ b/search_api.test
@@ -38,7 +38,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
 
   public function testFramework() {
     $this->drupalLogin($this->drupalCreateUser(array('administer search_api')));
-    // @todo Why is there no default index.
+    // @todo Why is there no default index?
     //$this->deleteDefaultIndex();
     $this->insertItems();
     $this->checkOverview1();
@@ -108,25 +108,25 @@ class SearchApiWebTest extends DrupalWebTestCase {
   protected function createIndex() {
     $values = array(
       'name' => '',
-      'entity_type' => '',
+      'item_type' => '',
       'enabled' => 1,
       'description' => 'An index used for testing.',
       'server' => '',
-      'cron_limit' => 5,
+      'options[cron_limit]' => 5,
     );
     $this->drupalPost('admin/config/search/search_api/add_index', $values, t('Create index'));
     $this->assertText(t('!name field is required.', array('!name' => t('Index name'))));
-    $this->assertText(t('!name field is required.', array('!name' => t('Entity type'))));
+    $this->assertText(t('!name field is required.', array('!name' => t('Item type'))));
 
     $this->index_id = $id = 'test_index';
     $values = array(
       'name' => 'Search API test index',
       'machine_name' => $id,
-      'entity_type' => 'search_api_test',
+      'item_type' => 'search_api_test',
       'enabled' => 1,
       'description' => 'An index used for testing.',
       'server' => '',
-      'cron_limit' => 5,
+      'options[cron_limit]' => 5,
     );
     $this->drupalPost(NULL, $values, t('Create index'));
 
@@ -135,11 +135,11 @@ class SearchApiWebTest extends DrupalWebTestCase {
     $this->assertTrue($found, t('Correct redirect.'));
     $index = search_api_index_load($id, TRUE);
     $this->assertEqual($index->name, $values['name'], t('Name correctly inserted.'));
-    $this->assertEqual($index->entity_type, $values['entity_type'], t('Index entity type correctly inserted.'));
+    $this->assertEqual($index->item_type, $values['item_type'], t('Index item type correctly inserted.'));
     $this->assertFalse($index->enabled, t('Status correctly inserted.'));
     $this->assertEqual($index->description, $values['description'], t('Description correctly inserted.'));
     $this->assertNull($index->server, t('Index server correctly inserted.'));
-    $this->assertEqual($index->options['cron_limit'], $values['cron_limit'], t('Cron limit correctly inserted.'));
+    $this->assertEqual($index->options['cron_limit'], $values['options[cron_limit]'], t('Cron limit correctly inserted.'));
 
     $values = array(
       'additional[field]' => 'parent',
@@ -212,7 +212,7 @@ class SearchApiWebTest extends DrupalWebTestCase {
     $this->drupalGet("admin/config/search/search_api/index/$id");
     $this->assertTitle('Search API test index | Drupal', t('Correct title when viewing index.'));
     $this->assertText('An index used for testing.', t('!field displayed.', array('!field' => t('Description'))));
-    $this->assertText('Search API test entity', t('!field displayed.', array('!field' => t('Entity type'))));
+    $this->assertText('Search API test entity', t('!field displayed.', array('!field' => t('Item type'))));
     $this->assertText(format_plural(5, '1 item per cron run.', '@count items per cron run.'), t('!field displayed.', array('!field' => t('Cron limit'))));
 
     $this->drupalGet("admin/config/search/search_api/index/$id/status");
@@ -409,7 +409,7 @@ class SearchApiUnitTest extends DrupalWebTestCase {
       'id' => 1,
       'name' => 'test',
       'enabled' => 1,
-      'entity_type' => 'user',
+      'item_type' => 'user',
       'options' => array(
         'fields' => array(
           'name' => array(
