From fad7a4cd708b3aa3b61929d9b01d8251e5d88ebf Mon Sep 17 00:00:00 2001 From: Tom Gregory Date: Wed, 19 Dec 2012 00:28:27 -0500 Subject: [PATCH] Multiple connections in Paris Finishes Paris side of j4mie/idiorm#15. Requires (of course) pull request at j4mie/idiorm#76, which is the Idiorm implementation. Unit tests and documentation included. Unit tests work, but I haven't yet tried this on "real" data. --- README.markdown | 41 +++++++- paris.php | 21 +++- test/idiorm.php | 222 +++++++++++++++++++++++++++++------------- test/test_classes.php | 55 ++++++++++- test/test_queries.php | 48 ++++++++- 5 files changed, 310 insertions(+), 77 deletions(-) diff --git a/README.markdown b/README.markdown index 14980d9d..8738f6ff 100644 --- a/README.markdown +++ b/README.markdown @@ -409,9 +409,48 @@ Despite this, Paris doesn't provide any built-in support for validation. This is However, there are several simple ways that you could add validation to your models without any help from Paris. You could override the `save()` method, check the data is valid, and return `false` on failure, or call `parent::save()` on success. You could create your own subclass of the `Model` base class and add your own generic validation methods. Or you could write your own external validation framework which you pass model instances to for checking. Choose whichever approach is most suitable for your own requirements. +### Mulitple Connections ### +Paris now works with multiple database conections (and necessarily relies on an updated version of Idiorm that also supports multiple connections). Database connections are identified by a string key, and default to `OrmWrapper::DEFAULT_CONNECTION` (which is really `ORM::DEFAULT_CONNECTION`). + +See [Idiorm's documentation](http://github.com/j4mie/idiorm/) for information about configuring multiple connections. + +The connection to use can be specified in two separate ways. To indicate a default connection key for a subclass of `Model`, create a public static property in your model class called `$_connection_key`. + +```php +// A named connection, where 'alternate' is an arbitray key name +ORM::configure('sqlite:./example2.db', 'alternate'); + +class SomeClass extends Model +{ + public static $_connection_key = 'alternate'; +} +``` + +The connection to use can also be specified as an optional additional parameter to `OrmWrapper::for_table()`, or to `Model::factory()`. This will override the default setting (if any) found in the `$_connection_key` static property. + +```php +$person = Model::factory('Author', 'alternate')->find_one(1); // Uses connection named 'alternate' + +``` + +The connection can be changed after a model is populated, should that be necessary: + +```php +$person = Model::factory('Author')->find_one(1); // Uses default connection +$person->orm = Model::factory('Author', ALTERNATE); // Switches to connection named 'alternate' +$person->name = 'Foo'; +$person->save(); // *Should* now save through the updated connection +``` + +Queries across multiple connections are not supported. However, as the Paris methods `has_one`, `has_many` and `belongs_to` don't require joins, these *should* work as expected, even when the objects on opposite sides of the relation belong to diffrent connections. The `has_many_through` relationship requires joins, and so will not reliably work across different connections. + ### Configuration ### -The only configuration options provided by Paris itself are the `$_table` and `$_id_column` static properties on model classes. To configure the database connection, you should use Idiorm's configuration system via the `ORM::configure` method. **See [Idiorm's documentation](http://github.com/j4mie/idiorm/) for full details.** +The only configuration options provided by Paris itself are the `$_table` and `$_id_column` static properties on model classes. To configure the database connection, you should use Idiorm's configuration system via the `ORM::configure` method. + +Now that multiple connections are implemented, the optional `$_connection_key` static property may also be used to provide a default string key indicating which database connection in `ORM` should be used. + +**See [Idiorm's documentation](http://github.com/j4mie/idiorm/) for full details.** ### Transactions ### diff --git a/paris.php b/paris.php index 2dd142fc..bfbe0088 100644 --- a/paris.php +++ b/paris.php @@ -86,10 +86,13 @@ public function filter() { /** * Factory method, return an instance of this * class bound to the supplied table name. + * + * A repeat of content in parent::for_table, so that + * created class is ORMWrapper, not ORM */ - public static function for_table($table_name) { - self::_setup_db(); - return new self($table_name); + public static function for_table($table_name, $which = parent::DEFAULT_CONNECTION) { + self::_setup_db($which); + return new self($table_name, array(), $which); } /** @@ -234,9 +237,17 @@ protected static function _build_foreign_key_name($specified_foreign_key_name, $ * responsible for returning instances of the correct class when * its find_one or find_many methods are called. */ - public static function factory($class_name) { + public static function factory($class_name, $which = null) { $table_name = self::_get_table_name($class_name); - $wrapper = ORMWrapper::for_table($table_name); + //TODO: Populate $which if not set, as specified in class + if ($which == null) { + $which = self::_get_static_property( + $class_name, + '_connection_key', + ORMWrapper::DEFAULT_CONNECTION + ); + } + $wrapper = ORMWrapper::for_table($table_name, $which); $wrapper->set_class_name($class_name); $wrapper->use_id_column(self::_get_id_column_name($class_name)); return $wrapper; diff --git a/test/idiorm.php b/test/idiorm.php index 7dbf9afa..24462583 100644 --- a/test/idiorm.php +++ b/test/idiorm.php @@ -48,12 +48,14 @@ class ORM { const WHERE_FRAGMENT = 0; const WHERE_VALUES = 1; + const DEFAULT_CONNECTION = 'default'; + // ------------------------ // // --- CLASS PROPERTIES --- // // ------------------------ // // Class configuration - protected static $_config = array( + protected static $_default_config = array( 'connection_string' => 'sqlite::memory:', 'id_column' => 'id', 'id_column_overrides' => array(), @@ -66,13 +68,15 @@ class ORM { 'caching' => false, ); - // Database connection, instance of the PDO class - protected static $_db; + protected static $_config = array(); + + // Map of database connections, instances of the PDO class + protected static $_db = array(); // Last query run, only populated if logging is enabled protected static $_last_query; - // Log of all queries run, only populated if logging is enabled + // Log of all queries run, mapped by connection key, only populated if logging is enabled protected static $_query_log = array(); // Query cache, only used if query caching is enabled @@ -82,6 +86,9 @@ class ORM { // --- INSTANCE PROPERTIES --- // // --------------------------- // + // Key name of the connections in self::$_db used by this instance + protected $_which_db; + // The name of the table the current ORM instance is associated with protected $_table_name; @@ -158,21 +165,23 @@ class ORM { * you wish to configure, another shortcut is to pass an array * of settings (and omit the second argument). */ - public static function configure($key, $value=null) { + public static function configure($key, $value = null, $which = self::DEFAULT_CONNECTION) { + self::_setup_db_config($which); //ensures at least default config is set + if (is_array($key)) { // Shortcut: If only one array argument is passed, // assume it's an array of configuration settings foreach ($key as $conf_key => $conf_value) { - self::configure($conf_key, $conf_value); + self::configure($conf_key, $conf_value, $which); } } else { - if (is_null($value)) { + if (empty($value)) { // Shortcut: If only one string argument is passed, // assume it's a connection string $value = $key; $key = 'connection_string'; } - self::$_config[$key] = $value; + self::$_config[$which][$key] = $value; } } @@ -183,34 +192,52 @@ public static function configure($key, $value=null) { * ORM::for_table('table_name')->find_one()-> etc. As such, * this will normally be the first method called in a chain. */ - public static function for_table($table_name) { - self::_setup_db(); - return new self($table_name); + public static function for_table($table_name, $which = self::DEFAULT_CONNECTION) + { + self::_setup_db($which); + return new self($table_name, array(), $which); } /** * Set up the database connection used by the class. - */ - protected static function _setup_db() { - if (!is_object(self::$_db)) { - $connection_string = self::$_config['connection_string']; - $username = self::$_config['username']; - $password = self::$_config['password']; - $driver_options = self::$_config['driver_options']; - $db = new PDO($connection_string, $username, $password, $driver_options); - $db->setAttribute(PDO::ATTR_ERRMODE, self::$_config['error_mode']); - self::set_db($db); + * Default value of parameter used for compatibility with Paris, until it can be updated + * @todo After paris is updated to support multiple connections, remove default value of parameter + */ + protected static function _setup_db($which = self::DEFAULT_CONNECTION) + { + if (!is_object(self::$_db[$which])) { + self::_setup_db_config($which); + + $db = new PDO( + self::$_config[$which]['connection_string'], + self::$_config[$which]['username'], + self::$_config[$which]['password'], + self::$_config[$which]['driver_options']); + + $db->setAttribute(PDO::ATTR_ERRMODE, self::$_config[$which]['error_mode']); + self::set_db($db, $which); + } + } + + /** + * Ensures configuration (mulitple connections) is at least set to default. + */ + protected static function _setup_db_config($which) { + if (!array_key_exists($which, self::$_config)) { + self::$_config[$which] = self::$_default_config; } } /** * Set the PDO object used by Idiorm to communicate with the database. * This is public in case the ORM should use a ready-instantiated - * PDO object as its database connection. + * PDO object as its database connection. Accepts an optional string key + * to identify the connection if multiple connections are used. */ - public static function set_db($db) { - self::$_db = $db; - self::_setup_identifier_quote_character(); + public static function set_db($db, $which = self::DEFAULT_CONNECTION) { + self::_setup_db_config($which); + self::$_db[$which] = $db; + self::_setup_identifier_quote_character($which); } /** @@ -219,9 +246,10 @@ public static function set_db($db) { * manually using ORM::configure('identifier_quote_character', 'some-char'), * this will do nothing. */ - public static function _setup_identifier_quote_character() { - if (is_null(self::$_config['identifier_quote_character'])) { - self::$_config['identifier_quote_character'] = self::_detect_identifier_quote_character(); + protected static function _setup_identifier_quote_character($which) { + if (is_null(self::$_config[$which]['identifier_quote_character'])) { + self::$_config[$which]['identifier_quote_character'] = + self::_detect_identifier_quote_character($which); } } @@ -229,8 +257,8 @@ public static function _setup_identifier_quote_character() { * Return the correct character used to quote identifiers (table * names, column names etc) by looking at the driver being used by PDO. */ - protected static function _detect_identifier_quote_character() { - switch(self::$_db->getAttribute(PDO::ATTR_DRIVER_NAME)) { + protected static function _detect_identifier_quote_character($which) { + switch(self::$_db[$which]->getAttribute(PDO::ATTR_DRIVER_NAME)) { case 'pgsql': case 'sqlsrv': case 'dblib': @@ -248,11 +276,35 @@ protected static function _detect_identifier_quote_character() { /** * Returns the PDO instance used by the the ORM to communicate with * the database. This can be called if any low-level DB access is - * required outside the class. + * required outside the class. If multiple connections are used, + * accepts an optional key name for the connection. + */ + public static function get_db($which = self::DEFAULT_CONNECTION) { + self::_setup_db($which); // required in case this is called before Idiorm is instantiated + return self::$_db[$which]; + } + + /** + * Executes a raw query as a wrapper for PDOStatement::execute. + * Useful for queries that can't be accomplished through Idiorm, + * particularly those using engine-specific features. + * @example raw_execute('SELECT `name`, AVG(`order`) FROM `customer` GROUP BY `name` HAVING AVG(`order`) > 10') + * @example raw_execute('INSERT OR REPLACE INTO `widget` (`id`, `name`) SELECT `id`, `name` FROM `other_table`') + * @param string $query The raw SQL query + * @param array $parameters Optional bound parameters + * @param array + * @return bool Success */ - public static function get_db() { - self::_setup_db(); // required in case this is called before Idiorm is instantiated - return self::$_db; + public static function raw_execute( + $query, + $parameters = array(), + $which = self::DEFAULT_CONNECTION + ) { + self::_setup_db($which); + + self::_log_query($query, $parameters, $which); + $statement = self::$_db[$which]->prepare($query); + return $statement->execute($parameters); } /** @@ -264,15 +316,19 @@ public static function get_db() { * parameters to the database which takes care of the binding) but * doing it this way makes the logged queries more readable. */ - protected static function _log_query($query, $parameters) { + protected static function _log_query($query, $parameters, $which) { // If logging is not enabled, do nothing - if (!self::$_config['logging']) { + if (!self::$_config[$which]['logging']) { return false; } + if (!isset(self::$_query_log[$which])) { + self::$_query_log[$which] = array(); + } + if (count($parameters) > 0) { // Escape the parameters - $parameters = array_map(array(self::$_db, 'quote'), $parameters); + $parameters = array_map(array(self::$_db[$which], 'quote'), $parameters); // Avoid %format collision for vsprintf $query = str_replace("%", "%%", $query); @@ -291,26 +347,41 @@ protected static function _log_query($query, $parameters) { } self::$_last_query = $bound_query; - self::$_query_log[] = $bound_query; + self::$_query_log[$which][] = $bound_query; return true; } /** * Get the last query executed. Only works if the * 'logging' config option is set to true. Otherwise - * this will return null. + * this will return null. Returns last query from all connections */ - public static function get_last_query() { - return self::$_last_query; + public static function get_last_query($which = null) { + if ($which === null) { + return self::$_last_query; + } + if (!isset(self::$_query_log[$which])) { + return ''; + } + + return implode('', array_slice(self::$_query_log[$which], -1)); + // Used implode(array_slice()) instead of end() to avoid resetting interal array pointer } /** - * Get an array containing all the queries run up to - * now. Only works if the 'logging' config option is - * set to true. Otherwise returned array will be empty. + * Get an array containing all the queries run on a + * specified connection up to now. + * Only works if the 'logging' config option is + * set to true. Otherwise, returned array will be empty. + * @param string $which Key of database connection */ - public static function get_query_log() { - return self::$_query_log; + public static function get_query_log($which = self::DEFAULT_CONNECTION) { + return isset(self::$_query_log[$which]) ? self::$_query_log[$which] : array(); + } + + public static function get_connection_keys() + { + return array_keys(self::$_db); } // ------------------------ // @@ -321,9 +392,16 @@ public static function get_query_log() { * "Private" constructor; shouldn't be called directly. * Use the ORM::for_table factory method instead. */ - protected function __construct($table_name, $data=array()) { + protected function __construct( + $table_name, + $data = array(), + $which = self::DEFAULT_CONNECTION + ) { $this->_table_name = $table_name; $this->_data = $data; + + $this->_which_db = $which; + self::_setup_db_config($which); } /** @@ -360,7 +438,7 @@ public function use_id_column($id_column) { * array of data fetched from the database) */ protected function _create_instance_from_row($row) { - $instance = self::for_table($this->_table_name); + $instance = self::for_table($this->_table_name, $this->_which_db); $instance->use_id_column($this->_instance_id_column); $instance->hydrate($row); return $instance; @@ -1100,7 +1178,7 @@ protected function _quote_identifier_part($part) { if ($part === '*') { return $part; } - $quote_character = self::$_config['identifier_quote_character']; + $quote_character = self::$_config[$this->_which_db]['identifier_quote_character']; return $quote_character . $part . $quote_character; } @@ -1117,8 +1195,9 @@ protected static function _create_cache_key($query, $parameters) { * Check the query cache for the given cache key. If a value * is cached for the key, return the value. Otherwise, return false. */ - protected static function _check_query_cache($cache_key) { - if (isset(self::$_query_cache[$cache_key])) { + protected static function _check_query_cache($cache_key, $which = self::DEFAULT_CONNECTION) + { + if (isset(self::$_query_cache[$which][$cache_key])) { return self::$_query_cache[$cache_key]; } return false; @@ -1134,8 +1213,15 @@ public static function clear_cache() { /** * Add the given value to the query cache. */ - protected static function _cache_query_result($cache_key, $value) { - self::$_query_cache[$cache_key] = $value; + protected static function _cache_query_result( + $cache_key, + $value, + $which = self::DEFAULT_CONNECTION + ) { + if (!isset(self::$_query_cache[$which])) { + self::$_query_cache[$which] = array(); + } + self::$_query_cache[$which][$cache_key] = $value; } /** @@ -1144,19 +1230,19 @@ protected static function _cache_query_result($cache_key, $value) { */ protected function _run() { $query = $this->_build_select(); - $caching_enabled = self::$_config['caching']; + $caching_enabled = self::$_config[$this->_which_db]['caching']; if ($caching_enabled) { $cache_key = self::_create_cache_key($query, $this->_values); - $cached_result = self::_check_query_cache($cache_key); + $cached_result = self::_check_query_cache($cache_key, $this->_which_db); if ($cached_result !== false) { return $cached_result; } } - self::_log_query($query, $this->_values); - $statement = self::$_db->prepare($query); + self::_log_query($query, $this->_values, $this->_which_db); + $statement = self::$_db[$this->_which_db]->prepare($query); $statement->execute($this->_values); $rows = array(); @@ -1165,7 +1251,7 @@ protected function _run() { } if ($caching_enabled) { - self::_cache_query_result($cache_key, $rows); + self::_cache_query_result($cache_key, $rows, $this->_which_db); } return $rows; @@ -1201,10 +1287,10 @@ protected function _get_id_column_name() { if (!is_null($this->_instance_id_column)) { return $this->_instance_id_column; } - if (isset(self::$_config['id_column_overrides'][$this->_table_name])) { - return self::$_config['id_column_overrides'][$this->_table_name]; + if (isset(self::$_config[$this->_which_db]['id_column_overrides'][$this->_table_name])) { + return self::$_config[$this->_which_db]['id_column_overrides'][$this->_table_name]; } else { - return self::$_config['id_column']; + return self::$_config[$this->_which_db]['id_column']; } } @@ -1280,15 +1366,16 @@ public function save() { $query = $this->_build_insert(); } - self::_log_query($query, $values); - $statement = self::$_db->prepare($query); + self::_log_query($query, $values, $this->_which_db); + $statement = self::$_db[$this->_which_db]->prepare($query); $success = $statement->execute($values); // If we've just inserted a new record, set the ID of this object if ($this->_is_new) { $this->_is_new = false; if (is_null($this->id())) { - $this->_data[$this->_get_id_column_name()] = self::$_db->lastInsertId(); + $this->_data[$this->_get_id_column_name()] = + self::$_db[$this->_which_db]->lastInsertId(); } } @@ -1344,8 +1431,8 @@ public function delete() { "= ?", )); $params = array($this->id()); - self::_log_query($query, $params); - $statement = self::$_db->prepare($query); + self::_log_query($query, $params, $this->_which_db); + $statement = self::$_db[$this->_which_db]->prepare($query); return $statement->execute($params); } @@ -1360,7 +1447,8 @@ public function delete_many() { $this->_quote_identifier($this->_table_name), $this->_build_where(), )); - $statement = self::$_db->prepare($query); + self::_log_query($query, $this->_values, $this->_which_db); + $statement = self::$_db[$this->_which_db]->prepare($query); return $statement->execute($this->_values); } diff --git a/test/test_classes.php b/test/test_classes.php index a948d013..33037311 100644 --- a/test/test_classes.php +++ b/test/test_classes.php @@ -11,7 +11,11 @@ class DummyPDOStatement extends PDOStatement { /** * Return some dummy data */ - public function fetch($fetch_style=PDO::FETCH_BOTH, $cursor_orientation=PDO::FETCH_ORI_NEXT, $cursor_offset=0) { + public function fetch( + $fetch_style = PDO::FETCH_BOTH, + $cursor_orientation = PDO::FETCH_ORI_NEXT, + $cursor_offset = 0 + ) { if ($this->current_row == 5) { return false; } else { @@ -38,6 +42,44 @@ public function prepare($statement, $driver_options=array()) { } } + /** + * Another mock PDOStatement class, for testing multiple connections + */ + class DummyDifferentPDOStatement extends PDOStatement { + + private $current_row = 0; + /** + * Return some dummy data + */ + public function fetch( + $fetch_style = PDO::FETCH_BOTH, + $cursor_orientation = PDO::FETCH_ORI_NEXT, + $cursor_offset = 0 + ) { + if ($this->current_row == 5) { + return false; + } else { + $this->current_row++; + return array('name' => 'Steve', 'age' => 80, 'id' => "{$this->current_row}"); + } + } + } + + /** + * A different mock database class, for testing multiple connections + * Mock database class implementing a subset of the PDO API. + */ + class DummyDifferentPDO extends PDO { + + /** + * Return a dummy PDO statement + */ + public function prepare($statement, $driver_options = array()) { + $this->last_query = new DummyDifferentPDOStatement($statement); + return $this->last_query; + } + } + /** * * Class to provide simple testing functionality @@ -112,10 +154,17 @@ public static function report() { */ public static function check_equal($test_name, $query) { $last_query = ORM::get_last_query(); - if ($query === $last_query) { + self::check_equal_string($test_name, $query, $last_query); + } + + /** + * Check the provided strings are equal + */ + public static function check_equal_string($test_name, $s1, $s2) { + if ($s1 === $s2) { self::report_pass($test_name); } else { - self::report_failure($test_name, $query, $last_query); + self::report_failure($test_name, $s1, $s2); } } } diff --git a/test/test_queries.php b/test/test_queries.php index 3c9171b7..f39a2880 100644 --- a/test/test_queries.php +++ b/test/test_queries.php @@ -202,7 +202,53 @@ public function authors() { $authors2 = $book2->authors()->find_many(); $expected = "SELECT `author_two`.* FROM `author_two` JOIN `wrote_the_book` ON `author_two`.`id` = `wrote_the_book`.`custom_author_id` WHERE `wrote_the_book`.`custom_book_id` = '1'"; Tester::check_equal("has_many_through relation with custom intermediate model and key names", $expected); - + + // Tests of muliple connections + define('ALTERNATE', 'alternate'); + ORM::set_db(new DummyDifferentPDO('sqlite::memory:'), ALTERNATE); + ORM::configure('logging', true, ALTERNATE); + + $person1 = Model::factory('author')->find_one(1); + $person2 = Model::factory('author', ALTERNATE)->find_one(1); + //$expected = "SELECT * FROM `author` WHERE `id` = '1' LIMIT 1"; + + Tester::check_equal_string("Multiple connection (1)", $person1->name, 'Fred'); + Tester::check_equal_string("Multiple connection (2)", $person2->name, 'Steve'); + + class AuthorThree extends Model { + public static $_connection_key = ALTERNATE; + } + + $person3 = Model::factory('AuthorThree')->find_one(1); + Tester::check_equal_string("Multiple connection (static connection key)", $person3->name, 'Steve'); + + // The following test requires PHP 5.3+ (to change accessibilty from protected + // through reflection), but the feature itself does not. + // Once the ORM::get_last_statement() branch is added (see #84 and #87), the test + // could be reworked to check for class name of the PDOStatement mock instances instead. + if (phpversion() >= '5.3.0') { + + $person4 = Model::factory('Author')->create(); + + $reflectedClass = new ReflectionClass($person4->orm); + $property = $reflectedClass->getProperty('_which_db'); + $property->setAccessible(true); + + // TODO: Get $person4->orm->_which = ORM::DEFAULT_CONNECTION + Tester::check_equal_string("Multiple connection switch db after instantiation (before)", + $property->getValue($person4->orm), + ORM::DEFAULT_CONNECTION + ); + + $person4->orm = Model::factory('Author', ALTERNATE); + + Tester::check_equal_string("Multiple connection switch db after instantiation (after)", + $property->getValue($person4->orm), + ALTERNATE + ); + } + + if (phpversion() >= '5.3.0') { include __DIR__.'/test_php53.php'; }