diff --git a/composer.json b/composer.json index 4f1d50a..502e90d 100644 --- a/composer.json +++ b/composer.json @@ -5,8 +5,9 @@ "homepage": "https://github.com/voycey/cakephp-salesforce", "license": "MIT", "require": { - "php": ">=5.4.16", - "cakephp/cakephp": "~3.0", + "php": ">=7.4", + "cakephp/cakephp": "^4.0", + "ae/salesforce-rest-sdk": "^2.0", "developerforce/force.com-toolkit-for-php": "^1.0@dev" }, "require-dev": { diff --git a/config/bootstrap.php b/config/bootstrap.php index 289be14..4a4d993 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -2,11 +2,11 @@ use Cake\Cache\Cache; -Cache::config('salesforce', [ +Cache::setConfig('salesforce', [ 'className' => 'Cake\Cache\Engine\FileEngine', 'duration' => '+1 hours', 'probability' => 100, 'path' => CACHE . 'salesforce' . DS, ]); -?> \ No newline at end of file +?> diff --git a/src/Database/Dialect/SalesforceDialectTrait.php b/src/Database/Dialect/SalesforceDialectTrait.php index 5633fef..69e64b4 100644 --- a/src/Database/Dialect/SalesforceDialectTrait.php +++ b/src/Database/Dialect/SalesforceDialectTrait.php @@ -12,8 +12,10 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database\Dialect; +use Cake\Database\Schema\SchemaDialect; use Salesforce\Database\Schema\SalesforceSchema; use Cake\Database\SqlDialectTrait; @@ -57,7 +59,7 @@ trait SalesforceDialectTrait * * @return \Cake\Database\Schema\MysqlSchema */ - public function schemaDialect() + public function schemaDialect(): SchemaDialect { if (!$this->_schemaDialect) { $this->_schemaDialect = new SalesforceSchema($this); @@ -68,7 +70,7 @@ public function schemaDialect() /** * {@inheritDoc} */ - public function disableForeignKeySQL() + public function disableForeignKeySQL(): string { return ''; } @@ -76,7 +78,7 @@ public function disableForeignKeySQL() /** * {@inheritDoc} */ - public function enableForeignKeySQL() + public function enableForeignKeySQL(): string { return ''; } diff --git a/src/Database/Driver/Salesforce.php b/src/Database/Driver/Salesforce.php index 0bf630f..0629c8b 100644 --- a/src/Database/Driver/Salesforce.php +++ b/src/Database/Driver/Salesforce.php @@ -12,16 +12,23 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database\Driver; +use AE\SalesforceRestSdk\AuthProvider\CachedSoapProvider; +use AE\SalesforceRestSdk\Rest\Client; use Cake\Database\Query; +use Cake\Database\QueryCompiler; +use Cake\Database\StatementInterface; use Cake\Database\ValueBinder; +use Cake\Filesystem\File; use Salesforce\Database\Dialect\SalesforceDialectTrait; use Salesforce\Database\Driver\SalesforceDriverTrait; use Salesforce\Database\SalesforceQueryCompiler; use Salesforce\Database\SalesforceQuery; use Salesforce\Database\Statement\SalesforceStatement; use Cake\Database\Driver; +use Symfony\Component\Cache\Adapter\FilesystemAdapter; class Salesforce extends Driver { @@ -46,37 +53,14 @@ class Salesforce extends Driver 'init' => [], ]; - /** - * Establishes a connection to the database server - * - * @return bool true on success - */ - public function connect() - { - if ($this->_connection) { - return true; - } - $config = $this->_config; - - $this->_connect($config); - - if (!empty($config['init'])) { - $connection = $this->connection(); - foreach ((array)$config['init'] as $command) { - $connection->exec($command); - } - } - return true; - } - /** * Returns whether php is able to use this driver for connecting to database * * @return bool true if it is valid to use this driver */ - public function enabled() + public function enabled(): bool { - return true; //Dont know if I need this? + return true; } /** @@ -84,35 +68,51 @@ public function enabled() * * @param string|\Cake\Database\Query $query The query to prepare. * @return \Cake\Database\StatementInterface + * @throws \ErrorException */ - public function prepare($query) + public function prepare($query): StatementInterface { $this->connect(); $isObject = $query instanceof SalesforceQuery; //$statement = $this->_connection->prepare($isObject ? $query->sql() : $query); $result = new SalesforceStatement($query, $this); - if ($isObject && $query->bufferResults() === false) { + if ($isObject && $query->isBufferedResultsEnabled() === false) { $result->bufferResults(false); } + return $result; } /** - * {@inheritDoc} + * Establishes a connection to the database server + * + * @return bool true on success + * @throws \ErrorException */ - public function supportsDynamicConstraints() + public function connect(): bool { + if ($this->_connection) { + return true; + } + $config = $this->_config; + + $this->_connect('', $config); + + if (!empty($config['init'])) { + $connection = $this->connection(); + foreach ((array)$config['init'] as $command) { + $connection->exec($command); + } + } return true; } /** - * Returns an instance of a QueryCompiler - * - * @return \Cake\Database\QueryCompiler + * {@inheritDoc} */ - public function newCompiler() + public function supportsDynamicConstraints(): bool { - return new SalesforceQueryCompiler; + return true; } /** @@ -124,11 +124,44 @@ public function newCompiler() * @return array containing 2 entries. The first entity is the transformed query * and the second one the compiled SQL */ - public function compileQuery(Query $query, ValueBinder $generator) + public function compileQuery(Query $query, ValueBinder $generator): array { $processor = $this->newCompiler(); $translator = $this->queryTranslator($query->type()); $query = $translator($query); return [$query, $processor->compile($query, $generator)]; } + + /** + * Returns an instance of a QueryCompiler + * + * @return SalesforceQueryCompiler + */ + public function newCompiler(): QueryCompiler + { + return new SalesforceQueryCompiler(); + } + + protected $restClient; + + public function getRestClient() + { + $wsdl = new File(CONFIG . $this->_config['my_wsdl']); + $res = preg_match('#soap:address location="(https://.*)/services/Soap/c/(\d+.\d)#', $wsdl->read(), $matches); + if ($this->restClient === null) { + $this->restClient = new Client( + new CachedSoapProvider( + new FilesystemAdapter('', 0, CACHE), + $this->_config['username'], + $this->_config['password'], + $matches[1] + ), + $matches[2] + ); + + } + + return $this->restClient; + } + } diff --git a/src/Database/Driver/SalesforceDriverTrait.php b/src/Database/Driver/SalesforceDriverTrait.php index 1daa3a3..00d6dbe 100644 --- a/src/Database/Driver/SalesforceDriverTrait.php +++ b/src/Database/Driver/SalesforceDriverTrait.php @@ -4,63 +4,29 @@ use Cake\Database\Query; use Cake\Cache\Cache; +use Cake\Database\StatementInterface; +use Cake\Log\Log; +use PDO; + /** * SF driver trait */ trait SalesforceDriverTrait { - protected $_connection; public $config; /** - * Establishes a connection to the salesforce server - * - * @param array $config configuration to be used for creating connection - * @return bool true on success + * @var \SforceEnterpriseClient */ - protected function _connect(array $config) - { - $this->config = $config; + public $client; - if (empty($this->config['my_wsdl'])) { - throw new \ErrorException ("A WSDL needs to be provided"); - } else { - $wsdl = CONFIG .DS . $this->config['my_wsdl']; - } - - $mySforceConnection = new \SforceEnterpriseClient(); - $mySforceConnection->createConnection($wsdl); - - $cache_key = $config['name'] . '_login'; - $sflogin = (array)Cache::read($cache_key, 'salesforce'); - - if(!empty($sflogin['sessionId'])) { - $mySforceConnection->setSessionHeader($sflogin['sessionId']); - $mySforceConnection->setEndPoint($sflogin['serverUrl']); - } else { - try{ - $mylogin = $mySforceConnection->login($this->config['username'], $this->config['password']); - $sflogin = array('sessionId' => $mylogin->sessionId, 'serverUrl' => $mylogin->serverUrl); - Cache::write($cache_key, $sflogin, 'salesforce'); - } catch (\Exception $e) { - $this->log("Error logging into salesforce - Salesforce down?"); - $this->log("Username: " . $this->config['username']); - $this->log("Password: " . $this->config['password']); - } - } - - $this->client = $mySforceConnection; - $this->connected = true; - return $this->connected; - } + /** + * @var null|\PDO $connection The PDO connection instance + */ + protected $_connection; /** - * Returns correct connection resource or object that is internally used - * If first argument is passed, it will set internal connection object or - * result to the value passed - * - * @param null|\PDO $connection The PDO connection instance. - * @return mixed connection object used internally + * {@inheritDoc} */ public function connection($connection = null) { @@ -71,22 +37,17 @@ public function connection($connection = null) } /** - * Disconnects from database server - * - * @return void + * {@inheritDoc} */ - public function disconnect() + public function disconnect(): void { $this->_connection = null; } /** - * Prepares a sql statement to be executed - * - * @param string|\Cake\Database\Query $query The query to turn into a prepared statement. - * @return \Cake\Database\StatementInterface + * {@inheritDoc} */ - public function prepare($query) + public function prepare($query): StatementInterface { $this->connect(); $isObject = $query instanceof Query; @@ -95,11 +56,9 @@ public function prepare($query) } /** - * Starts a transaction - * - * @return bool true on success, false otherwise + * {@inheritDoc} */ - public function beginTransaction() + public function beginTransaction(): bool { $this->connect(); if ($this->_connection->inTransaction()) { @@ -109,53 +68,44 @@ public function beginTransaction() } /** - * Commits a transaction - * - * @return bool true on success, false otherwise + * {@inheritDoc} */ - public function commitTransaction() + public function commitTransaction(): bool { $this->connect(); if (!$this->_connection->inTransaction()) { return false; } + return $this->_connection->commit(); } /** - * Rollsback a transaction - * - * @return bool true on success, false otherwise + * {@inheritDoc} */ - public function rollbackTransaction() + public function rollbackTransaction(): bool { if (!$this->_connection->inTransaction()) { return false; } + return $this->_connection->rollback(); } /** - * Returns a value in a safe representation to be used in a query string - * - * @param mixed $value The value to quote. - * @param string $type Type to be used for determining kind of quoting to perform - * @return string + * {@inheritDoc} */ - public function quote($value, $type) + public function quote($value, $type = PDO::PARAM_STR): string { $this->connect(); + return $this->_connection->quote($value, $type); } /** - * Returns last id generated for a table or sequence in database - * - * @param string|null $table table name or sequence to get last insert value from - * @param string|null $column the name of the column representing the primary key - * @return string|int + * {@inheritDoc} */ - public function lastInsertId($table = null, $column = null) + public function lastInsertId(?string $table = null, ?string $column = null) { $this->connect(); return $this->_connection->lastInsertId($table); @@ -166,8 +116,52 @@ public function lastInsertId($table = null, $column = null) * * @return bool */ - public function supportsQuoting() + public function supportsQuoting(): bool { return false; } + + /** + * Establishes a connection to the salesforce server + * + * @param string $dsn A Driver-specific PDO-DSN + * @param array $config configuration to be used for creating connection + * @return bool true on success + * @throws \ErrorException + */ + protected function _connect(string $dsn, array $config): bool + { + $this->config = $config; + + if (empty($this->config['my_wsdl'])) { + throw new \ErrorException ("A WSDL needs to be provided"); + } else { + $wsdl = CONFIG . DS . $this->config['my_wsdl']; + } + + $mySforceConnection = new \SforceEnterpriseClient(); + $mySforceConnection->createConnection($wsdl); + + $cache_key = $config['name'] . '_login'; + $sflogin = (array)Cache::read($cache_key, 'salesforce'); + + if (!empty($sflogin['sessionId'])) { + $mySforceConnection->setSessionHeader($sflogin['sessionId']); + $mySforceConnection->setEndPoint($sflogin['serverUrl']); + } else { + try { + $mylogin = $mySforceConnection->login($this->config['username'], $this->config['password']); + $sflogin = array('sessionId' => $mylogin->sessionId, 'serverUrl' => $mylogin->serverUrl); + Cache::write($cache_key, $sflogin, 'salesforce'); + } catch (\Exception $e) { + Log::write('error', "Error logging into salesforce - Salesforce down?"); + Log::write('error', "Username: " . $this->config['username']); + Log::write('error', "Password: " . $this->config['password']); + } + } + + $this->client = $mySforceConnection; + $this->connected = true; + return $this->connected; + } } diff --git a/src/Database/SalesforceConnection.php b/src/Database/SalesforceConnection.php index 7a32095..e3e0241 100644 --- a/src/Database/SalesforceConnection.php +++ b/src/Database/SalesforceConnection.php @@ -12,22 +12,13 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database; use Cake\Database\Query; +use Cake\Database\StatementInterface; use Cake\Database\ValueBinder; -use Salesforce\Database\Schema\SalesforceCollection as SchemaCollection; -use Cake\Database\TypeConverterTrait; use Cake\Database\Connection; -use Cake\Database\Exception\MissingConnectionException; -use Cake\Database\Exception\MissingDriverException; -use Cake\Database\Exception\MissingExtensionException; -use Cake\Database\Log\LoggedQuery; -use Cake\Database\Log\LoggingStatement; -use Cake\Database\Log\QueryLogger; -use Cake\Database\Schema\CachedCollection; -use Cake\Datasource\ConnectionInterface; -use Exception; /** * Represents a connection with a database server. @@ -35,55 +26,47 @@ class SalesforceConnection extends Connection { /** - * Compiles a Query object into a SQL string according to the dialect for this - * connection's driver - * - * @param Query $query The query to be compiled - * @param ValueBinder $generator The placeholder generator to use - * @return string + * {@inheritDoc} */ - public function compileQuery(Query $query, ValueBinder $generator) + public function compileQuery(Query $query, ValueBinder $binder): string { - return $this->driver()->compileQuery($query, $generator)[1]; + return $this->getDriver() + ->compileQuery($query, $binder)[1]; } /** - * Create a new Query instance for this connection. - * - * @return Query + * {@inheritDoc} */ - public function newQuery() + public function newQuery(): Query { return new SalesforceQuery($this); } - public function run(Query $query) + /** + * {@inheritDoc} + */ + public function run(Query $query): StatementInterface { $statement = $this->prepare($query); - $query->valueBinder()->attachTo($statement); + $query->getValueBinder() + ->attachTo($statement); $statement->execute(); return $statement; } /** - * Checks if the driver supports quoting. - * - * @return bool + * {@inheritDoc} */ - public function supportsQuoting() + public function supportsQuoting(): bool { return false; } /** - * Quotes a database identifier (a column name, table name, etc..) to - * be used safely in queries without the risk of using reserved words. - * - * @param string $identifier The identifier to quote. - * @return string + * {@inheritDoc} */ - public function quoteIdentifier($identifier) + public function quoteIdentifier($identifier): string { return $identifier; } diff --git a/src/Database/SalesforceQuery.php b/src/Database/SalesforceQuery.php index 66f61bd..304b74b 100644 --- a/src/Database/SalesforceQuery.php +++ b/src/Database/SalesforceQuery.php @@ -12,6 +12,7 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database; use Cake\Database\Query; @@ -21,6 +22,7 @@ use Cake\Database\Expression\ValuesExpression; use Cake\Database\Statement\CallbackStatement; use Cake\Database\ValueBinder; +use Closure; use IteratorAggregate; use RuntimeException; @@ -32,32 +34,6 @@ */ class SalesforceQuery extends Query { - /** - * Returns the SQL representation of this object. - * - * This function will compile this query to make it compatible - * with the SQL dialect that is used by the connection, This process might - * add, remove or alter any query part or internal expression to make it - * executable in the target platform. - * - * The resulting query may have placeholders that will be replaced with the actual - * values when the query is executed, hence it is most suitable to use with - * prepared statements. - * - * @param ValueBinder $generator A placeholder object that will hold - * associated values for expressions - * @return string - */ - public function sql(ValueBinder $generator = null, $incoming = null) - { - if (!$generator) { - $generator = $incoming->valueBinder(); - $generator->resetCount(); - } - - return $incoming->connection()->compileQuery($incoming, $generator); - } - /** * Will iterate over every specified part. Traversing functions can aggregate * results using variables in the closure or instance variables. This function @@ -80,12 +56,13 @@ public function sql(ValueBinder $generator = null, $incoming = null) * @param array $parts the query clauses to traverse * @return $this */ - public function traverse(callable $visitor, array $parts = []) + public function traverse(Closure $callback) { - $parts = $parts ?: array_keys($this->_parts); - foreach ($parts as $name) { - $visitor($this->_parts[$name], $name); + + foreach ($this->_parts as $name => $part) { + $callback($part, $name); } + return $this; } @@ -94,8 +71,35 @@ public function traverse(callable $visitor, array $parts = []) * * @return string */ - public function __toString() + public function __toString(): string { return $this->sql(); } + + /** + * Returns the SQL representation of this object. + * + * This function will compile this query to make it compatible + * with the SQL dialect that is used by the connection, This process might + * add, remove or alter any query part or internal expression to make it + * executable in the target platform. + * + * The resulting query may have placeholders that will be replaced with the actual + * values when the query is executed, hence it is most suitable to use with + * prepared statements. + * + * @param ValueBinder $generator A placeholder object that will hold + * associated values for expressions + * @return string + */ + public function sql(ValueBinder $generator = null, $incoming = null) + { + if (!$generator) { + $generator = $incoming->valueBinder(); + $generator->resetCount(); + } + + return $incoming->connection() + ->compileQuery($incoming, $generator); + } } \ No newline at end of file diff --git a/src/Database/SalesforceQueryCompiler.php b/src/Database/SalesforceQueryCompiler.php index d96a2e1..d0119c2 100644 --- a/src/Database/SalesforceQueryCompiler.php +++ b/src/Database/SalesforceQueryCompiler.php @@ -12,6 +12,7 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database; use Cake\Database\Query; @@ -26,19 +27,24 @@ class SalesforceQueryCompiler extends QueryCompiler { /** - * Helper function used to build the string representation of a SELECT clause, - * it constructs the field list taking care of aliasing and - * converting expression objects to string. This function also constructs the - * DISTINCT clause for the query. This has been modified to allow it to support Salesforce - * - * @param array $parts list of fields to be transformed to string - * @param \Cake\Database\Query $query The query that is being compiled - * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions - * @return string + * {@inheritDoc} + */ + public function compile(Query $query, ValueBinder $binder): string + { + $sql = ''; + $type = $query->type(); + $query->traverseParts($this->_sqlCompiler($sql, $query, $binder), $this->{'_' . $type . 'Parts'}); + + return $sql; + } + + /** + * {@inheritDoc} */ - protected function _buildSelectPart($parts, $query, $generator) + protected function _buildSelectPart(array $parts, Query $query, ValueBinder $binder): string { - $driver = $query->connection()->driver(); + $driver = $query->getConnection() + ->getDriver(); $select = 'SELECT %s%s%s'; if ($this->_orderedUnion && $query->clause('union')) { $select = '(SELECT %s%s%s'; @@ -47,7 +53,7 @@ protected function _buildSelectPart($parts, $query, $generator) $modifiers = $query->clause('modifier') ?: null; $normalized = []; - $parts = $this->_stringifyExpressions($parts, $generator); + $parts = $this->_stringifyExpressions($parts, $binder); foreach ($parts as $k => $p) { if (!is_numeric($k)) { $p = $p; //Leave it alone @@ -60,33 +66,14 @@ protected function _buildSelectPart($parts, $query, $generator) } if (is_array($distinct)) { - $distinct = $this->_stringifyExpressions($distinct, $generator); + $distinct = $this->_stringifyExpressions($distinct, $binder); $distinct = sprintf('DISTINCT ON (%s) ', implode(', ', $distinct)); } if ($modifiers !== null) { - $modifiers = $this->_stringifyExpressions($modifiers, $generator); + $modifiers = $this->_stringifyExpressions($modifiers, $binder); $modifiers = implode(' ', $modifiers) . ' '; } return sprintf($select, $distinct, $modifiers, implode(', ', $normalized)); } - - /** - * Returns the SQL representation of the provided query after generating - * the placeholders for the bound values using the provided generator - * - * @param \Cake\Database\Query $query The query that is being compiled - * @param \Cake\Database\ValueBinder $generator the placeholder generator to be used in expressions - * @return \Closure - */ - public function compile(Query $query, ValueBinder $generator) - { - $sql = ''; - $type = $query->type(); - $query->traverse( - $this->_sqlCompiler($sql, $query, $generator), - $this->{'_' . $type . 'Parts'} - ); - return $sql; - } } \ No newline at end of file diff --git a/src/Database/SalesforceType.php b/src/Database/SalesforceType.php index b803e4d..3c9af9b 100644 --- a/src/Database/SalesforceType.php +++ b/src/Database/SalesforceType.php @@ -12,9 +12,11 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database; use Cake\Database\Type; +use Cake\Database\TypeInterface; use InvalidArgumentException; /** @@ -31,12 +33,12 @@ class SalesforceType extends Type * * @var array */ - protected static $_types = [ + protected static $_typesMap = [ 'biginteger' => 'Cake\Database\Type\IntegerType', 'binary' => 'Cake\Database\Type\BinaryType', 'boolean' => 'Cake\Database\Type\BoolType', - 'date' => 'Cake\Database\Type\DateType', - 'datetime' => 'Cake\Database\Type\DateTimeType', + 'date' => 'Salesforce\Database\Type\SalesforceDateType', + 'datetime' => 'Salesforce\Database\Type\SalesforceDateTimeType', 'decimal' => 'Cake\Database\Type\FloatType', 'float' => 'Cake\Database\Type\FloatType', 'integer' => 'Cake\Database\Type\IntegerType', @@ -63,24 +65,109 @@ class SalesforceType extends Type ]; /** - * Returns a Type object capable of converting a type identified by $name + * Contains a map of type object instances to be reused if needed. * - * @param string $name type identifier - * @throws \InvalidArgumentException If type identifier is unknown - * @return Type + * @var \Cake\Database\TypeInterface[] + */ + protected static $_builtSalesforceTypes = []; + + /** + * {@inheritDoc} */ - public static function build($name) + public static function build(string $name): TypeInterface { - //force rebuild of string type - if ($name != "string") { - if (isset(static::$_builtTypes[$name])) { - return static::$_builtTypes[$name]; + //force rebuild of string and dates type + if (!in_array($name, ['string', 'date', 'datetime'])) { + if (isset(static::$_builtSalesforceTypes[$name])) { + return static::$_builtSalesforceTypes[$name]; } - if (!isset(static::$_types[$name])) { + if (!isset(static::$_typesMap[$name])) { throw new InvalidArgumentException(sprintf('Unknown type "%s"', $name)); } } - return static::$_builtTypes[$name] = new static::$_types[$name]($name); + return static::$_builtSalesforceTypes[$name] = new static::$_typesMap[$name]($name); + } + + + /** + * Returns an arrays with all the mapped type objects, indexed by name. + * + * @return \Cake\Database\TypeInterface[] + */ + public static function buildAll(): array + { + $result = []; + foreach (static::$_typesMap as $name => $type) { + $result[$name] = static::$_builtSalesforceTypes[$name] ?? static::build($name); + } + + return $result; + } + + /** + * Set TypeInterface instance capable of converting a type identified by $name + * + * @param string $name The type identifier you want to set. + * @param \Cake\Database\TypeInterface $instance The type instance you want to set. + * @return void + */ + public static function set(string $name, TypeInterface $instance): void + { + static::$_builtSalesforceTypes[$name] = $instance; + static::$_typesMap[$name] = get_class($instance); + } + + /** + * Registers a new type identifier and maps it to a fully namespaced classname. + * + * @param string $type Name of type to map. + * @param string $className The classname to register. + * @return void + * @psalm-param class-string<\Cake\Database\TypeInterface> $className + */ + public static function map(string $type, string $className): void + { + static::$_typesMap[$type] = $className; + unset(static::$_builtSalesforceTypes[$type]); + } + + /** + * Set type to classname mapping. + * + * @param string[] $map List of types to be mapped. + * @return void + * @psalm-param array> $map + */ + public static function setMap(array $map): void + { + static::$_typesMap = $map; + static::$_builtSalesforceTypes = []; + } + + /** + * Get mapped class name for given type or map array. + * + * @param string|null $type Type name to get mapped class for or null to get map array. + * @return string[]|string|null Configured class name for given $type or map array. + */ + public static function getMap(?string $type = null) + { + if ($type === null) { + return static::$_typesMap; + } + + return static::$_typesMap[$type] ?? null; + } + + /** + * Clears out all created instances and mapped types classes, useful for testing + * + * @return void + */ + public static function clear(): void + { + static::$_typesMap = []; + static::$_builtSalesforceTypes = []; } } diff --git a/src/Database/Schema/SalesforceCollection.php b/src/Database/Schema/SalesforceCollection.php index 9647965..fd19465 100644 --- a/src/Database/Schema/SalesforceCollection.php +++ b/src/Database/Schema/SalesforceCollection.php @@ -12,11 +12,13 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database\Schema; use Cake\Database\Exception; use Cake\Database\Schema\Collection; use Cake\Database\Schema\Table; +use Cake\Database\Schema\TableSchemaInterface; use Cake\Datasource\ConnectionInterface; use PDOException; @@ -34,7 +36,7 @@ class SalesforceCollection extends Collection * * @return array The list of tables in the connected database/schema. */ - public function listTables() + public function listTables(): array { list($sql, $params) = $this->_dialect->listTables($this->_connection->config()); $result = []; @@ -59,16 +61,17 @@ public function listTables() * * @param string $name The name of the table to describe. * @param array $options The options to use, see above. - * @return \Cake\Database\Schema\Table Object with column metadata. + * @return \Cake\Database\Schema\TableSchemaInterface Object with column metadata. * @throws \Cake\Database\Exception when table cannot be described. */ - public function describe($name, array $options = []) + public function describe($name, array $options = []): TableSchemaInterface { $config = $this->_connection->config(); if (strpos($name, '.')) { list($config['schema'], $name) = explode('.', $name); } - $table = new Table($name); + $table = $this->_connection->getDriver() + ->newTableSchema($name); $schema = $this->_dialect->describeColumn($name, $config); diff --git a/src/Database/Schema/SalesforceSchema.php b/src/Database/Schema/SalesforceSchema.php index 2b0278b..eca4a24 100644 --- a/src/Database/Schema/SalesforceSchema.php +++ b/src/Database/Schema/SalesforceSchema.php @@ -12,12 +12,13 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database\Schema; +use Cake\Database\Schema\TableSchema; use Salesforce\Database\Driver\SalesforceDriverTrait; use Cake\Database\Exception; use Cake\Database\Schema\BaseSchema; -use Cake\Database\Schema\Table; /** * Schema generation/reflection features for MySQL @@ -25,13 +26,14 @@ class SalesforceSchema extends BaseSchema { - use SalesforceDriverTrait; + use SalesforceDriverTrait; + /** * {@inheritDoc} */ - public function listTablesSql($config) + public function listTablesSql($config): array { - return false; + return []; } /** @@ -55,8 +57,6 @@ public function describeColumn($tableName, $config) foreach ($sfschema->fields as $field) { switch ($field->type) { case "id": - $field->type = "integer"; - break; case "integer": $field->type = "integer"; break; @@ -79,7 +79,12 @@ public function describeColumn($tableName, $config) //Capital letters here as it is emulating return from MySQL if ($field->length > 0) { - $newSchema[] = array('Field' => $field->name, 'Type' => $field->type, 'Length' => $field->length, 'Null' => $field->nillable); + $newSchema[] = array( + 'Field' => $field->name, + 'Type' => $field->type, + 'Length' => $field->length, + 'Null' => $field->nillable + ); } else { $newSchema[] = array('Field' => $field->name, 'Type' => $field->type, 'Null' => $field->nillable); } @@ -87,41 +92,57 @@ public function describeColumn($tableName, $config) return $newSchema; } + /** * {@inheritDoc} */ - public function describeColumnSql($tableName, $config) + public function describeColumnSql($tableName, $config): array { - return false; + return []; } /** * {@inheritDoc} */ - public function describeIndexSql($tableName, $config) + public function describeIndexSql($tableName, $config): array { - return false; + return []; } /** * {@inheritDoc} */ - public function describeOptionsSql($tableName, $config) + public function describeOptionsSql($tableName, $config): array { - return false; + return []; } /** * {@inheritDoc} */ - public function convertOptionsDescription(Table $table, $row) + public function convertOptionsDescription(TableSchema $table, $row): void { - $table->options([ + $table->setOptions([ 'engine' => $row['Engine'], 'collation' => $row['Collation'], ]); } + /** + * {@inheritDoc} + */ + public function convertColumnDescription(TableSchema $table, $row): void + { + $field = $this->_convertColumn($row['Type']); + $field += [ + 'null' => $row['Null'] === 'YES' ? true : false + ]; + if (isset($row['Extra']) && $row['Extra'] === 'auto_increment') { + $field['autoIncrement'] = true; + } + $table->addColumn($row['Field'], $field); + } + /** * Convert a MySQL column type into an abstract type. * @@ -200,64 +221,47 @@ protected function _convertColumn($column) /** * {@inheritDoc} */ - public function convertColumnDescription(Table $table, $row) - { - $field = $this->_convertColumn($row['Type']); - $field += [ - 'null' => $row['Null'] === 'YES' ? true : false - ]; - if (isset($row['Extra']) && $row['Extra'] === 'auto_increment') { - $field['autoIncrement'] = true; - } - $table->addColumn($row['Field'], $field); - } - - /** - * {@inheritDoc} - */ - public function convertIndexDescription(Table $table, $row) + public function convertIndexDescription(TableSchema $table, $row): void { - return false; } /** * {@inheritDoc} */ - public function describeForeignKeySql($tableName, $config) + public function describeForeignKeySql($tableName, $config): array { - return false; + return []; } /** * {@inheritDoc} */ - public function convertForeignKeyDescription(Table $table, $row) + public function convertForeignKeyDescription(TableSchema $table, $row): void { - return false; } /** * {@inheritDoc} */ - public function truncateTableSql(Table $table) + public function truncateTableSql(TableSchema $table): array { - return false; + return []; } /** * {@inheritDoc} */ - public function createTableSql(Table $table, $columns, $constraints, $indexes) + public function createTableSql(TableSchema $table, $columns, $constraints, $indexes): array { - return false; + return []; } /** * {@inheritDoc} */ - public function columnSql(Table $table, $name) + public function columnSql(TableSchema $table, $name): string { - $data = $table->column($name); + $data = $table->getColumn($name); $out = $this->_driver->quoteIdentifier($name); $typeMap = [ 'integer' => ' INTEGER', @@ -295,29 +299,21 @@ public function columnSql(Table $table, $name) } $hasPrecision = ['float', 'decimal']; - if (in_array($data['type'], $hasPrecision, true) && - (isset($data['length']) || isset($data['precision'])) - ) { + if (in_array($data['type'], $hasPrecision, true) && (isset($data['length']) || isset($data['precision']))) { $out .= '(' . (int)$data['length'] . ',' . (int)$data['precision'] . ')'; } $hasUnsigned = ['float', 'decimal', 'integer', 'biginteger']; - if (in_array($data['type'], $hasUnsigned, true) && - isset($data['unsigned']) && $data['unsigned'] === true - ) { + if (in_array($data['type'], $hasUnsigned, true) && isset($data['unsigned']) && $data['unsigned'] === true) { $out .= ' UNSIGNED'; } if (isset($data['null']) && $data['null'] === false) { $out .= ' NOT NULL'; } - $addAutoIncrement = ( - [$name] == (array)$table->primaryKey() && - !$table->hasAutoIncrement() - ); - if (in_array($data['type'], ['integer', 'biginteger']) && - ($data['autoIncrement'] === true || $addAutoIncrement) - ) { + $addAutoIncrement = ([$name] == (array)$table->getPrimaryKey() && !$table->hasAutoIncrement()); + if (in_array($data['type'], + ['integer', 'biginteger']) && ($data['autoIncrement'] === true || $addAutoIncrement)) { $out .= ' AUTO_INCREMENT'; } if (isset($data['null']) && $data['null'] === true) { @@ -328,10 +324,8 @@ public function columnSql(Table $table, $name) $out .= ' DEFAULT ' . $this->_driver->schemaValue($data['default']); unset($data['default']); } - if (isset($data['default']) && - in_array($data['type'], ['timestamp', 'datetime']) && - strtolower($data['default']) === 'current_timestamp' - ) { + if (isset($data['default']) && in_array($data['type'], + ['timestamp', 'datetime']) && strtolower($data['default']) === 'current_timestamp') { $out .= ' DEFAULT CURRENT_TIMESTAMP'; unset($data['default']); } @@ -344,15 +338,7 @@ public function columnSql(Table $table, $name) /** * {@inheritDoc} */ - public function constraintSql(Table $table, $name) - { - return false; - } - - /** - * {@inheritDoc} - */ - public function addConstraintSql(Table $table) + public function constraintSql(TableSchema $table, $name): string { return false; } @@ -360,28 +346,24 @@ public function addConstraintSql(Table $table) /** * {@inheritDoc} */ - public function dropConstraintSql(Table $table) + public function addConstraintSql(TableSchema $table): array { - return false; + return []; } /** * {@inheritDoc} */ - public function indexSql(Table $table, $name) + public function dropConstraintSql(TableSchema $table): array { - return false; + return []; } /** - * Helper method for generating key SQL snippets. - * - * @param string $prefix The key prefix - * @param array $data Key data. - * @return string + * {@inheritDoc} */ - protected function _keySql($prefix, $data) + public function indexSql(TableSchema $table, $name): string { - return false; + return ''; } } diff --git a/src/Database/Statement/SalesforceStatement.php b/src/Database/Statement/SalesforceStatement.php index 5eb6558..ccf86fe 100644 --- a/src/Database/Statement/SalesforceStatement.php +++ b/src/Database/Statement/SalesforceStatement.php @@ -12,36 +12,52 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\Database\Statement; use AssignmentRuleHeader; +use Cake\Database\DriverInterface; use Cake\Database\Statement\StatementDecorator; -use Cake\Database\ValueBinder; +use Cake\Database\StatementInterface; + /** * Statement class meant to be used by a Mysql PDO driver * + * @property \Cake\Database\Driver|\Salesforce\Database\Driver\SalesforceDriverTrait $_driver * @internal */ class SalesforceStatement extends StatementDecorator { protected $last_rows_affected = 0; + protected $last_result; //pretty sure this is awful! + protected $last_row_returned = 0; + private $_last_insert_id = []; + public function __construct($statement, DriverInterface $driver) + { + $this->_statement = $statement; + $this->_driver = $driver; + } + /** * {@inheritDoc} * + * @throws \Exception */ - public function execute($params = null) + public function execute(?array $params = null): bool { $sql = $this->_statement->sql(); - $bindings = $this->_statement->valueBinder()->bindings(); + $bindings = $this->_statement->getValueBinder()->bindings(); + $this->_driver->errors = null; //intercept Update here if ($this->_statement->type() == 'update') { - $results = $this->_driver->client->update([$this->_buildObjectFromUpdate($sql, $bindings)], $this->_statement->repository()->name); + $results = $this->_driver->client->update([$this->_buildObjectFromUpdate($sql, $bindings)], + $this->_statement->getRepository()->name); if (is_object($results)) { trigger_error('Unexpected object results', E_USER_ERROR); } @@ -52,48 +68,59 @@ public function execute($params = null) $result->size = 0; $this->_driver->errors = $results[0]->errors; } - } else if ($this->_statement->type() == 'insert') { - $object = $this->_buildObjectFromInsert($sql, $bindings); - if (empty($object->OwnerId)) { - $header = new AssignmentRuleHeader(null, true); // run the default lead assignment rule - $this->_driver->client->setAssignmentRuleHeader($header); - } - $results = $this->_driver->client->create([$object], $this->_statement->repository()->name); - if (is_object($results)) { - trigger_error('Unexpected object results', E_USER_ERROR); - } - $result = new \stdClass(); - if ($results[0]->success) { - $result->size = 1; - $this->_last_insert_id[$this->_statement->repository()->name] = $results[0]->id; + } else { + if ($this->_statement->type() == 'insert') { + $object = $this->_buildObjectFromInsert($sql, $bindings); + if (empty($object->OwnerId)) { + $header = new AssignmentRuleHeader(null, true); // run the default lead assignment rule + $this->_driver->client->setAssignmentRuleHeader($header); + } + $results = $this->_driver->client->create([$object], $this->_statement->getRepository()->name); + if (is_object($results)) { + trigger_error('Unexpected object results', E_USER_ERROR); + } + $result = new \stdClass(); + if ($results[0]->success) { + $result->size = 1; + $this->_last_insert_id[$this->_statement->getRepository()->name] = $results[0]->id; + } else { + $result->size = 0; + $this->_driver->errors = $results[0]->errors; + } } else { - $result->size = 0; - $this->_driver->errors = $results[0]->errors; + if ($this->_statement->type() == 'delete') { + if (count($bindings) == 1 && preg_match('/DELETE FROM .* WHERE Id = :c0/', $sql)) { + $id = $bindings[':c0']['value']; + $results = $this->_driver->client->delete([$id]); + if (is_object($results)) { + trigger_error('Unexpected object results', E_USER_ERROR); + } + $result = new \stdClass(); + if ($results[0]->success) { + $result->size = 1; + } else { + $result->size = 0; + $this->_driver->errors = $results[0]->errors; + } + } else { + throw new \Exception('Unsupported delete query. Only where ID clauses are supported'); + } + } else { + // Use epilog('@queryAll') with CakePHP's query build to include deleted records. + if (substr($sql, -10) === ' @queryAll') { + $sql = substr($sql, 0, strlen($sql) - 10); + $result = $this->_driver->client->queryAll($this->_interpolate($sql, $bindings)); + } else { + $result = $this->_driver->client->query($this->_interpolate($sql, $bindings)); + } + } } - } else { - $result = $this->_driver->client->query($this->_interpolate($sql, $bindings)); } $this->last_rows_affected = $result->size; $this->last_result = $result; - return $result; - } - - /** - * Helper function used to replace query placeholders by the real - * params used to execute the query. - * - * @param string $sql The sql query - * @param array $bindings List of placeholder replacement values - * @return string - */ - protected function _interpolate($sql, $bindings) - { - foreach ($bindings as $binding) { - $sql = preg_replace('/:'.$binding['placeholder'].'\b/i', $this->_replacement($binding, true), $sql); - } - return $sql; + return true; } /** @@ -108,16 +135,56 @@ protected function _buildObjectFromUpdate($sql, $bindings) preg_match('/UPDATE .* SET (.*) WHERE (.*)/', $sql, $parts); $cleanedSQL = explode(' , ', $parts[1]); $cleanedSQL[] = $parts[2]; - $newSQL = []; + $newSQL = ['fieldsToNull' => []]; foreach ($cleanedSQL as $row) { - $string = explode(' = ', $row); - $newSQL[trim($string[0])] = $this->_replacement($bindings[trim($string[1])]); + [$fieldName, $value] = explode(' = ', $row); + $fieldName = trim($fieldName); + $value = $this->_replacement($bindings[trim($value)]); + if ($value === '') { + $newSQL['fieldsToNull'][] = $fieldName; + } else { + $newSQL[$fieldName] = $value; + } + } + + if (count($newSQL['fieldsToNull']) === 0) { + unset($newSQL['fieldsToNull']); } //return as object return (object)$newSQL; } + protected function _replacement($binding, $quote = false) + { + switch ($binding['type']) { + case 'boolean': + return $binding['value'] ? 'true' : 'false'; + case 'integer': + return (int)$binding['value']; + case 'float': + return (float)$binding['value']; + case 'datetime': + case 'date': + if (is_string($binding['value'])) { + $ret = $binding['value']; + } else { + $ret = $binding['value'] ? $binding['value']->toIso8601String() : ''; + } + break; + case 'string': + $ret = trim($binding['value']); + break; + default: + $ret = addslashes(trim($binding['value'])); + break; + } + if ($quote) { + return "'$ret'"; + } + return $ret; + } + /** * Helper function used to build an sObject from an insert query. * @@ -142,34 +209,26 @@ protected function _buildObjectFromInsert($sql, $bindings) return (object)$newSQL; } - protected function _replacement($binding, $quote = false) + /** + * Helper function used to replace query placeholders by the real + * params used to execute the query. + * + * @param string $sql The sql query + * @param array $bindings List of placeholder replacement values + * @return string + */ + protected function _interpolate($sql, $bindings) { - switch ($binding['type']) { - case 'integer': - case 'boolean': - return (int)$binding['value']; - case 'float': - return (float)$binding['value']; - case 'datetime': - case 'date': - $ret = (string)$binding['value']; - break; - case 'string': - $ret = trim($binding['value']); - break; - default: - $ret = addslashes(trim($binding['value'])); - break; - } - if ($quote) { - return "'$ret'"; + foreach ($bindings as $binding) { + $sql = preg_replace('/:' . $binding['placeholder'] . '\b/i', $this->_replacement($binding, true), $sql); } - return $ret; + + return $sql; } - public function rowCount() + public function rowCount(): int { - return $this->last_rows_affected; + return $this->last_rows_affected; } /** @@ -209,6 +268,9 @@ public function fetch($type = 'num') */ public function errorCode() { + if (!empty($this->_driver->errors)) { + return $this->_driver->errors[0]->statusCode; + } return '00000'; } @@ -218,9 +280,14 @@ public function errorCode() * * @return array */ - public function errorInfo() + public function errorInfo(): array { - return 'Salesforce Datasource doesnt produce PDO error codes - exceptions are usually thrown'; + if (!empty($this->_driver->errors)) { + return $this->_driver->errors[0]->message; + } + return [ + 'Salesforce Datasource doesnt produce PDO error codes - exceptions are usually thrown' + ]; } /** @@ -230,9 +297,8 @@ public function errorInfo() * * @return void */ - public function closeCursor() + public function closeCursor(): void { - return true; } /** @@ -242,7 +308,7 @@ public function closeCursor() * @param string|null $column the name of the column representing the primary key * @return string */ - public function lastInsertId($table = null, $column = null) + public function lastInsertId(?string $table = null, ?string $column = null) { return $this->_last_insert_id[$table]; } diff --git a/src/Database/Type/SalesforceDateTimeType.php b/src/Database/Type/SalesforceDateTimeType.php new file mode 100644 index 0000000..9180bf1 --- /dev/null +++ b/src/Database/Type/SalesforceDateTimeType.php @@ -0,0 +1,16 @@ +table('Contact'); - $this->displayField('Name'); - $this->primaryKey('Id'); + $this->setTable('Contact'); + $this->setDisplayField('Name'); + $this->setPrimaryKey('Id'); } } diff --git a/src/Model/Table/SalesforcesTable.php b/src/Model/Table/SalesforcesTable.php index 3ea8fbe..79530a7 100644 --- a/src/Model/Table/SalesforcesTable.php +++ b/src/Model/Table/SalesforcesTable.php @@ -1,7 +1,15 @@ table(false); - $this->displayField('Id'); - $this->primaryKey('Id'); + $this->setTable(''); + $this->setDisplayField('Id'); + $this->setPrimaryKey('Id'); - if(!empty($config['connection']->config()['my_wsdl'])) { - $wsdl = CONFIG . DS . $config['connection']->config()['my_wsdl']; + if (!empty($config['connection']) && !empty($config['connection']->config()['my_wsdl'])) { + $wsdl = CONFIG . DS . $config['connection']->config()['my_wsdl']; } else { - throw new \Exception('You need to provide a WSDL'); + throw new Exception('You need to provide a WSDL'); } $mySforceConnection = new \SforceEnterpriseClient(); $mySforceConnection->createConnection($wsdl); $cache_key = $config['connection']->config()['name'] . '_login'; - $sflogin = (array)Cache::read($cache_key, 'salesforce'); - if(!empty($sflogin['sessionId'])) { - $mySforceConnection->setSessionHeader($sflogin['sessionId']); - $mySforceConnection->setEndpoint($sflogin['serverUrl']); + $sfLogin = (array)Cache::read($cache_key, 'salesforce'); + if (!empty($sfLogin['sessionId'])) { + $mySforceConnection->setSessionHeader($sfLogin['sessionId']); + $mySforceConnection->setEndpoint($sfLogin['serverUrl']); } else { - try{ - $mylogin = $mySforceConnection->login($config['connection']->config()['username'],$config['connection']->config()['password']); - $sflogin = ['sessionId' => $mylogin->sessionId, 'serverUrl' => $mylogin->serverUrl]; - Cache::write($cache_key, $sflogin, 'salesforce'); - } catch (\Exception $e) { + try { + $myLogin = $mySforceConnection->login($config['connection']->config()['username'], + $config['connection']->config()['password']); + $sfLogin = ['sessionId' => $myLogin->sessionId, 'serverUrl' => $myLogin->serverUrl]; + Cache::write($cache_key, $sfLogin, 'salesforce'); + } catch (Exception $e) { $this->log('Error logging into salesforce from Table - Salesforce down?'); throw $e; } } - if (!$sObject = Cache::read($this->name.'_sObject', 'salesforce')) { + if (!$sObject = Cache::read($this->name . '_sObject', 'salesforce')) { $sObject = $mySforceConnection->describeSObject($this->name); - Cache::write($this->name.'_sObject', $sObject, 'salesforce'); + Cache::write($this->name . '_sObject', $sObject, 'salesforce'); } - $this->_fields = Cache::remember($this->name.'_schema', function () use ($sObject) { + $this->_fields = Cache::remember($this->name . '_schema', function () use ($sObject) { $fields = []; foreach ($sObject->fields as $field) { - if(substr($field->soapType,0,3) != 'ens') { //we dont want type of ens - if(substr($field->soapType,4) == 'int') { + if (substr($field->soapType, 0, 3) != 'ens') { //we dont want type of ens + if (substr($field->soapType, 4) == 'int') { $type_name = 'integer'; - } elseif (substr($field->soapType,4) == 'double') { + } elseif (substr($field->soapType, 4) == 'double') { $type_name = 'float'; - } elseif (substr($field->soapType,4) == 'boolean') { + } elseif (substr($field->soapType, 4) == 'boolean') { $type_name = 'boolean'; - } elseif (substr($field->soapType,4) == 'dateTime') { + } elseif (substr($field->soapType, 4) == 'dateTime') { $type_name = 'datetime'; - } elseif (substr($field->soapType,4) == 'date') { + } elseif (substr($field->soapType, 4) == 'date') { $type_name = 'date'; } else { $type_name = 'string'; } - if($field->createable || $field->name == 'Id') { - $fields['creatable'][$field->name] = ['type' => $type_name, 'length' => $field->length, 'null' => $field->nillable]; + if ($field->createable || $field->name == 'Id') { + $fields['creatable'][$field->name] = [ + 'type' => $type_name, + 'length' => $field->length, + 'null' => $field->nillable + ]; } - if($field->updateable || $field->name == 'Id') { - $fields['updatable'][$field->name] = ['type' => $type_name, 'length' => $field->length, 'null' => $field->nillable]; + if ($field->updateable || $field->name == 'Id') { + $fields['updatable'][$field->name] = [ + 'type' => $type_name, + 'length' => $field->length, + 'null' => $field->nillable + ]; } - $fields['selectable'][$field->name] = ['type' => $type_name, 'length' => $field->length, 'null' => $field->nillable]; + $fields['selectable'][$field->name] = [ + 'type' => $type_name, + 'length' => $field->length, + 'null' => $field->nillable + ]; } } @@ -102,53 +124,171 @@ public function initialize(array $config) }, 'salesforce'); //Cache select fields right away as most likely need them immediately - $this->schema($this->_fields['selectable']); + $this->setSchema($this->_fields['selectable']); } /** * {@inheritDoc} */ - public function newEntity($data = null, array $options = []) { - $this->schema($this->_fields['creatable']); + public function newEntity(array $data, array $options = []): EntityInterface + { + $this->setSchema($this->_fields['creatable']); + return parent::newEntity($data, $options); } /** * {@inheritDoc} */ - public function newEntities(array $data, array $options = []) { - $this->schema($this->_fields['creatable']); + public function newEntities(array $data, array $options = []): array + { + $this->setSchema($this->_fields['creatable']); + return parent::newEntities($data, $options); } /** * {@inheritDoc} */ - public function patchEntity(EntityInterface $entity, array $data, array $options = []) { - $this->schema($this->_fields['updatable']); + public function patchEntity(EntityInterface $entity, array $data, array $options = []): EntityInterface + { + $this->setSchema($this->_fields['updatable']); + return parent::patchEntity($entity, $data, $options); } /** * {@inheritDoc} */ - public function patchEntities($entities, array $data, array $options = []) { - $this->schema($this->_fields['updatable']); + public function patchEntities(iterable $entities, array $data, array $options = []): array + { + $this->setSchema($this->_fields['updatable']); + return parent::patchEntities($entities, $data, $options); } - public function beforeFind($event, $query, $options, $primary) { - $this->schema($this->_fields['selectable']); + public function beforeFind(Event $event, Query $query, ArrayObject $options, $primary) + { + $this->setSchema($this->_fields['selectable']); } - public function beforeSave($event, $entity, $options) { - if($options['atomic']) { - throw new \Exception('Salesforce API does not support atomic transactions; set atomic to false.'); + /** + * @param Event $event + * @param EntityInterface $entity + * @param $options + * @return bool + * @throws Exception + */ + public function beforeSave(Event $event, EntityInterface $entity, $options): bool + { + if ($options['atomic']) { + throw new Exception('Salesforce API does not support atomic transactions; set atomic to false.'); } if ($entity->isNew()) { - $this->schema($this->_fields['creatable']); + $this->setSchema($this->_fields['creatable']); } else { - $this->schema($this->_fields['updatable']); + $this->setSchema($this->_fields['updatable']); } + + return true; } + + public function toCompositeSObject(EntityInterface $entity, $includeId = false) + { + $object = new CompositeSObject($this->getTable()); + foreach ($entity->extract($entity->getDirty()) as $name => $value) { + if (is_object($value) && method_exists($value, 'toIso8601String')) { + $value = $value->toIso8601String(); + } + $object->{$name} = $value; + } + if ($includeId) { + $object->Id = $entity['Id']; + } + + return $object; + } + + /** + * @param EntityInterface[] $records + * @return CollectionResponse[] + */ + public function createBulk(array $records): array + { + $items = collection($records)->map(function($item) { return $this->toCompositeSObject($item);})->toArray(); + $request = new CollectionRequest($items, true); + + $client = $this->getConnection()->getDriver()->getRestClient(); + $result = $client->getCompositeClient()->create($request); + + foreach ($result as $id => $resultItem) { + if ($resultItem->isSuccess()) { + $records[$id]->set('Id', $resultItem->getId()); + $records[$id]->clean(); + } + } + + return $result; + } + + /** + * @param EntityInterface[] $records + * @return CollectionResponse[] + */ + public function updateBulk(array $records): array + { + $items = collection($records)->map(function($item) { + return $this->toCompositeSObject($item, true); + })->toArray(); + + return $this->getConnection() + ->getDriver() + ->getRestClient() + ->getCompositeClient() + ->update(new CollectionRequest($items, true)); + } + + /** + * @param array $ids + * @return CollectionResponse[] + */ + public function readBulk(array $ids, $fields = null): array + { + $this->getSchema($this->_fields['selectable']); + if ($fields === null) { + $fields = array_keys($this->_fields['selectable']); + } + $response = $this->getConnection() + ->getDriver() + ->getRestClient() + ->getCompositeClient() + ->read($this->getTable(), $ids, $fields); + + return collection($response)->map(function (CompositeSObject $item) { + $entity = $this->marshaller()->one($item->getFields()); + $entity->clean(); + return $entity; + })->toArray(); + } + + /** + * @param array $ids + * @return CollectionResponse[] + */ + public function deleteBulk(array $ids): array + { + $items = collection($ids)->map(function($id) { + $object = new CompositeSObject($this->getTable()); + $object->Id = $id; + + return $object; + })->toArray(); + + return $this->getConnection() + ->getDriver() + ->getRestClient() + ->getCompositeClient() + ->delete(new CollectionRequest($items, true)); + } + } diff --git a/src/ORM/SalesforceMarshaller.php b/src/ORM/SalesforceMarshaller.php index f3e286f..e65d0e5 100644 --- a/src/ORM/SalesforceMarshaller.php +++ b/src/ORM/SalesforceMarshaller.php @@ -12,10 +12,14 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\ORM; -use ArrayObject; +use Cake\Database\TypeFactory; +use Cake\Datasource\EntityInterface; +use Cake\Datasource\InvalidPropertyInterface; use Cake\ORM\Marshaller; +use Cake\ORM\PropertyMarshalInterface; use Salesforce\Database\SalesforceType; /** @@ -30,43 +34,23 @@ */ class SalesforceMarshaller extends Marshaller { + /** - * Hydrate one entity and its associated data. - * - * ### Options: - * - * * associated: Associations listed here will be marshalled as well. - * * fieldList: A whitelist of fields to be assigned to the entity. If not present, - * the accessible fields list in the entity will be used. - * * accessibleFields: A list of fields to allow or deny in entity accessible fields. - * - * The above options can be used in each nested `associated` array. In addition to the above - * options you can also use the `onlyIds` option for HasMany and BelongsToMany associations. - * When true this option restricts the request data to only be read from `_ids`. - * - * ``` - * $result = $marshaller->one($data, [ - * 'associated' => ['Tags' => ['onlyIds' => true]] - * ]); - * ``` - * - * @param array $data The data to hydrate. - * @param array $options List of options - * @return \Cake\ORM\Entity - * @see \Cake\ORM\Table::newEntity() + * {@inheritDoc} */ - public function one(array $data, array $options = []) + public function one(array $data, array $options = []): EntityInterface { list($data, $options) = $this->_prepareDataAndOptions($data, $options); - $primaryKey = (array)$this->_table->primaryKey(); - $entityClass = $this->_table->entityClass(); + $primaryKey = (array)$this->_table->getPrimaryKey(); + $entityClass = $this->_table->getEntityClass(); + /** @var EntityInterface $entity */ $entity = new $entityClass(); - $entity->source($this->_table->registryAlias()); + $entity->setSource($this->_table->getRegistryAlias()); if (isset($options['accessibleFields'])) { foreach ((array)$options['accessibleFields'] as $key => $value) { - $entity->accessible($key, $value); + $entity->setAccess($key, $value); } } $errors = $this->_validate($data, $options, true); @@ -77,7 +61,7 @@ public function one(array $data, array $options = []) foreach ($data as $key => $value) { if (!empty($errors[$key])) { if ($entity instanceof InvalidPropertyInterface) { - $entity->invalid($key, $value); + $entity->setInvalidField($key, $value); } continue; } @@ -88,7 +72,8 @@ public function one(array $data, array $options = []) } elseif (isset($propertyMap[$key])) { $properties[$key] = $propertyMap[$key]($value, $entity); } else { - $columnType = $this->_table->schema()->columnType($key); + $columnType = $this->_table->getSchema() + ->getColumnType($key); if ($columnType) { $converter = SalesforceType::build($columnType); $properties[$key] = $converter->marshal($value); @@ -98,7 +83,7 @@ public function one(array $data, array $options = []) if (!isset($options['fieldList'])) { $entity->set($properties); - $entity->errors($errors); + $entity->setErrors($errors); return $entity; } @@ -109,8 +94,79 @@ public function one(array $data, array $options = []) } } - $entity->errors($errors); + $entity->setErrors($errors); return $entity; } + + protected function _buildPropertyMap(array $data, array $options): array + { + $map = []; + $schema = $this->_table->getSchema(); + + // Is a concrete column? + foreach (array_keys($data) as $prop) { + $prop = (string)$prop; + $columnType = $schema->getColumnType($prop); + if ($columnType) { + $map[$prop] = function ($value, $entity) use ($columnType) { + return SalesforceType::build($columnType)->marshal($value); + }; + } + } + + // Map associations + if (!isset($options['associated'])) { + $options['associated'] = []; + } + $include = $this->_normalizeAssociations($options['associated']); + foreach ($include as $key => $nested) { + if (is_int($key) && is_scalar($nested)) { + $key = $nested; + $nested = []; + } + // If the key is not a special field like _ids or _joinData + // it is a missing association that we should error on. + if (!$this->_table->hasAssociation($key)) { + if (substr($key, 0, 1) !== '_') { + throw new InvalidArgumentException(sprintf( + 'Cannot marshal data for "%s" association. It is not associated with "%s".', + (string)$key, + $this->_table->getAlias() + )); + } + continue; + } + $assoc = $this->_table->getAssociation($key); + + if (isset($options['forceNew'])) { + $nested['forceNew'] = $options['forceNew']; + } + if (isset($options['isMerge'])) { + $callback = function ($value, $entity) use ($assoc, $nested) { + /** @var \Cake\Datasource\EntityInterface $entity */ + $options = $nested + ['associated' => [], 'association' => $assoc]; + + return $this->_mergeAssociation($entity->get($assoc->getProperty()), $assoc, $value, $options); + }; + } else { + $callback = function ($value, $entity) use ($assoc, $nested) { + $options = $nested + ['associated' => []]; + + return $this->_marshalAssociation($assoc, $value, $options); + }; + } + $map[$assoc->getProperty()] = $callback; + } + + $behaviors = $this->_table->behaviors(); + foreach ($behaviors->loaded() as $name) { + $behavior = $behaviors->get($name); + if ($behavior instanceof PropertyMarshalInterface) { + $map += $behavior->buildMarshalMap($this, $map, $options); + } + } + + return $map; + } } diff --git a/src/ORM/SalesforceQuery.php b/src/ORM/SalesforceQuery.php index dae47c0..2590ffa 100644 --- a/src/ORM/SalesforceQuery.php +++ b/src/ORM/SalesforceQuery.php @@ -12,9 +12,11 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\ORM; use ArrayObject; +use Cake\Datasource\ResultSetInterface; use Salesforce\ORM\SalesforceResultSet; use Salesforce\Database\SalesforceQuery as SalesforceDatabaseQuery; use Cake\ORM\Query; @@ -33,28 +35,10 @@ class SalesforceQuery extends Query { public $queryString = ""; - /** - * Executes this query and returns a ResultSet object containing the results. - * This will also setup the correct statement class in order to eager load deep - * associations. - * - * @return \Cake\ORM\ResultSet - */ - protected function _execute() - { - $this->triggerBeforeFind(); - if ($this->_results) { - $decorator = $this->_decoratorClass(); - return new $decorator($this->_results); - } - $statement = $this->eagerLoader()->loadExternal($this, $this->execute()); - return new SalesforceResultSet($this, $statement); - } - /** * {@inheritDoc} */ - public function sql(ValueBinder $binder = null) + public function sql(?ValueBinder $binder = null): string { $this->triggerBeforeFind(); @@ -63,8 +47,32 @@ public function sql(ValueBinder $binder = null) // Not a static function, and can't be made static because of base class, // so we @hide the warning about a non-static function used in a static // context. - $sql = @SalesforceDatabaseQuery::sql(null, $this); + + //$sql = SalesforceDatabaseQuery::sql(null, $this); + + $generator = $this->getValueBinder(); + $generator->resetCount(); + + $sql = $this->getConnection() + ->compileQuery($this, $generator); + return $sql; } + + /** + * {@inheritDoc} + */ + protected function _execute(): ResultSetInterface + { + $this->triggerBeforeFind(); + if ($this->_results) { + $decorator = $this->_decoratorClass(); + return new $decorator($this->_results); + } + $statement = $this->getEagerLoader() + ->loadExternal($this, $this->execute()); + + return new SalesforceResultSet($this, $statement); + } } \ No newline at end of file diff --git a/src/ORM/SalesforceResultSet.php b/src/ORM/SalesforceResultSet.php index 434d2a2..9fe046c 100644 --- a/src/ORM/SalesforceResultSet.php +++ b/src/ORM/SalesforceResultSet.php @@ -12,16 +12,12 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\ORM; +use Cake\ORM\Query; use Cake\ORM\ResultSet; -use Cake\Collection\Collection; -use Cake\Collection\CollectionTrait; -use Cake\Database\Exception; -use Cake\Database\Type; -use Cake\Datasource\EntityInterface; -use Cake\Datasource\ResultSetInterface; -use SplFixedArray; + /** * Represents the results obtained after executing a query for a specific table @@ -33,12 +29,27 @@ class SalesforceResultSet extends ResultSet { /** - * Creates a map of row keys out of the query select clause that can be - * used to hydrate nested result sets more quickly. - * - * @return void + * {@inheritDoc} + */ + public function first() + { + foreach ($this as $result) { + if ($this->_statement && !$this->_useBuffering) { + $this->_statement->closeCursor(); + } + $result = new $this->_entityClass($result); + $result->clean(); + + return $result; + } + + return null; + } + + /** + * {@inheritDoc} */ - protected function _calculateColumnMap($query) + protected function _calculateColumnMap(Query $query): void { $map = []; //My one foreach ($query->clause('select') as $key => $field) { @@ -63,21 +74,7 @@ protected function _calculateColumnMap($query) } /** - * Correctly nests results keys including those coming from associations - * - * @param mixed $row Array containing columns and values or false if there is no results - * @return array Results - */ - protected function _groupResult($row) - { - return $row; - } - - /** - * Helper function to fetch the next result from the statement or - * seeded results. - * - * @return mixed + * {@inheritDoc} */ protected function _fetchResult() { @@ -93,21 +90,10 @@ protected function _fetchResult() } /** - * Get the first record from a result set. - * - * This method will also close the underlying statement cursor. - * - * @return array|object + * {@inheritDoc} */ - public function first() + protected function _groupResult($row) { - foreach ($this as $result) { - if ($this->_statement && !$this->_useBuffering) { - $this->_statement->closeCursor(); - } - $result = new $this->_entityClass($result); - $result->clean(); - return $result; - } + return $row; } } diff --git a/src/ORM/SalesforceTable.php b/src/ORM/SalesforceTable.php index 2b7d012..38685de 100644 --- a/src/ORM/SalesforceTable.php +++ b/src/ORM/SalesforceTable.php @@ -12,43 +12,41 @@ * @since 3.0.0 * @license http://www.opensource.org/licenses/mit-license.php MIT License */ + namespace Salesforce\ORM; use \ArrayObject; use Cake\Datasource\EntityInterface; +use Cake\ORM\Marshaller; +use Cake\ORM\Query; use Cake\ORM\Table; -use Salesforce\ORM\SalesforceQuery; -use Salesforce\ORM\SalesforceMarshaller; - class SalesforceTable extends Table { /** * {@inheritDoc} */ - public function query() + public function query(): Query { - return new SalesforceQuery($this->connection(), $this); + return new SalesforceQuery($this->getConnection(), $this); } /** * {@inheritDoc} */ - public function exists($conditions) + public function exists($conditions): bool { - return (bool)count( - $this->find('all') - ->select(['Id']) - ->where($conditions) - ->limit(1) - ->hydrate(false) - ->toArray() - ); + return (bool)count($this->find('all') + ->select(['Id']) + ->where($conditions) + ->limit(1) + ->enableHydration(false) + ->toArray()); } /** - * {@inheritDoc} - */ + * {@inheritDoc} + */ public function save(EntityInterface $entity, $options = []) { $options = new ArrayObject($options + [ @@ -62,15 +60,14 @@ public function save(EntityInterface $entity, $options = []) if (is_array($entity)) { $entity = $this->newEntity($entity); } - if ($entity->errors()) { + if ($entity->getErrors()) { return false; } - if ($entity->isNew() === false && !$entity->dirty()) { + if ($entity->isNew() === false && !$entity->isDirty()) { return $entity; } - $connection = $this->connection(); $success = $this->_processSave($entity, $options); if ($success) { @@ -79,49 +76,46 @@ public function save(EntityInterface $entity, $options = []) } if ($options['atomic'] || $options['_primary']) { - $entity->isNew(false); - $entity->source($this->registryAlias()); + $entity->setNew(false); + $entity->setSource($this->getRegistryAlias()); } } else { - $errors = $this->connection()->driver()->errors[0]; + $errors = $this->getConnection() + ->getDriver()->errors[0]; if (!empty($errors->fields)) { $field = $errors->fields[0]; } else { // For lack of anything better... $field = 'id'; } - $entity->errors($field, $errors->message); + $entity->setError($field, $errors->message); } return $success; } + /** - * {@inheritDoc} - */ - public function newEntity($data = null, array $options = []) + * {@inheritDoc} + */ + public function newEntity(array $data, array $options = []): EntityInterface { if ($data === null) { - $class = $this->entityClass(); - $entity = new $class([], ['source' => $this->registryAlias()]); - return $entity; + $class = $this->getEntityClass(); + + return new $class([], ['source' => $this->getRegistryAlias()]); } if (!isset($options['associated'])) { $options['associated'] = $this->_associations->keys(); } $marshaller = $this->marshaller(); + return $marshaller->one($data, $options); } /** - * Get the object used to marshal/convert array data into objects. - * - * Override this method if you want a table object to use custom - * marshalling logic. - * - * @return \Cake\ORM\Marshaller - * @see \Cake\ORM\Marshaller + * {@inheritDoc} */ - public function marshaller() + public function marshaller(): Marshaller { return new SalesforceMarshaller($this); }