diff --git a/drush.services.yml b/drush.services.yml
index c671c4c596ba7a09c720045c0aec941a6b16477e..6a34128abc2d177009cac0068d9a16e41d4b71cb 100644
--- a/drush.services.yml
+++ b/drush.services.yml
@@ -1,6 +1,6 @@
 services:
   salesforce.commands:
     class: \Drupal\salesforce\Commands\SalesforceCommands
-    arguments: ['@salesforce.client']
+    arguments: ['@salesforce.client', '@entity_type.manager']
     tags:
       - { name: drush.command }
diff --git a/modules/salesforce_pull/composer.json b/modules/salesforce_pull/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..12ab949838d18544e56e620e6808765369ee821a
--- /dev/null
+++ b/modules/salesforce_pull/composer.json
@@ -0,0 +1,21 @@
+{
+    "name": "org/salesforce_pull",
+    "description": "This extension provides new commands for Drush.",
+    "type": "drupal-drush",
+    "authors": [
+        {
+            "name": "Author name",
+            "email": "author@example.com"
+        }
+    ],
+    "require": {
+        "php": ">=5.6.0"
+    },
+    "extra": {
+        "drush": {
+            "services": {
+                "drush.services.yml": "^9"
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/modules/salesforce_pull/drush.services.yml b/modules/salesforce_pull/drush.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b66c6dde69578fc3645cf7655e3a4b8a169c6830
--- /dev/null
+++ b/modules/salesforce_pull/drush.services.yml
@@ -0,0 +1,6 @@
+services:
+  salesforce_pull.commands:
+    class: \Drupal\salesforce_pull\Commands\SalesforcePullCommands
+    arguments: ['@salesforce.client', '@entity_type.manager', '@salesforce_pull.queue_handler', '@event_dispatcher']
+    tags:
+      - { name: drush.command }
diff --git a/modules/salesforce_pull/salesforce_pull.drush.inc b/modules/salesforce_pull/salesforce_pull.drush.inc
index e52931657b6d5e8350cf02c0f2e833eca078521d..5338399cccb8c2490e2237d960bf1e7a0a75d25b 100644
--- a/modules/salesforce_pull/salesforce_pull.drush.inc
+++ b/modules/salesforce_pull/salesforce_pull.drush.inc
@@ -90,8 +90,10 @@ function salesforce_pull_drush_command() {
  * Queues records for pull from salesforce for the given mapping.
  *
  * @param string $name
+ * @deprecated Support for drush 8 is deprecated and will be removed in a future release.
  */
 function drush_salesforce_pull_sf_pull_query($name) {
+  _drush_salesforce_deprecated();
   if (!($mapping = _salesforce_drush_get_mapping($name))) {
     return;
   }
@@ -142,8 +144,10 @@ function drush_salesforce_pull_sf_pull_query($name) {
  *
  * @param string $file
  * @param string $name
+ * @deprecated Support for drush 8 is deprecated and will be removed in a future release.
  */
 function drush_salesforce_pull_sf_pull_file($file, $name = NULL) {
+  _drush_salesforce_deprecated();
   if (empty($file)) {
     drush_log("File argument is required.", 'error');
     drush_log("usage:\n  drush sf-pull-file file_name [mapping_id]", 'error');
@@ -242,7 +246,7 @@ function drush_salesforce_pull_sf_pull_file($file, $name = NULL) {
 }
 
 /**
- *
+ * @deprecated Support for drush 8 is deprecated and will be removed in a future release.
  */
 function _salesforce_pull_load_single_mapping_array_or_all_pull_mappings($name = NULL) {
   if ($name != NULL) {
@@ -263,7 +267,7 @@ function _salesforce_pull_load_single_mapping_array_or_all_pull_mappings($name =
 }
 
 /**
- *
+ * @deprecated Support for drush 8 is deprecated and will be removed in a future release.
  */
 function drush_salesforce_pull_sf_pull_reset($name = NULL) {
   $mappings = _salesforce_pull_load_single_mapping_array_or_all_pull_mappings($name);
@@ -278,7 +282,11 @@ function drush_salesforce_pull_sf_pull_reset($name = NULL) {
   }
 }
 
+/**
+ * @deprecated Support for drush 8 is deprecated and will be removed in a future release.
+ */
 function drush_salesforce_pull_sf_pull_set($name, $time = NULL) {
+  _drush_salesforce_deprecated();
   if (is_null($time)) {
     $time = time();
   }
diff --git a/modules/salesforce_pull/src/Commands/SalesforcePullCommands.php b/modules/salesforce_pull/src/Commands/SalesforcePullCommands.php
new file mode 100644
index 0000000000000000000000000000000000000000..2c97ae2a7d5d0599970328ecfb6044dbf0316056
--- /dev/null
+++ b/modules/salesforce_pull/src/Commands/SalesforcePullCommands.php
@@ -0,0 +1,325 @@
+<?php
+
+namespace Drupal\salesforce_pull\Commands;
+
+use Drupal\Component\EventDispatcher\ContainerAwareEventDispatcher;
+use Drupal\Core\Entity\EntityTypeManager;
+use Drupal\salesforce\Commands\SalesforceCommandsBase;
+use Drupal\salesforce\Event\SalesforceEvents;
+use Drupal\salesforce\Rest\RestClient;
+use Drupal\salesforce\SFID;
+use Drupal\salesforce_mapping\Event\SalesforceQueryEvent;
+use Drupal\salesforce_pull\QueueHandler;
+use Symfony\Component\Console\Input\Input;
+use Symfony\Component\Console\Output\Output;
+
+/**
+ * A Drush commandfile.
+ *
+ * In addition to this file, you need a drush.services.yml
+ * in root of your module, and a composer.json file that provides the name
+ * of the services file to use.
+ *
+ * See these files for an example of injecting Drupal services:
+ *   - http://cgit.drupalcode.org/devel/tree/src/Commands/DevelCommands.php
+ *   - http://cgit.drupalcode.org/devel/tree/drush.services.yml
+ */
+class SalesforcePullCommands extends SalesforceCommandsBase {
+
+  /** @var \Drupal\salesforce_pull\QueueHandler */
+  protected $pullQueue;
+
+  /** @var \Symfony\Component\EventDispatcher\EventDispatcher */
+  protected $eventDispatcher;
+
+  /**
+   * SalesforcePullCommands constructor.
+   *
+   * @param \Drupal\salesforce\Rest\RestClient $client
+   * @param \Drupal\Core\Entity\EntityTypeManager $etm
+   * @param \Drupal\salesforce_pull\QueueHandler $pullQueue
+   */
+  public function __construct(RestClient $client, EntityTypeManager $etm, QueueHandler $pullQueue, ContainerAwareEventDispatcher $eventDispatcher) {
+    parent::__construct($client, $etm);
+    $this->pullQueue = $pullQueue;
+    $this->eventDispatcher = $eventDispatcher;
+  }
+
+  /**
+   * @hook interact salesforce_pull:pull-query
+   */
+  public function interactPullQuery(Input $input, Output $output) {
+    return $this->interactPullMappings($input, $output, $message = 'Choose a Salesforce mapping', 'Pull All');
+  }
+
+  /**
+   * @hook interact salesforce_pull:pull-file
+   */
+  public function interactPullFile(Input $input, Output $output) {
+    $file = $input->getArgument('file');
+    if (empty($file)) {
+      return;
+    }
+    if (!file_exists($file)) {
+      $this->logger()->error('File does not exist');
+      return;
+    }
+
+    return $this->interactPullMappings($input, $output, $message = 'Choose a Salesforce mapping', FALSE);
+  }
+
+  /**
+   * @hook interact salesforce_pull:pull-reset
+   */
+  public function interactPullReset(Input $input, Output $output) {
+    return $this->interactPullMappings($input, $output, $message = 'Choose a Salesforce mapping', 'Reset All');
+  }
+
+  /**
+   * @hook interact salesforce_pull:pull-set
+   */
+  public function interactPullSet(Input $input, Output $output) {
+    return $this->interactPullMappings($input, $output, $message = 'Choose a Salesforce mapping', 'Set All');
+  }
+
+  /**
+   * Given a mapping, enqueue records for pull from Salesforce, ignoring modification timestamp. This command is useful, for example, when seeding content for a Drupal site prior to deployment.
+   *
+   * @param $name
+   *   Machine name of the Salesforce Mapping for which to queue pull records.
+   * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
+   *
+   * @option where
+   *   A WHERE clause to add to the SOQL pull query. Default behavior is to query and pull all records.
+   * @option start
+   *   strtotime()able string for the start timeframe over which to pull, e.g. "-5 hours". If omitted, use the value given by the mapping's pull timestamp. Must be in the past.
+   * @option stop
+   *   strtotime()able string for the end timeframe over which to pull, e.g. "-5 hours". If omitted, defaults to "now". Must be "now" or earlier
+   * @option force-pull
+   *   if given, force all queried records to be pulled regardless of updated timestamps. If omitted, only Salesforce records which are newer than linked Drupal records will be pulled.
+   * @usage drush sfpq user
+   *   Query and queue all records for "user" Salesforce mapping.
+   * @usage drush sfpq user --where="Email like '%foo%' AND (LastName = 'bar' OR FirstName = 'bar')"
+   *   Query and queue all records for "user" Salesforce mapping with Email field containing the string "foo" and First or Last name equal to "bar"
+   * @usage drush sfpq
+   *   Fetch and process all pull queue items
+   * @usage drush sfpq --start="-25 minutes" --stop="-5 minutes"
+   *   Fetch updated records for all mappings between 25 minutes and 5 minutes old, and process them.
+   * @usage drush sfpq foo --start="-25 minutes" --stop="-5 minutes"
+   *   Fetch updated records for mapping "foo" between 25 minutes and 5 minutes old, and process them.
+   *
+   * @command salesforce_pull:pull-query
+   * @aliases sfpq,sfiq,sf-pull-query,salesforce_pull:query
+   */
+  public function pullQuery($name, array $options = ['where' => '', 'start' => 0, 'stop' => 0, 'force-pull' => FALSE]) {
+    $mappings = $this->getPullMappingsFromName($name);
+    $start = $options['start'] ? strtotime($options['start']) : 0;
+    $stop = $options['stop'] ? strtotime($options['stop']) : 0;
+    if ($start > $stop) {
+      $this->logger()->error(dt('Stop date-time must be later than start date-time.'));
+      return;
+    }
+
+    foreach ($mappings as $mapping) {
+      if (!($soql = $mapping->getPullQuery([], $start, $stop))) {
+        $this->logger()->error(dt('!mapping: Unable to generate pull query. Does this mapping have any Salesforce Action Triggers enabled?', ['!mapping' => $mapping->id()]));
+        continue;
+      }
+
+      if ($options['where']) {
+        $soql->conditions[] = [$options['where']];
+      }
+
+      $this->eventDispatcher->dispatch(
+        SalesforceEvents::PULL_QUERY,
+        new SalesforceQueryEvent($mapping, $soql)
+      );
+
+      $this->logger()->info(dt('!mapping: Issuing pull query: !query', [
+        '!query' => (string) $soql,
+        '!mapping' => $mapping->id()
+      ]));
+      $results = $this->client->query($soql);
+
+      if (empty($results)) {
+        $this->logger()->warning(dt('!mapping: No records found to pull.', ['!mapping' => $mapping->id()]));
+        return;
+      }
+
+      $this->pullQueue->enqueueAllResults($mapping, $results, $options['force-pull']);
+
+      $this->logger()->info(dt('!mapping: Queued !count items for pull.', [
+        '!count' => $results->size(),
+        '!mapping' => $mapping->id()
+      ]));
+    }
+  }
+
+  /**
+   * Given a mapping, enqueue a list of object IDs to be pulled from a CSV file, e.g. a Salesforce report. The first column of the CSV file must be SFIDs. Additional columns will be ignored.
+   *
+   * @param $file
+   *   CSV file name of 15- or 18-character Salesforce ids to be pulled. 
+   * @param $name
+   *   Machine name of the Salesforce Mapping for which to queue pull records.
+   *
+   * @command salesforce_pull:pull-file
+   * @aliases sfpf,sfif,sf-pull-file,salesforce_pull:file
+   */
+  public function pullFile($file, $name) {
+    /** @var \Drupal\salesforce_mapping\Entity\SalesforceMapping $mapping */
+    if (!($mapping = $this->mappingStorage->load($name))) {
+      $this->logger()->error(dt('Failed to load mapping "%name"', ['%name' => $name]));
+      return;
+    }
+
+    // Fetch the base query to make sure we can pull using this mapping.
+    $soql = $mapping->getPullQuery([], 1, 0);
+    if (empty($soql)) {
+      $this->logger()->error(dt('Failed to load mapping "%name"', ['%name' => $name]));
+      return;
+    }
+
+    $rows = array_map('str_getcsv', file($file));
+
+    // Track IDs to avoid duplicates.
+    $seen = [];
+
+    // Max length for SOQL query is 20,000 characters. Chunk the IDs into smaller
+    // units to avoid this limit. 1000 IDs per query * 18 chars per ID = up to
+    // 18000 characters per query, plus up to 2000 for fields, where condition,
+    // etc.
+    $queries = [];
+    foreach (array_chunk($rows, 1000) as $i => $chunk) {
+      $base = $i * 1000;
+      // Reset our base query:
+      $soql = $mapping->getPullQuery([], 1, 0);
+
+      // Now add all the IDs to it.
+      $sfids = [];
+      foreach ($chunk as $j => $row) {
+        if (empty($row) || empty($row[0])) {
+          $this->logger->warning(dt('Skipping row !n, no SFID found.', ['!n' => $j]));
+          continue;
+        }
+        try {
+          $sfid = new SFID($row[0]);
+          // Sanity check to make sure the key-prefix is correct.
+          // If so, this is probably a good SFID.
+          // If not, it is definitely not a good SFID.
+          if ($mapping->getSalesforceObjectType() != $this->client->getObjectTypeName($sfid)) {
+            $this->logger()->error(dt('SFID !sfid does not match type !type', ['!sfid' => (string)$sfid, '!type' => $mapping->getSalesforceObjectType()]));
+            continue;
+          }
+        }
+        catch (\Exception $e) {
+          $this->logger->warning(dt('Skipping row !n, no SFID found.', ['!n' => $j]));
+          continue;
+        }
+        $sfid = (string) $sfid;
+        if (empty($sfids[$sfid])) {
+          $sfids[] = $sfid;
+          $seen[$sfid] = $sfid;
+        }
+      }
+      $soql->addCondition('Id', $sfids, 'IN');
+      $queries[] = $soql;
+    }
+    if (empty($seen)) {
+      $this->logger()->error('No SFIDs found in the given file.');
+      return;
+    }
+
+    if (!$this->io()->confirm(dt('Ready to enqueue !count records for pull?', ['!count' => count($seen)]))) {
+      return;
+    }
+
+    foreach ($queries as $soql) {
+      $this->eventDispatcher->dispatch(
+        SalesforceEvents::PULL_QUERY,
+        new SalesforceQueryEvent($mapping, $soql)
+      );
+
+      $this->logger()->info(dt('Issuing pull query: !query', ['!query' => (string) $soql]));
+
+      $results = $this->client->query($soql);
+
+      if (empty($results)) {
+        $this->logger()->warning('No records found to pull.');
+        continue;
+      }
+
+      $this->pullQueue->enqueueAllResults($mapping, $results);
+      $this->logger()->info(dt('Queued !count items for pull.', ['!count' => $results->size()]));
+    }
+  }
+
+  /**
+   * Reset pull timestamps for one or all Salesforce Mappings, and set all mapped objects to be force-pulled.
+   *
+   * @param string $name
+   *   mapping id.
+    * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
+   * @option delete
+   *   Reset delete date timestamp (instead of pull date timestamp)
+   * @usage drush sf-pull-reset
+   *   Reset pull timestamps for all mappings.
+   * @usage drush sf-pull-reset foo
+   *   Reset pull timestamps for mapping "foo"
+   * @usage drush sf-pull-reset --delete
+   *   Reset "delete" timestamps for all mappings
+   * @usage drush sf-pull-reset foo --delete
+   *   Reset "delete" timestamp for mapping "foo"
+   *
+   * @command salesforce_pull:pull-reset
+   * @aliases sf-pull-reset,salesforce_pull:reset
+   */
+  public function pullReset($name, array $options = ['delete' => null]) {
+    $mappings = $this->getPullMappingsFromName($name);
+    foreach ($mappings as $mapping) {
+      if ($options['delete']) {
+        $mapping->setLastDeleteTime(NULL);
+      }
+      else {
+        $mapping->setLastPullTime(NULL);
+      }
+      \Drupal::entityTypeManager()
+        ->getStorage('salesforce_mapped_object')
+        ->setForcePull($mapping);
+      $this->logger()->info(dt('Pull timestamp reset for !name', ['!name' => $name]));
+    }
+  }
+
+  /**
+   * Set pull timestamp on a single Salesforce Mappings to a specific point in history (or now).
+   *
+   * @param $name
+   *   mapping id.
+   * @param int $time
+   *   timestamp.
+   * @option delete
+   *   Reset delete date timestamp (instead of pull date timestamp)
+   * @usage drush sf-pull-set foo
+   *   Set pull timestamps for mapping "foo" to "now"
+   * @usage drush sf-pull-set foo 1517416761
+   *   Set pull timestamps for mapping "foo" to 2018-01-31T15:39:21+00:00
+   *
+   * @command salesforce_pull:pull-set
+   * @aliases sf-pull-set,salesforce_pull:set
+   */
+  public function pullSet($name, $time, $options = ['delete' => null]) {
+    $mappings = $this->getPullMappingsFromName($name);
+    foreach ($mappings as $mapping) {
+      $mapping->setLastPullTime(NULL);
+      if ($options['delete']) {
+        $mapping->setLastDeleteTime($time);
+      }
+      else {
+        $mapping->setLastPullTime($time);
+      }
+      $this->mappedObjectStorage->setForcePull($mapping);
+      $this->logger()->info(dt('Pull timestamp reset for !name', ['!name' => $name]));
+    }
+  }
+
+}
diff --git a/salesforce.install b/salesforce.install
index 78b8b54269499c423fdaacb60878b0692f96d831..804acf8c2ebb59c9974ee469fff5c9290e6643f4 100644
--- a/salesforce.install
+++ b/salesforce.install
@@ -197,3 +197,10 @@ function salesforce_update_8003() {
   ];
   \Drupal::state()->deleteMultiple($delete);
 }
+
+/**
+ * Clear salesforce:objects cache, whose structure has changed.
+ */
+function salesforce_update_8004() {
+  \Drupal::cache()->delete('salesforce:objects');
+}
diff --git a/src/Commands/QueryResult.php b/src/Commands/QueryResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..92dda9dbfd1fc76d6f12a87357db5eacac480a86
--- /dev/null
+++ b/src/Commands/QueryResult.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\salesforce\Commands;
+
+use Consolidation\OutputFormatters\StructuredData\RowsOfFieldsWithMetadata;
+use Drupal\salesforce\SelectQueryInterface;
+use Drupal\salesforce\SelectQueryResult;
+
+class QueryResult extends RowsOfFieldsWithMetadata {
+
+  protected $size;
+  protected $total;
+  protected $query;
+
+  public function __construct(SelectQueryInterface $query, SelectQueryResult $queryResult) {
+    print_r($queryResult->records());
+    $data = [];
+    foreach ($queryResult->records() as $id => $record) {
+      $data[$id] = $record->fields();
+    }
+    parent::__construct($data);
+    $this->size = count($queryResult->records());
+    $this->total = $queryResult->size();
+    $this->query = $query;
+  }
+
+  /**
+   * @return int
+   */
+  public function getSize() {
+    return $this->size;
+  }
+
+  /**
+   * @return mixed
+   */
+  public function getTotal() {
+    return $this->total;
+  }
+
+  /**
+   * @return \Drupal\salesforce\SelectQuery
+   */
+  public function getQuery() {
+    return $this->query;
+  }
+
+  public function getPrettyQuery() {
+    return str_replace('+', ' ', (string) $this->query);
+  }
+
+}
\ No newline at end of file
diff --git a/src/Commands/QueryResultTableFormatter.php b/src/Commands/QueryResultTableFormatter.php
new file mode 100644
index 0000000000000000000000000000000000000000..4701bb0cf98497aad3a00d61fede2be54483e911
--- /dev/null
+++ b/src/Commands/QueryResultTableFormatter.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\salesforce\Commands;
+
+use Consolidation\OutputFormatters\Formatters\TableFormatter;
+use Consolidation\OutputFormatters\Options\FormatterOptions;
+use Symfony\Component\Console\Output\OutputInterface;
+
+class QueryResultTableFormatter extends TableFormatter {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validDataTypes() {
+    return [
+      new \ReflectionClass('\Drupal\salesforce\Commands\QueryResult'),
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function writeMetadata(OutputInterface $output, $query, FormatterOptions $options) {
+    $output->writeln(str_pad(' ', 10 + strlen($query->getPrettyQuery()), '-'));
+    $output->writeln(dt('  Size: !size', ['!size' => $query->getSize()]));
+    $output->writeln(dt('  Total: !total', ['!total' => $query->getTotal()]));
+    $output->writeln(dt('  Query: !query', ['!query' => $query->getPrettyQuery()]));
+  }
+
+}
\ No newline at end of file
diff --git a/src/Commands/SalesforceCommands.php b/src/Commands/SalesforceCommands.php
index 7e9f43bee125525ada69d324b0f005d102894ff3..e83a41165425e59e7376c4a26c9cf17aa02bf4f9 100644
--- a/src/Commands/SalesforceCommands.php
+++ b/src/Commands/SalesforceCommands.php
@@ -2,22 +2,17 @@
 
 namespace Drupal\salesforce\Commands;
 
-use Consolidation\OutputFormatters\Formatters\TableFormatter;
-use Consolidation\OutputFormatters\Formatters\VarDumpFormatter;
-use Consolidation\OutputFormatters\Options\FormatterOptions;
 use Consolidation\OutputFormatters\StructuredData\PropertyList;
 use Consolidation\OutputFormatters\StructuredData\RowsOfFields;
-use Consolidation\OutputFormatters\StructuredData\RowsOfFieldsWithMetadata;
-use Drupal\salesforce\Rest\RestClient;
+use Drupal\salesforce\Rest\RestException;
 use Drupal\salesforce\SelectQuery;
+use Drupal\salesforce\SelectQueryRaw;
 use Drupal\salesforce\SFID;
-use Drush\Commands\DrushCommands;
 use Drush\Exceptions\UserAbortException;
 use Symfony\Component\Console\Helper\Table;
 use Symfony\Component\Console\Helper\TableCell;
 use Symfony\Component\Console\Input\Input;
 use Symfony\Component\Console\Output\Output;
-use Symfony\Component\Translation\Util\ArrayConverter;
 
 /**
  * A Drush commandfile.
@@ -30,14 +25,7 @@ use Symfony\Component\Translation\Util\ArrayConverter;
  *   - http://cgit.drupalcode.org/devel/tree/src/Commands/DevelCommands.php
  *   - http://cgit.drupalcode.org/devel/tree/drush.services.yml
  */
-class SalesforceCommands extends DrushCommands {
-
-  /** @var \Drupal\salesforce\Rest\RestClient */
-  protected $client;
-
-  public function __construct(RestClient $client) {
-    $this->client = $client;
-  }
+class SalesforceCommands extends SalesforceCommandsBase {
 
   /**
    * Display information about the current REST API version.
@@ -147,24 +135,12 @@ class SalesforceCommands extends DrushCommands {
     return $this->interactObject($input, $output);
   }
 
-  /**
-   * If there's a way to attach multiple hooks to one method, please do it here!
-   */
-  protected function interactObject(Input $input, Output $output, $message = 'Enter a Salesforce object to describe') {
-    if (!$input->getArgument('object')) {
-      if (!$answer = $this->io()->ask($message)) {
-        throw new UserAbortException();
-      }
-      $input->setArgument('object', $answer);
-    }
-  }
-
   /**
    * Retrieve all the metadata for an object, including information about each field, URLs, and child relationships.
    *
    * @param $object
    *   The object name in Salesforce.
-    * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
+   * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
    * @option output
    *   Specify an output type.
    *   Options are:
@@ -193,13 +169,15 @@ class SalesforceCommands extends DrushCommands {
   /**
    * Dump the raw describe response for given object.
    *
+   * @todo create a proper StructuredData return value for this.
+   *
    * @command salesforce:dump-object
    * @aliases sf-dump-object
    */
   public function dumpObject($object) {
     $objectDescription = $this->client->objectDescribe($object);
     if (!is_object($objectDescription)) {
-      throw new \Exception(dt('Could not load data for object !object', ['!object' => $object]));
+      $this->logger()->error(dt('Could not load data for object !object', ['!object' => $object]));
     }
     $this->output()->writeln(print_r($objectDescription->data, 1));
   }
@@ -230,7 +208,8 @@ class SalesforceCommands extends DrushCommands {
   public function describeRecordTypes($object) {
     $objectDescription = $this->client->objectDescribe($object);
     if (!is_object($objectDescription)) {
-      throw new \Exception(dt('Could not load data for object !object', ['!object' => $object]));
+      $this->logger()->error(dt('Could not load data for object !object', ['!object' => $object]));
+      return;
     }
     $data = $objectDescription->data['recordTypeInfos'];
     // Return if we cannot load any data.
@@ -290,7 +269,8 @@ class SalesforceCommands extends DrushCommands {
   public function describeMetadata($object) {
     $objectDescription = $this->client->objectDescribe($object);
     if (!is_object($objectDescription)) {
-      throw new \Exception(dt('Could not load data for object !object', ['!object' => $object]));
+      $this->logger()->error(dt('Could not load data for object !object', ['!object' => $object]));
+      return;
     }
     $data = $objectDescription->data;
     // Return if we cannot load any data.
@@ -391,7 +371,8 @@ class SalesforceCommands extends DrushCommands {
     $objectDescription = $this->client->objectDescribe($object);
     // Return if we cannot load any data.
     if (!is_object($objectDescription)) {
-      throw new \Exception(dt('Could not load data for object !object', ['!object' => $object]));
+      $this->logger()->error(dt('Could not load data for object !object', ['!object' => $object]));
+      return;
     }
 
     foreach ($objectDescription->getFields() as $field => $data) {
@@ -433,7 +414,7 @@ class SalesforceCommands extends DrushCommands {
       $this->output()->writeln("The following resources are available:");
       return new RowsOfFields($rows);
     }
-    throw new \Exception('Could not obtain a list of resources!');
+    $this->logger()->error('Could not obtain a list of resources!');
   }
 
   /**
@@ -451,6 +432,8 @@ class SalesforceCommands extends DrushCommands {
   /**
    * Retrieve all the data for an object with a specific ID.
    *
+   * @todo create a proper StructuredData return value
+   *
    * @command salesforce:read-object
    * @aliases sfro,sf-read-object
    */
@@ -470,13 +453,13 @@ class SalesforceCommands extends DrushCommands {
    * @hook interact salesforce:create-object
    */
   public function interactCreateObject(Input $input, Output $output) {
-    $format = $input->getOption('format');
+    $format = $input->getOption('encoding');
     if (empty($format)) {
-      $input->setOption('format', 'query');
+      $input->setOption('encoding', 'query');
       $format = 'query';
     }
-    elseif (!in_array($input->getOption('format'), ['query', 'json'])) {
-      throw new \Exception('Invalid format');
+    elseif (!in_array($input->getOption('encoding'), ['query', 'json'])) {
+      throw new \Exception('Invalid encoding');
     }
 
     $this->interactObject($input, $output, 'Enter the object type to be created');
@@ -512,16 +495,36 @@ class SalesforceCommands extends DrushCommands {
    * @param array $data
    *   The data to use when creating the object (default is JSON format). Use '-' to read the data from STDIN.
    * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
-   * @option format
+   * @option encoding
    *   Format to parse the object. Use  "json" for JSON (default) or "query" for data formatted like a query string, e.g. 'Company=Foo&LastName=Bar'.
-   *   Defaults to query.
+   *   Defaults to "query".
+   *
+   * @field-labels
+   *   status: Status
+   *   id: Id
+   *   errors: Errors
+   * @default-fields status,id,errors
+   *
+   * @return \Consolidation\OutputFormatters\StructuredData\PropertyList
    *
    * @command salesforce:create-object
    * @aliases sfco,sf-create-object
    */
-  public function createObject($object, $data, array $options = ['format' => 'query']) {
-    if ($result = $this->client->objectCreate($object, $data)) {
-      $this->output->writeln(dt('Successfully created !object with id !id', ['!object' => $object, '!id' => (string)$result]));
+  public function createObject($object, $data, array $options = ['encoding' => 'query']) {
+    try {
+      $result = $this->client->objectCreate($object, $data);
+      return new PropertyList([
+        'status' => 'Success',
+        'id' => (string)$result,
+        'errors' => '',
+      ]);
+    }
+    catch (RestException $e) {
+      return new PropertyList([
+        'status' => 'Fail',
+        'id' => '',
+        'errors' => $e->getMessage(),
+      ]);
     }
   }
 
@@ -537,8 +540,6 @@ class SalesforceCommands extends DrushCommands {
    * @param $object
    *   The object type name in Salesforce (e.g. Account).
     * @param array $options An associative array of options whose values come from cli, aliases, config, etc.
-   * @option format
-   *   Format to output the objects. Use "print_r" for print_r (default), "export" for var_export, and "json" for JSON.
    * @option where
    *   A WHERE clause to add to the SOQL query
    * @option fields
@@ -548,10 +549,12 @@ class SalesforceCommands extends DrushCommands {
    * @option order
    *   Comma-separated fields by which to sort results. Make sure to enclose in quotes for any whitespace.
    *
+   * @return \Drupal\salesforce\Commands\QueryResult
+   *
    * @command salesforcef:query-object
    * @aliases sfqo,sf-query-object
    */
-  public function queryObject($object, array $options = ['format' => null, 'where' => null, 'fields' => null, 'limit' => null, 'order' => null]) {
+  public function queryObject($object, array $options = ['format' => 'table', 'where' => null, 'fields' => null, 'limit' => null, 'order' => null]) {
     $query = new SelectQuery($object);
 
     if (!$options['fields']) {
@@ -580,21 +583,7 @@ class SalesforceCommands extends DrushCommands {
         $query->order[$field] = $dir;
       }
     }
-
-    $result = $this->client->query($query);
-    foreach ($result->records() as $sfid => $record) {
-      $this->output()->writeln(print_r($record->fields(), 1));
-    }
-    $pretty_query = str_replace('+', ' ', (string) $query);
-    if (!$options['fields']) {
-      $fields = implode(',', $query->fields);
-      $pretty_query = str_replace($fields, ' * ', $pretty_query);
-    }
-    $this->output()->writeln(dt("Showing !size of !total records for query:\n!query", [
-      '!size' => count($result->records()),
-      '!total' => $result->size(),
-      '!query' => $pretty_query,
-    ]));
+    return $this->returnQueryResult(new QueryResult($query, $this->client->query($query)));
   }
 
   /**
@@ -603,11 +592,14 @@ class SalesforceCommands extends DrushCommands {
    * @param string $query
    *   The query to execute.
    *
+   * @return \Drupal\salesforce\Commands\QueryResult
+   *
    * @command salesforce:execute-query
    * @aliases sfeq,soql,sf-execute-query
    */
-  public function executeQuery($query) {
-    $this->output()->writeln(print_r($this->client->apiCall('query?q=' . urlencode($query)), 1));
+  public function executeQuery($query, array $options = ['format' => 'table']) {
+    $query = new SelectQueryRaw($query);
+    return $this->returnQueryResult(new QueryResult($query, $this->client->query($query)));
   }
 
 }
diff --git a/src/Commands/SalesforceCommandsBase.php b/src/Commands/SalesforceCommandsBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..8bf95118ae30d3527783c5b5907797431b7af553
--- /dev/null
+++ b/src/Commands/SalesforceCommandsBase.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\salesforce\Commands;
+
+use Drupal\Core\Entity\EntityTypeManager;
+use Drupal\salesforce\Rest\RestClient;
+use Drupal\salesforce_mapping\Entity\SalesforceMapping;
+use Drush\Commands\DrushCommands;
+use Drush\Drush;
+use Drush\Exceptions\UserAbortException;
+use Symfony\Component\Console\Input\Input;
+use Symfony\Component\Console\Output\Output;
+
+abstract class SalesforceCommandsBase extends DrushCommands {
+
+  /** @var \Drupal\salesforce\Rest\RestClient */
+  protected $client;
+  /** @var \Drupal\Core\Entity\EntityTypeManager */
+  protected $etm;
+  /** @var \Drupal\salesforce_mapping\SalesforceMappingStorage */
+  protected $mappingStorage;
+  /** @var \Drupal\salesforce_mapping\MappedObjectStorage */
+  protected $mappedObjectStorage;
+
+  public function __construct(RestClient $client, EntityTypeManager $etm) {
+    $this->client = $client;
+    $this->etm = $etm;
+    $this->mappingStorage = $etm->getStorage('salesforce_mapping');
+    $this->mappedObjectStorage = $etm->getStorage('salesforce_mapped_object');
+  }
+
+  /**
+   * Collect a salesforce object name, and set it to "object" argument.
+   *
+   * NB: there's no actual validation done here against Salesforce objects.
+   * If there's a way to attach multiple hooks to one method, please patch this.
+   */
+  protected function interactObject(Input $input, Output $output, $message = 'Choose a Salesforce object name') {
+    if (!$input->getArgument('object')) {
+      $objects = $this->client->objects();
+      if (!$answer = $this->io()->choice($message, array_combine(array_keys($objects), array_keys($objects)))) {
+        throw new UserAbortException();
+      }
+      $input->setArgument('object', $answer);
+    }
+  }
+
+  /**
+   * Collect a salesforce mapping name, and set it to a "name" argument.
+   */
+  protected function interactPullMappings(Input $input, Output $output, $message = 'Choose a Salesforce mapping', $allOption = FALSE) {
+    if ($name = $input->getArgument('name')) {
+      if (strtoupper($name) == 'ALL') {
+        $input->setArgument('name', 'ALL');
+        return;
+      }
+      /** @var \Drupal\salesforce_mapping\Entity\SalesforceMapping $mapping */
+      $mapping = $this->mappingStorage->load($name);
+      if (!$mapping) {
+        $this->logger()->error(dt('Mapping %name does not exist.', ['%name' => $name]));
+      }
+      elseif (!$mapping->doesPull()) {
+        $this->logger()->error(dt('Mapping %name does not pull.', ['%name' => $name]));
+      }
+      else {
+        return;
+      }
+    }
+    $options = $this->mappingStorage->loadPullMappings();
+    $options = array_combine(array_keys($options), array_keys($options));
+    if ($allOption) {
+      $options['ALL'] = $allOption;
+    }
+    if (!$answer = $this->io()->choice($message, $options)) {
+      throw new UserAbortException();
+    }
+    $input->setArgument('name', $answer);
+  }
+
+  /**
+   * @param string $name
+   *
+   * @return SalesforceMapping[]
+   * @throws \Exception
+   */
+  protected function getPullMappingsFromName($name) {
+    $mappings = [];
+    if ($name == 'ALL') {
+      $mappings = $this->mappingStorage->loadPullMappings();
+    }
+    else {
+      $mapping = $this->mappingStorage->load($name);
+      if (!$mapping->doesPull()) {
+        throw new \Exception(dt("Mapping !name does not pull.", ['!name' => $name]));
+      }
+      $mappings = [$mapping];
+    }
+    $mappings = array_filter($mappings);
+    if (empty($mappings)) {
+      throw new \Exception(dt('No pull mappings loaded'));
+    }
+    return $mappings;
+  }
+
+  /**
+   * @param \Drupal\salesforce\Commands\QueryResult $query
+   *
+   * @return \Drupal\salesforce\Commands\QueryResult
+   */
+  protected function returnQueryResult(QueryResult $query) {
+    $formatter = new QueryResultTableFormatter();
+    $formatterManager = Drush::getContainer()->get('formatterManager');
+    $formatterManager->addFormatter('table', $formatter);
+    return $query;
+  }
+
+}
\ No newline at end of file
diff --git a/src/Rest/RestClient.php b/src/Rest/RestClient.php
index 92c4d97a149e1107d21e81c61d264cca0be388e9..79c9c089c9b69f558b85422bfab227e9a4c2fdf7 100644
--- a/src/Rest/RestClient.php
+++ b/src/Rest/RestClient.php
@@ -10,6 +10,7 @@ use Drupal\Core\Cache\CacheBackendInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
+use Drupal\salesforce\SelectQueryInterface;
 use Drupal\salesforce\SFID;
 use Drupal\salesforce\SObject;
 use Drupal\salesforce\SelectQuery;
@@ -722,7 +723,7 @@ class RestClient implements RestClientInterface {
    *   Whether to reset the cache and retrieve a fresh version from Salesforce.
    *
    * @return array
-   *   Available objects and metadata.
+   *   Available objects and metadata, indexed by object table name.
    *
    * @addtogroup salesforce_apicalls
    */
@@ -736,23 +737,27 @@ class RestClient implements RestClientInterface {
       $this->cache->set('salesforce:objects', $result, $this->getRequestTime() + self::CACHE_LIFETIME, ['salesforce']);
     }
 
-    if (!empty($conditions)) {
-      foreach ($result['sobjects'] as $key => $object) {
+    $sobjects = [];
+    // Filter the list by conditions, and assign SF table names as array keys.
+    foreach ($result['sobjects'] as $key => $object) {
+      if (!empty($conditions)) {
         foreach ($conditions as $condition => $value) {
-          if (!$object[$condition] == $value) {
-            unset($result['sobjects'][$key]);
+          if ($object[$condition] == $value) {
+            $sobjects[$object['name']] = $object;
           }
         }
       }
+      else {
+        $sobjects[$object['name']] = $object;
+      }
     }
-
-    return $result['sobjects'];
+    return $sobjects;
   }
 
   /**
    * {@inheritdoc}
    */
-  public function query(SelectQuery $query) {
+  public function query(SelectQueryInterface $query) {
     // $this->moduleHandler->alter('salesforce_query', $query);
     // Casting $query as a string calls SelectQuery::__toString().
     return new SelectQueryResult($this->apiCall('query?q=' . (string) $query));
@@ -761,7 +766,7 @@ class RestClient implements RestClientInterface {
   /**
    * {@inheritdoc}
    */
-  public function queryAll(SelectQuery $query) {
+  public function queryAll(SelectQueryInterface $query) {
     return new SelectQueryResult($this->apiCall('queryAll?q=' . (string) $query));
   }
 
diff --git a/src/Rest/RestClientInterface.php b/src/Rest/RestClientInterface.php
index 22872649d65f7d462dbd9fa9e28ad1ca39ee2e99..1f045bbdc7e60951b4a506b8f13ce3b98807b009 100644
--- a/src/Rest/RestClientInterface.php
+++ b/src/Rest/RestClientInterface.php
@@ -2,8 +2,8 @@
 
 namespace Drupal\salesforce\Rest;
 
+use Drupal\salesforce\SelectQueryInterface;
 use Drupal\salesforce\SFID;
-use Drupal\salesforce\SelectQuery;
 use Drupal\salesforce\SelectQueryResult;
 use GuzzleHttp\Psr7\Response;
 
@@ -265,7 +265,7 @@ interface RestClientInterface {
    *
    * @addtogroup salesforce_apicalls
    */
-  public function query(SelectQuery $query);
+  public function query(SelectQueryInterface $query);
 
   /**
    * Same as ::query(), but also returns deleted or archived records.
@@ -277,7 +277,7 @@ interface RestClientInterface {
    *
    * @addtogroup salesforce_apicalls
    */
-  public function queryAll(SelectQuery $query);
+  public function queryAll(SelectQueryInterface $query);
 
   /**
    * Given a select query result, fetch the next results set, if it exists.
diff --git a/src/SelectQuery.php b/src/SelectQuery.php
index c44b2c56e99424fca9d8e9a55bcbe6c2396573a5..b83060f880503276387d80da94b8bbb368b1f0dc 100644
--- a/src/SelectQuery.php
+++ b/src/SelectQuery.php
@@ -7,7 +7,7 @@ namespace Drupal\salesforce;
  *
  * @package Drupal\salesforce
  */
-class SelectQuery {
+class SelectQuery implements SelectQueryInterface {
 
   public $fields = [];
   public $order = [];
diff --git a/src/SelectQueryInterface.php b/src/SelectQueryInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..9a5ce0a041de7d5b0f40fee83d937d5c162db9d1
--- /dev/null
+++ b/src/SelectQueryInterface.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Drupal\salesforce;
+
+interface SelectQueryInterface {
+
+  /**
+   * Return the query as a string.
+   *
+   * @return string
+   *   The url-encoded query string.
+   */
+  public function __toString();
+
+}
\ No newline at end of file
diff --git a/src/SelectQueryRaw.php b/src/SelectQueryRaw.php
new file mode 100644
index 0000000000000000000000000000000000000000..87efac9cb2b2f4e448c18fc5a7f1687d0232f3ca
--- /dev/null
+++ b/src/SelectQueryRaw.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Drupal\salesforce;
+
+class SelectQueryRaw implements SelectQueryInterface {
+
+  protected $query;
+
+  /**
+   * SelectQueryRaw constructor.
+   *
+   * @param string $query
+   *   The SOQL query.
+   */
+  public function __construct($query) {
+    $this->query = $query;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __toString() {
+    return str_replace(' ', '+', $this->query);
+  }
+}
\ No newline at end of file
diff --git a/src/SelectQueryResult.php b/src/SelectQueryResult.php
index 294f1e6c5f0ab66baf5833d3fbe0432dbd927cec..2adef404cf81db62ded1c4a18fc069508e50d1fc 100644
--- a/src/SelectQueryResult.php
+++ b/src/SelectQueryResult.php
@@ -53,7 +53,7 @@ class SelectQueryResult {
   }
 
   /**
-   * @return array
+   * @return \Drupal\salesforce\SObject[]
    */
   public function records() {
     return $this->records;