diff --git a/data_entity/data_entity.info b/data_entity/data_entity.info
index 4dac363..99129aa 100644
--- a/data_entity/data_entity.info
+++ b/data_entity/data_entity.info
@@ -5,9 +5,4 @@ dependencies[] = data
 dependencies[] = entity
 dependencies[] = data_ui
 core = 7.x
-files[] = data_entity.module
-files[] = data_entity.pages.inc
-files[] = data_entity.admin.inc
 files[] = data_entity.entity.inc
-files[] = views/data_entity.views.inc
-files[] = views/data_entity_views_handler_field_edit_link.inc
diff --git a/data_entity/data_entity.install b/data_entity/data_entity.install
index f98fa7f..5c9ddb6 100644
--- a/data_entity/data_entity.install
+++ b/data_entity/data_entity.install
@@ -9,22 +9,24 @@
  * Update the settings on data tables used as entities.
  */
 function data_entity_update_7000() {
+  module_load_include('module', 'data');
   $tables = data_get_all_tables();
 
   foreach ($tables as $table_name => $table) {
-    if (!isset($table->table_schema['primary key'])) {
-      // Skip tables that don't have a primary key.
+    // Skip tables that don't have a single-field primary key. All tables were
+    // hitherto declared as entity types, but multi-field keys simply cannot
+    // function as them.
+    if (!isset($table->table_schema['primary key'])
+        || !is_array($table->table_schema['primary key'])
+        || count($table->table_schema['primary key']) != 1) {
       continue;
     }
 
     data_entity_meta_add_defaults($table->meta);
     $meta = $table->get('meta');
 
-    // All tables were hitherto declared as entity types.
     $meta['is_entity_type'] = TRUE;
 
-    // Prior to this update, all data entity types assumed there was a single
-    // primary key to use as the entity id field.
     $id_field = $table->table_schema['primary key'][0];
     $meta['entity_id'] = $id_field;
 
@@ -34,3 +36,11 @@ function data_entity_update_7000() {
 
   return t('Table settings have been updated.');
 }
+
+/**
+ * Empty update function.
+ */
+function data_entity_update_7001() {
+  // This function is kept empty for the benefit of those who have already
+  // increased the version number (by e.g. applying #2326857).
+}
diff --git a/data_entity/data_entity.module b/data_entity/data_entity.module
index e9183f4..1a246c2 100644
--- a/data_entity/data_entity.module
+++ b/data_entity/data_entity.module
@@ -170,16 +170,6 @@ function data_entity_menu() {
     );
   }
 
-  $items['admin/content/data/entity/%data_ui_table/%data_entity_item'] = array(
-    'title' => 'Edit data item',
-    'load arguments' => array(4),
-    'page callback' => 'drupal_get_form',
-    'page arguments' => array('data_entity_entity_edit_form', 4, 5),
-    'file' => 'data_entity.pages.inc',
-    'access callback' => 'data_entity_table_menu_access',
-    'access arguments' => array(4),
-  );
-
   return $items;
 }
 
@@ -204,40 +194,6 @@ function data_entity_menu_alter(&$items) {
   */
 }
 
-/**
- * Menu access callback.
- */
-function data_entity_table_menu_access($table) {
-  return user_access('edit data in table ' . $table->name);
-}
-
-/**
- * Menu loader callback.
- *
- * Called 'data_entity_item_load' to avoid being data's hook_entity_load()!
- */
-function data_entity_item_load($deid, $table_name) {
-  $entity_type = 'data_' . $table_name;
-  $data_entity = entity_load($entity_type, array($deid));
-  return $data_entity ? reset($data_entity) : FALSE;
-}
-
-/**
- * Implements hook_permission().
- */
-function data_entity_permission() {
-  $tables = data_entity_get_entity_tables();
-  $permissions = array();
-
-  foreach ($tables as $table_name => $table) {
-    $permissions['edit data in table ' . $table_name] = array(
-      'title' => t('Edit data in the %table_name table', array('%table_name' => $table->title)),
-    );
-  }
-
-  return $permissions;
-}
-
 /**
  * Entity Label callback.
  */
@@ -318,11 +274,6 @@ function data_entity_feeds_processor_targets_alter(&$targets, $entity_type, $bun
  * @see FeedsProcessor::existingEntityId()
  */
 function data_entity_feed_unique_callback(FeedsSource $source, $entity_type, $bundle, $target, $values) {
-  // Get the information about this entity.
-  $entity_info = entity_get_info($entity_type);
-  // Extract the entity ID key.
-  $entity_id_key = $entity_info['entity keys']['id'];
-
   // Attempt to load the table.
   if ($table = data_entity_get_table_by_entity_type($entity_type)) {
     // Get the single value out of the array. Not sure why it has to be an array
@@ -335,6 +286,8 @@ function data_entity_feed_unique_callback(FeedsSource $source, $entity_type, $bu
       $record = reset($record);
 
       // Determine the primary key value.
+      $key = $table->getUniqueKey();
+      $entity_id_key = reset($key);
       if (isset($record[$entity_id_key])) {
         return $record[$entity_id_key];
       }
diff --git a/data_entity/data_entity.pages.inc b/data_entity/data_entity.pages.inc
deleted file mode 100644
index c5c8ddc..0000000
--- a/data_entity/data_entity.pages.inc
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-/**
- * @file
- * Contains general page callbacks and associated functions.
- */
-
-/**
- * Form builder for viewing and editing a data entity.
- */
-function data_entity_entity_edit_form($form, &$form_state, $table, $data_entity = NULL) {
-  $form = array();
-
-  // Add in our defaults to the table meta data.
-  data_entity_meta_add_defaults($table->meta);
-
-  // Get entity defaults.
-  $entity_type = $data_entity->entity_type;
-  $entity_info = entity_get_info($entity_type);
-  list($id, ) = entity_extract_ids($entity_type, $data_entity);
-
-  drupal_set_title(t('Edit @item item @id', array(
-    '@item' => $entity_info['label'],
-    '@id'   => $id,
-  )));
-
-  // Store essential data.
-  $form['table'] = array(
-    '#type' => 'value',
-    '#value' => $table,
-  );
-  $form['#entity'] = $data_entity;
-
-  $form['data'] = array(
-    '#tree' => TRUE,
-    '#weight' => -100, // Ensure this goes above fields.
-  );
-  foreach ($table->table_schema['fields'] as $field_name => $field) {
-    // For some reason these are lower case as entity keys.
-    $field_id_safe = strtolower($field_name);
-    $label = !empty($table->meta['fields'][$field_name]['label']) ? $table->meta['fields'][$field_name]['label'] : $field_name;
-    $description = '';
-
-    $id_field = $table->meta['entity_id'];
-    if ($id_field == $field_name) {
-      $disabled = TRUE;
-      $description .= t('The id field can not be edited.');
-    }
-    else {
-      $disabled = $table->meta['fields'][$field_name]['locked'];
-    }
-
-    $form['data'][$field_name] = array(
-      '#type' => 'textfield',
-      '#title' => $label,
-      '#description' => $description,
-      // We need this check because after adding a field this gives errors.
-      '#default_value' => isset($data_entity->$field_id_safe) ? $data_entity->$field_id_safe : NULL,
-      '#disabled' => $disabled,
-      '#required' => $table->meta['fields'][$field_name]['required'],
-    );
-  }
-
-  $form['save'] = array(
-    '#type' => 'submit',
-    '#value' => t('Save'),
-    '#weight' => 100, // Ensure this goes below fields.
-  );
-
-  // Has no effect: http://drupal.org/node/1343722
-  //$form['#parents'] = array('fieldapi');
-  field_attach_form($entity_type, $data_entity, $form, $form_state);
-
-  return $form;
-}
-
-/**
- * Form validation handler for saving a data entity.
- */
-function data_entity_entity_edit_form_validate($form, &$form_state) {
-  $data_entity = $form['#entity'];
-  $entity_type = $data_entity->entity_type;
-
-  // Build a pseudo entity for FieldAPI field attach.
-  $pseudo_entity = $form_state['values'];
-  unset($pseudo_entity['data'], $pseudo_entity['table']);
-  $pseudo_entity += $form_state['values']['data'];
-  $pseudo_entity = (object) $pseudo_entity;
-
-  field_attach_form_validate($entity_type, $pseudo_entity, $form, $form_state);
-}
-
-/**
- * Form submit handler for saving a data entity.
- */
-function data_entity_entity_edit_form_submit($form, &$form_state) {
-  //dsm($form_state, 'fs');
-
-  $data_entity = $form['#entity'];
-  $entity_type = $data_entity->entity_type;
-
-  // Build a pseudo entity for FieldAPI field attach.
-  $pseudo_entity = $form_state['values'];
-  unset($pseudo_entity['data'], $pseudo_entity['table']);
-  $pseudo_entity += $form_state['values']['data'];
-  $pseudo_entity = (object) $pseudo_entity;
-
-  field_attach_submit($entity_type, $pseudo_entity, $form, $form_state);
-
-  $table = $form_state['values']['table'];
-
-  $record = $form_state['values']['data'];
-  drupal_write_record($table->name, $record, $table->table_schema['primary key']);
-
-  // Save fields.
-  field_attach_update($entity_type, $pseudo_entity);
-}
diff --git a/data_entity/views/data_entity.views.inc b/data_entity/views/data_entity.views.inc
deleted file mode 100644
index d316bad..0000000
--- a/data_entity/views/data_entity.views.inc
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-/**
- * @file
- * Contains Views hooks.
- */
-
-/**
- * Implements hook_views_data_alter().
- */
-function data_entity_views_data_alter(&$data) {
-  $tables = data_entity_get_entity_tables();
-  foreach ($tables as $table) {
-    $table_name = $table->get('name');
-    $data[$table_name]['edit_link'] = array(
-      'field' => array(
-        'title' => t('Edit link'),
-        'help' => t('Displays an edit link to the data item'),
-        'handler' => 'data_entity_views_handler_field_edit_link',
-      ),
-    );
-  }
-}
-
-/**
- * Implements hook_views_default_views_alter().
- *
- * Add our field to the default data table views, when the data table is
- * declared as an entity type.
- */
-function data_entity_views_default_views_alter(&$views) {
-  $tables = data_entity_get_entity_tables();
-
-  foreach ($tables as $table) {
-    $view_name = $table_name = $table->get('name');
-    // Not all tables have views, eg if they have no primary key.
-    if (isset($views[$view_name])) {
-      $view = $views[$view_name];
-
-      $handler =& $view->display['default']->handler;
-      /* Field: User: Name */
-      $handler->display->display_options['fields']['edit_link']['id'] = 'edit_link';
-      $handler->display->display_options['fields']['edit_link']['table'] = $table_name;
-      $handler->display->display_options['fields']['edit_link']['field'] = 'edit_link';
-    }
-  }
-}
-
diff --git a/data_entity/views/data_entity_views_handler_field_edit_link.inc b/data_entity/views/data_entity_views_handler_field_edit_link.inc
deleted file mode 100644
index a3adb57..0000000
--- a/data_entity/views/data_entity_views_handler_field_edit_link.inc
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-/**
- * @file
- * Class defition for data_entity_views_handler_field_edit_link.
- */
-
-/**
- * Provides a field handler that links to a data table entity's edit form.
- */
-class data_entity_views_handler_field_edit_link extends views_handler_field {
-  function query() {
-    // Fake field, do nothing.
-  }
-
-  /**
-   * Grant access to the field if the user can access the page it links to.
-   */
-  function access() {
-    $table = $this->view->base_table;
-    return user_access('edit data in table ' . $table);
-  }
-
-  function render($values) {
-    $table = $this->view->base_table;
-    $base_field = $this->view->base_field;
-
-    $this->options['alter']['make_link'] = TRUE;
-    $this->options['alter']['path'] = 'admin/content/data/entity/' . $table . '/' . $values->$base_field;
-    $this->options['alter']['query'] = drupal_get_destination();
-
-    return t('edit item');
-  }
-}
diff --git a/data_ui/data_ui.admin.inc b/data_ui/data_ui.admin.inc
index af46cca..f6edf56 100644
--- a/data_ui/data_ui.admin.inc
+++ b/data_ui/data_ui.admin.inc
@@ -4,32 +4,6 @@
  * Admin UI functions.
  */
 
-/**
- * List all tables for viewing content.
- */
-function data_ui_view() {
-  $tables = data_get_all_tables();
-  $rows = array();
-  foreach ($tables as $table) {
-    // TODO Please convert this statement to the D7 database API syntax.
-    $row = array(
-      check_plain($table->get('title')),
-      $table->get('name'),
-      db_query('SELECT COUNT(*) FROM {' . db_escape_table($table->get('name')) . '}')->fetchField(),
-    );
-    if (module_exists('views')) {
-      $path = data_ui_get_default_path($table->get('name'));
-      $row[] = $path ? l(t('View'), $path) : l(t('Edit schema'), 'admin/structure/data/edit/' . $table->get('name'));
-    }
-    $rows[] = $row;
-  }
-  $header = array(t('Title'), t('Name'), t('Number of rows'));
-  if (module_exists('views')) {
-    $header[] = '&nbsp;';
-  }
-  return theme('table', array('header' => $header, 'rows' => $rows));
-}
-
 /**
  * Main page for data table management.
  */
diff --git a/data_ui/data_ui.info b/data_ui/data_ui.info
index b0938d9..9407e74 100644
--- a/data_ui/data_ui.info
+++ b/data_ui/data_ui.info
@@ -4,3 +4,6 @@ package = Data
 dependencies[] = data
 dependencies[] = schema
 core = 7.x
+files[] = views/data_ui_views_handler_field_link_base.inc
+files[] = views/data_ui_views_handler_field_delete_link.inc
+files[] = views/data_ui_views_handler_field_edit_link.inc
diff --git a/data_ui/data_ui.module b/data_ui/data_ui.module
index 8aa627a..d474abb 100644
--- a/data_ui/data_ui.module
+++ b/data_ui/data_ui.module
@@ -42,10 +42,41 @@ function data_ui_menu() {
       'title' => 'Data tables',
       'description' => 'View data tables.',
       'page callback' => 'data_ui_view',
-      'file' => 'data_ui.admin.inc',
+      'file' => 'data_ui.pages.inc',
+      // @todo this needs a new 'view data tables' permission (which needs
+      //   either an update function, or a dedicated access callback, if we
+      //   care about current users with only the 'admin' permission).
       'access arguments' => array('administer data tables'),
     );
   }
+  $items['admin/content/data/add/%data_ui_table'] = array(
+    'title' => 'Add data item',
+    'load arguments' => array(4),
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('data_ui_item_edit_form', 4),
+    'file' => 'data_ui.pages.inc',
+    'access callback' => 'data_ui_table_menu_access',
+    'access arguments' => array(4, 'create'),
+  );
+  $items['admin/content/data/edit/%data_ui_table/%data_ui_item'] = array(
+    'title' => 'Edit data item',
+    'load arguments' => array(4, '%map', '%index'),
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('data_ui_item_edit_form', 4, 5),
+    'file' => 'data_ui.pages.inc',
+    'access callback' => 'data_ui_table_menu_access',
+    'access arguments' => array(4),
+  );
+  $items['admin/content/data/delete/%data_ui_table/%data_ui_item'] = array(
+    'title' => 'Delete data item',
+    'load arguments' => array(4, '%map', '%index'),
+    'page callback' => 'drupal_get_form',
+    'page arguments' => array('data_ui_item_delete_form', 4, 5),
+    'file' => 'data_ui.pages.inc',
+    'access callback' => 'data_ui_table_menu_access',
+    'access arguments' => array(4, 'delete'),
+  );
+
   $items['admin/structure/data'] = array(
     'title' => 'Data tables',
     'description' => 'Create, modify and delete data tables.',
@@ -209,6 +240,13 @@ function data_ui_menu() {
   return $items;
 }
 
+/**
+ * Menu access callback.
+ */
+function data_ui_table_menu_access($table, $action = 'edit') {
+  return user_access($action . ' data in table ' . $table->name);
+}
+
 /**
  * Menu loader callback.
  */
@@ -216,6 +254,42 @@ function data_ui_table_load($table_name) {
   return data_get_table($table_name);
 }
 
+/**
+ * Menu load callback.
+ */
+function data_ui_item_load($first_id_value, $table_name, $arg_map, $arg_index) {
+  // We're being called repeatedly (for menu translations, checks on local
+  // tasks / breadcrumb, ...) and non-entities don't have a load cache. We can
+  // be sure that we're being called to load the same item each time, so
+  // putting a static cache in place is OK for a menu load callback.
+  static $cache = array();
+  $cache_pointer =& $cache;
+  // The first value for the ID was passed in through the first function
+  // parameter (as is always the case in a load callback) and also in the arg
+  // map, at position <index> - with any additional values following.
+  $id_values = [];
+  do {
+    $id_value = $arg_map[$arg_index];
+    $id_values[] = $id_value;
+    $arg_index++;
+    $continue = isset($arg_map[$arg_index]);
+    // Even though we are very likely going to cache only one value, it's
+    // safest to cache it in a potentially multidimensional array with each
+    // id-value being a separate key. Initialize next level.
+    if ($continue) {
+      if (!isset($cache_pointer[$id_value]) || !is_array($cache_pointer[$id_value])) {
+        $cache_pointer[$id_value] = [];
+      }
+      $cache_pointer =& $cache_pointer[$id_value];
+    }
+  } while ($continue);
+
+  if (!isset($cache_pointer[$id_value])) {
+    $cache_pointer[$id_value] = data_ui_load_item($id_values, $table_name);
+  }
+  return $cache_pointer[$id_value];
+}
+
 /**
  * Title callback.
  */
@@ -259,13 +333,85 @@ function data_ui_theme() {
  * Implements hook_permission().
  */
 function data_ui_permission() {
-  return array(
+  $permissions = array(
     'administer data tables' => array(
       'title' => t('administer data tables'),
       'description' => t('Adopt and configure data tables.'),
       'restrict access' => TRUE,
     ),
   );
+
+  $tables = data_get_all_tables();
+  foreach ($tables as $table_name => $table) {
+    if ($table->getUniqueKey()) {
+      $permissions['edit data in table ' . $table_name] = array(
+        'title' => t('%table_name: Edit data', array('%table_name' => $table->get('title'))),
+      );
+      $permissions['create data in table ' . $table_name] = array(
+        'title' => t('%table_name: Create new data', array('%table_name' => $table->get('title'))),
+      );
+      $permissions['delete data in table ' . $table_name] = array(
+        'title' => t('%table_name: Delete data', array('%table_name' => $table->get('title'))),
+      );
+    }
+  }
+
+  return $permissions;
+}
+
+/**
+ * Loads a data row or entity, depending on the table's definition.
+ *
+ * This could be moved to data.module or folded into DataHandler::load() if the
+ * need arises, but there's no proven need yet outside of the UI to have a
+ * method that returns either an object or an entity; most code will know which
+ * specific type it needs.
+ *
+ * @param array $id_values
+ *   The ID for a table row. The number of values in the array must be equal to
+ *   the number of fields in the table's defined key, and must follow the same
+ *   order. (If they are keyed by the fields instead of numerically, that's OK
+ *   but the keys are ignored. It's the order that matters.)
+ * @param string $table_name
+ *   The table name.
+ *
+ * @return object|false
+ *   Either an entity or a table row (stdClass), depending on whether this
+ *   table is defined to be an entity type, or FALSE on error.
+ */
+function data_ui_load_item(array $id_values, $table_name) {
+  $items = FALSE;
+  $table = data_get_table($table_name);
+  if (!$table) {
+    drupal_set_message(t("Unrecognized data table '@table'.", array('@table' => $table_name)), 'error');
+  }
+  else {
+    $key = $table->getUniqueKey();
+    if (!$table) {
+      drupal_set_message(t("Table '@table' has no unique key.", array('@table' => $table_name)), 'error');
+    }
+    elseif (count($id_values) != count($key)) {
+      drupal_set_message(t("Invalid ID value(s) provided for data table '@table' whose index contains " . count($key) . ' fields; got ' . count($id_values) . ' values.', array('@table' => $table_name)), 'error');
+    }
+    elseif ($table->isEntityType()) {
+      // Check if we have a loader by checking if Drupal defines entity info.
+      $entity_type = $table->getEntityTypeName();
+      if (!entity_get_info($entity_type)) {
+        drupal_set_message(t("Data table '@table' is configured as an entity type but no entities can be loaded. (Is the data_entity module enabled?)", array('@table' => $table_name)), 'error');
+      }
+      else {
+        // $id_values was 'a single field/column value' but can double as 'a
+        // single value/row'.
+        $items = entity_load($entity_type, $id_values);
+      }
+    }
+    else {
+      $key_values = array_combine($key, $id_values);
+      $items = data_get_handler($table_name)->load($key_values, TRUE);
+    }
+  }
+
+  return $items ? reset($items) : FALSE;
 }
 
 /**
diff --git a/data_ui/data_ui.pages.inc b/data_ui/data_ui.pages.inc
new file mode 100644
index 0000000..e3622a4
--- /dev/null
+++ b/data_ui/data_ui.pages.inc
@@ -0,0 +1,270 @@
+<?php
+/**
+ * @file
+ * Contains page callbacks for content CRUD.
+ */
+
+/**
+ * Returns an empty data row or entity, depending on the table's definition.
+ *
+ * @param string $table_name
+ *   The table name.
+ *
+ * @return object|false
+ *   A newly initialized object or FALSE on error.
+ */
+function data_ui_get_new_item($table_name) {
+  $item = FALSE;
+  $table = data_get_table($table_name);
+  if (!$table) {
+    drupal_set_message(t("Unrecognized data table '@table'.", array('@table' => $table_name)), 'error');
+  }
+  else {
+    $key = $table->getUniqueKey();
+    if (!$key) {
+      drupal_set_message(t("Table '@table' has no unique key.", array('@table' => $table_name)), 'error');
+    }
+    elseif (!$table->isEntityType()) {
+      $item = new stdClass();
+    }
+    else {
+      $entity_type = $table->getEntityTypeName();
+      if (entity_get_info($entity_type)) {
+        $item = entity_create($table->getEntityTypeName(), array());
+      }
+      else {
+        drupal_set_message(t("Data table '@table' is configured as an entity type but no entities can be created. (Is the data_entity module enabled?)", array('@table' => $table_name)), 'error');
+      }
+    }
+  }
+
+  return $item;
+}
+
+/**
+ * List all tables for viewing content.
+ */
+function data_ui_view() {
+  $tables = data_get_all_tables();
+  $rows = array();
+  foreach ($tables as $table) {
+    // TODO Please convert this statement to the D7 database API syntax.
+    $row = array(
+      check_plain($table->get('title')),
+      $table->get('name'),
+      db_query('SELECT COUNT(*) FROM {' . db_escape_table($table->get('name')) . '}')->fetchField(),
+    );
+    if (module_exists('views')) {
+      $path = data_ui_get_default_path($table->get('name'));
+      $row[] = $path ? l(t('View'), $path) : l(t('Edit schema'), 'admin/structure/data/edit/' . $table->get('name'));
+    }
+    $rows[] = $row;
+  }
+  $header = array(t('Title'), t('Name'), t('Number of rows'));
+  if (module_exists('views')) {
+    $header[] = '&nbsp;';
+  }
+  return theme('table', array('header' => $header, 'rows' => $rows));
+}
+
+/**
+ * Form builder for viewing and editing a data item.
+ */
+function data_ui_item_edit_form($form, &$form_state, $table, $data_item = NULL) {
+  /** @var \DataTable $table */
+  $is_new = is_null($data_item);
+  if ($is_new) {
+    $data_item = data_ui_get_new_item($table->get('name'));
+    if ($data_item === FALSE) {
+      // We just produce an empty form and an error message.
+      return array();
+    }
+    $id = array();
+  }
+  else {
+    $id = $table->getUniqueValues($data_item);
+  }
+
+  drupal_set_title(t('@op @table item @id', array(
+    '@op' => $is_new ? 'Add' : 'Edit',
+    '@table' => $table->get('title') ?: $table->get('name'),
+    '@id'   => implode('/', $id),
+  )));
+
+  $form = array();
+  $form['table'] = array(
+    '#type' => 'value',
+    '#value' => $table,
+  );
+
+  $form['data'] = array(
+    '#tree' => TRUE,
+    '#weight' => -100, // Ensure this goes above fields.
+  );
+  $unique_key = $table->getUniqueKey();
+  $schema = $table->get('table_schema');
+  $meta = $table->get('meta');
+  foreach ($schema['fields'] as $field_name => $field) {
+    // For some reason these are lower case as entity keys.
+    $field_id_safe = strtolower($field_name);
+    $label = !empty($meta['fields'][$field_name]['label']) ? $meta['fields'][$field_name]['label'] : $field_name;
+    $description = '';
+
+    $is_key_field = in_array($field_name, $unique_key, TRUE);
+    if ($is_key_field && (!$is_new || $field['type'] === 'serial')) {
+      $disabled = TRUE;
+      $description .= t('The id field can not be edited.');
+    }
+    else {
+      $disabled = !empty($meta['fields'][$field_name]['locked']);
+    }
+
+    $form['data'][$field_name] = array(
+      '#type' => 'textfield',
+      '#title' => $label,
+      '#description' => $description,
+      // We need this check because after adding a field this gives errors.
+      '#default_value' => isset($data_item->$field_id_safe) ? $data_item->$field_id_safe : NULL,
+      '#disabled' => $disabled,
+      '#required' => $is_key_field || !empty($meta['fields'][$field_name]['required']),
+    );
+  }
+
+  $form['save'] = array(
+    '#type' => 'submit',
+    '#value' => t('Save'),
+    '#weight' => 100, // Ensure this goes below fields.
+  );
+
+  if ($table->isEntityType()) {
+    $form['#entity'] = $data_item;
+    field_attach_form($table->getEntityTypeName(), $data_item, $form, $form_state);
+  }
+  else {
+    $form['is_new'] = array(
+      '#type' => 'value',
+      '#value' => $is_new,
+    );
+  }
+
+  return $form;
+}
+
+/**
+ * Form validation handler for saving a data entity.
+ */
+function data_ui_item_edit_form_validate($form, &$form_state) {
+  // No validation is done (besides requiredness) on the values in the main
+  // table / the 'base' values of the entity.
+  if (isset($form['#entity_type'])) {
+    $entity_type = $form['#entity_type'];
+
+    // Build a pseudo entity for FieldAPI field attach.
+    $pseudo_entity = $form_state['values'];
+    unset($pseudo_entity['data'], $pseudo_entity['table']);
+    $pseudo_entity += $form_state['values']['data'];
+    $pseudo_entity = (object) $pseudo_entity;
+
+    field_attach_form_validate($entity_type, $pseudo_entity, $form, $form_state);
+  }
+}
+
+/**
+ * Form submit handler for saving a data entity.
+ */
+function data_ui_item_edit_form_submit($form, &$form_state) {
+  /** @var \DataTable $table */
+  $table = $form_state['values']['table'];
+  $record = $form_state['values']['data'];
+
+  if (isset($form['#entity_type'])) {
+    $data_entity = $form['#entity'];
+    $entity_type = $form['#entity_type'];
+    $entity_info = entity_get_info($entity_type);
+
+    // Build a pseudo entity for FieldAPI field attach.
+    $pseudo_entity = $form_state['values'];
+    unset($pseudo_entity['data'], $pseudo_entity['table']);
+    $pseudo_entity += $form_state['values']['data'];
+    $pseudo_entity = (object) $pseudo_entity;
+
+    field_attach_submit($entity_type, $pseudo_entity, $form, $form_state);
+
+    if (empty($data_entity->is_new)) {
+      drupal_write_record($table->get('name'), $record, $entity_info['entity keys']['id']);
+      field_attach_update($entity_type, $pseudo_entity);
+    }
+    else {
+      drupal_write_record($table->get('name'), $record);
+      $pseudo_entity->{$entity_info['entity keys']['id']} = $record[$entity_info['entity keys']['id']];
+      field_attach_insert($entity_type, $pseudo_entity);
+    }
+  }
+  elseif (empty($form_state['values']['is_new'])) {
+    $table->handler()->update($record, $table->getUniqueKey());
+  }
+  else {
+    $table->handler()->insert($record);
+  }
+}
+
+/**
+ * Form builder for deleting a data entity.
+ */
+function data_ui_item_delete_form($form, &$form_state, $table, $data_item = NULL) {
+  /** @var \DataTable $table */
+  $form['table'] = array(
+    '#type' => 'value',
+    '#value' => $table,
+  );
+  $unique_values = $table->getUniqueValues($data_item);
+
+  $field_label = '';
+  if ($table->isEntityType()) {
+    // Always provide entity id in the same form key as in the entity edit form.
+    $form['entity_id'] = array('#type' => 'value', '#value' => reset($unique_values));
+    $meta = $table->get('meta');
+    // So far, the label field is only configurable in the entity screen.
+    if (isset($meta['label_field']) && isset($data_item->{$meta['label_field']})) {
+      $field_label = $data_item->{$meta['label_field']};
+    }
+  }
+  else {
+    $form['item_unique_values'] = array('#type' => 'value', '#value' => $unique_values);
+  }
+
+  if ($field_label === '') {
+    $field_label = implode('/', $unique_values);
+  }
+  return confirm_form($form,
+    t('Are you sure you want to delete @item_type %label?', array(
+      '@item_type' => $table->get('title') ?: $table->get('name'),
+      '%label' => $field_label
+    )),
+    '<front>',
+    t('This action cannot be undone.'),
+    t('Delete'),
+    t('Cancel')
+  );
+}
+
+/**
+ * Executes node deletion.
+ *
+ * @see node_delete_confirm()
+ */
+function data_ui_item_delete_form_submit($form, &$form_state) {
+  if ($form_state['values']['confirm']) {
+    /** @var \DataTable $table */
+    $table = $form_state['values']['table'];
+    if ($table->isEntityType()) {
+      entity_delete($table->getEntityTypeName(), $form_state['values']['entity_id']);
+    }
+    else {
+      $table->handler()->delete($form_state['values']['item_unique_values']);
+    }
+    drupal_set_message(t('Item has been deleted.'));
+  }
+
+  $form_state['redirect'] = '<front>';
+}
diff --git a/data_ui/data_ui.views.inc b/data_ui/data_ui.views.inc
new file mode 100644
index 0000000..a421fdd
--- /dev/null
+++ b/data_ui/data_ui.views.inc
@@ -0,0 +1,71 @@
+<?php
+/**
+ * @file
+ * Contains Views hooks.
+ */
+
+/**
+ * Implements hook_views_data_alter().
+ */
+function data_ui_views_data_alter(&$data) {
+  $tables = data_get_all_tables();
+  foreach ($tables as $table) {
+    if ($table->getUniqueKey()) {
+      $table_name = $table->get('name');
+      $data[$table_name]['edit_link'] = array(
+        'field' => array(
+          'title' => t('Edit item'),
+          'help' => t('Displays an edit link to the data item'),
+          'handler' => 'data_ui_views_handler_field_edit_link',
+        ),
+      );
+      $data[$table_name]['delete_link'] = array(
+        'field' => array(
+          'title' => t('Delete item'),
+          'help' => t('Displays a delete link to the data item'),
+          'handler' => 'data_ui_views_handler_field_delete_link',
+        ),
+      );
+    }
+  }
+}
+
+/**
+ * Implements hook_views_default_views_alter().
+ *
+ * Add our field to the default data table views, when the data table is
+ * declared as an entity type.
+ */
+function data_ui_views_default_views_alter(&$views) {
+  $tables = data_get_all_tables();
+  foreach ($tables as $table) {
+    if ($table->getUniqueKey()) {
+      $view_name = $table_name = $table->get('name');
+      if (isset($views[$view_name])) {
+        $view = $views[$view_name];
+
+        $handler =& $view->display['default']->handler;
+
+        $handler->display->display_options['fields']['edit_link']['id'] = 'edit_link';
+        $handler->display->display_options['fields']['edit_link']['table'] = $table_name;
+        $handler->display->display_options['fields']['edit_link']['field'] = 'edit_link';
+
+        $handler->display->display_options['fields']['delete_link']['id'] = 'edit_link';
+        $handler->display->display_options['fields']['delete_link']['table'] = $table_name;
+        $handler->display->display_options['fields']['delete_link']['field'] = 'delete_link';
+
+        $handler->display->display_options['header']['data_add']['empty'] = 1;
+        $handler->display->display_options['header']['data_add']['id'] = 'data_add';
+        $handler->display->display_options['header']['data_add']['table'] = 'views';
+        $handler->display->display_options['header']['data_add']['field'] = 'area_text_custom';
+        $handler->display->display_options['header']['data_add']['label'] = 'Add link';
+        $handler->display->display_options['header']['data_add']['content'] =
+          '<ul class="action-links"><li>'
+          . l('Add item', 'admin/content/data/add/' . $table_name, array('query' => array('destination' => 'admin/content/data/view/' . $table_name)))
+          . '</li></ul>';
+      }
+    }
+  }
+
+}
+
diff --git a/data_ui/views/data_ui.views.inc b/data_ui/views/data_ui.views.inc
new file mode 100644
index 0000000..4dc27a9
--- /dev/null
+++ b/data_ui/views/data_ui.views.inc
@@ -0,0 +1,71 @@
+<?php
+/**
+ * @file
+ * Contains Views hooks.
+ */
+
+/**
+ * Implements hook_views_data_alter().
+ */
+function data_ui_views_data_alter(&$data) {
+  $tables = data_get_all_tables();
+  foreach ($tables as $table) {
+    if ($table->getUniqueKey()) {
+      $table_name = $table->get('name');
+      $data[$table_name]['edit_link'] = array(
+        'field' => array(
+          'title' => t('Edit link'),
+          'help' => t('Displays an edit link to the data item'),
+          'handler' => 'data_ui_views_handler_field_edit_link',
+        ),
+      );
+      $data[$table_name]['delete_link'] = array(
+        'field' => array(
+          'title' => t('Delete link'),
+          'help' => t('Displays a delete link to the data item'),
+          'handler' => 'data_ui_views_handler_field_delete_link',
+        ),
+      );
+    }
+  }
+}
+
+/**
+ * Implements hook_views_default_views_alter().
+ *
+ * Add our field to the default data table views, when the data table is
+ * declared as an entity type.
+ */
+function data_ui_views_default_views_alter(&$views) {
+  $tables = data_get_all_tables();
+  foreach ($tables as $table) {
+    if ($table->getUniqueKey()) {
+      $view_name = $table_name = $table->get('name');
+      if (isset($views[$view_name])) {
+        $view = $views[$view_name];
+
+        $handler =& $view->display['default']->handler;
+
+        $handler->display->display_options['fields']['edit_link']['id'] = 'edit_link';
+        $handler->display->display_options['fields']['edit_link']['table'] = $table_name;
+        $handler->display->display_options['fields']['edit_link']['field'] = 'edit_link';
+
+        $handler->display->display_options['fields']['delete_link']['id'] = 'edit_link';
+        $handler->display->display_options['fields']['delete_link']['table'] = $table_name;
+        $handler->display->display_options['fields']['delete_link']['field'] = 'delete_link';
+
+        $handler->display->display_options['header']['data_add']['empty'] = 1;
+        $handler->display->display_options['header']['data_add']['id'] = 'data_add';
+        $handler->display->display_options['header']['data_add']['table'] = 'views';
+        $handler->display->display_options['header']['data_add']['field'] = 'area_text_custom';
+        $handler->display->display_options['header']['data_add']['label'] = 'Add link';
+        $handler->display->display_options['header']['data_add']['content'] =
+          '<ul class="action-links"><li>'
+          . l('Add item', 'admin/content/data/entity/' . $table_name . '/add', array('query' => array('destination' => 'admin/content/data/view/' . $table_name)))
+          . '</li></ul>';
+      }
+    }
+  }
+
+}
+
diff --git a/data_ui/views/data_ui_views_handler_field_delete_link.inc b/data_ui/views/data_ui_views_handler_field_delete_link.inc
new file mode 100644
index 0000000..3302a89
--- /dev/null
+++ b/data_ui/views/data_ui_views_handler_field_delete_link.inc
@@ -0,0 +1,36 @@
+<?php
+/**
+ * @file
+ * Class defition for data_ui_views_handler_field_edit_link.
+ */
+
+/**
+ * Provides a field handler that links to a data table item's delete form.
+ */
+class data_ui_views_handler_field_delete_link extends data_ui_views_handler_field_link_base {
+
+  /**
+   * Grants access to the field if the user can access the page it links to.
+   */
+  function access() {
+    $table = $this->view->base_table;
+    return user_access('delete data in table ' . $table);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    $options['text']['default'] = 'delete item';
+    return $options;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  function render($values) {
+    return $this->render_link($values, 'admin/content/data/delete/' . $this->view->base_table);
+  }
+
+}
diff --git a/data_ui/views/data_ui_views_handler_field_edit_link.inc b/data_ui/views/data_ui_views_handler_field_edit_link.inc
new file mode 100644
index 0000000..fc4cb22
--- /dev/null
+++ b/data_ui/views/data_ui_views_handler_field_edit_link.inc
@@ -0,0 +1,36 @@
+<?php
+/**
+ * @file
+ * Class defition for data_ui_views_handler_field_edit_link.
+ */
+
+/**
+ * Provides a field handler that links to a data table item's edit form.
+ */
+class data_ui_views_handler_field_edit_link extends data_ui_views_handler_field_link_base {
+
+  /**
+   * Grants access to the field if the user can access the page it links to.
+   */
+  function access() {
+    $table = $this->view->base_table;
+    return user_access('edit data in table ' . $table);
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+    $options['text']['default'] = 'edit item';
+    return $options;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  function render($values) {
+    return $this->render_link($values, 'admin/content/data/edit/' . $this->view->base_table);
+  }
+
+}
diff --git a/data_ui/views/data_ui_views_handler_field_link_base.inc b/data_ui/views/data_ui_views_handler_field_link_base.inc
new file mode 100644
index 0000000..b2f633d
--- /dev/null
+++ b/data_ui/views/data_ui_views_handler_field_link_base.inc
@@ -0,0 +1,152 @@
+<?php
+/**
+ * @file
+ * Class defition for data_ui_views_handler_field_edit_link.
+ */
+
+/**
+ * Provides base functionality for a field handler to link to a data table.
+ */
+class data_ui_views_handler_field_link_base extends views_handler_field {
+
+  /**
+   * The Data table classes.
+   *
+   * @var \DataTable[]
+   */
+  public $data_table_definitions = array();
+
+  /**
+   * {@inheritdoc}
+   */
+  public function option_definition() {
+    $options = parent::option_definition();
+
+    $options['text'] = array('default' => 'link');
+    $options['text_no_id'] = array('default' => '');
+
+    return $options;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function options_form(&$form, &$form_state) {
+    // This is taken over from views_handler_field_node_link. There is no
+    // common base class for links / no real common way to modify the text of
+    // a link. (Or rather, there is... the "Rewrite results" section can be
+    // used to just enter a text without replacement. But field_node_link
+    // introduced this extra option, apparently because "Rewrite results" was
+    // not very obvious.)
+    $form['text'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Text to display as the link'),
+      '#default_value' => $this->options['text'],
+    );
+    $form['text_no_id'] = array(
+      '#type' => 'textfield',
+      '#title' => t('Text to display if the row has no ID'),
+      '#default_value' => $this->options['text_no_id'],
+    );
+    parent::options_form($form, $form_state);
+
+    // The path is set by render() so don't allow to modify it with an option.
+    $form['alter']['path'] = array('#access' => FALSE);
+    $form['alter']['external'] = array('#access' => FALSE);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  function query() {
+    // Ensure we have all the ID fields for our table (i.e. the table defined
+    // in data_ui_views_data()). Don't call parent::query() because we don't
+    // want to add our field (which is not a real field).
+    $this->ensure_my_table();
+    $table = $this->get_data_table($this->table);
+    foreach ($table->getUniqueKey() as $field) {
+      // Add the field unless it's there already. (Copied from parent.)
+      $params = $this->options['group_type'] != 'group' ? array('function' => $this->options['group_type']) : array();
+      $this->query->add_field($this->table_alias, $field, NULL, $params);
+    }
+  }
+
+  /**
+   * Gets the data table class.
+   *
+   * CTools already caches the definition so it's not a huge issue if we don't
+   * cache this here in the handler, but it still saves a lot of instantiations.
+   *
+   * @return \DataTable
+   */
+  function get_data_table($table_name) {
+    if (!isset($this->data_table_definitions[$table_name])) {
+      $this->data_table_definitions[$table_name] = data_get_table($table_name);
+    }
+    return $this->data_table_definitions[$table_name];
+  }
+
+  /**
+   * Determines the unique ID value(s) for this record.
+   *
+   * The unique ID can consist of one or more values depending on how many
+   * fields the (base) table's unique key consists of.
+   *
+   * @param $values
+   *   The values retrieved from the database.
+   *
+   * @return array
+   *   The unique field value(s), or an empty array if they could not all be
+   *   determined.
+   */
+  function get_unique_values($values) {
+    $table_name = $this->table;
+    $table = $this->get_data_table($table_name);
+    $id_values = array();
+    foreach ($table->getUniqueKey() as $field_name) {
+      // This should always be set by query().
+      if (empty($this->query->field_aliases[$table_name][$field_name])) {
+        return [];
+      }
+      $alias = $this->query->field_aliases[$table_name][$field_name];
+      if (!isset($values->$alias)) {
+        return [];
+      }
+      $id_values[$field_name] = $values->$alias;
+    }
+
+    return $id_values;
+  }
+
+  /**
+   * Returns the text for a link, and sets link options as necessary.
+   *
+   * This is meant to be called from render() (as seems to be an undocumented
+   * convention in other field handlers providing links).
+   *
+   * @param $values
+   *   The values retrieved from the database.
+   * @param $path
+   *   The link path excluding the ID.
+   *
+   * @return string
+   *   The text to display.
+   */
+  public function render_link($values, $path) {
+    $id_values = $this->get_unique_values($values);
+    if ($id_values) {
+      $this->options['alter']['make_link'] = TRUE;
+      $this->options['alter']['path'] = rtrim($path, '/') . '/' . implode('/', $id_values);
+      $this->options['alter']['query'] = drupal_get_destination();
+      $text = t($this->options['text']);
+    }
+    else {
+      // By default we display nothing if the ID field cannot be determined.
+      // (The editable 'text_no_id' option will serve as a pointer for confused
+      // users who inspect the view.)
+      $text = t($this->options['text_no_id']);
+    }
+    return $text;
+  }
+
+}
diff --git a/includes/DataHandler.inc b/includes/DataHandler.inc
index c831536..b6f490b 100644
--- a/includes/DataHandler.inc
+++ b/includes/DataHandler.inc
@@ -42,9 +42,23 @@ class DataHandler {
   }
 
   /**
-   * Load a record.
+   * Loads records.
+   *
+   * @param array $keys
+   *   Values to select records by, keyed by the field names containing these
+   *   values. Values may be a single value or an array of values to select.
+   * @param bool $return_objects
+   *   (Optional) return objects for each row rather than arrays. In this case
+   *   the returned value must be used to call e.g. update() / save(). (Even if
+   *   that might succeed, it could start failing later because of hook
+   *   implementations that rightfully expect arrays.)
+   *
+   * @return array|false
+   *   An array of table records (in practice most often a single one); FALSE
+   *   if no records are present matching the selection criteria, or if an
+   *   error occurred.
    */
-  public function load($keys) {
+  public function load($keys, $return_objects = FALSE) {
     $schema = drupal_get_schema($this->table);
     $fields = $schema['fields'];
     $query = db_select(db_escape_table($this->table))->fields(db_escape_table($this->table));
@@ -55,7 +69,7 @@ class DataHandler {
       $query->condition($key, $value, is_array($value) ? 'IN' : '=');
     }
     if ($query->getArguments()) {
-      $results = $query->execute()->fetchAll(PDO::FETCH_ASSOC);
+      $results = $query->execute()->fetchAll($return_objects ? PDO::FETCH_OBJ : PDO::FETCH_ASSOC);
       return empty($results) ? FALSE : $results;
     }
     return FALSE;
diff --git a/includes/DataTable.inc b/includes/DataTable.inc
index 3b33081..dd7118f 100644
--- a/includes/DataTable.inc
+++ b/includes/DataTable.inc
@@ -129,6 +129,39 @@ class DataTable {
     return 'data_' . $this->name;
   }
 
+  /**
+   * Gets the fields which can uniquely identify a record.
+   *
+   * @return string[]
+   *   The field names forming a unique index. If the table is defined as an
+   *   entity type, the return value is always a one-element array containing
+   *   the entity ID field; otherwise it's the primary key, another unique key
+   *   (always the same one unless/until the table schema definition changes),
+   *   or an empty array if no unique key is defined in the table schema.
+   */
+  public function getUniqueKey() {
+    // If this is an entity type, we prefer to use the entity's ID field even
+    // if the primary key is defined to be another field; this can be a benefit
+    // for Views/Entity integration code. (This could be the case for data
+    // tables which have been made into an entity later.)
+    if ($this->isEntityType()) {
+      return array($this->meta['entity_id']);
+    }
+    if (!empty($this->table_schema['primary key']) && is_array($this->table_schema['primary key'])) {
+      return $this->table_schema['primary key'];
+    }
+    if (!empty($this->table_schema['unique keys']) && is_array($this->table_schema['unique keys'])) {
+      // Any unique key is fine for this purpose; we just return the first one
+      // (but we do some likely-unnecessary check to see if it's an array).
+      foreach ($this->table_schema['unique keys'] as $key) {
+        if (is_array($key)) {
+          return $key;
+        }
+      }
+    }
+    return array();
+  }
+
   /**
    * Determines whether a table is defined.
    *
@@ -632,6 +665,38 @@ class DataTable {
     return data_get_handler($this->name);
   }
 
+  /**
+   * Get the ID (unique value) from an item (table row or entity).
+   *
+   * This needs both the table structure (DataTable class) and the already
+   * loaded record (which has no uniform object definition), so the method was
+   * put in DataTable.
+   *
+   * @param array|object $item
+   *   The table row or entity to extract data from.
+   *
+   * @param array
+   *   An array containing all components of the ID value, keyed by the fields
+   *   in the unique key and guaranteed to be in the same order. An empty
+   *   array if any of the values is not set / NULL.
+   *
+   * @see \DataTable::getUniqueKey()
+   */
+  public function getUniqueValues($item) {
+    if (is_array($item)) {
+      $item = (object) $item;
+    }
+
+    $value = array();
+    foreach ($this->getUniqueKey() as $field) {
+      if (!isset($item->$field)) {
+        return [];
+      }
+      $value[$field] = $item->$field;
+    }
+    return $value;
+  }
+
   /**
    * Clear relevant caches. Call after operations that create, delete or modify
    * tables.
