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;