diff --git a/core/lib/Drupal/Core/Database/Connection.php b/core/lib/Drupal/Core/Database/Connection.php
index 33d0337..61e53dd 100644
--- a/core/lib/Drupal/Core/Database/Connection.php
+++ b/core/lib/Drupal/Core/Database/Connection.php
@@ -786,6 +786,23 @@ public function merge($table, array $options = array()) {
     return new $class($this, $table, $options);
   }
 
+  /**
+   * Prepares and returns an UPSERT query object.
+   *
+   * @param string $table
+   *   The table to use for the upsert query.
+   * @param array $options
+   *   (optional) An array of options on the query.
+   *
+   * @return \Drupal\Core\Database\Query\Upsert
+   *   A new Upsert query object.
+   *
+   * @see \Drupal\Core\Database\Query\Upsert
+   */
+  public function upsert($table, array $options = array()) {
+    $class = $this->getDriverClass('Upsert');
+    return new $class($this, $table, $options);
+  }
 
   /**
    * Prepares and returns an UPDATE query object.
diff --git a/core/lib/Drupal/Core/Database/Driver/mysql/Insert.php b/core/lib/Drupal/Core/Database/Driver/mysql/Insert.php
index 8501b14..b458d36 100644
--- a/core/lib/Drupal/Core/Database/Driver/mysql/Insert.php
+++ b/core/lib/Drupal/Core/Database/Driver/mysql/Insert.php
@@ -55,30 +55,7 @@ public function __toString() {
 
     $query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
 
-    $max_placeholder = 0;
-    $values = array();
-    if (count($this->insertValues)) {
-      foreach ($this->insertValues as $insert_values) {
-        $placeholders = array();
-
-        // Default fields aren't really placeholders, but this is the most convenient
-        // way to handle them.
-        $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
-
-        $new_placeholder = $max_placeholder + count($insert_values);
-        for ($i = $max_placeholder; $i < $new_placeholder; ++$i) {
-          $placeholders[] = ':db_insert_placeholder_' . $i;
-        }
-        $max_placeholder = $new_placeholder;
-        $values[] = '(' . implode(', ', $placeholders) . ')';
-      }
-    }
-    else {
-      // If there are no values, then this is a default-only query. We still need to handle that.
-      $placeholders = array_fill(0, count($this->defaultFields), 'default');
-      $values[] = '(' . implode(', ', $placeholders) . ')';
-    }
-
+    $values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
     $query .= implode(', ', $values);
 
     return $query;
diff --git a/core/lib/Drupal/Core/Database/Driver/mysql/Upsert.php b/core/lib/Drupal/Core/Database/Driver/mysql/Upsert.php
new file mode 100644
index 0000000..6d50251
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Driver/mysql/Upsert.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Driver\mysql\Upsert.
+ */
+
+namespace Drupal\Core\Database\Driver\mysql;
+
+use Drupal\Core\Database\Query\Upsert as QueryUpsert;
+
+/**
+ * Implements the Upsert query for the MySQL database driver.
+ */
+class Upsert extends QueryUpsert {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __toString() {
+    // Create a sanitized comment string to prepend to the query.
+    $comments = $this->connection->makeComment($this->comments);
+
+    // Default fields are always placed first for consistency.
+    $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+
+    $query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
+
+    $values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
+    $query .= implode(', ', $values);
+
+    // Updating the unique / primary key is not necessary.
+    unset($insert_fields[$this->key]);
+
+    $update = [];
+    foreach ($insert_fields as $field) {
+      $update[] = "$field = VALUES($field)";
+    }
+
+    $query .= ' ON DUPLICATE KEY UPDATE ' . implode(', ', $update);
+
+    return $query;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Insert.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Insert.php
index 7bffc9e..f36f509 100644
--- a/core/lib/Drupal/Core/Database/Driver/pgsql/Insert.php
+++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Insert.php
@@ -128,30 +128,7 @@ public function __toString() {
 
     $query = $comments . 'INSERT INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
 
-    $max_placeholder = 0;
-    $values = array();
-      if (count($this->insertValues)) {
-      foreach ($this->insertValues as $insert_values) {
-        $placeholders = array();
-
-        // Default fields aren't really placeholders, but this is the most convenient
-        // way to handle them.
-        $placeholders = array_pad($placeholders, count($this->defaultFields), 'default');
-
-        $new_placeholder = $max_placeholder + count($insert_values);
-        for ($i = $max_placeholder; $i < $new_placeholder; ++$i) {
-          $placeholders[] = ':db_insert_placeholder_' . $i;
-        }
-        $max_placeholder = $new_placeholder;
-        $values[] = '(' . implode(', ', $placeholders) . ')';
-      }
-    }
-    else {
-      // If there are no values, then this is a default-only query. We still need to handle that.
-      $placeholders = array_fill(0, count($this->defaultFields), 'default');
-      $values[] = '(' . implode(', ', $placeholders) . ')';
-    }
-
+    $values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
     $query .= implode(', ', $values);
 
     return $query;
diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
index 6025a70..06b7540 100644
--- a/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
+++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php
@@ -93,9 +93,17 @@ protected function ensureIdentifiersLength($identifier) {
   public function queryTableInformation($table) {
     // Generate a key to reference this table's information on.
     $key = $this->connection->prefixTables('{' . $table . '}');
-    if (strpos($key, '.') === FALSE) {
+
+    // Take into account that temporary tables are stored in a different schema.
+    // \Drupal\Core\Database\Connection::generateTemporaryTableName() sets the
+    // 'db_temporary_' prefix to all temporary tables.
+    if (strpos($key, '.') === FALSE && strpos($table, 'db_temporary_') === FALSE) {
       $key = 'public.' . $key;
     }
+    else {
+      $schema = $this->connection->query('SELECT nspname FROM pg_namespace WHERE oid = pg_my_temp_schema()')->fetchField();
+      $key = $schema . '.' . $key;
+    }
 
     if (!isset($this->tableInformation[$key])) {
       // Split the key into schema and table for querying.
diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Upsert.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Upsert.php
new file mode 100644
index 0000000..a0a9de8
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Upsert.php
@@ -0,0 +1,118 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Driver\pgsql\Upsert.
+ */
+
+namespace Drupal\Core\Database\Driver\pgsql;
+
+use Drupal\Core\Database\Query\Upsert as QueryUpsert;
+
+/**
+ * Implements the Upsert query for the PostgreSQL database driver.
+ */
+class Upsert extends QueryUpsert {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute() {
+    if (!$this->preExecute()) {
+      return NULL;
+    }
+
+    // Default options for upsert queries.
+    $this->queryOptions += array(
+      'throw_exception' => TRUE,
+    );
+
+    // Default fields are always placed first for consistency.
+    $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+    $insert_fields_escaped = array_map(function($f) { return $this->connection->escapeField($f); }, $insert_fields);
+
+    $table = $this->connection->escapeTable($this->table);
+    $unique_key = $this->connection->escapeField($this->key);
+
+    // We have to execute multiple queries, therefore we wrap everything in a
+    // transaction so that it is atomic where possible.
+    $transaction = $this->connection->startTransaction();
+
+    try {
+      // First, create a temporary table with the same schema as the table we
+      // are trying to upsert in. This results in the following query:
+      //
+      // CREATE TEMP TABLE temp_table AS SELECT * FROM table_name LIMIT 0;
+      $query = 'SELECT * FROM {' . $table . '} LIMIT 0';
+      $temp_table = $this->connection->queryTemporary($query, [], $this->queryOptions);
+
+      // Second, insert the data in the temporary table.
+      $insert = $this->connection->insert($temp_table, $this->queryOptions)
+        ->fields($insert_fields);
+      foreach ($this->insertValues as $insert_values) {
+        $insert->values($insert_values);
+      }
+      $insert->execute();
+
+      // Third, lock the table we're upserting into.
+      $this->connection->query('LOCK TABLE {' . $table . '} IN EXCLUSIVE MODE', [], $this->queryOptions);
+
+      // Fourth, update any rows that can be updated. This results in the
+      // following query:
+      //
+      // UPDATE table_name
+      // SET column1 = temp_table.column1 [, column2 = temp_table.column2, ...]
+      // FROM temp_table
+      // WHERE table_name.id = temp_table.id;
+      $update = [];
+      foreach ($insert_fields_escaped as $field) {
+        if ($field !== $unique_key) {
+          $update[] = "$field = {" . $temp_table . "}.$field";
+        }
+      }
+
+      $update_query = 'UPDATE {' . $table . '} SET ' . implode(', ', $update);
+      $update_query .= ' FROM {' . $temp_table . '}';
+      $update_query .= ' WHERE {' . $temp_table . '}.' . $unique_key . ' = {' . $table . '}.' . $unique_key;
+      $this->connection->query($update_query, [], $this->queryOptions);
+
+      // Fifth, insert the remaining rows. This results in the following query:
+      //
+      // INSERT INTO table_name
+      // SELECT temp_table.primary_key, temp_table.column1 [, temp_table.column2 ...]
+      // FROM temp_table
+      // LEFT OUTER JOIN table_name ON (table_name.id = temp_table.id)
+      // WHERE table_name.id IS NULL;
+      $select = $this->connection->select($temp_table, 'temp_table', $this->queryOptions)
+        ->fields('temp_table', $insert_fields);
+      $select->leftJoin($this->table, 'actual_table', 'actual_table.' . $this->key . ' = temp_table.' . $this->key);
+      $select->isNull('actual_table.' . $this->key);
+
+      $this->connection->insert($this->table, $this->queryOptions)
+        ->from($select)
+        ->execute();
+    }
+    catch (\Exception $e) {
+      // One of the queries failed, rollback the whole batch.
+      $transaction->rollback();
+
+      // Rethrow the exception for the calling code.
+      throw $e;
+    }
+
+    // Re-initialize the values array so that we can re-use this query.
+    $this->insertValues = array();
+
+    // Transaction commits here where $transaction looses scope.
+
+    return TRUE;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __toString() {
+    // Nothing to do.
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Driver/sqlite/Upsert.php b/core/lib/Drupal/Core/Database/Driver/sqlite/Upsert.php
new file mode 100644
index 0000000..d382b18
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Driver/sqlite/Upsert.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Driver\sqlite\Upsert.
+ */
+
+namespace Drupal\Core\Database\Driver\sqlite;
+
+use Drupal\Core\Database\Query\Upsert as QueryUpsert;
+
+/**
+ * Implements the Upsert query for the SQLite database driver.
+ */
+class Upsert extends QueryUpsert {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __toString() {
+    // Create a sanitized comment string to prepend to the query.
+    $comments = $this->connection->makeComment($this->comments);
+
+    // Default fields are always placed first for consistency.
+    $insert_fields = array_merge($this->defaultFields, $this->insertFields);
+
+    $query = $comments . 'INSERT OR REPLACE INTO {' . $this->table . '} (' . implode(', ', $insert_fields) . ') VALUES ';
+
+    $values = $this->getInsertPlaceholderFragment($this->insertValues, $this->defaultFields);
+    $query .= implode(', ', $values);
+
+    return $query;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Query/Insert.php b/core/lib/Drupal/Core/Database/Query/Insert.php
index 453fbe3..045413a 100644
--- a/core/lib/Drupal/Core/Database/Query/Insert.php
+++ b/core/lib/Drupal/Core/Database/Query/Insert.php
@@ -16,43 +16,7 @@
  */
 class Insert extends Query {
 
-  /**
-   * The table on which to insert.
-   *
-   * @var string
-   */
-  protected $table;
-
-  /**
-   * An array of fields on which to insert.
-   *
-   * @var array
-   */
-  protected $insertFields = array();
-
-  /**
-   * An array of fields that should be set to their database-defined defaults.
-   *
-   * @var array
-   */
-  protected $defaultFields = array();
-
-  /**
-   * A nested array of values to insert.
-   *
-   * $insertValues is an array of arrays. Each sub-array is either an
-   * associative array whose keys are field names and whose values are field
-   * values to insert, or a non-associative array of values in the same order
-   * as $insertFields.
-   *
-   * Whether multiple insert sets will be run in a single query or multiple
-   * queries is left to individual drivers to implement in whatever manner is
-   * most appropriate. The order of values in each sub-array must match the
-   * order of fields in $insertFields.
-   *
-   * @var array
-   */
-  protected $insertValues = array();
+  use InsertTrait;
 
   /**
    * A SelectQuery object to fetch the rows that should be inserted.
@@ -80,96 +44,6 @@ public function __construct($connection, $table, array $options = array()) {
   }
 
   /**
-   * Adds a set of field->value pairs to be inserted.
-   *
-   * This method may only be called once. Calling it a second time will be
-   * ignored. To queue up multiple sets of values to be inserted at once,
-   * use the values() method.
-   *
-   * @param $fields
-   *   An array of fields on which to insert. This array may be indexed or
-   *   associative. If indexed, the array is taken to be the list of fields.
-   *   If associative, the keys of the array are taken to be the fields and
-   *   the values are taken to be corresponding values to insert. If a
-   *   $values argument is provided, $fields must be indexed.
-   * @param $values
-   *   An array of fields to insert into the database. The values must be
-   *   specified in the same order as the $fields array.
-   *
-   * @return \Drupal\Core\Database\Query\Insert
-   *   The called object.
-   */
-  public function fields(array $fields, array $values = array()) {
-    if (empty($this->insertFields)) {
-      if (empty($values)) {
-        if (!is_numeric(key($fields))) {
-          $values = array_values($fields);
-          $fields = array_keys($fields);
-        }
-      }
-      $this->insertFields = $fields;
-      if (!empty($values)) {
-        $this->insertValues[] = $values;
-      }
-    }
-
-    return $this;
-  }
-
-  /**
-   * Adds another set of values to the query to be inserted.
-   *
-   * If $values is a numeric-keyed array, it will be assumed to be in the same
-   * order as the original fields() call. If it is associative, it may be
-   * in any order as long as the keys of the array match the names of the
-   * fields.
-   *
-   * @param $values
-   *   An array of values to add to the query.
-   *
-   * @return \Drupal\Core\Database\Query\Insert
-   *   The called object.
-   */
-  public function values(array $values) {
-    if (is_numeric(key($values))) {
-      $this->insertValues[] = $values;
-    }
-    else {
-      // Reorder the submitted values to match the fields array.
-      foreach ($this->insertFields as $key) {
-        $insert_values[$key] = $values[$key];
-      }
-      // For consistency, the values array is always numerically indexed.
-      $this->insertValues[] = array_values($insert_values);
-    }
-    return $this;
-  }
-
-  /**
-   * Specifies fields for which the database defaults should be used.
-   *
-   * If you want to force a given field to use the database-defined default,
-   * not NULL or undefined, use this method to instruct the database to use
-   * default values explicitly. In most cases this will not be necessary
-   * unless you are inserting a row that is all default values, as you cannot
-   * specify no values in an INSERT query.
-   *
-   * Specifying a field both in fields() and in useDefaults() is an error
-   * and will not execute.
-   *
-   * @param $fields
-   *   An array of values for which to use the default values
-   *   specified in the table definition.
-   *
-   * @return \Drupal\Core\Database\Query\Insert
-   *   The called object.
-   */
-  public function useDefaults(array $fields) {
-    $this->defaultFields = $fields;
-    return $this;
-  }
-
-  /**
    * Sets the fromQuery on this InsertQuery object.
    *
    * @param \Drupal\Core\Database\Query\SelectInterface $query
@@ -265,13 +139,13 @@ public function __toString() {
   /**
    * Preprocesses and validates the query.
    *
-   * @return
+   * @return bool
    *   TRUE if the validation was successful, FALSE if not.
    *
    * @throws \Drupal\Core\Database\Query\FieldsOverlapException
    * @throws \Drupal\Core\Database\Query\NoFieldsException
    */
-  public function preExecute() {
+  protected function preExecute() {
     // Confirm that the user did not try to specify an identical
     // field and default field.
     if (array_intersect($this->insertFields, $this->defaultFields)) {
diff --git a/core/lib/Drupal/Core/Database/Query/InsertTrait.php b/core/lib/Drupal/Core/Database/Query/InsertTrait.php
new file mode 100644
index 0000000..551e870
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Query/InsertTrait.php
@@ -0,0 +1,184 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Query\InsertTrait.
+ */
+
+namespace Drupal\Core\Database\Query;
+
+/**
+ * Provides common functionality for INSERT and UPSERT queries.
+ *
+ * @ingroup database
+ */
+trait InsertTrait {
+
+  /**
+   * The table on which to insert.
+   *
+   * @var string
+   */
+  protected $table;
+
+  /**
+   * An array of fields on which to insert.
+   *
+   * @var array
+   */
+  protected $insertFields = array();
+
+  /**
+   * An array of fields that should be set to their database-defined defaults.
+   *
+   * @var array
+   */
+  protected $defaultFields = array();
+
+  /**
+   * A nested array of values to insert.
+   *
+   * $insertValues is an array of arrays. Each sub-array is either an
+   * associative array whose keys are field names and whose values are field
+   * values to insert, or a non-associative array of values in the same order
+   * as $insertFields.
+   *
+   * Whether multiple insert sets will be run in a single query or multiple
+   * queries is left to individual drivers to implement in whatever manner is
+   * most appropriate. The order of values in each sub-array must match the
+   * order of fields in $insertFields.
+   *
+   * @var array
+   */
+  protected $insertValues = array();
+
+  /**
+   * Adds a set of field->value pairs to be inserted.
+   *
+   * This method may only be called once. Calling it a second time will be
+   * ignored. To queue up multiple sets of values to be inserted at once,
+   * use the values() method.
+   *
+   * @param array $fields
+   *   An array of fields on which to insert. This array may be indexed or
+   *   associative. If indexed, the array is taken to be the list of fields.
+   *   If associative, the keys of the array are taken to be the fields and
+   *   the values are taken to be corresponding values to insert. If a
+   *   $values argument is provided, $fields must be indexed.
+   * @param array $values
+   *   (optional) An array of fields to insert into the database. The values
+   *   must be specified in the same order as the $fields array.
+   *
+   * @return $this
+   *   The called object.
+   */
+  public function fields(array $fields, array $values = array()) {
+    if (empty($this->insertFields)) {
+      if (empty($values)) {
+        if (!is_numeric(key($fields))) {
+          $values = array_values($fields);
+          $fields = array_keys($fields);
+        }
+      }
+      $this->insertFields = $fields;
+      if (!empty($values)) {
+        $this->insertValues[] = $values;
+      }
+    }
+
+    return $this;
+  }
+
+  /**
+   * Adds another set of values to the query to be inserted.
+   *
+   * If $values is a numeric-keyed array, it will be assumed to be in the same
+   * order as the original fields() call. If it is associative, it may be
+   * in any order as long as the keys of the array match the names of the
+   * fields.
+   *
+   * @param array $values
+   *   An array of values to add to the query.
+   *
+   * @return $this
+   *   The called object.
+   */
+  public function values(array $values) {
+    if (is_numeric(key($values))) {
+      $this->insertValues[] = $values;
+    }
+    elseif ($this->insertFields) {
+      // Reorder the submitted values to match the fields array.
+      foreach ($this->insertFields as $key) {
+        $insert_values[$key] = $values[$key];
+      }
+      // For consistency, the values array is always numerically indexed.
+      $this->insertValues[] = array_values($insert_values);
+    }
+    return $this;
+  }
+
+  /**
+   * Specifies fields for which the database defaults should be used.
+   *
+   * If you want to force a given field to use the database-defined default,
+   * not NULL or undefined, use this method to instruct the database to use
+   * default values explicitly. In most cases this will not be necessary
+   * unless you are inserting a row that is all default values, as you cannot
+   * specify no values in an INSERT query.
+   *
+   * Specifying a field both in fields() and in useDefaults() is an error
+   * and will not execute.
+   *
+   * @param array $fields
+   *   An array of values for which to use the default values
+   *   specified in the table definition.
+   *
+   * @return $this
+   *   The called object.
+   */
+  public function useDefaults(array $fields) {
+    $this->defaultFields = $fields;
+    return $this;
+  }
+
+  /**
+   * Returns the query placeholders for values that will be inserted.
+   *
+   * @param array $nested_insert_values
+   *   A nested array of values to insert.
+   * @param array $default_fields
+   *   An array of fields that should be set to their database-defined defaults.
+   *
+   * @return array
+   *   An array of insert placeholders.
+   */
+  protected function getInsertPlaceholderFragment(array $nested_insert_values, array $default_fields) {
+    $max_placeholder = 0;
+    $values = array();
+    if ($nested_insert_values) {
+      foreach ($nested_insert_values as $insert_values) {
+        $placeholders = array();
+
+        // Default fields aren't really placeholders, but this is the most convenient
+        // way to handle them.
+        $placeholders = array_pad($placeholders, count($default_fields), 'default');
+
+        $new_placeholder = $max_placeholder + count($insert_values);
+        for ($i = $max_placeholder; $i < $new_placeholder; ++$i) {
+          $placeholders[] = ':db_insert_placeholder_' . $i;
+        }
+        $max_placeholder = $new_placeholder;
+        $values[] = '(' . implode(', ', $placeholders) . ')';
+      }
+    }
+    else {
+      // If there are no values, then this is a default-only query. We still need to handle that.
+      $placeholders = array_fill(0, count($default_fields), 'default');
+      $values[] = '(' . implode(', ', $placeholders) . ')';
+    }
+
+    return $values;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Database/Query/NoUniqueFieldException.php b/core/lib/Drupal/Core/Database/Query/NoUniqueFieldException.php
new file mode 100644
index 0000000..9dade14
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Query/NoUniqueFieldException.php
@@ -0,0 +1,15 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Query\NoUniqueFieldException.
+ */
+
+namespace Drupal\Core\Database\Query;
+
+use Drupal\Core\Database\DatabaseException;
+
+/**
+ * Exception thrown if an upsert query doesn't specify a unique field.
+ */
+class NoUniqueFieldException extends \InvalidArgumentException implements DatabaseException {}
diff --git a/core/lib/Drupal/Core/Database/Query/Upsert.php b/core/lib/Drupal/Core/Database/Query/Upsert.php
new file mode 100644
index 0000000..e9b5d0e
--- /dev/null
+++ b/core/lib/Drupal/Core/Database/Query/Upsert.php
@@ -0,0 +1,119 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Database\Query\Upsert.
+ */
+
+namespace Drupal\Core\Database\Query;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\Database\Database;
+
+/**
+ * General class for an abstracted "Upsert" (UPDATE or INSERT) query operation.
+ *
+ * This class can only be used with a table with a single unique index.
+ * Often, this will be the primary key. On such a table this class works like
+ * Insert except the rows will be set to the desired values even if the key
+ * existed before.
+ */
+abstract class Upsert extends Query {
+
+  use InsertTrait;
+
+  /**
+   * The unique or primary key of the table.
+   *
+   * @var string
+   */
+  protected $key;
+
+  /**
+   * Constructs an Upsert object.
+   *
+   * @param \Drupal\Core\Database\Connection $connection
+   *   A Connection object.
+   * @param string $table
+   *   Name of the table to associate with this query.
+   * @param array $options
+   *   (optional) An array of database options.
+   */
+  public function __construct(Connection $connection, $table, array $options = []) {
+    $options['return'] = Database::RETURN_AFFECTED;
+    parent::__construct($connection, $options);
+    $this->table = $table;
+  }
+
+  /**
+   * Sets the unique / primary key field to be used as condition for this query.
+   *
+   * @param string $field
+   *   The name of the field to set.
+   *
+   * @return $this
+   */
+  public function key($field) {
+    $this->key = $field;
+
+    return $this;
+  }
+
+  /**
+   * Preprocesses and validates the query.
+   *
+   * @return bool
+   *   TRUE if the validation was successful, FALSE otherwise.
+   *
+   * @throws \Drupal\Core\Database\Query\NoUniqueFieldException
+   * @throws \Drupal\Core\Database\Query\FieldsOverlapException
+   * @throws \Drupal\Core\Database\Query\NoFieldsException
+   */
+  protected function preExecute() {
+    // Confirm that the user set the unique/primary key of the table.
+    if (!$this->key) {
+      throw new NoUniqueFieldException('There is no unique field specified.');
+    }
+
+    // Confirm that the user did not try to specify an identical
+    // field and default field.
+    if (array_intersect($this->insertFields, $this->defaultFields)) {
+      throw new FieldsOverlapException('You may not specify the same field to have a value and a schema-default value.');
+    }
+
+    // Don't execute query without fields.
+    if (count($this->insertFields) + count($this->defaultFields) == 0) {
+      throw new NoFieldsException('There are no fields available to insert with.');
+    }
+
+    // If no values have been added, silently ignore this query. This can happen
+    // if values are added conditionally, so we don't want to throw an
+    // exception.
+    return isset($this->insertValues[0]) || $this->insertFields;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function execute() {
+    if (!$this->preExecute()) {
+      return NULL;
+    }
+
+    $max_placeholder = 0;
+    $values = array();
+    foreach ($this->insertValues as $insert_values) {
+      foreach ($insert_values as $value) {
+        $values[':db_insert_placeholder_' . $max_placeholder++] = $value;
+      }
+    }
+
+    $last_insert_id = $this->connection->query((string) $this, $values, $this->queryOptions);
+
+    // Re-initialize the values array so that we can re-use this query.
+    $this->insertValues = array();
+
+    return $last_insert_id;
+  }
+
+}
diff --git a/core/modules/system/src/Tests/Database/UpsertTest.php b/core/modules/system/src/Tests/Database/UpsertTest.php
new file mode 100644
index 0000000..bd4db35
--- /dev/null
+++ b/core/modules/system/src/Tests/Database/UpsertTest.php
@@ -0,0 +1,61 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\system\Tests\Database\UpsertTest.
+ */
+
+namespace Drupal\system\Tests\Database;
+
+use Drupal\Core\Database\Database;
+
+/**
+ * Tests the Upsert query builder.
+ *
+ * @group Database
+ */
+class UpsertTest extends DatabaseTestBase {
+
+  /**
+   * Confirms that we can upsert (update-or-insert) records successfully.
+   */
+  public function testUpsert() {
+    $connection = Database::getConnection();
+    $num_records_before = $connection->query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+
+    $upsert = $connection->upsert('test_people')
+      ->key('job')
+      ->fields(['job', 'age', 'name']);
+
+    // Add a new row.
+    $upsert->values([
+      'job' => 'Presenter',
+      'age' => 31,
+      'name' => 'Tiffany',
+    ]);
+
+    // Update an existing row.
+    $upsert->values([
+      'job' => 'Speaker',
+      // The initial age was 30.
+      'age' => 32,
+      'name' => 'Meredith',
+    ]);
+
+    $upsert->execute();
+
+    $num_records_after = $connection->query('SELECT COUNT(*) FROM {test_people}')->fetchField();
+    $this->assertEqual($num_records_before + 1, $num_records_after, 'Rows were inserted and updated properly.');
+
+    $person = $connection->query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Presenter'))->fetch();
+    $this->assertEqual($person->job, 'Presenter', 'Job set correctly.');
+    $this->assertEqual($person->age, 31, 'Age set correctly.');
+    $this->assertEqual($person->name, 'Tiffany', 'Name set correctly.');
+
+    $person = $connection->query('SELECT * FROM {test_people} WHERE job = :job', array(':job' => 'Speaker'))->fetch();
+    $this->assertEqual($person->job, 'Speaker', 'Job was not changed.');
+    $this->assertEqual($person->age, 32, 'Age updated correctly.');
+    $this->assertEqual($person->name, 'Meredith', 'Name was not changed.');
+  }
+
+}
